quasarr 2.6.1__py3-none-any.whl → 2.7.1__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 (54) hide show
  1. quasarr/__init__.py +71 -61
  2. quasarr/api/__init__.py +1 -2
  3. quasarr/api/arr/__init__.py +66 -57
  4. quasarr/api/captcha/__init__.py +203 -154
  5. quasarr/downloads/__init__.py +12 -8
  6. quasarr/downloads/linkcrypters/al.py +4 -4
  7. quasarr/downloads/linkcrypters/filecrypt.py +1 -2
  8. quasarr/downloads/packages/__init__.py +62 -88
  9. quasarr/downloads/sources/al.py +3 -3
  10. quasarr/downloads/sources/by.py +3 -3
  11. quasarr/downloads/sources/he.py +8 -9
  12. quasarr/downloads/sources/nk.py +3 -3
  13. quasarr/downloads/sources/sl.py +6 -1
  14. quasarr/downloads/sources/wd.py +93 -37
  15. quasarr/downloads/sources/wx.py +11 -17
  16. quasarr/providers/auth.py +9 -13
  17. quasarr/providers/cloudflare.py +5 -4
  18. quasarr/providers/imdb_metadata.py +1 -3
  19. quasarr/providers/jd_cache.py +64 -90
  20. quasarr/providers/log.py +226 -8
  21. quasarr/providers/myjd_api.py +116 -94
  22. quasarr/providers/sessions/al.py +20 -22
  23. quasarr/providers/sessions/dd.py +1 -1
  24. quasarr/providers/sessions/dl.py +8 -10
  25. quasarr/providers/sessions/nx.py +1 -1
  26. quasarr/providers/shared_state.py +26 -15
  27. quasarr/providers/utils.py +15 -6
  28. quasarr/providers/version.py +1 -1
  29. quasarr/search/__init__.py +113 -82
  30. quasarr/search/sources/al.py +19 -23
  31. quasarr/search/sources/by.py +6 -6
  32. quasarr/search/sources/dd.py +8 -10
  33. quasarr/search/sources/dj.py +15 -18
  34. quasarr/search/sources/dl.py +25 -37
  35. quasarr/search/sources/dt.py +13 -15
  36. quasarr/search/sources/dw.py +24 -16
  37. quasarr/search/sources/fx.py +25 -11
  38. quasarr/search/sources/he.py +16 -14
  39. quasarr/search/sources/hs.py +7 -7
  40. quasarr/search/sources/mb.py +7 -7
  41. quasarr/search/sources/nk.py +24 -25
  42. quasarr/search/sources/nx.py +22 -15
  43. quasarr/search/sources/sf.py +18 -9
  44. quasarr/search/sources/sj.py +7 -7
  45. quasarr/search/sources/sl.py +26 -14
  46. quasarr/search/sources/wd.py +61 -31
  47. quasarr/search/sources/wx.py +33 -47
  48. quasarr/storage/config.py +1 -3
  49. {quasarr-2.6.1.dist-info → quasarr-2.7.1.dist-info}/METADATA +4 -1
  50. quasarr-2.7.1.dist-info/RECORD +84 -0
  51. quasarr-2.6.1.dist-info/RECORD +0 -84
  52. {quasarr-2.6.1.dist-info → quasarr-2.7.1.dist-info}/WHEEL +0 -0
  53. {quasarr-2.6.1.dist-info → quasarr-2.7.1.dist-info}/entry_points.txt +0 -0
  54. {quasarr-2.6.1.dist-info → quasarr-2.7.1.dist-info}/licenses/LICENSE +0 -0
@@ -6,6 +6,7 @@ import re
6
6
  import uuid
7
7
  from urllib.parse import urljoin
8
8
 
9
+ import requests
9
10
  from bs4 import BeautifulSoup
10
11
 
