quasarr 2.1.0__py3-none-any.whl → 2.1.2__py3-none-any.whl

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

Potentially problematic release.


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

quasarr/api/__init__.py CHANGED
@@ -25,7 +25,7 @@ def get_api(shared_state_dict, shared_state_lock):
25
25
 
26
26
  # Auth: routes must come first, then hook
27
27
  add_auth_routes(app)
28
- add_auth_hook(app, whitelist_prefixes=['/api', '/api/' '/sponsors_helper/', '/download/'])
28
+ add_auth_hook(app, whitelist_prefixes=['/api', '/api/', '/sponsors_helper/', '/download/'])
29
29
 
30
30
  setup_arr_routes(app)
31
31
  setup_captcha_routes(app)
@@ -6,7 +6,6 @@ import traceback
6
6
  import xml.sax.saxutils as sax_utils
7
7
  from base64 import urlsafe_b64decode
8
8
  from datetime import datetime
9
- from functools import wraps
10
9
  from urllib.parse import urlparse, parse_qs
11
10
  from xml.etree import ElementTree
12
11
 
@@ -15,23 +14,10 @@ from bottle import abort, request
15
14
  from quasarr.downloads import download
16
15
  from quasarr.downloads.packages import get_packages, delete_package
17
16
  from quasarr.providers import shared_state
17
+ from quasarr.providers.auth import require_api_key
18
18
  from quasarr.providers.log import info, debug
19
19
  from quasarr.providers.version import get_version
20
20
  from quasarr.search import get_search_results
21
- from quasarr.storage.config import Config
22
-
23
-
24
- def require_api_key(func):
25
- @wraps(func)
26
- def decorated(*args, **kwargs):
27
- api_key = Config('API').get('key')
28
- if not request.query.apikey:
29
- return abort(401, "Missing API key")
30
- if request.query.apikey != api_key:
31
- return abort(403, "Invalid API key")
32
- return func(*args, **kwargs)
33
-
34
- return decorated
35
21
 
36
22
 
37
23
  def parse_payload(payload_str):
@@ -8,6 +8,220 @@ from quasarr.providers import shared_state
8
8
  from quasarr.providers.html_templates import render_button, render_centered_html
9
9
 
10
10
 
