quasarr 1.32.0__py3-none-any.whl → 2.1.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 (38) hide show
  1. quasarr/api/__init__.py +324 -106
  2. quasarr/api/arr/__init__.py +56 -20
  3. quasarr/api/captcha/__init__.py +26 -1
  4. quasarr/api/config/__init__.py +1 -1
  5. quasarr/api/packages/__init__.py +435 -0
  6. quasarr/api/sponsors_helper/__init__.py +4 -0
  7. quasarr/downloads/__init__.py +96 -6
  8. quasarr/downloads/linkcrypters/filecrypt.py +1 -1
  9. quasarr/downloads/linkcrypters/hide.py +45 -6
  10. quasarr/providers/auth.py +250 -0
  11. quasarr/providers/html_templates.py +65 -10
  12. quasarr/providers/obfuscated.py +9 -7
  13. quasarr/providers/shared_state.py +24 -0
  14. quasarr/providers/version.py +1 -1
  15. quasarr/search/sources/al.py +1 -1
  16. quasarr/search/sources/by.py +1 -1
  17. quasarr/search/sources/dd.py +2 -1
  18. quasarr/search/sources/dj.py +2 -2
  19. quasarr/search/sources/dl.py +11 -4
  20. quasarr/search/sources/dt.py +1 -1
  21. quasarr/search/sources/dw.py +6 -7
  22. quasarr/search/sources/fx.py +4 -4
  23. quasarr/search/sources/he.py +1 -1
  24. quasarr/search/sources/mb.py +1 -1
  25. quasarr/search/sources/nk.py +1 -1
  26. quasarr/search/sources/nx.py +1 -1
  27. quasarr/search/sources/sf.py +4 -2
  28. quasarr/search/sources/sj.py +2 -2
  29. quasarr/search/sources/sl.py +3 -3
  30. quasarr/search/sources/wd.py +1 -1
  31. quasarr/search/sources/wx.py +4 -3
  32. quasarr/storage/setup.py +12 -0
  33. {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/METADATA +47 -24
  34. {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/RECORD +38 -36
  35. {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/WHEEL +0 -0
  36. {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/entry_points.txt +0 -0
  37. {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/licenses/LICENSE +0 -0
  38. {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/top_level.txt +0 -0
@@ -61,7 +61,21 @@ def setup_captcha_routes(app):
61
61
  {render_button("Confirm", "secondary", {"onclick": "location.href='/'"})}
62
62
  </p>''')
63
63
  else:
64
- package = protected[0]
64
+ # Check if a specific package_id was requested
65
+ requested_package_id = request.query.get('package_id')
66
+ package = None
67
+
68
+ if requested_package_id:
69
+ # Find the specific package
70
+ for p in protected:
71
+ if p[0] == requested_package_id:
72
+ package = p
73
+ break
74
+
75
+ # Fall back to first package if not found or not specified
76
+ if package is None:
77
+ package = protected[0]
78
+
65
79
  package_id = package[0]
66
80
  data = json.loads(package[1])
67
81
  title = data["title"]
@@ -1209,6 +1223,17 @@ def setup_captcha_routes(app):
1209
1223
  border-right: none !important;
1210
1224
  }
1211
1225
  }
1226
+ /* Fix captcha container to shrink-wrap iframe on desktop */
1227
+ .captcha-container {
1228
+ display: inline-block;
1229
+ background-color: var(--secondary);
1230
+ }
1231
+ #puzzle-captcha {
1232
+ display: block;
1233
+ }
1234
+ #puzzle-captcha iframe {
1235
+ display: block;
1236
+ }
1212
1237
  </style>
1213
1238
  <script type="text/javascript">
1214
1239
  // Package title for result display
@@ -235,7 +235,7 @@ def setup_config(app, shared_state):
235
235
  }}
236
236
  </script>
237
237
  '''
238
- return render_form("Configure FlareSolverr", form_html)
238
+ return render_form("FlareSolverr", form_html)
239
239
 
240
240
  @app.post('/api/flaresolverr')
241
241
  def set_flaresolverr_url():
@@ -0,0 +1,435 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import quasarr.providers.html_images as images
6
+ from quasarr.downloads.packages import get_packages, delete_package
7
+ from quasarr.providers import shared_state
8
+ from quasarr.providers.html_templates import render_button, render_centered_html
9
+
10
+
11
+ def setup_packages_routes(app):
12
+ @app.get('/packages/delete/<package_id>')
13
+ def delete_package_route(package_id):
14
+ success = delete_package(shared_state, package_id)
15
+
16
+ # Redirect back to packages page with status message via query param
17
+ from bottle import redirect
18
+ if success:
19
+ redirect('/packages?deleted=1')
20
+ else:
21
+ redirect('/packages?deleted=0')
22
+
23
+ @app.get('/packages')
24
+ def packages_status():
25
+ from bottle import request
26
+
27
+ try:
28
+ device = shared_state.values["device"]
29
+ except KeyError:
30
+ device = None
31
+
32
+ if not device:
33
+ back_btn = render_button("Back", "secondary", {"onclick": "location.href='/'"})
34
+ return render_centered_html(f'''
35
+ <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
36
+ <p>JDownloader connection not established.</p>
37
+ <p>{back_btn}</p>
38
+ ''')
39
+
40
+ # Check for delete status from redirect
41
+ deleted = request.query.get('deleted')
42
+ status_message = ""
43
+ if deleted == '1':
44
+ status_message = '<div class="status-message success">✅ Package deleted successfully.</div>'
45
+ elif deleted == '0':
46
+ status_message = '<div class="status-message error">❌ Failed to delete package.</div>'
47
+
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
+ '''
246
+
247
+ back_btn = render_button("Back", "secondary", {"onclick": "location.href='/'"})
248
+
249
+ packages_html = f'''
250
+ <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
251
+ <h2>Packages</h2>
252
+
253
+ {status_message}
254
+
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}
263
+ </div>
264
+
265
+ <p>{back_btn}</p>
266
+
267
+ <!-- Delete confirmation modal -->
268
+ <div class="modal" id="deleteModal">
269
+ <div class="modal-content">
270
+ <h3>🗑️ Delete Package?</h3>
271
+ <p class="modal-package-name" id="modalPackageName"></p>
272
+ <div class="modal-warning">
273
+ <strong>⛔ Warning:</strong> This will permanently delete the package AND all associated files from disk. This action cannot be undone!
274
+ </div>
275
+ <div class="modal-buttons">
276
+ <button class="btn-secondary" onclick="closeModal()">Cancel</button>
277
+ <button class="btn-danger" id="confirmDeleteBtn">🗑️ Delete Package & Files</button>
278
+ </div>
279
+ </div>
280
+ </div>
281
+
282
+ <style>
283
+ .packages-container {{ max-width: 600px; margin: 0 auto; }}
284
+ .section {{ margin: 20px 0; }}
285
+ .section h3 {{ margin-bottom: 15px; padding-bottom: 8px; border-bottom: 1px solid var(--border-color, #ddd); }}
286
+ .packages-list {{ display: flex; flex-direction: column; gap: 10px; }}
287
+
288
+ .package-card {{
289
+ background: var(--card-bg, #f8f9fa);
290
+ border: 1px solid var(--card-border, #dee2e6);
291
+ border-radius: 8px;
292
+ padding: 12px 15px;
293
+ transition: transform 0.2s, box-shadow 0.2s;
294
+ }}
295
+ .package-card:hover {{ transform: translateY(-1px); box-shadow: 0 2px 8px var(--card-shadow, rgba(0,0,0,0.1)); }}
296
+ .package-card.error {{ border-color: var(--error-border, #dc3545); background: var(--error-bg, #fff5f5); }}
297
+
298
+ .package-header {{ display: flex; align-items: flex-start; gap: 8px; margin-bottom: 8px; }}
299
+ .status-emoji {{ font-size: 1.2em; flex-shrink: 0; }}
300
+ .package-name {{ flex: 1; font-weight: 500; word-break: break-word; line-height: 1.3; }}
301
+
302
+ .badge {{ font-size: 0.75em; padding: 2px 6px; border-radius: 4px; white-space: nowrap; flex-shrink: 0; }}
303
+ .badge.archive {{ background: var(--badge-archive-bg, #e3f2fd); color: var(--badge-archive-color, #1565c0); }}
304
+ .badge.extracted {{ background: var(--badge-success-bg, #e8f5e9); color: var(--badge-success-color, #2e7d32); }}
305
+ .badge.pending {{ background: var(--badge-warning-bg, #fff3e0); color: var(--badge-warning-color, #e65100); }}
306
+
307
+ .package-progress {{ display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }}
308
+ .progress-track {{ flex: 1; height: 8px; background: var(--progress-track, #e0e0e0); border-radius: 4px; overflow: hidden; }}
309
+ .progress-fill {{ height: 100%; background: var(--progress-fill, #4caf50); border-radius: 4px; min-width: 4px; }}
310
+ .progress-waiting {{ flex: 1; color: var(--text-muted, #888); font-style: italic; font-size: 0.85em; }}
311
+ .progress-percent {{ font-weight: bold; min-width: 40px; text-align: right; font-size: 0.9em; }}
312
+
313
+ .package-details {{ display: flex; flex-wrap: wrap; gap: 15px; font-size: 0.85em; color: var(--text-muted, #666); }}
314
+ .package-error {{ margin-top: 8px; padding: 8px; background: var(--error-msg-bg, #ffebee); border-radius: 4px; font-size: 0.85em; color: var(--error-msg-color, #c62828); }}
315
+
316
+ .package-actions {{ margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-color, #eee); display: flex; gap: 8px; align-items: center; }}
317
+ .package-actions .spacer {{ flex: 1; }}
318
+ .package-actions.right-only {{ justify-content: flex-end; }}
319
+ .btn-small {{ padding: 5px 12px; font-size: 0.8em; border-radius: 4px; cursor: pointer; transition: all 0.2s; }}
320
+ .btn-small.primary {{ background: var(--btn-primary-bg, #007bff); color: white; border: none; }}
321
+ .btn-small.primary:hover {{ background: var(--btn-primary-hover, #0056b3); }}
322
+ .btn-small.danger {{ background: transparent; color: var(--btn-danger-text, #dc3545); border: 1px solid var(--btn-danger-border, #dc3545); }}
323
+ .btn-small.danger:hover {{ background: var(--btn-danger-hover-bg, #dc3545); color: white; }}
324
+
325
+ .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
+
329
+ .other-packages-section {{ margin-top: 30px; padding-top: 20px; border-top: 1px solid var(--border-color, #ddd); }}
330
+ .other-packages-section summary {{ cursor: pointer; padding: 8px 0; color: var(--text-muted, #666); }}
331
+ .other-packages-section summary:hover {{ color: var(--link-color, #0066cc); }}
332
+ .other-packages-content {{ margin-top: 15px; }}
333
+ .other-packages-content h4 {{ margin: 15px 0 10px 0; font-size: 0.95em; color: var(--text-muted, #666); }}
334
+
335
+ /* Status message styling */
336
+ .status-message {{
337
+ padding: 10px 15px;
338
+ border-radius: 6px;
339
+ margin-bottom: 15px;
340
+ font-weight: 500;
341
+ }}
342
+ .status-message.success {{
343
+ background: var(--success-bg, #d1e7dd);
344
+ color: var(--success-color, #198754);
345
+ border: 1px solid var(--success-border, #a3cfbb);
346
+ }}
347
+ .status-message.error {{
348
+ background: var(--error-bg, #f8d7da);
349
+ color: var(--error-color, #dc3545);
350
+ border: 1px solid var(--error-border, #f1aeb5);
351
+ }}
352
+
353
+ /* Modal */
354
+ .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; }}
355
+ .modal.show {{ display: flex; }}
356
+ .modal-content {{ background: var(--modal-bg, white); padding: 25px; border-radius: 12px; max-width: 400px; width: 90%; text-align: center; }}
357
+ .modal-content h3 {{ margin: 0 0 15px 0; color: var(--error-msg-color, #c62828); }}
358
+ .modal-package-name {{ font-weight: 500; word-break: break-word; padding: 10px; background: var(--code-bg, #f5f5f5); border-radius: 6px; margin: 10px 0; }}
359
+ .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; }}
360
+ .modal-buttons {{ display: flex; gap: 10px; justify-content: center; margin-top: 20px; }}
361
+ .btn-danger {{ background: var(--btn-danger-bg, #dc3545); color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-weight: 500; }}
362
+ .btn-danger:hover {{ opacity: 0.9; }}
363
+
364
+ /* Dark mode */
365
+ @media (prefers-color-scheme: dark) {{
366
+ :root {{
367
+ --card-bg: #2d3748; --card-border: #4a5568; --card-shadow: rgba(0,0,0,0.3);
368
+ --border-color: #4a5568; --text-muted: #a0aec0;
369
+ --progress-track: #4a5568; --progress-fill: #68d391;
370
+ --error-border: #fc8181; --error-bg: #3d2d2d; --error-msg-bg: #3d2d2d; --error-msg-color: #fc8181;
371
+ --badge-archive-bg: #1a365d; --badge-archive-color: #63b3ed;
372
+ --badge-success-bg: #1c4532; --badge-success-color: #68d391;
373
+ --badge-warning-bg: #3d2d1a; --badge-warning-color: #f6ad55;
374
+ --link-color: #63b3ed; --modal-bg: #2d3748; --code-bg: #1a202c;
375
+ --btn-primary-bg: #3182ce; --btn-primary-hover: #2c5282;
376
+ --btn-danger-text: #fc8181; --btn-danger-border: #fc8181; --btn-danger-hover-bg: #e53e3e;
377
+ --success-bg: #1c4532; --success-color: #68d391; --success-border: #276749;
378
+ }}
379
+ }}
380
+ </style>
381
+
382
+ <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);
390
+
391
+ // Clear status message from URL after display
392
+ if (window.location.search.includes('deleted=')) {{
393
+ const url = new URL(window.location);
394
+ url.searchParams.delete('deleted');
395
+ window.history.replaceState({{}}, '', url);
396
+ }}
397
+
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
+ // Delete modal
415
+ let deletePackageId = null;
416
+ function confirmDelete(packageId, packageName) {{
417
+ deletePackageId = packageId;
418
+ document.getElementById('modalPackageName').textContent = packageName;
419
+ document.getElementById('deleteModal').classList.add('show');
420
+ clearInterval(refreshInterval);
421
+ }}
422
+ function closeModal() {{
423
+ document.getElementById('deleteModal').classList.remove('show');
424
+ deletePackageId = null;
425
+ location.reload();
426
+ }}
427
+ document.getElementById('confirmDeleteBtn').onclick = function() {{
428
+ if (deletePackageId) location.href = '/packages/delete/' + encodeURIComponent(deletePackageId);
429
+ }};
430
+ document.getElementById('deleteModal').onclick = function(e) {{ if (e.target === this) closeModal(); }};
431
+ document.addEventListener('keydown', function(e) {{ if (e.key === 'Escape') closeModal(); }});
432
+ </script>
433
+ '''
434
+
435
+ return render_centered_html(packages_html)
@@ -164,3 +164,7 @@ def setup_sponsors_helper_routes(app):
164
164
  except:
165
165
  pass
166
166
  return abort(500, "Failed")
167
+
168
+ @app.get("/sponsors_helper/api/ping/")
169
+ def get_sponsor_status():
170
+ return "pong"
@@ -2,6 +2,7 @@
2
2
  # Quasarr
3
3
  # Project by https://github.com/rix1337
4
4
 
5
+ import hashlib
5
6
  import json
6
7
  import re
7
8
 
@@ -65,6 +66,72 @@ SOURCE_GETTERS = {
65
66
  }
66
67
 
67
68
 
69
+ # =============================================================================
70
+ # DETERMINISTIC PACKAGE ID GENERATION
71
+ # =============================================================================
72
+
73
+ def extract_client_type(request_from):
74
+ """
75
+ Extract client type from User-Agent, stripping version info.
76
+
77
+ Examples:
78
+ "Radarr/6.0.4.10291 (alpine 3.23.2)" → "radarr"
79
+ "Sonarr/4.0.0.123" → "sonarr"
80
+ "LazyLibrarian/1.0" → "lazylibrarian"
81
+ """
82
+ if not request_from:
83
+ return "unknown"
84
+
85
+ # Extract the client name before the version (first part before '/')
86
+ client = request_from.split('/')[0].lower().strip()
87
+
88
+ # Normalize known clients
89
+ if 'radarr' in client:
90
+ return 'radarr'
91
+ elif 'sonarr' in client:
92
+ return 'sonarr'
93
+ elif 'lazylibrarian' in client:
94
+ return 'lazylibrarian'
95
+
96
+ return client
97
+
98
+
99
+ def generate_deterministic_package_id(title, source_key, client_type):
100
+ """
101
+ Generate a deterministic package ID from title, source, and client type.
102
+
103
+ The same combination of (title, source_key, client_type) will ALWAYS produce
104
+ the same package_id, allowing clients to reliably blocklist erroneous releases.
105
+
106
+ Args:
107
+ title: Release title (e.g., "Movie.Name.2024.1080p.BluRay")
108
+ source_key: Source identifier/hostname shorthand (e.g., "nx", "dl", "al")
109
+ client_type: Client type without version (e.g., "radarr", "sonarr", "lazylibrarian")
110
+
111
+ Returns:
112
+ Deterministic package ID in format: Quasarr_{category}_{hash32}
113
+ """
114
+ # Normalize inputs for consistency
115
+ normalized_title = title.strip()
116
+ normalized_source = source_key.lower().strip() if source_key else "unknown"
117
+ normalized_client = client_type.lower().strip() if client_type else "unknown"
118
+
119
+ # Category mapping (for compatibility with existing package ID format)
120
+ category_map = {
121
+ "lazylibrarian": "docs",
122
+ "radarr": "movies",
123
+ "sonarr": "tv"
124
+ }
125
+ category = category_map.get(normalized_client, "tv")
126
+
127
+ # Create deterministic hash from combination using SHA256
128
+ hash_input = f"{normalized_title}|{normalized_source}|{normalized_client}"
129
+ hash_bytes = hashlib.sha256(hash_input.encode('utf-8')).hexdigest()
130
+
131
+ # Use first 32 characters for good collision resistance (128-bit)
132
+ return f"Quasarr_{category}_{hash_bytes[:32]}"
133
+
134
+
68
135
  # =============================================================================
69
136
  # LINK CLASSIFICATION
70
137
  # =============================================================================
@@ -228,26 +295,41 @@ def process_links(shared_state, source_result, title, password, package_id, imdb
228
295
  # MAIN ENTRY POINT
229
296
  # =============================================================================
230
297
 
231
- def download(shared_state, request_from, title, url, mirror, size_mb, password, imdb_id=None):
232
- """Main download entry point."""
233
- category = "docs" if "lazylibrarian" in request_from.lower() else \
234
- "movies" if "radarr" in request_from.lower() else "tv"
235
- package_id = f"Quasarr_{category}_{str(hash(title + url)).replace('-', '')}"
236
-
298
+ def download(shared_state, request_from, title, url, mirror, size_mb, password, imdb_id=None, source_key=None):
299
+ """
300
+ Main download entry point.
301
+
302
+ Args:
303
+ shared_state: Application shared state
304
+ request_from: User-Agent string (e.g., "Radarr/6.0.4.10291")
305
+ title: Release title
306
+ url: Source URL
307
+ mirror: Preferred mirror/hoster
308
+ size_mb: Size in MB
309
+ password: Archive password
310
+ imdb_id: IMDb ID (optional)
311
+ source_key: Hostname shorthand from search (e.g., "nx", "dl"). If not provided,
312
+ will be derived from URL matching against configured hostnames.
313
+ """
237
314
  if imdb_id and imdb_id.lower() == "none":
238
315
  imdb_id = None
239
316
 
240
317
  config = shared_state.values["config"]("Hostnames")
241
318
 
319
+ # Extract client type (without version) for deterministic hashing
320
+ client_type = extract_client_type(request_from)
321
+
242
322
  # Find matching source - all getters have unified signature
243
323
  source_result = None
244
324
  label = None
325
+ detected_source_key = None
245
326
 
246
327
  for key, getter in SOURCE_GETTERS.items():
247
328
  hostname = config.get(key)
248
329
  if hostname and hostname.lower() in url.lower():
249
330
  source_result = getter(shared_state, url, mirror, title, password)
250
331
  label = key.upper()
332
+ detected_source_key = key
251
333
  break
252
334
 
253
335
  # No source matched - check if URL is a known crypter directly
@@ -257,6 +339,14 @@ def download(shared_state, request_from, title, url, mirror, size_mb, password,
257
339
  # For direct crypter URLs, we only know the crypter type, not the hoster inside
258
340
  source_result = {"links": [[url, crypter]]}
259
341
  label = crypter.upper()
342
+ detected_source_key = crypter
343
+
344
+ # Use provided source_key if available, otherwise use detected one
345
+ # This ensures we use the authoritative source from the search results
346
+ final_source_key = source_key if source_key else detected_source_key
347
+
348
+ # Generate DETERMINISTIC package_id
349
+ package_id = generate_deterministic_package_id(title, final_source_key, client_type)
260
350
 
261
351
  if source_result is None:
262
352
  info(f'Could not find matching source for "{title}" - "{url}"')
@@ -229,7 +229,7 @@ def get_filecrypt_links(shared_state, token, title, url, password=None, mirror=N
229
229
  debug(f"Circle captcha present: {circle_captcha}")
230
230
  i = 0
231
231
  while circle_captcha and i < 3:
232
- debug(f"Submitting fake circle captcha click attempt {i+1}.")
232
+ debug(f"Submitting fake circle captcha click attempt {i + 1}.")
233
233
  random_x = str(random.randint(100, 200))
234
234
  random_y = str(random.randint(100, 200))
235
235
  output = session.post(url, data="buttonx.x=" + random_x + "&buttonx.y=" + random_y,