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.

@@ -16,6 +16,7 @@ from quasarr.search.sources.dt import dt_feed, dt_search
16
16
  from quasarr.search.sources.dw import dw_feed, dw_search
17
17
  from quasarr.search.sources.fx import fx_feed, fx_search
18
18
  from quasarr.search.sources.he import he_feed, he_search
19
+ from quasarr.search.sources.hs import hs_feed, hs_search
19
20
  from quasarr.search.sources.mb import mb_feed, mb_search
20
21
  from quasarr.search.sources.nk import nk_feed, nk_search
21
22
  from quasarr.search.sources.nx import nx_feed, nx_search
@@ -35,8 +36,6 @@ def get_search_results(
35
36
  season="",
36
37
  episode="",
37
38
  ):
38
- results = []
39
-
40
39
  if imdb_id and not imdb_id.startswith("tt"):
41
40
  imdb_id = f"tt{imdb_id}"
42
41
 
@@ -55,6 +54,7 @@ def get_search_results(
55
54
  dw = shared_state.values["config"]("Hostnames").get("dw")
56
55
  fx = shared_state.values["config"]("Hostnames").get("fx")
57
56
  he = shared_state.values["config"]("Hostnames").get("he")
57
+ hs = shared_state.values["config"]("Hostnames").get("hs")
58
58
  mb = shared_state.values["config"]("Hostnames").get("mb")
59
59
  nk = shared_state.values["config"]("Hostnames").get("nk")
60
60
  nx = shared_state.values["config"]("Hostnames").get("nx")
@@ -66,7 +66,7 @@ def get_search_results(
66
66
 
67
67
  start_time = time.time()
68
68
 
69
- functions = []
69
+ search_executor = SearchExecutor()
70
70
 
71
71
  # Radarr/Sonarr use imdb_id for searches
72
72
  imdb_map = [
@@ -79,6 +79,7 @@ def get_search_results(
79
79
  (dw, dw_search),
80
80
  (fx, fx_search),
81
81
  (he, he_search),
82
+ (hs, hs_search),
82
83
  (mb, mb_search),
83
84
  (nk, nk_search),
84
85
  (nx, nx_search),
@@ -110,6 +111,7 @@ def get_search_results(
110
111
  (dw, dw_feed),
111
112
  (fx, fx_feed),
112
113
  (he, he_feed),
114
+ (hs, hs_feed),
113
115
  (mb, mb_feed),
114
116
  (nk, nk_feed),
115
117
  (nx, nx_feed),
@@ -127,7 +129,7 @@ def get_search_results(
127
129
  )
128
130
  for flag, func in imdb_map:
129
131
  if flag:
130
- functions.append(lambda f=func, a=args, kw=kwargs: f(*a, **kw))
132
+ search_executor.add(func, args, kwargs, True)
131
133
 
132
134
  elif (
133
135
  search_phrase and docs_search
@@ -138,7 +140,7 @@ def get_search_results(
138
140
  )
139
141
  for flag, func in phrase_map:
140
142
  if flag:
141
- functions.append(lambda f=func, a=args, kw=kwargs: f(*a, **kw))
143
+ search_executor.add(func, args, kwargs)
142
144
 
143
145
  elif search_phrase:
144
146
  debug(
@@ -149,7 +151,7 @@ def get_search_results(
149
151
  args, kwargs = ((shared_state, start_time, request_from), {"mirror": mirror})
150
152
  for flag, func in feed_map:
151
153
  if flag:
152
- functions.append(lambda f=func, a=args, kw=kwargs: f(*a, **kw))
154
+ search_executor.add(func, args, kwargs)
153
155
 
154
156
  if imdb_id:
155
157
  stype = f'IMDb-ID "{imdb_id}"'
@@ -159,21 +161,94 @@ def get_search_results(
159
161
  stype = "feed search"
160
162
 
161
163
  info(
162
- f"Starting {len(functions)} search functions for {stype}... This may take some time."
164
+ f"Starting {len(search_executor.searches)} searches for {stype}... This may take some time."
165
+ )
166
+ results = search_executor.run_all()
167
+ elapsed_time = time.time() - start_time
168
+ info(
169
+ f"Providing {len(results)} releases to {request_from} for {stype}. Time taken: {elapsed_time:.2f} seconds"
163
170
  )
164
171
 
165
- with ThreadPoolExecutor() as executor:
166
- futures = [executor.submit(func) for func in functions]
167
- for future in as_completed(futures):
172
+ return results
173
+
174
+
175
+ class SearchExecutor:
176
+ def __init__(self):
177
+ self.searches = []
178
+
179
+ def add(self, func, args, kwargs, use_cache=False):
180
+ # create cache key
181
+ key_args = list(args)
182
+ key_args[1] = None # ignore start_time in cache key
183
+ key_args = tuple(key_args)
184
+ key = hash((func.__name__, key_args, frozenset(kwargs.items())))
185
+
186
+ self.searches.append((key, lambda: func(*args, **kwargs), use_cache))
187
+
188
+ def run_all(self):
189
+ results = []
190
+ futures = []
191
+ cache_keys = []
192
+ cache_used = False
193
+
194
+ with ThreadPoolExecutor() as executor:
195
+ for key, func, use_cache in self.searches:
196
+ if use_cache:
197
+ cached_result = search_cache.get(key)
198
+ if cached_result is not None:
199
+ debug(f"Using cached result for {key}")
200
+ cache_used = True
201
+ results.extend(cached_result)
202
+ continue
203
+
204
+ futures.append(executor.submit(func))
205
+ cache_keys.append(key if use_cache else None)
206
+
207
+ for index, future in enumerate(as_completed(futures)):
168
208
  try:
169
209
  result = future.result()
170
210
  results.extend(result)
211
+
212
+ if cache_keys[index]: # only cache if flag is set
213
+ search_cache.set(cache_keys[index], result)
171
214
  except Exception as e:
172
215
  info(f"An error occurred: {e}")
173
216
 
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
- )
217
+ if cache_used:
218
+ info("Presenting cached results instead of searching online.")
178
219
 
179
- return results
220
+ return results
221
+
222
+
223
+ class SearchCache:
224
+ def __init__(self):
225
+ self.last_cleaned = time.time()
226
+ self.cache = {}
227
+
228
+ def clean(self, now):
229
+ if now - self.last_cleaned < 60:
230
+ return
231
+
232
+ keys_to_delete = [
233
+ key for key, (_, expiry) in self.cache.items() if now >= expiry
234
+ ]
235
+
236
+ for key in keys_to_delete:
237
+ del self.cache[key]
238
+
239
+ self.last_cleaned = now
240
+
241
+ def get(self, key):
242
+ value, expiry = self.cache.get(key, (None, 0))
243
+ if time.time() < expiry:
244
+ return value
245
+
246
+ return None
247
+
248
+ def set(self, key, value, ttl=300):
249
+ now = time.time()
250
+ self.cache[key] = (value, now + ttl)
251
+ self.clean(now)
252
+
253
+
254
+ search_cache = SearchCache()
@@ -15,7 +15,7 @@ from quasarr.downloads.sources.al import (
15
15
  parse_info_from_feed_entry,
16
16
  )
17
17
  from quasarr.providers.hostname_issues import clear_hostname_issue, mark_hostname_issue
18
- from quasarr.providers.imdb_metadata import get_localized_title
18
+ from quasarr.providers.imdb_metadata import get_localized_title, get_year
19
19
  from quasarr.providers.log import debug, info
20
20
  from quasarr.providers.sessions.al import fetch_via_requests_session, invalidate_session
21
21
 
@@ -122,9 +122,7 @@ def al_feed(shared_state, start_time, request_from, mirror=None):
122
122
  host = shared_state.values["config"]("Hostnames").get(hostname)
123
123
 
124
124
  if not "arr" in request_from.lower():
125
- debug(
126
- f'Skipping {request_from} search on "{hostname.upper()}" (unsupported media type)!'
127
- )
125
+ debug(f"{hostname}: Skipping {request_from} search (unsupported media type)!")
128
126
  return releases
129
127
 
130
128
  if "Radarr" in request_from:
@@ -274,9 +272,7 @@ def al_search(
274
272
  host = shared_state.values["config"]("Hostnames").get(hostname)
275
273
 
276
274
  if not "arr" in request_from.lower():
277
- debug(
278
- f'Skipping {request_from} search on "{hostname.upper()}" (unsupported media type)!'
279
- )
275
+ debug(f"{hostname}: Skipping {request_from} search (unsupported media type)!")
280
276
  return releases
281
277
 
282
278
  if "Radarr" in request_from:
@@ -285,7 +281,7 @@ def al_search(
285
281
  valid_type = "series"
286
282
 
287
283
  if mirror and mirror not in supported_mirrors:
288
- debug(f'Mirror "{mirror}" not supported by {hostname}.')
284
+ debug(f'{hostname}: Mirror "{mirror}" not supported.')
289
285
  return releases
290
286
 
291
287
  imdb_id = shared_state.is_imdb_id(search_string)
@@ -303,7 +299,11 @@ def al_search(
303
299
  try:
304
300
  url = f"https://www.{host}/search?q={encoded_search_string}"
305
301
  r = fetch_via_requests_session(
306
- shared_state, method="GET", target_url=url, timeout=10
302
+ shared_state,
303
+ method="GET",
304
+ target_url=url,
305
+ timeout=10,
306
+ year=get_year(imdb_id) if imdb_id else None,
307
307
  )
308
308
  r.raise_for_status()
309
309
  except Exception as e:
@@ -322,7 +322,7 @@ def al_search(
322
322
  last_redirect.url, redirect_location
323
323
  ) # in case of relative URL
324
324
  debug(
325
- f"{search_string} redirected to {absolute_redirect_url} instead of search results page"
325
+ f"{hostname}: {search_string} redirected to {absolute_redirect_url} instead of search results page"
326
326
  )
327
327
 
328
328
  try:
@@ -350,9 +350,13 @@ def al_search(
350
350
  sanitized_search_string = shared_state.sanitize_string(search_string)
351
351
  sanitized_title = shared_state.sanitize_string(name)
352
352
  if not sanitized_search_string in sanitized_title:
353
- debug(f"Search string '{search_string}' doesn't match '{name}'")
353
+ debug(
354
+ f"{hostname}: Search string '{search_string}' doesn't match '{name}'"
355
+ )
354
356
  continue
355
- debug(f"Matched search string '{search_string}' with result '{name}'")
357
+ debug(
358
+ f"{hostname}: Matched search string '{search_string}' with result '{name}'"
359
+ )
356
360
 
357
361
  type_label = None
358
362
  for lbl in body.select("div.label-group a[href]"):
@@ -384,7 +388,7 @@ def al_search(
384
388
  use_cache = ts and ts > datetime.now() - timedelta(seconds=threshold)
385
389
 
386
390
  if use_cache and entry.get("html"):
387
- debug(f"Using cached content for '{url}'")
391
+ debug(f"{hostname}: Using cached content for '{url}'")
388
392
  data_html = entry["html"]
389
393
  else:
390
394
  entry = {"timestamp": datetime.now()}
@@ -13,7 +13,7 @@ import requests
13
13
  from bs4 import BeautifulSoup
14
14
 
15
15
  from quasarr.providers.hostname_issues import clear_hostname_issue, mark_hostname_issue
16
- from quasarr.providers.imdb_metadata import get_localized_title
16
+ from quasarr.providers.imdb_metadata import get_localized_title, get_year
17
17
  from quasarr.providers.log import debug, info
18
18
 
19
19
  hostname = "by"
@@ -232,6 +232,9 @@ def by_search(
232
232
  info(f"Could not extract title from IMDb-ID {imdb_id}")
233
233
  return []
234
234
  search_string = html.unescape(title)
235
+ if not season:
236
+ if year := get_year(imdb_id):
237
+ search_string += f" {year}"
235
238
 
236
239
  base_url = f"https://{by}"
237
240
  q = quote_plus(search_string)
@@ -8,7 +8,7 @@ from base64 import urlsafe_b64encode
8
8
  from datetime import datetime, timezone
9
9
 
10
10
  from quasarr.providers.hostname_issues import clear_hostname_issue, mark_hostname_issue
11
- from quasarr.providers.imdb_metadata import get_localized_title
11
+ from quasarr.providers.imdb_metadata import get_localized_title, get_year
12
12
  from quasarr.providers.log import debug, info
13
13
  from quasarr.providers.sessions.dd import (
14
14
  create_and_persist_session,
@@ -77,6 +77,13 @@ def dd_search(
77
77
  info(f"Could not extract title from IMDb-ID {imdb_id}")
78
78
  return releases
79
79
  search_string = html.unescape(search_string)
80
+ if season:
81
+ search_string += f" S{int(season):02d}"
82
+ if episode:
83
+ search_string += f"E{int(episode):02d}"
84
+ else:
85
+ if year := get_year(imdb_id):
86
+ search_string += f" {year}"
80
87
 
81
88
  if not search_string:
82
89
  search_type = "feed"
@@ -116,7 +123,7 @@ def dd_search(
116
123
  try:
117
124
  if release.get("fake"):
118
125
  debug(
119
- f"Release {release.get('release')} marked as fake. Invalidating {hostname.upper()} session..."
126
+ f"{hostname}: Release {release.get('release')} marked as fake. Invalidating {hostname.upper()} session..."
120
127
  )
121
128
  create_and_persist_session(shared_state)
122
129
  return []
@@ -128,14 +135,19 @@ def dd_search(
128
135
  ):
129
136
  continue
130
137
 
131
- imdb_id = release.get("imdbid", None)
138
+ release_imdb = release.get("imdbid", None)
139
+ if release_imdb and imdb_id and imdb_id != release_imdb:
140
+ debug(
141
+ f"{hostname}: Release {title} IMDb-ID mismatch ({imdb_id} != {release.get('imdbid', None)})"
142
+ )
143
+ continue
132
144
 
133
145
  source = f"https://{dd}/"
134
146
  size_item = extract_size(release.get("size"))
135
147
  mb = shared_state.convert_to_mb(size_item) * 1024 * 1024
136
148
  published = convert_to_rss_date(release.get("when"))
137
149
  payload = urlsafe_b64encode(
138
- f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode(
150
+ f"{title}|{source}|{mirror}|{mb}|{password}|{release_imdb}|{hostname}".encode(
139
151
  "utf-8"
140
152
  )
141
153
  ).decode("utf-8")
@@ -11,7 +11,7 @@ from html import unescape
11
11
  from bs4 import BeautifulSoup
12
12
 
13
13
  from quasarr.providers.hostname_issues import clear_hostname_issue, mark_hostname_issue
14
- from quasarr.providers.imdb_metadata import get_localized_title
14
+ from quasarr.providers.imdb_metadata import get_localized_title, get_year
15
15
  from quasarr.providers.log import debug, info
16
16
  from quasarr.providers.sessions.dl import (
17
17
  fetch_via_requests_session,
@@ -354,6 +354,9 @@ def dl_search(
354
354
  info(f"{hostname}: no title for IMDb {imdb_id}")
355
355
  return releases
356
356
  search_string = title
357
+ if not season:
358
+ if year := get_year(imdb_id):
359
+ search_string += f" {year}"
357
360
 
358
361
  search_string = unescape(search_string)
359
362
  max_search_duration = 7
@@ -371,6 +374,7 @@ def dl_search(
371
374
  search_id = None
372
375
  page_num = 0
373
376
  search_start_time = time.time()
377
+ release_titles_per_page = set()
374
378
 
375
379
  # Sequential search through pages until timeout or no results
376
380
  while (time.time() - search_start_time) < max_search_duration:
@@ -389,6 +393,14 @@ def dl_search(
389
393
  episode,
390
394
  )
391
395
 
396
+ page_release_titles = tuple(pr["details"]["title"] for pr in page_releases)
397
+ if page_release_titles in release_titles_per_page:
398
+ debug(
399
+ f"{hostname}: [Page {page_num}] duplicate page detected, stopping"
400
+ )
401
+ break
402
+ release_titles_per_page.add(page_release_titles)
403
+
392
404
  # Update search_id from first page
393
405
  if page_num == 1:
394
406
  search_id = extracted_search_id