quasarr 2.4.8__py3-none-any.whl → 2.4.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of quasarr might be problematic. Click here for more details.

Files changed (76) hide show
  1. quasarr/__init__.py +134 -70
  2. quasarr/api/__init__.py +40 -31
  3. quasarr/api/arr/__init__.py +116 -108
  4. quasarr/api/captcha/__init__.py +262 -137
  5. quasarr/api/config/__init__.py +76 -46
  6. quasarr/api/packages/__init__.py +138 -102
  7. quasarr/api/sponsors_helper/__init__.py +29 -16
  8. quasarr/api/statistics/__init__.py +19 -19
  9. quasarr/downloads/__init__.py +165 -72
  10. quasarr/downloads/linkcrypters/al.py +35 -18
  11. quasarr/downloads/linkcrypters/filecrypt.py +107 -52
  12. quasarr/downloads/linkcrypters/hide.py +5 -6
  13. quasarr/downloads/packages/__init__.py +342 -177
  14. quasarr/downloads/sources/al.py +191 -100
  15. quasarr/downloads/sources/by.py +31 -13
  16. quasarr/downloads/sources/dd.py +27 -14
  17. quasarr/downloads/sources/dj.py +1 -3
  18. quasarr/downloads/sources/dl.py +126 -71
  19. quasarr/downloads/sources/dt.py +11 -5
  20. quasarr/downloads/sources/dw.py +28 -14
  21. quasarr/downloads/sources/he.py +32 -24
  22. quasarr/downloads/sources/mb.py +19 -9
  23. quasarr/downloads/sources/nk.py +14 -10
  24. quasarr/downloads/sources/nx.py +8 -18
  25. quasarr/downloads/sources/sf.py +45 -20
  26. quasarr/downloads/sources/sj.py +1 -3
  27. quasarr/downloads/sources/sl.py +9 -5
  28. quasarr/downloads/sources/wd.py +32 -12
  29. quasarr/downloads/sources/wx.py +35 -21
  30. quasarr/providers/auth.py +42 -37
  31. quasarr/providers/cloudflare.py +28 -30
  32. quasarr/providers/hostname_issues.py +2 -1
  33. quasarr/providers/html_images.py +2 -2
  34. quasarr/providers/html_templates.py +22 -14
  35. quasarr/providers/imdb_metadata.py +149 -80
  36. quasarr/providers/jd_cache.py +131 -39
  37. quasarr/providers/log.py +1 -1
  38. quasarr/providers/myjd_api.py +260 -196
  39. quasarr/providers/notifications.py +53 -41
  40. quasarr/providers/obfuscated.py +9 -4
  41. quasarr/providers/sessions/al.py +71 -55
  42. quasarr/providers/sessions/dd.py +21 -14
  43. quasarr/providers/sessions/dl.py +30 -19
  44. quasarr/providers/sessions/nx.py +23 -14
  45. quasarr/providers/shared_state.py +292 -141
  46. quasarr/providers/statistics.py +75 -43
  47. quasarr/providers/utils.py +33 -27
  48. quasarr/providers/version.py +45 -14
  49. quasarr/providers/web_server.py +10 -5
  50. quasarr/search/__init__.py +30 -18
  51. quasarr/search/sources/al.py +124 -73
  52. quasarr/search/sources/by.py +110 -59
  53. quasarr/search/sources/dd.py +57 -35
  54. quasarr/search/sources/dj.py +69 -48
  55. quasarr/search/sources/dl.py +159 -100
  56. quasarr/search/sources/dt.py +110 -74
  57. quasarr/search/sources/dw.py +121 -61
  58. quasarr/search/sources/fx.py +108 -62
  59. quasarr/search/sources/he.py +78 -49
  60. quasarr/search/sources/mb.py +96 -48
  61. quasarr/search/sources/nk.py +80 -50
  62. quasarr/search/sources/nx.py +91 -62
  63. quasarr/search/sources/sf.py +171 -106
  64. quasarr/search/sources/sj.py +69 -48
  65. quasarr/search/sources/sl.py +115 -71
  66. quasarr/search/sources/wd.py +67 -44
  67. quasarr/search/sources/wx.py +188 -123
  68. quasarr/storage/config.py +65 -52
  69. quasarr/storage/setup.py +238 -140
  70. quasarr/storage/sqlite_database.py +10 -4
  71. {quasarr-2.4.8.dist-info → quasarr-2.4.10.dist-info}/METADATA +4 -3
  72. quasarr-2.4.10.dist-info/RECORD +81 -0
  73. quasarr-2.4.8.dist-info/RECORD +0 -81
  74. {quasarr-2.4.8.dist-info → quasarr-2.4.10.dist-info}/WHEEL +0 -0
  75. {quasarr-2.4.8.dist-info → quasarr-2.4.10.dist-info}/entry_points.txt +0 -0
  76. {quasarr-2.4.8.dist-info → quasarr-2.4.10.dist-info}/licenses/LICENSE +0 -0
