quasarr 2.6.0__py3-none-any.whl → 2.7.0__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 (57) hide show
  1. quasarr/__init__.py +71 -61
  2. quasarr/api/__init__.py +3 -4
  3. quasarr/api/arr/__init__.py +159 -56
  4. quasarr/api/captcha/__init__.py +203 -154
  5. quasarr/api/config/__init__.py +1 -1
  6. quasarr/api/jdownloader/__init__.py +19 -12
  7. quasarr/downloads/__init__.py +12 -8
  8. quasarr/downloads/linkcrypters/al.py +3 -3
  9. quasarr/downloads/linkcrypters/filecrypt.py +1 -2
  10. quasarr/downloads/packages/__init__.py +62 -88
  11. quasarr/downloads/sources/al.py +3 -3
  12. quasarr/downloads/sources/by.py +3 -3
  13. quasarr/downloads/sources/he.py +8 -9
  14. quasarr/downloads/sources/nk.py +3 -3
  15. quasarr/downloads/sources/sl.py +6 -1
  16. quasarr/downloads/sources/wd.py +132 -62
  17. quasarr/downloads/sources/wx.py +11 -17
  18. quasarr/providers/auth.py +9 -13
  19. quasarr/providers/cloudflare.py +50 -4
  20. quasarr/providers/imdb_metadata.py +0 -2
  21. quasarr/providers/jd_cache.py +64 -90
  22. quasarr/providers/log.py +226 -8
  23. quasarr/providers/myjd_api.py +116 -94
  24. quasarr/providers/sessions/al.py +20 -22
  25. quasarr/providers/sessions/dd.py +1 -1
  26. quasarr/providers/sessions/dl.py +8 -10
  27. quasarr/providers/sessions/nx.py +1 -1
  28. quasarr/providers/shared_state.py +26 -15
  29. quasarr/providers/utils.py +15 -6
  30. quasarr/providers/version.py +1 -1
  31. quasarr/search/__init__.py +91 -78
  32. quasarr/search/sources/al.py +19 -23
  33. quasarr/search/sources/by.py +6 -6
  34. quasarr/search/sources/dd.py +8 -10
  35. quasarr/search/sources/dj.py +15 -18
  36. quasarr/search/sources/dl.py +25 -37
  37. quasarr/search/sources/dt.py +13 -15
  38. quasarr/search/sources/dw.py +24 -16
  39. quasarr/search/sources/fx.py +25 -11
  40. quasarr/search/sources/he.py +16 -14
  41. quasarr/search/sources/hs.py +7 -7
  42. quasarr/search/sources/mb.py +7 -7
  43. quasarr/search/sources/nk.py +24 -25
  44. quasarr/search/sources/nx.py +22 -15
  45. quasarr/search/sources/sf.py +18 -9
  46. quasarr/search/sources/sj.py +7 -7
  47. quasarr/search/sources/sl.py +26 -14
  48. quasarr/search/sources/wd.py +63 -9
  49. quasarr/search/sources/wx.py +33 -47
  50. quasarr/storage/config.py +1 -3
  51. quasarr/storage/setup.py +13 -4
  52. {quasarr-2.6.0.dist-info → quasarr-2.7.0.dist-info}/METADATA +4 -1
  53. quasarr-2.7.0.dist-info/RECORD +84 -0
  54. quasarr-2.6.0.dist-info/RECORD +0 -84
  55. {quasarr-2.6.0.dist-info → quasarr-2.7.0.dist-info}/WHEEL +0 -0
  56. {quasarr-2.6.0.dist-info → quasarr-2.7.0.dist-info}/entry_points.txt +0 -0
  57. {quasarr-2.6.0.dist-info → quasarr-2.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,12 +3,18 @@
3
3
  # Project by https://github.com/rix1337
4
4
 
5
5
  import re
6
+ import uuid
6
7
  from urllib.parse import urljoin
7
8
 
8
9
  import requests
9
10
  from bs4 import BeautifulSoup
10
11
 
11
- from quasarr.providers.cloudflare import flaresolverr_get, is_cloudflare_challenge
12
+ from quasarr.providers.cloudflare import (
13
+ flaresolverr_create_session,
14
+ flaresolverr_destroy_session,
15
+ flaresolverr_get,
16
+ is_cloudflare_challenge,
17
+ )
12
18
  from quasarr.providers.hostname_issues import mark_hostname_issue
13
19
  from quasarr.providers.log import debug, info
14
20
  from quasarr.providers.utils import is_flaresolverr_available
@@ -16,11 +22,29 @@ from quasarr.providers.utils import is_flaresolverr_available
16
22
  hostname = "wd"
17
23
 
18
24
 
19
- def resolve_wd_redirect(url, user_agent):
25
+ def resolve_wd_redirect(shared_state, url, session_id=None):
20
26
  """
21
27
  Follow redirects for a WD mirror URL and return the final destination.
22
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
23
46
  try:
47
+ user_agent = shared_state.values["user_agent"]
24
48
  r = requests.get(
25
49
  url,
26
50
  allow_redirects=True,
@@ -33,6 +57,11 @@ def resolve_wd_redirect(url, user_agent):
33
57
  debug(f"Redirected from {resp.url} to {r.url}")
34
58
  return r.url
35
59
  else:
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..."
36
65
  info(
37
66
  f"WD blocked attempt to resolve {url}. Your IP may be banned. Try again later."
38
67
  )
@@ -52,32 +81,69 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
52
81
  """
53
82
 
54
83
  wd = shared_state.values["config"]("Hostnames").get("wd")
55
- user_agent = shared_state.values["user_agent"]
84
+
85
+ # Try normal request first
86
+ text = None
87
+ status_code = None
88
+ session_id = None
56
89
 
57
90
  try:
58
- r = requests.get(url)
59
- if r.status_code >= 400 or is_cloudflare_challenge(r.text):
60
- if is_flaresolverr_available(shared_state):
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
94
+ if r.status_code == 403 or is_cloudflare_challenge(r.text):
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:
61
109
  info(
62
- "WD is protected by Cloudflare. Using FlareSolverr to bypass protection."
110
+ "Could not create FlareSolverr session. Proceeding without session..."
63
111
  )
64
- r = flaresolverr_get(shared_state, url)
112
+ session_id = None
65
113
  else:
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
- )
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)
73
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))
138
+ return {"links": [], "imdb_id": None}
74
139
 
