quasarr 1.4.1__py3-none-any.whl → 1.20.4__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.

Files changed (67) hide show
  1. quasarr/__init__.py +157 -67
  2. quasarr/api/__init__.py +126 -43
  3. quasarr/api/arr/__init__.py +197 -78
  4. quasarr/api/captcha/__init__.py +885 -39
  5. quasarr/api/config/__init__.py +23 -0
  6. quasarr/api/sponsors_helper/__init__.py +84 -22
  7. quasarr/api/statistics/__init__.py +196 -0
  8. quasarr/downloads/__init__.py +236 -487
  9. quasarr/downloads/linkcrypters/al.py +237 -0
  10. quasarr/downloads/linkcrypters/filecrypt.py +178 -31
  11. quasarr/downloads/linkcrypters/hide.py +123 -0
  12. quasarr/downloads/packages/__init__.py +461 -0
  13. quasarr/downloads/sources/al.py +697 -0
  14. quasarr/downloads/sources/by.py +106 -0
  15. quasarr/downloads/sources/dd.py +6 -78
  16. quasarr/downloads/sources/dj.py +7 -0
  17. quasarr/downloads/sources/dt.py +1 -1
  18. quasarr/downloads/sources/dw.py +2 -2
  19. quasarr/downloads/sources/he.py +112 -0
  20. quasarr/downloads/sources/mb.py +47 -0
  21. quasarr/downloads/sources/nk.py +51 -0
  22. quasarr/downloads/sources/nx.py +36 -81
  23. quasarr/downloads/sources/sf.py +27 -4
  24. quasarr/downloads/sources/sj.py +7 -0
  25. quasarr/downloads/sources/sl.py +90 -0
  26. quasarr/downloads/sources/wd.py +110 -0
  27. quasarr/providers/cloudflare.py +204 -0
  28. quasarr/providers/html_images.py +20 -0
  29. quasarr/providers/html_templates.py +48 -39
  30. quasarr/providers/imdb_metadata.py +15 -2
  31. quasarr/providers/myjd_api.py +34 -5
  32. quasarr/providers/notifications.py +30 -5
  33. quasarr/providers/obfuscated.py +35 -0
  34. quasarr/providers/sessions/__init__.py +0 -0
  35. quasarr/providers/sessions/al.py +286 -0
  36. quasarr/providers/sessions/dd.py +78 -0
  37. quasarr/providers/sessions/nx.py +76 -0
  38. quasarr/providers/shared_state.py +347 -20
  39. quasarr/providers/statistics.py +154 -0
  40. quasarr/providers/version.py +1 -1
  41. quasarr/search/__init__.py +112 -36
  42. quasarr/search/sources/al.py +448 -0
  43. quasarr/search/sources/by.py +203 -0
  44. quasarr/search/sources/dd.py +17 -6
  45. quasarr/search/sources/dj.py +213 -0
  46. quasarr/search/sources/dt.py +37 -7
  47. quasarr/search/sources/dw.py +27 -47
  48. quasarr/search/sources/fx.py +27 -29
  49. quasarr/search/sources/he.py +196 -0
  50. quasarr/search/sources/mb.py +195 -0
  51. quasarr/search/sources/nk.py +188 -0
  52. quasarr/search/sources/nx.py +22 -6
  53. quasarr/search/sources/sf.py +143 -151
  54. quasarr/search/sources/sj.py +213 -0
  55. quasarr/search/sources/sl.py +246 -0
  56. quasarr/search/sources/wd.py +208 -0
  57. quasarr/storage/config.py +20 -4
  58. quasarr/storage/setup.py +216 -51
  59. quasarr-1.20.4.dist-info/METADATA +304 -0
  60. quasarr-1.20.4.dist-info/RECORD +72 -0
  61. {quasarr-1.4.1.dist-info → quasarr-1.20.4.dist-info}/WHEEL +1 -1
  62. quasarr/providers/tvmaze_metadata.py +0 -23
  63. quasarr-1.4.1.dist-info/METADATA +0 -174
  64. quasarr-1.4.1.dist-info/RECORD +0 -43
  65. {quasarr-1.4.1.dist-info → quasarr-1.20.4.dist-info}/entry_points.txt +0 -0
  66. {quasarr-1.4.1.dist-info → quasarr-1.20.4.dist-info}/licenses/LICENSE +0 -0
  67. {quasarr-1.4.1.dist-info → quasarr-1.20.4.dist-info}/top_level.txt +0 -0
