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 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
- else:
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
- al = Config('Hostnames').get('al')
164
- if al:
165
- user = Config('AL').get('user')
166
- password = Config('AL').get('password')
167
- if not user or not password:
168
- hostname_credentials_config(shared_state, "AL", al)
169
-
170
- dd = Config('Hostnames').get('dd')
171
- if dd:
172
- user = Config('DD').get('user')
173
- password = Config('DD').get('password')
174
- if not user or not password:
175
- hostname_credentials_config(shared_state, "DD", dd)
176
-
177
- nx = Config('Hostnames').get('nx')
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
 
@@ -2,9 +2,23 @@
2
2
  # Quasarr
3
3
  # Project by https://github.com/rix1337
4
4
 
5
- from quasarr.providers.html_templates import render_form
6
- from quasarr.providers.html_templates import render_button
7
- from quasarr.storage.setup import hostname_form_html, save_hostnames
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", hostname_form_html(shared_state, message) + back_button)
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 wont stop the rest.
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 its a palette (P) image with an indexed transparency, go through RGBA
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
 
@@ -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)