75
- if r.status_code >= 400:
140
+ try:
141
+ if status_code and status_code >= 400:
76
142
  mark_hostname_issue(
77
- hostname, "download", f"Download error: {str(r.status_code)}"
143
+ hostname, "download", f"Download error: {str(status_code)}"
78
144
  )
79
145
 
80
- soup = BeautifulSoup(r.text, "html.parser")
146
+ soup = BeautifulSoup(text, "html.parser")
81
147
 
82
148
  # extract IMDb id if present
83
149
  imdb_id = None
@@ -105,51 +171,55 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
105
171
  link_tags = body.find_all(
106
172
  "a", href=True, class_=lambda c: c and "background-" in c
107
173
  )
108
- except RuntimeError as e:
109
- # Catch FlareSolverr not configured error
110
- info(f"WD access failed: {e}")
111
- return {"links": [], "imdb_id": None}
112
- except Exception:
113
- info(
114
- f"WD site has been updated. Grabbing download links for {title} not possible!"
115
- )
116
- return {"links": [], "imdb_id": None}
117
174
 
118
- results = []
119
- try:
120
- for a in link_tags:
121
- raw_href = a["href"]
122
- full_link = urljoin(f"https://{wd}", raw_href)
123
-
124
- # resolve any redirects
125
- resolved = resolve_wd_redirect(full_link, user_agent)
126
-
127
- if resolved:
128
- if resolved.endswith("/404.html"):
129
- info(f"Link {resolved} is dead!")
130
- continue
131
-
132
- # determine hoster
133
- hoster = a.get_text(strip=True) or None
134
- if not hoster:
135
- for cls in a.get("class", []):
136
- if cls.startswith("background-"):
137
- hoster = cls.split("-", 1)[1]
138
- break
139
-
140
- if mirror and mirror.lower() not in hoster.lower():
141
- debug(
142
- f'Skipping link from "{hoster}" (not the desired mirror "{mirror}")!'
143
- )
144
- continue
175
+ results = []
176
+ try:
177
+ for a in link_tags:
178
+ raw_href = a["href"]
179
+ full_link = urljoin(f"https://{wd}", raw_href)
180
+
181
+ # resolve any redirects using the same session (or requests if no session)
182
+ resolved = resolve_wd_redirect(
183
+ shared_state, full_link, session_id=session_id
184
+ )
145
185
 