11
12
  from quasarr.providers.cloudflare import (
@@ -25,18 +26,45 @@ def resolve_wd_redirect(shared_state, url, session_id=None):
25
26
  """
26
27
  Follow redirects for a WD mirror URL and return the final destination.
27
28
  """
29
+ # Try FlareSolverr first if available and session_id is provided
30
+ if session_id and is_flaresolverr_available(shared_state):
31
+ try:
32
+ r = flaresolverr_get(shared_state, url, session_id=session_id)
33
+ if r.status_code == 200:
34
+ if r.url.endswith("/404.html"):
35
+ return None
36
+ return r.url
37
+ else:
38
+ info(f"WD blocked attempt to resolve {url}. Status: {r.status_code}")
39
+ except Exception as e:
40
+ info(f"FlareSolverr error fetching redirected URL for {url}: {e}")
41
+ # Fallback to requests if FlareSolverr fails?
42
+ # For now, let's assume if FS is configured we should rely on it or fail.
43
+ # But the user asked to reconsider "without cloudflare".
44
+
45
+ # Fallback to regular requests if FlareSolverr not used or failed/not configured
28
46
  try:
29
- # Use FlareSolverr to follow redirects as well, since the redirector might be protected
30
- r = flaresolverr_get(shared_state, url, session_id=session_id)
31
-
32
- # FlareSolverr follows redirects automatically and returns the final URL
33
- if r.status_code == 200:
34
- # Check if we landed on a 404 page (soft 404)
35
- if r.url.endswith("/404.html"):
36
- return None
47
+ user_agent = shared_state.values["user_agent"]
48
+ r = requests.get(
49
+ url,
50
+ allow_redirects=True,
51
+ timeout=10,
52
+ headers={"User-Agent": user_agent},
53
+ )
54
+ r.raise_for_status()
55
+ if r.history:
56
+ for resp in r.history:
57
+ debug(f"Redirected from {resp.url} to {r.url}")
37
58
  return r.url
38
59
  else:
39
- info(f"WD blocked attempt to resolve {url}. Status: {r.status_code}")
60
+ # If no history, maybe it wasn't a redirect or it blocked us?
61
+ # WD usually redirects. If we get 200 OK but same URL, it might be a block page or direct link?
62
+ # The original code assumed if no history -> blocked.
63
+ # But if it's a direct link, history is empty.
64
+ # Let's trust the original logic: "WD blocked attempt..."
65
+ info(
66
+ f"WD blocked attempt to resolve {url}. Your IP may be banned. Try again later."
67
+ )
40
68
  except Exception as e:
41
69
  info(f"Error fetching redirected URL for {url}: {e}")
42
70
  mark_hostname_issue(
@@ -54,36 +82,68 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
54
82
 
55
83
  wd = shared_state.values["config"]("Hostnames").get("wd")
56
84
 
57
- if not is_flaresolverr_available(shared_state):
58
- info(
59
- "WD is protected by Cloudflare but FlareSolverr is not configured. "
60
- "Please configure FlareSolverr in the web UI to access this site."
61
- )
62
- mark_hostname_issue(hostname, "download", "FlareSolverr required but missing.")
63
- return {"links": [], "imdb_id": None}
64
-
65
- # Create a temporary FlareSolverr session for this download attempt
66
- session_id = str(uuid.uuid4())
67
- created_session = flaresolverr_create_session(shared_state, session_id)
68
- if not created_session:
69
- info("Could not create FlareSolverr session. Proceeding without session...")
70
- session_id = None
71
- else:
72
- debug(f"Created FlareSolverr session: {session_id}")
85
+ # Try normal request first
86
+ text = None
87
+ status_code = None
88
+ session_id = None
73
89
 
74
90
  try:
75
- r = flaresolverr_get(shared_state, url, session_id=session_id)
91
+ headers = {"User-Agent": shared_state.values["user_agent"]}
92
+ r = requests.get(url, headers=headers, timeout=10)
93
+ # Don't raise for status yet, check for 403/challenge
76
94
  if r.status_code == 403 or is_cloudflare_challenge(r.text):
77
- info("Could not bypass Cloudflare protection with FlareSolverr!")
78
- mark_hostname_issue(hostname, "download", "Cloudflare challenge failed")
95
+ raise requests.RequestException("Cloudflare protection detected")
96
+ r.raise_for_status()
97
+ text = r.text
98
+ status_code = r.status_code
99
+ except Exception as e:
100
+ # If blocked or failed, try FlareSolverr
101
+ if is_flaresolverr_available(shared_state):
102
+ debug(
103
+ f"Encountered Cloudflare on {hostname} download. Trying FlareSolverr..."
104
+ )
105
+ # Create a temporary FlareSolverr session for this download attempt
106
+ session_id = str(uuid.uuid4())
107
+ created_session = flaresolverr_create_session(shared_state, session_id)
108
+ if not created_session:
109
+ info(
110
+ "Could not create FlareSolverr session. Proceeding without session..."
111
+ )
112
+ session_id = None
113
+ else:
114
+ debug(f"Created FlareSolverr session: {session_id}")
115
+
116
+ try:
117
+ r = flaresolverr_get(shared_state, url, session_id=session_id)
118
+ if r.status_code == 403 or is_cloudflare_challenge(r.text):
119
+ info("Could not bypass Cloudflare protection with FlareSolverr!")
120
+ mark_hostname_issue(
121
+ hostname, "download", "Cloudflare challenge failed"
122
+ )
123
+ if session_id:
124
+ flaresolverr_destroy_session(shared_state, session_id)
125
+ return {"links": [], "imdb_id": None}
126
+ text = r.text
127
+ status_code = r.status_code
128
+ except RuntimeError as fs_err:
129
+ info(f"WD access failed via FlareSolverr: {fs_err}")
130
+ if session_id:
131
+ flaresolverr_destroy_session(shared_state, session_id)
132
+ return {"links": [], "imdb_id": None}
133
+ else:
134
+ info(
135
+ f"WD site has been updated or is protected. Grabbing download links for {title} not possible! ({e})"
136
+ )
137
+ mark_hostname_issue(hostname, "download", str(e))
79
138
  return {"links": [], "imdb_id": None}
80
139
 
81
- if r.status_code >= 400:
140
+ try:
141
+ if status_code and status_code >= 400:
82
142
  mark_hostname_issue(
83
- hostname, "download", f"Download error: {str(r.status_code)}"
143
+ hostname, "download", f"Download error: {str(status_code)}"
84
144
  )
85
145
 
86
- soup = BeautifulSoup(r.text, "html.parser")
146
+ soup = BeautifulSoup(text, "html.parser")
87
147
 
88
148
  # extract IMDb id if present
89
149
  imdb_id = None
@@ -118,7 +178,7 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
118
178
  raw_href = a["href"]
119
179
  full_link = urljoin(f"https://{wd}", raw_href)
120
180
 
121
- # resolve any redirects using the same session
181
+ # resolve any redirects using the same session (or requests if no session)
122
182
  resolved = resolve_wd_redirect(
123
183
  shared_state, full_link, session_id=session_id
124
184
  )
@@ -153,17 +213,13 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
153
213
  "imdb_id": imdb_id,
154
214
  }
155
215
 
156
- except RuntimeError as e:
157
- # Catch FlareSolverr not configured error
158
- info(f"WD access failed: {e}")
159
- return {"links": [], "imdb_id": None}
160
216
  except Exception as e:
161
217
  info(
162
218
  f"WD site has been updated. Grabbing download links for {title} not possible! Error: {e}"
163
219
  )
164
220
  return {"links": [], "imdb_id": None}
165
221
  finally:
166
- # Always destroy the session
222
+ # Always destroy the session if we created one
167
223
  if session_id:
168
224
  debug(f"Destroying FlareSolverr session: {session_id}")
169
225
  flaresolverr_destroy_session(shared_state, session_id)
@@ -39,7 +39,7 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
39
39
  # Extract slug from URL
40
40
  slug_match = re.search(r"/detail/([^/?]+)", url)
41
41
  if not slug_match:
42
- info(f"{hostname.upper()}: Could not extract slug from URL: {url}")
42
+ info(f"Could not extract slug from URL: {url}")
43
43
  return {"links": []}
44
44
 
45
45
  api_url = f"https://api.{host}/start/d/{slug_match.group(1)}"
@@ -50,7 +50,7 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
50
50
  "Accept": "application/json",
51
51
  }
