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
quasarr/api/__init__.py CHANGED
@@ -8,9 +8,11 @@ import quasarr.providers.html_images as images
8
8
  from quasarr.api.arr import setup_arr_routes
9
9
  from quasarr.api.captcha import setup_captcha_routes
10
10
  from quasarr.api.config import setup_config
11
+ from quasarr.api.packages import setup_packages_routes
11
12
  from quasarr.api.sponsors_helper import setup_sponsors_helper_routes
12
13
  from quasarr.api.statistics import setup_statistics
13
14
  from quasarr.providers import shared_state
15
+ from quasarr.providers.auth import add_auth_routes, add_auth_hook, show_logout_link
14
16
  from quasarr.providers.html_templates import render_button, render_centered_html
15
17
  from quasarr.providers.web_server import Server
16
18
  from quasarr.storage.config import Config
@@ -21,158 +23,374 @@ def get_api(shared_state_dict, shared_state_lock):
21
23
 
22
24
  app = Bottle()
23
25
 
26
+ # Auth: routes must come first, then hook
27
+ add_auth_routes(app)
28
+ add_auth_hook(app, whitelist_prefixes=['/api', '/api/' '/sponsors_helper/', '/download/'])
29
+
24
30
  setup_arr_routes(app)
25
31
  setup_captcha_routes(app)
26
32
  setup_config(app, shared_state)
27
33
  setup_statistics(app, shared_state)
28
34
  setup_sponsors_helper_routes(app)
35
+ setup_packages_routes(app)
29
36
 
30
37
  @app.get('/')
31
38
  def index():
32
39
  protected = shared_state.get_db("protected").retrieve_all_titles()
33
40
  api_key = Config('API').get('key')
41
+
42
+ # Get quick status summary
43
+ try:
44
+ device = shared_state.values.get("device")
45
+ jd_connected = device is not None
46
+ except:
47
+ jd_connected = False
48
+
49
+ # CAPTCHA banner
34
50
  captcha_hint = ""
35
51
  if protected:
36
52
  plural = 's' if len(protected) > 1 else ''
37
- captcha_hint += f"""
38
- <div class="section">
39
- <h2>🔒 Link{plural} waiting for CAPTCHA solution</h2>
40
- """
41
-
42
- if not shared_state.values.get("helper_active"):
43
- captcha_hint += f"""
44
- <p>
45
- <a href="https://github.com/rix1337/Quasarr?tab=readme-ov-file#sponsorshelper" target="_blank">
46
- Sponsors get automated CAPTCHA solutions!
47
- </a>
48
- </p>
49
- """
50
-
51
- plural = 's' if len(protected) > 1 else ''
52
- captcha_hint += f"""
53
- <p>{render_button(f"Solve CAPTCHA{plural}", 'primary', {'onclick': "location.href='/captcha'"})}</p>
53
+ captcha_hint = f"""
54
+ <div class="alert alert-warning">
55
+ <span class="alert-icon">🔒</span>
56
+ <div class="alert-content">
57
+ <strong>{len(protected)} link{plural} waiting for CAPTCHA</strong>
58
+ {"" if shared_state.values.get("helper_active") else '<br><a href="https://github.com/rix1337/Quasarr?tab=readme-ov-file#sponsorshelper" target="_blank">Sponsors get automated CAPTCHA solutions!</a>'}
59
+ </div>
60
+ <div class="alert-action">
61
+ {render_button(f"Solve CAPTCHA{plural}", 'primary', {'onclick': "location.href='/captcha'"})}
62
+ </div>
54
63
  </div>
55
- <hr>
56
64
  """
57
65
 
66
+ # JDownloader status
67
+ jd_status = f"""
68
+ <div class="status-bar">
69
+ <span class="status-pill {'success' if jd_connected else 'error'}">
70
+ {'✅' if jd_connected else '❌'} JDownloader {'Connected' if jd_connected else 'Disconnected'}
71
+ </span>
72
+ </div>
73
+ """
74
+
58
75
  info = f"""
59
76
  <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
60
77
 
78
+ {jd_status}
61
79
  {captcha_hint}
62
80
 