146
- results.append([resolved, hoster])
147
- except Exception:
186
+ if resolved:
187
+ if resolved.endswith("/404.html"):
188
+ info(f"Link {resolved} is dead!")
189
+ continue
190
+
191
+ # determine hoster
192
+ hoster = a.get_text(strip=True) or None
193
+ if not hoster:
194
+ for cls in a.get("class", []):
195
+ if cls.startswith("background-"):
196
+ hoster = cls.split("-", 1)[1]
197
+ break
198
+
199
+ if mirror and mirror.lower() not in hoster.lower():
200
+ debug(
201
+ f'Skipping link from "{hoster}" (not the desired mirror "{mirror}")!'
202
+ )
203
+ continue
204
+
205
+ results.append([resolved, hoster])
206
+ except Exception as e:
207
+ info(
208
+ f"WD site has been updated. Parsing download links for {title} not possible! Error: {e}"
209
+ )
210
+
211
+ return {
212
+ "links": results,
213
+ "imdb_id": imdb_id,
214
+ }
215
+
216
+ except Exception as e:
148
217
  info(
149
- f"WD site has been updated. Parsing download links for {title} not possible!"
218
+ f"WD site has been updated. Grabbing download links for {title} not possible! Error: {e}"
150
219
  )
151
-
152
- return {
153
- "links": results,
154
- "imdb_id": imdb_id,
155
- }
220
+ return {"links": [], "imdb_id": None}
221
+ finally:
222
+ # Always destroy the session if we created one
223
+ if session_id:
224
+ debug(f"Destroying FlareSolverr session: {session_id}")
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):
172
+ def flaresolverr_get(shared_state, url, timeout=60, session_id=None):
172
173
  """
173
174
  Core function for performing a GET request via FlareSolverr only.
174
175
  Used internally by FlareSolverrSession.get()
@@ -186,6 +187,8 @@ def flaresolverr_get(shared_state, url, timeout=60):
186
187
  raise RuntimeError("FlareSolverr URL not configured in shared_state.")
187
188
 
188
189
  payload = {"cmd": "request.get", "url": url, "maxTimeout": timeout * 1000}
190
+ if session_id:
191
+ payload["session"] = session_id
189
192
 
190
193
  try:
191
194
  resp = requests.post(
@@ -196,7 +199,7 @@ def flaresolverr_get(shared_state, url, timeout=60):
196
199
  )
197
200
  resp.raise_for_status()
198
201
  except Exception as e:
199
- raise RuntimeError(f"Error communicating with FlareSolverr: {e}")
202
+ raise RuntimeError(f"Error communicating with FlareSolverr: {e}") from e
200
203
 
201
204
  data = resp.json()
202
205
 
@@ -219,3 +222,46 @@ def flaresolverr_get(shared_state, url, timeout=60):
219
222
  return FlareSolverrResponse(
220
223
  url=url, status_code=status_code, headers=fs_headers, text=html
221
224
  )
225
+
226
+
227
+ def flaresolverr_create_session(shared_state, session_id=None):
228
+ if not is_flaresolverr_available(shared_state):
229
+ return None
230
+
231
+ flaresolverr_url = shared_state.values["config"]("FlareSolverr").get("url")
232
+ payload = {"cmd": "sessions.create"}
233
+ if session_id:
234
+ payload["session"] = session_id
235
+
236
+ try:
237
+ resp = requests.post(
238
+ flaresolverr_url,
239
+ json=payload,
240
+ headers={"Content-Type": "application/json"},
241
+ timeout=10,
242
+ )
243
+ resp.raise_for_status()
244
+ data = resp.json()
245
+ if data.get("status") == "ok":
246
+ return data.get("session")
247
+ except Exception:
248
+ pass
249
+ return None
250
+
251
+
252
+ def flaresolverr_destroy_session(shared_state, session_id):
253
+ if not is_flaresolverr_available(shared_state):
254
+ return
255
+
256
+ flaresolverr_url = shared_state.values["config"]("FlareSolverr").get("url")
257
+ payload = {"cmd": "sessions.destroy", "session": session_id}
258
+
259
+ try:
260
+ requests.post(
261
+ flaresolverr_url,
262
+ json=payload,
263
+ headers={"Content-Type": "application/json"},
264
+ timeout=10,
265
+ )
266
+ except Exception:
267
+ pass
@@ -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 []