@@ -10,7 +10,7 @@ from bs4 import BeautifulSoup
10
10
 
11
11
  from quasarr.providers.cloudflare import flaresolverr_get, is_cloudflare_challenge
12
12
  from quasarr.providers.hostname_issues import mark_hostname_issue
13
- from quasarr.providers.log import info, debug
13
+ from quasarr.providers.log import debug, info
14
14
  from quasarr.providers.utils import is_flaresolverr_available
15
15
 
16
16
  hostname = "wd"
@@ -33,10 +33,14 @@ def resolve_wd_redirect(url, user_agent):
33
33
  debug(f"Redirected from {resp.url} to {r.url}")
34
34
  return r.url
35
35
  else:
36
- info(f"WD blocked attempt to resolve {url}. Your IP may be banned. Try again later.")
36
+ info(
37
+ f"WD blocked attempt to resolve {url}. Your IP may be banned. Try again later."
38
+ )
37
39
  except Exception as e:
38
40
  info(f"Error fetching redirected URL for {url}: {e}")
39
- mark_hostname_issue(hostname, "download", str(e) if "e" in dir() else "Download error")
41
+ mark_hostname_issue(
42
+ hostname, "download", str(e) if "e" in dir() else "Download error"
43
+ )
40
44
  return None
41
45
 
42
46
 
@@ -54,16 +58,24 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
54
58
  r = requests.get(url)
55
59
  if r.status_code >= 400 or is_cloudflare_challenge(r.text):
56
60
  if is_flaresolverr_available(shared_state):
57
- info("WD is protected by Cloudflare. Using FlareSolverr to bypass protection.")
61
+ info(
62
+ "WD is protected by Cloudflare. Using FlareSolverr to bypass protection."
63
+ )
58
64
  r = flaresolverr_get(shared_state, url)
59
65
  else:
60
- info("WD is protected by Cloudflare but FlareSolverr is not configured. "
61
- "Please configure FlareSolverr in the web UI to access this site.")
62
- mark_hostname_issue(hostname, "download", "FlareSolverr required but missing.")
66
+ info(
67
+ "WD is protected by Cloudflare but FlareSolverr is not configured. "
68
+ "Please configure FlareSolverr in the web UI to access this site."
69
+ )
70
+ mark_hostname_issue(
71
+ hostname, "download", "FlareSolverr required but missing."
72
+ )
63
73
  return {"links": [], "imdb_id": None}
64
74
 
65
75
  if r.status_code >= 400:
66
- mark_hostname_issue(hostname, "download", f"Download error: {str(r.status_code)}")
76
+ mark_hostname_issue(
77
+ hostname, "download", f"Download error: {str(r.status_code)}"
78
+ )
67
79
 
68
80
  soup = BeautifulSoup(r.text, "html.parser")
69
81
 
@@ -83,7 +95,9 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
83
95
  string=re.compile(r"^\s*Downloads\s*$", re.IGNORECASE),
84
96
  )
85
97
  if not header:
86
- info(f"WD Downloads section not found. Grabbing download links for {title} not possible!")
98
+ info(
99
+ f"WD Downloads section not found. Grabbing download links for {title} not possible!"
100
+ )
87
101
  return {"links": [], "imdb_id": None}
88
102
 
89
103
  card = header.find_parent("div", class_="card")
@@ -96,7 +110,9 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
96
110
  info(f"WD access failed: {e}")
97
111
  return {"links": [], "imdb_id": None}
98
112
  except Exception:
99
- info(f"WD site has been updated. Grabbing download links for {title} not possible!")
113
+ info(
114
+ f"WD site has been updated. Grabbing download links for {title} not possible!"
115
+ )
100
116
  return {"links": [], "imdb_id": None}
101
117
 
102
118
  results = []
@@ -122,12 +138,16 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
122
138
  break
123
139
 
124
140
  if mirror and mirror.lower() not in hoster.lower():
125
- debug(f'Skipping link from "{hoster}" (not the desired mirror "{mirror}")!')
141
+ debug(
142
+ f'Skipping link from "{hoster}" (not the desired mirror "{mirror}")!'
143
+ )
126
144
  continue
127
145
 
128
146
  results.append([resolved, hoster])
129
147
  except Exception:
130
- info(f"WD site has been updated. Parsing download links for {title} not possible!")
148
+ info(
149
+ f"WD site has been updated. Parsing download links for {title} not possible!"
150
+ )
131
151
 
132
152
  return {
133
153
  "links": results,
@@ -7,7 +7,7 @@ import re
7
7
  import requests
8
8
 
9
9
  from quasarr.providers.hostname_issues import mark_hostname_issue
10
- from quasarr.providers.log import info, debug
10
+ from quasarr.providers.log import debug, info
11
11
  from quasarr.providers.utils import check_links_online_status
12
12
 
13
13
  hostname = "wx"
@@ -25,8 +25,8 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
25
25
  host = shared_state.values["config"]("Hostnames").get(hostname)
26
26
 
27
27
  headers = {
28
- 'User-Agent': shared_state.values["user_agent"],
29
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
28
+ "User-Agent": shared_state.values["user_agent"],
29
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
30
30
  }
31
31
 
32
32
  try:
@@ -37,17 +37,17 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
37
37
  r.raise_for_status()
38
38
 
39
39
  # Extract slug from URL
40
- slug_match = re.search(r'/detail/([^/?]+)', url)
40
+ slug_match = re.search(r"/detail/([^/?]+)", url)
41
41
  if not slug_match:
42
42
  info(f"{hostname.upper()}: Could not extract slug from URL: {url}")
43
43
  return {"links": []}
44
44
 
45
- api_url = f'https://api.{host}/start/d/{slug_match.group(1)}'
45
+ api_url = f"https://api.{host}/start/d/{slug_match.group(1)}"
46
46
 
47
47
  # Update headers for API request
48
48
  api_headers = {
49
- 'User-Agent': shared_state.values["user_agent"],
50
- 'Accept': 'application/json'
49
+ "User-Agent": shared_state.values["user_agent"],
50
+ "Accept": "application/json",
51
51
  }
52
52
 
53
53
  debug(f"{hostname.upper()}: Fetching API data from: {api_url}")
@@ -57,28 +57,30 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
57
57
  data = api_r.json()
58
58
 
59
59
  # Navigate to releases in the API response
60
- if 'item' not in data or 'releases' not in data['item']:
60
+ if "item" not in data or "releases" not in data["item"]:
61
61
  info(f"{hostname.upper()}: No releases found in API response")
62
62
  return {"links": []}
63
63
 
64
- releases = data['item']['releases']
64
+ releases = data["item"]["releases"]
65
65
 
66
66
  # Find ALL releases matching the title (these are different mirrors: M1, M2, M3...)
67
- matching_releases = [r for r in releases if r.get('fulltitle') == title]
67
+ matching_releases = [r for r in releases if r.get("fulltitle") == title]
68
68
 
69
69
  if not matching_releases:
70
70
  info(f"{hostname.upper()}: No release found matching title: {title}")
71
71
  return {"links": []}
72
72
 
73
- debug(f"{hostname.upper()}: Found {len(matching_releases)} mirror(s) for: {title}")
73
+ debug(
74
+ f"{hostname.upper()}: Found {len(matching_releases)} mirror(s) for: {title}"
75
+ )
74
76
 
75
77
  # Evaluate each mirror and find the best one
76
78
  # Track: (online_count, is_hide, online_links)
77
79
  best_mirror = None # (online_count, is_hide, online_links)
78
80
 
79
81
  for idx, release in enumerate(matching_releases):
80
- crypted_links = release.get('crypted_links', {})
81
- check_urls = release.get('options', {}).get('check', {})
82
+ crypted_links = release.get("crypted_links", {})
83
+ check_urls = release.get("options", {}).get("check", {})
82
84
 
83
85
  if not crypted_links:
84
86
  continue
@@ -89,9 +91,9 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
89
91
 
90
92
  for hoster, container_url in crypted_links.items():
91
93
  state_url = check_urls.get(hoster)
92
- if re.search(r'hide\.', container_url, re.IGNORECASE):
94
+ if re.search(r"hide\.", container_url, re.IGNORECASE):
93
95
  hide_links.append([container_url, hoster, state_url])
94
- elif re.search(r'filecrypt\.', container_url, re.IGNORECASE):
96
+ elif re.search(r"filecrypt\.", container_url, re.IGNORECASE):
95
97
  other_links.append([container_url, hoster, state_url])
96
98
  # Skip other crypters we don't support
97
99
 
@@ -103,12 +105,15 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
103
105
  hide_total = len(hide_links)
104
106
  hide_online = len(online_hide)
105
107
 
106
- debug(f"{hostname.upper()}: M{idx + 1} hide.cx: {hide_online}/{hide_total} online")
108
+ debug(
109
+ f"{hostname.upper()}: M{idx + 1} hide.cx: {hide_online}/{hide_total} online"
110
+ )
107
111
 
108
112
  # If all hide.cx links are online, use this mirror immediately
109
113
  if hide_online == hide_total and hide_online > 0:
110
114
  debug(
111
- f"{hostname.upper()}: M{idx + 1} is complete (all {hide_online} hide.cx links online), using this mirror")
115
+ f"{hostname.upper()}: M{idx + 1} is complete (all {hide_online} hide.cx links online), using this mirror"
116
+ )
112
117
  return {"links": online_hide}