@@ -5,61 +5,137 @@
5
5
  import time
6
6
  from concurrent.futures import ThreadPoolExecutor, as_completed
7
7
 
8
- from quasarr.providers.log import info
9
- from quasarr.search.sources.dd import dd_search
10
- from quasarr.search.sources.dw import dw_feed, dw_search
8
+ from quasarr.providers.log import info, debug
9
+ from quasarr.search.sources.al import al_feed, al_search
10
+ from quasarr.search.sources.by import by_feed, by_search
11
+ from quasarr.search.sources.dd import dd_search, dd_feed
12
+ from quasarr.search.sources.dj import dj_search, dj_feed
11
13
  from quasarr.search.sources.dt import dt_feed, dt_search
14
+ from quasarr.search.sources.dw import dw_feed, dw_search
12
15
  from quasarr.search.sources.fx import fx_feed, fx_search
16
+ from quasarr.search.sources.he import he_feed, he_search
17
+ from quasarr.search.sources.mb import mb_feed, mb_search
18
+ from quasarr.search.sources.nk import nk_feed, nk_search
13
19
  from quasarr.search.sources.nx import nx_feed, nx_search
14
20
  from quasarr.search.sources.sf import sf_feed, sf_search
21
+ from quasarr.search.sources.sj import sj_search, sj_feed
22
+ from quasarr.search.sources.sl import sl_feed, sl_search
23
+ from quasarr.search.sources.wd import wd_feed, wd_search
15
24
 
16
25
 
17
- def get_search_results(shared_state, request_from, search_string="", mirror=None, season="", episode=""):
26
+ def get_search_results(shared_state, request_from, imdb_id="", search_phrase="", mirror=None, season="", episode=""):
18
27
  results = []
19
28
 
29
+ if imdb_id and not imdb_id.startswith('tt'):
30
+ imdb_id = f'tt{imdb_id}'
31
+
32
+ docs_search = "lazylibrarian" in request_from.lower()
33
+
34
+ al = shared_state.values["config"]("Hostnames").get("al")
35
+ by = shared_state.values["config"]("Hostnames").get("by")
20
36
  dd = shared_state.values["config"]("Hostnames").get("dd")
21
37
  dt = shared_state.values["config"]("Hostnames").get("dt")
38
+ dj = shared_state.values["config"]("Hostnames").get("dj")
22
39
  dw = shared_state.values["config"]("Hostnames").get("dw")
23
40
  fx = shared_state.values["config"]("Hostnames").get("fx")
41
+ he = shared_state.values["config"]("Hostnames").get("he")
42
+ mb = shared_state.values["config"]("Hostnames").get("mb")
43
+ nk = shared_state.values["config"]("Hostnames").get("nk")
24
44
  nx = shared_state.values["config"]("Hostnames").get("nx")
25
45
  sf = shared_state.values["config"]("Hostnames").get("sf")
46
+ sj = shared_state.values["config"]("Hostnames").get("sj")
47
+ sl = shared_state.values["config"]("Hostnames").get("sl")
48
+ wd = shared_state.values["config"]("Hostnames").get("wd")
26
49
 
27
50
  start_time = time.time()
28
51
 
29
52
  functions = []
