quasarr 1.27.0__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
@@ -17,7 +17,7 @@ from quasarr.providers import shared_state, version
17
17
  from quasarr.providers.log import info, debug
18
18
  from quasarr.providers.notifications import send_discord_message
19
19
  from quasarr.providers.utils import extract_allowed_keys, extract_kv_pairs, is_valid_url, check_ip, check_flaresolverr, \
20
- validate_address, Unbuffered
20
+ validate_address, Unbuffered, FALLBACK_USER_AGENT
21
21
  from quasarr.storage.config import Config, get_clean_hostnames
22
22
  from quasarr.storage.setup import path_config, hostnames_config, hostname_credentials_config, flaresolverr_config, \
23
23
  jdownloader_config
@@ -100,19 +100,37 @@ def run():
100
100
  shared_state.update("database", DataBase)
101
101
  supported_hostnames = extract_allowed_keys(Config._DEFAULT_CONFIG, 'Hostnames')
102
102
  shared_state.update("sites", [key.upper() for key in supported_hostnames])
103
- shared_state.update("user_agent", "") # will be set by FlareSolverr
103
+ shared_state.update("user_agent", "") # will be set by FlareSolverr or fallback
104
104
  shared_state.update("helper_active", False)
105
105
 
106
106
  print(f'Config path: "{config_path}"')
107
107
 
108
+ # Check if FlareSolverr was previously skipped
109
+ skip_flaresolverr_db = DataBase("skip_flaresolverr")
110
+ flaresolverr_skipped = skip_flaresolverr_db.retrieve("skipped")
111
+
108
112
  flaresolverr_url = Config('FlareSolverr').get('url')
109
- if not flaresolverr_url:
113
+ if not flaresolverr_url and not flaresolverr_skipped:
110
114
  flaresolverr_config(shared_state)
111
- 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:
112
126
  print(f'Flaresolverr URL: "{flaresolverr_url}"')
113
127
  flaresolverr_check = check_flaresolverr(shared_state, flaresolverr_url)
114
128
  if flaresolverr_check:
115
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}"')
116
134
 
117
135
  print("\n===== Hostnames =====")
118
136
  try:
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
 
@@ -3,6 +3,7 @@
3
3
  # Project by https://github.com/rix1337
4
4
 
5
5
  import os
6
+ import re
6
7
  import signal
7
8
  import threading
8
9
  import time
@@ -11,12 +12,12 @@ from urllib.parse import urlparse
11
12
  import requests
12
13
  from bottle import request, response
13
14
 
14
- from quasarr.providers.html_templates import render_form, render_button
15
+ from quasarr.providers.html_templates import render_form, render_button, render_fail
15
16
  from quasarr.providers.log import info
16
17
  from quasarr.providers.shared_state import extract_valid_hostname
17
- from quasarr.providers.utils import extract_kv_pairs, extract_allowed_keys
18
+ from quasarr.providers.utils import extract_kv_pairs, extract_allowed_keys, check_flaresolverr
18
19
  from quasarr.storage.config import Config
19
- from quasarr.storage.setup import hostname_form_html, save_hostnames
20
+ from quasarr.storage.setup import hostname_form_html, save_hostnames, render_reconnect_success
20
21
  from quasarr.storage.sqlite_database import DataBase
21
22
 
22
23
 
@@ -120,6 +121,197 @@ def setup_config(app, shared_state):
120
121
  info(f'Skip login preference cleared for "{shorthand.upper()}"')
121
122
  return {"success": True}
122
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
+
123
315
  @app.post("/api/restart")
124
316
  def restart_quasarr():
125
317
  """Restart Quasarr. In Docker with the restart loop, exit(0) triggers restart."""
@@ -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)
@@ -10,6 +10,7 @@ from bs4 import BeautifulSoup
10
10
 
11
11
  from quasarr.providers.cloudflare import flaresolverr_get, is_cloudflare_challenge
12
12
  from quasarr.providers.log import info, debug
13
+ from quasarr.providers.utils import is_flaresolverr_available
13
14
 
14
15
 
15
16
  def resolve_wd_redirect(url, user_agent):
@@ -47,8 +48,13 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
47
48
  try:
48
49
  output = requests.get(url)
49
50
  if output.status_code == 403 or is_cloudflare_challenge(output.text):
