quasarr 2.4.11__py3-none-any.whl → 2.6.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
@@ -386,48 +386,43 @@ def jdownloader_connection(shared_state_dict, shared_state_lock):
386
386
  try:
387
387
  shared_state.set_state(shared_state_dict, shared_state_lock)
388
388
 
389
- shared_state.set_device_from_config()
389
+ while True:
390
+ shared_state.set_device_from_config()
390
391
 
391
- connection_established = (
392
- shared_state.get_device() and shared_state.get_device().name
393
- )
394
- if not connection_established:
395
- i = 0
396
- while i < 10:
397
- i += 1
392
+ device = shared_state.get_device()
393
+
394
+ try:
398
395
  info(
399
- f'Connection {i} to JDownloader failed. Device name: "{shared_state.values["device"]}"'
396
+ f'Connection to JDownloader successful. Device name: "{device.name}"'
400
397
  )
401
- time.sleep(60)
402
- shared_state.set_device_from_config()
403
- connection_established = (
404
- shared_state.get_device() and shared_state.get_device().name
405
- )
406
- if connection_established:
407
- break
398
+ except Exception as e:
399
+ info(f"Error connecting to JDownloader: {e}! Stopping Quasarr...")
400
+ sys.exit(1)
408
401
 
409
- try:
410
- info(
411
- f'Connection to JDownloader successful. Device name: "{shared_state.get_device().name}"'
412
- )
413
- except Exception as e:
414
- info(f"Error connecting to JDownloader: {e}! Stopping Quasarr!")
415
- sys.exit(1)
402
+ try:
403
+ shared_state.set_device_settings()
404
+ except Exception as e:
405
+ print(f"Error checking settings: {e}")
416
406
 
417
- try:
418
- shared_state.set_device_settings()
419
- except Exception as e:
420
- print(f"Error checking settings: {e}")
407
+ try:
408
+ shared_state.update_jdownloader()
409
+ except Exception as e:
410
+ print(f"Error updating JDownloader: {e}")
421
411
 
422
- try:
423
- shared_state.update_jdownloader()
424
- except Exception as e:
425
- print(f"Error updating JDownloader: {e}")
412
+ try:
413
+ shared_state.start_downloads()
414
+ except Exception as e:
415
+ print(f"Error starting downloads: {e}")
426
416
 
427
- try:
428
- shared_state.start_downloads()
429
- except Exception as e:
430
- print(f"Error starting downloads: {e}")
417
+ while True:
418
+ time.sleep(300)
419
+ device_state = shared_state.check_device(
420
+ shared_state.values.get("device")
421
+ )
422
+ if not device_state:
423
+ info("Lost connection to JDownloader. Reconnecting...")
424
+ shared_state.update("device", False)
425
+ break
431
426
 
432
427
  except KeyboardInterrupt:
433
428
  pass
quasarr/api/__init__.py CHANGED
@@ -8,6 +8,7 @@ import quasarr.providers.html_images as images
8
8
  from quasarr.api.arr import setup_arr_routes
9
9
  from quasarr.api.captcha import setup_captcha_routes
10
10
  from quasarr.api.config import setup_config
11
+ from quasarr.api.jdownloader import get_jdownloader_modal_script, get_jdownloader_status
11
12
  from quasarr.api.packages import setup_packages_routes
12
13
  from quasarr.api.sponsors_helper import setup_sponsors_helper_routes
13
14
  from quasarr.api.statistics import setup_statistics
@@ -49,12 +50,9 @@ def get_api(shared_state_dict, shared_state_lock):
49
50
  protected = shared_state.get_db("protected").retrieve_all_titles()
50
51
  api_key = Config("API").get("key")
51
52
 
52
- # Get quick status summary
53
- try:
54
- device = shared_state.values.get("device")
55
- jd_connected = device is not None and device is not False
56
- except:
57
- jd_connected = False
53
+ # Get JDownloader status and modal script
54
+ jd_status = get_jdownloader_status(shared_state)
55
+ jd_modal_script = get_jdownloader_modal_script()
58
56
 
59
57
  # Calculate hostname status
60
58
  hostnames_config = Config("Hostnames")
@@ -126,10 +124,14 @@ def get_api(shared_state_dict, shared_state_lock):
126
124
  # Status bars
