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/__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
|
|
38
|
-
<div class="
|
|
39
|
-
<
|
|
40
|
-
""
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
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="
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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"
|
|
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
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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():
|
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>
|