11
+ def _get_category_emoji(cat):
12
+ return {'movies': '🎬', 'tv': '📺', 'docs': '📄', 'not_quasarr': '📦'}.get(cat, '📦')
13
+
14
+
15
+ def _format_size(mb=None, bytes_val=None):
16
+ if bytes_val is not None:
17
+ mb = bytes_val / (1024 * 1024)
18
+ if mb is None or mb == 0:
19
+ return "? MB"
20
+ if mb < 1024:
21
+ return f"{mb:.0f} MB"
22
+ return f"{mb / 1024:.1f} GB"
23
+
24
+
25
+ def _escape_js(s):
26
+ return s.replace('\\', '\\\\').replace("'", "\\'").replace('"', '\\"').replace('\n', '\\n')
27
+
28
+
29
+ def _render_queue_item(item):
30
+ filename = item.get('filename', 'Unknown')
31
+ percentage = item.get('percentage', 0)
32
+ timeleft = item.get('timeleft', '??:??:??')
33
+ mb = item.get('mb', 0)
34
+ cat = item.get('cat', 'not_quasarr')
35
+ is_archive = item.get('is_archive', False)
36
+ nzo_id = item.get('nzo_id', '')
37
+
38
+ is_captcha = '[CAPTCHA' in filename
39
+ if is_captcha:
40
+ status_emoji = '🔒'
41
+ elif '[Extracting]' in filename:
42
+ status_emoji = '📦'
43
+ elif '[Paused]' in filename:
44
+ status_emoji = '⏸️'
45
+ elif '[Linkgrabber]' in filename:
46
+ status_emoji = '🔗'
47
+ else:
48
+ status_emoji = '⬇️'
49
+
50
+ display_name = filename
51
+ for prefix in ['[Downloading] ', '[Extracting] ', '[Paused] ', '[Linkgrabber] ', '[CAPTCHA not solved!] ']:
52
+ display_name = display_name.replace(prefix, '')
53
+
54
+ archive_badge = '<span class="badge archive">📁 ARCHIVE</span>' if is_archive else ''
55
+ cat_emoji = _get_category_emoji(cat)
56
+ size_str = _format_size(mb=mb)
57
+
58
+ # Progress bar - show "waiting..." for 0%
59
+ if percentage == 0:
60
+ progress_html = '<span class="progress-waiting"></span>'
61
+ else:
62
+ progress_html = f'<div class="progress-track"><div class="progress-fill" style="width: {percentage}%"></div></div>'
63
+
64
+ # Action buttons - CAPTCHA left, delete right
65
+ if is_captcha and nzo_id:
66
+ actions = f'''
67
+ <div class="package-actions">
68
+ <button class="btn-small primary" onclick="location.href='/captcha?package_id={nzo_id}'">🔓 Solve CAPTCHA</button>
69
+ <span class="spacer"></span>
70
+ <button class="btn-small danger" onclick="confirmDelete('{nzo_id}', '{_escape_js(display_name)}')">🗑️</button>
71
+ </div>
72
+ '''
73
+ elif nzo_id:
74
+ actions = f'''
75
+ <div class="package-actions right-only">
76
+ <button class="btn-small danger" onclick="confirmDelete('{nzo_id}', '{_escape_js(display_name)}')">🗑️</button>
77
+ </div>
78
+ '''
79
+ else:
80
+ actions = ''
81
+
82
+ return f'''
83
+ <div class="package-card">
84
+ <div class="package-header">
85
+ <span class="status-emoji">{status_emoji}</span>
86
+ <span class="package-name">{display_name}</span>
87
+ {archive_badge}
88
+ </div>
89
+ <div class="package-progress">
90
+ {progress_html}
91
+ <span class="progress-percent">{percentage}%</span>
92
+ </div>
93
+ <div class="package-details">
94
+ <span>⏱️ {timeleft}</span>
95
+ <span>💾 {size_str}</span>
96
+ <span>{cat_emoji} {cat}</span>
97
+ </div>
98
+ {actions}
99
+ </div>
100
+ '''
101
+
102
+
103
+ def _render_history_item(item):
104
+ name = item.get('name', 'Unknown')
105
+ status = item.get('status', 'Unknown')
106
+ bytes_val = item.get('bytes', 0)
107
+ category = item.get('category', 'not_quasarr')
108
+ is_archive = item.get('is_archive', False)
109
+ extraction_status = item.get('extraction_status', '')
110
+ fail_message = item.get('fail_message', '')
111
+ nzo_id = item.get('nzo_id', '')
112
+
113
+ is_error = status.lower() in ['failed', 'error'] or fail_message
114
+ card_class = 'package-card error' if is_error else 'package-card'
115
+
116
+ cat_emoji = _get_category_emoji(category)
117
+ size_str = _format_size(bytes_val=bytes_val)
118
+
119
+ archive_badge = ''
120
+ if is_archive:
121
+ if extraction_status == 'SUCCESSFUL':
122
+ archive_badge = '<span class="badge extracted">✅ EXTRACTED</span>'
123
+ elif extraction_status == 'RUNNING':
124
+ archive_badge = '<span class="badge pending">⏳ EXTRACTING</span>'
125
+ else:
126
+ archive_badge = '<span class="badge archive">📁 ARCHIVE</span>'
127
+
128
+ status_emoji = '❌' if is_error else '✅'
129
+ error_html = f'<div class="package-error">⚠️ {fail_message}</div>' if fail_message else ''
130
+
131
+ # Delete button for history items
132
+ if nzo_id:
133
+ actions = f'''
134
+ <div class="package-actions right-only">
135
+ <button class="btn-small danger" onclick="confirmDelete('{nzo_id}', '{_escape_js(name)}')">🗑️</button>
136
+ </div>
137
+ '''
138
+ else:
139
+ actions = ''
140
+
141
+ return f'''
142
+ <div class="{card_class}">
143
+ <div class="package-header">
144
+ <span class="status-emoji">{status_emoji}</span>
145
+ <span class="package-name">{name}</span>
146
+ {archive_badge}
147
+ </div>
148
+ <div class="package-details">
149
+ <span>💾 {size_str}</span>
150
+ <span>{cat_emoji} {category}</span>
151
+ </div>
152
+ {error_html}
153
+ {actions}
154
+ </div>
155
+ '''
156
+
157
+
158
+ def _render_packages_content():
159
+ """Render just the packages content (used for both full page and AJAX refresh)."""
160
+ downloads = get_packages(shared_state)
161
+ queue = downloads.get('queue', [])
162
+ history = downloads.get('history', [])
163
+
164
+ # Separate Quasarr packages from others
165
+ quasarr_queue = [p for p in queue if p.get('cat') != 'not_quasarr']
166
+ other_queue = [p for p in queue if p.get('cat') == 'not_quasarr']
167
+ quasarr_history = [p for p in history if p.get('category') != 'not_quasarr']
168
+ other_history = [p for p in history if p.get('category') == 'not_quasarr']
169
+
170
+ # Build queue section
171
+ queue_html = ''
172
+ if quasarr_queue:
173
+ queue_items = ''.join(_render_queue_item(item) for item in quasarr_queue)
174
+ queue_html = f'''
175
+ <div class="section">
176
+ <h3>⬇️ Downloading</h3>
177
+ <div class="packages-list">{queue_items}</div>
178
+ </div>
179
+ '''
180
+ else:
181
+ queue_html = '<div class="section"><p class="empty-message">No active downloads</p></div>'
182
+
183
+ # Build history section
184
+ history_html = ''
185
+ if quasarr_history:
186
+ history_items = ''.join(_render_history_item(item) for item in quasarr_history[:10])
187
+ history_html = f'''
188
+ <div class="section">
189
+ <h3>📜 Recent History</h3>
190
+ <div class="packages-list">{history_items}</div>
191
+ </div>
192
+ '''
193
+
194
+ # Build "other packages" section (non-Quasarr)
195
+ other_html = ''
196
+ other_count = len(other_queue) + len(other_history)
197
+ if other_count > 0:
198
+ other_items = ''
199
+ if other_queue:
200
+ other_items += f'<h4>Queue ({len(other_queue)})</h4>'
201
+ other_items += ''.join(_render_queue_item(item) for item in other_queue)
202
+ if other_history:
203
+ other_items += f'<h4>History ({len(other_history)})</h4>'
204
+ other_items += ''.join(_render_history_item(item) for item in other_history[:5])
205
+
206
+ plural = 's' if other_count != 1 else ''
207
+ other_html = f'''
208
+ <div class="other-packages-section">
209
+ <details id="otherPackagesDetails">
210
+ <summary id="otherPackagesSummary">Show {other_count} non-Quasarr package{plural}</summary>
211
+ <div class="other-packages-content">{other_items}</div>
212
+ </details>
213
+ </div>
214
+ '''
215
+
216
+ return f'''
217
+ <div class="packages-container">
218
+ {queue_html}
219
+ {history_html}
220
+ {other_html}
221
+ </div>
222
+ '''
223
+
224
+
11
225
  def setup_packages_routes(app):