63
- <div class="section">
64
- <h2>📖 Setup Instructions</h2>
65
- <p>
66
- <a href="https://github.com/rix1337/Quasarr?tab=readme-ov-file#instructions" target="_blank">
67
- Refer to the README for detailed instructions.
68
- </a>
69
- </p>
81
+ <div class="quick-actions">
82
+ <a href="/packages" class="action-card">
83
+ <span class="action-icon">📦</span>
84
+ <span class="action-label">Packages</span>
85
+ </a>
86
+ <a href="/statistics" class="action-card">
87
+ <span class="action-icon">📊</span>
88
+ <span class="action-label">Statistics</span>
89
+ </a>
90
+ <a href="/hostnames" class="action-card">
91
+ <span class="action-icon">🌐</span>
92
+ <span class="action-label">Hostnames</span>
93
+ </a>
94
+ <a href="/flaresolverr" class="action-card">
95
+ <span class="action-icon">🛡️</span>
96
+ <span class="action-label">FlareSolverr</span>
97
+ </a>
70
98
  </div>
71
99
 
72
- <hr>
73
-
74
100
  <div class="section">
75
- <h2>⚙️ API Configuration</h2>
76
- <p>Use the URL and API Key below to set up a <strong>Newznab Indexer</strong> and <strong>SABnzbd Download Client</strong> in Radarr/Sonarr:</p>
77
-
78
101
  <details id="apiDetails">
79
- <summary id="apiSummary">Show API Settings</summary>
102
+ <summary id="apiSummary">⚙️ API Configuration</summary>
80
103
  <div class="api-settings">
104
+ <p class="api-hint">Use these settings for <strong>Newznab Indexer</strong> and <strong>SABnzbd Download Client</strong> in Radarr/Sonarr</p>
81
105
 
82
- <h3>URL</h3>
83
- <div class="url-wrapper">
84
- <input id="urlInput" class="copy-input" type="text" readonly value="{shared_state.values['internal_address']}" />
85
- <button id="copyUrl" class="btn-primary small">Copy</button>
106
+ <div class="input-group">
107
+ <label>URL</label>
108
+ <div class="input-row">
109
+ <input id="urlInput" type="text" readonly value="{shared_state.values['internal_address']}" />
110
+ <button id="copyUrl" type="button">Copy</button>
111
+ </div>
86
112
  </div>
87
113
 
88
- <h3>API Key</h3>
89
- <div class="api-key-wrapper">
90
- <input id="apiKeyInput" class="copy-input" type="password" readonly value="{api_key}" />
91
- <button id="toggleKey" class="btn-secondary small">Show</button>
92
- <button id="copyKey" class="btn-primary small">Copy</button>
114
+ <div class="input-group">
115
+ <label>API Key</label>
116
+ <div class="input-row">
117
+ <input id="apiKeyInput" type="password" readonly value="{api_key}" />
118
+ <button id="toggleKey" type="button">Show</button>
119
+ <button id="copyKey" type="button">Copy</button>
120
+ </div>
93
121
  </div>
94
122
 
95
- <p>{render_button("Regenerate API key", "secondary", {"onclick": "if(confirm('Regenerate API key?')) location.href='/regenerate-api-key';"})}</p>
123
+ <p style="margin-top: 15px;">
124
+ {render_button("Regenerate API key", "secondary", {"onclick": "if(confirm('Regenerate API key?')) location.href='/regenerate-api-key';"})}
125
+ </p>
96
126
  </div>
97
127
  </details>
98
128
  </div>
99
129
 
100
- <hr>
101
-
102
- <div class="section">
103
- <h2>🔧 Quick Actions</h2>
104
- <p><button class="btn-primary" onclick="location.href='/hostnames'">Update Hostnames</button></p>
105
- <p><button class="btn-primary" onclick="location.href='/flaresolverr'">Configure FlareSolverr</button></p>
106
- <p><button class="btn-primary" onclick="location.href='/statistics'">View Statistics</button></p>
130
+ <div class="section help-link">
131
+ <a href="https://github.com/rix1337/Quasarr?tab=readme-ov-file#instructions" target="_blank">
132
+ 📖 Setup Instructions & Documentation
133
+ </a>
107
134
  </div>
108
135
 
109
136
  <style>
