quasarr 2.1.5__py3-none-any.whl → 2.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of quasarr might be problematic. Click here for more details.

Files changed (60) hide show
  1. quasarr/__init__.py +38 -29
  2. quasarr/api/__init__.py +94 -23
  3. quasarr/api/captcha/__init__.py +0 -12
  4. quasarr/api/config/__init__.py +22 -11
  5. quasarr/api/packages/__init__.py +26 -34
  6. quasarr/api/statistics/__init__.py +15 -15
  7. quasarr/downloads/__init__.py +9 -1
  8. quasarr/downloads/packages/__init__.py +2 -2
  9. quasarr/downloads/sources/al.py +6 -0
  10. quasarr/downloads/sources/by.py +29 -20
  11. quasarr/downloads/sources/dd.py +9 -1
  12. quasarr/downloads/sources/dl.py +3 -0
  13. quasarr/downloads/sources/dt.py +16 -7
  14. quasarr/downloads/sources/dw.py +22 -17
  15. quasarr/downloads/sources/he.py +11 -6
  16. quasarr/downloads/sources/mb.py +9 -3
  17. quasarr/downloads/sources/nk.py +9 -3
  18. quasarr/downloads/sources/nx.py +21 -17
  19. quasarr/downloads/sources/sf.py +21 -13
  20. quasarr/downloads/sources/sl.py +10 -2
  21. quasarr/downloads/sources/wd.py +18 -9
  22. quasarr/downloads/sources/wx.py +7 -11
  23. quasarr/providers/auth.py +1 -1
  24. quasarr/providers/cloudflare.py +1 -1
  25. quasarr/providers/hostname_issues.py +63 -0
  26. quasarr/providers/html_images.py +1 -18
  27. quasarr/providers/html_templates.py +104 -12
  28. quasarr/providers/imdb_metadata.py +288 -75
  29. quasarr/providers/obfuscated.py +11 -11
  30. quasarr/providers/sessions/al.py +27 -11
  31. quasarr/providers/sessions/dd.py +12 -4
  32. quasarr/providers/sessions/dl.py +19 -11
  33. quasarr/providers/sessions/nx.py +12 -4
  34. quasarr/providers/version.py +1 -1
  35. quasarr/search/__init__.py +5 -0
  36. quasarr/search/sources/al.py +12 -1
  37. quasarr/search/sources/by.py +15 -4
  38. quasarr/search/sources/dd.py +22 -3
  39. quasarr/search/sources/dj.py +12 -1
  40. quasarr/search/sources/dl.py +12 -6
  41. quasarr/search/sources/dt.py +17 -4
  42. quasarr/search/sources/dw.py +15 -4
  43. quasarr/search/sources/fx.py +19 -6
  44. quasarr/search/sources/he.py +22 -3
  45. quasarr/search/sources/mb.py +15 -4
  46. quasarr/search/sources/nk.py +19 -3
  47. quasarr/search/sources/nx.py +15 -4
  48. quasarr/search/sources/sf.py +25 -8
  49. quasarr/search/sources/sj.py +14 -1
  50. quasarr/search/sources/sl.py +17 -2
  51. quasarr/search/sources/wd.py +15 -4
  52. quasarr/search/sources/wx.py +16 -18
  53. quasarr/storage/setup.py +150 -35
  54. {quasarr-2.1.5.dist-info → quasarr-2.3.0.dist-info}/METADATA +6 -3
  55. quasarr-2.3.0.dist-info/RECORD +82 -0
  56. {quasarr-2.1.5.dist-info → quasarr-2.3.0.dist-info}/WHEEL +1 -1
  57. quasarr-2.1.5.dist-info/RECORD +0 -81
  58. {quasarr-2.1.5.dist-info → quasarr-2.3.0.dist-info}/entry_points.txt +0 -0
  59. {quasarr-2.1.5.dist-info → quasarr-2.3.0.dist-info}/licenses/LICENSE +0 -0
  60. {quasarr-2.1.5.dist-info → quasarr-2.3.0.dist-info}/top_level.txt +0 -0
quasarr/__init__.py CHANGED
@@ -100,38 +100,12 @@ def run():
100
100
  shared_state.update("database", DataBase)
101
101
  supported_hostnames = extract_allowed_keys(Config._DEFAULT_CONFIG, 'Hostnames')