113
118
 
114
119
  # Check other crypters (filecrypt, etc.) - no early return, always check all mirrors for hide.cx first
@@ -119,7 +124,9 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
119
124
  other_total = len(other_links)
120
125
  other_online = len(online_other)
121
126
 
122
- debug(f"{hostname.upper()}: M{idx + 1} other crypters: {other_online}/{other_total} online")
127
+ debug(
128
+ f"{hostname.upper()}: M{idx + 1} other crypters: {other_online}/{other_total} online"
129
+ )
123
130
 
124
131
  # Determine best option for this mirror (prefer hide.cx on ties)
125
132
  mirror_links = None
@@ -144,7 +151,11 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
144
151
  best_mirror = (mirror_count, mirror_is_hide, mirror_links)
145
152
  elif mirror_count > best_mirror[0]:
146
153
  best_mirror = (mirror_count, mirror_is_hide, mirror_links)
147
- elif mirror_count == best_mirror[0] and mirror_is_hide and not best_mirror[1]:
154
+ elif (
155
+ mirror_count == best_mirror[0]
156
+ and mirror_is_hide
157
+ and not best_mirror[1]
158
+ ):
148
159
  # Same count but this is hide.cx and current best is not
149
160
  best_mirror = (mirror_count, mirror_is_hide, mirror_links)
150
161
 
@@ -152,7 +163,8 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
152
163
  if best_mirror and best_mirror[2]:
153
164
  crypter_type = "hide.cx" if best_mirror[1] else "other crypter"
154
165
  debug(
155
- f"{hostname.upper()}: No complete mirror, using best partial with {best_mirror[0]} online {crypter_type} link(s)")
166
+ f"{hostname.upper()}: No complete mirror, using best partial with {best_mirror[0]} online {crypter_type} link(s)"
167
+ )
156
168
  return {"links": best_mirror[2]}
157
169
 
158
170
  info(f"{hostname.upper()}: No online links found for: {title}")
@@ -160,5 +172,7 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
160
172
 
161
173
  except Exception as e:
162
174
  info(f"{hostname.upper()}: Error extracting download links from {url}: {e}")
163
- mark_hostname_issue(hostname, "download", str(e) if "e" in dir() else "Download error")
175
+ mark_hostname_issue(
176
+ hostname, "download", str(e) if "e" in dir() else "Download error"
177
+ )
164
178
  return {"links": []}
quasarr/providers/auth.py CHANGED
@@ -10,23 +10,23 @@ import os
10
10
  import time
11
11
  from functools import wraps
12
12
 
