quasarr 2.4.11__py3-none-any.whl → 2.5.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/api/__init__.py CHANGED
@@ -367,15 +367,15 @@ def get_api(shared_state_dict, shared_state_lock):
367
367
  /* Dark mode */
368
368
  @media (prefers-color-scheme: dark) {{
369
369
  :root {{
370
- --status-success-bg: #1b5e20;
371
- --status-success-color: #a5d6a7;
372
- --status-success-border: #2e7d32;
370
+ --status-success-bg: #1c4532;
371
+ --status-success-color: #68d391;
372
+ --status-success-border: #276749;
373
373
  --status-warning-bg: #3d3520;
374
374
  --status-warning-color: #ffb74d;
375
375
  --status-warning-border: #d69e2e;
376
- --status-error-bg: #b71c1c;
377
- --status-error-color: #ef9a9a;
378
- --status-error-border: #c62828;
376
+ --status-error-bg: #3d2d2d;
377
+ --status-error-color: #fc8181;
378
+ --status-error-border: #c53030;
379
379
  --alert-warning-bg: #3d3520;
380
380
  --alert-warning-border: #d69e2e;
381
381
  --card-bg: #2d3748;
@@ -3,27 +3,24 @@
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
- from bottle import request, response
10
+ from bottle import 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,
28
25
  )
29
26
  from quasarr.storage.sqlite_database import DataBase
@@ -50,7 +47,6 @@ def setup_config(app, shared_state):
50
47
  hostname_form_html(
51
48
  shared_state,
52
49
  message,
53
- show_restart_button=True,
54
50
  show_skip_management=True,
55
51
  )
56
52
  + back_button,
@@ -60,97 +56,21 @@ def setup_config(app, shared_state):
60
56
  def hostnames_api():
61
57
  return save_hostnames(shared_state, timeout=1, first_run=False)
62
58
 
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
- }
59
+ @app.post("/api/hostnames/check-credentials/<shorthand>")
60
+ def check_credentials_api(shorthand):
61
+ return check_credentials(shared_state, shorthand)
100
62
 
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)}"}
63
+ @app.post("/api/hostnames/import-url")
64
+ def import_hostnames_route():
65
+ return import_hostnames_from_url()
128
66
 
129
67
  @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}
68
+ def get_skip_login_route():
69
+ return get_skip_login()
140
70
 
141
71
  @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}
72
+ def clear_skip_login_route(shorthand):
73
+ return clear_skip_login(shorthand)
154
74
 
155
75
  @app.get("/flaresolverr")
156
76
  def flaresolverr_ui():
@@ -183,12 +103,6 @@ def setup_config(app, shared_state):
183
103
  {form_content}
184
104
  {render_button("Save", "primary", {"type": "submit", "id": "submitBtn"})}
185
105
  </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
106
  <p>{render_button("Back", "secondary", {"onclick": "location.href='/';"})}</p>
193
107
  <script>
194
108
  var formSubmitted = false;
@@ -278,78 +192,17 @@ def setup_config(app, shared_state):
278
192
  @app.post("/api/flaresolverr")
279
193
  def set_flaresolverr_url():
280
194
  """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
- )
195
+ return save_flaresolverr_url(shared_state)
329
196
 
330
197
  @app.get("/api/flaresolverr/status")
331
198
  def get_flaresolverr_status():
332
199
  """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}
200
+ return get_flaresolverr_status_data(shared_state)
344
201
 
345
202
  @app.delete("/api/skip-flaresolverr")
346
203
  def clear_skip_flaresolverr():
