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,697 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import base64
6
+ import json
7
+ import re
8
+ import time
9
+ from dataclasses import dataclass
10
+ from typing import Optional, List
11
+
12
+ from bs4 import BeautifulSoup
13
+
14
+ from quasarr.downloads.linkcrypters.al import decrypt_content, solve_captcha
15
+ from quasarr.providers.log import info, debug
16
+ from quasarr.providers.sessions.al import retrieve_and_validate_session, invalidate_session, unwrap_flaresolverr_body, \
17
+ fetch_via_flaresolverr, fetch_via_requests_session
18
+ from quasarr.providers.statistics import StatsHelper
19
+
20
+ hostname = "al"
21
+
22
+
23
+ @dataclass
24
+ class ReleaseInfo:
25
+ release_title: Optional[str]
26
+ audio_langs: List[str]
27
+ subtitle_langs: List[str]
28
+ resolution: str
29
+ audio: str
30
+ video: str
31
+ source: str
32
+ release_group: str
33
+ season_part: Optional[int]
34
+ season: Optional[int]
35
+ episode_min: Optional[int]
36
+ episode_max: Optional[int]
37
+
38
+
39
+ def roman_to_int(r: str) -> int:
40
+ roman_map = {'I': 1, 'V': 5, 'X': 10}
41
+ total = 0
42
+ prev = 0
43
+ for ch in r.upper()[::-1]:
44
+ val = roman_map.get(ch, 0)
45
+ if val < prev:
46
+ total -= val
47
+ else:
48
+ total += val
49
+ prev = val
50
+ return total
51
+
52
+
53
+ def extract_season_from_synonyms(soup):
54
+ """
55
+ Returns the first season found as "Season N" in the Synonym(s) <td>, or None.
56
+ Only scans the synonyms cell—no fallback to whole document.
57
+ """
58
+ syn_td = None
59
+ for tr in soup.select('tr'):
60
+ th = tr.find('th')
61
+ if th and 'synonym' in th.get_text(strip=True).lower():
62
+ syn_td = tr.find('td')
63
+ break
64
+
65
+ if not syn_td:
66
+ return None
67
+
68
+ text = syn_td.get_text(" ", strip=True)
69
+
70
+ synonym_season_patterns = [
71
+ re.compile(r"\b(?:Season|Staffel)\s*0?(\d+)\b", re.IGNORECASE),
72
+ re.compile(r"\b0?(\d+)(?:st|nd|rd|th)\s+Season\b", re.IGNORECASE),
73
+ re.compile(r"\b(\d+)\.\s*Staffel\b", re.IGNORECASE),
74
+ re.compile(r"\bS0?(\d+)\b", re.IGNORECASE), # S02, s2, etc.
75
+ re.compile(r"\b([IVXLCDM]+)\b(?=\s*$)"), # uppercase Roman at end
76
+ ]
77
+
78
+ for pat in synonym_season_patterns:
79
+ m = pat.search(text)
80
+ if not m:
81
+ continue
82
+
83
+ tok = m.group(0)
84
+ # Digit match → extract number
85
+ dm = re.search(r"(\d+)", tok)
86
+ if dm:
87
+ return int(dm.group(1))
88
+ # Uppercase Roman → convert & return
89
+ if tok.isupper() and re.fullmatch(r"[IVXLCDM]+", tok):
90
+ return roman_to_int(tok)
91
+
92
+ return None
93
+
94
+
95
+ def find_season_in_release_notes(soup):
96
+ """
97
+ Iterates through all <tr> rows with a "Release Notes" <th> (case-insensitive).
98
+ Returns the first season number found as an int, or None if not found.
99
+ """
100
+
101
+ patterns = [
102
+ re.compile(r"\b(?:Season|Staffel)\s*0?(\d+)\b", re.IGNORECASE),
103
+ re.compile(r"\b0?(\d+)(?:st|nd|rd|th)\s+Season\b", re.IGNORECASE),
104
+ re.compile(r"\b(\d+)\.\s*Staffel\b", re.IGNORECASE),
105
+ re.compile(r"\bS(?:eason)?0?(\d+)\b", re.IGNORECASE),
106
+ re.compile(r"\b([IVXLCDM]+)\b(?=\s*$)"), # uppercase Roman at end
107
+ ]
108
+
109
+ for tr in soup.select('tr'):
110
+ th = tr.find('th')
111
+ if not th:
112
+ continue
113
+
114
+ header = th.get_text(strip=True)
115
+ if 'release ' not in header.lower(): # release notes or release anmerkungen
116
+ continue
117
+
118
+ td = tr.find('td')
119
+ if not td:
120
+ continue
121
+
122
+ content = td.get_text(' ', strip=True)
123
+ for pat in patterns:
124
+ m = pat.search(content)
125
+ if not m:
126
+ continue
127
+
128
+ token = m.group(1)
129
+ # Roman numeral detection only uppercase
130
+ if pat.pattern.endswith('(?=\\s*$)'):
131
+ if token.isupper():
132
+ return roman_to_int(token)
133
+ else:
134
+ continue
135
+ return int(token)
136
+
137
+ return None
138
+
139
+
140
+ def extract_season_number_from_title(page_title, release_type, release_title=""):
141
+ """
142
+ Extracts the season number from the given page title.
143
+
144
+ Priority is given to standard patterns like S01/E01 or R2 in the optional release title.
145
+ If no match is found, it attempts to extract based on keywords like "Season"/"Staffel"
146
+ or trailing numbers/roman numerals in the page title.
147
+
148
+ Args:
149
+ page_title (str): The title of the page, used as a fallback.
150
+ release_type (str): The type of release (e.g., 'series').
151
+ release_title (Optional, str): The title of the release.
152
+
153
+ Returns:
154
+ int: The extracted or inferred season number. Defaults to 1 if not found.
155
+ """
156
+
157
+ season_num = None
158
+
159
+ if release_title:
160
+ match = re.search(r'\.(?:S(\d{1,4})|R(2))(?:E\d{1,4})?', release_title, re.IGNORECASE)
161
+ if match:
162
+ if match.group(1) is not None:
163
+ season_num = int(match.group(1))
164
+ elif match.group(2) is not None:
165
+ season_num = int(match.group(2))
166
+
167
+ if season_num is None:
168
+ page_title = page_title or ""
169
+ if "staffel" in page_title.lower() or "season" in page_title.lower() or release_type == "series":
170
+ match = re.search(r'\b(?:Season|Staffel)\s+(\d+|[IVX]+)\b|\bR(2)\b', page_title, re.IGNORECASE)
171
+ if match:
172
+ if match.group(1) is not None:
173
+ num = match.group(1)
174
+ season_num = int(num) if num.isdigit() else roman_to_int(num)
175
+ elif match.group(2) is not None:
176
+ season_num = int(match.group(2))
177
+ else:
178
+ trailing_match = re.search(r'\s+([2-9]\d*|[IVXLCDM]+)\s*$', page_title, re.IGNORECASE)
179
+ if trailing_match:
180
+ num = trailing_match.group(1)
181
+ season_candidate = int(num) if num.isdigit() else roman_to_int(num)
182
+ if season_candidate >= 2:
183
+ season_num = season_candidate
184
+
185
+ if season_num is None:
186
+ season_num = 1
187
+
188
+ return season_num
189
+
190
+
191
+ def parse_info_from_feed_entry(block, series_page_title, release_type) -> ReleaseInfo:
192
+ """
193
+ Parse a BeautifulSoup block from the feed entry into ReleaseInfo.
194
+ """
195
+ text = block.get_text(separator=" ", strip=True)
196
+
197
+ # detect season
198
+ season_num = extract_season_number_from_title(series_page_title, release_type)
199
+
200
+ # detect episodes
201
+ episode_min: Optional[int] = None
202
+ episode_max: Optional[int] = None
203
+ m_ep = re.search(r"Episode\s+(\d+)(?:-(\d+))?", text)
204
+ if m_ep:
205
+ episode_min = int(m_ep.group(1))
206
+ episode_max = int(m_ep.group(2)) if m_ep.group(2) else episode_min
207
+
208
+ # parse audio flags
209
+ audio_langs: List[str] = []
210
+ audio_icon = block.find("i", class_="fa-volume-up")
211
+ if audio_icon:
212
+ for sib in audio_icon.find_next_siblings():
213
+ if sib.name == "i" and "fa-closed-captioning" in sib.get("class", []): break
214
+ if sib.name == "i" and "flag" in sib.get("class", []):
215
+ code = sib["class"][1].replace("flag-", "").lower()
216
+ audio_langs.append({'jp': 'Japanese', 'de': 'German', 'en': 'English'}.get(code, code.title()))
217
+
218
+ # parse subtitle flags
219
+ subtitle_langs: List[str] = []
220
+ subtitle_icon = block.find("i", class_="fa-closed-captioning")
221
+ if subtitle_icon:
222
+ for sib in subtitle_icon.find_next_siblings():
223
+ if sib.name == "i" and "flag" in sib.get("class", []):
224
+ code = sib["class"][1].replace("flag-", "").lower()
225
+ subtitle_langs.append({'jp': 'Japanese', 'de': 'German', 'en': 'English'}.get(code, code.title()))
226
+
227
+ # resolution
228
+ m_res = re.search(r":\s*([0-9]{3,4}p)", text, re.IGNORECASE)
229
+ resolution = m_res.group(1) if m_res else "1080p"
230
+
231
+ # source not available in feed
232
+ source = "WEB-DL"
233
+ # video codec not available in feed
234
+ video = "x264"
235
+
236
+ # release group
237
+ span = block.find("span")
238
+ if span:
239
+ grp = span.get_text().split(":", 1)[-1].strip()
240
+ release_group = grp.replace(" ", "").replace("-", "")
241
+ else:
242
+ release_group = ""
243
+
244
+ return ReleaseInfo(
245
+ release_title=None,
246
+ audio_langs=audio_langs,
247
+ subtitle_langs=subtitle_langs,
248
+ resolution=resolution,
249
+ audio="",
250
+ video=video,
251
+ source=source,
252
+ release_group=release_group,
253
+ season_part=None,
254
+ season=season_num,
255
+ episode_min=episode_min,
256
+ episode_max=episode_max
257
+ )
258
+
259
+
260
+ def parse_info_from_download_item(tab, content, page_title=None, release_type=None,
261
+ requested_episode=None) -> ReleaseInfo:
262
+ """
263
+ Parse a BeautifulSoup 'tab' from a download item into ReleaseInfo.
264
+ """
265
+ # notes
266
+ notes_td = tab.select_one("tr:has(th>i.fa-info) td")
267
+ notes_text = notes_td.get_text(strip=True) if notes_td else ""
268
+ notes_lower = notes_text.lower()
269
+
270
+ release_title = None
271
+ if notes_text:
272
+ rn_with_dots = notes_text.replace(" ", ".").replace(".-.", "-")
273
+ rn_no_dot_duplicates = re.sub(r'\.{2,}', '.', rn_with_dots)
274
+ if "." in rn_with_dots and "-" in rn_with_dots:
275
+ # Check if string ends with Group tag (word after dash) - this should prevent false positives
276
+ if re.search(r"-[\s.]?\w+$", rn_with_dots):
277
+ release_title = rn_no_dot_duplicates
278
+
279
+ # resolution
280
+ res_td = tab.select_one("tr:has(th>i.fa-desktop) td")
281
+ resolution = "1080p"
282
+ if res_td:
283
+ match = re.search(r"(\d+)\s*x\s*(\d+)", res_td.get_text(strip=True))
284
+ if match:
285
+ h = int(match.group(2))
286
+ resolution = '2160p' if h >= 2000 else '1080p' if h >= 1000 else '720p'
287
+
288
+ # audio and subtitles
289
+ audio_codes = [icon["class"][1].replace("flag-", "") for icon in
290
+ tab.select("tr:has(th>i.fa-volume-up) i.flag")]
291
+ audio_langs = [{'jp': 'Japanese', 'de': 'German', 'en': 'English'}.get(c, c.title())
292
+ for c in audio_codes]
293
+ sub_codes = [icon["class"][1].replace("flag-", "") for icon in
294
+ tab.select("tr:has(th>i.fa-closed-captioning) i.flag")]
295
+ subtitle_langs = [{'jp': 'Japanese', 'de': 'German', 'en': 'English'}.get(c, c.title())
296
+ for c in sub_codes]
297
+
298
+ # audio codec
299
+ if "flac" in notes_lower:
300
+ audio = "FLAC"
301
+ elif "aac" in notes_lower:
302
+ audio = "AAC"
303
+ elif "opus" in notes_lower:
304
+ audio = "Opus"
305
+ elif "mp3" in notes_lower:
306
+ audio = "MP3"
307
+ elif "pcm" in notes_lower:
308
+ audio = "PCM"
309
+ elif "dts" in notes_lower:
310
+ audio = "DTS"
311
+ elif "ac3" in notes_lower or "eac3" in notes_lower:
312
+ audio = "AC3"
313
+ else:
314
+ audio = ""
315
+
316
+ # source
317
+ if re.search(r"(web-dl|webdl|webrip)", notes_lower):
318
+ source = "WEB-DL"
319
+ elif re.search(r"(blu-ray|\bbd\b|bluray)", notes_lower):
320
+ source = "BluRay"
321
+ elif re.search(r"(hdtv|tvrip)", notes_lower):
322
+ source = "HDTV"
323
+ else:
324
+ source = "WEB-DL"
325
+
326
+ if "265" in notes_lower or "hevc" in notes_lower:
327
+ video = "x265"
328
+ elif "av1" in notes_lower:
329
+ video = "AV1"
330
+ elif "avc" in notes_lower:
331
+ video = "AVC"
332
+ elif "xvid" in notes_lower:
333
+ video = "Xvid"
334
+ elif "mpeg" in notes_lower:
335
+ video = "MPEG"
336
+ elif "vc-1" in notes_lower:
337
+ video = "VC-1"
338
+ else:
339
+ video = "x264"
340
+
341
+ # release group
342
+ grp_td = tab.select_one("tr:has(th>i.fa-child) td")
343
+ if grp_td:
344
+ grp = grp_td.get_text(strip=True)
345
+ release_group = grp.replace(" ", "").replace("-", "")
346
+ else:
347
+ release_group = ""
348
+
349
+ # determine season
350
+ season_num = extract_season_from_synonyms(content)
351
+ if not season_num:
352
+ season_num = find_season_in_release_notes(content)
353
+ if not season_num:
354
+ season_num = extract_season_number_from_title(page_title, release_type, release_title=release_title)
355
+
356
+ # check if season part info is present
357
+ season_part: Optional[int] = None
358
+ if page_title:
359
+ match = re.search(r'(?i)\b(?:Part|Teil)\s+(\d+|[IVX]+)\b', page_title, re.IGNORECASE)
360
+ if match:
361
+ num = match.group(1)
362
+ season_part = int(num) if num.isdigit() else roman_to_int(num)
363
+ part_string = f"Part.{season_part}"
364
+ if release_title and part_string not in release_title:
365
+ release_title = re.sub(r"\.(German|Japanese|English)\.", f".{part_string}.\\1.", release_title, 1)
366
+
367
+ # determine if optional episode exists on release page
368
+ episode_min: Optional[int] = None
369
+ episode_max: Optional[int] = None
370
+ if requested_episode:
371
+ episodes_div = tab.find("div", class_="episodes")
372
+ if episodes_div:
373
+ episode_links = episodes_div.find_all("a", attrs={"data-loop": re.compile(r"^\d+$")})
374
+ total_episodes = len(episode_links)
375
+ if total_episodes > 0:
376
+ ep = int(requested_episode)
377
+ if ep <= total_episodes:
378
+ episode_min = 1
379
+ episode_max = total_episodes
380
+ if release_title:
381
+ release_title = re.sub(
382
+ r'(?<=\.)S(\d{1,4})(?=\.)',
383
+ lambda m: f"S{int(m.group(1)):02d}E{ep:02d}",
384
+ release_title,
385
+ count=1,
386
+ flags=re.IGNORECASE
387
+ )
388
+
389
+ return ReleaseInfo(
390
+ release_title=release_title,
391
+ audio_langs=audio_langs,
392
+ subtitle_langs=subtitle_langs,
393
+ resolution=resolution,
394
+ audio=audio,
395
+ video=video,
396
+ source=source,
397
+ release_group=release_group,
398
+ season_part=season_part,
399
+ season=season_num,
400
+ episode_min=episode_min,
401
+ episode_max=episode_max
402
+ )
403
+
404
+
405
+ def guess_title(shared_state, page_title, release_info: ReleaseInfo) -> str:
406
+ # remove labels
407
+ clean_title = page_title.rsplit('(', 1)[0].strip()
408
+ # Remove season/staffel info
409
+ pattern = r'(?i)\b(?:Season|Staffel)\s*\.?\s*\d+\b|\bR\d+\b'
410
+ clean_title = re.sub(pattern, '', clean_title)
411
+
412
+ # determine season token
413
+ if release_info.season is not None:
414
+ season_token = f"S{release_info.season:02d}"
415
+ else:
416
+ season_token = ""
417
+
418
+ # episode token
419
+ ep_token = ''
420
+ if release_info.episode_min is not None:
421
+ s = release_info.episode_min
422
+ e = release_info.episode_max if release_info.episode_max is not None else s
423
+ ep_token = f"E{s:02d}" + (f"-{e:02d}" if e != s else "")
424
+
425
+ title_core = clean_title.strip().replace(' ', '.')
426
+ if season_token:
427
+ title_core += f".{season_token}{ep_token}"
428
+ elif ep_token:
429
+ title_core += f".{ep_token}"
430
+
431
+ parts = [title_core]
432
+
433
+ part = release_info.season_part
434
+ if part:
435
+ part_string = f"Part.{part}"
436
+ if part_string not in title_core:
437
+ parts.append(part_string)
438
+
439
+ prefix = ''
440
+ a, su = release_info.audio_langs, release_info.subtitle_langs
441
+ if len(a) > 2 and 'German' in a:
442
+ prefix = 'German.ML'
443
+ elif len(a) == 2 and 'German' in a:
444
+ prefix = 'German.DL'
445
+ elif len(a) == 1 and 'German' in a:
446
+ prefix = 'German'
447
+ elif a and 'German' in su:
448
+ prefix = f"{a[0]}.Subbed"
449
+ if prefix: parts.append(prefix)
450
+
451
+ if release_info.audio:
452
+ parts.append(release_info.audio)
453
+
454
+ parts.extend([release_info.resolution, release_info.source, release_info.video])
455
+ title = '.'.join(parts)
456
+ if release_info.release_group:
457
+ title += f"-{release_info.release_group}"
458
+ return shared_state.sanitize_title(title)
459
+
460
+
461
+ def check_release(shared_state, details_html, release_id, title, episode_in_title):
462
+ soup = BeautifulSoup(details_html, "html.parser")
463
+
464
+ if int(release_id) == 0:
465
+ info("Feed download detected, hard-coding release_id to 1 to achieve successful download")
466
+ release_id = 1
467
+ # The following logic works, but the highest release ID sometimes does not have the desired episode
468
+ #
469
+ # If download was started from the feed, the highest download id is typically the best option
470
+ # panes = soup.find_all("div", class_="tab-pane")
471
+ # max_id = None
472
+ # for pane in panes:
473
+ # pane_id = pane.get("id", "")
474
+ # match = re.match(r"download_(\d+)$", pane_id)
475
+ # if match:
476
+ # num = int(match.group(1))
477
+ # if max_id is None or num > max_id:
478
+ # max_id = num
479
+ # if max_id:
480
+ # release_id = max_id
481
+
482
+ tab = soup.find("div", class_="tab-pane", id=f"download_{release_id}")
483
+ if tab:
484
+ try:
485
+ # We re-guess the title from the details page
486
+ # This ensures, that downloads initiated by the feed (which has limited/incomplete data) yield
487
+ # the best possible title for the download (including resolution, audio, video, etc.)
488
+ page_title_info = soup.find("title").text.strip().rpartition(" (")
489
+ page_title = page_title_info[0].strip()
490
+ release_type_info = page_title_info[2].strip()
491
+ if "serie" in release_type_info.lower():
492
+ release_type = "series"
493
+ else:
494
+ release_type = "movie"
495
+
496
+ release_info = parse_info_from_download_item(tab, soup, page_title=page_title, release_type=release_type,
497
+ requested_episode=episode_in_title)
498
+ real_title = release_info.release_title
499
+ if real_title:
500
+ if real_title.lower() != title.lower():
501
+ info(f'Identified true release title "{real_title}" on details page')
502
+ return real_title, release_id
503
+ else:
504
+ # Overwrite values so guessing the title only applies the requested episode
505
+ if episode_in_title:
506
+ release_info.episode_min = int(episode_in_title)
507
+ release_info.episode_max = int(episode_in_title)
508
+
509
+ guessed_title = guess_title(shared_state, page_title, release_info)
510
+ if guessed_title and guessed_title.lower() != title.lower():
511
+ info(f'Adjusted guessed release title to "{guessed_title}" from details page')
512
+ return guessed_title, release_id
513
+ except Exception as e:
514
+ info(f"Error guessing release title from release: {e}")
515
+
516
+ return title, release_id
517
+
518
+
519
+ def extract_episode(title: str) -> int | None:
520
+ match = re.search(r'\bS\d{1,4}E(\d+)\b(?![\-E\d])', title)
521
+ if match:
522
+ return int(match.group(1))
523
+
524
+ if not re.search(r'\bS\d{1,4}\b', title):
525
+ match = re.search(r'\.E(\d+)\b(?![\-E\d])', title)
526
+ if match:
527
+ return int(match.group(1))
528
+
529
+ return None
530
+
531
+
532
+ def get_al_download_links(shared_state, url, mirror, title,
533
+ release_id): # signature cant align with other download link functions!
534
+ al = shared_state.values["config"]("Hostnames").get(hostname)
535
+
536
+ sess = retrieve_and_validate_session(shared_state)
537
+ if not sess:
538
+ info(f"Could not retrieve valid session for {al}")
539
+ return {}
540
+
541
+ details_page = fetch_via_flaresolverr(shared_state, "GET", url, timeout=30)
542
+ details_html = details_page.get("text", "")
543
+ if not details_html:
544
+ info(f"Failed to load details page for {title} at {url}")
545
+ return {}
546
+
547
+ episode_in_title = extract_episode(title)
548
+ if episode_in_title:
549
+ selection = episode_in_title - 1 # Convert to zero-based index
550
+ else:
551
+ selection = "cnl"
552
+
553
+ title, release_id = check_release(shared_state, details_html, release_id, title, episode_in_title)
554
+ if int(release_id) == 0:
555
+ info(f"No valid release ID found for {title} - Download failed!")
556
+ return {}
557
+
558
+ anime_identifier = url.rstrip("/").split("/")[-1]
559
+
560
+ info(f'Selected "Release {release_id}" from {url}')
561
+
562
+ links = []
563
+ try:
564
+ raw_request = json.dumps(
565
+ ["media", anime_identifier, "downloads", release_id, selection]
566
+ )
567
+ b64 = base64.b64encode(raw_request.encode("ascii")).decode("ascii")
568
+
569
+ post_url = f"https://www.{al}/ajax/captcha"
570
+ payload = {"enc": b64, "response": "nocaptcha"}
571
+
572
+ result = fetch_via_flaresolverr(
573
+ shared_state,
574
+ method="POST",
575
+ target_url=post_url,
576
+ post_data=payload,
577
+ timeout=30
578
+ )
579
+
580
+ status = result.get("status_code")
581
+ if not status == 200:
582
+ info(f"FlareSolverr returned HTTP {status} for captcha request")
583
+ StatsHelper(shared_state).increment_failed_decryptions_automatic()
584
+ return {}
585
+ else:
586
+ text = result.get("text", "")
587
+ try:
588
+ response_json = result["json"]
589
+ except ValueError:
590
+ info(f"Unexpected response when initiating captcha: {text}")
591
+ StatsHelper(shared_state).increment_failed_decryptions_automatic()
592
+ return {}
593
+
594
+ code = response_json.get("code", "")
595
+ message = response_json.get("message", "")
596
+ content_items = response_json.get("content", [])
597
+
598
+ tries = 0
599
+ if code == "success" and content_items:
600
+ info('CAPTCHA not required')
601
+ elif message == "cnl_login":
602
+ info('Login expired, re-creating session...')
603
+ invalidate_session(shared_state)
604
+ else:
605
+ tries = 0
606
+ while tries < 3:
607
+ try:
608
+ tries += 1
609
+ info(
610
+ f"Starting attempt {tries} to solve CAPTCHA for "
611
+ f"{f'episode {episode_in_title}' if selection and selection != 'cnl' else 'all links'}"
612
+ )
613
+ attempt = solve_captcha(hostname, shared_state, fetch_via_flaresolverr,
614
+ fetch_via_requests_session)
615
+
616
+ solved = (unwrap_flaresolverr_body(attempt.get("response")) == "1")
617
+ captcha_id = attempt.get("captcha_id", None)
618
+
619
+ if solved and captcha_id:
620
+ payload = {
621
+ "enc": b64,
622
+ "response": "captcha",
623
+ "captcha-idhf": 0,
624
+ "captcha-hf": captcha_id
625
+ }
626
+ check_solution = fetch_via_flaresolverr(shared_state,
627
+ method="POST",
628
+ target_url=post_url,
629
+ post_data=payload,
630
+ timeout=30)
631
+ try:
632
+ response_json = check_solution.get("json", {})
633
+ except ValueError:
634
+ raise RuntimeError(
635
+ f"Unexpected /ajax/captcha response: {check_solution.get('text', '')}")
636
+
637
+ code = response_json.get("code", "")
638
+ message = response_json.get("message", "")
639
+ content_items = response_json.get("content", [])
640
+
641
+ if code == "success":
642
+ if content_items:
643
+ info("CAPTCHA solved successfully on attempt {}.".format(tries))
644
+ break
645
+ else:
646
+ info(f"CAPTCHA was solved, but no links are available for the selection!")
647
+ StatsHelper(shared_state).increment_failed_decryptions_automatic()
648
+ return {}
649
+ elif message == "cnl_login":
650
+ info('Login expired, re-creating session...')
651
+ invalidate_session(shared_state)
652
+ else:
653
+ info(
654
+ f"CAPTCHA POST returned code={code}, message={message}. Retrying... (attempt {tries})")
655
+
656
+ if "slowndown" in str(message).lower():
657
+ wait_period = 30
658
+ info(
659
+ f"CAPTCHAs solved too quickly. Waiting {wait_period} seconds before next attempt...")
660
+ time.sleep(wait_period)
661
+ else:
662
+ info(f"CAPTCHA solver returned invalid solution, retrying... (attempt {tries})")
663
+
664
+ except RuntimeError as e:
665
+ info(f"Error solving CAPTCHA: {e}")
666
+ else:
667
+ info(f"CAPTCHA solver returned invalid solution, retrying... (attempt {tries})")
668
+
669
+ if code != "success":
670
+ info(
671
+ f"CAPTCHA solution failed after {tries} attempts. Your IP is likely banned - "
672
+ f"Code: {code}, Message: {message}"
673
+ )
674
+ invalidate_session(shared_state)
675
+ StatsHelper(shared_state).increment_failed_decryptions_automatic()
676
+ return {}
677
+
678
+ try:
679
+ links = decrypt_content(content_items, mirror)
680
+ debug(f"Decrypted URLs: {links}")
681
+ except Exception as e:
682
+ info(f"Error during decryption: {e}")
683
+ except Exception as e:
684
+ info(f"Error loading AL download: {e}")
685
+ invalidate_session(shared_state)
686
+
687
+ success = bool(links)
688
+ if success:
689
+ StatsHelper(shared_state).increment_captcha_decryptions_automatic()
690
+ else:
691
+ StatsHelper(shared_state).increment_failed_decryptions_automatic()
692
+
693
+ return {
694
+ "links": links,
695
+ "password": f"www.{al}",
696
+ "title": title
697
+ }