50
- info("WD is protected by Cloudflare. Using FlareSolverr to bypass protection.")
51
- output = flaresolverr_get(shared_state, url)
51
+ if is_flaresolverr_available(shared_state):
52
+ info("WD is protected by Cloudflare. Using FlareSolverr to bypass protection.")
53
+ output = flaresolverr_get(shared_state, url)
54
+ else:
55
+ info("WD is protected by Cloudflare but FlareSolverr is not configured. "
56
+ "Please configure FlareSolverr in the web UI to access this site.")
57
+ return {"links": [], "imdb_id": None}
52
58
 
53
59
  soup = BeautifulSoup(output.text, "html.parser")
54
60
 
@@ -76,6 +82,10 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
76
82
  link_tags = body.find_all(
77
83
  "a", href=True, class_=lambda c: c and "background-" in c
78
84
  )
85
+ except RuntimeError as e:
86
+ # Catch FlareSolverr not configured error
87
+ info(f"WD access failed: {e}")
88
+ return {"links": [], "imdb_id": None}
79
89
  except Exception:
80
90
  info(f"WD site has been updated. Grabbing download links for {title} not possible!")
81
91
  return {"links": [], "imdb_id": None}
@@ -5,6 +5,8 @@
5
5
  import requests
6
6
  from bs4 import BeautifulSoup
7
7
 
8
+ from quasarr.providers.utils import is_flaresolverr_available
9
+
8
10
 
9
11
  def is_cloudflare_challenge(html: str) -> bool:
10
12
  soup = BeautifulSoup(html, "html.parser")
@@ -39,9 +41,14 @@ def update_session_via_flaresolverr(info,
39
41
  sess,
40
42
  target_url: str,
41
43
  timeout: int = 60):
44
+ # Check if FlareSolverr is available
45
+ if not is_flaresolverr_available(shared_state):
46
+ info("FlareSolverr is not configured. Cannot bypass Cloudflare protection.")
47
+ return False
48
+
42
49
  flaresolverr_url = shared_state.values["config"]('FlareSolverr').get('url')
43
50
  if not flaresolverr_url:
44
- info("Cannot proceed without FlareSolverr. Please set it up to try again!")
51
+ info("Cannot proceed without FlareSolverr. Please configure it in the web UI!")
45
52
  return False
46
53
 
