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 +30 -35
- quasarr/api/__init__.py +23 -15
- quasarr/api/arr/__init__.py +15 -6
- quasarr/api/captcha/__init__.py +2 -9
- quasarr/api/config/__init__.py +31 -168
- quasarr/api/jdownloader/__init__.py +232 -0
- quasarr/api/packages/__init__.py +2 -12
- quasarr/downloads/__init__.py +2 -0
- quasarr/downloads/sources/hs.py +131 -0
- quasarr/providers/html_templates.py +14 -3
- quasarr/providers/sessions/al.py +4 -0
- quasarr/providers/shared_state.py +17 -17
- quasarr/providers/version.py +1 -1
- quasarr/search/__init__.py +90 -15
- quasarr/search/sources/al.py +17 -13
- quasarr/search/sources/by.py +4 -1
- quasarr/search/sources/dd.py +16 -4
- quasarr/search/sources/dl.py +13 -1
- quasarr/search/sources/hs.py +515 -0
- quasarr/search/sources/mb.py +1 -7
- quasarr/search/sources/nx.py +4 -1
- quasarr/search/sources/wd.py +4 -1
- quasarr/search/sources/wx.py +10 -8
- quasarr/storage/config.py +1 -0
- quasarr/storage/setup.py +564 -266
- {quasarr-2.4.11.dist-info → quasarr-2.6.0.dist-info}/METADATA +1 -1
- {quasarr-2.4.11.dist-info → quasarr-2.6.0.dist-info}/RECORD +30 -27
- {quasarr-2.4.11.dist-info → quasarr-2.6.0.dist-info}/WHEEL +0 -0
- {quasarr-2.4.11.dist-info → quasarr-2.6.0.dist-info}/entry_points.txt +0 -0
- {quasarr-2.4.11.dist-info → quasarr-2.6.0.dist-info}/licenses/LICENSE +0 -0
quasarr/search/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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()
|
quasarr/search/sources/al.py
CHANGED
|
@@ -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
|
|
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,
|
|
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(
|
|
353
|
+
debug(
|
|
354
|
+
f"{hostname}: Search string '{search_string}' doesn't match '{name}'"
|
|
355
|
+
)
|
|
354
356
|
continue
|
|
355
|
-
debug(
|
|
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()}
|
quasarr/search/sources/by.py
CHANGED
|
@@ -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)
|
quasarr/search/sources/dd.py
CHANGED
|
@@ -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
|
-
|
|
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}|{
|
|
150
|
+
f"{title}|{source}|{mirror}|{mb}|{password}|{release_imdb}|{hostname}".encode(
|
|
139
151
|
"utf-8"
|
|
140
152
|
)
|
|
141
153
|
).decode("utf-8")
|
quasarr/search/sources/dl.py
CHANGED
|
@@ -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
|