quasarr 0.1.6__py3-none-any.whl → 1.23.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.

Files changed (77) hide show
  1. quasarr/__init__.py +316 -42
  2. quasarr/api/__init__.py +187 -0
  3. quasarr/api/arr/__init__.py +387 -0
  4. quasarr/api/captcha/__init__.py +1189 -0
  5. quasarr/api/config/__init__.py +23 -0
  6. quasarr/api/sponsors_helper/__init__.py +166 -0
  7. quasarr/api/statistics/__init__.py +196 -0
  8. quasarr/downloads/__init__.py +319 -256
  9. quasarr/downloads/linkcrypters/__init__.py +0 -0
  10. quasarr/downloads/linkcrypters/al.py +237 -0
  11. quasarr/downloads/linkcrypters/filecrypt.py +444 -0
  12. quasarr/downloads/linkcrypters/hide.py +123 -0
  13. quasarr/downloads/packages/__init__.py +476 -0
  14. quasarr/downloads/sources/al.py +697 -0
  15. quasarr/downloads/sources/by.py +106 -0
  16. quasarr/downloads/sources/dd.py +76 -0
  17. quasarr/downloads/sources/dj.py +7 -0
  18. quasarr/downloads/sources/dl.py +199 -0
  19. quasarr/downloads/sources/dt.py +66 -0
  20. quasarr/downloads/sources/dw.py +14 -7
  21. quasarr/downloads/sources/he.py +112 -0
  22. quasarr/downloads/sources/mb.py +47 -0
  23. quasarr/downloads/sources/nk.py +54 -0
  24. quasarr/downloads/sources/nx.py +42 -83
  25. quasarr/downloads/sources/sf.py +159 -0
  26. quasarr/downloads/sources/sj.py +7 -0
  27. quasarr/downloads/sources/sl.py +90 -0
  28. quasarr/downloads/sources/wd.py +110 -0
  29. quasarr/downloads/sources/wx.py +127 -0
  30. quasarr/providers/cloudflare.py +204 -0
  31. quasarr/providers/html_images.py +22 -0
  32. quasarr/providers/html_templates.py +211 -104
  33. quasarr/providers/imdb_metadata.py +108 -3
  34. quasarr/providers/log.py +19 -0
  35. quasarr/providers/myjd_api.py +201 -40
  36. quasarr/providers/notifications.py +99 -11
  37. quasarr/providers/obfuscated.py +65 -0
  38. quasarr/providers/sessions/__init__.py +0 -0
  39. quasarr/providers/sessions/al.py +286 -0
  40. quasarr/providers/sessions/dd.py +78 -0
  41. quasarr/providers/sessions/dl.py +175 -0
  42. quasarr/providers/sessions/nx.py +76 -0
  43. quasarr/providers/shared_state.py +656 -79
  44. quasarr/providers/statistics.py +154 -0
  45. quasarr/providers/version.py +60 -1
  46. quasarr/providers/web_server.py +1 -1
  47. quasarr/search/__init__.py +144 -15
  48. quasarr/search/sources/al.py +448 -0
  49. quasarr/search/sources/by.py +204 -0
  50. quasarr/search/sources/dd.py +135 -0
  51. quasarr/search/sources/dj.py +213 -0
  52. quasarr/search/sources/dl.py +354 -0
  53. quasarr/search/sources/dt.py +265 -0
  54. quasarr/search/sources/dw.py +94 -67
  55. quasarr/search/sources/fx.py +89 -33
  56. quasarr/search/sources/he.py +196 -0
  57. quasarr/search/sources/mb.py +195 -0
  58. quasarr/search/sources/nk.py +188 -0
  59. quasarr/search/sources/nx.py +75 -21
  60. quasarr/search/sources/sf.py +374 -0
  61. quasarr/search/sources/sj.py +213 -0
  62. quasarr/search/sources/sl.py +246 -0
  63. quasarr/search/sources/wd.py +208 -0
  64. quasarr/search/sources/wx.py +337 -0
  65. quasarr/storage/config.py +39 -10
  66. quasarr/storage/setup.py +269 -97
  67. quasarr/storage/sqlite_database.py +6 -1
  68. quasarr-1.23.0.dist-info/METADATA +306 -0
  69. quasarr-1.23.0.dist-info/RECORD +77 -0
  70. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/WHEEL +1 -1
  71. quasarr/arr/__init__.py +0 -423
  72. quasarr/captcha_solver/__init__.py +0 -284
  73. quasarr-0.1.6.dist-info/METADATA +0 -81
  74. quasarr-0.1.6.dist-info/RECORD +0 -31
  75. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/entry_points.txt +0 -0
  76. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info/licenses}/LICENSE +0 -0
  77. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/top_level.txt +0 -0
