quasarr 2.5.0__py3-none-any.whl → 2.6.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/__init__.py CHANGED
@@ -386,48 +386,43 @@ def jdownloader_connection(shared_state_dict, shared_state_lock):
386
386
  try:
387
387
  shared_state.set_state(shared_state_dict, shared_state_lock)
388
388
 
389
- shared_state.set_device_from_config()
389
+ while True:
390
+ shared_state.set_device_from_config()
390
391
 
391
- connection_established = (
392
- shared_state.get_device() and shared_state.get_device().name
393
- )
394
- if not connection_established:
395
- i = 0
396
- while i < 10:
397
- i += 1
392
+ device = shared_state.get_device()
393
+
394
+ try:
398
395
  info(
399
- f'Connection {i} to JDownloader failed. Device name: "{shared_state.values["device"]}"'
396
+ f'Connection to JDownloader successful. Device name: "{device.name}"'
400
397
  )
401
- time.sleep(60)
402
- shared_state.set_device_from_config()
403
- connection_established = (
404
- shared_state.get_device() and shared_state.get_device().name
405
- )
406
- if connection_established:
407
- break
398
+ except Exception as e:
399
+ info(f"Error connecting to JDownloader: {e}! Stopping Quasarr...")
400
+ sys.exit(1)
408
401
 
409
- try:
410
- info(
411
- f'Connection to JDownloader successful. Device name: "{shared_state.get_device().name}"'
412
- )
413
- except Exception as e:
414
- info(f"Error connecting to JDownloader: {e}! Stopping Quasarr!")
415
- sys.exit(1)
402
+ try:
403
+ shared_state.set_device_settings()
404
+ except Exception as e:
405
+ print(f"Error checking settings: {e}")
416
406
 
417
- try:
418
- shared_state.set_device_settings()
419
- except Exception as e:
420
- print(f"Error checking settings: {e}")
407
+ try:
408
+ shared_state.update_jdownloader()
409
+ except Exception as e:
410
+ print(f"Error updating JDownloader: {e}")
421
411
 
422
- try:
423
- shared_state.update_jdownloader()
424
- except Exception as e:
425
- print(f"Error updating JDownloader: {e}")
412
+ try:
413
+ shared_state.start_downloads()
414
+ except Exception as e:
415
+ print(f"Error starting downloads: {e}")
426
416
 
427
- try:
428
- shared_state.start_downloads()
429
- except Exception as e:
430
- print(f"Error starting downloads: {e}")
417
+ while True:
418
+ time.sleep(300)
419
+ device_state = shared_state.check_device(
420
+ shared_state.values.get("device")
421
+ )
422
+ if not device_state:
423
+ info("Lost connection to JDownloader. Reconnecting...")
424
+ shared_state.update("device", False)
425
+ break
431
426
 
432
427
  except KeyboardInterrupt:
433
428
  pass
quasarr/api/__init__.py CHANGED
@@ -8,6 +8,7 @@ import quasarr.providers.html_images as images
8
8
  from quasarr.api.arr import setup_arr_routes
9
9
  from quasarr.api.captcha import setup_captcha_routes
10
10
  from quasarr.api.config import setup_config
11
+ from quasarr.api.jdownloader import get_jdownloader_modal_script, get_jdownloader_status
11
12
  from quasarr.api.packages import setup_packages_routes
12
13
  from quasarr.api.sponsors_helper import setup_sponsors_helper_routes
13
14
  from quasarr.api.statistics import setup_statistics
@@ -49,12 +50,9 @@ def get_api(shared_state_dict, shared_state_lock):
49
50
  protected = shared_state.get_db("protected").retrieve_all_titles()
50
51
  api_key = Config("API").get("key")
51
52
 
52
- # Get quick status summary
53
- try:
54
- device = shared_state.values.get("device")
55
- jd_connected = device is not None and device is not False
56
- except:
57
- jd_connected = False
53
+ # Get JDownloader status and modal script
54
+ jd_status = get_jdownloader_status(shared_state)
55
+ jd_modal_script = get_jdownloader_modal_script()
58
56
 
59
57
  # Calculate hostname status
