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.
- quasarr/__init__.py +316 -42
- quasarr/api/__init__.py +187 -0
- quasarr/api/arr/__init__.py +387 -0
- quasarr/api/captcha/__init__.py +1189 -0
- quasarr/api/config/__init__.py +23 -0
- quasarr/api/sponsors_helper/__init__.py +166 -0
- quasarr/api/statistics/__init__.py +196 -0
- quasarr/downloads/__init__.py +319 -256
- quasarr/downloads/linkcrypters/__init__.py +0 -0
- quasarr/downloads/linkcrypters/al.py +237 -0
- quasarr/downloads/linkcrypters/filecrypt.py +444 -0
- quasarr/downloads/linkcrypters/hide.py +123 -0
- quasarr/downloads/packages/__init__.py +476 -0
- quasarr/downloads/sources/al.py +697 -0
- quasarr/downloads/sources/by.py +106 -0
- quasarr/downloads/sources/dd.py +76 -0
- quasarr/downloads/sources/dj.py +7 -0
- quasarr/downloads/sources/dl.py +199 -0
- quasarr/downloads/sources/dt.py +66 -0
- quasarr/downloads/sources/dw.py +14 -7
- quasarr/downloads/sources/he.py +112 -0
- quasarr/downloads/sources/mb.py +47 -0
- quasarr/downloads/sources/nk.py +54 -0
- quasarr/downloads/sources/nx.py +42 -83
- quasarr/downloads/sources/sf.py +159 -0
- quasarr/downloads/sources/sj.py +7 -0
- quasarr/downloads/sources/sl.py +90 -0
- quasarr/downloads/sources/wd.py +110 -0
- quasarr/downloads/sources/wx.py +127 -0
- quasarr/providers/cloudflare.py +204 -0
- quasarr/providers/html_images.py +22 -0
- quasarr/providers/html_templates.py +211 -104
- quasarr/providers/imdb_metadata.py +108 -3
- quasarr/providers/log.py +19 -0
- quasarr/providers/myjd_api.py +201 -40
- quasarr/providers/notifications.py +99 -11
- quasarr/providers/obfuscated.py +65 -0
- quasarr/providers/sessions/__init__.py +0 -0
- quasarr/providers/sessions/al.py +286 -0
- quasarr/providers/sessions/dd.py +78 -0
- quasarr/providers/sessions/dl.py +175 -0
- quasarr/providers/sessions/nx.py +76 -0
- quasarr/providers/shared_state.py +656 -79
- quasarr/providers/statistics.py +154 -0
- quasarr/providers/version.py +60 -1
- quasarr/providers/web_server.py +1 -1
- quasarr/search/__init__.py +144 -15
- quasarr/search/sources/al.py +448 -0
- quasarr/search/sources/by.py +204 -0
- quasarr/search/sources/dd.py +135 -0
- quasarr/search/sources/dj.py +213 -0
- quasarr/search/sources/dl.py +354 -0
- quasarr/search/sources/dt.py +265 -0
- quasarr/search/sources/dw.py +94 -67
- quasarr/search/sources/fx.py +89 -33
- quasarr/search/sources/he.py +196 -0
- quasarr/search/sources/mb.py +195 -0
- quasarr/search/sources/nk.py +188 -0
- quasarr/search/sources/nx.py +75 -21
- quasarr/search/sources/sf.py +374 -0
- quasarr/search/sources/sj.py +213 -0
- quasarr/search/sources/sl.py +246 -0
- quasarr/search/sources/wd.py +208 -0
- quasarr/search/sources/wx.py +337 -0
- quasarr/storage/config.py +39 -10
- quasarr/storage/setup.py +269 -97
- quasarr/storage/sqlite_database.py +6 -1
- quasarr-1.23.0.dist-info/METADATA +306 -0
- quasarr-1.23.0.dist-info/RECORD +77 -0
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/WHEEL +1 -1
- quasarr/arr/__init__.py +0 -423
- quasarr/captcha_solver/__init__.py +0 -284
- quasarr-0.1.6.dist-info/METADATA +0 -81
- quasarr-0.1.6.dist-info/RECORD +0 -31
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/entry_points.txt +0 -0
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info/licenses}/LICENSE +0 -0
- {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
|
+
}
|