127
125
  status_bars = f"""
128
126
  <div class="status-bar">
129
- <span class="status-pill {"success" if jd_connected else "error"}">
130
- {"" if jd_connected else "❌"} JDownloader {"connected" if jd_connected else "disconnected"}
127
+ <span class="status-pill {jd_status['status_class']}"
128
+ onclick="openJDownloaderModal()"
129
+ title="Click to configure JDownloader">
130
+ {jd_status['status_text']}
131
131
  </span>
132
- <span class="status-pill {hostname_status_class}">
132
+ <span class="status-pill {hostname_status_class}"
133
+ onclick="location.href='/hostnames'"
134
+ title="Click to configure Hostnames">
133
135
  {hostname_status_emoji} {hostname_status_text}
134
136
  </span>
135
137
  </div>
@@ -209,6 +211,11 @@ def get_api(shared_state_dict, shared_state_lock):
209
211
  padding: 8px 16px;
210
212
  border-radius: 0.5rem;
211
213
  font-weight: 500;
214
+ transition: transform 0.1s ease;
215
+ cursor: pointer;
216
+ }}
217
+ .status-pill:hover {{
218
+ transform: scale(1.05);
212
219
  }}
213
220
  .status-pill.success {{
214
221
  background: var(--status-success-bg, #e8f5e9);
@@ -367,15 +374,15 @@ def get_api(shared_state_dict, shared_state_lock):
367
374
  /* Dark mode */
368
375
  @media (prefers-color-scheme: dark) {{
369
376
  :root {{
370
- --status-success-bg: #1b5e20;
371
- --status-success-color: #a5d6a7;
372
- --status-success-border: #2e7d32;
377
+ --status-success-bg: #1c4532;
378
+ --status-success-color: #68d391;
379
+ --status-success-border: #276749;
373
380
  --status-warning-bg: #3d3520;
374
381
  --status-warning-color: #ffb74d;
375
382
  --status-warning-border: #d69e2e;
376
- --status-error-bg: #b71c1c;
377
- --status-error-color: #ef9a9a;
378
- --status-error-border: #c62828;
383
+ --status-error-bg: #3d2d2d;
384
+ --status-error-color: #fc8181;
385
+ --status-error-border: #c53030;
379
386
  --alert-warning-bg: #3d3520;
380
387
  --alert-warning-border: #d69e2e;
381
388
  --card-bg: #2d3748;
@@ -478,6 +485,7 @@ def get_api(shared_state_dict, shared_state_lock):
478
485
  );
479
486
  }}
480
487
  </script>
488
+ {jd_modal_script}
481
489
  """
482
490
  # Add logout link for form auth
483
491
  logout_html = '<a href="/logout">Logout</a>' if show_logout_link() else ""
@@ -56,6 +56,16 @@ def parse_payload(payload_str):
56
56
 
57
57
 
58
58
  def setup_arr_routes(app):
59
+ def check_user_agent():
60
+ user_agent = request.headers.get("User-Agent") or ""
61
+ if not any(
62
+ tool in user_agent.lower() for tool in ["radarr", "sonarr", "lazylibrarian"]
63
+ ):
64
+ msg = f"Unsupported User-Agent: {user_agent}. Quasarr as a compatibility layer must be called by Radarr, Sonarr or LazyLibrarian directly."
65
+ info(msg)
66
+ abort(406, msg)
67
+ return user_agent
68
+
59
69
  @app.get("/download/")
60
70
  def fake_nzb_file():
61
71
  payload = request.query.payload
@@ -75,6 +85,7 @@ def setup_arr_routes(app):
75
85
  @app.post("/api")
76
86
  @require_api_key
77
87
  def download_fake_nzb_file():
88
+ request_from = check_user_agent()
78
89
  downloads = request.files.getall("name")
79
90
  nzo_ids = [] # naming structure for package IDs expected in newznab
80
91
 
@@ -97,7 +108,6 @@ def setup_arr_routes(app):
97
108
  source_key = root.find(".//file").attrib.get("source_key") or None
98
109
 
99
110
  info(f'Attempting download for "{title}"')