347
204
  """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}
205
+ return delete_skip_flaresolverr_preference()
353
206
 
354
207
  @app.post("/api/restart")
355
208
  def restart_quasarr():
@@ -5,7 +5,7 @@
5
5
  import re
6
6
  import sys
7
7
 
8
- __version__ = "2.4.11"
8
+ __version__ = "2.5.0"
9
9
 
10
10
 
11
11
  def get_version():
@@ -35,8 +35,6 @@ def get_search_results(
35
35
  season="",
36
36
  episode="",
37
37
  ):
38
- results = []
39
-
40
38
  if imdb_id and not imdb_id.startswith("tt"):
41
39
  imdb_id = f"tt{imdb_id}"
42
40
 
@@ -66,7 +64,7 @@ def get_search_results(
66
64
 
67
65
  start_time = time.time()
68
66
 
69
- functions = []
67
+ search_executor = SearchExecutor()
70
68
 
71
69
  # Radarr/Sonarr use imdb_id for searches
72
70
  imdb_map = [
@@ -127,7 +125,7 @@ def get_search_results(
127
125
  )
128
126
  for flag, func in imdb_map:
129
127
  if flag:
130
- functions.append(lambda f=func, a=args, kw=kwargs: f(*a, **kw))
128
+ search_executor.add(func, args, kwargs, True)
131
129
 
132
130
  elif (
133
131
  search_phrase and docs_search
@@ -138,7 +136,7 @@ def get_search_results(
138
136
  )
139
137
  for flag, func in phrase_map:
140
138
  if flag:
141
- functions.append(lambda f=func, a=args, kw=kwargs: f(*a, **kw))
139
+ search_executor.add(func, args, kwargs)
142
140
 
143
141
  elif search_phrase:
144
142
  debug(
@@ -149,7 +147,7 @@ def get_search_results(
149
147
  args, kwargs = ((shared_state, start_time, request_from), {"mirror": mirror})
150
148
  for flag, func in feed_map:
151
149
  if flag:
152
- functions.append(lambda f=func, a=args, kw=kwargs: f(*a, **kw))
150
+ search_executor.add(func, args, kwargs)
153
151
 
154
152
  if imdb_id:
155
153
  stype = f'IMDb-ID "{imdb_id}"'
@@ -159,21 +157,94 @@ def get_search_results(
159
157
  stype = "feed search"
160
158
 
161
159
  info(
162
- f"Starting {len(functions)} search functions for {stype}... This may take some time."
160
+ f"Starting {len(search_executor.searches)} searches for {stype}... This may take some time."
161
+ )
162
+ results = search_executor.run_all()
163
+ elapsed_time = time.time() - start_time
164
+ info(
165
+ f"Providing {len(results)} releases to {request_from} for {stype}. Time taken: {elapsed_time:.2f} seconds"
163
166
  )
164
167
 
165
- with ThreadPoolExecutor() as executor:
166
- futures = [executor.submit(func) for func in functions]
167
- for future in as_completed(futures):
168
+ return results
169
+
170
+
171
+ class SearchExecutor:
172
+ def __init__(self):
173
+ self.searches = []
174
+
175
+ def add(self, func, args, kwargs, use_cache=False):
176
+ # create cache key
177
+ key_args = list(args)
178
+ key_args[1] = None # ignore start_time in cache key
179
+ key_args = tuple(key_args)
180
+ key = hash((func.__name__, key_args, frozenset(kwargs.items())))
181
+
182
+ self.searches.append((key, lambda: func(*args, **kwargs), use_cache))
183
+
184
+ def run_all(self):
185
+ results = []
186
+ futures = []
187
+ cache_keys = []
188
+ cache_used = False
189
+
190
+ with ThreadPoolExecutor() as executor:
191
+ for key, func, use_cache in self.searches:
192
+ if use_cache:
193
+ cached_result = search_cache.get(key)
194
+ if cached_result is not None:
195
+ debug(f"Using cached result for {key}")
196
+ cache_used = True
197
+ results.extend(cached_result)
198
+ continue
199
+
200
+ futures.append(executor.submit(func))
201
+ cache_keys.append(key if use_cache else None)
202
+
203
+ for index, future in enumerate(as_completed(futures)):
168
204
  try:
169
205
  result = future.result()
170
206
  results.extend(result)
207
+
208
+ if cache_keys[index]: # only cache if flag is set
209
+ search_cache.set(cache_keys[index], result)
171
210
  except Exception as e:
172
211
  info(f"An error occurred: {e}")
173
212
 
174
- elapsed_time = time.time() - start_time
175
- info(
176
- f"Providing {len(results)} releases to {request_from} for {stype}. Time taken: {elapsed_time:.2f} seconds"
177
- )
213
+ if cache_used:
214
+ info("Presenting cached results instead of searching online.")
178
215
 
179
- return results
216
+ return results
217
+
218
+
219
+ class SearchCache:
220
+ def __init__(self):
221
+ self.last_cleaned = time.time()
222
+ self.cache = {}
223
+
224
+ def clean(self, now):
225
+ if now - self.last_cleaned < 60:
226
+ return
227
+
228
+ keys_to_delete = [
229
+ key for key, (_, expiry) in self.cache.items() if now >= expiry
230
+ ]
231
+
232
+ for key in keys_to_delete:
233
+ del self.cache[key]
234
+
235
+ self.last_cleaned = now
236
+
237
+ def get(self, key):
238
+ value, expiry = self.cache.get(key, (None, 0))
239
+ if time.time() < expiry:
240
+ return value
241
+
242
+ return None
243
+
244
+ def set(self, key, value, ttl=300):
245
+ now = time.time()
246
+ self.cache[key] = (value, now + ttl)
247
+ self.clean(now)
248
+
249
+
250
+ search_cache = SearchCache()