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.
- quasarr/api/__init__.py +324 -106
- quasarr/api/arr/__init__.py +56 -20
- quasarr/api/captcha/__init__.py +26 -1
- quasarr/api/config/__init__.py +1 -1
- quasarr/api/packages/__init__.py +435 -0
- quasarr/api/sponsors_helper/__init__.py +4 -0
- quasarr/downloads/__init__.py +96 -6
- quasarr/downloads/linkcrypters/filecrypt.py +1 -1
- quasarr/downloads/linkcrypters/hide.py +45 -6
- quasarr/providers/auth.py +250 -0
- quasarr/providers/html_templates.py +65 -10
- quasarr/providers/obfuscated.py +9 -7
- quasarr/providers/shared_state.py +24 -0
- 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 +11 -4
- 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/storage/setup.py +12 -0
- {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/METADATA +47 -24
- {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/RECORD +38 -36
- {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/WHEEL +0 -0
- {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/entry_points.txt +0 -0
- {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/licenses/LICENSE +0 -0
- {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/top_level.txt +0 -0
quasarr/api/captcha/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
quasarr/api/config/__init__.py
CHANGED
|
@@ -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)
|
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,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
|
-
"""
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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,
|