quasarr 2.0.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.

quasarr/api/__init__.py CHANGED
@@ -66,7 +66,7 @@ def get_api(shared_state_dict, shared_state_lock):
66
66
  # JDownloader status
67
67
  jd_status = f"""
68
68
  <div class="status-bar">
69
- <span class="status-item {'status-ok' if jd_connected else 'status-error'}">
69
+ <span class="status-pill {'success' if jd_connected else 'error'}">
70
70
  {'✅' if jd_connected else '❌'} JDownloader {'Connected' if jd_connected else 'Disconnected'}
71
71
  </span>
72
72
  </div>
@@ -389,8 +389,8 @@ def get_api(shared_state_dict, shared_state_lock):
389
389
  </script>
390
390
  """
391
391
  # Add logout link for form auth
392
- logout_html = '<div class="logout-link"><a href="/logout">Logout</a></div>' if show_logout_link() else ''
393
- return render_centered_html(info + logout_html)
392
+ logout_html = '<a href="/logout">Logout</a>' if show_logout_link() else ''
393
+ return render_centered_html(info, footer_content=logout_html)
394
394
 
395
395
  @app.get('/regenerate-api-key')
396
396
  def regenerate_api_key():
@@ -34,18 +34,57 @@ def require_api_key(func):
34
34
  return decorated
35
35
 
36
36
 
37
+ def parse_payload(payload_str):
38
+ """
39
+ Parse the base64-encoded payload string into its components.
40
+
41
+ Supports both legacy 6-field format and new 7-field format:
42
+ - Legacy (6 fields): title|url|mirror|size_mb|password|imdb_id
43
+ - New (7 fields): title|url|mirror|size_mb|password|imdb_id|source_key
44
+
45
+ Returns:
46
+ dict with keys: title, url, mirror, size_mb, password, imdb_id, source_key
47
+ """
48
+ decoded = urlsafe_b64decode(payload_str.encode()).decode()
49
+ parts = decoded.split("|")
50
+
51
+ if len(parts) == 6:
52
+ # Legacy format - no source_key provided
53
+ title, url, mirror, size_mb, password, imdb_id = parts
54
+ source_key = None
55
+ elif len(parts) == 7:
56
+ # New format with source_key
57
+ title, url, mirror, size_mb, password, imdb_id, source_key = parts
58
+ else:
59
+ raise ValueError(f"expected 6 or 7 fields, got {len(parts)}")
60
+
61
+ return {
62
+ "title": title,
63
+ "url": url,
64
+ "mirror": None if mirror == "None" else mirror,
65
+ "size_mb": size_mb,
66
+ "password": password if password else None,
67
+ "imdb_id": imdb_id if imdb_id else None,
68
+ "source_key": source_key if source_key else None
69
+ }
70
+
71
+
37
72
  def setup_arr_routes(app):
38
73
  @app.get('/download/')
39
74
  def fake_nzb_file():
40
75
  payload = request.query.payload
41
76
  decoded_payload = urlsafe_b64decode(payload).decode("utf-8").split("|")
77
+
78
+ # Support both 6 and 7 field formats
42
79
  title = decoded_payload[0]
43
80
  url = decoded_payload[1]
44
81
  mirror = decoded_payload[2]
45
82
  size_mb = decoded_payload[3]
46
83
  password = decoded_payload[4]
47
84
  imdb_id = decoded_payload[5]
48
- return f'<nzb><file title="{title}" url="{url}" mirror="{mirror}" size_mb="{size_mb}" password="{password}" imdb_id="{imdb_id}"/></nzb>'
85
+ source_key = decoded_payload[6] if len(decoded_payload) > 6 else ""
86
+
87
+ return f'<nzb><file title="{title}" url="{url}" mirror="{mirror}" size_mb="{size_mb}" password="{password}" imdb_id="{imdb_id}" source_key="{source_key}"/></nzb>'
49
88
 
50
89
  @app.post('/api')
51
90
  @require_api_key
@@ -65,10 +104,12 @@ def setup_arr_routes(app):
65
104
  size_mb = root.find(".//file").attrib["size_mb"]