47
54
  fs_payload = {
@@ -104,6 +111,12 @@ def ensure_session_cf_bypassed(info, shared_state, session, url, headers):
104
111
 
105
112
  # If page is protected, try FlareSolverr
106
113
  if resp.status_code == 403 or is_cloudflare_challenge(resp.text):
114
+ # Check if FlareSolverr is available before attempting bypass
115
+ if not is_flaresolverr_available(shared_state):
116
+ info("Cloudflare protection detected but FlareSolverr is not configured. "
117
+ "Please configure FlareSolverr in the web UI to access this site.")
118
+ return None, None, None
119
+
107
120
  info("Encountered Cloudflare protection. Solving challenge with FlareSolverr...")
108
121
  flaresolverr_result = update_session_via_flaresolverr(info, shared_state, session, url)
109
122
  if not flaresolverr_result:
@@ -156,7 +169,13 @@ def flaresolverr_get(shared_state, url, timeout=60):
156
169
  """
157
170
  Core function for performing a GET request via FlareSolverr only.
158
171
  Used internally by FlareSolverrSession.get()
172
+
173
+ Returns None if FlareSolverr is not available.
159
174
  """
175
+ # Check if FlareSolverr is available
176
+ if not is_flaresolverr_available(shared_state):
177
+ raise RuntimeError("FlareSolverr is not configured. Please configure it in the web UI.")
178
+
160
179
  flaresolverr_url = shared_state.values["config"]('FlareSolverr').get('url')
161
180
  if not flaresolverr_url:
162
181
  raise RuntimeError("FlareSolverr URL not configured in shared_state.")
@@ -13,19 +13,31 @@ from bs4 import BeautifulSoup
13
13
  from requests.exceptions import Timeout, RequestException
14
14
 
15
15
  from quasarr.providers.log import info, debug
16
- from quasarr.providers.utils import is_site_usable
16
+ from quasarr.providers.utils import is_site_usable, is_flaresolverr_available
17
17
 
18
18
 
19
19
  class SkippedSiteError(Exception):
20
20
  """Raised when a site is skipped due to missing credentials or login being skipped."""
21
21
  pass
22
22
 
23
+
24
+ class FlareSolverrNotAvailableError(Exception):
25
+ """Raised when FlareSolverr is required but not available."""
26
+ pass
27
+
28
+
23
29
  hostname = "al"
24
30
 
25
31
  SESSION_MAX_AGE_SECONDS = 24 * 60 * 60 # 24 hours
26
32
 
27
33
 
28
34
  def create_and_persist_session(shared_state):
35
+ # AL requires FlareSolverr - check availability first
36
+ if not is_flaresolverr_available(shared_state):
37
+ info(f'"{hostname.upper()}" requires FlareSolverr which is not configured. '
38
+ f'Please configure FlareSolverr in the web UI to use this site.')
39
+ return None
40
+
29
41
  cfg = shared_state.values["config"]("Hostnames")
30
42
  host = cfg.get(hostname)
31
43
  credentials_cfg = shared_state.values["config"](hostname.upper())
@@ -115,6 +127,11 @@ def retrieve_and_validate_session(shared_state):
115
127
  if not is_site_usable(shared_state, hostname):
116
128
  return None
117
129
 
130
+ # AL requires FlareSolverr - check availability
131
+ if not is_flaresolverr_available(shared_state):
132
+ info(f'"{hostname.upper()}" requires FlareSolverr which is not configured')
133
+ return None
134
+
118
135
  db = shared_state.values["database"]("sessions")
119
136
  stored = db.retrieve(hostname)
120
137
  if not stored:
@@ -222,6 +239,19 @@ def fetch_via_flaresolverr(shared_state,
222
239
  – post_data: dict of form‐fields if method=="POST"
223
240
  – timeout: seconds (FlareSolverr's internal maxTimeout = timeout*1000 ms)
224
241
  """
242
+ # Check if FlareSolverr is available
243
+ if not is_flaresolverr_available(shared_state):
244
+ info(f'"{hostname.upper()}" requires FlareSolverr which is not configured. '
245
+ f'Please configure FlareSolverr in the web UI.')
246
+ return {
247
+ "status_code": None,
248
+ "headers": {},
249
+ "json": None,
250
+ "text": "",
251
+ "cookies": [],
252
+ "error": "FlareSolverr is not configured"
253
+ }
254
+
225
255
  flaresolverr_url = shared_state.values["config"]('FlareSolverr').get('url')
226
256
 
227
257
  sess = retrieve_and_validate_session(shared_state)
@@ -9,6 +9,9 @@ from urllib.parse import urlparse
9
9
 
10
10
  import requests
11
11
 
12
+ # Fallback user agent when FlareSolverr is not available
13
+ FALLBACK_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
14
+
12
15
 
13
16
  class Unbuffered(object):
14
17
  def __init__(self, stream):
@@ -130,6 +133,25 @@ def validate_address(address, name):
130
133
  f"Error: {name} '{address}' is invalid. It must contain 1 or 2 colons, but it has {colon_count}.")
131
134
 
132
135
 
136
+ def is_flaresolverr_available(shared_state):
137
+ """
138
+ Check if FlareSolverr is configured and available.
139
+
140
+ Returns:
141
+ bool: True if FlareSolverr URL is set and not skipped, False otherwise
142
+ """
143
+ # Check if FlareSolverr was skipped
144
+ if shared_state.values["database"]("skip_flaresolverr").retrieve("skipped"):
145
+ return False
146
+
147
+ # Check if FlareSolverr URL is configured
148
+ flaresolverr_url = shared_state.values["config"]('FlareSolverr').get('url')
149
+ if not flaresolverr_url:
150
+ return False
151
+
152
+ return True
153
+
154
+
133
155
  def is_site_usable(shared_state, shorthand):
134
156
  """
135
157
  Check if a site is fully configured and usable.
@@ -8,7 +8,7 @@ import requests
8
8
 
9
9
 
10
10
  def get_version():
11
- return "1.27.0"
11
+ return "1.28.0"
12
12
 
13
13
 
14
14
  def get_latest_version():
@@ -2,10 +2,8 @@
2
2
  # Quasarr
3
3
  # Project by https://github.com/rix1337
4
4
 
5
- import re
6
5
  import time
7
6
  from base64 import urlsafe_b64encode
8
- from datetime import datetime, timedelta
9
7
  from html import unescape
10
8
  from urllib.parse import urljoin, quote_plus
11
9
 
quasarr/storage/setup.py CHANGED
@@ -19,7 +19,7 @@ from quasarr.providers.html_templates import render_button, render_form, render_
19
19
  render_centered_html
20
20
  from quasarr.providers.log import info
21
21
  from quasarr.providers.shared_state import extract_valid_hostname
22
- from quasarr.providers.utils import extract_kv_pairs, extract_allowed_keys
22
+ from quasarr.providers.utils import extract_kv_pairs, extract_allowed_keys, FALLBACK_USER_AGENT
23
23
  from quasarr.providers.web_server import Server
24
24
  from quasarr.storage.config import Config
25
25
  from quasarr.storage.sqlite_database import DataBase
@@ -789,10 +789,39 @@ def flaresolverr_config(shared_state):
789
789
  <input type="text" id="url" name="url" placeholder="http://192.168.0.1:8191/v1"><br>
790
790
  '''
791
791
  form_html = f'''
792
+ <style>
793
+ .button-row {{
794
+ display: flex;
795
+ gap: 0.75rem;
796
+ justify-content: center;
797
+ flex-wrap: wrap;
798
+ margin-top: 1rem;
799
+ }}
800
+ .btn-warning {{
801
+ background-color: #ffc107;
802
+ color: #212529;
803
+ border: 1.5px solid #d39e00;
804
+ padding: 0.5rem 1rem;
805
+ font-size: 1rem;
806
+ border-radius: 0.5rem;
807
+ font-weight: 500;
808
+ cursor: pointer;
809
+ }}
810
+ .btn-warning:hover {{
811
+ background-color: #e0a800;
812
+ border-color: #c69500;
813
+ }}
814
+ </style>
792
815
  <form action="/api/flaresolverr" method="post" onsubmit="return handleSubmit(this)">
793
816
  {form_content}
794
- {render_button("Save", "primary", {"type": "submit", "id": "submitBtn"})}
817
+ <div class="button-row">
818
+ {render_button("Save", "primary", {"type": "submit", "id": "submitBtn"})}
819
+ <button type="button" class="btn-warning" id="skipBtn" onclick="skipFlaresolverr()">Skip for now</button>
820
+ </div>
795
821
  </form>
822
+ <p style="font-size:0.875rem; color:var(--secondary, #6c757d); margin-top:1rem;">
823
+ Skipping will allow Quasarr to start, but some sites (like AL) won't work without FlareSolverr.
824
+ </p>
796
825
  <script>
797
826
  var formSubmitted = false;
798
827
  function handleSubmit(form) {{
@@ -800,12 +829,54 @@ def flaresolverr_config(shared_state):
800
829
  formSubmitted = true;
801
830
  var btn = document.getElementById('submitBtn');
802
831
  if (btn) {{ btn.disabled = true; btn.textContent = 'Saving...'; }}
832
+ document.getElementById('skipBtn').disabled = true;
803
833
  return true;
804
834
  }}
835
+ function skipFlaresolverr() {{
836
+ if (formSubmitted) return;
837
+ formSubmitted = true;
838
+ var skipBtn = document.getElementById('skipBtn');
839
+ var submitBtn = document.getElementById('submitBtn');
840
+ if (skipBtn) {{ skipBtn.disabled = true; skipBtn.textContent = 'Skipping...'; }}
841
+ if (submitBtn) {{ submitBtn.disabled = true; }}
842
+
843
+ fetch('/api/flaresolverr/skip', {{ method: 'POST' }})
844
+ .then(response => {{
845
+ if (response.ok) {{
846
+ window.location.href = '/skip-success';
847
+ }} else {{
848
+ alert('Failed to skip FlareSolverr setup');
849
+ formSubmitted = false;
850
+ if (skipBtn) {{ skipBtn.disabled = false; skipBtn.textContent = 'Skip for now'; }}
851
+ if (submitBtn) {{ submitBtn.disabled = false; }}
852
+ }}
853
+ }})
854
+ .catch(error => {{
855
+ alert('Error: ' + error.message);
856
+ formSubmitted = false;
857
+ if (skipBtn) {{ skipBtn.disabled = false; skipBtn.textContent = 'Skip for now'; }}
858
+ if (submitBtn) {{ submitBtn.disabled = false; }}
859
+ }});
860
+ }}
805
861
  </script>
806
862
  '''
807
863
  return render_form("Set FlareSolverr URL", form_html)
808
864
 
865
+ @app.get('/skip-success')
866
+ def skip_success():
867
+ return render_reconnect_success(
868
+ "FlareSolverr setup skipped. Some sites (like AL) won't work. You can configure it later in the web UI.")
869
+
870
+ @app.post('/api/flaresolverr/skip')
871
+ def skip_flaresolverr():
872
+ """Skip FlareSolverr setup and continue startup."""
873
+ DataBase("skip_flaresolverr").update_store("skipped", "true")
874
+ # Set fallback user agent
875
+ shared_state.update("user_agent", FALLBACK_USER_AGENT)
876
+ info('FlareSolverr setup skipped by user choice')
877
+ quasarr.providers.web_server.temp_server_success = True
878
+ return {"success": True}
879
+
809
880
  @app.post('/api/flaresolverr')
810
881
  def set_flaresolverr_url():
811
882
  url = request.forms.get('url').strip()
@@ -825,6 +896,8 @@ def flaresolverr_config(shared_state):
825
896
  resp = requests.post(url, headers=headers, json=data, timeout=30)
826
897
  if resp.status_code == 200:
827
898
  config.save("url", url)
899
+ # Clear skip preference since we now have a working URL
900
+ DataBase("skip_flaresolverr").delete("skipped")
828
901
  print(f'Using Flaresolverr URL: "{url}"')
829
902
  quasarr.providers.web_server.temp_server_success = True
830
903
  return render_reconnect_success("FlareSolverr URL saved successfully!")
@@ -836,10 +909,10 @@ def flaresolverr_config(shared_state):
836
909
  return render_fail("Could not reach FlareSolverr at that URL (expected HTTP 200).")
837
910
 
838
911
  info(
839
- '"flaresolverr" URL is required for proper operation. '
912
+ '"flaresolverr" URL is required for some sites (like AL). '
840
913
  f'Starting web server for config at: "{shared_state.values["internal_address"]}".'
841
914
  )
842
- info("Please enter your FlareSolverr URL now.")
915
+ info("Please enter your FlareSolverr URL now, or skip to allow Quasarr to launch!")
843
916
  return Server(app, listen='0.0.0.0', port=shared_state.values['port']).serve_temporarily()
844
917
 
845
918
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quasarr
3
- Version: 1.27.0
3
+ Version: 1.28.0
4
4
  Summary: Quasarr connects JDownloader with Radarr, Sonarr and LazyLibrarian. It also decrypts links protected by CAPTCHAs.
5
5
  Home-page: https://github.com/rix1337/Quasarr
6
6
  Author: rix1337
@@ -45,22 +45,28 @@ Quasarr will confidently handle the rest. Some CAPTCHA types require [Tampermonk
45
45
 
46
46
  # Instructions
47
47
 
48
- 1. Set up and run [FlareSolverr 3](https://github.com/FlareSolverr/FlareSolverr)
49
- 2. Set up and run [JDownloader 2](https://jdownloader.org/download/index)
50
- 3. Configure the integrations below
48
+ # Instructions
49
+
50
+ 1. Set up and run [JDownloader 2](https://jdownloader.org/download/index)
51
+ 2. Configure the integrations below
52
+ 3. (Optional) Set up [FlareSolverr 3](https://github.com/FlareSolverr/FlareSolverr) for sites that require it
51
53
 
52
54
  > **Finding your Quasarr URL and API Key**
53
55
  > Both values are shown in the console output under **API Information**, or in the Quasarr web UI.
54
56
 
55
57
  ---
56
58
 
57
- ## FlareSolverr
59
+ ## FlareSolverr (Optional)
58
60
 
59
- Provide your FlareSolverr URL during setup. Include the version path:
61
+ FlareSolverr is **optional** but **required for some sites** (e.g., AL) that use Cloudflare protection. You can skip FlareSolverr during setup and configure it later via the web UI.
62
+
63
+ If using FlareSolverr, provide your URL including the version path:
60
64
  ```
61
65
  http://192.168.1.1:8191/v1
62
66
  ```
63
67
 
68
+ > **Note:** Sites requiring FlareSolverr will show a warning in the console when it's not configured.
69
+
64
70
  ---
65
71
 
66
72
  ## Quasarr
@@ -1,18 +1,18 @@
1
- quasarr/__init__.py,sha256=6-6FpRbLyr_DXpNTlXkzQAPnDxboMkHKvkhlmyP632Q,13593
2
- quasarr/api/__init__.py,sha256=9Y_DTNYsHeimrXL3mAli8OUg0zqo7QGLF2ft40d3R-c,6822
1
+ quasarr/__init__.py,sha256=cEtxN2AuwKvrxpIvAR7UL997VtYQ4iN3Eo3ZnP-WjZQ,14682
2
+ quasarr/api/__init__.py,sha256=UOyyuOjF2WN6Um2wwQNHjFA-Rj0prb11z8SCjbifKJU,6940
3
3
  quasarr/api/arr/__init__.py,sha256=6CFASudVLlqKVNhTnS72Np2T3uAMsJXE-8u0986WW9c,17460
4
4
  quasarr/api/captcha/__init__.py,sha256=IhJVn9iWtb01P2yfoqtOF7wSsiXizES7HNn29BX1uHk,60268
5
- quasarr/api/config/__init__.py,sha256=hUPDhGDyLe3DhzEl6mK5iBf8FDRu-9bNSjEnECsNXkY,5163
5
+ quasarr/api/config/__init__.py,sha256=0oN7-2uev6K3SSLEv7kixBY5_kS9vT6kiaQRS2frbgA,13749
6
6
  quasarr/api/sponsors_helper/__init__.py,sha256=kAZabPlplPYRG6Uw7ZHTk5uypualwvhs-NoTOjQhhhA,6369
7
7
  quasarr/api/statistics/__init__.py,sha256=NrBAjjHkIUE95HhPUGIfNqh2IqBqJ_zm00S90Y-Qnus,7038
8
8
  quasarr/downloads/__init__.py,sha256=bpNg6LNqoqpnA-U7uVDhq9jM6VYB2bkekCw1XxZRpWM,11613
9
9
  quasarr/downloads/linkcrypters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- quasarr/downloads/linkcrypters/al.py,sha256=pM3NDan8x0WU8OS1GV3HuuV4B6Nm0a-ATrVORvLHt9M,8487
10
+ quasarr/downloads/linkcrypters/al.py,sha256=mfUG5VclC_-FcGoZL9zHYD7dz7X_YpaNmoKkgiyl9-0,8812
11
11
  quasarr/downloads/linkcrypters/filecrypt.py,sha256=GT51x_MG_hW4IpOF6OvL5r-2mTnMijI8K7_1D5Bfn4U,18884
12
12
  quasarr/downloads/linkcrypters/hide.py,sha256=8YmNm49JmVa1zZdTHpjK9gnQrX435Cq5fo4JTNsIpds,4850
13
13
  quasarr/downloads/packages/__init__.py,sha256=Cub3ztyFYBm30HprvZl7qvfYnjaOH9FsRWDLEyCPHkE,18305
14
14
  quasarr/downloads/sources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- quasarr/downloads/sources/al.py,sha256=tb7HpMWFnW3VZCAjsk4tFZsh56xygDVmPgpv5cW1q1M,26942
15
+ quasarr/downloads/sources/al.py,sha256=g587VESZRZHZ03uxHKpufEr5qAtzbyGLmoijksU35jk,27297
16
16
  quasarr/downloads/sources/by.py,sha256=kmUTn3izayRCV7W-t0E4kYE8qTbt3L3reCLozfvRGcU,3807
17
17
  quasarr/downloads/sources/dd.py,sha256=8X2tOle3qTq0b60Aa3o0uqp2vNELDHYYj99ERI7U_X0,2971
18
18
  quasarr/downloads/sources/dj.py,sha256=wY00hVRNhucZBG1hfExKqayhP1ISD8FFQm7wHYxutOk,404
@@ -26,10 +26,10 @@ quasarr/downloads/sources/nx.py,sha256=ESWGDz07m2kntvTGoNlL9Gleld-HUl9ckphaJA9PU
26
26
  quasarr/downloads/sources/sf.py,sha256=ecPHNsNiRNXTfQX9MBLzJKqrEc1IpkrKkBXpihTPhkE,6352
27
27
  quasarr/downloads/sources/sj.py,sha256=Bkv0c14AXct50n_viaTNK3bYG-Bpvx8x2D0UN_6gm78,404
28
28
  quasarr/downloads/sources/sl.py,sha256=jWprFt1Hew1T67fB1O_pc9YWgc3NVh30KXSwSyS50Pc,3186
29
- quasarr/downloads/sources/wd.py,sha256=0FzfLaUUdrnoybWlTxszmT5a38j9KrlLGhkLNeEuIQ8,3905
29
+ quasarr/downloads/sources/wd.py,sha256=kr1I1uJa7ZkEPH2LA6alXTJEn0LBPgLCwIh3wLXwCv8,4447
30
30
  quasarr/downloads/sources/wx.py,sha256=EygMfkgBMZYj3tSk4gvj5DcojkRswGhY_y8FMPNnVeU,4834
31
31
  quasarr/providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
- quasarr/providers/cloudflare.py,sha256=mJxTYM7JjwIjdO6U_GyVj-dyug6XdD0-ObkYTaPtFVg,7028
32
+ quasarr/providers/cloudflare.py,sha256=9iet8runc2VHVcA0_2z1qkrL6D5JKqz1ndktqCgsJFs,7873
33
33
  quasarr/providers/html_images.py,sha256=2n82gTJg7E7q2ytPFN4FWouYTIlmPYu_iHFtG7uktIA,28482
34
34
  quasarr/providers/html_templates.py,sha256=YMwdi7l_tHL0-qsUnwi4aPrE5Q6ZDxbjsPIfr-6uemY,10265
35
35
  quasarr/providers/imdb_metadata.py,sha256=10L4kZkt6Fg0HGdNcc6KCtIQHRYEqdarLyaMVN6mT8w,4843
@@ -39,17 +39,17 @@ quasarr/providers/notifications.py,sha256=bohT-6yudmFnmZMc3BwCGX0n1HdzSVgQG_LDZm
39
39
  quasarr/providers/obfuscated.py,sha256=xPI3WrteOiZN5BgNDp0CURcYfkRrdnRCz_cT7BpzIJU,1363310
40
40
  quasarr/providers/shared_state.py,sha256=-TIiH2lkCfovq7bzUZicpUjXEjS87ZHCcevsFgySOqw,29944
41
41
  quasarr/providers/statistics.py,sha256=cEQixYnDMDqtm5wWe40E_2ucyo4mD0n3SrfelhQi1L8,6452
42
- quasarr/providers/utils.py,sha256=CEBsrtuasrQ8-l535Va4S_7gT9N47qjjgOKYGJX0TE0,5095
43
- quasarr/providers/version.py,sha256=pwciXHt2xf8Tbxgm6j1ZZ_-zwPJuduxnv-hGwn0wbjM,4004
42
+ quasarr/providers/utils.py,sha256=TpNuuUfH811CfROf41uraKbFBvQ6on-7dCz7IK5i3iI,5836
43
+ quasarr/providers/version.py,sha256=vO8g5xcAenB1SYXJRp6v298rkOmbAkbSSS34w5_iOec,4004
44
44
  quasarr/providers/web_server.py,sha256=AYd0KRxdDWMBr87BP8wlSMuL4zZo0I_rY-vHBai6Pfg,1688
45
45
  quasarr/providers/sessions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
- quasarr/providers/sessions/al.py,sha256=ARNgWAhTKQhAz0mF8kB2OsT8bGcC-mmYF-Wd22to-wA,11346
46
+ quasarr/providers/sessions/al.py,sha256=WXue9LaT4y0BzsbKtHbN6bb_72c4AZZWR9NP-vg9-cg,12462
47
47
  quasarr/providers/sessions/dd.py,sha256=I9tQCdxmhtbdmRUhKlkM1ZJjja1N1bdRSEZdfbSRWkc,2832
48
48
  quasarr/providers/sessions/dl.py,sha256=6tch4QzkdwbU6XoNQE22T5nz5eoKlBVGa0eyjXJHPyA,5574
49
49
  quasarr/providers/sessions/nx.py,sha256=vZDVnu4sKizx1wyKmrTr8itGURSEIVOtzMI5efjF6Oo,2924
50
50
  quasarr/search/__init__.py,sha256=V59LIiC75mQvasDdTjiWZRbPD1jXO1lhXlKeNVX0iOc,5726
51
51
  quasarr/search/sources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
- quasarr/search/sources/al.py,sha256=yr6wx-VcSOFYK_o3N1bepC4t6Gvt9eDvcG9fQBFg0bg,17203
52
+ quasarr/search/sources/al.py,sha256=AmTQDc6voMXh8Sh_IrJX_3gQ5UYKMri-aGaE-wbI3ik,17152
53
53
  quasarr/search/sources/by.py,sha256=vnE3L43V8suPhPHcn6LVxKO1e3mJaDRqIIMg2BGxr_g,7915
54
54
  quasarr/search/sources/dd.py,sha256=pVpdHLZlw2CYklBf_YLkeDWbCNsDLR2iecccR2c2RyI,4889
55
55
  quasarr/search/sources/dj.py,sha256=2HIdg5ddXP4DtjHlyXmuQ8QVhOPt3Hh2kL4uxhFJK-8,7074
@@ -68,11 +68,11 @@ quasarr/search/sources/wd.py,sha256=O02j3irSlVw2qES82g_qHuavAk-njjSRH1dHSCnOUas,
68
68
  quasarr/search/sources/wx.py,sha256=_h1M6GhkJzixwHscrt0lMOnPSEDP1Xl24OypEe8Jy7c,12906
69
69
  quasarr/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
70
70
  quasarr/storage/config.py,sha256=SSTgIce2FVYoVTK_6OCU3msknhxuLA3EC4Kcrrf_dxQ,6378
71
- quasarr/storage/setup.py,sha256=YpF11NchXSMRfI7S0yzg-LqlmUhlWMgjZCFyGYDwS5M,38151
71
+ quasarr/storage/setup.py,sha256=-EZotV31hqJpd40sXYvvt0XwemLyb_7f4oFurCBEqZA,41436
72
72
  quasarr/storage/sqlite_database.py,sha256=yMqFQfKf0k7YS-6Z3_7pj4z1GwWSXJ8uvF4IydXsuTE,3554
73
- quasarr-1.27.0.dist-info/licenses/LICENSE,sha256=QQFCAfDgt7lSA8oSWDHIZ9aTjFbZaBJdjnGOHkuhK7k,1060
74
- quasarr-1.27.0.dist-info/METADATA,sha256=D3_jpkN9ALKqlaZM-X524wBJyJyND7m9RdDlxB3Cqdg,10679
75
- quasarr-1.27.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
76
- quasarr-1.27.0.dist-info/entry_points.txt,sha256=gXi8mUKsIqKVvn-bOc8E5f04sK_KoMCC-ty6b2Hf-jc,40
77
- quasarr-1.27.0.dist-info/top_level.txt,sha256=dipJdaRda5ruTZkoGfZU60bY4l9dtPlmOWwxK_oGSF0,8
78
- quasarr-1.27.0.dist-info/RECORD,,
73
+ quasarr-1.28.0.dist-info/licenses/LICENSE,sha256=QQFCAfDgt7lSA8oSWDHIZ9aTjFbZaBJdjnGOHkuhK7k,1060
74
+ quasarr-1.28.0.dist-info/METADATA,sha256=N0B7e7mk6JIhSMRnNGXVI-h6kYwHNGTG7A3N54Y4pnI,11019
75
+ quasarr-1.28.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
76
+ quasarr-1.28.0.dist-info/entry_points.txt,sha256=gXi8mUKsIqKVvn-bOc8E5f04sK_KoMCC-ty6b2Hf-jc,40
77
+ quasarr-1.28.0.dist-info/top_level.txt,sha256=dipJdaRda5ruTZkoGfZU60bY4l9dtPlmOWwxK_oGSF0,8
78
+ quasarr-1.28.0.dist-info/RECORD,,