quasarr 2.1.4__tar.gz → 2.2.0__tar.gz

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 (89) hide show
  1. {quasarr-2.1.4 → quasarr-2.2.0}/PKG-INFO +6 -3
  2. {quasarr-2.1.4 → quasarr-2.2.0}/README.md +5 -2
  3. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/api/__init__.py +94 -23
  4. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/api/captcha/__init__.py +0 -12
  5. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/api/config/__init__.py +22 -11
  6. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/api/packages/__init__.py +32 -43
  7. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/api/statistics/__init__.py +15 -15
  8. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/__init__.py +9 -1
  9. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/packages/__init__.py +6 -6
  10. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/al.py +6 -0
  11. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/by.py +29 -20
  12. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/dd.py +9 -1
  13. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/dl.py +3 -0
  14. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/dt.py +16 -7
  15. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/dw.py +22 -17
  16. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/he.py +11 -6
  17. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/mb.py +9 -3
  18. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/nk.py +9 -3
  19. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/nx.py +21 -17
  20. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/sf.py +21 -13
  21. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/sl.py +10 -2
  22. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/wd.py +18 -9
  23. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/wx.py +7 -11
  24. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/auth.py +1 -1
  25. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/cloudflare.py +1 -1
  26. quasarr-2.2.0/quasarr/providers/hostname_issues.py +63 -0
  27. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/html_images.py +1 -18
  28. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/html_templates.py +104 -12
  29. quasarr-2.2.0/quasarr/providers/obfuscated.py +121 -0
  30. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/sessions/al.py +27 -11
  31. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/sessions/dd.py +12 -4
  32. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/sessions/dl.py +19 -11
  33. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/sessions/nx.py +12 -4
  34. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/version.py +1 -1
  35. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/al.py +12 -1
  36. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/by.py +15 -4
  37. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/dd.py +22 -3
  38. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/dj.py +12 -1
  39. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/dl.py +12 -6
  40. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/dt.py +17 -4
  41. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/dw.py +15 -4
  42. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/fx.py +19 -6
  43. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/he.py +15 -2
  44. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/mb.py +15 -4
  45. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/nk.py +15 -2
  46. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/nx.py +15 -4
  47. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/sf.py +25 -8
  48. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/sj.py +14 -1
  49. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/sl.py +17 -2
  50. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/wd.py +15 -4
  51. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/wx.py +16 -18
  52. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/storage/setup.py +150 -35
  53. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr.egg-info/PKG-INFO +6 -3
  54. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr.egg-info/SOURCES.txt +1 -0
  55. quasarr-2.1.4/quasarr/providers/obfuscated.py +0 -121
  56. {quasarr-2.1.4 → quasarr-2.2.0}/LICENSE +0 -0
  57. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/__init__.py +0 -0
  58. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/api/arr/__init__.py +0 -0
  59. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/api/sponsors_helper/__init__.py +0 -0
  60. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/linkcrypters/__init__.py +0 -0
  61. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/linkcrypters/al.py +0 -0
  62. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/linkcrypters/filecrypt.py +0 -0
  63. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/linkcrypters/hide.py +0 -0
  64. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/__init__.py +0 -0
  65. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/dj.py +0 -0
  66. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/downloads/sources/sj.py +0 -0
  67. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/__init__.py +0 -0
  68. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/imdb_metadata.py +0 -0
  69. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/jd_cache.py +0 -0
  70. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/log.py +0 -0
  71. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/myjd_api.py +0 -0
  72. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/notifications.py +0 -0
  73. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/sessions/__init__.py +0 -0
  74. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/shared_state.py +0 -0
  75. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/statistics.py +0 -0
  76. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/utils.py +0 -0
  77. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/providers/web_server.py +0 -0
  78. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/__init__.py +0 -0
  79. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/search/sources/__init__.py +0 -0
  80. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/storage/__init__.py +0 -0
  81. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/storage/config.py +0 -0
  82. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr/storage/sqlite_database.py +0 -0
  83. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr.egg-info/dependency_links.txt +0 -0
  84. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr.egg-info/entry_points.txt +0 -0
  85. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr.egg-info/not-zip-safe +0 -0
  86. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr.egg-info/requires.txt +0 -0
  87. {quasarr-2.1.4 → quasarr-2.2.0}/quasarr.egg-info/top_level.txt +0 -0
  88. {quasarr-2.1.4 → quasarr-2.2.0}/setup.cfg +0 -0
  89. {quasarr-2.1.4 → quasarr-2.2.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quasarr
3
- Version: 2.1.4
3
+ Version: 2.2.0
4
4
  Summary: Quasarr connects JDownloader with Radarr, Sonarr and LazyLibrarian. It also decrypts links protected by CAPTCHAs.
5
5
  Home-page: https://github.com/rix1337/Quasarr
6
6
  Author: rix1337
@@ -25,7 +25,7 @@ Dynamic: license-file
25
25
  Dynamic: requires-dist
26
26
  Dynamic: summary
27
27
 
28
- #
28
+ #
29
29
 
30
30
  <img src="https://raw.githubusercontent.com/rix1337/Quasarr/main/Quasarr.png" data-canonical-src="https://raw.githubusercontent.com/rix1337/Quasarr/main/Quasarr.png" width="64" height="64" />
31
31
 
@@ -125,12 +125,15 @@ Add Quasarr as both a **Newznab Indexer** and **SABnzbd Download Client** using
125
125
  <details>
126
126
  <summary>Restrict results to a specific mirror</summary>
127
127
 
128
- Append the mirror name to your Newznab URL:
128
+ 1. In the Newznab Settings for Quasarr, enable advanced settings.
129
+ 2. Append the desired mirror name to the `API Path` field.
129
130
 
130
131
  ```
131
132
  /api/dropbox/
132
133
  ```
133
134
 
135
+ Using the `URL` field will not work!
136
+
134
137
  Only releases with `dropbox` in a link will be returned. If the mirror isn't available, the release will fail.
135
138
 
136
139
  </details>
@@ -1,4 +1,4 @@
1
- #
1
+ #
2
2
 
3
3
  <img src="https://raw.githubusercontent.com/rix1337/Quasarr/main/Quasarr.png" data-canonical-src="https://raw.githubusercontent.com/rix1337/Quasarr/main/Quasarr.png" width="64" height="64" />
4
4
 
@@ -98,12 +98,15 @@ Add Quasarr as both a **Newznab Indexer** and **SABnzbd Download Client** using
98
98
  <details>
99
99
  <summary>Restrict results to a specific mirror</summary>
100
100
 
101
- Append the mirror name to your Newznab URL:
101
+ 1. In the Newznab Settings for Quasarr, enable advanced settings.
102
+ 2. Append the desired mirror name to the `API Path` field.
102
103
 
103
104
  ```
104
105
  /api/dropbox/
105
106
  ```
106
107
 
108
+ Using the `URL` field will not work!
109
+
107
110
  Only releases with `dropbox` in a link will be returned. If the mirror isn't available, the release will fail.
108
111
 
109
112
  </details>
@@ -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 = `
@@ -14,19 +14,15 @@ def _get_category_emoji(cat):
14
14
 
15
15
  def _format_size(mb=None, bytes_val=None):
16
16
  if bytes_val is not None:
17
- # Handle bytes directly for better precision with small files
18
17
  if bytes_val == 0:
19
18
  return "? MB"
20
- if bytes_val < 1024 * 1024: # Less than 1 MB
21
- kb = bytes_val / 1024
22
- if kb < 1:
23
- return f"{bytes_val} B"
24
- return f"{kb:.0f} KB"
19
+ if bytes_val < 1024:
20
+ return f"{bytes_val} B"
21
+ if bytes_val < 1024 * 1024:
22
+ return f"{bytes_val // 1024} KB"
25
23
  mb = bytes_val / (1024 * 1024)
26
24
  if mb is None or mb == 0:
27
25
  return "? MB"
28
- if mb < 1:
29
- return f"{mb * 1024:.0f} KB"
30
26
  if mb < 1024:
31
27
  return f"{mb:.0f} MB"
32
28
  return f"{mb / 1024:.1f} GB"
@@ -41,6 +37,7 @@ def _render_queue_item(item):
41
37
  percentage = item.get('percentage', 0)
42
38
  timeleft = item.get('timeleft', '??:??:??')
43
39
  bytes_val = item.get('bytes', 0)
40
+ mb = item.get('mb', 0)
44
41
  cat = item.get('cat', 'not_quasarr')
45
42
  is_archive = item.get('is_archive', False)
46
43
  nzo_id = item.get('nzo_id', '')
@@ -63,7 +60,7 @@ def _render_queue_item(item):
63
60
 
64
61
  archive_badge = '<span class="badge archive">📁</span>' if is_archive else ''
65
62
  cat_emoji = _get_category_emoji(cat)
66
- size_str = _format_size(bytes_val=bytes_val)
63
+ size_str = _format_size(bytes_val=bytes_val) if bytes_val else _format_size(mb=mb)
67
64
 
68
65
  # Progress bar - show "waiting..." for 0%
69
66
  if percentage == 0:
@@ -312,21 +309,6 @@ def setup_packages_routes(app):
312
309
 
313
310
  <p>{back_btn}</p>
314
311
 
315
- <!-- Delete confirmation modal -->
316
- <div class="modal" id="deleteModal">
317
- <div class="modal-content">
318
- <h3>🗑️ Delete Package?</h3>
319
- <p class="modal-package-name" id="modalPackageName"></p>
320
- <div class="modal-warning">
321
- <strong>⛔ Warning:</strong> This will permanently delete the package AND all associated files from disk. This action cannot be undone!
322
- </div>
323
- <div class="modal-buttons">
324
- <button class="btn-secondary" onclick="closeModal()">Cancel</button>
325
- <button class="btn-danger" id="confirmDeleteBtn">🗑️ Delete Package & Files</button>
326
- </div>
327
- </div>
328
- </div>
329
-
330
312
  <style>
331
313
  .packages-container {{ max-width: 600px; margin: 0 auto; }}
332
314
  .section {{ margin: 20px 0; }}
@@ -415,14 +397,6 @@ def setup_packages_routes(app):
415
397
  border: 1px solid var(--error-border, #f1aeb5);
416
398
  }}
417
399
 
418
- /* Modal */
419
- .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; }}
420
- .modal.show {{ display: flex; }}
421
- .modal-content {{ background: var(--modal-bg, white); padding: 25px; border-radius: 12px; max-width: 400px; width: 90%; text-align: center; }}
422
- .modal-content h3 {{ margin: 0 0 15px 0; color: var(--error-msg-color, #c62828); }}
423
- .modal-package-name {{ font-weight: 500; word-break: break-word; padding: 10px; background: var(--code-bg, #f5f5f5); border-radius: 6px; margin: 10px 0; }}
424
- .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; }}
425
- .modal-buttons {{ display: flex; gap: 10px; justify-content: center; margin-top: 20px; }}
426
400
  .btn-danger {{ background: var(--btn-danger-bg, #dc3545); color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-weight: 500; }}
427
401
  .btn-danger:hover {{ opacity: 0.9; }}
428
402
 
@@ -547,20 +521,35 @@ def setup_packages_routes(app):
547
521
  let deletePackageId = null;
548
522
  function confirmDelete(packageId, packageName) {{
549
523
  deletePackageId = packageId;
550
- document.getElementById('modalPackageName').textContent = packageName;
551
- document.getElementById('deleteModal').classList.add('show');
552
- 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;
553
539
  }}
554
- function closeModal() {{
555
- document.getElementById('deleteModal').classList.remove('show');
556
- deletePackageId = null;
557
- refreshPaused = false; // Resume background refresh
540
+
541
+ function performDelete() {{
542
+ if (deletePackageId) {{
543
+ location.href = '/packages/delete/' + encodeURIComponent(deletePackageId);
544
+ }}
558
545
  }}
559
- document.getElementById('confirmDeleteBtn').onclick = function() {{
560
- 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;
561
552
  }};
562
- document.getElementById('deleteModal').onclick = function(e) {{ if (e.target === this) closeModal(); }};
563
- document.addEventListener('keydown', function(e) {{ if (e.key === 'Escape') closeModal(); }});
564
553
  </script>
565
554
  '''
566
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
@@ -468,8 +468,10 @@ def get_packages(shared_state, _cache=None):
468
468
  details = package["details"]
469
469
  name = f"[Linkgrabber] {details.get('name', 'unknown')}"
470
470
  try:
471
- mb = mb_left = int(details.get("bytesTotal", 0)) / (1024 * 1024)
471
+ bytes_total = int(details.get("bytesTotal", 0))
472
+ mb = mb_left = bytes_total / (1024 * 1024)
472
473
  except (KeyError, TypeError, ValueError):
474
+ bytes_total = 0
473
475
  mb = mb_left = 0
474
476
  package_id = package["comment"]
475
477
  category = get_category_from_package_id(package_id)
@@ -505,6 +507,7 @@ def get_packages(shared_state, _cache=None):
505
507
  details = package["details"]
506
508
  name = f"[CAPTCHA not solved!] {details.get('title', 'unknown')}"
507
509
  mb = mb_left = details.get("size_mb") or 0
510
+ bytes_total = 0 # Protected packages don't have reliable byte data
508
511
  package_id = package.get("package_id")
509
512
  category = get_category_from_package_id(package_id)
510
513
  package_type = "protected"
@@ -519,9 +522,6 @@ def get_packages(shared_state, _cache=None):
519
522
  except (ZeroDivisionError, ValueError, TypeError):
520
523
  percentage = 0
521
524
 
522
- # Keep mb/mbleft as integers for API compatibility, add bytes for UI display
523
- bytes_total = int(mb * 1024 * 1024) if mb else 0
524
-
525
525
  downloads["queue"].append({
526
526
  "index": queue_index,
527
527
  "nzo_id": effective_id,
@@ -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)