12
226
  @app.get('/packages/delete/<package_id>')
13
227
  def delete_package_route(package_id):
@@ -20,6 +234,19 @@ def setup_packages_routes(app):
20
234
  else:
21
235
  redirect('/packages?deleted=0')
22
236
 
237
+ @app.get('/api/packages/content')
238
+ def packages_content_api():
239
+ """AJAX endpoint - returns just the packages content HTML for background refresh."""
240
+ try:
241
+ device = shared_state.values["device"]
242
+ except KeyError:
243
+ device = None
244
+
245
+ if not device:
246
+ return '<p class="empty-message">JDownloader connection not established.</p>'
247
+
248
+ return _render_packages_content()
249
+
23
250
  @app.get('/packages')
24
251
  def packages_status():
25
252
  from bottle import request
@@ -45,204 +272,8 @@ def setup_packages_routes(app):
45
272
  elif deleted == '0':
46
273
  status_message = '<div class="status-message error">❌ Failed to delete package.</div>'
47
274
 
48
- # Get packages data
49
- downloads = get_packages(shared_state)
50
- queue = downloads.get('queue', [])
51
- history = downloads.get('history', [])
52
-
53
- # Separate Quasarr packages from others
54
- quasarr_queue = [p for p in queue if p.get('cat') != 'not_quasarr']
55
- other_queue = [p for p in queue if p.get('cat') == 'not_quasarr']
56
- quasarr_history = [p for p in history if p.get('category') != 'not_quasarr']
57
- other_history = [p for p in history if p.get('category') == 'not_quasarr']
58
-
59
- def get_category_emoji(cat):
60
- return {'movies': '🎬', 'tv': '📺', 'docs': '📄', 'not_quasarr': '📦'}.get(cat, '📦')
61
-
62
- def format_size(mb=None, bytes_val=None):
63
- if bytes_val is not None:
64
- mb = bytes_val / (1024 * 1024)
65
- if mb is None or mb == 0:
66
- return "? MB"
67
- if mb < 1024:
68
- return f"{mb:.0f} MB"
69
- return f"{mb / 1024:.1f} GB"
70
-
71
- def escape_js(s):
72
- return s.replace('\\', '\\\\').replace("'", "\\'").replace('"', '\\"').replace('\n', '\\n')
73
-
74
- def render_queue_item(item):
75
- filename = item.get('filename', 'Unknown')
76
- percentage = item.get('percentage', 0)
77
- timeleft = item.get('timeleft', '??:??:??')
78
- mb = item.get('mb', 0)
79
- cat = item.get('cat', 'not_quasarr')
80
- is_archive = item.get('is_archive', False)
81
- nzo_id = item.get('nzo_id', '')
82
-
83
- is_captcha = '[CAPTCHA' in filename
84
- if is_captcha:
85
- status_emoji = '🔒'
86
- elif '[Extracting]' in filename:
87
- status_emoji = '📦'
88
- elif '[Paused]' in filename:
89
- status_emoji = '⏸️'
90
- elif '[Linkgrabber]' in filename:
91
- status_emoji = '🔗'
92
- else:
93
- status_emoji = '⬇️'
94
-
95
- display_name = filename
96
- for prefix in ['[Downloading] ', '[Extracting] ', '[Paused] ', '[Linkgrabber] ', '[CAPTCHA not solved!] ']:
97
- display_name = display_name.replace(prefix, '')
98
-
99
- archive_badge = '<span class="badge archive">📁 ARCHIVE</span>' if is_archive else ''
100
- cat_emoji = get_category_emoji(cat)
101
- size_str = format_size(mb=mb)
102
-
103
- # Progress bar - show "waiting..." for 0%
104
- if percentage == 0:
105
- progress_html = '<span class="progress-waiting"></span>'
106
- else:
107
- progress_html = f'<div class="progress-track"><div class="progress-fill" style="width: {percentage}%"></div></div>'
108
-
109
- # Action buttons - CAPTCHA left, delete right
110
- if is_captcha and nzo_id:
111
- actions = f'''
112
- <div class="package-actions">
113
- <button class="btn-small primary" onclick="location.href='/captcha?package_id={nzo_id}'">🔓 Solve CAPTCHA</button>
114
- <span class="spacer"></span>
115
- <button class="btn-small danger" onclick="confirmDelete('{nzo_id}', '{escape_js(display_name)}')">🗑️</button>
116
- </div>
117
- '''
118
- elif nzo_id:
119
- actions = f'''
120
- <div class="package-actions right-only">
121
- <button class="btn-small danger" onclick="confirmDelete('{nzo_id}', '{escape_js(display_name)}')">🗑️</button>
122
- </div>
123
- '''
124
- else:
125
- actions = ''
126
-
127
- return f'''
128
- <div class="package-card">
129
- <div class="package-header">
130
- <span class="status-emoji">{status_emoji}</span>
131
- <span class="package-name">{display_name}</span>
132
- {archive_badge}
133
- </div>
134
- <div class="package-progress">
135
- {progress_html}
136
- <span class="progress-percent">{percentage}%</span>
137
- </div>
138
- <div class="package-details">
139
- <span>⏱️ {timeleft}</span>
140
- <span>💾 {size_str}</span>
141
- <span>{cat_emoji} {cat}</span>
142
- </div>
143
- {actions}
144
- </div>
145
- '''
146
-
147
- def render_history_item(item):
148
- name = item.get('name', 'Unknown')
149
- status = item.get('status', 'Unknown')
150
- bytes_val = item.get('bytes', 0)
151
- category = item.get('category', 'not_quasarr')
152
- is_archive = item.get('is_archive', False)
153
- extraction_status = item.get('extraction_status', '')
154
- fail_message = item.get('fail_message', '')
155
- nzo_id = item.get('nzo_id', '')
156
-
157
- is_error = status.lower() in ['failed', 'error'] or fail_message
158
- card_class = 'package-card error' if is_error else 'package-card'
159
-
160
- cat_emoji = get_category_emoji(category)
161
- size_str = format_size(bytes_val=bytes_val)
162
-
163
- archive_badge = ''
164
- if is_archive:
165
- if extraction_status == 'SUCCESSFUL':
166
- archive_badge = '<span class="badge extracted">✅ EXTRACTED</span>'
167
- elif extraction_status == 'RUNNING':
168
- archive_badge = '<span class="badge pending">⏳ EXTRACTING</span>'
169
- else:
170
- archive_badge = '<span class="badge archive">📁 ARCHIVE</span>'
171
-
172
- status_emoji = '❌' if is_error else '✅'
173
- error_html = f'<div class="package-error">⚠️ {fail_message}</div>' if fail_message else ''
174
-
175
- # Delete button for history items
176
- if nzo_id:
177
- actions = f'''
178
- <div class="package-actions right-only">
179
- <button class="btn-small danger" onclick="confirmDelete('{nzo_id}', '{escape_js(name)}')">🗑️</button>
180
- </div>
181
- '''
182
- else:
183
- actions = ''
184
-
185
- return f'''
186
- <div class="{card_class}">
187
- <div class="package-header">
188
- <span class="status-emoji">{status_emoji}</span>
189
- <span class="package-name">{name}</span>
190
- {archive_badge}
191
- </div>
192
- <div class="package-details">
193
- <span>💾 {size_str}</span>
194
- <span>{cat_emoji} {category}</span>
195
- </div>
196
- {error_html}
197
- {actions}
198
- </div>
199
- '''
200
-
201
- # Build queue section
202
- queue_html = ''
203
- if quasarr_queue:
204
- queue_items = ''.join(render_queue_item(item) for item in quasarr_queue)
205
- queue_html = f'''
206
- <div class="section">
207
- <h3>⬇️ Downloading</h3>
208
- <div class="packages-list">{queue_items}</div>
209
- </div>
210
- '''
211
- else:
212
- queue_html = '<div class="section"><p class="empty-message">No active downloads</p></div>'
213
-
214
- # Build history section
215
- history_html = ''
216
- if quasarr_history:
217
- history_items = ''.join(render_history_item(item) for item in quasarr_history[:10])
218
- history_html = f'''
219
- <div class="section">
220
- <h3>📜 Recent History</h3>
221
- <div class="packages-list">{history_items}</div>
222
- </div>
223
- '''
224
-
225
- # Build "other packages" section (non-Quasarr)
226
- other_html = ''
227
- other_count = len(other_queue) + len(other_history)
228
- if other_count > 0:
229
- other_items = ''
230
- if other_queue:
231
- other_items += f'<h4>Queue ({len(other_queue)})</h4>'
232
- other_items += ''.join(render_queue_item(item) for item in other_queue)
233
- if other_history:
234
- other_items += f'<h4>History ({len(other_history)})</h4>'
235
- other_items += ''.join(render_history_item(item) for item in other_history[:5])
236
-
237
- plural = 's' if other_count != 1 else ''
238
- other_html = f'''
239
- <div class="other-packages-section">
240
- <details id="otherPackagesDetails">
241
- <summary id="otherPackagesSummary">Show {other_count} non-Quasarr package{plural}</summary>
242
- <div class="other-packages-content">{other_items}</div>
243
- </details>
244
- </div>
245
- '''
275
+ # Get rendered packages content using shared helper
276
+ packages_content = _render_packages_content()
246
277
 
