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.
- quasarr/__init__.py +71 -61
- quasarr/api/__init__.py +1 -2
- quasarr/api/arr/__init__.py +66 -57
- quasarr/api/captcha/__init__.py +203 -154
- quasarr/downloads/__init__.py +12 -8
- quasarr/downloads/linkcrypters/al.py +4 -4
- quasarr/downloads/linkcrypters/filecrypt.py +1 -2
- quasarr/downloads/packages/__init__.py +62 -88
- quasarr/downloads/sources/al.py +3 -3
- quasarr/downloads/sources/by.py +3 -3
- quasarr/downloads/sources/he.py +8 -9
- quasarr/downloads/sources/nk.py +3 -3
- quasarr/downloads/sources/sl.py +6 -1
- quasarr/downloads/sources/wd.py +93 -37
- quasarr/downloads/sources/wx.py +11 -17
- quasarr/providers/auth.py +9 -13
- quasarr/providers/cloudflare.py +5 -4
- quasarr/providers/imdb_metadata.py +1 -3
- quasarr/providers/jd_cache.py +64 -90
- quasarr/providers/log.py +226 -8
- quasarr/providers/myjd_api.py +116 -94
- quasarr/providers/sessions/al.py +20 -22
- quasarr/providers/sessions/dd.py +1 -1
- quasarr/providers/sessions/dl.py +8 -10
- quasarr/providers/sessions/nx.py +1 -1
- quasarr/providers/shared_state.py +26 -15
- quasarr/providers/utils.py +15 -6
- quasarr/providers/version.py +1 -1
- quasarr/search/__init__.py +113 -82
- quasarr/search/sources/al.py +19 -23
- quasarr/search/sources/by.py +6 -6
- quasarr/search/sources/dd.py +8 -10
- quasarr/search/sources/dj.py +15 -18
- quasarr/search/sources/dl.py +25 -37
- quasarr/search/sources/dt.py +13 -15
- quasarr/search/sources/dw.py +24 -16
- quasarr/search/sources/fx.py +25 -11
- quasarr/search/sources/he.py +16 -14
- quasarr/search/sources/hs.py +7 -7
- quasarr/search/sources/mb.py +7 -7
- quasarr/search/sources/nk.py +24 -25
- quasarr/search/sources/nx.py +22 -15
- quasarr/search/sources/sf.py +18 -9
- quasarr/search/sources/sj.py +7 -7
- quasarr/search/sources/sl.py +26 -14
- quasarr/search/sources/wd.py +61 -31
- quasarr/search/sources/wx.py +33 -47
- quasarr/storage/config.py +1 -3
- {quasarr-2.6.1.dist-info → quasarr-2.7.1.dist-info}/METADATA +4 -1
- quasarr-2.7.1.dist-info/RECORD +84 -0
- quasarr-2.6.1.dist-info/RECORD +0 -84
- {quasarr-2.6.1.dist-info → quasarr-2.7.1.dist-info}/WHEEL +0 -0
- {quasarr-2.6.1.dist-info → quasarr-2.7.1.dist-info}/entry_points.txt +0 -0
- {quasarr-2.6.1.dist-info → quasarr-2.7.1.dist-info}/licenses/LICENSE +0 -0
quasarr/downloads/sources/wd.py
CHANGED
|
@@ -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
|
-
|
|
30
|
-
r =
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
140
|
+
try:
|
|
141
|
+
if status_code and status_code >= 400:
|
|
82
142
|
mark_hostname_issue(
|
|
83
|
-
hostname, "download", f"Download error: {str(
|
|
143
|
+
hostname, "download", f"Download error: {str(status_code)}"
|
|
84
144
|
)
|
|
85
145
|
|
|
86
|
-
soup = BeautifulSoup(
|
|
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)
|
quasarr/downloads/sources/wx.py
CHANGED
|
@@ -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"
|
|
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"
|
|
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(
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
164
|
+
info(f"No online links found for: {title}")
|
|
171
165
|
return {"links": []}
|
|
172
166
|
|
|
173
167
|
except Exception as e:
|
|
174
|
-
info(f"
|
|
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,
|
|
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
|
-
|
|
286
|
+
whitelist: List of path prefixes or suffixes to skip auth
|
|
287
287
|
"""
|
|
288
|
-
if
|
|
289
|
-
|
|
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
|
-
|
|
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
|
|
303
|
-
for
|
|
304
|
-
if path.startswith(
|
|
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
|
quasarr/providers/cloudflare.py
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
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=
|
|
239
|
+
timeout=30,
|
|
242
240
|
)
|
|
243
241
|
if response.status_code == 200:
|
|
244
242
|
json_response = response.json()
|