60
58
  hostnames_config = Config("Hostnames")
@@ -126,10 +124,14 @@ def get_api(shared_state_dict, shared_state_lock):
126
124
  # Status bars
127
125
  status_bars = f"""
128
126
  <div class="status-bar">
129
- <span class="status-pill {"success" if jd_connected else "error"}">
130
- {"" if jd_connected else "❌"} JDownloader {"connected" if jd_connected else "disconnected"}
127
+ <span class="status-pill {jd_status['status_class']}"
128
+ onclick="openJDownloaderModal()"
129
+ title="Click to configure JDownloader">
130
+ {jd_status['status_text']}
131
131
  </span>
132
- <span class="status-pill {hostname_status_class}">
132
+ <span class="status-pill {hostname_status_class}"
133
+ onclick="location.href='/hostnames'"
134
+ title="Click to configure Hostnames">
133
135
  {hostname_status_emoji} {hostname_status_text}
134
136
  </span>
135
137
  </div>
@@ -209,6 +211,11 @@ def get_api(shared_state_dict, shared_state_lock):
209
211
  padding: 8px 16px;
210
212
  border-radius: 0.5rem;
211
213
  font-weight: 500;
214
+ transition: transform 0.1s ease;
215
+ cursor: pointer;
216
+ }}
217
+ .status-pill:hover {{
218
+ transform: scale(1.05);
212
219
  }}
213
220
  .status-pill.success {{
214
221
  background: var(--status-success-bg, #e8f5e9);
@@ -478,6 +485,7 @@ def get_api(shared_state_dict, shared_state_lock):
478
485
  );
479
486
  }}
480
487
  </script>
488
+ {jd_modal_script}
481
489
  """
482
490
  # Add logout link for form auth
483
491
  logout_html = '<a href="/logout">Logout</a>' if show_logout_link() else ""
@@ -56,6 +56,16 @@ def parse_payload(payload_str):
56
56
 
57
57
 
58
58
  def setup_arr_routes(app):
59
+ def check_user_agent():
60
+ user_agent = request.headers.get("User-Agent") or ""
61
+ if not any(
62
+ tool in user_agent.lower() for tool in ["radarr", "sonarr", "lazylibrarian"]
63
+ ):
64
+ msg = f"Unsupported User-Agent: {user_agent}. Quasarr as a compatibility layer must be called by Radarr, Sonarr or LazyLibrarian directly."
65
+ info(msg)
66
+ abort(406, msg)
67
+ return user_agent
68
+
59
69
  @app.get("/download/")
60
70
  def fake_nzb_file():
61
71
  payload = request.query.payload
@@ -75,6 +85,7 @@ def setup_arr_routes(app):
75
85
  @app.post("/api")
76
86
  @require_api_key
77
87
  def download_fake_nzb_file():
88
+ request_from = check_user_agent()
78
89
  downloads = request.files.getall("name")
79
90
  nzo_ids = [] # naming structure for package IDs expected in newznab
80
91
 
@@ -97,7 +108,6 @@ def setup_arr_routes(app):
97
108
  source_key = root.find(".//file").attrib.get("source_key") or None
98
109
 
99
110
  info(f'Attempting download for "{title}"')
