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,376 @@
1
+ import asyncio
2
+ import logging
3
+ import re
4
+ from collections.abc import AsyncIterator, Generator
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from html import unescape
8
+ from html.parser import HTMLParser
9
+ from typing import Any, cast, get_args
10
+ from urllib.parse import urljoin
11
+
12
+ from httpx import AsyncClient
13
+
14
+ from .catalogue import Catalogue, Type
15
+ from .episode import Episode
16
+ from .langs import Lang, flagid2lang, flags
17
+ from .season import Season
18
+ from .utils import filter_literal, is_Literal
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class _CatalogueCardParser(HTMLParser):
24
+ def __init__(self) -> None:
25
+ super().__init__(convert_charrefs=True)
26
+ self.cards: list[dict[str, Any]] = []
27
+ self._card: dict[str, Any] | None = None
28
+ self._div_depth = 0
29
+ self._title_active = False
30
+ self._title_text = ""
31
+ self._badge_active = False
32
+ self._badge_text = ""
33
+
34
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
35
+ attributes = dict(attrs)
36
+ classes = (attributes.get("class") or "").split()
37
+
38
+ if tag == "div" and self._card is None and "as-carte" in classes:
39
+ self._card = {
40
+ "url": attributes.get("data-url") or "",
41
+ "name": attributes.get("data-nom") or "",
42
+ "image_url": attributes.get("data-img") or "",
43
+ "types": [],
44
+ "languages": [],
45
+ }
46
+ self._div_depth = 1
47
+ return
48
+
49
+ if self._card is None:
50
+ return
51
+
52
+ if tag == "div":
53
+ self._div_depth += 1
54
+ elif tag == "h3":
55
+ self._title_active = True
56
+ self._title_text = ""
57
+ elif tag == "span" and any(name.startswith("as-badge") for name in classes):
58
+ self._badge_active = True
59
+ self._badge_text = ""
60
+ elif tag == "img":
61
+ language_name = (attributes.get("title") or "").strip().casefold()
62
+ language_by_name = {
63
+ language.casefold(): language for language in get_args(Lang)
64
+ }
65
+ language = language_by_name.get(language_name)
66
+ if language is None:
67
+ language = next(
68
+ (
69
+ value
70
+ for flag_id, value in flagid2lang.items()
71
+ if flag_id.casefold() == language_name
72
+ ),
73
+ None,
74
+ )
75
+ if language is not None:
76
+ self._card["languages"].append(language)
77
+
78
+ def handle_data(self, data: str) -> None:
79
+ if self._title_active:
80
+ self._title_text += data
81
+ if self._badge_active:
82
+ self._badge_text += data
83
+
84
+ def handle_endtag(self, tag: str) -> None:
85
+ if self._card is None:
86
+ return
87
+
88
+ if tag == "h3" and self._title_active:
89
+ if self._title_text.strip():
90
+ self._card["name"] = self._title_text.strip()
91
+ self._title_active = False
92
+ elif tag == "span" and self._badge_active:
93
+ badge = self._badge_text.strip()
94
+ if badge:
95
+ self._card["types"].append(badge)
96
+ self._badge_active = False
97
+ elif tag == "div":
98
+ self._div_depth -= 1
99
+ if self._div_depth == 0:
100
+ if self._card["name"] and self._card["url"]:
101
+ self.cards.append(self._card)
102
+ self._card = None
103
+
104
+
105
+ async def find_site_url(
106
+ client: AsyncClient | None = None, provider_url="https://anime-sama.pw/"
107
+ ) -> str | None:
108
+ client = client or AsyncClient()
109
+
110
+ response = await client.get(provider_url)
111
+
112
+ if response.is_error:
113
+ return None
114
+
115
+ # * Sometimes need to check for the great word "anime-sama" in lowercase or uppercase but if add re.IGNORECASE it will work
116
+ match = re.search(
117
+ r"href=\"(.+?)\">Accéder à Anime-Sama", response.text, re.IGNORECASE
118
+ )
119
+
120
+ # * Ajouter un suive de redirection d'url au match au cas ou le site n'est pas a jour et redirige vers une autre url, puis garder l'url finale
121
+
122
+ if match:
123
+ redirected = await client.get(match.group(1), follow_redirects=True)
124
+ return str(redirected.url) + "/"
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class EpisodeRelease:
129
+ page_url: str
130
+ image_url: str
131
+ series_name: str
132
+ types: tuple[Type]
133
+ language: Lang
134
+ episode_name: str
135
+ timestamp: datetime
136
+
137
+ def get_real_episodes(self) -> list[Episode]:
138
+ raise NotImplementedError
139
+
140
+ @property
141
+ def fancy_name(self) -> str:
142
+ return (
143
+ f"{self.series_name} - {self.episode_name} {flags.get(self.language, '')}"
144
+ )
145
+
146
+
147
+ class AnimeSama:
148
+ def __init__(self, site_url: str, client: AsyncClient | None = None) -> None:
149
+ self.site_url = site_url
150
+ self.client = client or AsyncClient()
151
+
152
+ async def _get_homepage_section(self, section_name: str, how_many: int = 1) -> str:
153
+ homepage = await self.client.get(self.site_url)
154
+
155
+ if homepage.is_error:
156
+ return ""
157
+
158
+ sections = homepage.text.split("<!--")
159
+ for index, section in enumerate(sections):
160
+ comment_end_pos = section.find("-->")
161
+ if section_name in section[:comment_end_pos]:
162
+ return "<!--" + "<!--".join(sections[index : index + how_many])
163
+
164
+ return ""
165
+
166
+ def _yield_catalogues_from(self, html: str) -> Generator[Catalogue]:
167
+ parser = _CatalogueCardParser()
168
+ parser.feed(html)
169
+ if parser.cards:
170
+ for card in parser.cards:
171
+ url = urljoin(self.site_url, card["url"])
172
+
173
+ def not_in_literal(value: Any, url=url) -> None:
174
+ logger.warning(
175
+ "Error while parsing '%s'. Please report this with URL: %s",
176
+ value,
177
+ url,
178
+ )
179
+
180
+ card_types = cast(
181
+ set[Type],
182
+ set(filter_literal(card["types"], Type, not_in_literal)),
183
+ )
184
+ yield Catalogue(
185
+ url=url,
186
+ name=card["name"],
187
+ types=card_types,
188
+ languages=set(card["languages"]),
189
+ image_url=card["image_url"],
190
+ client=self.client,
191
+ )
192
+ return
193
+
194
+ text_without_script = re.sub(r"<script[\W\w]+?</script>", "", html)
195
+ for match in re.finditer(
196
+ rf"href=\"({self.site_url}catalogue/.+)\"[\W\w]+?src=\"(.+?)\"[\W\w]+?<h2.+?>(.*)\n?<[\W\w]+?<p.+?>(.*)\n?<[\W\w]+?<div class=\"genre-tags\">([\W\w]*?)</div[\W\w]+?<p.+?>(.*)\n?<[\W\w]+?<div class=\"lang-flags\">([\W\w]*?)</div",
197
+ text_without_script,
198
+ ):
199
+ (
200
+ url,
201
+ image_url,
202
+ name,
203
+ alternative_names_str,
204
+ genres_str,
205
+ types_str,
206
+ flags_str,
207
+ ) = (unescape(item) for item in match.groups())
208
+
209
+ alternative_names = (
210
+ alternative_names_str.split(", ") if alternative_names_str else []
211
+ )
212
+
213
+ genres = re.findall(r">(.+?)<", genres_str)
214
+ types = types_str.split(", ") if types_str else []
215
+ flags = re.findall(r"title=\"(.+?)\"", flags_str)
216
+
217
+ def not_in_literal(value: Any, url=url) -> None:
218
+ logger.warning(
219
+ f"Error while parsing '{value}'. \nPlease report this to the developer with URL: {url}"
220
+ )
221
+
222
+ types_checked = cast(
223
+ set[Type], set(filter_literal(types, Type, not_in_literal))
224
+ )
225
+ languages = {
226
+ flagid2lang[flag.lower()]
227
+ for flag in flags
228
+ if flag.lower() in flagid2lang
229
+ }
230
+
231
+ yield Catalogue(
232
+ url=url,
233
+ name=name,
234
+ alternative_names=alternative_names,
235
+ genres=genres,
236
+ types=types_checked,
237
+ languages=languages,
238
+ image_url=image_url,
239
+ client=self.client,
240
+ )
241
+
242
+ def _yield_release_episodes_from(self, html: str) -> Generator[EpisodeRelease]:
243
+ for match in re.finditer(
244
+ r"href=\"/(catalogue\/.+)\"[\W\w]+?src=\"(.+?)\"[\W\w]+?>.*\n?<[\W\w]+?>.*\n?<[\W\w]+?>(.*)\n?<[\W\w]+?>.*\n?<[\W\w]+?\">([\W\w]*?)\n?</[\W\w]+?>(.*)\n?<[\W\w]+?>.*\n?<[\W\w]+?>(.*)\n?<[\W\w]+?>(.*)\n?<",
245
+ html,
246
+ ):
247
+ (
248
+ season_url,
249
+ image_url,
250
+ types,
251
+ language_str,
252
+ series_name,
253
+ episode_name,
254
+ timestamp,
255
+ ) = match.groups()
256
+ season_url = self.site_url + season_url
257
+
258
+ types = types.split(", ") if types else ["Anime"]
259
+
260
+ def not_in_literal(value: Any, season_url=season_url) -> None:
261
+ logger.warning(
262
+ f"Error while parsing '{value}'. \nPlease report this to the developer with URL: {season_url} (from homepage)"
263
+ )
264
+
265
+ types_checked = cast(
266
+ tuple[Type],
267
+ tuple(filter_literal(types, Type, not_in_literal)),
268
+ )
269
+
270
+ language = re.findall(r"title=\"(.+?)\"", language_str)[0]
271
+ is_Literal(language, Lang, not_in_literal)
272
+
273
+ yield EpisodeRelease(
274
+ page_url=season_url,
275
+ image_url=image_url,
276
+ series_name=series_name,
277
+ types=types_checked,
278
+ language=language,
279
+ episode_name=episode_name,
280
+ timestamp=datetime.strptime(timestamp, "%d/%m/%Y %H:%M"),
281
+ )
282
+
283
+ async def search(self, query: str) -> list[Catalogue]:
284
+ response = (
285
+ await self.client.get(
286
+ f"{self.site_url}catalogue/", params={"search": query}
287
+ )
288
+ ).raise_for_status()
289
+
290
+ pages_regex = re.findall(r"page=(\d+)", response.text)
291
+
292
+ last_page = max((int(page) for page in pages_regex), default=1)
293
+
294
+ responses = [response] + await asyncio.gather(
295
+ *(
296
+ self.client.get(
297
+ f"{self.site_url}catalogue/",
298
+ params={"search": query, "page": num},
299
+ )
300
+ for num in range(2, last_page + 1)
301
+ )
302
+ )
303
+
304
+ catalogues = []
305
+ for response in responses:
306
+ if response.is_error:
307
+ continue
308
+
309
+ # catalogues += list(self._yield_catalogues_from(response.text))
310
+ # catalogues = [
311
+ # *catalogue
312
+ # for response in response
313
+ # if not response.is_error
314
+ # for catalogue in self._yield_catalogues_from(response.text)
315
+ # ]
316
+
317
+ catalogues.extend(self._yield_catalogues_from(response.text))
318
+
319
+ return catalogues
320
+
321
+ async def search_iter(self, query: str) -> AsyncIterator[Catalogue]:
322
+ response = (
323
+ await self.client.get(
324
+ f"{self.site_url}catalogue/", params={"search": query}
325
+ )
326
+ ).raise_for_status()
327
+
328
+ pages_regex = re.findall(r"page=(\d+)", response.text)
329
+
330
+ last_page = max((int(page) for page in pages_regex), default=1)
331
+
332
+ for catalogue in self._yield_catalogues_from(response.text):
333
+ yield catalogue
334
+
335
+ for number in range(2, last_page + 1):
336
+ response = await self.client.get(
337
+ f"{self.site_url}catalogue/",
338
+ params={"search": query, "page": number},
339
+ )
340
+
341
+ if response.is_error:
342
+ continue
343
+
344
+ for catalogue in self._yield_catalogues_from(response.text):
345
+ yield catalogue
346
+
347
+ async def catalogues_iter(self) -> AsyncIterator[Catalogue]:
348
+ async for catalogue in self.search_iter(""):
349
+ yield catalogue
350
+
351
+ async def all_catalogues(self) -> list[Catalogue]:
352
+ return await self.search("")
353
+
354
+ async def planning(self) -> list[list[Season]]:
355
+ # Get from homepage, return value should be change
356
+ raise NotImplementedError
357
+
358
+ async def new_episodes(self) -> list[EpisodeRelease]:
359
+ """
360
+ Return the new available episodes on anime-sama using the homepage sorted from oldest to newest.
361
+ """
362
+ section = await self._get_homepage_section("ajouts animes", 4)
363
+ release_episodes = list(self._yield_release_episodes_from(section))
364
+ return release_episodes[::-1]
365
+
366
+ """async def new_scans(self) -> list[Scan]:
367
+ raise NotImplementedError"""
368
+
369
+ async def new_content(self) -> list[Catalogue]:
370
+ raise NotImplementedError
371
+
372
+ async def classics(self) -> list[Catalogue]:
373
+ raise NotImplementedError
374
+
375
+ async def highlights(self) -> list[Catalogue]:
376
+ raise NotImplementedError
@@ -0,0 +1,54 @@
1
+ import functools
2
+ import operator
3
+ import re
4
+ from collections.abc import Callable, Generator, Iterable
5
+ from itertools import zip_longest
6
+ from typing import Any, TypeVar, cast, get_args
7
+
8
+ T = TypeVar("T")
9
+ NO_VALUE = object()
10
+
11
+
12
+ def zip_varlen(*iterables: Iterable[T]) -> list[list[T]]:
13
+ return cast(
14
+ list[list[T]],
15
+ [
16
+ [entry for entry in row if entry is not NO_VALUE]
17
+ for row in zip_longest(*iterables, fillvalue=NO_VALUE)
18
+ ],
19
+ )
20
+
21
+
22
+ def split_and_strip(string: str, delimiters: Iterable[str] | str) -> list[str]:
23
+ if isinstance(delimiters, str):
24
+ return [part.strip() for part in string.split(delimiters)]
25
+ string_list = [string]
26
+ for delimiter in delimiters:
27
+ string_list = functools.reduce(
28
+ operator.iadd, (part.split(delimiter) for part in string_list), []
29
+ )
30
+ return [part.strip() for part in string_list]
31
+
32
+
33
+ def remove_some_js_comments(string: str) -> str:
34
+ string = re.sub(r"/\*[\W\w]*?\*/", "", string)
35
+ return re.sub(r"<!--[\W\w]*?-->", "", string)
36
+
37
+
38
+ def is_Literal(
39
+ value: Any,
40
+ Lit: Any,
41
+ callback_when_false: Callable[[Any], None] = lambda _: None,
42
+ ) -> bool:
43
+ if value in get_args(Lit):
44
+ return True
45
+ callback_when_false(value)
46
+ return False
47
+
48
+
49
+ def filter_literal(
50
+ iterable: Iterable[Any],
51
+ Lit: T,
52
+ callback_when_false: Callable[[Any], None] = lambda _: None,
53
+ ) -> Generator[T, None, None]:
54
+ return (value for value in iterable if is_Literal(value, Lit, callback_when_false))
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.5
2
+ Name: anime-sama_cli_api
3
+ Version: 0.4.0
4
+ Summary: Api for anime-sama
5
+ Project-URL: Repository, https://github.com/Mathieux83/anime-sama_api
6
+ Author-email: NiniKo <pcninik@gmail.com>, Mathieux83 <car.math@live.fr>
7
+ License-Expression: GPL-3.0-or-later
8
+ License-File: LICENSE
9
+ Keywords: anime,anime-sama,downloader
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
20
+ Classifier: Topic :: Multimedia :: Video :: Capture
21
+ Classifier: Topic :: System :: Archiving
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: httpx2>=2.16.0
24
+ Requires-Dist: httpx>=0.28.1
25
+ Provides-Extra: cli
26
+ Requires-Dist: platformdirs>=4.13.5; extra == 'cli'
27
+ Requires-Dist: rich>=15.0.0; extra == 'cli'
28
+ Requires-Dist: yt-dlp>=2026.8.19; extra == 'cli'
29
+ Description-Content-Type: text/markdown
30
+
31
+ > [!WARNING]
32
+ > This project is an updated fork of the main repo of Niniko, with his authorization I only work in this fork no PR was send for now.
33
+
34
+ ---
35
+
36
+ # Anime-Sama API
37
+
38
+ An API for anime-sama, also provides a CLI to download videos.
39
+
40
+ I have implemented all the features I care about. This project is now in maintenance mode.
41
+
42
+ # Installation
43
+
44
+ Requirements:
45
+
46
+ - Python **3.11** or higher
47
+
48
+ You can simply install it with (note that you can use tools like pipx to isolate the installation):
49
+
50
+ ```bash
51
+ pip install anime-sama-api[cli]
52
+ ```
53
+
54
+ And to run it:
55
+
56
+ ```bash
57
+ anime-sama
58
+ ```
59
+
60
+ ## Configuration
61
+
62
+ You can customize the config at :
63
+
64
+ - Linux: `~/.config/anime-sama_cli/config.toml`
65
+ - Windows: `%USER%/AppData/Local/anime-sama_cli/config.toml`
66
+ - macOS: `~/Library/Application Support/anime-sama_api/config.toml`
67
+
68
+ ---
69
+
70
+ # For developers
71
+
72
+ ## Requirements
73
+
74
+ - git
75
+ - [uv](https://docs.astral.sh/uv/#installation)
76
+
77
+ ## Install locally
78
+
79
+ ```bash
80
+ git clone https://github.com/Sky-NiniKo/anime-sama_api.git
81
+ cd anime-sama_downloader
82
+ uv sync --extra cli
83
+ ```
84
+
85
+ ## Run
86
+
87
+ ```bash
88
+ uv run anime-sama
89
+ ```
90
+
91
+ ## Update
92
+
93
+ In the `anime_sama` folder:
94
+
95
+ ```bash
96
+ git pull
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Contribution
102
+
103
+ I am open to contribution. Please only open a PR for ONE change. AKA, don't do "Various improvements" and explain your motivation behind your improvement ("Various typos fix"/"Cleanup" is fine).
@@ -0,0 +1,21 @@
1
+ anime_sama_api/__init__.py,sha256=unxLlRekkszdKueHtqU6-wC_D9VZ0nOPlBdUxzcI99Y,1246
2
+ anime_sama_api/catalogue.py,sha256=VIpVfl0xV9OiMlSUfhInC-PYNxJEiz6F3kC-lKl8buQ,7737
3
+ anime_sama_api/episode.py,sha256=2E8ZzvgqXhX_SrRGAqjNDk2eW0e9CAflPPcZ0cKzc7U,7027
4
+ anime_sama_api/langs.py,sha256=K8jjV4oIXTNfvbldPWlb0fA7AHB2JlzM22mp8eFmqO0,2072
5
+ anime_sama_api/link_resolver.py,sha256=h5U6nPSvk2erTFc35LupaWFIa--zGIx7hDY0cVWi_nQ,6241
6
+ anime_sama_api/season.py,sha256=5a2zsi6qmOvo0XH2QKLimam1Q0iaIQbawbazk1-T0X0,9317
7
+ anime_sama_api/top_level.py,sha256=zwasoI_-DCxcokgaGEBUgOoPu2keBq6w5S96ZS_kh48,12981
8
+ anime_sama_api/utils.py,sha256=0AmJAPigX8u4OPbY4PpcI7zksAxOQHsbEHtTEuI-jvM,1523
9
+ anime_sama_api/cli/__main__.py,sha256=SMZU4ixVPQKP9tc_HSQBCMDZjqr4Nu-WjCGK0yLTs6c,2982
10
+ anime_sama_api/cli/config.py,sha256=jVk5u727R8-TKREdeBwtm1Ncvbzs_2l3AWcgV2RRFbc,4061
11
+ anime_sama_api/cli/config.toml,sha256=_vAsf5ZSTNYMIvBU7p_9OSj9STG8Oeg7YgN1RRNnDXM,1028
12
+ anime_sama_api/cli/downloader.py,sha256=zjyQeUMpvYEcW0nsQlujhtGoCAPbR-R-RUglrtj3t14,10908
13
+ anime_sama_api/cli/error_handling.py,sha256=PnWIlrwvYGVh8Hfh1shYhLYVJEP7XDd6IhOrypihiEY,2064
14
+ anime_sama_api/cli/internal_player.py,sha256=Pqwiv0dcfZYr-H3xNLiqhBKVBYA6cri7lv2NSDVj1ho,1535
15
+ anime_sama_api/cli/play_menu.py,sha256=nhvagx6Us3QbTYTJ9NfMYj45hRFDtZBFCefHnxPf_Hg,1637
16
+ anime_sama_api/cli/utils.py,sha256=LVlcZzBn9lh3eFs2ekBpdeAchYuFQDp-OTGBIrnPtVk,4395
17
+ anime_sama_cli_api-0.4.0.dist-info/METADATA,sha256=povEg3R0_18RuKNoqlr37KM0NYDp546q1cX8nnmrtpo,2579
18
+ anime_sama_cli_api-0.4.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
19
+ anime_sama_cli_api-0.4.0.dist-info/entry_points.txt,sha256=AoSk4oUFjzp4TwPxqXFOfg7R7rTGtakEHrHUk_XeNQ0,51
20
+ anime_sama_cli_api-0.4.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
21
+ anime_sama_cli_api-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ anime-sama = anime_sama_api:main