quasarr 1.22.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.

@@ -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, resolve_sf_redirect
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
- def handle_unprotected(shared_state, title, password, package_id, imdb_id, url,
33
- mirror=None, size_mb=None, links=None, func=None, label=""):
34
- if func:
35
- links = func(shared_state, url, mirror, title)
36
-
37
- if links:
38
- info(f"Decrypted {len(links)} download links for {title}")
39
- send_discord_message(shared_state, title=title, case="unprotected", imdb_id=imdb_id, source=url)
40
- added = shared_state.download_package(links, title, password, package_id)
41
- if not added:
42
- fail(title, package_id, shared_state,
43
- reason=f'Failed to add {len(links)} links for "{title}" to linkgrabber')
44
- return {"success": False, "title": title}
45
- else:
46
- fail(title, package_id, shared_state,
47
- reason=f'Offline / no links found for "{title}" on {label} - "{url}"')
48
- return {"success": False, "title": title}
49
-
50
- StatsHelper(shared_state).increment_package_with_links(links)
51
- return {"success": True, "title": title}
52
-
53
-
54
- def handle_protected(shared_state, title, password, package_id, imdb_id, url,
55
- mirror=None, size_mb=None, func=None, label=""):
56
- links = func(shared_state, url, mirror, title)
57
- if links:
58
- valid_links = [pair for pair in links if "/404.html" not in pair[0]]
59
-
60
- # If none left, IP was banned
61
- if not valid_links:
62
- fail(
63
- title,
64
- package_id,
65
- shared_state,
66
- reason=f'IP was banned during download of "{title}" on {label} - "{url}"'
67
- )
68
- return {"success": False, "title": title}
69
- links = valid_links
70
-
71
- info(f'CAPTCHA-Solution required for "{title}" at: "{shared_state.values['external_address']}/captcha"')
72
- send_discord_message(shared_state, title=title, case="captcha", imdb_id=imdb_id, source=url)
73
- blob = json.dumps({"title": title, "links": links, "size_mb": size_mb, "password": password})
74
- shared_state.values["database"]("protected").update_store(package_id, blob)
75
- else:
76
- fail(title, package_id, shared_state,
77
- reason=f'No protected links found for "{title}" on {label} - "{url}"')
78
- return {"success": False, "title": title}
79
- return {"success": True, "title": title}
80
-
81
-
82
- def handle_hide(shared_state, title, password, package_id, imdb_id, url, links, label):
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
- Attempt to decrypt hide.cx links and handle the result.
85
- Returns a dict with 'handled' (bool) and 'result' (response dict or None).
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
- decrypted = decrypt_links_if_hide(shared_state, links)
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
- decrypt_result = handle_hide(
127
- shared_state, title, password, package_id, imdb_id, url, links, 'BY'
128
- )
98
+ for link in links:
99
+ url = link[0]
129
100
 
130
- if decrypt_result["handled"]:
131
- return decrypt_result["result"]
101
+ if is_junkies_link(url, shared_state):
102
+ classified['protected'].append(link)
103
+ continue
132
104
 
133
- return handle_protected(
134
- shared_state, title, password, package_id, imdb_id, url,
135
- mirror=mirror,
136
- size_mb=size_mb,
137
- func=lambda ss, u, m, t: links,
138
- label='BY'
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
- decrypt_result = handle_hide(
212
- shared_state, title, password, package_id, imdb_id, url, links, 'WD'
213
- )
117
+ # =============================================================================
118
+ # LINK PROCESSING
119
+ # =============================================================================
214
120
 
215
- if decrypt_result["handled"]:
216
- return decrypt_result["result"]
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
- return handle_protected(
219
- shared_state, title, password, package_id, imdb_id, url,
220
- mirror=mirror,
221
- size_mb=size_mb,
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 handle_wx(shared_state, title, password, package_id, imdb_id, url, mirror, size_mb):
228
- links = get_wx_download_links(shared_state, url, mirror, title)
229
- if not links:
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
- decrypt_result = handle_hide(
235
- shared_state, title, password, package_id, imdb_id, url, links, 'WX'
236
- )
136
+ if result.get("status") != "success":
137
+ return {"success": False, "reason": "Auto-decrypt failed"}
237
138
 
238
- if decrypt_result["handled"]:
239
- return decrypt_result["result"]
139
+ decrypted_urls = result.get("results", [])
140
+ if not decrypted_urls:
141
+ return {"success": False, "reason": "No links decrypted"}
240
142
 
241
- return handle_protected(
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 is not None and imdb_id.lower() == "none":
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
- flags = {
265
- 'AL': config.get("al"),
266
- 'BY': config.get("by"),
267
- 'DD': config.get("dd"),
268
- 'DJ': config.get("dj"),
269
- 'DL': config.get("dl"),
270
- 'DT': config.get("dt"),
271
- 'DW': config.get("dw"),
272
- 'HE': config.get("he"),
273
- 'MB': config.get("mb"),
274
- 'NK': config.get("nk"),
275
- 'NX': config.get("nx"),
276
- 'SF': config.get("sf"),
277
- 'SJ': config.get("sj"),
278
- 'SL': config.get("sl"),
279
- 'WD': config.get("wd"),
280
- 'WX': config.get("wx")
281
- }
282
-
283
- handlers = [
284
- (flags['AL'], handle_al),
285
- (flags['BY'], handle_by),
286
- (flags['DD'], lambda *a: handle_unprotected(*a, func=get_dd_download_links, label='DD')),
287
- (flags['DJ'], lambda *a: handle_protected(*a, func=get_dj_download_links, label='DJ')),
288
- (flags['DL'], handle_dl),
289
- (flags['DT'], lambda *a: handle_unprotected(*a, func=get_dt_download_links, label='DT')),
290
- (flags['DW'], lambda *a: handle_protected(*a, func=get_dw_download_links, label='DW')),
291
- (flags['HE'], lambda *a: handle_unprotected(*a, func=get_he_download_links, label='HE')),
292
- (flags['MB'], lambda *a: handle_protected(*a, func=get_mb_download_links, label='MB')),
293
- (flags['NK'], lambda *a: handle_protected(*a, func=get_nk_download_links, label='NK')),
294
- (flags['NX'], lambda *a: handle_unprotected(*a, func=get_nx_download_links, label='NX')),
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
- stored = shared_state.get_db("failed").store(package_id, json.dumps(blob))
325
- if stored:
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
- return False
272
+ return {"success": False, "title": title}
@@ -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
- release_id): # signature cant align with other download link functions!
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": links,
719
+ "links": links_with_mirrors,
695
720
  "password": f"www.{al}",
696
721
  "title": title
697
722
  }
@@ -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): # signature must align with other download link functions!
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}