100
- request_from = request.headers.get("User-Agent")
101
111
  downloaded = download(
102
112
  shared_state,
103
113
  request_from,
@@ -128,6 +138,8 @@ def setup_arr_routes(app):
128
138
  @app.get("/api/<mirror>")
129
139
  @require_api_key
130
140
  def quasarr_api(mirror=None):
141
+ request_from = check_user_agent()
142
+
131
143
  api_type = (
132
144
  "arr_download_client"
133
145
  if request.query.mode
@@ -198,7 +210,6 @@ def setup_arr_routes(app):
198
210
 
199
211
  nzo_ids = []
200
212
  info(f'Attempting download for "{parsed_payload["title"]}"')
201
- request_from = "lazylibrarian"
202
213
 
203
214
  downloaded = download(
204
215
  shared_state,
@@ -267,8 +278,6 @@ def setup_arr_routes(app):
267
278
  )
268
279
 
269
280
  mode = request.query.t
270
- request_from = request.headers.get("User-Agent")
271
-
272
281
  if mode == "caps":
273
282
  info(f"Providing indexer capability information to {request_from}")
274
283
  return """<?xml version="1.0" encoding="UTF-8"?>
@@ -352,10 +361,10 @@ def setup_arr_routes(app):
352
361
  mirror=mirror,
353
362
  )
354
363
  else:
355
- info(
364
+ # sonarr expects this but we will not support non-imdbid searches
365
+ debug(
356
366
  f"Ignoring search request from {request_from} - only imdbid searches are supported"
357
367
  )
358
- releases = [] # sonarr expects this but we will not support non-imdbid searches
359
368
 
360
369
  items = ""
361
370
  for release in releases:
@@ -11,6 +11,7 @@ import requests
11
11
  from bottle import HTTPResponse, redirect, request, response
12
12
 
13
13
  import quasarr.providers.html_images as images
14
+ from quasarr.api.jdownloader import get_jdownloader_disconnected_page
14
15
  from quasarr.downloads.linkcrypters.filecrypt import DLC, get_filecrypt_links
15
16
  from quasarr.downloads.packages import delete_package
16
17
  from quasarr.providers import obfuscated, shared_state
@@ -46,15 +47,7 @@ def setup_captcha_routes(app):
46
47
  except KeyError:
47
48
  device = None
48
49
  if not device:
49
- return render_centered_html(f'''<h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
50
- <div class="status-bar">
51
- <span class="status-pill error">
52
- ❌ JDownloader disconnected
53
- </span>
54
- </div>
55
- <p>
56
- {render_button("Back", "secondary", {"onclick": "location.href='/'"})}
57
- </p>''')
50
+ return get_jdownloader_disconnected_page(shared_state)
58
51
 
59
52
  protected = shared_state.get_db("protected").retrieve_all_titles()
60
53
  if not protected:
@@ -7,7 +7,7 @@ import signal
7
7
  import threading
8
8
  import time
9
9
 
10
- from bottle import response
10
+ from bottle import request, response
11
11
 
12
12
  from quasarr.providers.html_templates import render_button, render_form
13
13
  from quasarr.providers.log import info
@@ -22,6 +22,8 @@ from quasarr.storage.setup import (
22
22
  import_hostnames_from_url,
23
23
  save_flaresolverr_url,
24
24
  save_hostnames,
25
+ save_jdownloader_settings,
26
+ verify_jdownloader_credentials,
25
27
  )
26
28
  from quasarr.storage.sqlite_database import DataBase
27
29
 
@@ -217,3 +219,11 @@ def setup_config(app, shared_state):
217
219
 
218
220
  threading.Thread(target=delayed_exit, daemon=True).start()
219
221
  return {"success": True, "message": "Restarting..."}
222
+
223
+ @app.post("/api/jdownloader/verify")
224
+ def verify_jdownloader_api():
225
+ return verify_jdownloader_credentials(shared_state)
226
+
227
+ @app.post("/api/jdownloader/save")
228
+ def save_jdownloader_api():
229
+ return save_jdownloader_settings(shared_state, is_setup=False)
@@ -0,0 +1,232 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ from quasarr.providers.html_templates import render_button
6
+ from quasarr.storage.config import Config
7
+
8
+
9
+ def get_jdownloader_status(shared_state):
10
+ """Get JDownloader connection status and device name."""
11
+ try:
12
+ device = shared_state.values.get("device")
13
+ jd_connected = device is not None and device is not False
14
+ except:
15
+ jd_connected = False
16
+
17
+ jd_config = Config("JDownloader")
18
+ jd_device = jd_config.get("device") or ""
19
+
20
+ dev_name = jd_device if jd_device else "JDownloader"
21
+ dev_name_safe = dev_name.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
22
+
23
+ if jd_connected:
24
+ status_text = f"✅ {dev_name_safe} connected"
25
+ status_class = "success"
26
+ elif jd_device:
27
+ status_text = f"❌ {dev_name_safe} disconnected"
28
+ status_class = "error"
29
+ else:
30
+ status_text = "❌ JDownloader disconnected"
31
+ status_class = "error"
32
+
33
+ return {
34
+ "connected": jd_connected,
35
+ "device_name": jd_device,
36
+ "status_text": status_text,
37
+ "status_class": status_class
38
+ }
39
+
40
+
41
+ def get_jdownloader_modal_script():
42
+ """Return the JavaScript for the JDownloader configuration modal."""
43
+ jd_config = Config("JDownloader")
44
+ jd_user = jd_config.get("user") or ""
45
+ jd_pass = jd_config.get("password") or ""
46
+ jd_device = jd_config.get("device") or ""
47
+
48
+ jd_user_js = jd_user.replace("\\", "\\\\").replace("'", "\\'")
49
+ jd_pass_js = jd_pass.replace("\\", "\\\\").replace("'", "\\'")
50
+ jd_device_js = jd_device.replace("\\", "\\\\").replace("'", "\\'")
51
+
52
+ return f"""
53
+ <script>
54
+ function openJDownloaderModal() {{
55
+ var currentUser = '{jd_user_js}';
56
+ var currentPass = '{jd_pass_js}';
57
+ var currentDevice = '{jd_device_js}';
58
+
59
+ var content = `
60
+ <div id="jd-step-1">
61
+ <input type="hidden" id="jd-current-device" value="${{currentDevice}}">
62
+ <p><strong>JDownloader must be running and connected to My JDownloader!</strong></p>
63
+ <div style="margin-bottom: 1rem;">
64
+ <label style="display:block; font-size: 0.875rem;">E-Mail</label>
65
+ <input type="text" id="jd-user" value="${{currentUser}}" placeholder="user@example.org" style="width: 100%; padding: 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem;">
66
+ </div>
67
+ <div style="margin-bottom: 1rem;">
68
+ <label style="display:block; font-size: 0.875rem;">Password</label>
69
+ <input type="password" id="jd-pass" value="${{currentPass}}" placeholder="Password" style="width: 100%; padding: 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem;">
70
+ </div>
71
+ <div id="jd-status" style="margin-bottom: 0.5rem; font-size: 0.875rem; min-height: 1.25em;"></div>
72
+ <button class="btn-primary" onclick="verifyJDCredentials()">Verify Credentials</button>
73
+ </div>
74
+
75
+ <div id="jd-step-2" style="display:none;">
76
+ <p>Select your JDownloader instance:</p>
77
+ <div style="margin-bottom: 1rem;">
78
+ <select id="jd-device" style="width: 100%; padding: 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem;"></select>
79
+ </div>
80
+ <div id="jd-save-status" style="margin-bottom: 0.5rem; font-size: 0.875rem; min-height: 1.25em;"></div>
81
+ <button class="btn-primary" onclick="saveJDSettings()">Save</button>
82
+ </div>
83
+ `;
84
+
85
+ showModal('Configure JDownloader', content, '<button class="btn-secondary" onclick="closeModal()">Close</button>');
86
+ }}
87
+
88
+ function verifyJDCredentials() {{
89
+ var user = document.getElementById('jd-user').value;
90
+ var pass = document.getElementById('jd-pass').value;
91
+ var statusDiv = document.getElementById('jd-status');
92
+
93
+ statusDiv.innerHTML = 'Verifying...';
94
+ statusDiv.style.color = 'var(--secondary, #6c757d)';
95
+
96
+ fetch('/api/jdownloader/verify', {{
97
+ method: 'POST',
98
+ headers: {{ 'Content-Type': 'application/json' }},
99
+ body: JSON.stringify({{ user: user, pass: pass }})
100
+ }})
101
+ .then(response => response.json())
102
+ .then(data => {{
103
+ if (data.success) {{
104
+ var select = document.getElementById('jd-device');
105
+ select.innerHTML = '';
106
+ var currentDevice = document.getElementById('jd-current-device').value;
107
+ data.devices.forEach(device => {{
108
+ var opt = document.createElement('option');
109
+ opt.value = device;
110
+ opt.innerHTML = device;
111
+ if (device === currentDevice) {{
112
+ opt.selected = true;
113
+ }}
114
+ select.appendChild(opt);
115
+ }});
116
+
117
+ document.getElementById('jd-step-1').style.display = 'none';
118
+ document.getElementById('jd-step-2').style.display = 'block';
119
+ }} else {{
120
+ statusDiv.innerHTML = '❌ ' + (data.message || 'Verification failed');
121
+ statusDiv.style.color = '#dc3545';
122
+ }}
123
+ }})
124
+ .catch(error => {{
125
+ statusDiv.innerHTML = '❌ Error: ' + error.message;
126
+ statusDiv.style.color = '#dc3545';
127
+ }});
128
+ }}
129
+
130
+ function saveJDSettings() {{
131
+ var user = document.getElementById('jd-user').value;
132
+ var pass = document.getElementById('jd-pass').value;
133
+ var device = document.getElementById('jd-device').value;
134
+ var statusDiv = document.getElementById('jd-save-status');
135
+
136
+ statusDiv.innerHTML = 'Saving...';
137
+ statusDiv.style.color = 'var(--secondary, #6c757d)';
138
+
139
+ fetch('/api/jdownloader/save', {{
140
+ method: 'POST',
141
+ headers: {{ 'Content-Type': 'application/json' }},
142
+ body: JSON.stringify({{ user: user, pass: pass, device: device }})
143
+ }})
144
+ .then(response => response.json())
145
+ .then(data => {{
146
+ if (data.success) {{
147
+ statusDiv.innerHTML = '✅ ' + data.message;
148
+ statusDiv.style.color = '#198754';
149
+ setTimeout(function() {{
150
+ window.location.reload();
151
+ }}, 1000);
152
+ }} else {{
153
+ statusDiv.innerHTML = '❌ ' + data.message;
154
+ statusDiv.style.color = '#dc3545';
155
+ }}
156
+ }})
157
+ .catch(error => {{
158
+ statusDiv.innerHTML = '❌ Error: ' + error.message;
159
+ statusDiv.style.color = '#dc3545';
160
+ }});
161
+ }}
162
+ </script>
163
+ """
164
+
165
+
166
+ def get_jdownloader_status_pill(shared_state):
167
+ """Return the HTML for the JDownloader status pill."""
168
+ status = get_jdownloader_status(shared_state)
169
+
170
+ return f"""
171
+ <span class="status-pill {status['status_class']}"
172
+ onclick="openJDownloaderModal()"
173
+ style="cursor: pointer;"
174
+ title="Click to configure JDownloader">
175
+ {status['status_text']}
176
+ </span>
177
+ """
178
+
179
+
180
+ def get_jdownloader_disconnected_page(shared_state, back_url="/"):
181
+ """Return a full error page when JDownloader is disconnected."""
182
+ import quasarr.providers.html_images as images
183
+ from quasarr.providers.html_templates import render_centered_html
184
+
185
+ status_pill = get_jdownloader_status_pill(shared_state)
186
+ modal_script = get_jdownloader_modal_script()
187
+
188
+ back_btn = render_button("Back", "secondary", {"onclick": f"location.href='{back_url}'"})
189
+
190
+ content = f'''
191
+ <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
192
+ <div class="status-bar">
193
+ {status_pill}
194
+ </div>
195
+ <p>{back_btn}</p>
196
+ <style>
197
+ .status-pill {{
198
+ font-size: 0.9em;
199
+ padding: 8px 16px;
200
+ border-radius: 0.5rem;
201
+ font-weight: 500;
202
+ transition: transform 0.1s ease;
203
+ }}
204
+ .status-pill:hover {{
205
+ transform: scale(1.05);
206
+ }}
207
+ .status-pill.success {{
208
+ background: var(--status-success-bg, #e8f5e9);
209
+ color: var(--status-success-color, #2e7d32);
210
+ border: 1px solid var(--status-success-border, #a5d6a7);
211
+ }}
212
+ .status-pill.error {{
213
+ background: var(--status-error-bg, #ffebee);
214
+ color: var(--status-error-color, #c62828);
215
+ border: 1px solid var(--status-error-border, #ef9a9a);
216
+ }}
217
+ /* Dark mode */
218
+ @media (prefers-color-scheme: dark) {{
219
+ :root {{
220
+ --status-success-bg: #1c4532;
221
+ --status-success-color: #68d391;
222
+ --status-success-border: #276749;
223
+ --status-error-bg: #3d2d2d;
224
+ --status-error-color: #fc8181;
225
+ --status-error-border: #c53030;
226
+ }}
227
+ }}
228
+ </style>
229
+ {modal_script}
230
+ '''
231
+
232
+ return render_centered_html(content)
@@ -3,6 +3,7 @@
3
3
  # Project by https://github.com/rix1337
4
4
 
5
5
  import quasarr.providers.html_images as images
6
+ from quasarr.api.jdownloader import get_jdownloader_disconnected_page
6
7
  from quasarr.downloads.packages import delete_package, get_packages
7
8
  from quasarr.providers import shared_state
8
9
  from quasarr.providers.html_templates import render_button, render_centered_html
@@ -349,18 +350,7 @@ def setup_packages_routes(app):
349
350
  device = None
350
351
 
351
352
  if not device:
352
- back_btn = render_button(
353
- "Back", "secondary", {"onclick": "location.href='/'"}
354
- )
355
- return render_centered_html(f'''
356
- <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
357
- <div class="status-bar">
358
- <span class="status-pill error">
359
- ❌ JDownloader disconnected
360
- </span>
361
- </div>
362
- <p>{back_btn}</p>
363
- ''')
353
+ return get_jdownloader_disconnected_page(shared_state)
364
354
 
365
355
  # Check for delete status from redirect
366
356
  deleted = request.query.get("deleted")
@@ -16,6 +16,7 @@ from quasarr.downloads.sources.dl import get_dl_download_links
16
16
  from quasarr.downloads.sources.dt import get_dt_download_links
17
17
  from quasarr.downloads.sources.dw import get_dw_download_links
18
18
  from quasarr.downloads.sources.he import get_he_download_links
19
+ from quasarr.downloads.sources.hs import get_hs_download_links
19
20
  from quasarr.downloads.sources.mb import get_mb_download_links
20
21
  from quasarr.downloads.sources.nk import get_nk_download_links
21
22
  from quasarr.downloads.sources.nx import get_nx_download_links
@@ -57,6 +58,7 @@ SOURCE_GETTERS = {
57
58
  "dt": get_dt_download_links,
58
59
  "dw": get_dw_download_links,
59
60
  "he": get_he_download_links,
61
+ "hs": get_hs_download_links,
60
62
  "mb": get_mb_download_links,
61
63
  "nk": get_nk_download_links,
62
64
  "nx": get_nx_download_links,
@@ -0,0 +1,131 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import re
6
+
7
+ import requests
8
+ from bs4 import BeautifulSoup
9
+
10
+ from quasarr.providers.hostname_issues import mark_hostname_issue
11
+ from quasarr.providers.log import debug, info
12
+
13
+ hostname = "hs"
14
+
15
+ FILECRYPT_REGEX = re.compile(
16
+ r"https?://(?:www\.)?filecrypt\.(?:cc|co|to)/[Cc]ontainer/[A-Za-z0-9]+\.html", re.I
17
+ )
18
+ AFFILIATE_REGEX = re.compile(r"af\.php\?v=([a-zA-Z0-9]+)")
19
+
20
+
21
+ def normalize_mirror_name(name):
22
+ """Normalize mirror names - ddlto/ddl.to -> ddownload"""
23
+ if not name:
24
+ return None
25
+ name_lower = name.lower().strip()
26
+ if "ddlto" in name_lower or "ddl.to" in name_lower or "ddownload" in name_lower:
27
+ return "ddownload"
28
+ if "rapidgator" in name_lower:
29
+ return "rapidgator"
30
+ if "katfile" in name_lower:
31
+ return "katfile"
32
+ return name_lower
33
+
34
+
35
+ def get_hs_download_links(shared_state, url, mirror, title, password):
36
+ """
37
+ KEEP THE SIGNATURE EVEN IF SOME PARAMETERS ARE UNUSED!
38
+
39
+ HS handler - extracts filecrypt download links from release pages.
40
+ The site structure pairs affiliate links (indicating mirror) with filecrypt links.
41
+ """
42
+ headers = {"User-Agent": shared_state.values["user_agent"]}
43
+
44
+ mirror_lower = mirror.lower() if mirror else None
45
+ links = []
46
+
47
+ try:
48
+ r = requests.get(url, headers=headers, timeout=30)
49
+ r.raise_for_status()
50
+ soup = BeautifulSoup(r.text, "html.parser")
51
+
52
+ # Find all links in the page
53
+ all_links = soup.find_all("a", href=True)
54
+
55
+ # Strategy: Build mirror detection from multiple sources
56
+ # 1. Text labels on filecrypt links (most reliable)
57
+ # 2. Affiliate links preceding filecrypt links (fallback)
58
+
59
+ # First pass: detect mirrors from link text labels
60
+ text_labeled_mirrors = {} # url -> mirror_name
61
+ for link in all_links:
62
+ href = link.get("href", "")
63
+ if FILECRYPT_REGEX.match(href):
64
+ link_text = link.get_text(strip=True).lower()
65
+ detected_mirror = None
66
+ if "ddownload" in link_text or "ddl" in link_text:
67
+ detected_mirror = "ddownload"
68
+ elif "rapidgator" in link_text:
69
+ detected_mirror = "rapidgator"
70
+ elif "katfile" in link_text:
71
+ detected_mirror = "katfile"
72
+ if detected_mirror:
73
+ text_labeled_mirrors[href] = detected_mirror
74
+
75
+ # Second pass: track affiliate links for fallback
76
+ affiliate_mirrors = {} # url -> mirror_name (from preceding affiliate)
77
+ current_mirror = None
78
+ for link in all_links:
79
+ href = link.get("href", "")
80
+
81
+ # Check if this is an affiliate link (indicates mirror name)
82
+ aff_match = AFFILIATE_REGEX.search(href)
83
+ if aff_match:
84
+ current_mirror = normalize_mirror_name(aff_match.group(1))
85
+ continue
86
+
87
+ # Check if this is a filecrypt link
88
+ if FILECRYPT_REGEX.match(href):
89
+ if current_mirror and href not in affiliate_mirrors:
90
+ affiliate_mirrors[href] = current_mirror
91
+ current_mirror = None # Reset for next pair
92
+
93
+ # Combine results: text labels take priority over affiliate tracking
94
+ filecrypt_mirrors = []
95
+ seen_urls = set()
96
+ for link in all_links:
97
+ href = link.get("href", "")
98
+ if FILECRYPT_REGEX.match(href) and href not in seen_urls:
99
+ seen_urls.add(href)
100
+ # Priority: text label > affiliate > "filecrypt"
101
+ mirror_name = (
102
+ text_labeled_mirrors.get(href)
103
+ or affiliate_mirrors.get(href)
104
+ or "filecrypt"
105
+ )
106
+ filecrypt_mirrors.append((href, mirror_name))
107
+
108
+ # Filter by requested mirror and deduplicate
109
+ seen_urls = set()
110
+ for fc_url, fc_mirror in filecrypt_mirrors:
111
+ if fc_url in seen_urls:
112
+ continue
113
+ seen_urls.add(fc_url)
114
+
115
+ # Filter by requested mirror if specified
116
+ if mirror_lower:
117
+ if mirror_lower != fc_mirror:
118
+ debug(f"Skipping {fc_mirror} link (requested mirror: {mirror})")
119
+ continue
120
+
121
+ # Store [url, mirror_name] - mirror_name is used by CAPTCHA page for filtering
122
+ links.append([fc_url, fc_mirror])
123
+
124
+ if not links:
125
+ debug(f"No filecrypt links found on {url} for {title}")
126
+
127
+ except Exception as e:
128
+ info(f"Error loading HS download links: {e}")
129
+ mark_hostname_issue(hostname, "download", str(e))
130
+
131
+ return {"links": links}