30
- if search_string:
31
- if season and episode:
32
- search_string = f"{search_string} S{int(season):02}E{int(episode):02}"
33
- elif season:
34
- search_string = f"{search_string} S{int(season):02}"
35
-
36
- if dd:
37
- functions.append(lambda: dd_search(shared_state, start_time, search_string, mirror=mirror))
38
- if dw:
39
- functions.append(lambda: dw_search(shared_state, start_time, request_from, search_string, mirror=mirror))
40
- if dt:
41
- functions.append(lambda: dt_search(shared_state, start_time, request_from, search_string, mirror=mirror))
42
- if fx:
43
- functions.append(lambda: fx_search(shared_state, start_time, search_string, mirror=mirror))
44
- if nx:
45
- functions.append(lambda: nx_search(shared_state, start_time, request_from, search_string, mirror=mirror))
46
- if sf:
47
- functions.append(lambda: sf_search(shared_state, start_time, request_from, search_string, mirror=mirror))
53
+
54
+ # Radarr/Sonarr use imdb_id for searches
55
+ imdb_map = [
56
+ (al, al_search),
57
+ (by, by_search),
58
+ (dd, dd_search),
59
+ (dt, dt_search),
60
+ (dj, dj_search),
61
+ (dw, dw_search),
62
+ (fx, fx_search),
63
+ (he, he_search),
64
+ (mb, mb_search),
65
+ (nk, nk_search),
66
+ (nx, nx_search),
67
+ (sf, sf_search),
68
+ (sj, sj_search),
69
+ (sl, sl_search),
70
+ (wd, wd_search),
71
+ ]
72
+
73
+ # LazyLibrarian uses search_phrase for searches
74
+ phrase_map = [
75
+ (by, by_search),
76
+ (dt, dt_search),
77
+ (nx, nx_search),
78
+ (sl, sl_search),
79
+ (wd, wd_search),
80
+ ]
81
+
82
+ # Feed searches omit imdb_id and search_phrase
83
+ feed_map = [
84
+ (al, al_feed),
85
+ (by, by_feed),
86
+ (dd, dd_feed),
87
+ (dj, dj_feed),
88
+ (dt, dt_feed),
89
+ (dw, dw_feed),
90
+ (fx, fx_feed),
91
+ (he, he_feed),
92
+ (mb, mb_feed),
93
+ (nk, nk_feed),
94
+ (nx, nx_feed),
95
+ (sf, sf_feed),
96
+ (sj, sj_feed),
97
+ (sl, sl_feed),
98
+ (wd, wd_feed),
99
+ ]
100
+
101
+ if imdb_id: # only Radarr/Sonarr are using imdb_id
102
+ args, kwargs = (
103
+ (shared_state, start_time, request_from, imdb_id),
104
+ {'mirror': mirror, 'season': season, 'episode': episode}
105
+ )
106
+ for flag, func in imdb_map:
107
+ if flag:
108
+ functions.append(lambda f=func, a=args, kw=kwargs: f(*a, **kw))
109
+
110
+ elif search_phrase and docs_search: # only LazyLibrarian is allowed to use search_phrase
111
+ args, kwargs = (
112
+ (shared_state, start_time, request_from, search_phrase),
113
+ {'mirror': mirror, 'season': season, 'episode': episode}
114
+ )
115
+ for flag, func in phrase_map:
116
+ if flag:
117
+ functions.append(lambda f=func, a=args, kw=kwargs: f(*a, **kw))
118
+
119
+ elif search_phrase:
120
+ debug(
121
+ f"Search phrase '{search_phrase}' is not supported for {request_from}. Only LazyLibrarian can use search phrases.")
122
+
48
123
  else:
49
- if dd:
50
- functions.append(lambda: dd_search(shared_state, start_time, mirror=mirror))
51
- if dt:
52
- functions.append(lambda: dt_feed(shared_state, start_time, request_from, mirror=mirror))
53
- if dw:
54
- functions.append(lambda: dw_feed(shared_state, start_time, request_from, mirror=mirror))
55
- if fx:
56
- functions.append(lambda: fx_feed(shared_state, start_time, mirror=mirror))
57
- if nx:
58
- functions.append(lambda: nx_feed(shared_state, start_time, request_from, mirror=mirror))
59
- if sf:
60
- functions.append(lambda: sf_feed(shared_state, start_time, request_from, mirror=mirror))
61
-
62
- stype = f'search phrase "{search_string}"' if search_string else "feed search"
124
+ args, kwargs = (
125
+ (shared_state, start_time, request_from),
126
+ {'mirror': mirror}
127
+ )
128
+ for flag, func in feed_map:
129
+ if flag:
130
+ functions.append(lambda f=func, a=args, kw=kwargs: f(*a, **kw))
131
+
132
+ if imdb_id:
133
+ stype = f'IMDb-ID "{imdb_id}"'
134
+ elif search_phrase:
135
+ stype = f'Search-Phrase "{search_phrase}"'
136
+ else:
137
+ stype = "feed search"
138
+
63
139
  info(f'Starting {len(functions)} search functions for {stype}... This may take some time.')
64
140
 
65
141
  with ThreadPoolExecutor() as executor:
@@ -0,0 +1,448 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import re
6
+ import time
7
+ from base64 import urlsafe_b64encode
8
+ from datetime import datetime, timedelta
9
+ from html import unescape
10
+ from urllib.parse import urljoin, quote_plus
11
+
12
+ from bs4 import BeautifulSoup
13
+
14
+ from quasarr.downloads.sources.al import (guess_title,
15
+ parse_info_from_feed_entry, parse_info_from_download_item)
16
+ from quasarr.providers.imdb_metadata import get_localized_title
17
+ from quasarr.providers.log import info, debug
18
+ from quasarr.providers.sessions.al import invalidate_session, fetch_via_requests_session
19
+
20
+ hostname = "al"
21
+ supported_mirrors = ["rapidgator", "ddownload"]
22
+
23
+
24
+ def convert_to_rss_date(date_str: str) -> str:
25
+ parsed = datetime.strptime(date_str, "%d.%m.%Y - %H:%M")
26
+ return parsed.strftime("%a, %d %b %Y %H:%M:%S +0000")
27
+
28
+
29
+ import re
30
+ from datetime import datetime, timedelta
31
+
32
+
33
+ def convert_to_rss_date(date_str: str) -> str:
34
+ # First try to parse relative dates (German and English)
35
+ parsed_date = parse_relative_date(date_str)
36
+ if parsed_date:
37
+ return parsed_date.strftime("%a, %d %b %Y %H:%M:%S +0000")
38
+
39
+ # Fall back to absolute date parsing
40
+ try:
41
+ parsed = datetime.strptime(date_str, "%d.%m.%Y - %H:%M")
42
+ return parsed.strftime("%a, %d %b %Y %H:%M:%S +0000")
43
+ except ValueError:
44
+ # If parsing fails, return the original string or handle as needed
45
+ raise ValueError(f"Could not parse date: {date_str}")
46
+
47
+
48
+ def parse_relative_date(raw: str) -> datetime | None:
49
+ # German pattern: "vor X Einheit(en)"
50
+ german_match = re.match(r"vor\s+(\d+)\s+(\w+)", raw, re.IGNORECASE)
51
+ if german_match:
52
+ num = int(german_match.group(1))
53
+ unit = german_match.group(2).lower()
54
+
55
+ if unit.startswith("sekunde"):
56
+ delta = timedelta(seconds=num)
57
+ elif unit.startswith("minute"):
58
+ delta = timedelta(minutes=num)
59
+ elif unit.startswith("stunde"):
60
+ delta = timedelta(hours=num)
61
+ elif unit.startswith("tag"):
62
+ delta = timedelta(days=num)
63
+ elif unit.startswith("woche"):
64
+ delta = timedelta(weeks=num)
65
+ elif unit.startswith("monat"):
66
+ delta = timedelta(days=30 * num)
67
+ elif unit.startswith("jahr"):
68
+ delta = timedelta(days=365 * num)
69
+ else:
70
+ return None
71
+
72
+ return datetime.utcnow() - delta
73
+
74
+ # English pattern: "X Unit(s) ago"
75
+ english_match = re.match(r"(\d+)\s+(\w+)\s+ago", raw, re.IGNORECASE)
76
+ if english_match:
77
+ num = int(english_match.group(1))
78
+ unit = english_match.group(2).lower()
79
+
80
+ # Remove plural 's' if present
81
+ if unit.endswith('s'):
82
+ unit = unit[:-1]
83
+
84
+ if unit.startswith("second"):
85
+ delta = timedelta(seconds=num)
86
+ elif unit.startswith("minute"):
87
+ delta = timedelta(minutes=num)
88
+ elif unit.startswith("hour"):
89
+ delta = timedelta(hours=num)
90
+ elif unit.startswith("day"):
91
+ delta = timedelta(days=num)
92
+ elif unit.startswith("week"):
93
+ delta = timedelta(weeks=num)
94
+ elif unit.startswith("month"):
95
+ delta = timedelta(days=30 * num)
96
+ elif unit.startswith("year"):
97
+ delta = timedelta(days=365 * num)
98
+ else:
99
+ return None
100
+
101
+ return datetime.utcnow() - delta
102
+
103
+ return None
104
+
105
+
106
+ def extract_size(text):
107
+ match = re.match(r"(\d+(\.\d+)?) ([A-Za-z]+)", text)
108
+ if match:
109
+ size = match.group(1)
110
+ unit = match.group(3)
111
+ return {"size": size, "sizeunit": unit}
112
+ else:
113
+ raise ValueError(f"Invalid size format: {text}")
114
+
115
+
116
+ def get_release_id(tag):
117
+ match = re.search(r"release\s+(\d+):", tag.get_text(strip=True), re.IGNORECASE)
118
+ if match:
119
+ return int(match.group(1))
120
+ return 0
121
+
122
+
123
+ def al_feed(shared_state, start_time, request_from, mirror=None):
124
+ releases = []
125
+ host = shared_state.values["config"]("Hostnames").get(hostname)
126
+
127
+ if not "arr" in request_from.lower():
128
+ debug(f'Skipping {request_from} search on "{hostname.upper()}" (unsupported media type)!')
129
+ return releases
130
+
131
+ if "Radarr" in request_from:
132
+ wanted_type = "movie"
133
+ else:
134
+ wanted_type = "series"
135
+
136
+ if mirror and mirror not in supported_mirrors:
137
+ debug(f'Mirror "{mirror}" not supported by {hostname}.')
138
+ return releases
139
+
140
+ try:
141
+ r = fetch_via_requests_session(shared_state, method="GET", target_url=f'https://www.{host}/', timeout=10)
142
+ r.raise_for_status()
143
+ except Exception as e:
144
+ info(f"{hostname}: could not fetch feed: {e}")
145
+ invalidate_session(shared_state)
146
+ return releases
147
+
148
+ soup = BeautifulSoup(r.content, 'html.parser')
149
+
150
+ # 1) New “Releases”
151
+ release_rows = soup.select("#releases_updates_list table tbody tr")
152
+ # 2) New “Episodes”
153
+ episode_rows = soup.select("#episodes_updates_list table tbody tr")
154
+ # 3) “Upgrades” Releases
155
+ upgrade_rows = soup.select("#releases_modified_updates_list table tbody tr")
156
+
157
+ for tr in release_rows + episode_rows + upgrade_rows:
158
+ try:
159
+ p_tag = tr.find("p")
160
+ if not p_tag:
161
+ continue
162
+ a_tag = p_tag.find("a", href=True)
163
+ if not a_tag:
164
+ continue
165
+
166
+ url = a_tag["href"].strip()
167
+ # Prefer data-original-title, fall back to title, then to inner text
168
+ if a_tag.get("data-original-title"):
169
+ raw_base_title = a_tag["data-original-title"]
170
+ elif a_tag.get("title"):
171
+ raw_base_title = a_tag["title"]
172
+ else:
173
+ raw_base_title = a_tag.get_text(strip=True)
174
+
175
+ release_type = None
176
+ label_div = tr.find("div", class_="label-group")
177
+ if label_div:
178
+ for lbl in label_div.find_all("a", href=True):
179
+ href = lbl["href"].rstrip("/").lower()
180
+ if href.endswith("/anime-series"):
181
+ release_type = "series"
182
+ break
183
+ elif href.endswith("/anime-movies"):
184
+ release_type = "movie"
185
+ break
186
+
187
+ if release_type is None or release_type != wanted_type:
188
+ continue
189
+
190
+ date_converted = ""
191
+ small_tag = tr.find("small", class_="text-muted")
192
+ if small_tag:
193
+ raw_date_str = small_tag.get_text(strip=True)
194
+ if raw_date_str.startswith("vor"):
195
+ dt = parse_relative_date(raw_date_str)
196
+ if dt:
197
+ date_converted = dt.strftime("%a, %d %b %Y %H:%M:%S +0000")
198
+ else:
199
+ try:
200
+ date_converted = convert_to_rss_date(raw_date_str)
201
+ except Exception as e:
202
+ debug(f"{hostname}: could not parse date '{raw_date_str}': {e}")
203
+
204
+ # Each of these signifies an individual release block
205
+ mt_blocks = tr.find_all("div", class_="mt10")
206
+ for block in mt_blocks:
207
+ release_id = get_release_id(block)
208
+ release_info = parse_info_from_feed_entry(block, raw_base_title, release_type)
209
+ final_title = guess_title(shared_state, raw_base_title, release_info)
210
+
211
+ # Build payload using final_title
212
+ mb = 0 # size not available in feed
213
+ raw = f"{final_title}|{url}|{mirror}|{mb}|{release_id}|".encode("utf-8")
214
+ payload = urlsafe_b64encode(raw).decode("utf-8")
215
+ link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
216
+
217
+ # Append only unique releases
218
+ if final_title not in [r["details"]["title"] for r in releases]:
219
+ releases.append({
220
+ "details": {
221
+ "title": final_title,
222
+ "hostname": hostname,
223
+ "imdb_id": None,
224
+ "link": link,
225
+ "mirror": mirror,
226
+ "size": mb * 1024 * 1024,
227
+ "date": date_converted,
228
+ "source": url
229
+ },
230
+ "type": "protected"
231
+ })
232
+
233
+ except Exception as e:
234
+ info(f"{hostname}: error parsing feed item: {e}")
235
+
236
+ elapsed = time.time() - start_time
237
+ debug(f"Time taken: {elapsed:.2f}s ({hostname})")
238
+ return releases
239
+
240
+
241
+ def extract_season(title: str) -> int | None:
242
+ match = re.search(r'(?i)(?:^|[^a-zA-Z0-9])S(\d{1,4})(?!\d)', title)
243
+ if match:
244
+ return int(match.group(1))
245
+ return None
246
+
247
+
248
+ def al_search(shared_state, start_time, request_from, search_string,
249
+ mirror=None, season=None, episode=None):
250
+ releases = []
251
+ host = shared_state.values["config"]("Hostnames").get(hostname)
252
+
253
+ if not "arr" in request_from.lower():
254
+ debug(f'Skipping {request_from} search on "{hostname.upper()}" (unsupported media type)!')
255
+ return releases
256
+
257
+ if "Radarr" in request_from:
258
+ valid_type = "movie"
259
+ else:
260
+ valid_type = "series"
261
+
262
+ if mirror and mirror not in supported_mirrors:
263
+ debug(f'Mirror "{mirror}" not supported by {hostname}.')
264
+ return releases
265
+
266
+ imdb_id = shared_state.is_imdb_id(search_string)
267
+ if imdb_id:
268
+ title = get_localized_title(shared_state, imdb_id, 'de')
269
+ if not title:
270
+ info(f"{hostname}: no title for IMDb {imdb_id}")
271
+ return releases
272
+ search_string = title
273
+
274
+ search_string = unescape(search_string)
275
+
276
+ encoded_search_string = quote_plus(search_string)
277
+
278
+ try:
279
+ url = f'https://www.{host}/search?q={encoded_search_string}'
280
+ r = fetch_via_requests_session(shared_state, method="GET", target_url=url, timeout=10)
281
+ r.raise_for_status()
282
+ except Exception as e:
283
+ info(f"{hostname}: search load error: {e}")
284
+ invalidate_session(shared_state)
285
+ return releases
286
+
287
+ if r.history:
288
+ # If just one valid search result exists, AL skips the search result page
289
+ last_redirect = r.history[-1]
290
+ redirect_location = last_redirect.headers['Location']
291
+ absolute_redirect_url = urljoin(last_redirect.url, redirect_location) # in case of relative URL
292
+ debug(f"{search_string} redirected to {absolute_redirect_url} instead of search results page")
293
+
294
+ try:
295
+ soup = BeautifulSoup(r.text, "html.parser")
296
+ page_title = soup.title.string
297
+ except:
298
+ page_title = ""
299
+
300
+ results = [{"url": absolute_redirect_url, "title": page_title}]
301
+ else:
302
+ soup = BeautifulSoup(r.text, 'html.parser')
303
+ results = []
304
+
305
+ for panel in soup.select('div.panel.panel-default'):
306
+ body = panel.find('div', class_='panel-body')
307
+ if not body:
308
+ continue
309
+
310
+ title_tag = body.select_one('h4.title-list a[href]')
311
+ if not title_tag:
312
+ continue
313
+ url = title_tag['href'].strip()
314
+ name = title_tag.get_text(strip=True)
315
+
316
+ sanitized_search_string = shared_state.sanitize_string(search_string)
317
+ sanitized_title = shared_state.sanitize_string(name)
318
+ if not sanitized_search_string in sanitized_title:
319
+ debug(f"Search string '{search_string}' doesn't match '{name}'")
320
+ continue
321
+ debug(f"Matched search string '{search_string}' with result '{name}'")
322
+
323
+ type_label = None
324
+ for lbl in body.select('div.label-group a[href]'):
325
+ href = lbl['href']
326
+ if '/anime-series' in href:
327
+ type_label = 'series'
328
+ break
329
+ if '/anime-movies' in href:
330
+ type_label = 'movie'
331
+ break
332
+
333
+ if not type_label or type_label != valid_type:
334
+ continue
335
+
336
+ results.append({"url": url, "title": name})
337
+
338
+ for result in results:
339
+ try:
340
+ url = result["url"]
341
+ title = result.get("title") or ""
342
+
343
+ context = "recents_al"
344
+ threshold = 60
345
+ recently_searched = shared_state.get_recently_searched(shared_state, context, threshold)
346
+ entry = recently_searched.get(url, {})
347
+ ts = entry.get("timestamp")
348
+ use_cache = ts and ts > datetime.now() - timedelta(seconds=threshold)
349
+
350
+ if use_cache and entry.get("html"):
351
+ debug(f"Using cached content for '{url}'")
352
+ data_html = entry["html"]
353
+ else:
354
+ entry = {"timestamp": datetime.now()}
355
+ data_html = fetch_via_requests_session(shared_state, method="GET", target_url=url, timeout=10).text
356
+
357
+ entry["html"] = data_html
358
+ recently_searched[url] = entry
359
+ shared_state.update(context, recently_searched)
360
+
361
+ content = BeautifulSoup(data_html, "html.parser")
362
+
363
+ # Find each download‐table and process it
364
+ release_id = 0
365
+ download_tabs = content.select("div[id^=download_]")
366
+ for tab in download_tabs:
367
+ release_id += 1
368
+
369
+ release_info = parse_info_from_download_item(tab, content, page_title=title,
370
+ release_type=valid_type, requested_episode=episode)
371
+
372
+ # Parse date
373
+ date_td = tab.select_one("tr:has(th>i.fa-calendar-alt) td.modified")
374
+ if date_td:
375
+ raw_date = date_td.get_text(strip=True)
376
+ try:
377
+ dt = datetime.strptime(raw_date, "%d.%m.%Y %H:%M")
378
+ date_str = dt.strftime("%a, %d %b %Y %H:%M:%S +0000")
379
+ except Exception:
380
+ date_str = ""
381
+ else:
382
+ date_str = (datetime.utcnow() - timedelta(hours=1)) \
383
+ .strftime("%a, %d %b %Y %H:%M:%S +0000")
384
+
385
+ # Parse filesize from the <tr> with <i class="fa-hdd">
386
+ size_td = tab.select_one("tr:has(th>i.fa-hdd) td")
387
+ mb = 0
388
+ if size_td:
389
+ size_text = size_td.get_text(strip=True)
390
+ candidates = re.findall(r'(\d+(\.\d+)?\s*[A-Za-z]+)', size_text)
391
+ if candidates:
392
+ size_string = candidates[-1][0]
393
+ try:
394
+ size_item = extract_size(size_string)
395
+ mb = shared_state.convert_to_mb(size_item)
396
+ except Exception as e:
397
+ debug(f"Error extracting size for {title}: {e}")
398
+
399
+ if episode:
400
+ try:
401
+ total_episodes = release_info.episode_max
402
+ if total_episodes:
403
+ if mb > 0:
404
+ mb = int(mb / total_episodes)
405
+ # Overwrite values so guessing the title only applies the requested episode
406
+ release_info.episode_min = int(episode)
407
+ release_info.episode_max = int(episode)
408
+ else: # if no total episode count - assume the requested episode is missing in the release
409
+ continue
410
+ except ValueError:
411
+ pass
412
+
413
+ # If no valid title was grabbed from Release Notes, guess the title
414
+ if release_info.release_title:
415
+ release_title = release_info.release_title
416
+ else:
417
+ release_title = guess_title(shared_state, title, release_info)
418
+
419
+ if season and release_info.season != int(season):
420
+ debug(f"Excluding {release_title} due to season mismatch: {release_info.season} != {season}")
421
+ continue
422
+
423
+ payload = urlsafe_b64encode(
424
+ f"{release_title}|{url}|{mirror}|{mb}|{release_id}|{imdb_id or ''}"
425
+ .encode("utf-8")
426
+ ).decode("utf-8")
427
+ link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
428
+
429
+ releases.append({
430
+ "details": {
431
+ "title": release_title,
432
+ "hostname": hostname,
433
+ "imdb_id": imdb_id,
434
+ "link": link,
435
+ "mirror": mirror,
436
+ "size": mb * 1024 * 1024,
437
+ "date": date_str,
438
+ "source": f"{url}#download_{release_id}"
439
+ },
440
+ "type": "protected"
441
+ })
442
+
443
+ except Exception as e:
444
+ info(f"{hostname}: error parsing search item: {e}")
445
+
446
+ elapsed = time.time() - start_time
447
+ debug(f"Time taken: {elapsed:.2f}s ({hostname})")
448
+ return releases