66
105
  password = root.find(".//file").attrib.get("password")
67
106
  imdb_id = root.find(".//file").attrib.get("imdb_id")
107
+ source_key = root.find(".//file").attrib.get("source_key") or None
68
108
 
69
109
  info(f'Attempting download for "{title}"')
70
110
  request_from = request.headers.get('User-Agent')
71
- downloaded = download(shared_state, request_from, title, url, mirror, size_mb, password, imdb_id)
111
+ downloaded = download(shared_state, request_from, title, url, mirror, size_mb, password, imdb_id,
112
+ source_key)
72
113
  try:
73
114
  success = downloaded["success"]
74
115
  package_id = downloaded["package_id"]
@@ -166,37 +207,31 @@ def setup_arr_routes(app):
166
207
  if not payload:
167
208
  abort(400, "missing 'payload' parameter in URL")
168
209
 
169
- title = url = mirror = size_mb = password = imdb_id = None
170
210
  try:
171
- decoded = urlsafe_b64decode(payload.encode()).decode()
172
- parts = decoded.split("|")
173
- if len(parts) != 6:
174
- raise ValueError(f"expected 6 fields, got {len(parts)}")
175
- title, url, mirror, size_mb, password, imdb_id = parts
211
+ parsed_payload = parse_payload(payload)
176
212
  except Exception as e:
177
213
  abort(400, f"invalid payload format: {e}")
178
214
 
179
- mirror = None if mirror == "None" else mirror
180
-
181
215
  nzo_ids = []
182
- info(f'Attempting download for "{title}"')
216
+ info(f'Attempting download for "{parsed_payload["title"]}"')
183
217
  request_from = "lazylibrarian"
184
218
 
185
219
  downloaded = download(
186
220
  shared_state,
187
221
  request_from,
188
- title,
189
- url,
190
- mirror,
191
- size_mb,
192
- password or None,
193
- imdb_id or None,
222
+ parsed_payload["title"],
223
+ parsed_payload["url"],
224
+ parsed_payload["mirror"],
225
+ parsed_payload["size_mb"],
226
+ parsed_payload["password"],
227
+ parsed_payload["imdb_id"],
228
+ parsed_payload["source_key"],
194
229
  )
195
230
 
196
231
  try:
197
232
  success = downloaded["success"]
198
233
  package_id = downloaded["package_id"]
199
- title = downloaded.get("title", title)
234
+ title = downloaded.get("title", parsed_payload["title"])
200
235
 
201
236
  if success:
202
237
  info(f'"{title}" added successfully!')
@@ -204,7 +239,7 @@ def setup_arr_routes(app):
204
239
  info(f'"{title}" added unsuccessfully! See log for details.')
205
240
  nzo_ids.append(package_id)
206
241
  except KeyError:
207
- info(f'Failed to download "{title}" - no package_id returned')
242
+ info(f'Failed to download "{parsed_payload["title"]}" - no package_id returned')
208
243
 
