quasarr 1.26.7__py3-none-any.whl → 1.28.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 +44 -155
- quasarr/api/__init__.py +1 -1
- quasarr/api/config/__init__.py +308 -4
- quasarr/downloads/linkcrypters/al.py +8 -2
- quasarr/downloads/sources/al.py +7 -0
- quasarr/downloads/sources/wd.py +12 -2
- quasarr/providers/cloudflare.py +20 -1
- quasarr/providers/sessions/al.py +51 -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 +190 -0
- quasarr/providers/version.py +1 -1
- quasarr/search/sources/al.py +0 -2
- quasarr/storage/config.py +3 -0
- quasarr/storage/setup.py +532 -18
- {quasarr-1.26.7.dist-info → quasarr-1.28.0.dist-info}/METADATA +86 -94
- {quasarr-1.26.7.dist-info → quasarr-1.28.0.dist-info}/RECORD +22 -21
- {quasarr-1.26.7.dist-info → quasarr-1.28.0.dist-info}/WHEEL +0 -0
- {quasarr-1.26.7.dist-info → quasarr-1.28.0.dist-info}/entry_points.txt +0 -0
- {quasarr-1.26.7.dist-info → quasarr-1.28.0.dist-info}/licenses/LICENSE +0 -0
- {quasarr-1.26.7.dist-info → quasarr-1.28.0.dist-info}/top_level.txt +0 -0
quasarr/__init__.py
CHANGED
|
@@ -6,19 +6,18 @@ import argparse
|
|
|
6
6
|
import multiprocessing
|
|
7
7
|
import os
|
|
8
8
|
import re
|
|
9
|
-
import socket
|
|
10
9
|
import sys
|
|
11
10
|
import tempfile
|
|
12
11
|
import time
|
|
13
|
-
from urllib.parse import urlparse, urljoin, parse_qs
|
|
14
12
|
|
|
15
|
-
import dukpy
|
|
16
13
|
import requests
|
|
17
14
|
|
|
18
15
|
from quasarr.api import get_api
|
|
19
16
|
from quasarr.providers import shared_state, version
|
|
20
17
|
from quasarr.providers.log import info, debug
|
|
21
18
|
from quasarr.providers.notifications import send_discord_message
|
|
19
|
+
from quasarr.providers.utils import extract_allowed_keys, extract_kv_pairs, is_valid_url, check_ip, check_flaresolverr, \
|
|
20
|
+
validate_address, Unbuffered, FALLBACK_USER_AGENT
|
|
22
21
|
from quasarr.storage.config import Config, get_clean_hostnames
|
|
23
22
|
from quasarr.storage.setup import path_config, hostnames_config, hostname_credentials_config, flaresolverr_config, \
|
|
24
23
|
jdownloader_config
|
|
@@ -101,25 +100,45 @@ def run():
|
|
|
101
100
|
shared_state.update("database", DataBase)
|
|
102
101
|
supported_hostnames = extract_allowed_keys(Config._DEFAULT_CONFIG, 'Hostnames')
|
|
103
102
|
shared_state.update("sites", [key.upper() for key in supported_hostnames])
|
|
104
|
-
shared_state.update("user_agent", "") # will be set by FlareSolverr
|
|
103
|
+
shared_state.update("user_agent", "") # will be set by FlareSolverr or fallback
|
|
105
104
|
shared_state.update("helper_active", False)
|
|
106
105
|
|
|
107
106
|
print(f'Config path: "{config_path}"')
|
|
108
107
|
|
|
108
|
+
# Check if FlareSolverr was previously skipped
|
|
109
|
+
skip_flaresolverr_db = DataBase("skip_flaresolverr")
|
|
110
|
+
flaresolverr_skipped = skip_flaresolverr_db.retrieve("skipped")
|
|
111
|
+
|
|
109
112
|
flaresolverr_url = Config('FlareSolverr').get('url')
|
|
110
|
-
if not flaresolverr_url:
|
|
113
|
+
if not flaresolverr_url and not flaresolverr_skipped:
|
|
111
114
|
flaresolverr_config(shared_state)
|
|
112
|
-
|
|
115
|
+
# Re-check after config - user may have skipped
|
|
116
|
+
flaresolverr_skipped = skip_flaresolverr_db.retrieve("skipped")
|
|
117
|
+
flaresolverr_url = Config('FlareSolverr').get('url')
|
|
118
|
+
|
|
119
|
+
if flaresolverr_skipped:
|
|
120
|
+
info('FlareSolverr setup skipped by user preference')
|
|
121
|
+
info('Some sites (AL) will not work without FlareSolverr. Configure it later in the web UI.')
|
|
122
|
+
# Set fallback user agent
|
|
123
|
+
shared_state.update("user_agent", FALLBACK_USER_AGENT)
|
|
124
|
+
print(f'User Agent (fallback): "{FALLBACK_USER_AGENT}"')
|
|
125
|
+
elif flaresolverr_url:
|
|
113
126
|
print(f'Flaresolverr URL: "{flaresolverr_url}"')
|
|
114
127
|
flaresolverr_check = check_flaresolverr(shared_state, flaresolverr_url)
|
|
115
128
|
if flaresolverr_check:
|
|
116
129
|
print(f'User Agent: "{shared_state.values["user_agent"]}"')
|
|
130
|
+
else:
|
|
131
|
+
info('FlareSolverr check failed - using fallback user agent')
|
|
132
|
+
shared_state.update("user_agent", FALLBACK_USER_AGENT)
|
|
133
|
+
print(f'User Agent (fallback): "{FALLBACK_USER_AGENT}"')
|
|
117
134
|
|
|
118
135
|
print("\n===== Hostnames =====")
|
|
119
136
|
try:
|
|
120
137
|
if arguments.hostnames:
|
|
121
138
|
hostnames_link = arguments.hostnames
|
|
122
139
|
if is_valid_url(hostnames_link):
|
|
140
|
+
# Store the hostnames URL for later use in web UI
|
|
141
|
+
Config("Settings").save("hostnames_url", hostnames_link)
|
|
123
142
|
print(f"Extracting hostnames from {hostnames_link}...")
|
|
124
143
|
allowed_keys = supported_hostnames
|
|
125
144
|
max_keys = len(allowed_keys)
|
|
@@ -160,33 +179,21 @@ def run():
|
|
|
160
179
|
print(f"You have [{len(hostnames)} of {len(Config._DEFAULT_CONFIG['Hostnames'])}] supported hostnames set up")
|
|
161
180
|
print(f"For efficiency it is recommended to set up as few hostnames as needed.")
|
|
162
181
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if nx:
|
|
179
|
-
user = Config('NX').get('user')
|
|
180
|
-
password = Config('NX').get('password')
|
|
181
|
-
if not user or not password:
|
|
182
|
-
hostname_credentials_config(shared_state, "NX", nx)
|
|
183
|
-
|
|
184
|
-
dl = Config('Hostnames').get('dl')
|
|
185
|
-
if dl:
|
|
186
|
-
user = Config('DL').get('user')
|
|
187
|
-
password = Config('DL').get('password')
|
|
188
|
-
if not user or not password:
|
|
189
|
-
hostname_credentials_config(shared_state, "DL", dl)
|
|
182
|
+
# Check credentials for login-required hostnames
|
|
183
|
+
skip_login_db = DataBase("skip_login")
|
|
184
|
+
login_required_sites = ['al', 'dd', 'nx', 'dl']
|
|
185
|
+
|
|
186
|
+
for site in login_required_sites:
|
|
187
|
+
hostname = Config('Hostnames').get(site)
|
|
188
|
+
if hostname:
|
|
189
|
+
site_config = Config(site.upper())
|
|
190
|
+
user = site_config.get('user')
|
|
191
|
+
password = site_config.get('password')
|
|
192
|
+
if not user or not password:
|
|
193
|
+
if skip_login_db.retrieve(site):
|
|
194
|
+
info(f'"{site.upper()}" login skipped by user preference')
|
|
195
|
+
else:
|
|
196
|
+
hostname_credentials_config(shared_state, site.upper(), hostname)
|
|
190
197
|
|
|
191
198
|
config = Config('JDownloader')
|
|
192
199
|
user = config.get('user')
|
|
@@ -234,21 +241,21 @@ def run():
|
|
|
234
241
|
|
|
235
242
|
jdownloader = multiprocessing.Process(
|
|
236
243
|
target=jdownloader_connection,
|
|
237
|
-
args=(shared_state_dict, shared_state_lock)
|
|
244
|
+
args=(shared_state_dict, shared_state_lock),
|
|
245
|
+
daemon=True
|
|
238
246
|
)
|
|
239
247
|
jdownloader.start()
|
|
240
248
|
|
|
241
249
|
updater = multiprocessing.Process(
|
|
242
250
|
target=update_checker,
|
|
243
|
-
args=(shared_state_dict, shared_state_lock)
|
|
251
|
+
args=(shared_state_dict, shared_state_lock),
|
|
252
|
+
daemon=True
|
|
244
253
|
)
|
|
245
254
|
updater.start()
|
|
246
255
|
|
|
247
256
|
try:
|
|
248
257
|
get_api(shared_state_dict, shared_state_lock)
|
|
249
258
|
except KeyboardInterrupt:
|
|
250
|
-
jdownloader.kill()
|
|
251
|
-
updater.kill()
|
|
252
259
|
sys.exit(0)
|
|
253
260
|
|
|
254
261
|
|
|
@@ -327,121 +334,3 @@ def jdownloader_connection(shared_state_dict, shared_state_lock):
|
|
|
327
334
|
|
|
328
335
|
except KeyboardInterrupt:
|
|
329
336
|
pass
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
class Unbuffered(object):
|
|
333
|
-
def __init__(self, stream):
|
|
334
|
-
self.stream = stream
|
|
335
|
-
|
|
336
|
-
def write(self, data):
|
|
337
|
-
self.stream.write(data)
|
|
338
|
-
self.stream.flush()
|
|
339
|
-
|
|
340
|
-
def writelines(self, datas):
|
|
341
|
-
self.stream.writelines(datas)
|
|
342
|
-
self.stream.flush()
|
|
343
|
-
|
|
344
|
-
def __getattr__(self, attr):
|
|
345
|
-
return getattr(self.stream, attr)
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
def check_ip():
|
|
349
|
-
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
350
|
-
try:
|
|
351
|
-
s.connect(('10.255.255.255', 0))
|
|
352
|
-
ip = s.getsockname()[0]
|
|
353
|
-
except:
|
|
354
|
-
ip = '127.0.0.1'
|
|
355
|
-
finally:
|
|
356
|
-
s.close()
|
|
357
|
-
return ip
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def check_flaresolverr(shared_state, flaresolverr_url):
|
|
361
|
-
# Ensure it ends with /v<digit+>
|
|
362
|
-
if not re.search(r"/v\d+$", flaresolverr_url):
|
|
363
|
-
print(f"FlareSolverr URL does not end with /v#: {flaresolverr_url}")
|
|
364
|
-
return False
|
|
365
|
-
|
|
366
|
-
# Try sending a simple test request
|
|
367
|
-
headers = {"Content-Type": "application/json"}
|
|
368
|
-
data = {
|
|
369
|
-
"cmd": "request.get",
|
|
370
|
-
"url": "http://www.google.com/",
|
|
371
|
-
"maxTimeout": 10000
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
try:
|
|
375
|
-
response = requests.post(flaresolverr_url, headers=headers, json=data, timeout=10)
|
|
376
|
-
response.raise_for_status()
|
|
377
|
-
json_data = response.json()
|
|
378
|
-
|
|
379
|
-
# Check if the structure looks like a valid FlareSolverr response
|
|
380
|
-
if "status" in json_data and json_data["status"] == "ok":
|
|
381
|
-
solution = json_data["solution"]
|
|
382
|
-
solution_ua = solution.get("userAgent", None)
|
|
383
|
-
if solution_ua:
|
|
384
|
-
shared_state.update("user_agent", solution_ua)
|
|
385
|
-
return True
|
|
386
|
-
else:
|
|
387
|
-
print(f"Unexpected FlareSolverr response: {json_data}")
|
|
388
|
-
return False
|
|
389
|
-
|
|
390
|
-
except Exception as e:
|
|
391
|
-
print(f"Failed to connect to FlareSolverr: {e}")
|
|
392
|
-
return False
|
|
393
|
-
|
|
394
|
-
def is_valid_url(url):
|
|
395
|
-
if "/raw/eX4Mpl3" in url:
|
|
396
|
-
print("Example URL detected. Please provide a valid URL found on pastebin or any other public site!")
|
|
397
|
-
return False
|
|
398
|
-
|
|
399
|
-
parsed = urlparse(url)
|
|
400
|
-
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
def validate_address(address, name):
|
|
404
|
-
if not address.startswith("http"):
|
|
405
|
-
sys.exit(f"Error: {name} '{address}' is invalid. It must start with 'http'.")
|
|
406
|
-
|
|
407
|
-
colon_count = address.count(":")
|
|
408
|
-
if colon_count < 1 or colon_count > 2:
|
|
409
|
-
sys.exit(
|
|
410
|
-
f"Error: {name} '{address}' is invalid. It must contain 1 or 2 colons, but it has {colon_count}.")
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
def extract_allowed_keys(config, section):
|
|
414
|
-
"""
|
|
415
|
-
Extracts allowed keys from the specified section in the configuration.
|
|
416
|
-
|
|
417
|
-
:param config: The configuration dictionary.
|
|
418
|
-
:param section: The section from which to extract keys.
|
|
419
|
-
:return: A list of allowed keys.
|
|
420
|
-
"""
|
|
421
|
-
if section not in config:
|
|
422
|
-
raise ValueError(f"Section '{section}' not found in configuration.")
|
|
423
|
-
return [key for key, *_ in config[section]]
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
def extract_kv_pairs(input_text, allowed_keys):
|
|
427
|
-
"""
|
|
428
|
-
Extracts key-value pairs from the given text where keys match allowed_keys.
|
|
429
|
-
|
|
430
|
-
:param input_text: The input text containing key-value pairs.
|
|
431
|
-
:param allowed_keys: A list of allowed two-letter shorthand keys.
|
|
432
|
-
:return: A dictionary of extracted key-value pairs.
|
|
433
|
-
"""
|
|
434
|
-
kv_pattern = re.compile(rf"^({'|'.join(map(re.escape, allowed_keys))})\s*=\s*(.*)$")
|
|
435
|
-
kv_pairs = {}
|
|
436
|
-
|
|
437
|
-
for line in input_text.splitlines():
|
|
438
|
-
match = kv_pattern.match(line.strip())
|
|
439
|
-
if match:
|
|
440
|
-
key, value = match.groups()
|
|
441
|
-
kv_pairs[key] = value
|
|
442
|
-
elif "[Hostnames]" in line:
|
|
443
|
-
pass
|
|
444
|
-
else:
|
|
445
|
-
print(f"Skipping line because it does not contain any supported hostname: {line}")
|
|
446
|
-
|
|
447
|
-
return kv_pairs
|
quasarr/api/__init__.py
CHANGED
|
@@ -31,7 +31,6 @@ def get_api(shared_state_dict, shared_state_lock):
|
|
|
31
31
|
def index():
|
|
32
32
|
protected = shared_state.get_db("protected").retrieve_all_titles()
|
|
33
33
|
api_key = Config('API').get('key')
|
|
34
|
-
|
|
35
34
|
captcha_hint = ""
|
|
36
35
|
if protected:
|
|
37
36
|
plural = 's' if len(protected) > 1 else ''
|
|
@@ -103,6 +102,7 @@ def get_api(shared_state_dict, shared_state_lock):
|
|
|
103
102
|
<div class="section">
|
|
104
103
|
<h2>🔧 Quick Actions</h2>
|
|
105
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
106
|
<p><button class="btn-primary" onclick="location.href='/statistics'">View Statistics</button></p>
|
|
107
107
|
</div>
|
|
108
108
|
|
quasarr/api/config/__init__.py
CHANGED
|
@@ -2,9 +2,23 @@
|
|
|
2
2
|
# Quasarr
|
|
3
3
|
# Project by https://github.com/rix1337
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import signal
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
from bottle import request, response
|
|
14
|
+
|
|
15
|
+
from quasarr.providers.html_templates import render_form, render_button, render_fail
|
|
16
|
+
from quasarr.providers.log import info
|
|
17
|
+
from quasarr.providers.shared_state import extract_valid_hostname
|
|
18
|
+
from quasarr.providers.utils import extract_kv_pairs, extract_allowed_keys, check_flaresolverr
|
|
19
|
+
from quasarr.storage.config import Config
|
|
20
|
+
from quasarr.storage.setup import hostname_form_html, save_hostnames, render_reconnect_success
|
|
21
|
+
from quasarr.storage.sqlite_database import DataBase
|
|
8
22
|
|
|
9
23
|
|
|
10
24
|
def setup_config(app, shared_state):
|
|
@@ -16,8 +30,298 @@ def setup_config(app, shared_state):
|
|
|
16
30
|
back_button = f'''<p>
|
|
17
31
|
{render_button("Back", "secondary", {"onclick": "location.href='/'"})}
|
|
18
32
|
</p>'''
|
|
19
|
-
return render_form("Hostnames",
|
|
33
|
+
return render_form("Hostnames",
|
|
34
|
+
hostname_form_html(shared_state, message, show_restart_button=True,
|
|
35
|
+
show_skip_management=True) + back_button)
|
|
20
36
|
|
|
21
37
|
@app.post("/api/hostnames")
|
|
22
38
|
def hostnames_api():
|
|
23
39
|
return save_hostnames(shared_state, timeout=1, first_run=False)
|
|
40
|
+
|
|
41
|
+
@app.post("/api/hostnames/import-url")
|
|
42
|
+
def import_hostnames_from_url():
|
|
43
|
+
"""Fetch URL and parse hostnames, return JSON for JS to populate fields."""
|
|
44
|
+
response.content_type = 'application/json'
|
|
45
|
+
try:
|
|
46
|
+
data = request.json
|
|
47
|
+
url = data.get('url', '').strip()
|
|
48
|
+
|
|
49
|
+
if not url:
|
|
50
|
+
return {"success": False, "error": "No URL provided"}
|
|
51
|
+
|
|
52
|
+
# Validate URL
|
|
53
|
+
parsed = urlparse(url)
|
|
54
|
+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
55
|
+
return {"success": False, "error": "Invalid URL format"}
|
|
56
|
+
|
|
57
|
+
if "/raw/eX4Mpl3" in url:
|
|
58
|
+
return {"success": False, "error": "Example URL detected. Please provide a real URL."}
|
|
59
|
+
|
|
60
|
+
# Fetch content
|
|
61
|
+
try:
|
|
62
|
+
resp = requests.get(url, timeout=15)
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
content = resp.text
|
|
65
|
+
except requests.RequestException as e:
|
|
66
|
+
return {"success": False, "error": f"Failed to fetch URL: {str(e)}"}
|
|
67
|
+
|
|
68
|
+
# Parse hostnames
|
|
69
|
+
allowed_keys = extract_allowed_keys(Config._DEFAULT_CONFIG, 'Hostnames')
|
|
70
|
+
results = extract_kv_pairs(content, allowed_keys)
|
|
71
|
+
|
|
72
|
+
if not results:
|
|
73
|
+
return {"success": False, "error": "No hostnames found in the provided URL"}
|
|
74
|
+
|
|
75
|
+
# Validate each hostname
|
|
76
|
+
valid_hostnames = {}
|
|
77
|
+
invalid_hostnames = {}
|
|
78
|
+
for shorthand, hostname in results.items():
|
|
79
|
+
domain_check = extract_valid_hostname(hostname, shorthand)
|
|
80
|
+
domain = domain_check.get('domain')
|
|
81
|
+
if domain:
|
|
82
|
+
valid_hostnames[shorthand] = domain
|
|
83
|
+
else:
|
|
84
|
+
invalid_hostnames[shorthand] = domain_check.get('message', 'Invalid')
|
|
85
|
+
|
|
86
|
+
if not valid_hostnames:
|
|
87
|
+
return {"success": False, "error": "No valid hostnames found in the provided URL"}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
"success": True,
|
|
91
|
+
"hostnames": valid_hostnames,
|
|
92
|
+
"errors": invalid_hostnames
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
except Exception as e:
|
|
96
|
+
return {"success": False, "error": f"Error: {str(e)}"}
|
|
97
|
+
|
|
98
|
+
@app.get("/api/skip-login")
|
|
99
|
+
def get_skip_login():
|
|
100
|
+
"""Return list of hostnames with skipped login."""
|
|
101
|
+
response.content_type = 'application/json'
|
|
102
|
+
skip_db = DataBase("skip_login")
|
|
103
|
+
login_required_sites = ['al', 'dd', 'dl', 'nx']
|
|
104
|
+
skipped = []
|
|
105
|
+
for site in login_required_sites:
|
|
106
|
+
if skip_db.retrieve(site):
|
|
107
|
+
skipped.append(site)
|
|
108
|
+
return {"skipped": skipped}
|
|
109
|
+
|
|
110
|
+
@app.delete("/api/skip-login/<shorthand>")
|
|
111
|
+
def clear_skip_login(shorthand):
|
|
112
|
+
"""Clear skip login preference for a hostname."""
|
|
113
|
+
response.content_type = 'application/json'
|
|
114
|
+
shorthand = shorthand.lower()
|
|
115
|
+
login_required_sites = ['al', 'dd', 'dl', 'nx']
|
|
116
|
+
if shorthand not in login_required_sites:
|
|
117
|
+
return {"success": False, "error": f"Invalid shorthand: {shorthand}"}
|
|
118
|
+
|
|
119
|
+
skip_db = DataBase("skip_login")
|
|
120
|
+
skip_db.delete(shorthand)
|
|
121
|
+
info(f'Skip login preference cleared for "{shorthand.upper()}"')
|
|
122
|
+
return {"success": True}
|
|
123
|
+
|
|
124
|
+
@app.get('/flaresolverr')
|
|
125
|
+
def flaresolverr_ui():
|
|
126
|
+
"""Web UI page for configuring FlareSolverr."""
|
|
127
|
+
skip_db = DataBase("skip_flaresolverr")
|
|
128
|
+
is_skipped = skip_db.retrieve("skipped")
|
|
129
|
+
current_url = Config('FlareSolverr').get('url') or ""
|
|
130
|
+
|
|
131
|
+
skip_indicator = ""
|
|
132
|
+
if is_skipped:
|
|
133
|
+
skip_indicator = '''
|
|
134
|
+
<div class="skip-indicator" style="margin-bottom:1rem; padding:0.75rem; background:var(--code-bg, #f8f9fa); border-radius:0.25rem; font-size:0.875rem;">
|
|
135
|
+
<span style="color:#dc3545;">⚠️ FlareSolverr setup was skipped</span>
|
|
136
|
+
<p style="margin:0.5rem 0 0 0; font-size:0.75rem; color:var(--secondary, #6c757d);">
|
|
137
|
+
Some sites (like AL) won't work until FlareSolverr is configured.
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
'''
|
|
141
|
+
|
|
142
|
+
form_content = f'''
|
|
143
|
+
{skip_indicator}
|
|
144
|
+
<span><a href="https://github.com/FlareSolverr/FlareSolverr?tab=readme-ov-file#installation" target="_blank">FlareSolverr</a>
|
|
145
|
+
must be running and reachable to Quasarr for some sites to work.</span><br><br>
|
|
146
|
+
<label for="url">FlareSolverr URL</label>
|
|
147
|
+
<input type="text" id="url" name="url" placeholder="http://192.168.0.1:8191/v1" value="{current_url}"><br>
|
|
148
|
+
'''
|
|
149
|
+
|
|
150
|
+
form_html = f'''
|
|
151
|
+
<form action="/api/flaresolverr" method="post" onsubmit="return handleSubmit(this)">
|
|
152
|
+
{form_content}
|
|
153
|
+
{render_button("Save", "primary", {"type": "submit", "id": "submitBtn"})}
|
|
154
|
+
</form>
|
|
155
|
+
<p style="font-size:0.875rem; color:var(--secondary, #6c757d); margin-top:1rem;">
|
|
156
|
+
A restart is recommended after configuring FlareSolverr.
|
|
157
|
+
</p>
|
|
158
|
+
<div class="section-divider" style="margin-top:1.5rem; padding-top:1rem; border-top:1px solid var(--divider-color, #dee2e6);">
|
|
159
|
+
{render_button("Restart Quasarr", "secondary", {"type": "button", "onclick": "confirmRestart()"})}
|
|
160
|
+
</div>
|
|
161
|
+
<p>{render_button("Back", "secondary", {"onclick": "location.href='/';"})}</p>
|
|
162
|
+
<script>
|
|
163
|
+
var formSubmitted = false;
|
|
164
|
+
function handleSubmit(form) {{
|
|
165
|
+
if (formSubmitted) return false;
|
|
166
|
+
formSubmitted = true;
|
|
167
|
+
var btn = document.getElementById('submitBtn');
|
|
168
|
+
if (btn) {{ btn.disabled = true; btn.textContent = 'Saving...'; }}
|
|
169
|
+
return true;
|
|
170
|
+
}}
|
|
171
|
+
function confirmRestart() {{
|
|
172
|
+
if (confirm('Restart Quasarr now?')) {{
|
|
173
|
+
fetch('/api/restart', {{ method: 'POST' }})
|
|
174
|
+
.then(response => response.json())
|
|
175
|
+
.then(data => {{
|
|
176
|
+
if (data.success) {{
|
|
177
|
+
showRestartOverlay();
|
|
178
|
+
}}
|
|
179
|
+
}})
|
|
180
|
+
.catch(error => {{
|
|
181
|
+
showRestartOverlay();
|
|
182
|
+
}});
|
|
183
|
+
}}
|
|
184
|
+
}}
|
|
185
|
+
function showRestartOverlay() {{
|
|
186
|
+
document.body.innerHTML = `
|
|
187
|
+
<div style="text-align:center; padding:2rem; font-family:system-ui,-apple-system,sans-serif;">
|
|
188
|
+
<h2>Restarting Quasarr...</h2>
|
|
189
|
+
<p id="restartStatus">Waiting <span id="countdown">10</span> seconds...</p>
|
|
190
|
+
<div id="spinner" style="display:none; margin-top:1rem;">
|
|
191
|
+
<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>
|
|
192
|
+
<style>@keyframes spin {{ to {{ transform: rotate(360deg); }} }}</style>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
`;
|
|
196
|
+
startCountdown(10);
|
|
197
|
+
}}
|
|
198
|
+
function startCountdown(seconds) {{
|
|
199
|
+
var countdownEl = document.getElementById('countdown');
|
|
200
|
+
var statusEl = document.getElementById('restartStatus');
|
|
201
|
+
var spinnerEl = document.getElementById('spinner');
|
|
202
|
+
var remaining = seconds;
|
|
203
|
+
var interval = setInterval(function() {{
|
|
204
|
+
remaining--;
|
|
205
|
+
if (countdownEl) countdownEl.textContent = remaining;
|
|
206
|
+
if (remaining <= 0) {{
|
|
207
|
+
clearInterval(interval);
|
|
208
|
+
statusEl.textContent = 'Reconnecting...';
|
|
209
|
+
spinnerEl.style.display = 'block';
|
|
210
|
+
tryReconnect();
|
|
211
|
+
}}
|
|
212
|
+
}}, 1000);
|
|
213
|
+
}}
|
|
214
|
+
function tryReconnect() {{
|
|
215
|
+
var statusEl = document.getElementById('restartStatus');
|
|
216
|
+
var attempts = 0;
|
|
217
|
+
function attempt() {{
|
|
218
|
+
attempts++;
|
|
219
|
+
fetch('/', {{ method: 'HEAD', cache: 'no-store' }})
|
|
220
|
+
.then(response => {{
|
|
221
|
+
if (response.ok) {{
|
|
222
|
+
statusEl.textContent = 'Connected! Reloading...';
|
|
223
|
+
setTimeout(function() {{ window.location.href = '/'; }}, 500);
|
|
224
|
+
}} else {{
|
|
225
|
+
scheduleRetry();
|
|
226
|
+
}}
|
|
227
|
+
}})
|
|
228
|
+
.catch(function() {{
|
|
229
|
+
scheduleRetry();
|
|
230
|
+
}});
|
|
231
|
+
}}
|
|
232
|
+
function scheduleRetry() {{
|
|
233
|
+
statusEl.textContent = 'Reconnecting... (attempt ' + attempts + ')';
|
|
234
|
+
setTimeout(attempt, 1000);
|
|
235
|
+
}}
|
|
236
|
+
attempt();
|
|
237
|
+
}}
|
|
238
|
+
</script>
|
|
239
|
+
'''
|
|
240
|
+
return render_form("Configure FlareSolverr", form_html)
|
|
241
|
+
|
|
242
|
+
@app.post('/api/flaresolverr')
|
|
243
|
+
def set_flaresolverr_url():
|
|
244
|
+
"""Save FlareSolverr URL from web UI."""
|
|
245
|
+
url = request.forms.get('url', '').strip()
|
|
246
|
+
config = Config("FlareSolverr")
|
|
247
|
+
|
|
248
|
+
if not url:
|
|
249
|
+
return render_fail("Please provide a FlareSolverr URL.")
|
|
250
|
+
|
|
251
|
+
if not url.startswith("http://") and not url.startswith("https://"):
|
|
252
|
+
url = "http://" + url
|
|
253
|
+
|
|
254
|
+
# Validate URL format
|
|
255
|
+
if not re.search(r"/v\d+$", url):
|
|
256
|
+
return render_fail("FlareSolverr URL must end with /v1 (or similar version path).")
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
headers = {"Content-Type": "application/json"}
|
|
260
|
+
data = {
|
|
261
|
+
"cmd": "request.get",
|
|
262
|
+
"url": "http://www.google.com/",
|
|
263
|
+
"maxTimeout": 30000
|
|
264
|
+
}
|
|
265
|
+
resp = requests.post(url, headers=headers, json=data, timeout=30)
|
|
266
|
+
if resp.status_code == 200:
|
|
267
|
+
json_data = resp.json()
|
|
268
|
+
if json_data.get("status") == "ok":
|
|
269
|
+
config.save("url", url)
|
|
270
|
+
# Clear skip preference since we now have a working URL
|
|
271
|
+
DataBase("skip_flaresolverr").delete("skipped")
|
|
272
|
+
# Update user agent from FlareSolverr response
|
|
273
|
+
solution = json_data.get("solution", {})
|
|
274
|
+
solution_ua = solution.get("userAgent")
|
|
275
|
+
if solution_ua:
|
|
276
|
+
shared_state.update("user_agent", solution_ua)
|
|
277
|
+
info(f'FlareSolverr URL configured: "{url}"')
|
|
278
|
+
return render_reconnect_success(
|
|
279
|
+
"FlareSolverr URL saved successfully! A restart is recommended.")
|
|
280
|
+
else:
|
|
281
|
+
return render_fail(f"FlareSolverr returned unexpected status: {json_data.get('status')}")
|
|
282
|
+
except requests.RequestException as e:
|
|
283
|
+
return render_fail(f"Could not reach FlareSolverr: {str(e)}")
|
|
284
|
+
|
|
285
|
+
return render_fail("Could not reach FlareSolverr at that URL (expected HTTP 200).")
|
|
286
|
+
|
|
287
|
+
@app.get("/api/flaresolverr/status")
|
|
288
|
+
def get_flaresolverr_status():
|
|
289
|
+
"""Return FlareSolverr configuration status."""
|
|
290
|
+
response.content_type = 'application/json'
|
|
291
|
+
skip_db = DataBase("skip_flaresolverr")
|
|
292
|
+
is_skipped = bool(skip_db.retrieve("skipped"))
|
|
293
|
+
current_url = Config('FlareSolverr').get('url') or ""
|
|
294
|
+
|
|
295
|
+
# Test connection if URL is set
|
|
296
|
+
is_working = False
|
|
297
|
+
if current_url and not is_skipped:
|
|
298
|
+
is_working = check_flaresolverr(shared_state, current_url)
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
"skipped": is_skipped,
|
|
302
|
+
"url": current_url,
|
|
303
|
+
"working": is_working
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
@app.delete("/api/skip-flaresolverr")
|
|
307
|
+
def clear_skip_flaresolverr():
|
|
308
|
+
"""Clear skip FlareSolverr preference."""
|
|
309
|
+
response.content_type = 'application/json'
|
|
310
|
+
skip_db = DataBase("skip_flaresolverr")
|
|
311
|
+
skip_db.delete("skipped")
|
|
312
|
+
info('Skip FlareSolverr preference cleared')
|
|
313
|
+
return {"success": True}
|
|
314
|
+
|
|
315
|
+
@app.post("/api/restart")
|
|
316
|
+
def restart_quasarr():
|
|
317
|
+
"""Restart Quasarr. In Docker with the restart loop, exit(0) triggers restart."""
|
|
318
|
+
response.content_type = 'application/json'
|
|
319
|
+
info("Restart requested via web UI")
|
|
320
|
+
|
|
321
|
+
def delayed_exit():
|
|
322
|
+
time.sleep(0.5)
|
|
323
|
+
# Send SIGINT to main process - triggers KeyboardInterrupt handler
|
|
324
|
+
os.kill(os.getpid(), signal.SIGINT)
|
|
325
|
+
|
|
326
|
+
threading.Thread(target=delayed_exit, daemon=True).start()
|
|
327
|
+
return {"success": True, "message": "Restarting..."}
|
|
@@ -9,6 +9,7 @@ from Cryptodome.Cipher import AES
|
|
|
9
9
|
from PIL import Image, ImageChops
|
|
10
10
|
|
|
11
11
|
from quasarr.providers.log import info, debug
|
|
12
|
+
from quasarr.providers.utils import is_flaresolverr_available
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class CNL:
|
|
@@ -137,7 +138,7 @@ def decrypt_content(content_items: list[dict], mirror: str | None) -> list[str]:
|
|
|
137
138
|
decrypted_links.extend(urls)
|
|
138
139
|
debug(f"[Item {idx} | hoster={hoster_name}] Decrypted {len(urls)} URLs")
|
|
139
140
|
except Exception as e:
|
|
140
|
-
# Log and keep going; one bad item won
|
|
141
|
+
# Log and keep going; one bad item won't stop the rest.
|
|
141
142
|
info(f"[Item {idx} | hoster={hoster_name}] Error during decryption: {e}")
|
|
142
143
|
|
|
143
144
|
return decrypted_links
|
|
@@ -160,6 +161,11 @@ def calculate_pixel_based_difference(img1, img2):
|
|
|
160
161
|
|
|
161
162
|
|
|
162
163
|
def solve_captcha(hostname, shared_state, fetch_via_flaresolverr, fetch_via_requests_session):
|
|
164
|
+
# Check if FlareSolverr is available
|
|
165
|
+
if not is_flaresolverr_available(shared_state):
|
|
166
|
+
raise RuntimeError("FlareSolverr is required for CAPTCHA solving but is not configured. "
|
|
167
|
+
"Please configure FlareSolverr in the web UI.")
|
|
168
|
+
|
|
163
169
|
al = shared_state.values["config"]("Hostnames").get(hostname)
|
|
164
170
|
captcha_base = f"https://www.{al}/files/captcha"
|
|
165
171
|
|
|
@@ -195,7 +201,7 @@ def solve_captcha(hostname, shared_state, fetch_via_flaresolverr, fetch_via_requ
|
|
|
195
201
|
for image_id, raw_bytes in images:
|
|
196
202
|
img = Image.open(BytesIO(raw_bytes))
|
|
197
203
|
|
|
198
|
-
# if it
|
|
204
|
+
# if it's a palette (P) image with an indexed transparency, go through RGBA
|
|
199
205
|
if img.mode == "P" and "transparency" in img.info:
|
|
200
206
|
img = img.convert("RGBA")
|
|
201
207
|
|
quasarr/downloads/sources/al.py
CHANGED
|
@@ -17,6 +17,7 @@ from quasarr.providers.log import info, debug
|
|
|
17
17
|
from quasarr.providers.sessions.al import retrieve_and_validate_session, invalidate_session, unwrap_flaresolverr_body, \
|
|
18
18
|
fetch_via_flaresolverr, fetch_via_requests_session
|
|
19
19
|
from quasarr.providers.statistics import StatsHelper
|
|
20
|
+
from quasarr.providers.utils import is_flaresolverr_available
|
|
20
21
|
|
|
21
22
|
hostname = "al"
|
|
22
23
|
|
|
@@ -552,6 +553,12 @@ def get_al_download_links(shared_state, url, mirror, title, password):
|
|
|
552
553
|
This is set by the search module, not a user password.
|
|
553
554
|
"""
|
|
554
555
|
|
|
556
|
+
# Check if FlareSolverr is available - AL requires it
|
|
557
|
+
if not is_flaresolverr_available(shared_state):
|
|
558
|
+
info(f'"{hostname.upper()}" requires FlareSolverr which is not configured. '
|
|
559
|
+
f'Please configure FlareSolverr in the web UI to use this site.')
|
|
560
|
+
return {}
|
|
561
|
+
|
|
555
562
|
release_id = password # password field carries release_id for AL
|
|
556
563
|
|
|
557
564
|
al = shared_state.values["config"]("Hostnames").get(hostname)
|