anime-sama-api 0.1.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,47 @@
1
+ from .top_level import AnimeSama
2
+ from .catalogue import Catalogue
3
+ from .season import Season
4
+ from .episode import Episode, Languages, Players
5
+ from .langs import Lang, LangId, lang2ids, id2lang, lang_ids, flags
6
+
7
+ try:
8
+ from .cli.__main__ import main
9
+ from .cli.downloader import download, multi_download
10
+ except ImportError:
11
+ import sys
12
+
13
+ def main() -> int:
14
+ print(
15
+ "This anime-sama_api function could not run because the required "
16
+ "dependencies were not installed.\nMake sure you've installed "
17
+ "everything with: pip install 'anime-sama_api[cli]'"
18
+ )
19
+
20
+ sys.exit(1)
21
+
22
+ download = multi_download = main # type: ignore
23
+
24
+
25
+ # __package__ = "anime-sama_api"
26
+ __all__ = [
27
+ "AnimeSama",
28
+ "Catalogue",
29
+ "Season",
30
+ "Players",
31
+ "Languages",
32
+ "Episode",
33
+ "Lang",
34
+ "LangId",
35
+ "lang2ids",
36
+ "id2lang",
37
+ "lang_ids",
38
+ "flags",
39
+ "download",
40
+ "multi_download",
41
+ "main",
42
+ ]
43
+
44
+ """__locals = locals()
45
+ for __name in __all__:
46
+ if not __name.startswith("__"):
47
+ setattr(__locals[__name], "__module__", "anime-sama_api") # noqa"""
@@ -0,0 +1,69 @@
1
+ import re
2
+
3
+ from httpx import AsyncClient
4
+
5
+ from .season import Season
6
+
7
+
8
+ class Catalogue:
9
+ def __init__(self, url: str, name="", client: AsyncClient | None = None) -> None:
10
+ self.url = url + "/" if url[-1] != "/" else url
11
+ self.name = name or url.split("/")[-2]
12
+ self.site_url = "/".join(url.split("/")[:3]) + "/"
13
+ self.client = client or AsyncClient()
14
+ self._page = None
15
+
16
+ async def page(self) -> str:
17
+ if self._page is not None:
18
+ return self._page
19
+
20
+ response = await self.client.get(self.url)
21
+
22
+ if not response.is_success:
23
+ self._page = ""
24
+ else:
25
+ self._page = response.text
26
+
27
+ return self._page
28
+
29
+ async def seasons(self) -> list[Season]:
30
+ seasons = re.findall(
31
+ r'panneauAnime\("(.+?)", *"(.+?)(?:vostfr|vf)"\);', await self.page()
32
+ )
33
+
34
+ seasons = [
35
+ Season(
36
+ url=self.url + link,
37
+ name=name,
38
+ serie_name=self.name,
39
+ client=self.client,
40
+ )
41
+ for name, link in seasons
42
+ ]
43
+
44
+ return seasons
45
+
46
+ async def advancement(self) -> str:
47
+ search = re.findall(r"Avancement.+?>(.+?)<", await self.page())
48
+
49
+ if not search:
50
+ return ""
51
+
52
+ return search[0]
53
+
54
+ async def correspondance(self):
55
+ search = re.findall(r"Correspondance.+?>(.+?)<", await self.page())
56
+
57
+ if not search:
58
+ return ""
59
+
60
+ return search[0]
61
+
62
+ def __repr__(self):
63
+ return f"Catalogue({self.url!r}, {self.name!r})"
64
+
65
+ def __str__(self):
66
+ return self.name
67
+
68
+ def __eq__(self, value):
69
+ return self.url == value.url
@@ -0,0 +1,67 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ import httpx
5
+ from rich import get_console
6
+ from rich.logging import RichHandler
7
+
8
+ from . import downloader, internal_player
9
+ from .config import config
10
+ from .utils import safe_input, select_one, select_range
11
+
12
+ from ..top_level import AnimeSama
13
+
14
+ console = get_console()
15
+ console._highlight = False
16
+ logging.basicConfig(format="%(message)s", datefmt="[%X]", handlers=[RichHandler()])
17
+
18
+
19
+ def spinner(text: str):
20
+ return console.status(text, spinner_style="cyan")
21
+
22
+
23
+ async def async_main():
24
+ query = safe_input("Anime name: \033[0;34m", str)
25
+
26
+ with spinner(f"Searching for [blue]{query}"):
27
+ catalogues = await AnimeSama(config.url, httpx.AsyncClient()).search(query)
28
+ catalogue = select_one(catalogues)
29
+
30
+ with spinner(f"Getting season list for [blue]{catalogue.name}"):
31
+ seasons = await catalogue.seasons()
32
+ season = select_one(seasons)
33
+
34
+ with spinner(f"Getting episode list for [blue]{season.name}"):
35
+ episodes = await season.episodes()
36
+
37
+ console.print(f"\n[cyan bold underline]{season.serie_name} - {season.name}")
38
+ selected_episodes = select_range(
39
+ episodes, msg="Choose episode(s)", print_choices=True
40
+ )
41
+
42
+ if config.download:
43
+ downloader.multi_download(
44
+ selected_episodes,
45
+ config.download_path,
46
+ config.concurrent_downloads,
47
+ config.prefer_languages,
48
+ )
49
+ else:
50
+ command = internal_player.play_episode(
51
+ selected_episodes[0], config.prefer_languages
52
+ )
53
+ if command is not None:
54
+ command.wait()
55
+
56
+
57
+ def main() -> int:
58
+ try:
59
+ asyncio.run(async_main())
60
+ except (KeyboardInterrupt, asyncio.exceptions.CancelledError, EOFError):
61
+ console.print("\n[red]Exiting...")
62
+
63
+ return 0
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
@@ -0,0 +1,82 @@
1
+ # TODO: Code hard to read / violent error, need refactor
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from dataclasses import dataclass
6
+ import os
7
+ import sys
8
+
9
+ from anime_sama_api.langs import Lang, lang2ids
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else:
14
+ import tomli as tomllib
15
+
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class PlayersConfig:
22
+ prefers: list[str]
23
+ bans: list[str]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Config:
28
+ prefer_languages: list[Lang]
29
+ download_path: Path
30
+ download: bool
31
+ internal_player_command: list[str]
32
+ url: str
33
+ players: PlayersConfig
34
+ concurrent_downloads: dict[str, int]
35
+
36
+
37
+ # Load default config
38
+ exemple_config = Path(__file__).parent / "config.toml"
39
+ if not exemple_config.exists():
40
+ raise FileNotFoundError(
41
+ "There is an issues with the installation of the package.\nThe exemple config cannot be found."
42
+ )
43
+ with open(exemple_config, "rb") as file:
44
+ default_config = tomllib.load(file)
45
+
46
+
47
+ # Load user config
48
+ possible_path = ["."]
49
+ possible_path.append(
50
+ "~/AppData/Local/anime-sama_api" if os.name == "nt" else "~/.config/anime-sama_cli"
51
+ )
52
+ possible_path = [Path(path).expanduser() for path in possible_path]
53
+
54
+ user_config = {}
55
+ for path in possible_path:
56
+ config_file = path / "config.toml"
57
+ if config_file.is_file():
58
+ with open(config_file, "rb") as config_file:
59
+ user_config = tomllib.load(config_file)
60
+ break
61
+ else:
62
+ from shutil import copy
63
+
64
+ possible_path[1].mkdir(parents=True, exist_ok=True)
65
+ copy(exemple_config, possible_path[1])
66
+ logger.info("Default config created at %s", possible_path[1])
67
+
68
+
69
+ # Update the default values by values set by the user
70
+ config = default_config | user_config
71
+
72
+ # Check if value respect the type
73
+ for lang in config["prefer_languages"]:
74
+ assert (
75
+ lang in lang2ids
76
+ ), f"{lang} is not a valid languages for prefer_languages\nOnly the following are acceptable: {list(lang2ids.keys())}"
77
+
78
+ # Convert type
79
+ config["download_path"] = Path(config["download_path"])
80
+ config["internal_player_command"] = config["internal_player_command"].split()
81
+ config["players"] = PlayersConfig(**config["players"])
82
+ config = Config(**config)
@@ -0,0 +1,26 @@
1
+ # Languages at the start of the list are preferred over the ones that come after
2
+ # Possible values: "VA", "VCN", "VF", "VJ", "VKR", "VQC", "VO"
3
+ prefer_languages = ["VO"]
4
+
5
+ # Where to put the downloads (an additional folder is created for every series)
6
+ download_path = "~/Downloads/Anime-Sama"
7
+
8
+ # If it downloads or plays the selected episodes
9
+ download = true
10
+ # What application to use to play
11
+ internal_player_command = "mpv"
12
+
13
+ # url of anime-sama (You shouldn't touch that)
14
+ url = "https://anime-sama.fr/"
15
+
16
+ [players]
17
+ # player to use if multiple are available (first in the list are prefer over the afters)
18
+ prefers = []
19
+ # player to not use (even if that the only player)
20
+ bans = []
21
+
22
+ [concurrent_downloads]
23
+ # how many fragment of a video to download at once
24
+ fragment = 3
25
+ # how many video to download at once
26
+ video = 5
@@ -0,0 +1,152 @@
1
+ import time
2
+ import logging
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from pathlib import Path
5
+
6
+ from yt_dlp import YoutubeDL, DownloadError
7
+ from rich import print, get_console
8
+ from rich.live import Live
9
+ from rich.console import Group
10
+ from rich.progress import (
11
+ BarColumn,
12
+ DownloadColumn,
13
+ Progress,
14
+ TextColumn,
15
+ TimeRemainingColumn,
16
+ TransferSpeedColumn,
17
+ MofNCompleteColumn,
18
+ TaskID,
19
+ )
20
+
21
+ from .error_handeling import YDL_log_filter, reaction_to
22
+ from ..episode import Episode
23
+ from ..langs import Lang
24
+
25
+
26
+ logger = logging.getLogger(__name__)
27
+ logger.addFilter(YDL_log_filter)
28
+
29
+ console = get_console()
30
+ download_progress = Progress(
31
+ TextColumn("[bold blue]{task.fields[episode_name]}", justify="right"),
32
+ BarColumn(bar_width=None),
33
+ "[progress.percentage]{task.percentage:>3.1f}%",
34
+ "•",
35
+ DownloadColumn(),
36
+ "•",
37
+ TransferSpeedColumn(),
38
+ "•",
39
+ TimeRemainingColumn(),
40
+ console=console,
41
+ )
42
+ total_progress = Progress(
43
+ TextColumn("[bold cyan]{task.description}"),
44
+ BarColumn(bar_width=None),
45
+ MofNCompleteColumn(),
46
+ TimeRemainingColumn(),
47
+ console=console,
48
+ )
49
+ progress = Group(total_progress, download_progress)
50
+
51
+
52
+ def download(
53
+ episode: Episode,
54
+ path: Path,
55
+ prefer_languages: list[Lang] = ["VO"],
56
+ concurrent_fragment_downloads=3,
57
+ ):
58
+ if not any(episode.languages.values()):
59
+ print("[red]No player available")
60
+ return
61
+
62
+ me = download_progress.add_task("download", episode_name=episode.name, total=None)
63
+ task = download_progress.tasks[me]
64
+
65
+ full_path = (
66
+ path / episode.serie_name / episode.season_name / episode.name
67
+ ).expanduser()
68
+
69
+ def hook(data: dict):
70
+ if data.get("status") != "downloading":
71
+ return
72
+
73
+ task.total = data.get("total_bytes") or data.get("total_bytes_estimate")
74
+ download_progress.update(me, completed=data.get("downloaded_bytes", 0))
75
+
76
+ option = {
77
+ "outtmpl": {"default": f"{full_path}.%(ext)s"},
78
+ "concurrent_fragment_downloads": concurrent_fragment_downloads,
79
+ "progress_hooks": [hook],
80
+ "logger": logger,
81
+ }
82
+
83
+ for player in episode.consume_player(prefer_languages):
84
+ retry_time = 1
85
+ sucess = False
86
+
87
+ while True:
88
+ try:
89
+ with YoutubeDL(option) as ydl: # type: ignore
90
+ error_code: int = ydl.download([player]) # type: ignore
91
+
92
+ if not error_code:
93
+ sucess = True
94
+ else:
95
+ logger.fatal(
96
+ "The download error with the code %s. Please report this to the developper.",
97
+ error_code,
98
+ )
99
+
100
+ break
101
+
102
+ except DownloadError as execption:
103
+ match reaction_to(execption.msg):
104
+ case "continue":
105
+ break
106
+
107
+ case "retry":
108
+ logger.warning(
109
+ "Download interrupted. Retrying in %ss.", retry_time
110
+ )
111
+ time.sleep(retry_time)
112
+ retry_time *= 2
113
+
114
+ case "crash":
115
+ raise execption
116
+
117
+ case "":
118
+ logger.fatal(
119
+ "The above error wasn't handle. Please report it to the developper."
120
+ )
121
+ break
122
+
123
+ if sucess:
124
+ break
125
+
126
+ download_progress.update(me, visible=False)
127
+ if total_progress.tasks:
128
+ total_progress.update(TaskID(0), advance=1)
129
+
130
+
131
+ def multi_download(
132
+ episodes: list[Episode],
133
+ path: Path,
134
+ concurrent_downloads={},
135
+ prefer_languages: list[Lang] = ["VO"],
136
+ ):
137
+ """
138
+ Not sure if you can use this function multiple times
139
+ """
140
+ total_progress.add_task("Downloaded", total=len(episodes))
141
+ with Live(progress, console=console):
142
+ with ThreadPoolExecutor(
143
+ max_workers=concurrent_downloads.get("video", 1)
144
+ ) as executor:
145
+ for episode in episodes:
146
+ executor.submit(
147
+ download,
148
+ episode,
149
+ path,
150
+ prefer_languages,
151
+ concurrent_downloads.get("fragment", 1),
152
+ )
@@ -0,0 +1,42 @@
1
+ from logging import LogRecord
2
+ from typing import Literal
3
+
4
+ Reaction = Literal["continue", "retry", "crash", ""]
5
+
6
+ how_to_react: dict[Reaction, tuple[str, ...]] = {
7
+ "continue": (
8
+ "[Errno 61] Connection refused",
9
+ "Remote end closed connection without response",
10
+ "HTTPError 404: Not Found",
11
+ "Unsupported URL:",
12
+ "The read operation timed out",
13
+ ),
14
+ "retry": ("TransportError('timed out')",),
15
+ "crash": (),
16
+ }
17
+
18
+
19
+ def reaction_to(msg: str) -> Reaction:
20
+ for reaction, error_msgs in how_to_react.items():
21
+ for error_msg in error_msgs:
22
+ if error_msg in msg:
23
+ return reaction
24
+ return ""
25
+
26
+
27
+ def is_error_handle(msg: str):
28
+ return bool(reaction_to(msg))
29
+
30
+
31
+ def YDL_log_filter(record: LogRecord):
32
+ if record.filename != "YoutubeDL.py":
33
+ return True
34
+
35
+ match record.levelname:
36
+ case "WARNING":
37
+ if "Falling back on generic information extractor" in record.msg:
38
+ return False
39
+ case "ERROR":
40
+ return not is_error_handle(record.msg)
41
+ case _:
42
+ return True
@@ -0,0 +1,58 @@
1
+ import os
2
+ import sys
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from rich import print
7
+
8
+ from anime_sama_api.langs import Lang
9
+
10
+ from .config import config
11
+ from ..episode import Episode
12
+
13
+
14
+ def open_silent_process(command: list[str]) -> subprocess.Popen:
15
+ try:
16
+
17
+ if os.name in ("nt", "dos"):
18
+ return subprocess.Popen(command)
19
+ else:
20
+ return subprocess.Popen(
21
+ command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
22
+ )
23
+
24
+ except FileNotFoundError as e:
25
+ print(f"[red]Error: {e}")
26
+ sys.exit(404)
27
+
28
+
29
+ def play_episode(
30
+ episode: Episode,
31
+ prefer_languages: list[Lang],
32
+ args: list[str] | None = None,
33
+ ) -> subprocess.Popen | None:
34
+ best = episode.best(prefer_languages)
35
+ if best is None:
36
+ print("[red]No player available")
37
+ return None
38
+
39
+ player_command = config.internal_player_command + [best]
40
+ if args is not None:
41
+ player_command += args
42
+
43
+ # Check by the caller
44
+ # if isinstance(self._sub_proc, sp.Popen):
45
+ # self.kill_player()
46
+
47
+ return open_silent_process(player_command)
48
+
49
+ # self._write_hist(entry)
50
+ # self._start_dc_presence(entry)
51
+
52
+
53
+ def play_file(path: Path, args: list[str] | None = None) -> subprocess.Popen:
54
+ player_command = config.internal_player_command + [str(path)]
55
+ if args is not None:
56
+ player_command += args
57
+
58
+ return open_silent_process(player_command)
@@ -0,0 +1,30 @@
1
+ from ..episode import Episode
2
+
3
+
4
+ class EpisodesManager:
5
+ def __init__(self, episodes: list[Episode], current_index=0) -> None:
6
+ self.episodes = episodes
7
+ self.current_index = current_index
8
+
9
+ def __next__(self):
10
+ if self.current_index < len(self.episodes) - 1:
11
+ self.current_index += 1
12
+ return self.episodes[self.current_index]
13
+
14
+ raise StopIteration
15
+
16
+ def previous(self):
17
+ if self.current_index > 0:
18
+ self.current_index -= 1
19
+ return self.episodes[self.current_index]
20
+
21
+ raise StopIteration
22
+
23
+ @property
24
+ def current(self):
25
+ return self.episodes[self.current_index]
26
+
27
+
28
+ class PlayMenu:
29
+ def print_menu(self):
30
+ pass
@@ -0,0 +1,73 @@
1
+ import sys
2
+ from collections.abc import Callable
3
+ from typing import TypeVar
4
+
5
+ from rich import print as print_func
6
+
7
+ input_func = input
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ def safe_input(
13
+ text: str, transform: Callable[[str], T], exceptions=(ValueError, IndexError)
14
+ ) -> T:
15
+ while True:
16
+ try:
17
+ print_func(text, end="")
18
+ output = input_func()
19
+ return transform(output)
20
+ except exceptions:
21
+ pass
22
+
23
+
24
+ def print_selection(choices: list, print_choices=True) -> None:
25
+ if len(choices) == 0:
26
+ print_func("[red]No result")
27
+ sys.exit(404)
28
+ if len(choices) == 1:
29
+ print_func(f"-> [blue]{choices[0]}")
30
+ return
31
+ if not print_choices:
32
+ return
33
+
34
+ for index, choice in enumerate(choices, start=1):
35
+ line_colors = "yellow" if index % 2 == 0 else "white"
36
+ print_func(
37
+ f"[green][{index:{len(str(len(choices)))}}]",
38
+ f"[{line_colors}]{choice}",
39
+ )
40
+
41
+
42
+ def select_one(choices: list[T], msg="Choose a number", **_) -> T:
43
+ print_selection(choices)
44
+ if len(choices) == 1:
45
+ return choices[0]
46
+
47
+ return safe_input(f"{msg}: \033[0;34m", lambda string: choices[int(string) - 1])
48
+
49
+
50
+ def select_range(choices: list[T], msg="Choose a range", print_choices=True) -> list[T]:
51
+ print_selection(choices, print_choices)
52
+
53
+ if len(choices) == 1:
54
+ return [choices[0]]
55
+
56
+ def transform(string: str) -> list[T]:
57
+ ints_set = set()
58
+ for args in string.split(","):
59
+ ints = [int(num) for num in args.split("-")]
60
+
61
+ if len(ints) == 1:
62
+ ints_set.add(ints[0])
63
+ elif len(ints) == 2:
64
+ ints_set.update(range(ints[0], ints[1] + 1))
65
+ else:
66
+ raise ValueError
67
+
68
+ return [choices[i - 1] for i in ints_set]
69
+
70
+ return safe_input(
71
+ f"{msg} [green][1-{len(choices)}][/]: \033[0;34m",
72
+ transform,
73
+ )