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 +3 -3
- quasarr/api/arr/__init__.py +56 -20
- quasarr/api/config/__init__.py +1 -1
- quasarr/api/packages/__init__.py +115 -54
- quasarr/downloads/__init__.py +96 -8
- quasarr/downloads/linkcrypters/filecrypt.py +1 -1
- quasarr/providers/html_templates.py +65 -10
- quasarr/providers/version.py +1 -1
- quasarr/search/sources/al.py +1 -1
- quasarr/search/sources/by.py +1 -1
- quasarr/search/sources/dd.py +2 -1
- quasarr/search/sources/dj.py +2 -2
- quasarr/search/sources/dl.py +8 -2
- quasarr/search/sources/dt.py +1 -1
- quasarr/search/sources/dw.py +6 -7
- quasarr/search/sources/fx.py +4 -4
- quasarr/search/sources/he.py +1 -1
- quasarr/search/sources/mb.py +1 -1
- quasarr/search/sources/nk.py +1 -1
- quasarr/search/sources/nx.py +1 -1
- quasarr/search/sources/sf.py +4 -2
- quasarr/search/sources/sj.py +2 -2
- quasarr/search/sources/sl.py +3 -3
- quasarr/search/sources/wd.py +1 -1
- quasarr/search/sources/wx.py +4 -3
- {quasarr-2.0.0.dist-info → quasarr-2.1.0.dist-info}/METADATA +36 -23
- {quasarr-2.0.0.dist-info → quasarr-2.1.0.dist-info}/RECORD +31 -31
- {quasarr-2.0.0.dist-info → quasarr-2.1.0.dist-info}/WHEEL +0 -0
- {quasarr-2.0.0.dist-info → quasarr-2.1.0.dist-info}/entry_points.txt +0 -0
- {quasarr-2.0.0.dist-info → quasarr-2.1.0.dist-info}/licenses/LICENSE +0 -0
- {quasarr-2.0.0.dist-info → quasarr-2.1.0.dist-info}/top_level.txt +0 -0
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-
|
|
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 = '<
|
|
393
|
-
return render_centered_html(info
|
|
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():
|
quasarr/api/arr/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
193
|
-
imdb_id
|
|
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,
|
|
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>
|
quasarr/api/config/__init__.py
CHANGED
quasarr/api/packages/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
{
|
|
197
|
+
{actions}
|
|
180
198
|
</div>
|
|
181
199
|
'''
|
|
182
200
|
|
|
183
|
-
# Build
|
|
184
|
-
queue_html = ''
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
|
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
|
|
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
|
|
216
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
|
234
|
-
<div
|
|
267
|
+
<!-- Delete confirmation modal -->
|
|
268
|
+
<div class="modal" id="deleteModal">
|
|
235
269
|
<div class="modal-content">
|
|
236
|
-
<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(--
|
|
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: #
|
|
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 =
|
|
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');
|
quasarr/downloads/__init__.py
CHANGED
|
@@ -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
|
-
"""
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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,
|