52
52
 
53
- debug(f"{hostname.upper()}: Fetching API data from: {api_url}")
53
+ debug(f"Fetching API data from: {api_url}")
54
54
  api_r = session.get(api_url, headers=api_headers, timeout=30)
55
55
  api_r.raise_for_status()
56
56
 
@@ -58,7 +58,7 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
58
58
 
59
59
  # Navigate to releases in the API response
60
60
  if "item" not in data or "releases" not in data["item"]:
61
- info(f"{hostname.upper()}: No releases found in API response")
61
+ info("No releases found in API response")
62
62
  return {"links": []}
63
63
 
64
64
  releases = data["item"]["releases"]
@@ -67,12 +67,10 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
67
67
  matching_releases = [r for r in releases if r.get("fulltitle") == title]
68
68
 
69
69
  if not matching_releases:
70
- info(f"{hostname.upper()}: No release found matching title: {title}")
70
+ info(f"No release found matching title: {title}")
71
71
  return {"links": []}
72
72
 
73
- debug(
74
- f"{hostname.upper()}: Found {len(matching_releases)} mirror(s) for: {title}"
75
- )
73
+ debug(f"Found {len(matching_releases)} mirror(s) for: {title}")
76
74
 
77
75
  # Evaluate each mirror and find the best one
78
76
  # Track: (online_count, is_hide, online_links)