100
- request_from = request.headers.get("User-Agent")
101
111
  downloaded = download(
102
112
  shared_state,
103
113
  request_from,
@@ -128,6 +138,8 @@ def setup_arr_routes(app):
128
138
  @app.get("/api/<mirror>")
129
139
  @require_api_key
130
140
  def quasarr_api(mirror=None):
141
+ request_from = check_user_agent()
142
+
131
143
  api_type = (
132
144
  "arr_download_client"
133
145
  if request.query.mode
@@ -198,7 +210,6 @@ def setup_arr_routes(app):
198
210
 
199
211
  nzo_ids = []
200
212
  info(f'Attempting download for "{parsed_payload["title"]}"')
201
- request_from = "lazylibrarian"
202
213
 
203
214
  downloaded = download(
204
215
  shared_state,
@@ -267,8 +278,6 @@ def setup_arr_routes(app):
267
278
  )
268
279
 
269
280
  mode = request.query.t
270
- request_from = request.headers.get("User-Agent")
271
-
272
281
  if mode == "caps":
273
282
  info(f"Providing indexer capability information to {request_from}")
274
283
  return """<?xml version="1.0" encoding="UTF-8"?>
@@ -352,10 +361,10 @@ def setup_arr_routes(app):
352
361
  mirror=mirror,
353
362
  )
354
363
  else:
355
- info(
364
+ # sonarr expects this but we will not support non-imdbid searches
365
+ debug(
356
366
  f"Ignoring search request from {request_from} - only imdbid searches are supported"
357
367
  )
358
- releases = [] # sonarr expects this but we will not support non-imdbid searches
359
368
 
360
369
  items = ""
361
370
  for release in releases:
@@ -11,6 +11,7 @@ import requests
11
11
  from bottle import HTTPResponse, redirect, request, response
12
12
 
13
13
  import quasarr.providers.html_images as images
14
+ from quasarr.api.jdownloader import get_jdownloader_disconnected_page
14
15
  from quasarr.downloads.linkcrypters.filecrypt import DLC, get_filecrypt_links
15
16
  from quasarr.downloads.packages import delete_package
16
17
  from quasarr.providers import obfuscated, shared_state
@@ -46,15 +47,7 @@ def setup_captcha_routes(app):
46
47
  except KeyError:
47
48
  device = None
48
49
  if not device:
49
- return render_centered_html(f'''<h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
50
- <div class="status-bar">
51
- <span class="status-pill error">
52
- ❌ JDownloader disconnected
53
- </span>
54
- </div>
55
- <p>
56
- {render_button("Back", "secondary", {"onclick": "location.href='/'"})}
57
- </p>''')
50
+ return get_jdownloader_disconnected_page(shared_state)
58
51
 
59
52
  protected = shared_state.get_db("protected").retrieve_all_titles()
60
53
  if not protected:
@@ -3,28 +3,27 @@
3
3
  # Project by https://github.com/rix1337
4
4
 
5
5
  import os
6
- import re
7
6
  import signal
8
7
  import threading
9
8
  import time
10
- from urllib.parse import urlparse
11
9
 
12
- import requests
13
10
  from bottle import request, response
14
11
 
15
- from quasarr.providers.html_templates import render_button, render_fail, render_form
12
+ from quasarr.providers.html_templates import render_button, render_form
16
13
  from quasarr.providers.log import info
17
- from quasarr.providers.shared_state import extract_valid_hostname
18
- from quasarr.providers.utils import (
19
- check_flaresolverr,
20
- extract_allowed_keys,
21
- extract_kv_pairs,
22
- )
23
14
  from quasarr.storage.config import Config
24
15
  from quasarr.storage.setup import (
16
+ check_credentials,
17
+ clear_skip_login,
18
+ delete_skip_flaresolverr_preference,
19
+ get_flaresolverr_status_data,
20
+ get_skip_login,
25
21
  hostname_form_html,
26
- render_reconnect_success,
22
+ import_hostnames_from_url,
23
+ save_flaresolverr_url,
27
24
  save_hostnames,
25
+ save_jdownloader_settings,
26
+ verify_jdownloader_credentials,
28
27
  )
29
28
  from quasarr.storage.sqlite_database import DataBase
30
29
 
@@ -50,7 +49,6 @@ def setup_config(app, shared_state):
50
49
  hostname_form_html(
51
50
  shared_state,
52
51
  message,
53
- show_restart_button=True,
54
52
  show_skip_management=True,
55
53
  )
56
54
  + back_button,
@@ -60,97 +58,21 @@ def setup_config(app, shared_state):
60
58
  def hostnames_api():
61
59
  return save_hostnames(shared_state, timeout=1, first_run=False)
62
60
 
63
- @app.post("/api/hostnames/import-url")
64
- def import_hostnames_from_url():
65
- """Fetch URL and parse hostnames, return JSON for JS to populate fields."""
66
- response.content_type = "application/json"
67
- try:
68
- data = request.json
69
- url = data.get("url", "").strip()
70
-
71
- if not url:
72
- return {"success": False, "error": "No URL provided"}
73
-
74
- # Validate URL
75
- parsed = urlparse(url)
76
- if parsed.scheme not in ("http", "https") or not parsed.netloc:
77
- return {"success": False, "error": "Invalid URL format"}
78
-
79
- # Fetch content
80
- try:
81
- resp = requests.get(url, timeout=15)
82
- resp.raise_for_status()
83
- content = resp.text
84
- except requests.RequestException as e:
85
- info(f"Failed to fetch hostnames URL: {e}")
86
- return {
87
- "success": False,
88
- "error": "Failed to fetch URL. Check the console log for details.",
89
- }
90
-
91
- # Parse hostnames
92
- allowed_keys = extract_allowed_keys(Config._DEFAULT_CONFIG, "Hostnames")
93
- results = extract_kv_pairs(content, allowed_keys)
94
-
95
- if not results:
96
- return {
97
- "success": False,
98
- "error": "No hostnames found in the provided URL",
99
- }
61
+ @app.post("/api/hostnames/check-credentials/<shorthand>")
62
+ def check_credentials_api(shorthand):
63
+ return check_credentials(shared_state, shorthand)
100
64
 
101
- # Validate each hostname
102
- valid_hostnames = {}
103
- invalid_hostnames = {}
104
- for shorthand, hostname in results.items():
105
- domain_check = extract_valid_hostname(hostname, shorthand)
106
- domain = domain_check.get("domain")
107
- if domain:
108
- valid_hostnames[shorthand] = domain
109
- else:
110
- invalid_hostnames[shorthand] = domain_check.get(
111
- "message", "Invalid"
112
- )
113
-
114
- if not valid_hostnames:
115
- return {
116
- "success": False,
117
- "error": "No valid hostnames found in the provided URL",
118
- }
119
-
120
- return {
121
- "success": True,
122
- "hostnames": valid_hostnames,
123
- "errors": invalid_hostnames,
124
- }
125
-
126
- except Exception as e:
127
- return {"success": False, "error": f"Error: {str(e)}"}
65
+ @app.post("/api/hostnames/import-url")
66
+ def import_hostnames_route():
67
+ return import_hostnames_from_url()
128
68
 
129
69
  @app.get("/api/skip-login")
130
- def get_skip_login():
131
- """Return list of hostnames with skipped login."""
132
- response.content_type = "application/json"
133
- skip_db = DataBase("skip_login")
134
- login_required_sites = ["al", "dd", "dl", "nx"]
135
- skipped = []
136
- for site in login_required_sites:
137
- if skip_db.retrieve(site):
138
- skipped.append(site)
139
- return {"skipped": skipped}
70
+ def get_skip_login_route():
71
+ return get_skip_login()
140
72
 
141
73
  @app.delete("/api/skip-login/<shorthand>")
142
- def clear_skip_login(shorthand):
143
- """Clear skip login preference for a hostname."""
144
- response.content_type = "application/json"
145
- shorthand = shorthand.lower()
146
- login_required_sites = ["al", "dd", "dl", "nx"]
147
- if shorthand not in login_required_sites:
148
- return {"success": False, "error": f"Invalid shorthand: {shorthand}"}
149
-
150
- skip_db = DataBase("skip_login")
151
- skip_db.delete(shorthand)
152
- info(f'Skip login preference cleared for "{shorthand.upper()}"')
153
- return {"success": True}
74
+ def clear_skip_login_route(shorthand):
75
+ return clear_skip_login(shorthand)
154
76
 
155
77
  @app.get("/flaresolverr")
156
78
  def flaresolverr_ui():
@@ -183,12 +105,6 @@ def setup_config(app, shared_state):
183
105
  {form_content}
184
106
  {render_button("Save", "primary", {"type": "submit", "id": "submitBtn"})}
185
107
  </form>
186
- <p style="font-size:0.875rem; color:var(--secondary, #6c757d); margin-top:1rem;">
187
- A restart is recommended after configuring FlareSolverr.
188
- </p>
189
- <div class="section-divider" style="margin-top:1.5rem; padding-top:1rem; border-top:1px solid var(--divider-color, #dee2e6);">
190
- {render_button("Restart Quasarr", "secondary", {"type": "button", "onclick": "confirmRestart()"})}
191
- </div>
192
108
  <p>{render_button("Back", "secondary", {"onclick": "location.href='/';"})}</p>
193
109
  <script>
194
110
  var formSubmitted = false;
@@ -278,78 +194,17 @@ def setup_config(app, shared_state):
278
194
  @app.post("/api/flaresolverr")
279
195
  def set_flaresolverr_url():
280
196
  """Save FlareSolverr URL from web UI."""
281
- url = request.forms.get("url", "").strip()
282
- config = Config("FlareSolverr")
283
-
284
- if not url:
285
- return render_fail("Please provide a FlareSolverr URL.")
286
-
287
- if not url.startswith("http://") and not url.startswith("https://"):
288
- url = "http://" + url
289
-
290
- # Validate URL format
291
- if not re.search(r"/v\d+$", url):
292
- return render_fail(
293
- "FlareSolverr URL must end with /v1 (or similar version path)."
294
- )
295
-
296
- try:
297
- headers = {"Content-Type": "application/json"}
298
- data = {
299
- "cmd": "request.get",
300
- "url": "http://www.google.com/",
301
- "maxTimeout": 30000,
302
- }
303
- resp = requests.post(url, headers=headers, json=data, timeout=30)
304
- if resp.status_code == 200:
305
- json_data = resp.json()
306
- if json_data.get("status") == "ok":
307
- config.save("url", url)
308
- # Clear skip preference since we now have a working URL
309
- DataBase("skip_flaresolverr").delete("skipped")
310
- # Update user agent from FlareSolverr response
311
- solution = json_data.get("solution", {})
312
- solution_ua = solution.get("userAgent")
313
- if solution_ua:
314
- shared_state.update("user_agent", solution_ua)
315
- info(f'FlareSolverr URL configured: "{url}"')
316
- return render_reconnect_success(
317
- "FlareSolverr URL saved successfully! A restart is recommended."
318
- )
319
- else:
320
- return render_fail(
321
- f"FlareSolverr returned unexpected status: {json_data.get('status')}"
322
- )
323
- except requests.RequestException:
324
- return render_fail("Could not reach FlareSolverr!")
325
-
326
- return render_fail(
327
- "Could not reach FlareSolverr at that URL (expected HTTP 200)."
328
- )
197
+ return save_flaresolverr_url(shared_state)
329
198
 
330
199
  @app.get("/api/flaresolverr/status")
331
200
  def get_flaresolverr_status():
332
201
  """Return FlareSolverr configuration status."""
333
- response.content_type = "application/json"
334
- skip_db = DataBase("skip_flaresolverr")
335
- is_skipped = bool(skip_db.retrieve("skipped"))
336
- current_url = Config("FlareSolverr").get("url") or ""
337
-
338
- # Test connection if URL is set
339
- is_working = False
340
- if current_url and not is_skipped:
341
- is_working = check_flaresolverr(shared_state, current_url)
342
-
343
- return {"skipped": is_skipped, "url": current_url, "working": is_working}
202
+ return get_flaresolverr_status_data(shared_state)
344
203
 
345
204
  @app.delete("/api/skip-flaresolverr")
346
205
  def clear_skip_flaresolverr():
347
206
  """Clear skip FlareSolverr preference."""
348
- response.content_type = "application/json"
349
- skip_db = DataBase("skip_flaresolverr")
350
- skip_db.delete("skipped")
351
- info("Skip FlareSolverr preference cleared")
352
- return {"success": True}
207
+ return delete_skip_flaresolverr_preference()
353
208
 
354
209
  @app.post("/api/restart")
355
210
  def restart_quasarr():
@@ -364,3 +219,11 @@ def setup_config(app, shared_state):
364
219
 
365
220
  threading.Thread(target=delayed_exit, daemon=True).start()
366
221
  return {"success": True, "message": "Restarting..."}
222
+
223
+ @app.post("/api/jdownloader/verify")
224
+ def verify_jdownloader_api():
225
+ return verify_jdownloader_credentials(shared_state)
226
+
227
+ @app.post("/api/jdownloader/save")
228
+ def save_jdownloader_api():
229
+ return save_jdownloader_settings(shared_state, is_setup=False)