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,52 @@
1
+ from .catalogue import Catalogue
2
+ from .episode import Episode, Languages, Players
3
+ from .langs import Lang, LangId, flags, id2lang, lang2ids
4
+ from .season import Season
5
+ from .top_level import AnimeSama, find_site_url
6
+
7
+ try:
8
+ from .cli.downloader import download, multi_download
9
+ except ImportError:
10
+ import sys
11
+
12
+ def main() -> int:
13
+ print(
14
+ "This anime-sama_api function could not run because the required "
15
+ "dependencies were not installed.\nMake sure you've installed "
16
+ "everything with: pip install 'anime-sama_api[cli]'"
17
+ )
18
+
19
+ sys.exit(1)
20
+
21
+ download = multi_download = main
22
+ else:
23
+
24
+ def main() -> int:
25
+ from .cli.__main__ import main as cli_main
26
+
27
+ return cli_main()
28
+
29
+
30
+ # __package__ = "anime-sama_api"
31
+ __all__ = [
32
+ "AnimeSama",
33
+ "Catalogue",
34
+ "Episode",
35
+ "Lang",
36
+ "LangId",
37
+ "Languages",
38
+ "Players",
39
+ "Season",
40
+ "download",
41
+ "find_site_url",
42
+ "flags",
43
+ "id2lang",
44
+ "lang2ids",
45
+ "main",
46
+ "multi_download",
47
+ ]
48
+
49
+ """__locals = locals()
50
+ for __name in __all__:
51
+ if not __name.startswith("__"):
52
+ setattr(__locals[__name], "__module__", "anime-sama_api") # noqa"""
@@ -0,0 +1,252 @@
1
+ import logging
2
+ import re
3
+ from collections.abc import Sequence
4
+ from datetime import date
5
+ from html import unescape
6
+ from typing import Literal, get_args
7
+
8
+ from httpx import AsyncClient
9
+
10
+ from .langs import Lang, flags
11
+ from .season import Season
12
+ from .utils import remove_some_js_comments
13
+
14
+ Type = Literal["Anime", "Film", "Scans", "Webtoon", "Autres"]
15
+ State = Literal["À venir", "En cours", "Terminé", "Annulé", "En pause"]
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class Catalogue:
21
+ def __init__(
22
+ self,
23
+ url: str,
24
+ name: str = "",
25
+ alternative_names: Sequence[str] | None = None,
26
+ genres: Sequence[str] | None = None,
27
+ types: set[Type] | None = None,
28
+ languages: set[Lang] | None = None,
29
+ image_url: str = "",
30
+ client: AsyncClient | None = None,
31
+ ) -> None:
32
+ if alternative_names is None:
33
+ alternative_names = []
34
+ if genres is None:
35
+ genres = []
36
+ if types is None:
37
+ types = set()
38
+ if languages is None:
39
+ languages = set()
40
+
41
+ self.url = url + "/" if url[-1] != "/" else url
42
+ self.site_url = "/".join(url.split("/")[:3]) + "/"
43
+ self.client = client or AsyncClient()
44
+
45
+ self.name = name or url.split("/")[-2]
46
+
47
+ self._page: str | None = None
48
+ self.alternative_names = alternative_names
49
+ self.genres = genres
50
+ self.types = types
51
+ self.languages = languages
52
+ self.image_url = image_url
53
+
54
+ async def page(self) -> str:
55
+ if self._page is not None:
56
+ return self._page
57
+
58
+ response = await self.client.get(self.url)
59
+
60
+ if response.is_error:
61
+ self._page = ""
62
+ else:
63
+ self._page = response.text
64
+
65
+ return self._page
66
+
67
+ async def seasons(self) -> list[Season]:
68
+ page_without_comments = remove_some_js_comments(string=await self.page())
69
+
70
+ seasons = re.findall(
71
+ r'panneauAnime\("(.+?)", *"(.+?)(?:vostfr|vf)"\);?', page_without_comments
72
+ )
73
+
74
+ seasons = [
75
+ Season(
76
+ url=self.url + link,
77
+ name=name,
78
+ series_name=self.name,
79
+ client=self.client,
80
+ )
81
+ for name, link in seasons
82
+ ]
83
+
84
+ return seasons
85
+
86
+ async def _info_values(self) -> dict[str, list[str]]:
87
+ values: dict[str, list[str]] = {}
88
+ page = await self.page()
89
+ info_blocks = re.findall(
90
+ r"""<dl\b[^>]*class=["'][^"']*\boe-infos\b[^"']*["'][^>]*>(.*?)</dl\s*>""",
91
+ page,
92
+ re.IGNORECASE | re.DOTALL,
93
+ )
94
+ for block in info_blocks:
95
+ for label, value in re.findall(
96
+ r"<dt\b[^>]*>(.*?)</dt\s*>\s*<dd\b[^>]*>(.*?)</dd\s*>",
97
+ block,
98
+ re.IGNORECASE | re.DOTALL,
99
+ ):
100
+ clean_label = self._clean_html(label).casefold()
101
+ values.setdefault(clean_label, []).append(self._clean_html(value))
102
+ return values
103
+
104
+ # TODO: move to utils
105
+ @staticmethod
106
+ def _clean_html(value: str) -> str:
107
+ return re.sub(r"\s+", " ", unescape(re.sub(r"<[^>]*>", " ", value))).strip()
108
+
109
+ async def news(self) -> str:
110
+ search = re.findall(r"Actualité.+?>(.+?)<", await self.page())
111
+
112
+ if not search:
113
+ return ""
114
+
115
+ return search[0].lstrip()
116
+
117
+ async def state(self) -> str:
118
+ info = await self._info_values()
119
+ states = info.get("statut", [])
120
+ if states:
121
+ if states[0] in get_args(State):
122
+ return states[0]
123
+ logger.warning(
124
+ f"Error while parsing '{states[0]}'. \nPlease report this to the developer with URL: {self.url}",
125
+ )
126
+ return ""
127
+
128
+ async def broadcasting_date(self) -> date | None:
129
+ raise NotImplementedError
130
+
131
+ info = await self._info_values()
132
+ diffusions = info.get("diffusion", [])
133
+
134
+ if not diffusions:
135
+ return
136
+
137
+ import time
138
+
139
+ return date(
140
+ *(time.strptime(diffusions[0], "%d %b. %Y")[0:3])
141
+ ) # issue with local: %b should be interpreted with french local
142
+
143
+ async def year(self) -> int | None:
144
+ info = await self._info_values()
145
+ for label in ("diffusion", "année", "année de sortie", "début"):
146
+ for value in info.get(label, []):
147
+ match = re.search(r"\b(?:19|20)\d{2}\b", value)
148
+ if match:
149
+ return int(match.group(0))
150
+
151
+ async def episode_count(self) -> int | None:
152
+ info = await self._info_values()
153
+ for value in info.get("épisodes", []):
154
+ match = re.search(r"\d+", value)
155
+ if match:
156
+ return int(match.group(0))
157
+
158
+ # Currently not provided by Anime Sama. We consider this as deprecated: TODO add a warning
159
+ async def chapter_count(self) -> int | None:
160
+ info = await self._info_values()
161
+ for value in info.get("chapitres", []):
162
+ match = re.search(r"\d+", value)
163
+ if match:
164
+ return int(match.group(0))
165
+
166
+ async def creator(self) -> str:
167
+ info = await self._info_values()
168
+ creators = info.get("auteur", [])
169
+
170
+ if not creators:
171
+ return ""
172
+
173
+ return creators[0]
174
+
175
+ async def studio(self) -> str:
176
+ info = await self._info_values()
177
+ studios = info.get("studio", [])
178
+
179
+ if not studios:
180
+ return ""
181
+
182
+ return studios[0]
183
+
184
+ async def correspondence(self) -> str:
185
+ page = await self.page()
186
+ match = re.search(
187
+ r"Correspondance\s*:.*?</strong>\s*(.*?)</span>",
188
+ page,
189
+ re.IGNORECASE | re.DOTALL,
190
+ )
191
+
192
+ if not match:
193
+ return ""
194
+
195
+ return self._clean_html(match.group(1))
196
+
197
+ async def synopsis(self) -> str:
198
+ page = await self.page()
199
+ synopsis = re.search(
200
+ r"""<p\b[^>]*id=["'][^"']*\bsynopsisText\b[^"']*["'][^>]*>(.*?)</p\s*>""",
201
+ page,
202
+ re.IGNORECASE | re.DOTALL,
203
+ )
204
+
205
+ if not synopsis:
206
+ return ""
207
+
208
+ return self._clean_html(synopsis.group(1))
209
+
210
+ async def is_mature(self) -> bool:
211
+ """Return True if the catalogue contain a warning about adult content"""
212
+ return (
213
+ re.search(
214
+ r'<div class=".*?yellow.*?">[\W\w]+?public averti', await self.page()
215
+ )
216
+ is not None
217
+ )
218
+
219
+ @property
220
+ def is_anime(self) -> bool:
221
+ return "Anime" in self.types
222
+
223
+ @property
224
+ def is_manga(self) -> bool:
225
+ return "Scans" in self.types
226
+
227
+ @property
228
+ def is_film(self) -> bool:
229
+ return "Film" in self.types
230
+
231
+ @property
232
+ def is_other(self) -> bool:
233
+ return "Autres" in self.types
234
+
235
+ @property
236
+ def fancy_name(self) -> str:
237
+ names = [""] + list(self.alternative_names) if self.alternative_names else []
238
+ return f"{self.name}[bright_black]{' - '.join(names)} {' '.join(flags[lang] for lang in self.languages if lang != 'VOSTFR')}"
239
+
240
+ def __repr__(self) -> str:
241
+ return f"Catalogue({self.url!r}, {self.name!r})"
242
+
243
+ def __str__(self) -> str:
244
+ return self.fancy_name
245
+
246
+ def __eq__(self, value: object) -> bool:
247
+ if not isinstance(value, Catalogue):
248
+ return False
249
+ return self.url == value.url
250
+
251
+ def __hash__(self) -> int:
252
+ return hash(self.url + self.name + "".join(self.alternative_names))
@@ -0,0 +1,80 @@
1
+ """CLI using the package's HTML backend and lazy API resolution."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from urllib.error import URLError
6
+
7
+ from httpx import AsyncClient, HTTPError
8
+ from rich import get_console
9
+ from rich.logging import RichHandler
10
+
11
+ from anime_sama_api.cli.downloader import download_episodes
12
+ from anime_sama_api.cli.utils import install_clean_prompts
13
+ from anime_sama_api.link_resolver import LinkError, RateLimitedError
14
+ from anime_sama_api.season import EpisodeDataUnavailableError
15
+ from anime_sama_api.top_level import AnimeSama, find_site_url
16
+
17
+ from . import utils as cli_utils
18
+ from .config import config
19
+
20
+ console = get_console()
21
+
22
+
23
+ async def async_main():
24
+ query = cli_utils.safe_input("Anime name: ", str)
25
+ async with AsyncClient(timeout=30, follow_redirects=True) as client:
26
+ url = config.url or await find_site_url(client, config.provider_url)
27
+ if not url:
28
+ raise URLError("Could not find the Anime-Sama domain")
29
+ with console.status("Searching..."):
30
+ catalogues = await AnimeSama(url, client).search(query)
31
+ if not catalogues:
32
+ console.print("No results found.")
33
+ return True
34
+ catalogue = cli_utils.select_one(catalogues)
35
+ with console.status("Loading seasons..."):
36
+ seasons = await catalogue.seasons()
37
+ if not seasons:
38
+ console.print("No seasons found.")
39
+ return True
40
+ selected = cli_utils.select_range(seasons, msg="Choose season(s)")
41
+ if not selected:
42
+ return True
43
+ episodes = []
44
+ for season in selected:
45
+ with console.status("Loading episodes: " + season.name):
46
+ available = await season.episodes()
47
+ if len(selected) == 1:
48
+ console.print(season.series_name + " - " + season.name, markup=False)
49
+ episodes = cli_utils.select_range(
50
+ available, msg="Choose episode(s)", print_choices=True
51
+ )
52
+ else:
53
+ episodes.extend(available)
54
+ if not episodes:
55
+ return True
56
+ if config.download:
57
+ return await download_episodes(episodes, config, await catalogue.year())
58
+
59
+
60
+ def main():
61
+ install_clean_prompts()
62
+ logging.basicConfig(
63
+ level=logging.INFO, format="%(message)s", handlers=[RichHandler()]
64
+ )
65
+ logging.getLogger("httpx").setLevel(logging.WARNING)
66
+ try:
67
+ return 0 if asyncio.run(async_main()) else 1
68
+ except (KeyboardInterrupt, asyncio.CancelledError, EOFError):
69
+ console.print("Interrupted.", style="red", markup=False)
70
+ return 130
71
+ except RateLimitedError as exc:
72
+ console.print("Server rate limit: " + str(exc), style="red", markup=False)
73
+ return 2
74
+ except (LinkError, EpisodeDataUnavailableError, HTTPError, URLError) as exc:
75
+ console.print("Error: " + str(exc), style="red", markup=False)
76
+ return 1
77
+
78
+
79
+ if __name__ == "__main__":
80
+ raise SystemExit(main())
@@ -0,0 +1,119 @@
1
+ # TODO: Allway hard to read but I can't find a better way to do it
2
+ import logging
3
+ import tomllib
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ # import platformdirs for config_dir and download_dir
8
+ from platformdirs import user_config_dir, user_downloads_dir
9
+
10
+ from anime_sama_api.langs import Lang, lang2ids
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class PlayersConfig:
17
+ prefers: list[str] = field(default_factory=list)
18
+ bans: list[str] = field(default_factory=list)
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Config:
23
+ prefer_languages: list[Lang]
24
+ download_path: Path
25
+ episode_path: str
26
+ download: bool
27
+ show_players: bool
28
+ max_retry_time: int
29
+ format: str
30
+ format_sort: str
31
+ internal_player_command: list[str]
32
+ url: str
33
+ provider_url: str
34
+ players_config: PlayersConfig
35
+ concurrent_downloads: dict[str, int]
36
+
37
+
38
+ def load_config() -> Config:
39
+ # 1. Load default config from the package
40
+ default_config_file = Path(__file__).parent / "config.toml"
41
+ if not default_config_file.exists():
42
+ raise FileNotFoundError(
43
+ f"The default config.toml could not be found at {default_config_file}"
44
+ )
45
+
46
+ with open(default_config_file, "rb") as f:
47
+ config_dict = tomllib.load(f)
48
+
49
+ # 2. Determine user config path
50
+ # Order of priority:
51
+ # - current directory (./config.toml)
52
+ # - system config directory (e.g. ~/AppData/Local/anime-sama_api/config.toml)
53
+ local_config = Path("config.toml")
54
+ system_config_dir = Path(user_config_dir("anime-sama_api", appauthor=False))
55
+ system_config_file = system_config_dir / "config.toml"
56
+
57
+ user_config_data = {}
58
+ if local_config.exists():
59
+ with open(local_config, "rb") as f:
60
+ user_config_data = tomllib.load(f)
61
+ elif system_config_file.exists():
62
+ with open(system_config_file, "rb") as f:
63
+ user_config_data = tomllib.load(f)
64
+ else:
65
+ # Recreate default config if not found anywhere
66
+ from shutil import copy
67
+
68
+ system_config_dir.mkdir(parents=True, exist_ok=True)
69
+ copy(default_config_file, system_config_file)
70
+ logger.info("Default config created at %s", system_config_file)
71
+
72
+ # 3. Merge configs (user values override defaults)
73
+ config_dict.update(user_config_data)
74
+
75
+ # 4. Backward compatibility and data cleaning
76
+ # Languages conversion
77
+ new_langs: list[Lang] = []
78
+ for lang in config_dict.get("prefer_languages", []):
79
+ if lang == "VO":
80
+ lang = "VOSTFR"
81
+ if lang in lang2ids:
82
+ new_langs.append(lang)
83
+ else:
84
+ logger.warning("'%s' is not a valid language, ignoring it.", lang)
85
+ config_dict["prefer_languages"] = new_langs
86
+
87
+ # Path detection and expansion
88
+ raw_path = config_dict.get("download_path")
89
+ if not raw_path:
90
+ # Default to system Downloads / Anime-Sama if not specified
91
+ config_dict["download_path"] = Path(user_downloads_dir()) / "Anime-Sama"
92
+ else:
93
+ config_dict["download_path"] = Path(raw_path).expanduser()
94
+
95
+ # Internal player command string to list
96
+ player_cmd = config_dict.get("internal_player_command")
97
+ if isinstance(player_cmd, str):
98
+ config_dict["internal_player_command"] = player_cmd.split()
99
+ elif player_cmd is None:
100
+ config_dict["internal_player_command"] = []
101
+
102
+ # Players config mapping
103
+ players_data = config_dict.pop("players_hostname", {})
104
+ if "players" in config_dict: # Old key removal
105
+ del config_dict["players"]
106
+ config_dict["players_config"] = PlayersConfig(**players_data)
107
+
108
+ # Spelling
109
+ config_dict["episode_path"] = config_dict["episode_path"].replace(
110
+ "{serie}", "{series}"
111
+ )
112
+
113
+ # Ensure all required keys are present for Config dataclass
114
+ # (Checking against keys in Config.__annotations__ if necessary)
115
+ return Config(**config_dict)
116
+
117
+
118
+ # Exported config instance
119
+ config = load_config()
@@ -0,0 +1,35 @@
1
+ # Languages at the start of the list are preferred over the ones that come after
2
+ # Possible values: "VASTFR", "VCN", "VF", "VJSTFR", "VKR", "VQC", "VOSTFR"
3
+ prefer_languages = ["VOSTFR"]
4
+
5
+ # Where to put the downloads
6
+ download_path = "~/Downloads/Anime-Sama"
7
+ # How to organize the downloads (you can use {series} {season} {episode} {release_year_parentheses})
8
+ episode_path = "{series}/{season}/{episode}"
9
+
10
+ # If it downloads or plays the selected episodes
11
+ download = true
12
+ show_players = false
13
+ max_retry_time = 1024
14
+
15
+ # See https://github.com/yt-dlp/yt-dlp#format-selection
16
+ # For example, if you want the lowest size, put "+size,+br,+res,+fps" in format_sort
17
+ format = "all"
18
+ format_sort = ""
19
+
20
+ # What application to use to play
21
+ internal_player_command = "mpv"
22
+
23
+ # url of anime-sama (You shouldn't touch that)
24
+ url = ""
25
+ provider_url = "https://anime-sama.pw/"
26
+
27
+ [concurrent_downloads]
28
+ # how many fragment of a video to download at once
29
+ fragment = 3
30
+ # how many video to download at once
31
+ video = 5
32
+
33
+ [players_hostname]
34
+ prefers = []
35
+ bans = []