102
102
  shared_state.update("sites", [key.upper() for key in supported_hostnames])
103
- shared_state.update("user_agent", "") # will be set by FlareSolverr or fallback
103
+ # Set fallback user agent immediately so it's available while background check runs
104
+ shared_state.update("user_agent", FALLBACK_USER_AGENT)
104
105
  shared_state.update("helper_active", False)
105
106
 
106
107
  print(f'Config path: "{config_path}"')
107
108
 
108
- # Check if FlareSolverr was previously skipped
109
- skip_flaresolverr_db = DataBase("skip_flaresolverr")
110
- flaresolverr_skipped = skip_flaresolverr_db.retrieve("skipped")
111
-
112
- flaresolverr_url = Config('FlareSolverr').get('url')
113
- if not flaresolverr_url and not flaresolverr_skipped:
114
- flaresolverr_config(shared_state)
115
- # Re-check after config - user may have skipped
116
- flaresolverr_skipped = skip_flaresolverr_db.retrieve("skipped")
117
- flaresolverr_url = Config('FlareSolverr').get('url')
118
-
119
- if flaresolverr_skipped:
120
- info('FlareSolverr setup skipped by user preference')
121
- info('Some sites (AL) will not work without FlareSolverr. Configure it later in the web UI.')
122
- # Set fallback user agent
123
- shared_state.update("user_agent", FALLBACK_USER_AGENT)
124
- print(f'User Agent (fallback): "{FALLBACK_USER_AGENT}"')
125
- elif flaresolverr_url:
126
- print(f'Flaresolverr URL: "{flaresolverr_url}"')
127
- flaresolverr_check = check_flaresolverr(shared_state, flaresolverr_url)
128
- if flaresolverr_check:
129
- print(f'User Agent: "{shared_state.values["user_agent"]}"')
130
- else:
131
- info('FlareSolverr check failed - using fallback user agent')
132
- shared_state.update("user_agent", FALLBACK_USER_AGENT)
133
- print(f'User Agent (fallback): "{FALLBACK_USER_AGENT}"')
134
-
135
109
  print("\n===== Hostnames =====")
136
110
  try:
137
111
  if arguments.hostnames:
@@ -181,7 +155,7 @@ def run():
181
155
 
182
156
  # Check credentials for login-required hostnames
183
157
  skip_login_db = DataBase("skip_login")
184
- login_required_sites = ['al', 'dd', 'nx', 'dl']
158
+ login_required_sites = ['al', 'dd', 'dl', 'nx']
185
159
 
186
160
  for site in login_required_sites:
187
161
  hostname = Config('Hostnames').get(site)
@@ -239,6 +213,13 @@ def run():
239
213
  info(f'CAPTCHA-Solution required for {package_count} package{'s' if package_count > 1 else ''} at: '
240
214
  f'"{shared_state.values["external_address"]}/captcha"!')
241
215
 
