anime-sama-cli-api 0.4.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.
@@ -0,0 +1,207 @@
1
+ import logging
2
+ import re
3
+ from collections.abc import Generator, Sequence
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+ from urllib.parse import urlparse, urlsplit
7
+
8
+ from httpx import HTTPError
9
+
10
+ from .langs import Lang, LangId, flags, id2lang, lang2ids
11
+ from .link_resolver import LinkError, RateLimitedError, validate_url
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class Players(list[str]):
17
+ def __init__(self, *args: Sequence[Any], **kwargs: dict[Any, Any]):
18
+ super().__init__(*args, **kwargs)
19
+ self.swapPlayers()
20
+ for index, _ in enumerate(self):
21
+ self[index] = self[index].replace("vidmoly.to", "vidmoly.net")
22
+
23
+ def swapPlayers(self) -> None:
24
+ if len(self) < 2:
25
+ return
26
+ self[0], self[1] = (self[1], self[0])
27
+
28
+ def sort_and_filter(
29
+ self, prefer_players: list[str], ban_players: list[str]
30
+ ) -> list[str]:
31
+
32
+ def ban_filter(player: str) -> bool:
33
+ player_hostname = urlparse(player).hostname
34
+ if player_hostname is None:
35
+ return False
36
+ return player_hostname in ban_players
37
+
38
+ def key(player: str) -> int:
39
+ player_hostname = urlparse(player).hostname
40
+ if player_hostname is None:
41
+ return 0
42
+ try:
43
+ return prefer_players.index(player_hostname) - len(prefer_players)
44
+ except ValueError:
45
+ return 0
46
+
47
+ return sorted([player for player in self if not ban_filter(player)], key=key)
48
+
49
+
50
+ class Languages(dict[LangId, Players]):
51
+ def __init__(self, *args: dict[LangId, Players], **kargs: Players) -> None:
52
+ super().__init__(*args, **kargs)
53
+ if not self:
54
+ logger.warning("No player available for %s", self)
55
+
56
+ @property
57
+ def availables(self) -> dict[Lang, list[Players]]:
58
+ availables: dict[Lang, list[Players]] = {}
59
+ for lang_id, players in self.items():
60
+ if availables.get(id2lang[lang_id]) is None:
61
+ availables[id2lang[lang_id]] = []
62
+ availables[id2lang[lang_id]].append(players)
63
+ return availables
64
+
65
+ def consume_player(self, prefer_languages, prefer_players, ban_players):
66
+ available = self.availables
67
+ seen_urls = set()
68
+ for language in ordered_languages(prefer_languages, available):
69
+ for players in available.get(language, []):
70
+ for url in players.sort_and_filter(prefer_players, ban_players):
71
+ if url not in seen_urls:
72
+ seen_urls.add(url)
73
+ yield url
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class Episode:
78
+ languages: Languages
79
+ series_name: str = ""
80
+ season_name: str = ""
81
+ _name: str = ""
82
+ index: int = 1
83
+
84
+ @property
85
+ def name(self) -> str:
86
+ return self._name.strip()
87
+
88
+ @property
89
+ def fancy_name(self) -> str:
90
+ return f"{self._name.lstrip()} " + " ".join(
91
+ flags[lang] for lang in self.languages.availables if lang != "VOSTFR"
92
+ )
93
+
94
+ @property
95
+ def season_number(self) -> int:
96
+ match_season_number = re.search("\\d+", self.season_name)
97
+ return int(match_season_number.group(0)) if match_season_number else 0
98
+
99
+ @property
100
+ def long_name(self) -> str:
101
+ return f"{self.season_name} - {self.name}"
102
+
103
+ @property
104
+ def short_name(self) -> str:
105
+ return f"{self.series_name} S{self.season_number:02}E{self.index:02}"
106
+
107
+ def __str__(self) -> str:
108
+ return self.fancy_name
109
+
110
+ def consume_player(
111
+ self,
112
+ prefer_languages: list[Lang],
113
+ prefer_players: list[str] | None = None,
114
+ ban_players: list[str] | None = None,
115
+ ) -> Generator[str]:
116
+ if prefer_players is None:
117
+ prefer_players = []
118
+ if ban_players is None:
119
+ ban_players = []
120
+ yield from self.languages.consume_player(
121
+ prefer_languages, prefer_players, ban_players
122
+ )
123
+
124
+ def best(self, prefer_languages: list[Lang]) -> str | None:
125
+ try:
126
+ return next(self.consume_player(prefer_languages))
127
+ except StopIteration:
128
+ return None
129
+
130
+ async def aconsume_player(self, prefer_languages=None, ban_players=None):
131
+ """Yield resolved URLs on demand, suitable for the current HTML/API backend."""
132
+ async for url in iter_resolved_players(
133
+ self,
134
+ ["VOSTFR"] if prefer_languages is None else prefer_languages,
135
+ ban_players or [],
136
+ ):
137
+ yield url
138
+
139
+
140
+ class DeferredPlayers(Players):
141
+ def __init__(self, references, resolver):
142
+ list.__init__(self, references)
143
+ self.resolver = resolver
144
+
145
+ def sort_and_filter(self, prefer_players, ban_players):
146
+ raise RuntimeError(
147
+ "Liens différés : utiliser Episode.aconsume_player() ou la CLI."
148
+ )
149
+
150
+
151
+ def candidates_for(episode, prefer_languages):
152
+ order = ordered_languages(prefer_languages)
153
+ seen = set()
154
+ for language in order:
155
+ for lang_id, players in episode.languages.items():
156
+ if id2lang[lang_id] != language:
157
+ continue
158
+ for ref in players:
159
+ owner = (
160
+ id(players.resolver)
161
+ if isinstance(players, DeferredPlayers)
162
+ else None
163
+ )
164
+ key = (owner, ref)
165
+ if ref and key not in seen:
166
+ seen.add(key)
167
+ yield (language, players, ref)
168
+
169
+
170
+ async def resolve_reference(players, reference):
171
+ if isinstance(players, DeferredPlayers):
172
+ return await players.resolver.resolve(reference)
173
+ if reference.startswith("lien:"):
174
+ raise LinkError("Référence sans contexte de résolution")
175
+ return validate_url(reference)
176
+
177
+
178
+ async def iter_resolved_players(
179
+ episode, prefer_languages, ban_players=(), resolve=None
180
+ ):
181
+ resolver = resolve or resolve_reference
182
+ seen_urls = set()
183
+ bans = set(ban_players)
184
+ for language, players, reference in candidates_for(episode, prefer_languages):
185
+ try:
186
+ url = await resolver(players, reference)
187
+ except RateLimitedError:
188
+ raise
189
+ except (LinkError, HTTPError) as exc:
190
+ logger.warning(
191
+ "%s / %s : résolution échouée (%s), candidat suivant",
192
+ episode.name,
193
+ language,
194
+ type(exc).__name__,
195
+ )
196
+ continue
197
+ if not url or url in seen_urls:
198
+ continue
199
+ seen_urls.add(url)
200
+ if urlsplit(url).hostname in bans:
201
+ continue
202
+ yield url
203
+
204
+
205
+ def ordered_languages(prefer_languages, available=()):
206
+ """Preserve configured priorities, then append fallback languages once."""
207
+ return list(dict.fromkeys([*prefer_languages, *lang2ids, *available]))
@@ -0,0 +1,76 @@
1
+ from typing import Literal
2
+
3
+ Lang = Literal["VASTFR", "VCN", "VF", "VJSTFR", "VKR", "VQC", "VOSTFR"]
4
+ LangId = Literal["va", "vcn", "vf", "vf1", "vf2", "vj", "vkr", "vqc", "vostfr"]
5
+ FlagId = Literal["cn", "qc", "en", "pal", "kr", "fr", "jp"]
6
+
7
+ lang2ids: dict[Lang, list[LangId]] = {
8
+ "VOSTFR": ["vostfr"],
9
+ "VASTFR": ["va"],
10
+ "VCN": ["vcn"],
11
+ "VF": ["vf", "vf1", "vf2"],
12
+ "VJSTFR": ["vj"],
13
+ "VKR": ["vkr"],
14
+ "VQC": ["vqc"],
15
+ }
16
+
17
+ id2lang: dict[LangId, Lang] = {
18
+ lang_id: lang for lang, langs_id in lang2ids.items() for lang_id in langs_id
19
+ }
20
+
21
+ flags: dict[Lang | LangId, str] = {
22
+ "VOSTFR": "",
23
+ "VASTFR": "🇬🇧",
24
+ "VCN": "🇨🇳",
25
+ "VF": "🇫🇷",
26
+ "VJSTFR": "🇯🇵",
27
+ "VKR": "🇰🇷",
28
+ "VQC": "🏴󠁣󠁡󠁱󠁣󠁿",
29
+ }
30
+
31
+ flagid2lang: dict[FlagId, Lang] = {
32
+ "cn": "VCN",
33
+ "qc": "VQC",
34
+ "en": "VASTFR",
35
+ "kr": "VKR",
36
+ "fr": "VF",
37
+ "jp": "VJSTFR",
38
+ }
39
+
40
+ for language, language_ids in lang2ids.items():
41
+ for lang_id in language_ids:
42
+ flags[lang_id] = flags[language]
43
+
44
+
45
+ if __name__ == "__main__":
46
+ import asyncio
47
+ import re
48
+ from pprint import pprint
49
+
50
+ import httpx
51
+
52
+ from anime_sama_api.top_level import AnimeSama
53
+
54
+ SCRIPT_VIDEO_URL = "https://anime-sama.si/js/contenu/script_videos.js"
55
+ page = httpx.get(SCRIPT_VIDEO_URL).text
56
+ langs = {}
57
+
58
+ matches = re.findall(r"if\((.+)\){langue = \"(.+)\";}", page)
59
+ for match in matches:
60
+ langs[match[1]] = match[0].split('"')[1::2]
61
+
62
+ pprint(langs)
63
+
64
+ async def main() -> None:
65
+ async for catalogue in AnimeSama("https://anime-sama.to/").catalogues_iter():
66
+ if await catalogue.seasons():
67
+ break
68
+ else:
69
+ raise RuntimeError
70
+
71
+ vostfr_url = (await catalogue.seasons())[0].url + "vostfr/"
72
+ response = await catalogue.client.get(vostfr_url)
73
+ response.raise_for_status()
74
+ print(set(re.findall(r"src=\".+flag_(.+?)\.png\"", response.text)))
75
+
76
+ asyncio.run(main())
@@ -0,0 +1,187 @@
1
+ """Inline episode references and Anime-Sama link API. No JavaScript execution."""
2
+
3
+ import asyncio
4
+ import json
5
+ import re
6
+ from dataclasses import dataclass
7
+ from html.parser import HTMLParser
8
+ from urllib.parse import urljoin, urlsplit
9
+
10
+
11
+ class LinkError(RuntimeError):
12
+ pass
13
+
14
+
15
+ class BddScriptParser(HTMLParser):
16
+ def __init__(self):
17
+ super().__init__(convert_charrefs=False)
18
+ self.active = False
19
+ self.parts = []
20
+
21
+ def handle_starttag(self, tag, attrs):
22
+ if tag.lower() == "script":
23
+ attributes = dict(attrs)
24
+ self.active = attributes.get("data-source") == "bdd"
25
+
26
+ def handle_data(self, data):
27
+ if self.active:
28
+ self.parts.append(data)
29
+
30
+ def handle_endtag(self, tag):
31
+ if tag.lower() == "script":
32
+ self.active = False
33
+
34
+
35
+ def extract_bdd_script(html):
36
+ parser = BddScriptParser()
37
+ parser.feed(html)
38
+
39
+ source = "\n".join(parser.parts)
40
+
41
+ if not source.strip():
42
+ raise LinkError('Script <script data-source="bdd"> introuvable.')
43
+
44
+ return source
45
+
46
+
47
+ class RateLimitedError(LinkError):
48
+ pass
49
+
50
+
51
+ @dataclass
52
+ class PageData:
53
+ token: str
54
+ players: dict[int, list[str]]
55
+ final_order: bool
56
+
57
+
58
+ def parse_page(html):
59
+ source = extract_bdd_script(html)
60
+ token = re.search(
61
+ r"\b(?:var|let|const)\s+epsJeton\s*=\s*([\"'])(.*?)\1", source, re.DOTALL
62
+ )
63
+ players = {}
64
+ for match in re.finditer(
65
+ r"\b(?:var|let|const)\s+eps(\d+)\s*=\s*(\[[^;]*?\])\s*;", source, re.DOTALL
66
+ ):
67
+ try:
68
+ values = json.loads(match[2])
69
+ except ValueError as exc:
70
+ raise LinkError("Unsupported episode array syntax (expected JSON)") from exc
71
+ if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
72
+ raise LinkError("Invalid episode array")
73
+ players[int(match[1])] = values
74
+ if not players:
75
+ raise LinkError(
76
+ "No inline epsN arrays found; page unavailable or format changed"
77
+ )
78
+ needs_token = any(v.startswith("lien:") for a in players.values() for v in a)
79
+ if needs_token and not token:
80
+ raise LinkError("epsJeton missing")
81
+ final = bool(re.search(r"\bepsOrdreFinal\s*=\s*true\b", source))
82
+ return PageData(token[2] if token else "", players, final)
83
+
84
+
85
+ def validate_url(value):
86
+ if not isinstance(value, str):
87
+ raise LinkError("API field u must be a string")
88
+ parsed = urlsplit(value)
89
+ if (
90
+ parsed.scheme not in ("https", "http")
91
+ or not parsed.hostname
92
+ or parsed.username
93
+ or parsed.password
94
+ ):
95
+ raise LinkError("Invalid player URL")
96
+ return value
97
+
98
+
99
+ class LinkResolver:
100
+ def __init__(self, client, page_url):
101
+ self.client = client
102
+ self.page_url = page_url
103
+ self.data = None
104
+ self.cache = {}
105
+ self.min_interval = 0.0
106
+ self._last_request = 0.0
107
+ self._refresh_lock = asyncio.Lock()
108
+
109
+ async def load(self):
110
+ response = await self.client.get(self.page_url, follow_redirects=True)
111
+ response.raise_for_status()
112
+ self.page_url = str(response.url)
113
+ self.data = parse_page(response.text)
114
+ self.cache.clear()
115
+ return response.text
116
+
117
+ async def resolve(self, reference):
118
+ if not reference:
119
+ return ""
120
+ if not reference.startswith("lien:"):
121
+ return validate_url(reference)
122
+ identifier = reference[5:]
123
+ if not identifier.isdecimal():
124
+ raise LinkError("Invalid lien ID")
125
+ if self.data is None:
126
+ await self.load()
127
+ refreshed = False
128
+ for attempt in range(3):
129
+ key = (self.data.token, identifier)
130
+ if key in self.cache:
131
+ return self.cache[key]
132
+ try:
133
+ loop = asyncio.get_running_loop()
134
+ wait = self.min_interval - (loop.time() - self._last_request)
135
+ if wait > 0:
136
+ await asyncio.sleep(wait)
137
+ self._last_request = loop.time()
138
+ response = await self.client.get(
139
+ urljoin(self.page_url, "/api/lien.php"),
140
+ params={"j": self.data.token, "l": identifier},
141
+ headers={"Referer": self.page_url},
142
+ follow_redirects=False,
143
+ )
144
+ if response.status_code == 429:
145
+ retry_after = response.headers.get("Retry-After")
146
+
147
+ detail = (
148
+ f"Retry-After reçu : {retry_after}."
149
+ if retry_after
150
+ else "Aucun délai Retry-After fourni par le serveur."
151
+ )
152
+
153
+ raise RateLimitedError(
154
+ "Anime-Sama limite actuellement les requêtes. " + detail
155
+ )
156
+
157
+ response.raise_for_status()
158
+ try:
159
+ payload = response.json()
160
+ except ValueError as exc:
161
+ raise LinkError(
162
+ "API did not return JSON (possibly a verification page)"
163
+ ) from exc
164
+ if not isinstance(payload, dict):
165
+ raise LinkError("API response must be an object")
166
+ if payload.get("u"):
167
+ value = validate_url(payload["u"])
168
+ self.cache[key] = value
169
+ return value
170
+ error = payload.get("erreur", "missing u")
171
+ if error == "expire" and not refreshed:
172
+ refreshed = True
173
+ async with self._refresh_lock:
174
+ if self.data.token == key[0]:
175
+ await self.load()
176
+ continue
177
+ if error in ("absent", "expire"):
178
+ raise LinkError("Terminal API error: " + str(error))
179
+ if attempt == 2:
180
+ raise LinkError("API error: " + str(error))
181
+ except LinkError:
182
+ raise
183
+ except Exception:
184
+ if attempt == 2:
185
+ raise
186
+ await asyncio.sleep((1, 3)[min(attempt, 1)])
187
+ raise LinkError("Resolution failed after token refresh")
@@ -0,0 +1,226 @@
1
+ import logging
2
+ import re
3
+ from ast import literal_eval
4
+ from dataclasses import dataclass, replace
5
+ from functools import reduce
6
+ from typing import get_args
7
+
8
+ from httpx import AsyncClient
9
+
10
+ from anime_sama_api.episode import DeferredPlayers
11
+
12
+ from .episode import Episode, Languages, Players
13
+ from .langs import LangId, flagid2lang, lang2ids
14
+ from .link_resolver import LinkError, LinkResolver, RateLimitedError, parse_page
15
+ from .utils import remove_some_js_comments, split_and_strip
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class EpisodeDataUnavailableError(RuntimeError):
21
+ pass
22
+
23
+
24
+ @dataclass
25
+ class SeasonLangPage:
26
+ lang_id: LangId
27
+ html: str = ""
28
+ episodes_js: str = ""
29
+
30
+
31
+ class Season:
32
+ def __init__(
33
+ self,
34
+ url: str,
35
+ name: str = "",
36
+ series_name: str = "",
37
+ client: AsyncClient | None = None,
38
+ ) -> None:
39
+ self.url = url
40
+ self.site_url = "/".join(url.split("/")[:3]) + "/"
41
+ self.name = name or url.split("/")[-2]
42
+ self.series_name = series_name or url.split("/")[-3]
43
+ self.client = client or AsyncClient()
44
+
45
+ async def get_all_pages(self):
46
+ pages = []
47
+ self._api_rows = {}
48
+ for lang_id in get_args(LangId):
49
+ page_url = self.url.rstrip("/") + "/" + lang_id + "/"
50
+ response = await self.client.get(page_url, follow_redirects=True)
51
+ if response.status_code == 404:
52
+ continue
53
+ if response.status_code == 429:
54
+ delay = response.headers.get("Retry-After", "non fourni")
55
+ raise RateLimitedError(
56
+ "Limitation lors du chargement HTML. Retry-After : " + delay
57
+ )
58
+ response.raise_for_status()
59
+ try:
60
+ data = parse_page(response.text)
61
+ except LinkError as exc:
62
+ logger.warning("Page langue ignorée (%s) : %s", page_url, exc)
63
+ continue
64
+ resolver = LinkResolver(self.client, str(response.url))
65
+ resolver.data = data
66
+ arrays = [data.players[n] for n in sorted(data.players)]
67
+ rows = []
68
+ for index in range(max(map(len, arrays), default=0)):
69
+ refs = [a[index] if index < len(a) else "" for a in arrays]
70
+ if not data.final_order and len(refs) > 1:
71
+ refs[0], refs[1] = (refs[1], refs[0])
72
+ rows.append(DeferredPlayers([r for r in refs if r], resolver))
73
+ self._api_rows[lang_id] = rows
74
+ pages.append(SeasonLangPage(lang_id=lang_id, html=response.text))
75
+ mapping = {p.lang_id: p for p in pages}
76
+ if "vostfr" in mapping:
77
+ matches = re.findall(
78
+ 'src="[^"]*flag_([^"]+?)\\.png".*?<span[^>]*>\\s*VOSTFR\\s*</span>',
79
+ remove_some_js_comments(mapping["vostfr"].html),
80
+ re.DOTALL | re.IGNORECASE,
81
+ )
82
+ if matches and matches[0] in flagid2lang:
83
+ for target in lang2ids[flagid2lang[matches[0]]]:
84
+ if target not in mapping:
85
+ pages.append(replace(mapping["vostfr"], lang_id=target))
86
+ self._api_rows[target] = self._api_rows["vostfr"]
87
+ break
88
+ if not pages:
89
+ raise EpisodeDataUnavailableError(
90
+ "Aucune page avec données bdd exploitable : " + self.url
91
+ )
92
+ return pages
93
+
94
+ def _get_players_from(self, page):
95
+ return self._api_rows[page.lang_id]
96
+
97
+ def _get_episodes_names(
98
+ self, page: SeasonLangPage, number_of_episodes: int, number_of_episodes_max: int
99
+ ) -> list[str]:
100
+ functions = re.findall(
101
+ "resetListe\\(\\); *[\\n\\r]+\\t*(.*?)}",
102
+ remove_some_js_comments(page.html),
103
+ re.DOTALL,
104
+ )
105
+ if not functions:
106
+ raise EpisodeDataUnavailableError(
107
+ f"Could not find episode names for {self.series_name} - {self.name} ({self.url + page.lang_id}/)."
108
+ )
109
+ functions = functions[-1]
110
+ functions_list = split_and_strip(functions, (";", "\n"))[:-1]
111
+
112
+ def padding(n: int) -> str:
113
+ return " " * (len(str(number_of_episodes_max)) - len(str(n)))
114
+
115
+ def episode_name_range(*args) -> list[str]:
116
+ return [f"Episode {n}{padding(n)}" for n in range(*args)]
117
+
118
+ episodes_name: list[str] = []
119
+ for function in functions_list:
120
+ if function.startswith("//"):
121
+ continue
122
+ call_start = function.find("(")
123
+ function, args_sting = (
124
+ function[:call_start],
125
+ function[call_start + 1 : -1],
126
+ )
127
+ if args_sting:
128
+ args = literal_eval(node_or_string=args_sting + ",")
129
+ if type(args) is not tuple:
130
+ continue
131
+ else:
132
+ args = ()
133
+ match function:
134
+ case "":
135
+ continue
136
+ case "creerListe":
137
+ if len(args) < 2:
138
+ continue
139
+ episodes_name += episode_name_range(int(args[0]), int(args[1]) + 1)
140
+ case "finirListe" | "finirListeOP":
141
+ if not args:
142
+ break
143
+ episodes_name += episode_name_range(
144
+ int(args[0]),
145
+ int(args[0]) + number_of_episodes - len(episodes_name),
146
+ )
147
+ break
148
+ case "newSP":
149
+ if not args:
150
+ raise NotImplementedError(
151
+ f"Error while parsing 'newSP'.\nPlease report this to the developer with URL: {self.url + page.lang_id}/"
152
+ )
153
+ episodes_name.append(f"Episode {args[0]}")
154
+ case "newSPF":
155
+ if not args:
156
+ raise NotImplementedError(
157
+ f"Error while parsing 'newSPF'.\nPlease report this to the developer with URL: {self.url + page.lang_id}/"
158
+ )
159
+ episodes_name.append(args[0])
160
+ case name:
161
+ raise NotImplementedError(
162
+ f"Error cannot parse '{name}'.\nPlease report this to the developer with URL: {self.url + page.lang_id}/"
163
+ )
164
+ return episodes_name
165
+
166
+ @staticmethod
167
+ def _extend_episodes(
168
+ current: list[tuple[str, Languages]],
169
+ new: tuple[SeasonLangPage, list[str], list[Players]],
170
+ ) -> list[tuple[str, Languages]]:
171
+ """
172
+ Extend a list of episodes AKA (name, languages) from a list names and players corresponding
173
+ to a language while preserving the relative order of names.
174
+ This function is intended to be used with reduce.
175
+ """
176
+ page, names, players_list = new
177
+ fusion = []
178
+ curr_done = 0
179
+ for name_new, players in zip(names, players_list):
180
+ for pos, (name_current, languages) in enumerate(current[curr_done:]):
181
+ if name_new == name_current:
182
+ languages[page.lang_id] = players
183
+ fusion.extend(current[curr_done : curr_done + pos + 1])
184
+ curr_done += pos + 1
185
+ break
186
+ else:
187
+ fusion.append((name_new, Languages({page.lang_id: players})))
188
+ fusion.extend(current[curr_done:])
189
+ return fusion
190
+
191
+ async def episodes(self) -> list[Episode]:
192
+ pages = await self.get_all_pages()
193
+ if not pages:
194
+ raise EpisodeDataUnavailableError(
195
+ f"Could not retrieve episode data for {self.series_name} - {self.name} ({self.url}). The season may be unavailable or Anime-Sama's page structure may have changed."
196
+ )
197
+ players_list = [self._get_players_from(page) for page in pages]
198
+ if not any(players_list):
199
+ raise EpisodeDataUnavailableError(
200
+ f"Could not parse episode players for {self.series_name} - {self.name} ({self.url}). Anime-Sama's page structure may have changed."
201
+ )
202
+ number_of_episodes_max = max(
203
+ len(episodes_page) for episodes_page in players_list
204
+ )
205
+ episodes_names = [
206
+ self._get_episodes_names(page, len(episodes_page), number_of_episodes_max)
207
+ for page, episodes_page in zip(pages, players_list)
208
+ ]
209
+ episodes: list[tuple[str, Languages]] = reduce(
210
+ self._extend_episodes, zip(pages, episodes_names, players_list), []
211
+ )
212
+ return [
213
+ Episode(languages, self.series_name, self.name, name, index)
214
+ for index, (name, languages) in enumerate(episodes, start=1)
215
+ ]
216
+
217
+ def __repr__(self) -> str:
218
+ return f"Season({self.name!r}, {self.series_name!r})"
219
+
220
+ def __str__(self) -> str:
221
+ return self.name
222
+
223
+ def __eq__(self, value: object) -> bool:
224
+ if not isinstance(value, Season):
225
+ return False
226
+ return self.url == value.url