quasarr 1.23.0__py3-none-any.whl → 1.24.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.
- quasarr/api/captcha/__init__.py +44 -19
- quasarr/downloads/__init__.py +217 -278
- quasarr/downloads/sources/al.py +28 -3
- quasarr/downloads/sources/by.py +8 -2
- quasarr/downloads/sources/dd.py +15 -8
- quasarr/downloads/sources/dj.py +11 -2
- quasarr/downloads/sources/dl.py +49 -57
- quasarr/downloads/sources/dt.py +34 -12
- quasarr/downloads/sources/dw.py +9 -3
- quasarr/downloads/sources/he.py +10 -4
- quasarr/downloads/sources/mb.py +10 -4
- quasarr/downloads/sources/nk.py +9 -3
- quasarr/downloads/sources/nx.py +31 -10
- quasarr/downloads/sources/sf.py +61 -55
- quasarr/downloads/sources/sj.py +11 -2
- quasarr/downloads/sources/sl.py +22 -9
- quasarr/downloads/sources/wd.py +9 -3
- quasarr/downloads/sources/wx.py +12 -13
- quasarr/providers/obfuscated.py +27 -23
- quasarr/providers/sessions/al.py +38 -10
- quasarr/providers/version.py +1 -1
- quasarr/search/sources/dl.py +10 -6
- {quasarr-1.23.0.dist-info → quasarr-1.24.0.dist-info}/METADATA +2 -2
- {quasarr-1.23.0.dist-info → quasarr-1.24.0.dist-info}/RECORD +28 -28
- {quasarr-1.23.0.dist-info → quasarr-1.24.0.dist-info}/WHEEL +0 -0
- {quasarr-1.23.0.dist-info → quasarr-1.24.0.dist-info}/entry_points.txt +0 -0
- {quasarr-1.23.0.dist-info → quasarr-1.24.0.dist-info}/licenses/LICENSE +0 -0
- {quasarr-1.23.0.dist-info → quasarr-1.24.0.dist-info}/top_level.txt +0 -0
quasarr/downloads/__init__.py
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
2
|
# Quasarr
|
|
3
3
|
# Project by https://github.com/rix1337
|
|
4
|
-
#
|
|
5
|
-
# Special note: The signatures of all handlers must stay the same so we can neatly call them in download()
|
|
6
|
-
# Same is true for every get_xx_download_links() function in sources/xx.py
|
|
7
4
|
|
|
8
5
|
import json
|
|
6
|
+
import re
|
|
9
7
|
|
|
10
8
|
from quasarr.downloads.linkcrypters.hide import decrypt_links_if_hide
|
|
11
9
|
from quasarr.downloads.sources.al import get_al_download_links
|
|
@@ -19,7 +17,7 @@ from quasarr.downloads.sources.he import get_he_download_links
|
|
|
19
17
|
from quasarr.downloads.sources.mb import get_mb_download_links
|
|
20
18
|
from quasarr.downloads.sources.nk import get_nk_download_links
|
|
21
19
|
from quasarr.downloads.sources.nx import get_nx_download_links
|
|
22
|
-
from quasarr.downloads.sources.sf import get_sf_download_links
|
|
20
|
+
from quasarr.downloads.sources.sf import get_sf_download_links
|
|
23
21
|
from quasarr.downloads.sources.sj import get_sj_download_links
|
|
24
22
|
from quasarr.downloads.sources.sl import get_sl_download_links
|
|
25
23
|
from quasarr.downloads.sources.wd import get_wd_download_links
|
|
@@ -28,306 +26,247 @@ from quasarr.providers.log import info
|
|
|
28
26
|
from quasarr.providers.notifications import send_discord_message
|
|
29
27
|
from quasarr.providers.statistics import StatsHelper
|
|
30
28
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
29
|
+
# =============================================================================
|
|
30
|
+
# CRYPTER CONFIGURATION
|
|
31
|
+
# =============================================================================
|
|
32
|
+
|
|
33
|
+
# Patterns match crypter name only - TLDs may change
|
|
34
|
+
AUTO_DECRYPT_PATTERNS = {
|
|
35
|
+
'hide': re.compile(r'hide\.', re.IGNORECASE),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
PROTECTED_PATTERNS = {
|
|
39
|
+
'filecrypt': re.compile(r'filecrypt\.', re.IGNORECASE),
|
|
40
|
+
'tolink': re.compile(r'tolink\.', re.IGNORECASE),
|
|
41
|
+
'keeplinks': re.compile(r'keeplinks\.', re.IGNORECASE),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Source key -> getter function mapping
|
|
45
|
+
# All getters have signature: (shared_state, url, mirror, title, password)
|
|
46
|
+
# AL uses password as release_id, others ignore it
|
|
47
|
+
SOURCE_GETTERS = {
|
|
48
|
+
'al': get_al_download_links,
|
|
49
|
+
'by': get_by_download_links,
|
|
50
|
+
'dd': get_dd_download_links,
|
|
51
|
+
'dj': get_dj_download_links,
|
|
52
|
+
'dl': get_dl_download_links,
|
|
53
|
+
'dt': get_dt_download_links,
|
|
54
|
+
'dw': get_dw_download_links,
|
|
55
|
+
'he': get_he_download_links,
|
|
56
|
+
'mb': get_mb_download_links,
|
|
57
|
+
'nk': get_nk_download_links,
|
|
58
|
+
'nx': get_nx_download_links,
|
|
59
|
+
'sf': get_sf_download_links,
|
|
60
|
+
'sj': get_sj_download_links,
|
|
61
|
+
'sl': get_sl_download_links,
|
|
62
|
+
'wd': get_wd_download_links,
|
|
63
|
+
'wx': get_wx_download_links,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# =============================================================================
|
|
68
|
+
# LINK CLASSIFICATION
|
|
69
|
+
# =============================================================================
|
|
70
|
+
|
|
71
|
+
def detect_crypter(url):
|
|
72
|
+
"""Returns (crypter_name, 'auto'|'protected') or (None, None)."""
|
|
73
|
+
for name, pattern in AUTO_DECRYPT_PATTERNS.items():
|
|
74
|
+
if pattern.search(url):
|
|
75
|
+
return name, 'auto'
|
|
76
|
+
for name, pattern in PROTECTED_PATTERNS.items():
|
|
77
|
+
if pattern.search(url):
|
|
78
|
+
return name, 'protected'
|
|
79
|
+
return None, None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_junkies_link(url, shared_state):
|
|
83
|
+
"""Check if URL is a junkies (sj/dj) link."""
|
|
84
|
+
sj = shared_state.values["config"]("Hostnames").get("sj")
|
|
85
|
+
dj = shared_state.values["config"]("Hostnames").get("dj")
|
|
86
|
+
url_lower = url.lower()
|
|
87
|
+
return (sj and sj.lower() in url_lower) or (dj and dj.lower() in url_lower)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def classify_links(links, shared_state):
|
|
83
91
|
"""
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
Classify links into direct/auto/protected categories.
|
|
93
|
+
Direct = anything that's not a known crypter or junkies link.
|
|
94
|
+
Mirror names from source are preserved.
|
|
86
95
|
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if not decrypted or decrypted.get("status") == "none":
|
|
90
|
-
return {"handled": False, "result": None}
|
|
91
|
-
|
|
92
|
-
status = decrypted.get("status", "error")
|
|
93
|
-
decrypted_links = decrypted.get("results", [])
|
|
94
|
-
|
|
95
|
-
if status == "success":
|
|
96
|
-
result = handle_unprotected(
|
|
97
|
-
shared_state, title, password, package_id, imdb_id, url,
|
|
98
|
-
links=decrypted_links, label=label
|
|
99
|
-
)
|
|
100
|
-
return {"handled": True, "result": result}
|
|
101
|
-
else:
|
|
102
|
-
fail(title, package_id, shared_state,
|
|
103
|
-
reason=f'Error decrypting hide.cx links for "{title}" on {label} - "{url}"')
|
|
104
|
-
return {"handled": True, "result": {"success": False, "title": title}}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def handle_al(shared_state, title, password, package_id, imdb_id, url, mirror, size_mb):
|
|
108
|
-
data = get_al_download_links(shared_state, url, mirror, title, password)
|
|
109
|
-
links = data.get("links", [])
|
|
110
|
-
title = data.get("title", title)
|
|
111
|
-
password = data.get("password", "")
|
|
112
|
-
return handle_unprotected(
|
|
113
|
-
shared_state, title, password, package_id, imdb_id, url,
|
|
114
|
-
links=links,
|
|
115
|
-
label='AL'
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def handle_by(shared_state, title, password, package_id, imdb_id, url, mirror, size_mb):
|
|
120
|
-
links = get_by_download_links(shared_state, url, mirror, title)
|
|
121
|
-
if not links:
|
|
122
|
-
fail(title, package_id, shared_state,
|
|
123
|
-
reason=f'Offline / no links found for "{title}" on BY - "{url}"')
|
|
124
|
-
return {"success": False, "title": title}
|
|
96
|
+
classified = {'direct': [], 'auto': [], 'protected': []}
|
|
125
97
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
)
|
|
98
|
+
for link in links:
|
|
99
|
+
url = link[0]
|
|
129
100
|
|
|
130
|
-
|
|
131
|
-
|
|
101
|
+
if is_junkies_link(url, shared_state):
|
|
102
|
+
classified['protected'].append(link)
|
|
103
|
+
continue
|
|
132
104
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
105
|
+
crypter, crypter_type = detect_crypter(url)
|
|
106
|
+
if crypter_type == 'auto':
|
|
107
|
+
classified['auto'].append(link)
|
|
108
|
+
elif crypter_type == 'protected':
|
|
109
|
+
classified['protected'].append(link)
|
|
110
|
+
else:
|
|
111
|
+
# Not a known crypter = direct hoster link
|
|
112
|
+
classified['direct'].append(link)
|
|
140
113
|
|
|
114
|
+
return classified
|
|
141
115
|
|
|
142
|
-
def handle_dl(shared_state, title, password, package_id, imdb_id, url, mirror, size_mb):
|
|
143
|
-
links, extracted_password = get_dl_download_links(shared_state, url, mirror, title)
|
|
144
|
-
if not links:
|
|
145
|
-
fail(title, package_id, shared_state,
|
|
146
|
-
reason=f'Offline / no links found for "{title}" on DL - "{url}"')
|
|
147
|
-
return {"success": False, "title": title}
|
|
148
|
-
|
|
149
|
-
# Use extracted password if available, otherwise fall back to provided password
|
|
150
|
-
final_password = extracted_password if extracted_password else password
|
|
151
|
-
|
|
152
|
-
decrypt_result = handle_hide(
|
|
153
|
-
shared_state, title, final_password, package_id, imdb_id, url, links, 'DL'
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
if decrypt_result["handled"]:
|
|
157
|
-
return decrypt_result["result"]
|
|
158
|
-
|
|
159
|
-
return handle_protected(
|
|
160
|
-
shared_state, title, final_password, package_id, imdb_id, url,
|
|
161
|
-
mirror=mirror,
|
|
162
|
-
size_mb=size_mb,
|
|
163
|
-
func=lambda ss, u, m, t: links,
|
|
164
|
-
label='DL'
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def handle_sf(shared_state, title, password, package_id, imdb_id, url, mirror, size_mb):
|
|
169
|
-
if url.startswith(f"https://{shared_state.values['config']('Hostnames').get('sf')}/external"):
|
|
170
|
-
url = resolve_sf_redirect(url, shared_state.values["user_agent"])
|
|
171
|
-
elif url.startswith(f"https://{shared_state.values['config']('Hostnames').get('sf')}/"):
|
|
172
|
-
data = get_sf_download_links(shared_state, url, mirror, title)
|
|
173
|
-
url = data.get("real_url")
|
|
174
|
-
if not imdb_id:
|
|
175
|
-
imdb_id = data.get("imdb_id")
|
|
176
|
-
|
|
177
|
-
if not url:
|
|
178
|
-
fail(title, package_id, shared_state,
|
|
179
|
-
reason=f'Failed to get download link from SF for "{title}" - "{url}"')
|
|
180
|
-
return {"success": False, "title": title}
|
|
181
|
-
|
|
182
|
-
return handle_protected(
|
|
183
|
-
shared_state, title, password, package_id, imdb_id, url,
|
|
184
|
-
mirror=mirror,
|
|
185
|
-
size_mb=size_mb,
|
|
186
|
-
func=lambda ss, u, m, t: [[url, "filecrypt"]],
|
|
187
|
-
label='SF'
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def handle_sl(shared_state, title, password, package_id, imdb_id, url, mirror, size_mb):
|
|
192
|
-
data = get_sl_download_links(shared_state, url, mirror, title)
|
|
193
|
-
links = data.get("links")
|
|
194
|
-
if not imdb_id:
|
|
195
|
-
imdb_id = data.get("imdb_id")
|
|
196
|
-
return handle_unprotected(
|
|
197
|
-
shared_state, title, password, package_id, imdb_id, url,
|
|
198
|
-
links=links,
|
|
199
|
-
label='SL'
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def handle_wd(shared_state, title, password, package_id, imdb_id, url, mirror, size_mb):
|
|
204
|
-
data = get_wd_download_links(shared_state, url, mirror, title)
|
|
205
|
-
links = data.get("links", []) if data else []
|
|
206
|
-
if not links:
|
|
207
|
-
fail(title, package_id, shared_state,
|
|
208
|
-
reason=f'Offline / no links found for "{title}" on WD - "{url}"')
|
|
209
|
-
return {"success": False, "title": title}
|
|
210
116
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
117
|
+
# =============================================================================
|
|
118
|
+
# LINK PROCESSING
|
|
119
|
+
# =============================================================================
|
|
214
120
|
|
|
215
|
-
|
|
216
|
-
|
|
121
|
+
def handle_direct_links(shared_state, links, title, password, package_id):
|
|
122
|
+
"""Send direct hoster links to JDownloader."""
|
|
123
|
+
urls = [link[0] for link in links]
|
|
124
|
+
info(f"Sending {len(urls)} direct download links for {title}")
|
|
217
125
|
|
|
218
|
-
|
|
219
|
-
shared_state
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
func=lambda ss, u, m, t: links,
|
|
223
|
-
label='WD'
|
|
224
|
-
)
|
|
126
|
+
if shared_state.download_package(urls, title, password, package_id):
|
|
127
|
+
StatsHelper(shared_state).increment_package_with_links(urls)
|
|
128
|
+
return {"success": True}
|
|
129
|
+
return {"success": False, "reason": f'Failed to add {len(urls)} links to linkgrabber'}
|
|
225
130
|
|
|
226
131
|
|
|
227
|
-
def
|
|
228
|
-
links
|
|
229
|
-
|
|
230
|
-
fail(title, package_id, shared_state,
|
|
231
|
-
reason=f'Offline / no links found for "{title}" on WX - "{url}"')
|
|
232
|
-
return {"success": False, "title": title}
|
|
132
|
+
def handle_auto_decrypt_links(shared_state, links, title, password, package_id):
|
|
133
|
+
"""Decrypt hide.cx links and send to JDownloader."""
|
|
134
|
+
result = decrypt_links_if_hide(shared_state, links)
|
|
233
135
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
)
|
|
136
|
+
if result.get("status") != "success":
|
|
137
|
+
return {"success": False, "reason": "Auto-decrypt failed"}
|
|
237
138
|
|
|
238
|
-
|
|
239
|
-
|
|
139
|
+
decrypted_urls = result.get("results", [])
|
|
140
|
+
if not decrypted_urls:
|
|
141
|
+
return {"success": False, "reason": "No links decrypted"}
|
|
240
142
|
|
|
241
|
-
|
|
242
|
-
shared_state, title, password, package_id, imdb_id, url,
|
|
243
|
-
mirror=mirror,
|
|
244
|
-
size_mb=size_mb,
|
|
245
|
-
func=lambda ss, u, m, t: links,
|
|
246
|
-
label='WX'
|
|
247
|
-
)
|
|
143
|
+
info(f"Decrypted {len(decrypted_urls)} download links for {title}")
|
|
248
144
|
|
|
145
|
+
if shared_state.download_package(decrypted_urls, title, password, package_id):
|
|
146
|
+
StatsHelper(shared_state).increment_package_with_links(decrypted_urls)
|
|
147
|
+
return {"success": True}
|
|
148
|
+
return {"success": False, "reason": "Failed to add decrypted links to linkgrabber"}
|
|
249
149
|
|
|
250
|
-
def download(shared_state, request_from, title, url, mirror, size_mb, password, imdb_id=None):
|
|
251
|
-
if "lazylibrarian" in request_from.lower():
|
|
252
|
-
category = "docs"
|
|
253
|
-
elif "radarr" in request_from.lower():
|
|
254
|
-
category = "movies"
|
|
255
|
-
else:
|
|
256
|
-
category = "tv"
|
|
257
150
|
|
|
151
|
+
def store_protected_links(shared_state, links, title, password, package_id, size_mb=None, original_url=None):
|
|
152
|
+
"""Store protected links for CAPTCHA UI."""
|
|
153
|
+
blob_data = {"title": title, "links": links, "password": password, "size_mb": size_mb}
|
|
154
|
+
if original_url:
|
|
155
|
+
blob_data["original_url"] = original_url
|
|
156
|
+
|
|
157
|
+
shared_state.values["database"]("protected").update_store(package_id, json.dumps(blob_data))
|
|
158
|
+
info(f'CAPTCHA-Solution required for "{title}" at: "{shared_state.values["external_address"]}/captcha"')
|
|
159
|
+
return {"success": True}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def process_links(shared_state, source_result, title, password, package_id, imdb_id, source_url, size_mb, label):
|
|
163
|
+
"""
|
|
164
|
+
Central link processor with priority: direct → auto-decrypt → protected.
|
|
165
|
+
If ANY direct links exist, use them and ignore crypted fallbacks.
|
|
166
|
+
"""
|
|
167
|
+
if not source_result:
|
|
168
|
+
return fail(title, package_id, shared_state,
|
|
169
|
+
reason=f'Source returned no data for "{title}" on {label} - "{source_url}"')
|
|
170
|
+
|
|
171
|
+
links = source_result.get("links", [])
|
|
172
|
+
password = source_result.get("password") or password
|
|
173
|
+
imdb_id = imdb_id or source_result.get("imdb_id")
|
|
174
|
+
title = source_result.get("title") or title
|
|
175
|
+
|
|
176
|
+
if not links:
|
|
177
|
+
return fail(title, package_id, shared_state,
|
|
178
|
+
reason=f'No links found for "{title}" on {label} - "{source_url}"')
|
|
179
|
+
|
|
180
|
+
# Filter out 404 links
|
|
181
|
+
valid_links = [link for link in links if "/404.html" not in link[0]]
|
|
182
|
+
if not valid_links:
|
|
183
|
+
return fail(title, package_id, shared_state,
|
|
184
|
+
reason=f'All links are offline or IP is banned for "{title}" on {label} - "{source_url}"')
|
|
185
|
+
links = valid_links
|
|
186
|
+
|
|
187
|
+
classified = classify_links(links, shared_state)
|
|
188
|
+
|
|
189
|
+
# PRIORITY 1: Direct hoster links
|
|
190
|
+
if classified['direct']:
|
|
191
|
+
info(f"Found {len(classified['direct'])} direct hoster links for {title}")
|
|
192
|
+
send_discord_message(shared_state, title=title, case="unprotected", imdb_id=imdb_id, source=source_url)
|
|
193
|
+
result = handle_direct_links(shared_state, classified['direct'], title, password, package_id)
|
|
194
|
+
if result["success"]:
|
|
195
|
+
return {"success": True, "title": title}
|
|
196
|
+
return fail(title, package_id, shared_state, reason=result.get("reason"))
|
|
197
|
+
|
|
198
|
+
# PRIORITY 2: Auto-decryptable (hide.cx)
|
|
199
|
+
if classified['auto']:
|
|
200
|
+
info(f"Found {len(classified['auto'])} auto-decryptable links for {title}")
|
|
201
|
+
result = handle_auto_decrypt_links(shared_state, classified['auto'], title, password, package_id)
|
|
202
|
+
if result["success"]:
|
|
203
|
+
send_discord_message(shared_state, title=title, case="unprotected", imdb_id=imdb_id, source=source_url)
|
|
204
|
+
return {"success": True, "title": title}
|
|
205
|
+
info(f"Auto-decrypt failed for {title}, checking for protected fallback...")
|
|
206
|
+
|
|
207
|
+
# PRIORITY 3: Protected (filecrypt, tolink, keeplinks, junkies)
|
|
208
|
+
if classified['protected']:
|
|
209
|
+
info(f"Found {len(classified['protected'])} protected links for {title}")
|
|
210
|
+
send_discord_message(shared_state, title=title, case="captcha", imdb_id=imdb_id, source=source_url)
|
|
211
|
+
store_protected_links(shared_state, classified['protected'], title, password, package_id,
|
|
212
|
+
size_mb=size_mb, original_url=source_url)
|
|
213
|
+
return {"success": True, "title": title}
|
|
214
|
+
|
|
215
|
+
return fail(title, package_id, shared_state,
|
|
216
|
+
reason=f'No usable links found for "{title}" on {label} - "{source_url}"')
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# =============================================================================
|
|
220
|
+
# MAIN ENTRY POINT
|
|
221
|
+
# =============================================================================
|
|
222
|
+
|
|
223
|
+
def download(shared_state, request_from, title, url, mirror, size_mb, password, imdb_id=None):
|
|
224
|
+
"""Main download entry point."""
|
|
225
|
+
category = "docs" if "lazylibrarian" in request_from.lower() else \
|
|
226
|
+
"movies" if "radarr" in request_from.lower() else "tv"
|
|
258
227
|
package_id = f"Quasarr_{category}_{str(hash(title + url)).replace('-', '')}"
|
|
259
228
|
|
|
260
|
-
if imdb_id
|
|
229
|
+
if imdb_id and imdb_id.lower() == "none":
|
|
261
230
|
imdb_id = None
|
|
262
231
|
|
|
263
232
|
config = shared_state.values["config"]("Hostnames")
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
(
|
|
286
|
-
(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
(flags['SF'], handle_sf),
|
|
296
|
-
(flags['SJ'], lambda *a: handle_protected(*a, func=get_sj_download_links, label='SJ')),
|
|
297
|
-
(flags['SL'], handle_sl),
|
|
298
|
-
(flags['WD'], handle_wd),
|
|
299
|
-
(flags['WX'], handle_wx),
|
|
300
|
-
]
|
|
301
|
-
|
|
302
|
-
for flag, fn in handlers:
|
|
303
|
-
if flag and flag.lower() in url.lower():
|
|
304
|
-
return {"package_id": package_id,
|
|
305
|
-
**fn(shared_state, title, password, package_id, imdb_id, url, mirror, size_mb)}
|
|
306
|
-
|
|
307
|
-
if "filecrypt" in url.lower():
|
|
308
|
-
return {"package_id": package_id, **handle_protected(
|
|
309
|
-
shared_state, title, password, package_id, imdb_id, url, mirror, size_mb,
|
|
310
|
-
func=lambda ss, u, m, t: [[u, "filecrypt"]],
|
|
311
|
-
label='filecrypt'
|
|
312
|
-
)}
|
|
313
|
-
|
|
314
|
-
info(f'Could not parse URL for "{title}" - "{url}"')
|
|
315
|
-
StatsHelper(shared_state).increment_failed_downloads()
|
|
316
|
-
return {"success": False, "package_id": package_id, "title": title}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
def fail(title, package_id, shared_state, reason="Offline / no links found"):
|
|
233
|
+
|
|
234
|
+
# Find matching source - all getters have unified signature
|
|
235
|
+
source_result = None
|
|
236
|
+
label = None
|
|
237
|
+
|
|
238
|
+
for key, getter in SOURCE_GETTERS.items():
|
|
239
|
+
hostname = config.get(key)
|
|
240
|
+
if hostname and hostname.lower() in url.lower():
|
|
241
|
+
source_result = getter(shared_state, url, mirror, title, password)
|
|
242
|
+
label = key.upper()
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
# No source matched - check if URL is a known crypter directly
|
|
246
|
+
if source_result is None:
|
|
247
|
+
crypter, crypter_type = detect_crypter(url)
|
|
248
|
+
if crypter_type:
|
|
249
|
+
# For direct crypter URLs, we only know the crypter type, not the hoster inside
|
|
250
|
+
source_result = {"links": [[url, crypter]]}
|
|
251
|
+
label = crypter.upper()
|
|
252
|
+
|
|
253
|
+
if source_result is None:
|
|
254
|
+
info(f'Could not find matching source for "{title}" - "{url}"')
|
|
255
|
+
StatsHelper(shared_state).increment_failed_downloads()
|
|
256
|
+
return {"success": False, "package_id": package_id, "title": title}
|
|
257
|
+
|
|
258
|
+
result = process_links(shared_state, source_result, title, password, package_id, imdb_id, url, size_mb, label)
|
|
259
|
+
return {"package_id": package_id, **result}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def fail(title, package_id, shared_state, reason="Unknown error"):
|
|
263
|
+
"""Mark download as failed."""
|
|
320
264
|
try:
|
|
321
265
|
info(f"Reason for failure: {reason}")
|
|
322
266
|
StatsHelper(shared_state).increment_failed_downloads()
|
|
323
267
|
blob = json.dumps({"title": title, "error": reason})
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
info(f'Package "{title}" marked as failed!"')
|
|
327
|
-
return True
|
|
328
|
-
else:
|
|
329
|
-
info(f'Failed to mark package "{title}" as failed!"')
|
|
330
|
-
return False
|
|
268
|
+
shared_state.get_db("failed").store(package_id, json.dumps(blob))
|
|
269
|
+
info(f'Package "{title}" marked as failed!')
|
|
331
270
|
except Exception as e:
|
|
332
271
|
info(f'Error marking package "{package_id}" as failed: {e}')
|
|
333
|
-
|
|
272
|
+
return {"success": False, "title": title}
|
quasarr/downloads/sources/al.py
CHANGED
|
@@ -8,6 +8,7 @@ import re
|
|
|
8
8
|
import time
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
from typing import Optional, List
|
|
11
|
+
from urllib.parse import urlparse
|
|
11
12
|
|
|
12
13
|
from bs4 import BeautifulSoup
|
|
13
14
|
|
|
@@ -50,6 +51,17 @@ def roman_to_int(r: str) -> int:
|
|
|
50
51
|
return total
|
|
51
52
|
|
|
52
53
|
|
|
54
|
+
def derive_mirror(url):
|
|
55
|
+
try:
|
|
56
|
+
hostname = urlparse(url).netloc.lower()
|
|
57
|
+
if hostname.startswith('www.'):
|
|
58
|
+
hostname = hostname[4:]
|
|
59
|
+
parts = hostname.split('.')
|
|
60
|
+
return parts[-2] if len(parts) >= 2 else hostname
|
|
61
|
+
except:
|
|
62
|
+
return "unknown"
|
|
63
|
+
|
|
64
|
+
|
|
53
65
|
def extract_season_from_synonyms(soup):
|
|
54
66
|
"""
|
|
55
67
|
Returns the first season found as "Season N" in the Synonym(s) <td>, or None.
|
|
@@ -529,8 +541,19 @@ def extract_episode(title: str) -> int | None:
|
|
|
529
541
|
return None
|
|
530
542
|
|
|
531
543
|
|
|
532
|
-
def get_al_download_links(shared_state, url, mirror, title,
|
|
533
|
-
|
|
544
|
+
def get_al_download_links(shared_state, url, mirror, title, password):
|
|
545
|
+
"""
|
|
546
|
+
KEEP THE SIGNATURE EVEN IF SOME PARAMETERS ARE UNUSED!
|
|
547
|
+
|
|
548
|
+
AL source handler. Returns plain download links automatically by solving CAPTCHA.
|
|
549
|
+
|
|
550
|
+
Note: The 'password' parameter is intentionally repurposed as release_id
|
|
551
|
+
to ensure we download the correct release from the search results.
|
|
552
|
+
This is set by the search module, not a user password.
|
|
553
|
+
"""
|
|
554
|
+
|
|
555
|
+
release_id = password # password field carries release_id for AL
|
|
556
|
+
|
|
534
557
|
al = shared_state.values["config"]("Hostnames").get(hostname)
|
|
535
558
|
|
|
536
559
|
sess = retrieve_and_validate_session(shared_state)
|
|
@@ -690,8 +713,10 @@ def get_al_download_links(shared_state, url, mirror, title,
|
|
|
690
713
|
else:
|
|
691
714
|
StatsHelper(shared_state).increment_failed_decryptions_automatic()
|
|
692
715
|
|
|
716
|
+
links_with_mirrors = [[url, derive_mirror(url)] for url in links]
|
|
717
|
+
|
|
693
718
|
return {
|
|
694
|
-
"links":
|
|
719
|
+
"links": links_with_mirrors,
|
|
695
720
|
"password": f"www.{al}",
|
|
696
721
|
"title": title
|
|
697
722
|
}
|
quasarr/downloads/sources/by.py
CHANGED
|
@@ -13,7 +13,13 @@ from bs4 import BeautifulSoup
|
|
|
13
13
|
from quasarr.providers.log import info, debug
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def get_by_download_links(shared_state, url, mirror, title):
|
|
16
|
+
def get_by_download_links(shared_state, url, mirror, title, password):
|
|
17
|
+
"""
|
|
18
|
+
KEEP THE SIGNATURE EVEN IF SOME PARAMETERS ARE UNUSED!
|
|
19
|
+
|
|
20
|
+
BY source handler - fetches protected download links from BY iframes.
|
|
21
|
+
"""
|
|
22
|
+
|
|
17
23
|
by = shared_state.values["config"]("Hostnames").get("by")
|
|
18
24
|
headers = {
|
|
19
25
|
'User-Agent': shared_state.values["user_agent"],
|
|
@@ -103,4 +109,4 @@ def get_by_download_links(shared_state, url, mirror, title): # signature must a
|
|
|
103
109
|
except Exception as e:
|
|
104
110
|
info(f"Error loading BY download links: {e}")
|
|
105
111
|
|
|
106
|
-
return links
|
|
112
|
+
return {"links": links}
|