@@ -105,14 +103,12 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
105
103
  hide_total = len(hide_links)
106
104
  hide_online = len(online_hide)
107
105
 
108
- debug(
109
- f"{hostname.upper()}: M{idx + 1} hide.cx: {hide_online}/{hide_total} online"
110
- )
106
+ debug(f"M{idx + 1} hide.cx: {hide_online}/{hide_total} online")
111
107
 
112
108
  # If all hide.cx links are online, use this mirror immediately
113
109
  if hide_online == hide_total and hide_online > 0:
114
110
  debug(
115
- f"{hostname.upper()}: M{idx + 1} is complete (all {hide_online} hide.cx links online), using this mirror"
111
+ f"M{idx + 1} is complete (all {hide_online} hide.cx links online), using this mirror"
116
112
  )
117
113
  return {"links": online_hide}
118
114
 
@@ -124,9 +120,7 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
124
120
  other_total = len(other_links)
125
121
  other_online = len(online_other)
126
122
 
127
- debug(
128
- f"{hostname.upper()}: M{idx + 1} other crypters: {other_online}/{other_total} online"
129
- )
123
+ debug(f"M{idx + 1} other crypters: {other_online}/{other_total} online")
130
124
 
131
125
  # Determine best option for this mirror (prefer hide.cx on ties)
132
126
  mirror_links = None
@@ -163,15 +157,15 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
163
157
  if best_mirror and best_mirror[2]:
164
158
  crypter_type = "hide.cx" if best_mirror[1] else "other crypter"
165
159
  debug(
166
- f"{hostname.upper()}: No complete mirror, using best partial with {best_mirror[0]} online {crypter_type} link(s)"
160
+ f"No complete mirror, using best partial with {best_mirror[0]} online {crypter_type} link(s)"
167
161
  )
168
162
  return {"links": best_mirror[2]}
169
163
 
170
- info(f"{hostname.upper()}: No online links found for: {title}")
164
+ info(f"No online links found for: {title}")
171
165
  return {"links": []}
172
166
 
173
167
  except Exception as e:
174
- info(f"{hostname.upper()}: Error extracting download links from {url}: {e}")
168
+ info(f"Error extracting download links from {url}: {e}")
175
169
  mark_hostname_issue(
176
170
  hostname, "download", str(e) if "e" in dir() else "Download error"
177
171
  )
quasarr/providers/auth.py CHANGED
@@ -278,35 +278,31 @@ def add_auth_routes(app):
278
278
  return _handle_logout()
279
279
 
280
280
 
