quasarr 1.26.6__py3-none-any.whl → 1.27.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/__init__.py +24 -208
- quasarr/api/config/__init__.py +115 -3
- quasarr/providers/sessions/al.py +21 -0
- quasarr/providers/sessions/dd.py +8 -1
- quasarr/providers/sessions/dl.py +34 -23
- quasarr/providers/sessions/nx.py +8 -1
- quasarr/providers/utils.py +168 -0
- quasarr/providers/version.py +1 -1
- quasarr/storage/config.py +3 -0
- quasarr/storage/setup.py +456 -15
- {quasarr-1.26.6.dist-info → quasarr-1.27.0.dist-info}/METADATA +79 -93
- {quasarr-1.26.6.dist-info → quasarr-1.27.0.dist-info}/RECORD +16 -15
- {quasarr-1.26.6.dist-info → quasarr-1.27.0.dist-info}/WHEEL +0 -0
- {quasarr-1.26.6.dist-info → quasarr-1.27.0.dist-info}/entry_points.txt +0 -0
- {quasarr-1.26.6.dist-info → quasarr-1.27.0.dist-info}/licenses/LICENSE +0 -0
- {quasarr-1.26.6.dist-info → quasarr-1.27.0.dist-info}/top_level.txt +0 -0
quasarr/storage/setup.py
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
|
+
from urllib.parse import urlparse
|
|
7
8
|
|
|
8
9
|
import requests
|
|
9
10
|
from bottle import Bottle, request, response
|
|
@@ -14,11 +15,70 @@ import quasarr.providers.sessions.al
|
|
|
14
15
|
import quasarr.providers.sessions.dd
|
|
15
16
|
import quasarr.providers.sessions.dl
|
|
16
17
|
import quasarr.providers.sessions.nx
|
|
17
|
-
from quasarr.providers.html_templates import render_button, render_form, render_success, render_fail
|
|
18
|
+
from quasarr.providers.html_templates import render_button, render_form, render_success, render_fail, \
|
|
19
|
+
render_centered_html
|
|
18
20
|
from quasarr.providers.log import info
|
|
19
21
|
from quasarr.providers.shared_state import extract_valid_hostname
|
|
22
|
+
from quasarr.providers.utils import extract_kv_pairs, extract_allowed_keys
|
|
20
23
|
from quasarr.providers.web_server import Server
|
|
21
24
|
from quasarr.storage.config import Config
|
|
25
|
+
from quasarr.storage.sqlite_database import DataBase
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render_reconnect_success(message, countdown_seconds=3):
|
|
29
|
+
"""Render a success page that waits, then polls until the server is back online."""
|
|
30
|
+
button_html = render_button(f"Continuing in {countdown_seconds}...", "secondary",
|
|
31
|
+
{"id": "reconnectBtn", "disabled": "true"})
|
|
32
|
+
|
|
33
|
+
script = f'''
|
|
34
|
+
<script>
|
|
35
|
+
var remaining = {countdown_seconds};
|
|
36
|
+
var btn = document.getElementById('reconnectBtn');
|
|
37
|
+
|
|
38
|
+
var interval = setInterval(function() {{
|
|
39
|
+
remaining--;
|
|
40
|
+
btn.innerText = 'Continuing in ' + remaining + '...';
|
|
41
|
+
if (remaining <= 0) {{
|
|
42
|
+
clearInterval(interval);
|
|
43
|
+
btn.innerText = 'Reconnecting...';
|
|
44
|
+
tryReconnect();
|
|
45
|
+
}}
|
|
46
|
+
}}, 1000);
|
|
47
|
+
|
|
48
|
+
function tryReconnect() {{
|
|
49
|
+
var attempts = 0;
|
|
50
|
+
function attempt() {{
|
|
51
|
+
attempts++;
|
|
52
|
+
fetch('/', {{ method: 'HEAD', cache: 'no-store' }})
|
|
53
|
+
.then(function(response) {{
|
|
54
|
+
if (response.ok) {{
|
|
55
|
+
btn.innerText = 'Connected! Reloading...';
|
|
56
|
+
btn.className = 'btn-primary';
|
|
57
|
+
setTimeout(function() {{ window.location.href = '/'; }}, 500);
|
|
58
|
+
}} else {{
|
|
59
|
+
scheduleRetry();
|
|
60
|
+
}}
|
|
61
|
+
}})
|
|
62
|
+
.catch(function() {{
|
|
63
|
+
scheduleRetry();
|
|
64
|
+
}});
|
|
65
|
+
}}
|
|
66
|
+
function scheduleRetry() {{
|
|
67
|
+
btn.innerText = 'Reconnecting... (attempt ' + attempts + ')';
|
|
68
|
+
setTimeout(attempt, 1000);
|
|
69
|
+
}}
|
|
70
|
+
attempt();
|
|
71
|
+
}}
|
|
72
|
+
</script>
|
|
73
|
+
'''
|
|
74
|
+
|
|
75
|
+
content = f'''<h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
|
|
76
|
+
<h2>✓ Success</h2>
|
|
77
|
+
<p>{message}</p>
|
|
78
|
+
{button_html}
|
|
79
|
+
{script}
|
|
80
|
+
'''
|
|
81
|
+
return render_centered_html(content)
|
|
22
82
|
|
|
23
83
|
|
|
24
84
|
def add_no_cache_headers(app):
|
|
@@ -81,22 +141,31 @@ def path_config(shared_state):
|
|
|
81
141
|
config_path = request.forms.get("config_path")
|
|
82
142
|
config_path = set_config_path(config_path)
|
|
83
143
|
quasarr.providers.web_server.temp_server_success = True
|
|
84
|
-
return
|
|
85
|
-
5)
|
|
144
|
+
return render_reconnect_success(f'Config path set to: "{config_path}"')
|
|
86
145
|
|
|
87
146
|
info(f'Starting web server for config at: "{shared_state.values['internal_address']}".')
|
|
88
147
|
info("Please set desired config path there!")
|
|
89
148
|
return Server(app, listen='0.0.0.0', port=shared_state.values['port']).serve_temporarily()
|
|
90
149
|
|
|
91
150
|
|
|
92
|
-
def hostname_form_html(shared_state, message):
|
|
151
|
+
def hostname_form_html(shared_state, message, show_restart_button=False, show_skip_management=False):
|
|
93
152
|
hostname_fields = '''
|
|
94
153
|
<label for="{id}" style="display:inline-flex; align-items:center; gap:4px;">{label}{img_html}</label>
|
|
95
154
|
<input type="text" id="{id}" name="{id}" placeholder="example.com" autocorrect="off" autocomplete="off" value="{value}"><br>
|
|
96
155
|
'''
|
|
97
156
|
|
|
157
|
+
skip_indicator = '''
|
|
158
|
+
<div class="skip-indicator" id="skip-indicator-{id}" style="margin-top:-0.5rem; margin-bottom:0.75rem; padding:0.5rem; background:var(--code-bg, #f8f9fa); border-radius:0.25rem; font-size:0.875rem;">
|
|
159
|
+
<span style="color:#dc3545;">⚠️ Login skipped</span>
|
|
160
|
+
<button type="button" class="btn-subtle" style="margin-left:0.5rem; padding:0.25rem 0.5rem; font-size:0.75rem;" onclick="clearSkipLogin('{id}', this)">Clear & require login</button>
|
|
161
|
+
</div>
|
|
162
|
+
'''
|
|
163
|
+
|
|
98
164
|
field_html = []
|
|
99
165
|
hostnames = Config('Hostnames') # Load once outside the loop
|
|
166
|
+
skip_login_db = DataBase("skip_login")
|
|
167
|
+
login_required_sites = ['al', 'dd', 'dl', 'nx']
|
|
168
|
+
|
|
100
169
|
for label in shared_state.values["sites"]:
|
|
101
170
|
field_id = label.lower()
|
|
102
171
|
img_html = ''
|
|
@@ -119,32 +188,116 @@ def hostname_form_html(shared_state, message):
|
|
|
119
188
|
value=current_value
|
|
120
189
|
))
|
|
121
190
|
|
|
191
|
+
# Add skip indicator for login-required sites if skip management is enabled
|
|
192
|
+
if show_skip_management and field_id in login_required_sites:
|
|
193
|
+
if current_value and skip_login_db.retrieve(field_id):
|
|
194
|
+
field_html.append(skip_indicator.format(id=field_id))
|
|
195
|
+
|
|
122
196
|
hostname_form_content = "".join(field_html)
|
|
123
197
|
button_html = render_button("Save", "primary", {"type": "submit", "id": "submitBtn"})
|
|
124
198
|
|
|
199
|
+
# Get stored hostnames URL if available
|
|
200
|
+
stored_url = Config('Settings').get("hostnames_url") or ""
|
|
201
|
+
|
|
202
|
+
# Build restart button HTML if needed
|
|
203
|
+
restart_section = ""
|
|
204
|
+
if show_restart_button:
|
|
205
|
+
restart_section = f'''
|
|
206
|
+
<div class="section-divider" style="margin-top:1.5rem; padding-top:1rem; border-top:1px solid var(--divider-color, #dee2e6);">
|
|
207
|
+
<p style="font-size:0.875rem; color:var(--secondary, #6c757d);">Restart required after changing login-required hostnames (AL, DD, DL, NX)</p>
|
|
208
|
+
{render_button("Restart Quasarr", "secondary", {"type": "button", "onclick": "confirmRestart()"})}
|
|
209
|
+
</div>
|
|
210
|
+
'''
|
|
211
|
+
|
|
125
212
|
template = """
|
|
213
|
+
<style>
|
|
214
|
+
.url-import-section {{
|
|
215
|
+
border: 1px solid var(--divider-color, #dee2e6);
|
|
216
|
+
border-radius: 0.5rem;
|
|
217
|
+
padding: 1rem;
|
|
218
|
+
margin-bottom: 1.5rem;
|
|
219
|
+
background: var(--code-bg, #f8f9fa);
|
|
220
|
+
}}
|
|
221
|
+
.url-import-section h3 {{
|
|
222
|
+
margin: 0 0 0.75rem 0;
|
|
223
|
+
font-size: 1rem;
|
|
224
|
+
font-weight: 600;
|
|
225
|
+
}}
|
|
226
|
+
.url-import-row {{
|
|
227
|
+
display: flex;
|
|
228
|
+
gap: 0.5rem;
|
|
229
|
+
align-items: stretch;
|
|
230
|
+
}}
|
|
231
|
+
.url-import-row input {{
|
|
232
|
+
flex: 1;
|
|
233
|
+
margin-bottom: 0;
|
|
234
|
+
}}
|
|
235
|
+
.url-import-row button {{
|
|
236
|
+
margin-top: 0;
|
|
237
|
+
white-space: nowrap;
|
|
238
|
+
}}
|
|
239
|
+
.import-status {{
|
|
240
|
+
margin-top: 0.5rem;
|
|
241
|
+
font-size: 0.875rem;
|
|
242
|
+
min-height: 1.25rem;
|
|
243
|
+
}}
|
|
244
|
+
.import-status.success {{ color: #198754; }}
|
|
245
|
+
.import-status.error {{ color: #dc3545; }}
|
|
246
|
+
.import-status.loading {{ color: var(--secondary, #6c757d); }}
|
|
247
|
+
.btn-subtle {{
|
|
248
|
+
background: transparent;
|
|
249
|
+
color: var(--fg-color, #212529);
|
|
250
|
+
border: 1px solid var(--btn-subtle-border, #ced4da);
|
|
251
|
+
padding: 0.25rem 0.5rem;
|
|
252
|
+
border-radius: 0.25rem;
|
|
253
|
+
cursor: pointer;
|
|
254
|
+
font-size: 0.875rem;
|
|
255
|
+
}}
|
|
256
|
+
.btn-subtle:hover {{
|
|
257
|
+
background: var(--btn-subtle-bg, #e9ecef);
|
|
258
|
+
}}
|
|
259
|
+
</style>
|
|
260
|
+
|
|
126
261
|
<div id="message" style="margin-bottom:0.5em;">{message}</div>
|
|
127
262
|
<div id="error-msg" style="color:red; margin-bottom:1em;"></div>
|
|
128
263
|
|
|
264
|
+
<div class="url-import-section">
|
|
265
|
+
<h3>📥 Import from URL</h3>
|
|
266
|
+
<div class="url-import-row">
|
|
267
|
+
<input type="text" id="hostnamesUrl" placeholder="https://pastebin.com/raw/..." value="{stored_url}" autocorrect="off" autocomplete="off">
|
|
268
|
+
<button type="button" class="btn-secondary" id="importBtn" onclick="importHostnames()">Import</button>
|
|
269
|
+
</div>
|
|
270
|
+
<div id="importStatus" class="import-status"></div>
|
|
271
|
+
<p style="font-size:0.75rem; color:var(--secondary, #6c757d); margin:0.5rem 0 0 0;">
|
|
272
|
+
Paste a URL containing hostname definitions (same format as --hostnames parameter)
|
|
273
|
+
</p>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
129
276
|
<form action="/api/hostnames" method="post" onsubmit="return validateHostnames(this)">
|
|
277
|
+
<input type="hidden" id="hostnamesUrlHidden" name="hostnames_url" value="{stored_url}">
|
|
130
278
|
{hostname_form_content}
|
|
131
279
|
{button}
|
|
132
280
|
</form>
|
|
133
281
|
|
|
282
|
+
{restart_section}
|
|
283
|
+
|
|
134
284
|
<script>
|
|
135
285
|
var formSubmitted = false;
|
|
286
|
+
|
|
136
287
|
function validateHostnames(form) {{
|
|
137
288
|
if (formSubmitted) return false;
|
|
138
289
|
|
|
139
290
|
var errorDiv = document.getElementById('error-msg');
|
|
140
291
|
errorDiv.textContent = '';
|
|
141
292
|
|
|
142
|
-
var inputs = form.querySelectorAll('input[type="text"]');
|
|
293
|
+
var inputs = form.querySelectorAll('input[type="text"]:not(#hostnamesUrl)');
|
|
143
294
|
for (var i = 0; i < inputs.length; i++) {{
|
|
144
295
|
if (inputs[i].value.trim() !== '') {{
|
|
145
296
|
formSubmitted = true;
|
|
146
297
|
var btn = document.getElementById('submitBtn');
|
|
147
298
|
if (btn) {{ btn.disabled = true; btn.textContent = 'Saving...'; }}
|
|
299
|
+
// Sync the URL field to hidden input
|
|
300
|
+
document.getElementById('hostnamesUrlHidden').value = document.getElementById('hostnamesUrl').value.trim();
|
|
148
301
|
return true;
|
|
149
302
|
}}
|
|
150
303
|
}}
|
|
@@ -153,12 +306,164 @@ def hostname_form_html(shared_state, message):
|
|
|
153
306
|
inputs[0].focus();
|
|
154
307
|
return false;
|
|
155
308
|
}}
|
|
309
|
+
|
|
310
|
+
function importHostnames() {{
|
|
311
|
+
var urlInput = document.getElementById('hostnamesUrl');
|
|
312
|
+
var url = urlInput.value.trim();
|
|
313
|
+
var statusDiv = document.getElementById('importStatus');
|
|
314
|
+
var importBtn = document.getElementById('importBtn');
|
|
315
|
+
|
|
316
|
+
if (!url) {{
|
|
317
|
+
statusDiv.className = 'import-status error';
|
|
318
|
+
statusDiv.textContent = 'Please enter a URL';
|
|
319
|
+
return;
|
|
320
|
+
}}
|
|
321
|
+
|
|
322
|
+
statusDiv.className = 'import-status loading';
|
|
323
|
+
statusDiv.textContent = 'Importing...';
|
|
324
|
+
importBtn.disabled = true;
|
|
325
|
+
importBtn.textContent = 'Importing...';
|
|
326
|
+
|
|
327
|
+
fetch('/api/hostnames/import-url', {{
|
|
328
|
+
method: 'POST',
|
|
329
|
+
headers: {{ 'Content-Type': 'application/json' }},
|
|
330
|
+
body: JSON.stringify({{ url: url }})
|
|
331
|
+
}})
|
|
332
|
+
.then(response => response.json())
|
|
333
|
+
.then(data => {{
|
|
334
|
+
importBtn.disabled = false;
|
|
335
|
+
importBtn.textContent = 'Import';
|
|
336
|
+
|
|
337
|
+
if (data.success) {{
|
|
338
|
+
var count = 0;
|
|
339
|
+
for (var key in data.hostnames) {{
|
|
340
|
+
var input = document.getElementById(key);
|
|
341
|
+
if (input) {{
|
|
342
|
+
input.value = data.hostnames[key];
|
|
343
|
+
count++;
|
|
344
|
+
}}
|
|
345
|
+
}}
|
|
346
|
+
statusDiv.className = 'import-status success';
|
|
347
|
+
var msg = 'Imported ' + count + ' hostname(s)';
|
|
348
|
+
if (data.errors && Object.keys(data.errors).length > 0) {{
|
|
349
|
+
msg += ' (' + Object.keys(data.errors).length + ' invalid)';
|
|
350
|
+
}}
|
|
351
|
+
statusDiv.textContent = msg + '. Review and click Save.';
|
|
352
|
+
}} else {{
|
|
353
|
+
statusDiv.className = 'import-status error';
|
|
354
|
+
statusDiv.textContent = data.error || 'Import failed';
|
|
355
|
+
}}
|
|
356
|
+
}})
|
|
357
|
+
.catch(error => {{
|
|
358
|
+
importBtn.disabled = false;
|
|
359
|
+
importBtn.textContent = 'Import';
|
|
360
|
+
statusDiv.className = 'import-status error';
|
|
361
|
+
statusDiv.textContent = 'Network error: ' + error.message;
|
|
362
|
+
}});
|
|
363
|
+
}}
|
|
364
|
+
|
|
365
|
+
function clearSkipLogin(shorthand, btnElement) {{
|
|
366
|
+
fetch('/api/skip-login/' + shorthand, {{ method: 'DELETE' }})
|
|
367
|
+
.then(response => response.json())
|
|
368
|
+
.then(data => {{
|
|
369
|
+
if (data.success) {{
|
|
370
|
+
// Remove the skip indicator using the button's parent
|
|
371
|
+
var indicator = btnElement.closest('.skip-indicator');
|
|
372
|
+
if (indicator) indicator.remove();
|
|
373
|
+
alert('Login requirement restored for ' + shorthand.toUpperCase() + '. Restart Quasarr to be prompted for credentials.');
|
|
374
|
+
}} else {{
|
|
375
|
+
alert('Failed to clear skip preference');
|
|
376
|
+
}}
|
|
377
|
+
}})
|
|
378
|
+
.catch(error => {{
|
|
379
|
+
alert('Error: ' + error.message);
|
|
380
|
+
}});
|
|
381
|
+
}}
|
|
382
|
+
|
|
383
|
+
function confirmRestart() {{
|
|
384
|
+
if (confirm('Restart Quasarr now? Any unsaved changes will be lost.')) {{
|
|
385
|
+
fetch('/api/restart', {{ method: 'POST' }})
|
|
386
|
+
.then(response => response.json())
|
|
387
|
+
.then(data => {{
|
|
388
|
+
if (data.success) {{
|
|
389
|
+
showRestartOverlay();
|
|
390
|
+
}}
|
|
391
|
+
}})
|
|
392
|
+
.catch(error => {{
|
|
393
|
+
// Expected - connection will be lost during restart
|
|
394
|
+
showRestartOverlay();
|
|
395
|
+
}});
|
|
396
|
+
}}
|
|
397
|
+
}}
|
|
398
|
+
|
|
399
|
+
function showRestartOverlay() {{
|
|
400
|
+
document.body.innerHTML = `
|
|
401
|
+
<div style="text-align:center; padding:2rem; font-family:system-ui,-apple-system,sans-serif;">
|
|
402
|
+
<h2>Restarting Quasarr...</h2>
|
|
403
|
+
<p id="restartStatus">Waiting <span id="countdown">10</span> seconds...</p>
|
|
404
|
+
<div id="spinner" style="display:none; margin-top:1rem;">
|
|
405
|
+
<div style="display:inline-block; width:24px; height:24px; border:3px solid #ccc; border-top-color:#333; border-radius:50%; animation:spin 1s linear infinite;"></div>
|
|
406
|
+
<style>@keyframes spin {{ to {{ transform: rotate(360deg); }} }}</style>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
`;
|
|
410
|
+
startCountdown(10);
|
|
411
|
+
}}
|
|
412
|
+
|
|
413
|
+
function startCountdown(seconds) {{
|
|
414
|
+
var countdownEl = document.getElementById('countdown');
|
|
415
|
+
var statusEl = document.getElementById('restartStatus');
|
|
416
|
+
var spinnerEl = document.getElementById('spinner');
|
|
417
|
+
|
|
418
|
+
var remaining = seconds;
|
|
419
|
+
var interval = setInterval(function() {{
|
|
420
|
+
remaining--;
|
|
421
|
+
if (countdownEl) countdownEl.textContent = remaining;
|
|
422
|
+
|
|
423
|
+
if (remaining <= 0) {{
|
|
424
|
+
clearInterval(interval);
|
|
425
|
+
statusEl.textContent = 'Reconnecting...';
|
|
426
|
+
spinnerEl.style.display = 'block';
|
|
427
|
+
tryReconnect();
|
|
428
|
+
}}
|
|
429
|
+
}}, 1000);
|
|
430
|
+
}}
|
|
431
|
+
|
|
432
|
+
function tryReconnect() {{
|
|
433
|
+
var statusEl = document.getElementById('restartStatus');
|
|
434
|
+
var attempts = 0;
|
|
435
|
+
|
|
436
|
+
function attempt() {{
|
|
437
|
+
attempts++;
|
|
438
|
+
fetch('/', {{ method: 'HEAD', cache: 'no-store' }})
|
|
439
|
+
.then(response => {{
|
|
440
|
+
if (response.ok) {{
|
|
441
|
+
statusEl.textContent = 'Connected! Reloading...';
|
|
442
|
+
setTimeout(function() {{ window.location.href = '/'; }}, 500);
|
|
443
|
+
}} else {{
|
|
444
|
+
scheduleRetry();
|
|
445
|
+
}}
|
|
446
|
+
}})
|
|
447
|
+
.catch(function() {{
|
|
448
|
+
scheduleRetry();
|
|
449
|
+
}});
|
|
450
|
+
}}
|
|
451
|
+
|
|
452
|
+
function scheduleRetry() {{
|
|
453
|
+
statusEl.textContent = 'Reconnecting... (attempt ' + attempts + ')';
|
|
454
|
+
setTimeout(attempt, 1000);
|
|
455
|
+
}}
|
|
456
|
+
|
|
457
|
+
attempt();
|
|
458
|
+
}}
|
|
156
459
|
</script>
|
|
157
460
|
"""
|
|
158
461
|
return template.format(
|
|
159
462
|
message=message,
|
|
160
463
|
hostname_form_content=hostname_form_content,
|
|
161
|
-
button=button_html
|
|
464
|
+
button=button_html,
|
|
465
|
+
stored_url=stored_url,
|
|
466
|
+
restart_section=restart_section
|
|
162
467
|
)
|
|
163
468
|
|
|
164
469
|
|
|
@@ -212,6 +517,11 @@ def save_hostnames(shared_state, timeout=5, first_run=True):
|
|
|
212
517
|
if old_val != '':
|
|
213
518
|
hostnames.save(shorthand, '')
|
|
214
519
|
|
|
520
|
+
# Handle hostnames URL storage
|
|
521
|
+
hostnames_url = request.forms.get('hostnames_url', '').strip()
|
|
522
|
+
settings_config = Config("Settings")
|
|
523
|
+
settings_config.save("hostnames_url", hostnames_url)
|
|
524
|
+
|
|
215
525
|
quasarr.providers.web_server.temp_server_success = True
|
|
216
526
|
|
|
217
527
|
# Build success message, include any per-site errors
|
|
@@ -227,7 +537,8 @@ def save_hostnames(shared_state, timeout=5, first_run=True):
|
|
|
227
537
|
if site.lower() in {'al', 'dd', 'dl', 'nx'}:
|
|
228
538
|
optional_text += f"{site.upper()}: You must restart Quasarr and follow additional steps to start using this site.<br>"
|
|
229
539
|
|
|
230
|
-
|
|
540
|
+
full_message = f"{success_msg}<br><small>{optional_text}</small>"
|
|
541
|
+
return render_reconnect_success(full_message)
|
|
231
542
|
|
|
232
543
|
|
|
233
544
|
def hostnames_config(shared_state):
|
|
@@ -248,6 +559,63 @@ def hostnames_config(shared_state):
|
|
|
248
559
|
def set_hostnames():
|
|
249
560
|
return save_hostnames(shared_state)
|
|
250
561
|
|
|
562
|
+
@app.post("/api/hostnames/import-url")
|
|
563
|
+
def import_hostnames_from_url():
|
|
564
|
+
"""Fetch URL and parse hostnames, return JSON for JS to populate fields."""
|
|
565
|
+
response.content_type = 'application/json'
|
|
566
|
+
try:
|
|
567
|
+
data = request.json
|
|
568
|
+
url = data.get('url', '').strip()
|
|
569
|
+
|
|
570
|
+
if not url:
|
|
571
|
+
return {"success": False, "error": "No URL provided"}
|
|
572
|
+
|
|
573
|
+
# Validate URL
|
|
574
|
+
parsed = urlparse(url)
|
|
575
|
+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
576
|
+
return {"success": False, "error": "Invalid URL format"}
|
|
577
|
+
|
|
578
|
+
if "/raw/eX4Mpl3" in url:
|
|
579
|
+
return {"success": False, "error": "Example URL detected. Please provide a real URL."}
|
|
580
|
+
|
|
581
|
+
# Fetch content
|
|
582
|
+
try:
|
|
583
|
+
resp = requests.get(url, timeout=15)
|
|
584
|
+
resp.raise_for_status()
|
|
585
|
+
content = resp.text
|
|
586
|
+
except requests.RequestException as e:
|
|
587
|
+
return {"success": False, "error": f"Failed to fetch URL: {str(e)}"}
|
|
588
|
+
|
|
589
|
+
# Parse hostnames
|
|
590
|
+
allowed_keys = extract_allowed_keys(Config._DEFAULT_CONFIG, 'Hostnames')
|
|
591
|
+
results = extract_kv_pairs(content, allowed_keys)
|
|
592
|
+
|
|
593
|
+
if not results:
|
|
594
|
+
return {"success": False, "error": "No hostnames found in the provided URL"}
|
|
595
|
+
|
|
596
|
+
# Validate each hostname
|
|
597
|
+
valid_hostnames = {}
|
|
598
|
+
invalid_hostnames = {}
|
|
599
|
+
for shorthand, hostname in results.items():
|
|
600
|
+
domain_check = extract_valid_hostname(hostname, shorthand)
|
|
601
|
+
domain = domain_check.get('domain')
|
|
602
|
+
if domain:
|
|
603
|
+
valid_hostnames[shorthand] = domain
|
|
604
|
+
else:
|
|
605
|
+
invalid_hostnames[shorthand] = domain_check.get('message', 'Invalid')
|
|
606
|
+
|
|
607
|
+
if not valid_hostnames:
|
|
608
|
+
return {"success": False, "error": "No valid hostnames found in the provided URL"}
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
"success": True,
|
|
612
|
+
"hostnames": valid_hostnames,
|
|
613
|
+
"errors": invalid_hostnames
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
except Exception as e:
|
|
617
|
+
return {"success": False, "error": f"Error: {str(e)}"}
|
|
618
|
+
|
|
251
619
|
info(f'Hostnames not set. Starting web server for config at: "{shared_state.values['internal_address']}".')
|
|
252
620
|
info("Please set at least one valid hostname there!")
|
|
253
621
|
return Server(app, listen='0.0.0.0', port=shared_state.values['port']).serve_temporarily()
|
|
@@ -271,10 +639,39 @@ def hostname_credentials_config(shared_state, shorthand, domain):
|
|
|
271
639
|
'''
|
|
272
640
|
|
|
273
641
|
form_html = f'''
|
|
642
|
+
<style>
|
|
643
|
+
.button-row {{
|
|
644
|
+
display: flex;
|
|
645
|
+
gap: 0.75rem;
|
|
646
|
+
justify-content: center;
|
|
647
|
+
flex-wrap: wrap;
|
|
648
|
+
margin-top: 1rem;
|
|
649
|
+
}}
|
|
650
|
+
.btn-warning {{
|
|
651
|
+
background-color: #ffc107;
|
|
652
|
+
color: #212529;
|
|
653
|
+
border: 1.5px solid #d39e00;
|
|
654
|
+
padding: 0.5rem 1rem;
|
|
655
|
+
font-size: 1rem;
|
|
656
|
+
border-radius: 0.5rem;
|
|
657
|
+
font-weight: 500;
|
|
658
|
+
cursor: pointer;
|
|
659
|
+
}}
|
|
660
|
+
.btn-warning:hover {{
|
|
661
|
+
background-color: #e0a800;
|
|
662
|
+
border-color: #c69500;
|
|
663
|
+
}}
|
|
664
|
+
</style>
|
|
274
665
|
<form id="credentialsForm" action="/api/credentials/{shorthand}" method="post" onsubmit="return handleSubmit(this)">
|
|
275
666
|
{form_content}
|
|
276
|
-
|
|
667
|
+
<div class="button-row">
|
|
668
|
+
{render_button("Save", "primary", {"type": "submit", "id": "submitBtn"})}
|
|
669
|
+
<button type="button" class="btn-warning" id="skipBtn" onclick="skipLogin()">Skip for now</button>
|
|
670
|
+
</div>
|
|
277
671
|
</form>
|
|
672
|
+
<p style="font-size:0.875rem; color:var(--secondary, #6c757d); margin-top:1rem;">
|
|
673
|
+
Skipping will allow Quasarr to start, but this site won't work until credentials are provided.
|
|
674
|
+
</p>
|
|
278
675
|
<script>
|
|
279
676
|
var formSubmitted = false;
|
|
280
677
|
function handleSubmit(form) {{
|
|
@@ -282,13 +679,54 @@ def hostname_credentials_config(shared_state, shorthand, domain):
|
|
|
282
679
|
formSubmitted = true;
|
|
283
680
|
var btn = document.getElementById('submitBtn');
|
|
284
681
|
if (btn) {{ btn.disabled = true; btn.textContent = 'Saving...'; }}
|
|
682
|
+
document.getElementById('skipBtn').disabled = true;
|
|
285
683
|
return true;
|
|
286
684
|
}}
|
|
685
|
+
function skipLogin() {{
|
|
686
|
+
if (formSubmitted) return;
|
|
687
|
+
formSubmitted = true;
|
|
688
|
+
var skipBtn = document.getElementById('skipBtn');
|
|
689
|
+
var submitBtn = document.getElementById('submitBtn');
|
|
690
|
+
if (skipBtn) {{ skipBtn.disabled = true; skipBtn.textContent = 'Skipping...'; }}
|
|
691
|
+
if (submitBtn) {{ submitBtn.disabled = true; }}
|
|
692
|
+
|
|
693
|
+
fetch('/api/credentials/{shorthand}/skip', {{ method: 'POST' }})
|
|
694
|
+
.then(response => {{
|
|
695
|
+
if (response.ok) {{
|
|
696
|
+
window.location.href = '/skip-success';
|
|
697
|
+
}} else {{
|
|
698
|
+
alert('Failed to skip login');
|
|
699
|
+
formSubmitted = false;
|
|
700
|
+
if (skipBtn) {{ skipBtn.disabled = false; skipBtn.textContent = 'Skip for now'; }}
|
|
701
|
+
if (submitBtn) {{ submitBtn.disabled = false; }}
|
|
702
|
+
}}
|
|
703
|
+
}})
|
|
704
|
+
.catch(error => {{
|
|
705
|
+
alert('Error: ' + error.message);
|
|
706
|
+
formSubmitted = false;
|
|
707
|
+
if (skipBtn) {{ skipBtn.disabled = false; skipBtn.textContent = 'Skip for now'; }}
|
|
708
|
+
if (submitBtn) {{ submitBtn.disabled = false; }}
|
|
709
|
+
}});
|
|
710
|
+
}}
|
|
287
711
|
</script>
|
|
288
712
|
'''
|
|
289
713
|
|
|
290
714
|
return render_form(f"Set User and Password for {shorthand}", form_html)
|
|
291
715
|
|
|
716
|
+
@app.get('/skip-success')
|
|
717
|
+
def skip_success():
|
|
718
|
+
return render_reconnect_success(
|
|
719
|
+
f"{shorthand} login skipped. You can configure credentials later in the web UI.")
|
|
720
|
+
|
|
721
|
+
@app.post("/api/credentials/<sh>/skip")
|
|
722
|
+
def skip_credentials(sh):
|
|
723
|
+
"""Skip login for this hostname and continue startup."""
|
|
724
|
+
sh_lower = sh.lower()
|
|
725
|
+
DataBase("skip_login").update_store(sh_lower, "true")
|
|
726
|
+
info(f'Login for "{sh}" skipped by user choice')
|
|
727
|
+
quasarr.providers.web_server.temp_server_success = True
|
|
728
|
+
return {"success": True}
|
|
729
|
+
|
|
292
730
|
@app.post("/api/credentials/<sh>")
|
|
293
731
|
def set_credentials(sh):
|
|
294
732
|
# Guard against duplicate submissions (e.g., double-click)
|
|
@@ -303,22 +741,25 @@ def hostname_credentials_config(shared_state, shorthand, domain):
|
|
|
303
741
|
config.save("user", user)
|
|
304
742
|
config.save("password", password)
|
|
305
743
|
|
|
744
|
+
# Clear any skip preference since we now have credentials
|
|
745
|
+
DataBase("skip_login").delete(sh.lower())
|
|
746
|
+
|
|
306
747
|
if sh.lower() == "al":
|
|
307
748
|
if quasarr.providers.sessions.al.create_and_persist_session(shared_state):
|
|
308
749
|
quasarr.providers.web_server.temp_server_success = True
|
|
309
|
-
return
|
|
750
|
+
return render_reconnect_success(f"{sh} credentials set successfully")
|
|
310
751
|
elif sh.lower() == "dd":
|
|
311
752
|
if quasarr.providers.sessions.dd.create_and_persist_session(shared_state):
|
|
312
753
|
quasarr.providers.web_server.temp_server_success = True
|
|
313
|
-
return
|
|
754
|
+
return render_reconnect_success(f"{sh} credentials set successfully")
|
|
314
755
|
elif sh.lower() == "dl":
|
|
315
756
|
if quasarr.providers.sessions.dl.create_and_persist_session(shared_state):
|
|
316
757
|
quasarr.providers.web_server.temp_server_success = True
|
|
317
|
-
return
|
|
758
|
+
return render_reconnect_success(f"{sh} credentials set successfully")
|
|
318
759
|
elif sh.lower() == "nx":
|
|
319
760
|
if quasarr.providers.sessions.nx.create_and_persist_session(shared_state):
|
|
320
761
|
quasarr.providers.web_server.temp_server_success = True
|
|
321
|
-
return
|
|
762
|
+
return render_reconnect_success(f"{sh} credentials set successfully")
|
|
322
763
|
else:
|
|
323
764
|
quasarr.providers.web_server.temp_server_success = False
|
|
324
765
|
return render_fail(f"Unknown site shorthand! ({sh})")
|
|
@@ -331,7 +772,7 @@ def hostname_credentials_config(shared_state, shorthand, domain):
|
|
|
331
772
|
f'"{shorthand.lower()}" credentials required to access download links. '
|
|
332
773
|
f'Starting web server for config at: "{shared_state.values['internal_address']}".')
|
|
333
774
|
info(f"If needed register here: 'https://{domain}'")
|
|
334
|
-
info("Please set your credentials now, to allow Quasarr to launch!")
|
|
775
|
+
info("Please set your credentials now, or skip to allow Quasarr to launch!")
|
|
335
776
|
return Server(app, listen='0.0.0.0', port=shared_state.values['port']).serve_temporarily()
|
|
336
777
|
|
|
337
778
|
|
|
@@ -386,7 +827,7 @@ def flaresolverr_config(shared_state):
|
|
|
386
827
|
config.save("url", url)
|
|
387
828
|
print(f'Using Flaresolverr URL: "{url}"')
|
|
388
829
|
quasarr.providers.web_server.temp_server_success = True
|
|
389
|
-
return
|
|
830
|
+
return render_reconnect_success("FlareSolverr URL saved successfully!")
|
|
390
831
|
except requests.RequestException:
|
|
391
832
|
pass
|
|
392
833
|
|
|
@@ -524,7 +965,7 @@ def jdownloader_config(shared_state):
|
|
|
524
965
|
config.save('password', password)
|
|
525
966
|
config.save('device', device)
|
|
526
967
|
quasarr.providers.web_server.temp_server_success = True
|
|
527
|
-
return
|
|
968
|
+
return render_reconnect_success("Credentials set")
|
|
528
969
|
|
|
529
970
|
return render_fail("Could not set credentials!")
|
|
530
971
|
|