13
- from bottle import request, response, redirect, abort
13
+ from bottle import abort, redirect, request, response
14
14
 
15
15
  import quasarr.providers.html_images as images
16
16
  from quasarr.providers.version import get_version
17
17
  from quasarr.storage.config import Config
18
18
 
19
19
  # Auth configuration from environment
20
- AUTH_USER = os.environ.get('USER', '')
21
- AUTH_PASS = os.environ.get('PASS', '')
22
- AUTH_TYPE = os.environ.get('AUTH', '').lower()
20
+ AUTH_USER = os.environ.get("USER", "")
21
+ AUTH_PASS = os.environ.get("PASS", "")
22
+ AUTH_TYPE = os.environ.get("AUTH", "").lower()
23
23
 
24
24
  # Cookie settings
25
- COOKIE_NAME = 'quasarr_session'
25
+ COOKIE_NAME = "quasarr_session"
26
26
  COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days
27
27
 
28
28
  # Stable secret derived from PASS (restart-safe)
29
- _SECRET_KEY = hashlib.sha256(AUTH_PASS.encode('utf-8')).digest()
29
+ _SECRET_KEY = hashlib.sha256(AUTH_PASS.encode("utf-8")).digest()
30
30
 
31
31
 
32
32
  def is_auth_enabled():
@@ -36,15 +36,15 @@ def is_auth_enabled():
36
36
 
37
37
  def is_form_auth():
38
38
  """Check if form-based auth is enabled."""
39
- return AUTH_TYPE == 'form'
39
+ return AUTH_TYPE == "form"
40
40
 
41
41
 
42
42
  def _b64encode(data: bytes) -> str:
43
- return base64.urlsafe_b64encode(data).decode('ascii').rstrip('=')
43
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
44
44
 
45
45
 
46
46
  def _b64decode(data: str) -> bytes:
47
- padding = '=' * (-len(data) % 4)
47
+ padding = "=" * (-len(data) % 4)
48
48
  return base64.urlsafe_b64decode(data + padding)
49
49
 
50
50
 
@@ -57,7 +57,7 @@ def _mask_user(user: str) -> str:
57
57
  One-way masked user identifier.
58
58
  Stable across restarts, not reversible.
59
59
  """
60
- return hashlib.sha256(f"user:{user}".encode('utf-8')).hexdigest()
60
+ return hashlib.sha256(f"user:{user}".encode("utf-8")).hexdigest()
61
61
 
62
62
 
63
63
  def _create_session_cookie(user: str) -> str:
@@ -69,14 +69,14 @@ def _create_session_cookie(user: str) -> str:
69
69
  "u": _mask_user(user),
70
70
  "exp": int(time.time()) + COOKIE_MAX_AGE,
71
71
  }
72
- raw = json.dumps(payload, separators=(',', ':')).encode('utf-8')
72
+ raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
73
73
  sig = _sign(raw)
74
74
  return f"{_b64encode(raw)}.{_b64encode(sig)}"
75
75
 
76
76
 
77
77
  def _invalidate_cookie():
78
78
  try:
79
- response.delete_cookie(COOKIE_NAME, path='/')
79
+ response.delete_cookie(COOKIE_NAME, path="/")
80
80
  except Exception:
81
81
  pass
82
82
 
@@ -87,17 +87,17 @@ def _verify_session_cookie(value: str) -> bool:
87
87
  On ANY failure → force logout (cookie deletion).
88
88
  """
89
89
  try:
90
- if not value or '.' not in value:
90
+ if not value or "." not in value:
91
91
  raise ValueError
92
92
 
93
- raw_b64, sig_b64 = value.split('.', 1)
93
+ raw_b64, sig_b64 = value.split(".", 1)
94
94
  raw = _b64decode(raw_b64)
95
95
  sig = _b64decode(sig_b64)
96
96
 
97
97
  if not hmac.compare_digest(sig, _sign(raw)):
98
98
  raise ValueError
99
99
 
100
- payload = json.loads(raw.decode('utf-8'))
100
+ payload = json.loads(raw.decode("utf-8"))
101
101
 
102
102
  if payload.get("u") != _mask_user(AUTH_USER):
103
103
  raise ValueError
@@ -113,12 +113,12 @@ def _verify_session_cookie(value: str) -> bool:
113
113
 