281
- def add_auth_hook(app, whitelist_prefixes=[], whitelist_suffixes=[]):
281
+ def add_auth_hook(app, whitelist=None):
282
282
  """Add authentication hook to a Bottle app.
283
283
 
284
284
  Args:
285
285
  app: Bottle application
286
- whitelist_prefixes: List of path prefixes to skip auth (e.g., ['/api/', '/sponsors_helper/'])
286
+ whitelist: List of path prefixes or suffixes to skip auth
287
287
  """
288
- if whitelist_prefixes is None:
289
- whitelist_prefixes = []
288
+ if whitelist is None:
289
+ whitelist = []
290
290
 
291
291
  @app.hook("before_request")
292
292
  def auth_hook():
293
293
  if not is_auth_enabled():
294
294
  return
295
295
 
296
- path = request.path
296
+ # Strip query parameters for path matching
297
+ path = request.path.split("?")[0]
297
298
 
298
299
  # Always allow login/logout
299
300
  if path in ["/login", "/logout"]:
300
301
  return
301
302
 
302
- # Check whitelist prefixes
303
- for prefix in whitelist_prefixes:
304
- if path.startswith(prefix):
305
- return
306
-
307
- # Check whitelist suffixes:
308
- for suffix in whitelist_suffixes:
309
- if path.endswith(suffix):
303
+ # Check whitelist
304
+ for item in whitelist:
305
+ if path.startswith(item) or path.endswith(item):
310
306
  return
311
307
 
312
308
  # Check authentication
@@ -5,6 +5,7 @@
5
5
  import requests
6
6
  from bs4 import BeautifulSoup
7
7
 
8
+ from quasarr.providers.log import debug
8
9
  from quasarr.providers.utils import is_flaresolverr_available
9
10
 
10
11
 
@@ -73,7 +74,7 @@ def update_session_via_flaresolverr(
73
74
  "error": f"FlareSolverr request failed: {e}",
74
75
  }
75
76
  except Exception as e:
76
- raise RuntimeError(f"Could not reach FlareSolverr: {e}")
77
+ raise RuntimeError(f"Could not reach FlareSolverr: {e}") from e
77
78
 
78
79
  fs_json = resp.json()
79
80
  if fs_json.get("status") != "ok" or "solution" not in fs_json:
@@ -116,7 +117,7 @@ def ensure_session_cf_bypassed(info, shared_state, session, url, headers):
116
117
  )
117
118
  return None, None, None
118
119
 
119
- info(
120
+ debug(
120
121
  "Encountered Cloudflare protection. Solving challenge with FlareSolverr..."
121
122
  )
122
123
  flaresolverr_result = update_session_via_flaresolverr(
@@ -168,7 +169,7 @@ class FlareSolverrResponse:
168
169
  raise requests.HTTPError(f"{self.status_code} Error at {self.url}")
169
170
 
170
171
 
171
- def flaresolverr_get(shared_state, url, timeout=60, session_id=None):
172
+ def flaresolverr_get(shared_state, url, timeout=30, session_id=None):
172
173
  """
173
174
  Core function for performing a GET request via FlareSolverr only.
174
175
  Used internally by FlareSolverrSession.get()
@@ -198,7 +199,7 @@ def flaresolverr_get(shared_state, url, timeout=60, session_id=None):
198
199
  )
199
200
  resp.raise_for_status()
200
201
  except Exception as e:
201
- raise RuntimeError(f"Error communicating with FlareSolverr: {e}")
202
+ raise RuntimeError(f"Error communicating with FlareSolverr: {e}") from e
202
203
 
203
204
  data = resp.json()
204
205
 
@@ -207,8 +207,6 @@ class IMDbCDN:
207
207
  return results
208
208
 
209
209
  except Exception as e:
210
- from quasarr.providers.log import debug
211
-
212
210
  debug(f"IMDb CDN search failed: {e}")
213
211
 
214
212
  return []
@@ -238,7 +236,7 @@ class IMDbFlareSolverr:
238
236
  flaresolverr_url,
239
237
  json=post_data,
240
238
  headers={"Content-Type": "application/json"},
241
- timeout=60,
239
+ timeout=30,
242
240
  )
243
241
  if response.status_code == 200:
244
242
  json_response = response.json()