@@ -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
@@ -0,0 +1,204 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import html
6
+ import re
7
+ import time
8
+ from base64 import urlsafe_b64encode
9
+ from datetime import datetime
10
+ from urllib.parse import quote_plus
11
+
12
+ import requests
13
+ from bs4 import BeautifulSoup
14
+
15
+ from quasarr.providers.imdb_metadata import get_localized_title
16
+ from quasarr.providers.log import info, debug
17
+
18
+ hostname = "by"
19
+ supported_mirrors = ["rapidgator", "ddownload", "nitroflare"]
20
+
21
+ RESOLUTION_REGEX = re.compile(r"\d{3,4}p", re.I)
22
+ CODEC_REGEX = re.compile(r"x264|x265|h264|h265|hevc|avc", re.I)
23
+ XXX_REGEX = re.compile(r"\.xxx\.", re.I)
24
+ IMDB_REGEX = re.compile(r"imdb\.com/title/(tt\d+)")
25
+
26
+
27
+ def convert_to_rss_date(date_str):
28
+ """
29
+ BY date format: 'dd.mm.yy HH:MM', e.g. '20.07.25 17:48'
30
+ """
31
+ dt_obj = datetime.strptime(date_str, "%d.%m.%y %H:%M")
32
+ return dt_obj.strftime("%a, %d %b %Y %H:%M:%S +0000")
33
+
34
+
35
+ def extract_size(text):
36
+ m = re.match(r"(\d+(?:[.,]\d+)?)\s*([A-Za-z]+)", text)
37
+ if not m:
38
+ raise ValueError(f"Invalid size format: {text!r}")
39
+ size_str = m.group(1).replace(',', '.')
40
+ sizeunit = m.group(2)
41
+ size_float = float(size_str) # convert to float here
42
+ return {"size": size_float, "sizeunit": sizeunit}
43
+
44
+
45
+ def _parse_posts(soup, shared_state, base_url, password, mirror_filter,
46
+ is_search=False, request_from=None, search_string=None,
47
+ season=None, episode=None):
48
+ releases = []
49
+ if not is_search:
50
+ feed_container = soup.find('table', class_='AUDIO_ITEMLIST') # it is actually called this way
51
+ candidates = []
52
+ if feed_container:
53
+ for tbl in feed_container.find_all('table'):
54
+ if tbl.find(string=re.compile(r"Erstellt am:")):
55
+ candidates.append(tbl)
56
+ items = candidates
57
+ else:
58
+ search_table = soup.find('table', class_='SEARCH_ITEMLIST')
59
+ items = []
60
+ if search_table:
61
+ items = [
62
+ tr for tr in search_table.find_all('tr')
63
+ if tr.find('p', class_='TITLE') and tr.find('p', class_='TITLE').find('a', href=True)
64
+ ]
65
+
66
+ for entry in items:
67
+ if entry.find('table'):
68
+ continue # Skip header rows
69
+ try:
70
+ if not is_search:
71
+ table = entry
72
+ # title & source
73
+ try:
74
+ link_tag = table.find('th').find('a')
75
+ except AttributeError:
76
+ link_tag = table.find('a')
77
+ title = link_tag.get_text(strip=True)
78
+ if 'lazylibrarian' in request_from.lower():
79
+ # lazylibrarian can only detect specific date formats / issue numbering for magazines
80
+ title = shared_state.normalize_magazine_title(title)
81
+ else:
82
+ title = title.replace(" ", ".")
83
+
84
+ source = base_url + link_tag['href']
85
+ # extract date and size
86
+ date_str = size_str = None
87
+ for row in table.find_all('tr', height=True):
88
+ cols = row.find_all('td')
89
+ if len(cols) == 2:
90
+ label = cols[0].get_text(strip=True)
91
+ val = cols[1].get_text(strip=True)
92
+ if label.startswith('Erstellt am'):
93
+ date_str = val
94
+ elif label.startswith('Größe'):
95
+ size_str = val
96
+ published = convert_to_rss_date(date_str) if date_str else ''
97
+ size_info = extract_size(size_str) if size_str else {'size': '0', 'sizeunit': 'MB'}
98
+ mb = float(size_info['size'])
99
+ size_bytes = int(mb * 1024 * 1024)
100
+ imdb_id = None
101
+ else:
102
+ row = entry
103
+ title_tag = row.find('p', class_='TITLE').find('a')
104
+ title = title_tag.get_text(strip=True)
105
+ if 'lazylibrarian' in request_from.lower():
106
+ # lazylibrarian can only detect specific date formats / issue numbering for magazines
107
+ title = shared_state.normalize_magazine_title(title)
108
+ else:
109
+ title = title.replace(" ", ".")
110
+ if not (RESOLUTION_REGEX.search(title) or CODEC_REGEX.search(title)):
111
+ continue
112
+
113
+ if not shared_state.is_valid_release(title, request_from, search_string, season, episode):
114
+ continue
115
+ if XXX_REGEX.search(title) and 'xxx' not in search_string.lower():
116
+ continue
117
+
118
+ source = base_url + title_tag['href']
119
+ date_cell = row.find_all('td')[2]
120
+ date_str = date_cell.get_text(strip=True)
121
+ published = convert_to_rss_date(date_str)
122
+ size_bytes = 0
123
+ mb = 0
124
+ imdb_id = None
125
+
126
+ payload = urlsafe_b64encode(
127
+ f"{title}|{source}|{mirror_filter}|{mb}|{password}|{imdb_id}".encode()
128
+ ).decode()
129
+ link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
130
+
131
+ releases.append({
132
+ 'details': {
133
+ 'title': title,
134
+ 'hostname': hostname,
135
+ 'imdb_id': imdb_id,
136
+ 'link': link,
137
+ 'mirror': mirror_filter,
138
+ 'size': size_bytes,
139
+ 'date': published,
140
+ 'source': source
141
+ },
142
+ 'type': 'protected'
143
+ })
144
+ except Exception as e:
145
+ debug(f"Error parsing {hostname.upper()}: {e}")
146
+ continue
147
+
148
+ return releases
149
+
150
+
151
+ def by_feed(shared_state, start_time, request_from, mirror=None):
152
+ by = shared_state.values['config']('Hostnames').get(hostname)
153
+ password = by
154
+
155
+ if "lazylibrarian" in request_from.lower():
156
+ feed_type = "?cat=71"
157
+ elif "radarr" in request_from.lower():
158
+ feed_type = "?cat=1"
159
+ else:
160
+ feed_type = "?cat=2"
161
+
162
+ base_url = f"https://{by}"
163
+ url = f"{base_url}/{feed_type}"
164
+ headers = {'User-Agent': shared_state.values['user_agent']}
165
+ try:
166
+ html_doc = requests.get(url, headers=headers, timeout=10).content
167
+ soup = BeautifulSoup(html_doc, 'html.parser')
168
+ releases = _parse_posts(soup, shared_state, base_url, password, request_from=request_from, mirror_filter=mirror)
169
+ except Exception as e:
170
+ info(f"Error loading {hostname.upper()} feed: {e}")
171
+ releases = []
172
+ debug(f"Time taken: {time.time() - start_time:.2f}s ({hostname})")
173
+ return releases
174
+
175
+
176
+ def by_search(shared_state, start_time, request_from, search_string, mirror=None, season=None, episode=None):
177
+ by = shared_state.values['config']('Hostnames').get(hostname)
178
+ password = by
179
+
180
+ imdb_id = shared_state.is_imdb_id(search_string)
181
+ if imdb_id:
182
+ title = get_localized_title(shared_state, imdb_id, 'de')
183
+ if not title:
184
+ info(f"Could not extract title from IMDb-ID {imdb_id}")
185
+ return []
186
+ search_string = html.unescape(title)
187
+
188
+ base_url = f"https://{by}"
189
+ q = quote_plus(search_string)
190
+ url = f"{base_url}/?q={q}"
191
+ headers = {'User-Agent': shared_state.values['user_agent']}
192
+ try:
193
+ html_doc = requests.get(url, headers=headers, timeout=10).content
194
+ soup = BeautifulSoup(html_doc, 'html.parser')
195
+ releases = _parse_posts(
196
+ soup, shared_state, base_url, password, mirror_filter=mirror,
197
+ is_search=True, request_from=request_from,
198
+ search_string=search_string, season=season, episode=episode
199
+ )
200
+ except Exception as e:
201
+ info(f"Error loading {hostname.upper()} search: {e}")
202
+ releases = []
203
+ debug(f"Time taken: {time.time() - start_time:.2f}s ({hostname})")
204
+ return releases