114
114
  def check_basic_auth():
115
115
  """Check HTTP Basic Auth header. Returns True if valid."""
116
- auth = request.headers.get('Authorization', '')
117
- if not auth.startswith('Basic '):
116
+ auth = request.headers.get("Authorization", "")
117
+ if not auth.startswith("Basic "):
118
118
  return False
119
119
  try:
120
- decoded = base64.b64decode(auth[6:]).decode('utf-8')
121
- user, passwd = decoded.split(':', 1)
120
+ decoded = base64.b64decode(auth[6:]).decode("utf-8")
121
+ user, passwd = decoded.split(":", 1)
122
122
  return user == AUTH_USER and passwd == AUTH_PASS
123
123
  except:
124
124
  return False
@@ -133,14 +133,18 @@ def check_form_auth():
133
133
  def require_basic_auth():
134
134
  """Send 401 response for Basic Auth."""
135
135
  response.status = 401
136
- response.set_header('WWW-Authenticate', 'Basic realm="Quasarr"')
136
+ response.set_header("WWW-Authenticate", 'Basic realm="Quasarr"')
137
137
  return "Authentication required"
138
138
 
139
139
 
140
140
  def _render_login_page(error=None):
141
141
  """Render login form page using Quasarr styling."""
142
- error_html = f'<p style="color: #dc3545; margin-bottom: 1rem;"><b>{error}</b></p>' if error else ''
143
- next_url = request.query.get('next', '/')
142
+ error_html = (
143
+ f'<p style="color: #dc3545; margin-bottom: 1rem;"><b>{error}</b></p>'
144
+ if error
145
+ else ""
146
+ )
147
+ next_url = request.query.get("next", "/")
144
148
 
145
149
  # Inline the centered HTML to avoid circular import