216
+ flaresolverr = multiprocessing.Process(
217
+ target=flaresolverr_checker,
218
+ args=(shared_state_dict, shared_state_lock),
219
+ daemon=True
220
+ )
221
+ flaresolverr.start()
222
+
242
223
  jdownloader = multiprocessing.Process(
243
224
  target=jdownloader_connection,
244
225
  args=(shared_state_dict, shared_state_lock),
@@ -259,6 +240,34 @@ def run():
259
240
  sys.exit(0)
260
241
 
261
242
 
243
+ def flaresolverr_checker(shared_state_dict, shared_state_lock):
244
+ try:
245
+ shared_state.set_state(shared_state_dict, shared_state_lock)
246
+
247
+ # Check if FlareSolverr was previously skipped
248
+ skip_flaresolverr_db = DataBase("skip_flaresolverr")
249
+ flaresolverr_skipped = skip_flaresolverr_db.retrieve("skipped")
250
+
251
+ flaresolverr_url = Config('FlareSolverr').get('url')
252
+ if not flaresolverr_url and not flaresolverr_skipped:
253
+ flaresolverr_config(shared_state)
254
+ # Re-check after config - user may have skipped
255
+ flaresolverr_skipped = skip_flaresolverr_db.retrieve("skipped")
256
+ flaresolverr_url = Config('FlareSolverr').get('url')
257
+
258
+ if flaresolverr_skipped:
259
+ info('FlareSolverr setup skipped by user preference')
260
+ info('Some sites (AL) will not work without FlareSolverr. Configure it later in the web UI.')
261
+ elif flaresolverr_url:
262
+ print(f'Flaresolverr URL: "{flaresolverr_url}"')
263
+ flaresolverr_check = check_flaresolverr(shared_state, flaresolverr_url)
264
+ if flaresolverr_check:
265
+ print(f'Using same User-Agent as FlareSolverr: "{shared_state.values["user_agent"]}"')
266
+
267
+ except KeyboardInterrupt:
268
+ pass
269
+
270
+
262
271
  def update_checker(shared_state_dict, shared_state_lock):
263
272
  try:
264
273
  shared_state.set_state(shared_state_dict, shared_state_lock)
quasarr/api/__init__.py CHANGED
@@ -13,9 +13,11 @@ from quasarr.api.sponsors_helper import setup_sponsors_helper_routes
13
13
  from quasarr.api.statistics import setup_statistics
14
14
  from quasarr.providers import shared_state
15
15
  from quasarr.providers.auth import add_auth_routes, add_auth_hook, show_logout_link
16
- from quasarr.providers.html_templates import render_button, render_centered_html
16
+ from quasarr.providers.hostname_issues import get_all_hostname_issues
17
+ from quasarr.providers.html_templates import render_button, render_centered_html, render_success
17
18
  from quasarr.providers.web_server import Server
18
19
  from quasarr.storage.config import Config
20
+ from quasarr.storage.sqlite_database import DataBase
19
21
 
20
22
 
21
23
  def get_api(shared_state_dict, shared_state_lock):
@@ -49,6 +51,50 @@ def get_api(shared_state_dict, shared_state_lock):
49
51
  except:
50
52
  jd_connected = False
51
53
 
54
+ # Calculate hostname status
55
+ hostnames_config = Config('Hostnames')
56
+ skip_login_db = DataBase("skip_login")
57
+ hostname_issues = get_all_hostname_issues()
58
+ login_required_sites = ['al', 'dd', 'dl', 'nx']
59
+
60
+ working_count = 0
61
+ total_count = 0
62
+
63
+ for site_key in shared_state.values["sites"]:
64
+ shorthand = site_key.lower()
65
+ current_value = hostnames_config.get(shorthand)
66
+
67
+ # Skip unset hostnames and skipped logins
68
+ if not current_value:
69
+ continue
70
+ if shorthand in login_required_sites and skip_login_db.retrieve(shorthand):
71
+ continue
72
+
73
+ # This hostname counts toward total
74
+ total_count += 1
75
+
76
+ # Check if it's working (no issues)
77
+ if shorthand not in hostname_issues:
78
+ working_count += 1
79
+
80
+ # Determine status
81
+ if total_count == 0:
82
+ hostname_status_class = 'error'
83
+ hostname_status_emoji = '⚫️'
84
+ hostname_status_text = 'No hostnames configured'
85
+ elif working_count == 0:
86
+ hostname_status_class = 'error'
87
+ hostname_status_emoji = '🔴'
88
+ hostname_status_text = f'0/{total_count} hostnames operational'
89
+ elif working_count < total_count:
90
+ hostname_status_class = 'warning'
91
+ hostname_status_emoji = '🟡'
92
+ hostname_status_text = f'{working_count}/{total_count} hostnames operational'
93
+ else:
94
+ hostname_status_class = 'success'
95
+ hostname_status_emoji = '🟢'
96
+ hostname_status_text = f'{working_count}/{total_count} hostnames operational'
97
+
52
98
  # CAPTCHA banner
53
99
  captcha_hint = ""
54
100
  if protected:
@@ -66,11 +112,14 @@ def get_api(shared_state_dict, shared_state_lock):
66
112
  </div>
67
113
  """
68
114
 
69
- # JDownloader status
70
- jd_status = f"""
115
+ # Status bars
116
+ status_bars = f"""
71
117
  <div class="status-bar">
72
118
  <span class="status-pill {'success' if jd_connected else 'error'}">
73
- {'✅' if jd_connected else '❌'} JDownloader {'Connected' if jd_connected else 'Disconnected'}
119
+ {'✅' if jd_connected else '❌'} JDownloader {'connected' if jd_connected else 'disconnected'}
120
+ </span>
121
+ <span class="status-pill {hostname_status_class}">
122
+ {hostname_status_emoji} {hostname_status_text}
74
123
  </span>
75
124
  </div>
76
125
  """
@@ -78,7 +127,7 @@ def get_api(shared_state_dict, shared_state_lock):
78
127
  info = f"""
79
128
  <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
80
129
 
81
- {jd_status}
130
+ {status_bars}
82
131
  {captcha_hint}
83
132
 
84
133
  <div class="quick-actions">
@@ -124,7 +173,7 @@ def get_api(shared_state_dict, shared_state_lock):
124
173
  </div>
125
174
 
126
175
  <p style="margin-top: 15px;">
127
- {render_button("Regenerate API key", "secondary", {"onclick": "if(confirm('Regenerate API key?')) location.href='/regenerate-api-key';"})}
176
+ {render_button("Regenerate API key", "secondary", {"onclick": "confirmRegenerateApiKey()"})}
128
177
  </p>
129
178
  </div>
130
179
  </details>
@@ -144,14 +193,27 @@ def get_api(shared_state_dict, shared_state_lock):
144
193
  margin-bottom: 20px;
145
194
  flex-wrap: wrap;
146
195
  }}
147
- .status-item {{
196
+ .status-pill {{
148
197
  font-size: 0.9em;
149
- padding: 6px 12px;
150
- border-radius: 20px;
151
- background: var(--status-bg, #f5f5f5);
198
+ padding: 8px 16px;
199
+ border-radius: 0.5rem;
200
+ font-weight: 500;
201
+ }}
202
+ .status-pill.success {{
203
+ background: var(--status-success-bg, #e8f5e9);
204
+ color: var(--status-success-color, #2e7d32);
205
+ border: 1px solid var(--status-success-border, #a5d6a7);
206
+ }}
207
+ .status-pill.warning {{
208
+ background: var(--status-warning-bg, #fff3e0);
209
+ color: var(--status-warning-color, #f57c00);
210
+ border: 1px solid var(--status-warning-border, #ffb74d);
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);
152
216
  }}
153
- .status-ok {{ color: var(--status-ok, #2e7d32); }}
154
- .status-error {{ color: var(--status-error, #c62828); }}
155
217
 
156
218
  .alert {{
157
219
  display: flex;
@@ -294,9 +356,15 @@ def get_api(shared_state_dict, shared_state_lock):
294
356
  /* Dark mode */
295
357
  @media (prefers-color-scheme: dark) {{
296
358
  :root {{
297
- --status-bg: #2d3748;
298
- --status-ok: #68d391;
299
- --status-error: #fc8181;
359
+ --status-success-bg: #1b5e20;
360
+ --status-success-color: #a5d6a7;
361
+ --status-success-border: #2e7d32;
362
+ --status-warning-bg: #3d3520;
363
+ --status-warning-color: #ffb74d;
364
+ --status-warning-border: #d69e2e;
365
+ --status-error-bg: #b71c1c;
366
+ --status-error-color: #ef9a9a;
367
+ --status-error-border: #c62828;
300
368
  --alert-warning-bg: #3d3520;
301
369
  --alert-warning-border: #d69e2e;
302
370
  --card-bg: #2d3748;
@@ -356,7 +424,7 @@ def get_api(shared_state_dict, shared_state_lock):
356
424
  if (callback) callback();
357
425
  }}, 1500);
358
426
  }} catch (e) {{
359
- alert('Copy failed. Please copy manually.');
427
+ showModal('Error', 'Copy failed. Please copy manually.');
360
428
  }}
361
429
  document.body.removeChild(textarea);
362
430
  }}
@@ -389,6 +457,15 @@ def get_api(shared_state_dict, shared_state_lock):
389
457
  }};
390
458
  }}
391
459
  }})();
460
+
461
+ function confirmRegenerateApiKey() {{
462
+ showModal(
463
+ 'Regenerate API key?',
464
+ 'Are you sure you want to regenerate the API key? This will invalidate the current key.',
465
+ `<button class="btn-secondary" onclick="closeModal()">Cancel</button>
466
+ <button class="btn-primary" onclick="location.href='/regenerate-api-key'">Regenerate</button>`
467
+ );
468
+ }}
392
469
  </script>
393
470
  """
394
471
  # Add logout link for form auth
@@ -397,12 +474,6 @@ def get_api(shared_state_dict, shared_state_lock):
397
474
 
398
475
  @app.get('/regenerate-api-key')
399
476
  def regenerate_api_key():
400
- api_key = shared_state.generate_api_key()
401
- return f"""
402
- <script>
403
- alert('API key replaced with: {api_key}');
404
- window.location.href = '/';
405
- </script>
406
- """
477
+ return render_success(f'API key replaced!', 5)
407
478
 
408
479
  Server(app, listen='0.0.0.0', port=shared_state.values["port"]).serve_forever()
@@ -1211,18 +1211,6 @@ def setup_captcha_routes(app):
1211
1211
 
1212
1212
  content = render_centered_html(r'''
1213
1213
  <style>
1214
- @media (max-width: 600px) {
1215
- .package-selector,
1216
- #failed-attempts-warning {
1217
- margin-left: 0 !important;
1218
- margin-right: 0 !important;
1219
- padding-left: 8px !important;
1220
- padding-right: 8px !important;
1221
- border-radius: 0 !important;
1222
- border-left: none !important;
1223
- border-right: none !important;
1224
- }
1225
- }
1226
1214
  /* Fix captcha container to shrink-wrap iframe on desktop */
1227
1215
  .captcha-container {
1228
1216
  display: inline-block;
@@ -22,6 +22,12 @@ from quasarr.storage.sqlite_database import DataBase
22
22
 
23
23
 
24
24
  def setup_config(app, shared_state):
25
+ @app.get("/api/hostname-issues")
26
+ def get_hostname_issues_api():
27
+ response.content_type = 'application/json'
28
+ from quasarr.providers.hostname_issues import get_all_hostname_issues
29
+ return {"issues": get_all_hostname_issues()}
30
+
25
31
  @app.get('/hostnames')
26
32
  def hostnames_ui():
27
33
  message = """<p>
@@ -167,18 +173,23 @@ def setup_config(app, shared_state):
167
173
  return true;
168
174
  }}
169
175
  function confirmRestart() {{
170
- if (confirm('Restart Quasarr now?')) {{
171
- fetch('/api/restart', {{ method: 'POST' }})
172
- .then(response => response.json())
173
- .then(data => {{
174
- if (data.success) {{
175
- showRestartOverlay();
176
- }}
177
- }})
178
- .catch(error => {{
176
+ showModal('Restart Quasarr?', 'Are you sure you want to restart Quasarr now?',
177
+ `<button class="btn-secondary" onclick="closeModal()">Cancel</button>
178
+ <button class="btn-primary" onclick="performRestart()">Restart</button>`
179
+ );
180
+ }}
181
+ function performRestart() {{
182
+ closeModal();
183
+ fetch('/api/restart', {{ method: 'POST' }})
184
+ .then(response => response.json())
185
+ .then(data => {{
186
+ if (data.success) {{
179
187
  showRestartOverlay();
180
- }});
181
- }}
188
+ }}
189
+ }})
190
+ .catch(error => {{
191
+ showRestartOverlay();
192
+ }});
182
193
  }}
183
194
  function showRestartOverlay() {{
184
195
  document.body.innerHTML = `
@@ -309,21 +309,6 @@ def setup_packages_routes(app):
309
309
 
310
310
  <p>{back_btn}</p>
311
311
 
312
- <!-- Delete confirmation modal -->
313
- <div class="modal" id="deleteModal">
314
- <div class="modal-content">
315
- <h3>🗑️ Delete Package?</h3>
316
- <p class="modal-package-name" id="modalPackageName"></p>
317
- <div class="modal-warning">
318
- <strong>⛔ Warning:</strong> This will permanently delete the package AND all associated files from disk. This action cannot be undone!
319
- </div>
320
- <div class="modal-buttons">
321
- <button class="btn-secondary" onclick="closeModal()">Cancel</button>
322
- <button class="btn-danger" id="confirmDeleteBtn">🗑️ Delete Package & Files</button>
323
- </div>
324
- </div>
325
- </div>
326
-
327
312
  <style>
328
313
  .packages-container {{ max-width: 600px; margin: 0 auto; }}
329
314
  .section {{ margin: 20px 0; }}
@@ -412,14 +397,6 @@ def setup_packages_routes(app):
412
397
  border: 1px solid var(--error-border, #f1aeb5);
413
398
  }}
414
399
 
415
- /* Modal */
416
- .modal {{ display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center; }}
417
- .modal.show {{ display: flex; }}
418
- .modal-content {{ background: var(--modal-bg, white); padding: 25px; border-radius: 12px; max-width: 400px; width: 90%; text-align: center; }}
419
- .modal-content h3 {{ margin: 0 0 15px 0; color: var(--error-msg-color, #c62828); }}
420
- .modal-package-name {{ font-weight: 500; word-break: break-word; padding: 10px; background: var(--code-bg, #f5f5f5); border-radius: 6px; margin: 10px 0; }}
421
- .modal-warning {{ background: var(--error-msg-bg, #ffebee); color: var(--error-msg-color, #c62828); padding: 12px; border-radius: 6px; margin: 15px 0; font-size: 0.9em; text-align: left; }}
422
- .modal-buttons {{ display: flex; gap: 10px; justify-content: center; margin-top: 20px; }}
423
400
  .btn-danger {{ background: var(--btn-danger-bg, #dc3545); color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-weight: 500; }}
424
401
  .btn-danger:hover {{ opacity: 0.9; }}
425
402
 
@@ -544,20 +521,35 @@ def setup_packages_routes(app):
544
521
  let deletePackageId = null;
545
522
  function confirmDelete(packageId, packageName) {{
546
523
  deletePackageId = packageId;
547
- document.getElementById('modalPackageName').textContent = packageName;
548
- document.getElementById('deleteModal').classList.add('show');
549
- refreshPaused = true; // Pause background refresh while modal is open
524
+
525
+ const content = `
526
+ <p class="modal-package-name" style="font-weight: 500; word-break: break-word; padding: 10px; background: var(--code-bg, #f5f5f5); border-radius: 6px; margin: 10px 0;">${{packageName}}</p>
527
+ <div class="modal-warning" style="background: var(--error-msg-bg, #ffebee); color: var(--error-msg-color, #c62828); padding: 12px; border-radius: 6px; margin: 15px 0; font-size: 0.9em; text-align: left;">
528
+ <strong>⛔ Warning:</strong> This will permanently delete the package AND all associated files from disk. This action cannot be undone!
529
+ </div>
530
+ `;
531
+
532
+ const buttons = `
533
+ <button class="btn-secondary" onclick="closeModal()">Cancel</button>
534
+ <button class="btn-danger" onclick="performDelete()">🗑️ Delete Package & Files</button>
535
+ `;
536
+
537
+ showModal('🗑️ Delete Package?', content, buttons);
538
+ refreshPaused = true;
550
539
  }}
551
- function closeModal() {{
552
- document.getElementById('deleteModal').classList.remove('show');
553
- deletePackageId = null;
554
- refreshPaused = false; // Resume background refresh
540
+
541
+ function performDelete() {{
542
+ if (deletePackageId) {{
543
+ location.href = '/packages/delete/' + encodeURIComponent(deletePackageId);
544
+ }}
555
545
  }}
556
- document.getElementById('confirmDeleteBtn').onclick = function() {{
557
- if (deletePackageId) location.href = '/packages/delete/' + encodeURIComponent(deletePackageId);
546
+
547
+ // Hook into modal closing to resume refresh
548
+ const baseCloseModal = window.closeModal;
549
+ window.closeModal = function() {{
550
+ if (baseCloseModal) baseCloseModal();
551
+ refreshPaused = false;
558
552
  }};
559
- document.getElementById('deleteModal').onclick = function(e) {{ if (e.target === this) closeModal(); }};
560
- document.addEventListener('keydown', function(e) {{ if (e.key === 'Escape') closeModal(); }});
561
553
  </script>
562
554
  '''
563
555
 
@@ -21,33 +21,33 @@ def setup_statistics(app, shared_state):
21
21
  <div class="stats-grid compact">
22
22
  <div class="stat-card highlight">
23
23
  <h3>📦 Total Download Attempts</h3>
24
- <div class="stat-value">{stats['total_download_attempts']}</div>
25
- <div class="stat-subtitle">Success Rate: {stats['download_success_rate']:.1f}%</div>
24
+ <div class="stat-value">{stats['total_download_attempts']:,}</div>
25
+ <div class="stat-subtitle">Success Rate: {stats['download_success_rate']:,.1f}%</div>
26
26
  </div>
27
27
  <div class="stat-card highlight">
28
28
  <h3>🔐 Total CAPTCHA Decryptions</h3>
29
- <div class="stat-value">{stats['total_captcha_decryptions']}</div>
30
- <div class="stat-subtitle">Success Rate: {stats['decryption_success_rate']:.1f}%</div>
29
+ <div class="stat-value">{stats['total_captcha_decryptions']:,}</div>
30
+ <div class="stat-subtitle">Success Rate: {stats['decryption_success_rate']:,.1f}%</div>
31
31
  </div>
32
32
  </div>
33
33
 
34
- <h3>📥 Downloads</h3>
34
+ <h3>⬇️ Downloads</h3>
35
35
  <div class="stats-grid compact">
36
36
  <div class="stat-card">
37
37
  <h3>✅ Packages Downloaded</h3>
38
- <div class="stat-value">{stats['packages_downloaded']}</div>
38
+ <div class="stat-value">{stats['packages_downloaded']:,}</div>
39
39
  </div>
40
40
  <div class="stat-card">
41
41
  <h3>⚙️ Links Processed</h3>
42
- <div class="stat-value">{stats['links_processed']}</div>
42
+ <div class="stat-value">{stats['links_processed']:,}</div>
43
43
  </div>
44
44
  <div class="stat-card">
45
45
  <h3>❌ Failed Downloads</h3>
46
- <div class="stat-value">{stats['failed_downloads']}</div>
46
+ <div class="stat-value">{stats['failed_downloads']:,}</div>
47
47
  </div>
48
48
  <div class="stat-card">
49
49
  <h3>🔗 Average Links per Package</h3>
50
- <div class="stat-value">{stats['average_links_per_package']:.1f}</div>
50
+ <div class="stat-value">{stats['average_links_per_package']:,.1f}</div>
51
51
  </div>
52
52
  </div>
53
53
 
@@ -55,21 +55,21 @@ def setup_statistics(app, shared_state):
55
55
  <div class="stats-grid compact">
56
56
  <div class="stat-card">
57
57
  <h3>🤖 Automatic Decryptions</h3>
58
- <div class="stat-value">{stats['captcha_decryptions_automatic']}</div>
59
- <div class="stat-subtitle">Success Rate: {stats['automatic_decryption_success_rate']:.1f}%</div>
58
+ <div class="stat-value">{stats['captcha_decryptions_automatic']:,}</div>
59
+ <div class="stat-subtitle">Success Rate: {stats['automatic_decryption_success_rate']:,.1f}%</div>
60
60
  </div>
61
61
  <div class="stat-card">
62
62
  <h3>👤 Manual Decryptions</h3>
63
- <div class="stat-value">{stats['captcha_decryptions_manual']}</div>
64
- <div class="stat-subtitle">Success Rate: {stats['manual_decryption_success_rate']:.1f}%</div>
63
+ <div class="stat-value">{stats['captcha_decryptions_manual']:,}</div>
64
+ <div class="stat-subtitle">Success Rate: {stats['manual_decryption_success_rate']:,.1f}%</div>
65
65
  </div>
66
66
  <div class="stat-card">
67
67
  <h3>⛔ Failed Auto Decryptions</h3>
68
- <div class="stat-value">{stats['failed_decryptions_automatic']}</div>
68
+ <div class="stat-value">{stats['failed_decryptions_automatic']:,}</div>
69
69
  </div>
70
70
  <div class="stat-card">
71
71
  <h3>🚫 Failed Manual Decryptions</h3>
72
- <div class="stat-value">{stats['failed_decryptions_manual']}</div>
72
+ <div class="stat-value">{stats['failed_decryptions_manual']:,}</div>
73
73
  </div>
74
74
  </div>
75
75
  </div>
@@ -24,6 +24,7 @@ from quasarr.downloads.sources.sj import get_sj_download_links
24
24
  from quasarr.downloads.sources.sl import get_sl_download_links
25
25
  from quasarr.downloads.sources.wd import get_wd_download_links
26
26
  from quasarr.downloads.sources.wx import get_wx_download_links
27
+ from quasarr.providers.hostname_issues import mark_hostname_issue, clear_hostname_issue
27
28
  from quasarr.providers.log import info
28
29
  from quasarr.providers.notifications import send_discord_message
29
30
  from quasarr.providers.statistics import StatsHelper
@@ -345,7 +346,14 @@ def download(shared_state, request_from, title, url, mirror, size_mb, password,
345
346
  for key, getter in SOURCE_GETTERS.items():
346
347
  hostname = config.get(key)
347
348
  if hostname and hostname.lower() in url.lower():
348
- source_result = getter(shared_state, url, mirror, title, password)
349
+ try:
350
+ source_result = getter(shared_state, url, mirror, title, password)
351
+ if source_result and source_result.get("links"):
352
+ clear_hostname_issue(key)
353
+ except Exception as e:
354
+ info(f"Error getting download links from {key.upper()}: {e}")
355
+ mark_hostname_issue(key, "download", str(e))
356
+ source_result = None
349
357
  label = key.upper()
350
358
  detected_source_key = key
351
359
  break
@@ -639,12 +639,12 @@ def get_packages(shared_state, _cache=None):
639
639
  debug(
640
640
  f" -> {item['percentage']}% | {item['timeleft']} | {size_str} | {item['cat']} {archive_indicator}")
641
641
  for item in downloads['history']:
642
- status_icon = "" if item['status'] == 'Completed' else "✗"
642
+ status_icon = "" if item['status'] == 'Completed' else "✗"
643
643
  is_archive = item.get('is_archive')
644
644
  extraction_ok = item.get('extraction_ok', False)
645
645
  # Only show archive status if we know it's an archive
646
646
  if is_archive:
647
- archive_status = f"[ARCHIVE: {'EXTRACTED ' if extraction_ok else 'NOT EXTRACTED'}]"
647
+ archive_status = f"[ARCHIVE: {'EXTRACTED ' if extraction_ok else 'NOT EXTRACTED'}]"
648
648
  else:
649
649
  archive_status = ""
650
650
  # Format size
@@ -13,6 +13,7 @@ from urllib.parse import urlparse
13
13
  from bs4 import BeautifulSoup
14
14
 
15
15
  from quasarr.downloads.linkcrypters.al import decrypt_content, solve_captcha
16
+ from quasarr.providers.hostname_issues import mark_hostname_issue
16
17
  from quasarr.providers.log import info, debug
17
18
  from quasarr.providers.sessions.al import retrieve_and_validate_session, invalidate_session, unwrap_flaresolverr_body, \
18
19
  fetch_via_flaresolverr, fetch_via_requests_session
@@ -525,6 +526,7 @@ def check_release(shared_state, details_html, release_id, title, episode_in_titl
525
526
  return guessed_title, release_id
526
527
  except Exception as e:
527
528
  info(f"Error guessing release title from release: {e}")
529
+ mark_hostname_issue(hostname, "download", str(e) if "e" in dir() else "Download error")
528
530
 
529
531
  return title, release_id
530
532
 
@@ -566,6 +568,7 @@ def get_al_download_links(shared_state, url, mirror, title, password):
566
568
  sess = retrieve_and_validate_session(shared_state)
567
569
  if not sess:
568
570
  info(f"Could not retrieve valid session for {al}")
571
+ mark_hostname_issue(hostname, "download", "Session error")
569
572
  return {}
570
573
 
571
574
  details_page = fetch_via_flaresolverr(shared_state, "GET", url, timeout=30)
@@ -693,6 +696,7 @@ def get_al_download_links(shared_state, url, mirror, title, password):
693
696
 
694
697
  except RuntimeError as e:
695
698
  info(f"Error solving CAPTCHA: {e}")
699
+ mark_hostname_issue(hostname, "download", str(e) if "e" in dir() else "Download error")
696
700
  else:
697
701
  info(f"CAPTCHA solver returned invalid solution, retrying... (attempt {tries})")
698
702
 
@@ -710,8 +714,10 @@ def get_al_download_links(shared_state, url, mirror, title, password):
710
714
  debug(f"Decrypted URLs: {links}")
711
715
  except Exception as e:
712
716
  info(f"Error during decryption: {e}")
717
+ mark_hostname_issue(hostname, "download", str(e) if "e" in dir() else "Download error")
713
718
  except Exception as e:
714
719
  info(f"Error loading AL download: {e}")
720
+ mark_hostname_issue(hostname, "download", str(e) if "e" in dir() else "Download error")
715
721
  invalidate_session(shared_state)
716
722
 
717
723
  success = bool(links)