110
- .section {{ margin: 20px 0; }}
111
- .api-settings {{ padding: 15px 0; }}
112
- hr {{ margin: 25px 0; border: none; border-top: 1px solid #ddd; }}
113
- details {{ margin: 10px 0; }}
114
- summary {{
115
- cursor: pointer;
116
- padding: 8px 0;
137
+ .status-bar {{
138
+ display: flex;
139
+ justify-content: center;
140
+ gap: 20px;
141
+ margin-bottom: 20px;
142
+ flex-wrap: wrap;
143
+ }}
144
+ .status-item {{
145
+ font-size: 0.9em;
146
+ padding: 6px 12px;
147
+ border-radius: 20px;
148
+ background: var(--status-bg, #f5f5f5);
149
+ }}
150
+ .status-ok {{ color: var(--status-ok, #2e7d32); }}
151
+ .status-error {{ color: var(--status-error, #c62828); }}
152
+
153
+ .alert {{
154
+ display: flex;
155
+ flex-direction: column;
156
+ align-items: center;
157
+ text-align: center;
158
+ gap: 12px;
159
+ padding: 20px;
160
+ border-radius: 8px;
161
+ margin-bottom: 25px;
162
+ }}
163
+ .alert-warning {{
164
+ background: var(--alert-warning-bg, #fff3e0);
165
+ border: 1px solid var(--alert-warning-border, #ffb74d);
166
+ }}
167
+ .alert-icon {{ font-size: 1.5em; }}
168
+ .alert-content {{ }}
169
+ .alert-content a {{ color: var(--link-color, #0066cc); }}
170
+ .alert-action {{ margin-top: 5px; }}
171
+
172
+ .quick-actions {{
173
+ display: grid;
174
+ grid-template-columns: repeat(4, 1fr);
175
+ gap: 12px;
176
+ max-width: 500px;
177
+ margin: 0 auto 30px auto;
178
+ }}
179
+ @media (max-width: 500px) {{
180
+ .quick-actions {{
181
+ grid-template-columns: repeat(2, 1fr);
182
+ }}
183
+ }}
184
+ .action-card {{
185
+ display: flex;
186
+ flex-direction: column;
187
+ align-items: center;
188
+ padding: 15px 10px;
189
+ background: var(--card-bg, #f8f9fa);
190
+ border: 1px solid var(--card-border, #dee2e6);
191
+ border-radius: 10px;
192
+ text-decoration: none;
193
+ color: inherit;
194
+ transition: transform 0.2s, box-shadow 0.2s;
195
+ }}
196
+ .action-card:hover {{
197
+ transform: translateY(-2px);
198
+ box-shadow: 0 4px 12px var(--card-shadow, rgba(0,0,0,0.1));
199
+ border-color: var(--card-hover-border, #007bff);
200
+ }}
201
+ .action-icon {{ font-size: 1.8em; margin-bottom: 5px; }}
202
+ .action-label {{ font-size: 0.85em; font-weight: 500; }}
203
+
204
+ .section {{ margin: 20px 0; max-width: 500px; margin-left: auto; margin-right: auto; }}
205
+ details {{ background: var(--card-bg, #f8f9fa); border: 1px solid var(--card-border, #dee2e6); border-radius: 8px; }}
206
+ summary {{
207
+ cursor: pointer;
208
+ padding: 12px 15px;
209
+ font-weight: 500;
210
+ list-style: none;
211
+ }}
212
+ summary::-webkit-details-marker {{ display: none; }}
213
+ summary::before {{ content: '▶ '; font-size: 0.8em; }}
214
+ details[open] summary::before {{ content: '▼ '; }}
215
+ summary:hover {{ color: var(--link-color, #0066cc); }}
216
+
217
+ .api-settings {{ padding: 15px; border-top: 1px solid var(--card-border, #dee2e6); }}
218
+ .api-hint {{ font-size: 0.9em; color: var(--text-muted, #666); margin-bottom: 15px; }}
219
+ .input-group {{ margin-bottom: 15px; }}
220
+ .input-group label {{ display: block; font-weight: 500; margin-bottom: 6px; font-size: 0.95em; text-align: left; }}
221
+ .input-row {{
222
+ display: flex;
223
+ gap: 8px;
224
+ align-items: stretch;
225
+ }}
226
+ .input-row input {{
227
+ flex: 1;
228
+ padding: 8px 12px;
229
+ border: 1px solid var(--input-border, #ced4da);
230
+ border-radius: 4px;
231
+ font-family: monospace;
232
+ font-size: 0.9em;
233
+ background: var(--input-bg, #e9ecef);
234
+ color: var(--fg-color, #212529);
235
+ min-width: 0;
236
+ margin: 0;
237
+ }}
238
+ .input-row button {{
239
+ padding: 8px 16px;
240
+ border: none;
241
+ border-radius: 4px;
242
+ cursor: pointer;
243
+ font-size: 0.9em;
244
+ font-weight: 500;
245
+ transition: background 0.2s;
246
+ white-space: nowrap;
247
+ margin: 0;
248
+ flex-shrink: 0;
249
+ }}
250
+ #copyUrl, #copyKey {{
251
+ background: var(--btn-primary-bg, #007bff);
252
+ color: white;
253
+ }}
254
+ #copyUrl:hover, #copyKey:hover {{
255
+ background: var(--btn-primary-hover, #0056b3);
256
+ }}
257
+ #toggleKey {{
258
+ background: var(--btn-secondary-bg, #6c757d);
259
+ color: white;
260
+ }}
261
+ #toggleKey:hover {{
262
+ background: var(--btn-secondary-hover, #545b62);
263
+ }}
264
+
265
+ .help-link {{
266
+ text-align: center;
267
+ padding: 15px;
268
+ background: var(--card-bg, #f8f9fa);
269
+ border: 1px solid var(--card-border, #dee2e6);
270
+ border-radius: 8px;
271
+ }}
272
+ .help-link a {{
273
+ color: var(--link-color, #0066cc);
274
+ text-decoration: none;
117
275
  font-weight: 500;
118
276
  }}
119
- summary:hover {{
120
- color: #0066cc;
277
+ .help-link a:hover {{ text-decoration: underline; }}
278
+
279
+ .logout-link {{
280
+ display: block;
281
+ text-align: center;
282
+ margin-top: 20px;
283
+ font-size: 0.85em;
284
+ }}
285
+ .logout-link a {{
286
+ color: var(--text-muted, #666);
287
+ text-decoration: none;
288
+ }}
289
+ .logout-link a:hover {{ text-decoration: underline; }}
290
+
291
+ /* Dark mode */
292
+ @media (prefers-color-scheme: dark) {{
293
+ :root {{
294
+ --status-bg: #2d3748;
295
+ --status-ok: #68d391;
296
+ --status-error: #fc8181;
297
+ --alert-warning-bg: #3d3520;
298
+ --alert-warning-border: #d69e2e;
299
+ --card-bg: #2d3748;
300
+ --card-border: #4a5568;
301
+ --card-shadow: rgba(0,0,0,0.3);
302
+ --card-hover-border: #63b3ed;
303
+ --text-muted: #a0aec0;
304
+ --link-color: #63b3ed;
305
+ --input-bg: #1a202c;
306
+ --input-border: #4a5568;
307
+ --btn-primary-bg: #3182ce;
308
+ --btn-primary-hover: #2c5282;
309
+ --btn-secondary-bg: #4a5568;
310
+ --btn-secondary-hover: #2d3748;
311
+ }}
121
312
  }}
122
313
  </style>
123
314
 
124
315
  <script>
125
- const urlInput = document.getElementById('urlInput');
126
- const copyUrlBtn = document.getElementById('copyUrl');
127
-
128
- if (copyUrlBtn) {{
129
- copyUrlBtn.onclick = () => {{
130
- urlInput.select();
131
- document.execCommand('copy');
132
- copyUrlBtn.innerText = 'Copied!';
133
- setTimeout(() => {{ copyUrlBtn.innerText = 'Copy'; }}, 2000);
134
- }};
135
- }}
136
-
137
- const apiInput = document.getElementById('apiKeyInput');
138
- const toggleBtn = document.getElementById('toggleKey');
139
- const copyBtn = document.getElementById('copyKey');
140
-
141
- if (toggleBtn) {{
142
- toggleBtn.onclick = () => {{
143
- const isHidden = apiInput.type === 'password';
144
- apiInput.type = isHidden ? 'text' : 'password';
145
- toggleBtn.innerText = isHidden ? 'Hide' : 'Show';
146
- }};
147
- }}
148
-
149
- if (copyBtn) {{
150
- copyBtn.onclick = () => {{
151
- apiInput.type = 'text';
152
- apiInput.select();
153
- document.execCommand('copy');
154
- copyBtn.innerText = 'Copied!';
155
- toggleBtn.innerText = 'Hide';
156
- setTimeout(() => {{ copyBtn.innerText = 'Copy'; }}, 2000);
157
- }};
158
- }}
159
-
160
- // Handle details toggle
161
- const apiDetails = document.getElementById('apiDetails');
162
- const apiSummary = document.getElementById('apiSummary');
163
-
164
- if (apiDetails && apiSummary) {{
165
- apiDetails.addEventListener('toggle', () => {{
166
- if (apiDetails.open) {{
167
- apiSummary.textContent = 'Hide API Settings';
168
- }} else {{
169
- apiSummary.textContent = 'Show API Settings';
170
- }}
171
- }});
172
- }}
316
+ (function() {{
317
+ var urlInput = document.getElementById('urlInput');
318
+ var copyUrlBtn = document.getElementById('copyUrl');
319
+ var apiInput = document.getElementById('apiKeyInput');
320
+ var toggleBtn = document.getElementById('toggleKey');
321
+ var copyKeyBtn = document.getElementById('copyKey');
322
+
323
+ function copyToClipboard(text, button, callback) {{
324
+ if (navigator.clipboard && navigator.clipboard.writeText) {{
325
+ navigator.clipboard.writeText(text).then(function() {{
326
+ var originalText = button.innerText;
327
+ button.innerText = 'Copied!';
328
+ setTimeout(function() {{
329
+ button.innerText = originalText;
330
+ if (callback) callback();
331
+ }}, 1500);
332
+ }}).catch(function() {{
333
+ fallbackCopy(text, button, callback);
334
+ }});
335
+ }} else {{
336
+ fallbackCopy(text, button, callback);
337
+ }}
338
+ }}
339
+
340
+ function fallbackCopy(text, button, callback) {{
341
+ var textarea = document.createElement('textarea');
342
+ textarea.value = text;
343
+ textarea.style.position = 'fixed';
344
+ textarea.style.opacity = '0';
345
+ document.body.appendChild(textarea);
346
+ textarea.select();
347
+ try {{
348
+ document.execCommand('copy');
349
+ var originalText = button.innerText;
350
+ button.innerText = 'Copied!';
351
+ setTimeout(function() {{
352
+ button.innerText = originalText;
353
+ if (callback) callback();
354
+ }}, 1500);
355
+ }} catch (e) {{
356
+ alert('Copy failed. Please copy manually.');
357
+ }}
358
+ document.body.removeChild(textarea);
359
+ }}
360
+
361
+ if (copyUrlBtn) {{
362
+ copyUrlBtn.onclick = function() {{
363
+ copyToClipboard(urlInput.value, copyUrlBtn);
364
+ }};
365
+ }}
366
+
367
+ if (copyKeyBtn) {{
368
+ copyKeyBtn.onclick = function() {{
369
+ copyToClipboard(apiInput.value, copyKeyBtn, function() {{
370
+ // Re-hide the API key after copying
371
+ apiInput.type = 'password';
372
+ toggleBtn.innerText = 'Show';
373
+ }});
374
+ }};
375
+ }}
376
+
377
+ if (toggleBtn) {{
378
+ toggleBtn.onclick = function() {{
379
+ if (apiInput.type === 'password') {{
380
+ apiInput.type = 'text';
381
+ toggleBtn.innerText = 'Hide';
382
+ }} else {{
383
+ apiInput.type = 'password';
384
+ toggleBtn.innerText = 'Show';
385
+ }}
386
+ }};
387
+ }}
388
+ }})();
173
389
  </script>
174
390
  """
175
- return render_centered_html(info)
391
+ # Add logout link for form auth
392
+ logout_html = '<a href="/logout">Logout</a>' if show_logout_link() else ''
393
+ return render_centered_html(info, footer_content=logout_html)
176
394
 
177
395
  @app.get('/regenerate-api-key')
178
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>