146
150
  return f'''<html>
@@ -220,21 +224,21 @@ def _render_login_page(error=None):
220
224
 
221
225
  def _handle_login_post():
222
226
  """Handle login form submission."""
223
- username = request.forms.get('username', '')
224
- password = request.forms.get('password', '')
225
- next_url = request.forms.get('next', '/')
227
+ username = request.forms.get("username", "")
228
+ password = request.forms.get("password", "")
229
+ next_url = request.forms.get("next", "/")
226
230
 
227
231
  if username == AUTH_USER and password == AUTH_PASS:
228
232
  cookie = _create_session_cookie(username)
229
- secure_flag = request.url.startswith('https://')
233
+ secure_flag = request.url.startswith("https://")
230
234
  response.set_cookie(
231
235
  COOKIE_NAME,
232
236
  cookie,
233
237
  max_age=COOKIE_MAX_AGE,
234
- path='/',
238
+ path="/",
235
239
  httponly=True,
236
240
  secure=secure_flag,
237
- samesite='Lax'
241
+ samesite="Lax",
238
242
  )
239
243
  redirect(next_url)
240
244
  else:
@@ -244,7 +248,7 @@ def _handle_login_post():
244
248
 
245
249
  def _handle_logout():
246
250
  _invalidate_cookie()
247
- redirect('/login')
251
+ redirect("/login")
248
252
 
249
253
 
250
254
  def show_logout_link():
@@ -258,17 +262,18 @@ def add_auth_routes(app):
258
262
  return
259
263
 
260
264
  if is_form_auth():
261
- @app.get('/login')
265
+
266
+ @app.get("/login")
262
267
  def login_get():
263
268
  if check_form_auth():
264
- redirect('/')
269
+ redirect("/")
265
270
  return _render_login_page()
266
271
 
267
- @app.post('/login')
272
+ @app.post("/login")
268
273
  def login_post():
269
274
  return _handle_login_post()
270
275
 
271
- @app.get('/logout')
276
+ @app.get("/logout")
272
277
  def logout():
273
278
  return _handle_logout()
274
279
 
@@ -283,7 +288,7 @@ def add_auth_hook(app, whitelist_prefixes=[], whitelist_suffixes=[]):
283
288
  if whitelist_prefixes is None:
284
289
  whitelist_prefixes = []
285
290
 
286
- @app.hook('before_request')
291
+ @app.hook("before_request")
287
292
  def auth_hook():
288
293
  if not is_auth_enabled():
289
294
  return
@@ -291,7 +296,7 @@ def add_auth_hook(app, whitelist_prefixes=[], whitelist_suffixes=[]):
291
296
  path = request.path
292
297
 
293
298
  # Always allow login/logout
294
- if path in ['/login', '/logout']:
299
+ if path in ["/login", "/logout"]:
295
300
  return
296
301
 
297
302
  # Check whitelist prefixes
@@ -308,7 +313,7 @@ def add_auth_hook(app, whitelist_prefixes=[], whitelist_suffixes=[]):
308
313
  if is_form_auth():
309
314
  if not check_form_auth():
310
315
  _invalidate_cookie()
311
- redirect(f'/login?next={path}')
316
+ redirect(f"/login?next={path}")
312
317
  else:
313
318
  if not check_basic_auth():
314
319
  return require_basic_auth()
@@ -317,7 +322,7 @@ def add_auth_hook(app, whitelist_prefixes=[], whitelist_suffixes=[]):
317
322
  def require_api_key(func):
318
323
  @wraps(func)
319
324
  def decorated(*args, **kwargs):
320
- api_key = Config('API').get('key')
325
+ api_key = Config("API").get("key")
321
326
  if not request.query.apikey:
322
327
  return abort(401, "Missing API Key")
323
328
  if request.query.apikey != api_key:
@@ -36,17 +36,15 @@ def is_cloudflare_challenge(html: str) -> bool:
36
36
  return False
37
37
 
38
38
 
39
- def update_session_via_flaresolverr(info,
40
- shared_state,
41
- sess,
42
- target_url: str,
43
- timeout: int = 60):
39
+ def update_session_via_flaresolverr(
40
+ info, shared_state, sess, target_url: str, timeout: int = 60
41
+ ):
44
42
  # Check if FlareSolverr is available
45
43
  if not is_flaresolverr_available(shared_state):
46
44
  info("FlareSolverr is not configured. Cannot bypass Cloudflare protection.")
47
45
  return False
48
46
 
49
- flaresolverr_url = shared_state.values["config"]('FlareSolverr').get('url')
47
+ flaresolverr_url = shared_state.values["config"]("FlareSolverr").get("url")
50
48
  if not flaresolverr_url:
51
49
  info("Cannot proceed without FlareSolverr. Please configure it in the web UI!")
52
50
  return False
@@ -61,10 +59,7 @@ def update_session_via_flaresolverr(info,
61
59
  fs_headers = {"Content-Type": "application/json"}
62
60
  try:
63
61
  resp = requests.post(
64
- flaresolverr_url,
65
- headers=fs_headers,
66
- json=fs_payload,
67
- timeout=timeout + 10
62
+ flaresolverr_url, headers=fs_headers, json=fs_payload, timeout=timeout + 10
68
63
  )
69
64
  resp.raise_for_status()
70
65
  except requests.exceptions.RequestException as e:
@@ -75,14 +70,16 @@ def update_session_via_flaresolverr(info,
75
70
  "json": None,
76
71
  "text": "",
77
72
  "cookies": [],
78
- "error": f"FlareSolverr request failed: {e}"
73
+ "error": f"FlareSolverr request failed: {e}",
79
74
  }
80
75
  except Exception as e:
81
76
  raise RuntimeError(f"Could not reach FlareSolverr: {e}")
82
77
 
83
78
  fs_json = resp.json()
84
79
  if fs_json.get("status") != "ok" or "solution" not in fs_json:
85
- raise RuntimeError(f"FlareSolverr did not return a valid solution: {fs_json.get('message', '<no message>')}")
80
+ raise RuntimeError(
81
+ f"FlareSolverr did not return a valid solution: {fs_json.get('message', '<no message>')}"
82
+ )
86
83
 
87
84
  solution = fs_json["solution"]
88
85
 
@@ -93,7 +90,7 @@ def update_session_via_flaresolverr(info,
93
90
  ck.get("name"),
94
91
  ck.get("value"),
95
92
  domain=ck.get("domain"),
96
- path=ck.get("path", "/")
93
+ path=ck.get("path", "/"),
97
94
  )
98
95
  return {"session": sess, "user_agent": solution.get("userAgent", None)}
99
96
 
@@ -113,12 +110,18 @@ def ensure_session_cf_bypassed(info, shared_state, session, url, headers):
113
110
  if resp.status_code == 403 or is_cloudflare_challenge(resp.text):
114
111
  # Check if FlareSolverr is available before attempting bypass
115
112
  if not is_flaresolverr_available(shared_state):
116
- info("Cloudflare protection detected but FlareSolverr is not configured. "
117
- "Please configure FlareSolverr in the web UI to access this site.")
113
+ info(
114
+ "Cloudflare protection detected but FlareSolverr is not configured. "
115
+ "Please configure FlareSolverr in the web UI to access this site."
116
+ )
118
117
  return None, None, None
119
118
 
120
- info("Encountered Cloudflare protection. Solving challenge with FlareSolverr...")
121
- flaresolverr_result = update_session_via_flaresolverr(info, shared_state, session, url)
119
+ info(
120
+ "Encountered Cloudflare protection. Solving challenge with FlareSolverr..."
121
+ )
122
+ flaresolverr_result = update_session_via_flaresolverr(
123
+ info, shared_state, session, url
124
+ )
122
125
  if not flaresolverr_result:
123
126
  info("FlareSolverr did not return a result.")
124
127
  return None, None, None
@@ -129,7 +132,7 @@ def ensure_session_cf_bypassed(info, shared_state, session, url, headers):
129
132
  if user_agent and user_agent != shared_state.values.get("user_agent"):
130
133
  info("Updating User-Agent from FlareSolverr solution: " + user_agent)
131
134
  shared_state.update("user_agent", user_agent)
132
- headers = {'User-Agent': shared_state.values["user_agent"]}
135
+ headers = {"User-Agent": shared_state.values["user_agent"]}
133
136
 
134
137
  # re-fetch using the new session/headers
135
138
  try:
@@ -174,24 +177,22 @@ def flaresolverr_get(shared_state, url, timeout=60):
174
177
  """
175
178
  # Check if FlareSolverr is available
176
179
  if not is_flaresolverr_available(shared_state):
177
- raise RuntimeError("FlareSolverr is not configured. Please configure it in the web UI.")
180
+ raise RuntimeError(
181
+ "FlareSolverr is not configured. Please configure it in the web UI."
182
+ )
178
183
 
179
- flaresolverr_url = shared_state.values["config"]('FlareSolverr').get('url')
184
+ flaresolverr_url = shared_state.values["config"]("FlareSolverr").get("url")
180
185
  if not flaresolverr_url:
181
186
  raise RuntimeError("FlareSolverr URL not configured in shared_state.")
182
187
 
183
- payload = {
184
- "cmd": "request.get",
185
- "url": url,
186
- "maxTimeout": timeout * 1000
187
- }
188
+ payload = {"cmd": "request.get", "url": url, "maxTimeout": timeout * 1000}
188
189
 
189
190
  try:
190
191
  resp = requests.post(
191
192
  flaresolverr_url,
192
193
  json=payload,
193
194
  headers={"Content-Type": "application/json"},
194
- timeout=timeout + 10
195
+ timeout=timeout + 10,
195
196
  )
196
197
  resp.raise_for_status()
197
198
  except Exception as e:
@@ -216,8 +217,5 @@ def flaresolverr_get(shared_state, url, timeout=60):
216
217
  shared_state.update("user_agent", user_agent)
217
218
 
218
219
  return FlareSolverrResponse(
219
- url=url,
220
- status_code=status_code,
221
- headers=fs_headers,
222
- text=html
220
+ url=url, status_code=status_code, headers=fs_headers, text=html
223
221
  )
@@ -13,6 +13,7 @@ from datetime import datetime
13
13
  def _get_db(table_name):
14
14
  """Lazy import to avoid circular dependency."""
15
15
  from quasarr.storage.sqlite_database import DataBase
16
+
16
17
  return DataBase(table_name)
17
18
 
18
19
 
@@ -23,7 +24,7 @@ def mark_hostname_issue(shorthand, operation, error_message):
23
24
  issue_data = {
24
25
  "operation": operation,
25
26
  "error": str(error_message)[:500],
26
- "timestamp": datetime.now().isoformat()
27
+ "timestamp": datetime.now().isoformat(),
27
28
  }
28
29
 
29
30
  db.update_store(shorthand, json.dumps(issue_data))