209
244
  return {
210
245
  "status": True,
@@ -353,7 +388,8 @@ def setup_arr_routes(app):
353
388
  <enclosure url="{release.get("link", "")}" length="{release.get("size", 0)}" type="application/x-nzb" />
354
389
  </item>'''
355
390
 
356
- requires_placeholder_item = not getattr(request.query, 'imdbid', '') and not getattr(request.query, 'q', '')
391
+ requires_placeholder_item = not getattr(request.query, 'imdbid', '') and not getattr(request.query,
392
+ 'q', '')
357
393
  if requires_placeholder_item and not items:
358
394
  items = f'''
359
395
  <item>
@@ -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():
@@ -13,21 +13,17 @@ def setup_packages_routes(app):
13
13
  def delete_package_route(package_id):
14
14
  success = delete_package(shared_state, package_id)
15
15
 
16
+ # Redirect back to packages page with status message via query param
17
+ from bottle import redirect
16
18
  if success:
17
- return render_centered_html(f'''
18
- <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
19
- <p>✅ Package deleted successfully.</p>
20
- <p>{render_button("Back to Packages", "primary", {"onclick": "location.href='/packages'"})}</p>
21
- ''')
19
+ redirect('/packages?deleted=1')
22
20
  else:
23
- return render_centered_html(f'''
24
- <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
25
- <p>❌ Failed to delete package.</p>
26
- <p>{render_button("Back to Packages", "secondary", {"onclick": "location.href='/packages'"})}</p>
27
- ''')
21
+ redirect('/packages?deleted=0')
28
22
 
29
23
  @app.get('/packages')
30
24
  def packages_status():
25
+ from bottle import request
26
+
31
27
  try:
32
28
  device = shared_state.values["device"]
33
29
  except KeyError:
@@ -41,6 +37,14 @@ def setup_packages_routes(app):
41
37
  <p>{back_btn}</p>
42
38
  ''')
43
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
+
44
48
  # Get packages data
45
49
  downloads = get_packages(shared_state)
46
50
  queue = downloads.get('queue', [])
@@ -143,29 +147,43 @@ def setup_packages_routes(app):
143
147
  def render_history_item(item):
144
148
  name = item.get('name', 'Unknown')
145
149
  status = item.get('status', 'Unknown')
146
- category = item.get('category', 'not_quasarr')
147
150
  bytes_val = item.get('bytes', 0)
148
- is_archive = item.get('is_archive')
149
- extraction_ok = item.get('extraction_ok', False)
151
+ category = item.get('category', 'not_quasarr')
152
+ is_archive = item.get('is_archive', False)
153
+ extraction_status = item.get('extraction_status', '')
150
154
  fail_message = item.get('fail_message', '')
151
155
  nzo_id = item.get('nzo_id', '')
152
156
 
153
- status_emoji = '✅' if status == 'Completed' else ''
154
-
155
- archive_badge = ''
156
- if is_archive:
157
- archive_badge = '<span class="badge extracted">📁 EXTRACTED</span>' if extraction_ok else '<span class="badge pending">📁 NOT EXTRACTED</span>'
157
+ is_error = status.lower() in ['failed', 'error'] or fail_message
158
+ card_class = 'package-card error' if is_error else 'package-card'
158
159
 
159
160
  cat_emoji = get_category_emoji(category)
160
161
  size_str = format_size(bytes_val=bytes_val)
161
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 '✅'
162
173
  error_html = f'<div class="package-error">⚠️ {fail_message}</div>' if fail_message else ''
163
- error_class = 'error' if status != 'Completed' else ''
164
174
 
165
- delete_btn = f'<div class="package-actions right-only"><button class="btn-small danger" onclick="confirmDelete(\'{nzo_id}\', \'{escape_js(name)}\')">🗑️</button></div>' if nzo_id else ''
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 = ''
166
184
 
167
185
  return f'''
168
- <div class="package-card {error_class}">
186
+ <div class="{card_class}">
169
187
  <div class="package-header">
170
188
  <span class="status-emoji">{status_emoji}</span>
171
189
  <span class="package-name">{name}</span>
@@ -176,24 +194,46 @@ def setup_packages_routes(app):
176
194
  <span>{cat_emoji} {category}</span>
177
195
  </div>
178
196
  {error_html}
179
- {delete_btn}
197
+ {actions}
180
198
  </div>
181
199
  '''
182
200
 
183
- # Build HTML sections
184
- queue_html = ''.join(render_queue_item(item) for item in
185
- quasarr_queue) if quasarr_queue else '<p class="empty-message">No Quasarr packages in queue</p>'
186
- history_html = ''.join(render_history_item(item) for item in
187
- quasarr_history) if quasarr_history else '<p class="empty-message">No Quasarr packages in history</p>'
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
+ '''
188
224
 
225
+ # Build "other packages" section (non-Quasarr)
189
226
  other_html = ''
190
- if other_queue or other_history:
191
- other_count = len(other_queue) + len(other_history)
227
+ other_count = len(other_queue) + len(other_history)
228
+ if other_count > 0:
192
229
  other_items = ''
193
230
  if other_queue:
194
- other_items += '<h4>⏳ Queue</h4>' + ''.join(render_queue_item(item) for item in 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)
195
233
  if other_history:
196
- other_items += '<h4>📜 History</h4>' + ''.join(render_history_item(item) for item in 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
+
197
237
  plural = 's' if other_count != 1 else ''
198
238
  other_html = f'''
199
239
  <div class="other-packages-section">
@@ -204,36 +244,30 @@ def setup_packages_routes(app):
204
244
  </div>
205
245
  '''
206
246
 
207
- queue_extra = f" + {len(other_queue)} other" if other_queue else ""
208
- history_extra = f" + {len(other_history)} other" if other_history else ""
209
-
210
- refresh_btn = render_button("Refresh Now", "primary", {"onclick": "location.reload()"})
211
247
  back_btn = render_button("Back", "secondary", {"onclick": "location.href='/'"})
212
248
 
213
249
  packages_html = f'''
214
250
  <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
215
- <h2>📦 Package Status</h2>
216
- <div class="refresh-indicator"><span id="countdown">30</span>s until refresh</div>
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>
217
258
 
218
259
  <div class="packages-container">
219
- <div class="section">
220
- <h3>⏳ Queue ({len(quasarr_queue)}{queue_extra})</h3>
221
- <div class="packages-list">{queue_html}</div>
222
- </div>
223
- <div class="section">
224
- <h3>📜 History ({len(quasarr_history)}{history_extra})</h3>
225
- <div class="packages-list">{history_html}</div>
226
- </div>
260
+ {queue_html}
261
+ {history_html}
227
262
  {other_html}
228
263
  </div>
229
264
 
230
- <p>{refresh_btn}</p>
231
265
  <p>{back_btn}</p>
232
266
 
233
- <!-- Delete Confirmation Modal -->
234
- <div id="deleteModal" class="modal">
267
+ <!-- Delete confirmation modal -->
268
+ <div class="modal" id="deleteModal">
235
269
  <div class="modal-content">
236
- <h3>⚠️ Delete Package?</h3>
270
+ <h3>🗑️ Delete Package?</h3>
237
271
  <p class="modal-package-name" id="modalPackageName"></p>
238
272
  <div class="modal-warning">
239
273
  <strong>⛔ Warning:</strong> This will permanently delete the package AND all associated files from disk. This action cannot be undone!
@@ -289,7 +323,8 @@ def setup_packages_routes(app):
289
323
  .btn-small.danger:hover {{ background: var(--btn-danger-hover-bg, #dc3545); color: white; }}
290
324
 
291
325
  .empty-message {{ color: var(--text-muted, #888); font-style: italic; text-align: center; padding: 20px; }}
292
- .refresh-indicator {{ text-align: center; font-size: 0.85em; color: var(--text-muted, #888); margin-bottom: 15px; }}
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; }}
293
328
 
294
329
  .other-packages-section {{ margin-top: 30px; padding-top: 20px; border-top: 1px solid var(--border-color, #ddd); }}
295
330
  .other-packages-section summary {{ cursor: pointer; padding: 8px 0; color: var(--text-muted, #666); }}
@@ -297,12 +332,30 @@ def setup_packages_routes(app):
297
332
  .other-packages-content {{ margin-top: 15px; }}
298
333
  .other-packages-content h4 {{ margin: 15px 0 10px 0; font-size: 0.95em; color: var(--text-muted, #666); }}
299
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
+
300
353
  /* Modal */
301
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; }}
302
355
  .modal.show {{ display: flex; }}
303
356
  .modal-content {{ background: var(--modal-bg, white); padding: 25px; border-radius: 12px; max-width: 400px; width: 90%; text-align: center; }}
304
357
  .modal-content h3 {{ margin: 0 0 15px 0; color: var(--error-msg-color, #c62828); }}
305
- .modal-package-name {{ font-weight: 500; word-break: break-word; padding: 10px; background: var(--card-bg, #f5f5f5); border-radius: 6px; margin: 10px 0; }}
358
+ .modal-package-name {{ font-weight: 500; word-break: break-word; padding: 10px; background: var(--code-bg, #f5f5f5); border-radius: 6px; margin: 10px 0; }}
306
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; }}
307
360
  .modal-buttons {{ display: flex; gap: 10px; justify-content: center; margin-top: 20px; }}
308
361
  .btn-danger {{ background: var(--btn-danger-bg, #dc3545); color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-weight: 500; }}
@@ -314,19 +367,20 @@ def setup_packages_routes(app):
314
367
  --card-bg: #2d3748; --card-border: #4a5568; --card-shadow: rgba(0,0,0,0.3);
315
368
  --border-color: #4a5568; --text-muted: #a0aec0;
316
369
  --progress-track: #4a5568; --progress-fill: #68d391;
317
- --error-border: #fc8181; --error-bg: #2d2d2d; --error-msg-bg: #3d2d2d; --error-msg-color: #fc8181;
370
+ --error-border: #fc8181; --error-bg: #3d2d2d; --error-msg-bg: #3d2d2d; --error-msg-color: #fc8181;
318
371
  --badge-archive-bg: #1a365d; --badge-archive-color: #63b3ed;
319
372
  --badge-success-bg: #1c4532; --badge-success-color: #68d391;
320
373
  --badge-warning-bg: #3d2d1a; --badge-warning-color: #f6ad55;
321
- --link-color: #63b3ed; --modal-bg: #2d3748;
374
+ --link-color: #63b3ed; --modal-bg: #2d3748; --code-bg: #1a202c;
322
375
  --btn-primary-bg: #3182ce; --btn-primary-hover: #2c5282;
323
376
  --btn-danger-text: #fc8181; --btn-danger-border: #fc8181; --btn-danger-hover-bg: #e53e3e;
377
+ --success-bg: #1c4532; --success-color: #68d391; --success-border: #276749;
324
378
  }}
325
379
  }}
326
380
  </style>
327
381
 
328
382
  <script>
329
- let countdown = 30;
383
+ let countdown = 10;
330
384
  const countdownEl = document.getElementById('countdown');
331
385
  const refreshInterval = setInterval(() => {{
332
386
  countdown--;
@@ -334,6 +388,13 @@ def setup_packages_routes(app):
334
388
  if (countdown <= 0) location.reload();
335
389
  }}, 1000);
336
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
+
337
398
  // Restore collapse state from localStorage
338
399
  const otherDetails = document.getElementById('otherPackagesDetails');
339
400
  const otherSummary = document.getElementById('otherPackagesSummary');
@@ -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,28 +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
-
236
- # Problem, we should make this id deterministic, so same source and same request_from (radarr / sonarr, not their version!) must yield same hash
237
- package_id = f"Quasarr_{category}_{str(hash(title + url)).replace('-', '')}"
238
-
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
+ """
239
314
  if imdb_id and imdb_id.lower() == "none":
240
315
  imdb_id = None
241
316
 
242
317
  config = shared_state.values["config"]("Hostnames")
243
318
 
319
+ # Extract client type (without version) for deterministic hashing
320
+ client_type = extract_client_type(request_from)
321
+
244
322
  # Find matching source - all getters have unified signature
245
323
  source_result = None
246
324
  label = None
325
+ detected_source_key = None
247
326
 
248
327
  for key, getter in SOURCE_GETTERS.items():
249
328
  hostname = config.get(key)
250
329
  if hostname and hostname.lower() in url.lower():
251
330
  source_result = getter(shared_state, url, mirror, title, password)
252
331
  label = key.upper()
332
+ detected_source_key = key
253
333
  break
254
334
 
255
335
  # No source matched - check if URL is a known crypter directly
@@ -259,6 +339,14 @@ def download(shared_state, request_from, title, url, mirror, size_mb, password,
259
339
  # For direct crypter URLs, we only know the crypter type, not the hoster inside
260
340
  source_result = {"links": [[url, crypter]]}
261
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)
262
350
 
263
351
  if source_result is None:
264
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,