quasarr 2.1.1__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.

@@ -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);