247
278
  back_btn = render_button("Back", "secondary", {"onclick": "location.href='/'"})
248
279
 
@@ -252,14 +283,8 @@ def setup_packages_routes(app):
252
283
 
253
284
  {status_message}
254
285
 
255
- <div class="refresh-indicator" onclick="location.reload()">
256
- Auto-refresh in <span id="countdown">10</span>s
257
- </div>
258
-
259
- <div class="packages-container">
260
- {queue_html}
261
- {history_html}
262
- {other_html}
286
+ <div id="packages-content">
287
+ {packages_content}
263
288
  </div>
264
289
 
265
290
  <p>{back_btn}</p>
@@ -323,8 +348,6 @@ def setup_packages_routes(app):
323
348
  .btn-small.danger:hover {{ background: var(--btn-danger-hover-bg, #dc3545); color: white; }}
324
349
 
325
350
  .empty-message {{ color: var(--text-muted, #888); font-style: italic; text-align: center; padding: 20px; }}
326
- .refresh-indicator {{ text-align: center; font-size: 0.85em; color: var(--text-muted, #888); margin-bottom: 15px; cursor: pointer; }}
327
- .refresh-indicator:hover {{ color: var(--link-color, #0066cc); text-decoration: underline; }}
328
351
 
329
352
  .other-packages-section {{ margin-top: 30px; padding-top: 20px; border-top: 1px solid var(--border-color, #ddd); }}
330
353
  .other-packages-section summary {{ cursor: pointer; padding: 8px 0; color: var(--text-muted, #666); }}
@@ -380,13 +403,56 @@ def setup_packages_routes(app):
380
403
  </style>
381
404
 
382
405
  <script>
383
- let countdown = 10;
384
- const countdownEl = document.getElementById('countdown');
385
- const refreshInterval = setInterval(() => {{
386
- countdown--;
387
- if (countdownEl) countdownEl.textContent = countdown;
388
- if (countdown <= 0) location.reload();
389
- }}, 1000);
406
+ // Background refresh - fetches content via AJAX, waits 5s between refresh cycles
407
+ let refreshPaused = false;
408
+
409
+ async function refreshContent() {{
410
+ if (refreshPaused) return;
411
+ try {{
412
+ const response = await fetch('/api/packages/content');
413
+ if (response.ok) {{
414
+ const html = await response.text();
415
+ const container = document.getElementById('packages-content');
416
+ if (container && html) {{
417
+ container.innerHTML = html;
418
+ // Re-apply collapse state after content update
419
+ restoreCollapseState();
420
+ }}
421
+ }}
422
+ }} catch (e) {{
423
+ // Silent fail - will retry on next cycle
424
+ }}
425
+ // Schedule next refresh 5 seconds after this one completes
426
+ setTimeout(refreshContent, 5000);
427
+ }}
428
+
429
+ function restoreCollapseState() {{
430
+ const otherDetails = document.getElementById('otherPackagesDetails');
431
+ const otherSummary = document.getElementById('otherPackagesSummary');
432
+ if (otherDetails && otherSummary) {{
433
+ const count = otherSummary.textContent.match(/\\d+/)?.[0] || '0';
434
+ const plural = count !== '1' ? 's' : '';
435
+ if (localStorage.getItem('otherPackagesOpen') === 'true') {{
436
+ otherDetails.open = true;
437
+ otherSummary.textContent = 'Hide ' + count + ' non-Quasarr package' + plural;
438
+ }}
439
+ // Re-attach event listener
440
+ otherDetails.onclick = null;
441
+ otherDetails.addEventListener('toggle', function() {{
442
+ localStorage.setItem('otherPackagesOpen', this.open);
443
+ const summaryEl = document.getElementById('otherPackagesSummary');
444
+ if (summaryEl) {{
445
+ summaryEl.textContent = (this.open ? 'Hide ' : 'Show ') + count + ' non-Quasarr package' + plural;
446
+ }}
447
+ }});
448
+ }}
449
+ }}
450
+
451
+ // Start refresh cycle after initial 5s delay
452
+ setTimeout(refreshContent, 5000);
453
+
454
+ // Initial collapse state setup
455
+ restoreCollapseState();
390
456
 
391
457
  // Clear status message from URL after display
392
458
  if (window.location.search.includes('deleted=')) {{
@@ -395,34 +461,18 @@ def setup_packages_routes(app):
395
461
  window.history.replaceState({{}}, '', url);
396
462
  }}
397
463
 
398
- // Restore collapse state from localStorage
399
- const otherDetails = document.getElementById('otherPackagesDetails');
400
- const otherSummary = document.getElementById('otherPackagesSummary');
401
- if (otherDetails && otherSummary) {{
402
- const count = otherSummary.textContent.match(/\\d+/)?.[0] || '0';
403
- const plural = count !== '1' ? 's' : '';
404
- if (localStorage.getItem('otherPackagesOpen') === 'true') {{
405
- otherDetails.open = true;
406
- otherSummary.textContent = 'Hide ' + count + ' non-Quasarr package' + plural;
407
- }}
408
- otherDetails.addEventListener('toggle', () => {{
409
- localStorage.setItem('otherPackagesOpen', otherDetails.open);
410
- otherSummary.textContent = (otherDetails.open ? 'Hide ' : 'Show ') + count + ' non-Quasarr package' + plural;
411
- }});
412
- }}
413
-
414
464
  // Delete modal
415
465
  let deletePackageId = null;
416
466
  function confirmDelete(packageId, packageName) {{
417
467
  deletePackageId = packageId;
418
468
  document.getElementById('modalPackageName').textContent = packageName;
419
469
  document.getElementById('deleteModal').classList.add('show');
420
- clearInterval(refreshInterval);
470
+ refreshPaused = true; // Pause background refresh while modal is open
421
471
  }}
422
472
  function closeModal() {{
423
473
  document.getElementById('deleteModal').classList.remove('show');
424
474
  deletePackageId = null;
425
- location.reload();
475
+ refreshPaused = false; // Resume background refresh
426
476
  }}
427
477
  document.getElementById('confirmDeleteBtn').onclick = function() {{
428
478
  if (deletePackageId) location.href = '/packages/delete/' + encodeURIComponent(deletePackageId);
@@ -8,13 +8,21 @@ from bottle import request, abort
8
8
 
9
9
  from quasarr.downloads import fail
10
10
  from quasarr.providers import shared_state
11
+ from quasarr.providers.auth import require_api_key
11
12
  from quasarr.providers.log import info
12
13
  from quasarr.providers.notifications import send_discord_message
13
14
  from quasarr.providers.statistics import StatsHelper
14
15
 
15
16
 
16
17
  def setup_sponsors_helper_routes(app):
18
+ @app.get("/sponsors_helper/api/ping/")
19
+ @require_api_key
20
+ def ping_api():
21
+ """Health check endpoint for SponsorsHelper to verify connectivity."""
22
+ return "pong"
23
+
17
24
  @app.get("/sponsors_helper/api/to_decrypt/")
25
+ @require_api_key
18
26
  def to_decrypt_api():
19
27
  try:
20
28
  if not shared_state.values["helper_active"]:
@@ -60,6 +68,7 @@ def setup_sponsors_helper_routes(app):
60
68
  return abort(500, str(e))
61
69
 
62
70
  @app.post("/sponsors_helper/api/to_download/")
71
+ @require_api_key
63
72
  def to_download_api():
64
73
  try:
65
74
  data = request.json
@@ -86,9 +95,10 @@ def setup_sponsors_helper_routes(app):
86
95
  info(f"Error decrypting: {e}")
87
96
 
88
97
  StatsHelper(shared_state).increment_failed_decryptions_automatic()
89
- return abort(500, "Failed") #
98
+ return abort(500, "Failed")
90
99
 
91
100
  @app.post("/sponsors_helper/api/to_replace/")
101
+ @require_api_key
92
102
  def to_replace_api():
93
103
  try:
94
104
  data = request.json
@@ -130,6 +140,7 @@ def setup_sponsors_helper_routes(app):
130
140
  return {"error": str(e)}, 500
131
141
 
132
142
  @app.delete("/sponsors_helper/api/to_failed/")
143
+ @require_api_key
133
144
  def move_to_failed_api():
134
145
  try:
135
146
  StatsHelper(shared_state).increment_failed_decryptions_automatic()
@@ -153,6 +164,7 @@ def setup_sponsors_helper_routes(app):
153
164
  return abort(500, "Failed")
154
165
 
155
166
  @app.put("/sponsors_helper/api/set_sponsor_status/")
167
+ @require_api_key
156
168
  def activate_sponsor_status():
157
169
  try:
158
170
  data = request.body.read().decode("utf-8")
@@ -164,7 +176,3 @@ def setup_sponsors_helper_routes(app):
164
176
  except:
165
177
  pass
166
178
  return abort(500, "Failed")
167
-
168
- @app.get("/sponsors_helper/api/ping/")
169
- def get_sponsor_status():
170
- return "pong"