anime-sama-cli 2.0.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.
- anime_sama_api/__init__.py +49 -0
- anime_sama_api/assets/ascii_art +6 -0
- anime_sama_api/catalogue.py +157 -0
- anime_sama_api/cli/__main__.py +96 -0
- anime_sama_api/cli/config.py +119 -0
- anime_sama_api/cli/config.toml +35 -0
- anime_sama_api/cli/downloader.py +217 -0
- anime_sama_api/cli/episode_extra_info.py +136 -0
- anime_sama_api/cli/episode_tree.py +345 -0
- anime_sama_api/cli/error_handeling.py +79 -0
- anime_sama_api/cli/internal_player.py +56 -0
- anime_sama_api/cli/play_menu.py +30 -0
- anime_sama_api/cli/utils.py +98 -0
- anime_sama_api/cli_standalone.py +20 -0
- anime_sama_api/episode.py +143 -0
- anime_sama_api/langs.py +76 -0
- anime_sama_api/season.py +227 -0
- anime_sama_api/standalone/__init__.py +8 -0
- anime_sama_api/standalone/anilist.py +308 -0
- anime_sama_api/standalone/api_helpers.py +43 -0
- anime_sama_api/standalone/catalogue_tui.py +90 -0
- anime_sama_api/standalone/completions.py +109 -0
- anime_sama_api/standalone/config.py +87 -0
- anime_sama_api/standalone/constants.py +47 -0
- anime_sama_api/standalone/download_utils.py +69 -0
- anime_sama_api/standalone/flows.py +361 -0
- anime_sama_api/standalone/fzf_utils.py +337 -0
- anime_sama_api/standalone/history.py +39 -0
- anime_sama_api/standalone/menus.py +404 -0
- anime_sama_api/standalone/planning.py +351 -0
- anime_sama_api/standalone/planning_tui.py +95 -0
- anime_sama_api/standalone/playback.py +81 -0
- anime_sama_api/standalone/runner.py +175 -0
- anime_sama_api/standalone/system_deps.py +75 -0
- anime_sama_api/standalone/terminal.py +100 -0
- anime_sama_api/top_level.py +353 -0
- anime_sama_api/utils.py +58 -0
- anime_sama_cli-2.0.0.dist-info/METADATA +254 -0
- anime_sama_cli-2.0.0.dist-info/RECORD +42 -0
- anime_sama_cli-2.0.0.dist-info/WHEEL +4 -0
- anime_sama_cli-2.0.0.dist-info/entry_points.txt +3 -0
- anime_sama_cli-2.0.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,49 @@
|
|
|
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, PlanningDay, PlanningEntry
|
|
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
|
+
"PlanningDay",
|
|
30
|
+
"PlanningEntry",
|
|
31
|
+
"Season",
|
|
32
|
+
"Players",
|
|
33
|
+
"Languages",
|
|
34
|
+
"Episode",
|
|
35
|
+
"Lang",
|
|
36
|
+
"LangId",
|
|
37
|
+
"lang2ids",
|
|
38
|
+
"id2lang",
|
|
39
|
+
"flags",
|
|
40
|
+
"download",
|
|
41
|
+
"multi_download",
|
|
42
|
+
"main",
|
|
43
|
+
"find_site_url",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
"""__locals = locals()
|
|
47
|
+
for __name in __all__:
|
|
48
|
+
if not __name.startswith("__"):
|
|
49
|
+
setattr(__locals[__name], "__module__", "anime-sama_api") # noqa"""
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
█████╗ ███╗ ██╗██╗███╗ ███╗███████╗ ███████╗ █████╗ ███╗ ███╗ █████╗
|
|
2
|
+
██╔══██╗████╗ ██║██║████╗ ████║██╔════╝ ██╔════╝██╔══██╗████╗ ████║██╔══██╗
|
|
3
|
+
███████║██╔██╗ ██║██║██╔████╔██║█████╗█████╗███████╗███████║██╔████╔██║███████║
|
|
4
|
+
██╔══██║██║╚██╗██║██║██║╚██╔╝██║██╔══╝╚════╝╚════██║██╔══██║██║╚██╔╝██║██╔══██║
|
|
5
|
+
██║ ██║██║ ╚████║██║██║ ╚═╝ ██║███████╗ ███████║██║ ██║██║ ╚═╝ ██║██║ ██║
|
|
6
|
+
╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚═╝╚══════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from typing import Any, Literal, cast
|
|
4
|
+
|
|
5
|
+
from httpx import AsyncClient
|
|
6
|
+
|
|
7
|
+
from .langs import Lang, flags
|
|
8
|
+
from .season import Season
|
|
9
|
+
from .utils import remove_some_js_comments
|
|
10
|
+
|
|
11
|
+
# Oversight from anime-sama that we should handle
|
|
12
|
+
# 'Animes' instead of 'Anime' seen in Cyberpunk: Edgerunners and Valkyrie Apocalypse
|
|
13
|
+
# 'Autre' instead of 'Autres' seen in Hazbin Hotel
|
|
14
|
+
# 'Scans' is in the language section for Watamote (harder to handle)
|
|
15
|
+
Category = Literal["Anime", "Scans", "Film", "Autres"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Catalogue:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
url: str,
|
|
22
|
+
name: str = "",
|
|
23
|
+
alternative_names: Sequence[str] | None = None,
|
|
24
|
+
genres: Sequence[str] | None = None,
|
|
25
|
+
categories: set[Category] | None = None,
|
|
26
|
+
languages: set[Lang] | None = None,
|
|
27
|
+
image_url: str = "",
|
|
28
|
+
client: AsyncClient | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
if alternative_names is None:
|
|
31
|
+
alternative_names = []
|
|
32
|
+
if genres is None:
|
|
33
|
+
genres = []
|
|
34
|
+
if categories is None:
|
|
35
|
+
categories = set()
|
|
36
|
+
if languages is None:
|
|
37
|
+
languages = set()
|
|
38
|
+
|
|
39
|
+
self.url = url + "/" if url[-1] != "/" else url
|
|
40
|
+
self.site_url = "/".join(url.split("/")[:3]) + "/"
|
|
41
|
+
self.client = client or AsyncClient()
|
|
42
|
+
|
|
43
|
+
self.name = name or url.split("/")[-2]
|
|
44
|
+
|
|
45
|
+
self._page: str | None = None
|
|
46
|
+
self.alternative_names = alternative_names
|
|
47
|
+
self.genres = genres
|
|
48
|
+
self.categories = categories
|
|
49
|
+
self.languages = languages
|
|
50
|
+
self.image_url = image_url
|
|
51
|
+
|
|
52
|
+
async def page(self) -> str:
|
|
53
|
+
if self._page is not None:
|
|
54
|
+
return self._page
|
|
55
|
+
|
|
56
|
+
response = await self.client.get(self.url)
|
|
57
|
+
|
|
58
|
+
if response.is_error:
|
|
59
|
+
self._page = ""
|
|
60
|
+
else:
|
|
61
|
+
self._page = response.text
|
|
62
|
+
|
|
63
|
+
return self._page
|
|
64
|
+
|
|
65
|
+
async def seasons(self) -> list[Season]:
|
|
66
|
+
page_without_comments = remove_some_js_comments(string=await self.page())
|
|
67
|
+
|
|
68
|
+
# Insensible à la casse pour VOSTFR/Vf (ex. Berserk, pages avec VOSTFR en majuscules)
|
|
69
|
+
seasons = re.findall(
|
|
70
|
+
r'panneauAnime\("(.+?)", *"(.+?)(?:vostfr|vf)"\);',
|
|
71
|
+
page_without_comments,
|
|
72
|
+
re.IGNORECASE,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
seasons = [
|
|
76
|
+
Season(
|
|
77
|
+
url=self.url + link,
|
|
78
|
+
name=name,
|
|
79
|
+
serie_name=self.name,
|
|
80
|
+
client=self.client,
|
|
81
|
+
)
|
|
82
|
+
for name, link in seasons
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
return seasons
|
|
86
|
+
|
|
87
|
+
async def advancement(self) -> str:
|
|
88
|
+
search = cast(list[str], re.findall(r"Actualité.+?>(.+?)<", await self.page()))
|
|
89
|
+
|
|
90
|
+
if not search:
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
return search[0]
|
|
94
|
+
|
|
95
|
+
async def correspondence(self) -> str:
|
|
96
|
+
search = cast(
|
|
97
|
+
list[str], re.findall(r"Correspondance.+?>(.+?)<", await self.page())
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if not search:
|
|
101
|
+
return ""
|
|
102
|
+
|
|
103
|
+
return search[0]
|
|
104
|
+
|
|
105
|
+
async def synopsis(self) -> str:
|
|
106
|
+
search = cast(
|
|
107
|
+
list[str], re.findall(r"Synopsis[\W\w]+?>(.+)<", await self.page())
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if not search:
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
return search[0]
|
|
114
|
+
|
|
115
|
+
async def is_mature(self) -> bool:
|
|
116
|
+
"""Return True if the catalogue contain a warning about adult content"""
|
|
117
|
+
return (
|
|
118
|
+
re.search(
|
|
119
|
+
r'<div class=".*?yellow.*?">[\W\w]+?public averti', await self.page()
|
|
120
|
+
)
|
|
121
|
+
is not None
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def is_anime(self) -> bool:
|
|
126
|
+
return "Anime" in self.categories
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def is_manga(self) -> bool:
|
|
130
|
+
return "Scans" in self.categories
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def is_film(self) -> bool:
|
|
134
|
+
return "Film" in self.categories
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def is_other(self) -> bool:
|
|
138
|
+
return "Autres" in self.categories
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def fancy_name(self) -> str:
|
|
142
|
+
names = [""] + list(self.alternative_names) if self.alternative_names else []
|
|
143
|
+
return f"{self.name}[bright_black]{' - '.join(names)} {' '.join(flags[lang] for lang in self.languages if lang != 'VOSTFR')}"
|
|
144
|
+
|
|
145
|
+
def __repr__(self) -> str:
|
|
146
|
+
return f"Catalogue({self.url!r}, {self.name!r})"
|
|
147
|
+
|
|
148
|
+
def __str__(self) -> str:
|
|
149
|
+
return self.fancy_name
|
|
150
|
+
|
|
151
|
+
def __eq__(self, value: Any) -> bool:
|
|
152
|
+
if not isinstance(value, Catalogue):
|
|
153
|
+
return False
|
|
154
|
+
return self.url == value.url
|
|
155
|
+
|
|
156
|
+
def __hash__(self) -> int:
|
|
157
|
+
return hash(self.url + self.name + "".join(self.alternative_names))
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from urllib.error import URLError
|
|
4
|
+
|
|
5
|
+
from httpx import AsyncClient
|
|
6
|
+
from rich import get_console
|
|
7
|
+
from rich.logging import RichHandler
|
|
8
|
+
from rich.status import Status
|
|
9
|
+
|
|
10
|
+
from anime_sama_api.cli import downloader, internal_player
|
|
11
|
+
from anime_sama_api.cli.config import config
|
|
12
|
+
from anime_sama_api.cli.episode_extra_info import convert_with_extra_info
|
|
13
|
+
from anime_sama_api.cli.episode_tree import run_episode_tree
|
|
14
|
+
from anime_sama_api.cli.utils import safe_input, select_one, select_range
|
|
15
|
+
from anime_sama_api.top_level import AnimeSama, find_site_url
|
|
16
|
+
|
|
17
|
+
console = get_console()
|
|
18
|
+
console._highlight = False
|
|
19
|
+
logging.basicConfig(format="%(message)s", datefmt="[%X]", handlers=[RichHandler()])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def spinner(text: str) -> Status:
|
|
23
|
+
return console.status(text, spinner_style="cyan")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def async_main() -> None:
|
|
27
|
+
query = safe_input("Anime name: \033[0;34m", str)
|
|
28
|
+
|
|
29
|
+
client = AsyncClient()
|
|
30
|
+
url = config.url or await find_site_url(client, config.provider_url)
|
|
31
|
+
|
|
32
|
+
if url is None:
|
|
33
|
+
raise URLError("Failed to get AnimeSama url")
|
|
34
|
+
|
|
35
|
+
with spinner(f"Searching for [blue]{query}"):
|
|
36
|
+
catalogues = await AnimeSama(url, client).search(query)
|
|
37
|
+
catalogue = select_one(catalogues)
|
|
38
|
+
|
|
39
|
+
with spinner(f"Getting season list for [blue]{catalogue.name}"):
|
|
40
|
+
seasons_list = await catalogue.seasons()
|
|
41
|
+
|
|
42
|
+
selected_seasons = select_range(seasons_list, msg="Choose season(s)")
|
|
43
|
+
|
|
44
|
+
if len(selected_seasons) > 1:
|
|
45
|
+
# Multi-season automatic download
|
|
46
|
+
selected_episodes = []
|
|
47
|
+
for s in selected_seasons:
|
|
48
|
+
with spinner(f"Getting episode list for [blue]{s.name}"):
|
|
49
|
+
episodes = await s.episodes()
|
|
50
|
+
selected_episodes.extend(episodes)
|
|
51
|
+
else:
|
|
52
|
+
# Single season: affichage des épisodes en Tree (Textual)
|
|
53
|
+
season = selected_seasons[0]
|
|
54
|
+
with spinner(f"Getting episode list for [blue]{season.name}"):
|
|
55
|
+
episodes = await season.episodes()
|
|
56
|
+
|
|
57
|
+
# Tree : Espace = sélection (parent = tout, enfants = épisodes), Tab = déplier/replier, Entrée = valider
|
|
58
|
+
selected_episodes = run_episode_tree(season, episodes)
|
|
59
|
+
if not selected_episodes:
|
|
60
|
+
console.print("\n[red]Aucun épisode sélectionné.")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if config.download:
|
|
64
|
+
downloader.multi_download(
|
|
65
|
+
[
|
|
66
|
+
convert_with_extra_info(episode, catalogue)
|
|
67
|
+
for episode in selected_episodes
|
|
68
|
+
],
|
|
69
|
+
config.download_path,
|
|
70
|
+
config.episode_path,
|
|
71
|
+
config.concurrent_downloads,
|
|
72
|
+
config.prefer_languages,
|
|
73
|
+
config.players_config,
|
|
74
|
+
config.max_retry_time,
|
|
75
|
+
config.format,
|
|
76
|
+
config.format_sort,
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
command = internal_player.play_episode(
|
|
80
|
+
selected_episodes[0], config.prefer_languages
|
|
81
|
+
)
|
|
82
|
+
if command is not None:
|
|
83
|
+
command.wait()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> int:
|
|
87
|
+
try:
|
|
88
|
+
asyncio.run(async_main())
|
|
89
|
+
except (KeyboardInterrupt, asyncio.exceptions.CancelledError, EOFError):
|
|
90
|
+
console.print("\n[red]Exiting...")
|
|
91
|
+
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
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 sys
|
|
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
|
+
if sys.version_info >= (3, 11):
|
|
13
|
+
import tomllib
|
|
14
|
+
else:
|
|
15
|
+
import tomli as tomllib
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class PlayersConfig:
|
|
22
|
+
prefers: list[str] = field(default_factory=list)
|
|
23
|
+
bans: list[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Config:
|
|
28
|
+
prefer_languages: list[Lang]
|
|
29
|
+
download_path: Path
|
|
30
|
+
episode_path: str
|
|
31
|
+
download: bool
|
|
32
|
+
show_players: bool
|
|
33
|
+
max_retry_time: int
|
|
34
|
+
format: str
|
|
35
|
+
format_sort: str
|
|
36
|
+
internal_player_command: list[str]
|
|
37
|
+
url: str
|
|
38
|
+
provider_url: str
|
|
39
|
+
players_config: PlayersConfig
|
|
40
|
+
concurrent_downloads: dict[str, int]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_config() -> Config:
|
|
44
|
+
# 1. Load default config from the package
|
|
45
|
+
default_config_file = Path(__file__).parent / "config.toml"
|
|
46
|
+
if not default_config_file.exists():
|
|
47
|
+
raise FileNotFoundError(
|
|
48
|
+
f"The default config.toml could not be found at {default_config_file}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
with open(default_config_file, "rb") as f:
|
|
52
|
+
config_dict = tomllib.load(f)
|
|
53
|
+
|
|
54
|
+
# 2. Determine user config path
|
|
55
|
+
# Order of priority:
|
|
56
|
+
# - current directory (./config.toml)
|
|
57
|
+
# - system config directory (e.g. ~/AppData/Local/anime-sama_api/config.toml)
|
|
58
|
+
local_config = Path("config.toml")
|
|
59
|
+
system_config_dir = Path(user_config_dir("anime-sama_api", appauthor=False))
|
|
60
|
+
system_config_file = system_config_dir / "config.toml"
|
|
61
|
+
|
|
62
|
+
user_config_data = {}
|
|
63
|
+
if local_config.exists():
|
|
64
|
+
with open(local_config, "rb") as f:
|
|
65
|
+
user_config_data = tomllib.load(f)
|
|
66
|
+
elif system_config_file.exists():
|
|
67
|
+
with open(system_config_file, "rb") as f:
|
|
68
|
+
user_config_data = tomllib.load(f)
|
|
69
|
+
else:
|
|
70
|
+
# Recreate default config if not found anywhere
|
|
71
|
+
from shutil import copy
|
|
72
|
+
|
|
73
|
+
system_config_dir.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
copy(default_config_file, system_config_file)
|
|
75
|
+
logger.info("Default config created at %s", system_config_file)
|
|
76
|
+
|
|
77
|
+
# 3. Merge configs (user values override defaults)
|
|
78
|
+
config_dict.update(user_config_data)
|
|
79
|
+
|
|
80
|
+
# 4. Backward compatibility and data cleaning
|
|
81
|
+
# Languages conversion
|
|
82
|
+
new_langs: list[Lang] = []
|
|
83
|
+
for lang in config_dict.get("prefer_languages", []):
|
|
84
|
+
if lang == "VO":
|
|
85
|
+
lang = "VOSTFR"
|
|
86
|
+
if lang in lang2ids:
|
|
87
|
+
new_langs.append(lang)
|
|
88
|
+
else:
|
|
89
|
+
logger.warning("« %s » n'est pas une langue valide, ignorée.", lang)
|
|
90
|
+
config_dict["prefer_languages"] = new_langs
|
|
91
|
+
|
|
92
|
+
# Path detection and expansion
|
|
93
|
+
raw_path = config_dict.get("download_path")
|
|
94
|
+
if not raw_path:
|
|
95
|
+
# Default to system Downloads / Anime-Sama if not specified
|
|
96
|
+
config_dict["download_path"] = Path(user_downloads_dir()) / "Anime-Sama"
|
|
97
|
+
else:
|
|
98
|
+
config_dict["download_path"] = Path(raw_path).expanduser()
|
|
99
|
+
|
|
100
|
+
# Internal player command string to list
|
|
101
|
+
player_cmd = config_dict.get("internal_player_command")
|
|
102
|
+
if isinstance(player_cmd, str):
|
|
103
|
+
config_dict["internal_player_command"] = player_cmd.split()
|
|
104
|
+
elif player_cmd is None:
|
|
105
|
+
config_dict["internal_player_command"] = []
|
|
106
|
+
|
|
107
|
+
# Players config mapping
|
|
108
|
+
players_data = config_dict.pop("players_hostname", {})
|
|
109
|
+
if "players" in config_dict: # Old key removal
|
|
110
|
+
del config_dict["players"]
|
|
111
|
+
config_dict["players_config"] = PlayersConfig(**players_data)
|
|
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 {serie} {season} {episode} {release_year_parentheses})
|
|
8
|
+
episode_path = "{serie}/{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 exemple, 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 = []
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import random
|
|
3
|
+
import time
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import cast
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from rich import get_console
|
|
10
|
+
from rich.console import Group
|
|
11
|
+
from rich.live import Live
|
|
12
|
+
from rich.progress import (
|
|
13
|
+
BarColumn,
|
|
14
|
+
MofNCompleteColumn,
|
|
15
|
+
Progress,
|
|
16
|
+
ProgressColumn,
|
|
17
|
+
TaskID,
|
|
18
|
+
TextColumn,
|
|
19
|
+
TimeRemainingColumn,
|
|
20
|
+
TotalFileSizeColumn,
|
|
21
|
+
TransferSpeedColumn,
|
|
22
|
+
)
|
|
23
|
+
from rich.table import Column
|
|
24
|
+
from yt_dlp import YoutubeDL
|
|
25
|
+
from yt_dlp.utils import DownloadError
|
|
26
|
+
|
|
27
|
+
from anime_sama_api.langs import Lang
|
|
28
|
+
from anime_sama_api.cli.config import PlayersConfig, config
|
|
29
|
+
from anime_sama_api.cli.episode_extra_info import EpisodeWithExtraInfo
|
|
30
|
+
from anime_sama_api.cli.error_handeling import YDL_log_filter, reaction_to
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
logger.addFilter(YDL_log_filter)
|
|
34
|
+
|
|
35
|
+
console = get_console()
|
|
36
|
+
download_progress_list: list[str | ProgressColumn] = [
|
|
37
|
+
"[bold blue]{task.fields[episode_name]}",
|
|
38
|
+
BarColumn(bar_width=None),
|
|
39
|
+
"[progress.percentage]{task.percentage:>3.1f}%", # TODO: should disappear if the console is not wide enough
|
|
40
|
+
TransferSpeedColumn(),
|
|
41
|
+
TotalFileSizeColumn(),
|
|
42
|
+
TimeRemainingColumn(compact=True, elapsed_when_finished=True),
|
|
43
|
+
]
|
|
44
|
+
if config.show_players:
|
|
45
|
+
download_progress_list.insert(
|
|
46
|
+
1,
|
|
47
|
+
TextColumn(
|
|
48
|
+
"[green]{task.fields[site]}",
|
|
49
|
+
table_column=Column(max_width=12),
|
|
50
|
+
justify="right",
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
download_progress = Progress(*download_progress_list, console=console)
|
|
55
|
+
|
|
56
|
+
total_progress = Progress(
|
|
57
|
+
TextColumn("[bold cyan]{task.description}"),
|
|
58
|
+
BarColumn(bar_width=None),
|
|
59
|
+
MofNCompleteColumn(),
|
|
60
|
+
TimeRemainingColumn(elapsed_when_finished=True),
|
|
61
|
+
console=console,
|
|
62
|
+
)
|
|
63
|
+
progress = Group(total_progress, download_progress)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def download(
|
|
67
|
+
episode: EpisodeWithExtraInfo,
|
|
68
|
+
path: Path,
|
|
69
|
+
episode_path: str = "{episode}",
|
|
70
|
+
prefer_languages: list[Lang] | None = None,
|
|
71
|
+
players_config: PlayersConfig | None = None,
|
|
72
|
+
concurrent_fragment_downloads: int = 3,
|
|
73
|
+
max_retry_time: int = 1024,
|
|
74
|
+
video_format: str = "",
|
|
75
|
+
format_sort: str = "",
|
|
76
|
+
) -> None:
|
|
77
|
+
if prefer_languages is None:
|
|
78
|
+
prefer_languages = ["VOSTFR"]
|
|
79
|
+
if players_config is None:
|
|
80
|
+
players_config = PlayersConfig([], [])
|
|
81
|
+
|
|
82
|
+
if not any(episode.warpped.languages.values()):
|
|
83
|
+
logger.error("No player available")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
me = download_progress.add_task(
|
|
87
|
+
"download", episode_name=episode.warpped.name, site="", total=None
|
|
88
|
+
)
|
|
89
|
+
task = next(t for t in download_progress.tasks if t.id == me)
|
|
90
|
+
|
|
91
|
+
full_path = (
|
|
92
|
+
path
|
|
93
|
+
/ episode_path.format(
|
|
94
|
+
serie=episode.warpped.serie_name,
|
|
95
|
+
season=episode.warpped.season_name,
|
|
96
|
+
episode=episode.warpped.name,
|
|
97
|
+
release_year_parentheses=episode.release_year_parentheses(),
|
|
98
|
+
)
|
|
99
|
+
).expanduser()
|
|
100
|
+
|
|
101
|
+
def hook(data: dict) -> None:
|
|
102
|
+
if data.get("status") != "downloading":
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# Directly accessing .total is needed to not reset the speed
|
|
106
|
+
task.total = data.get("total_bytes") or data.get("total_bytes_estimate")
|
|
107
|
+
download_progress.update(me, completed=data.get("downloaded_bytes", 0))
|
|
108
|
+
|
|
109
|
+
option = {
|
|
110
|
+
"outtmpl": f"{full_path}.%(ext)s",
|
|
111
|
+
"concurrent_fragment_downloads": concurrent_fragment_downloads,
|
|
112
|
+
"progress_hooks": [hook],
|
|
113
|
+
"logger": logger,
|
|
114
|
+
"format": video_format,
|
|
115
|
+
"format_sort": format_sort.split(","),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for player in episode.warpped.consume_player(
|
|
119
|
+
prefer_languages, players_config.prefers, players_config.bans
|
|
120
|
+
):
|
|
121
|
+
retry_time = 1
|
|
122
|
+
sucess = False
|
|
123
|
+
download_progress.update(me, site=urlparse(player).hostname)
|
|
124
|
+
|
|
125
|
+
while True:
|
|
126
|
+
try:
|
|
127
|
+
with YoutubeDL(option) as ydl: # type: ignore
|
|
128
|
+
error_code = cast(int, ydl.download([player]))
|
|
129
|
+
|
|
130
|
+
if not error_code:
|
|
131
|
+
sucess = True
|
|
132
|
+
else:
|
|
133
|
+
logger.fatal(
|
|
134
|
+
"The download encountered an error code %s. Please report this to the developer with URL: %s",
|
|
135
|
+
error_code,
|
|
136
|
+
player,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
except DownloadError as exception:
|
|
142
|
+
match reaction_to(exception.msg):
|
|
143
|
+
case "continue":
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
case "retry":
|
|
147
|
+
if retry_time >= max_retry_time:
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
logger.warning(
|
|
151
|
+
"« %s » interrompu (%s). Nouvelle tentative dans %s s.",
|
|
152
|
+
episode.warpped.name,
|
|
153
|
+
exception.msg,
|
|
154
|
+
retry_time,
|
|
155
|
+
)
|
|
156
|
+
# random is used to spread the resume time and so mitigate deadlock when multiple downloads resume at the same time
|
|
157
|
+
time.sleep(retry_time * random.uniform(0.8, 1.2))
|
|
158
|
+
retry_time *= 2
|
|
159
|
+
|
|
160
|
+
case "crash":
|
|
161
|
+
raise exception
|
|
162
|
+
|
|
163
|
+
case "":
|
|
164
|
+
logger.debug(
|
|
165
|
+
"Download failed (trying next player): %s - URL: %s",
|
|
166
|
+
exception.msg,
|
|
167
|
+
player,
|
|
168
|
+
)
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
if sucess:
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
download_progress.update(me, visible=False)
|
|
175
|
+
if total_progress.tasks:
|
|
176
|
+
total_progress.update(TaskID(0), advance=1)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def multi_download(
|
|
180
|
+
episodes: list[EpisodeWithExtraInfo],
|
|
181
|
+
path: Path,
|
|
182
|
+
episode_path: str = "{episode}",
|
|
183
|
+
concurrent_downloads: dict[str, int] | None = None,
|
|
184
|
+
prefer_languages: list[Lang] | None = None,
|
|
185
|
+
players_config: PlayersConfig | None = None,
|
|
186
|
+
max_retry_time: int = 1024,
|
|
187
|
+
video_format: str = "",
|
|
188
|
+
format_sort: str = "",
|
|
189
|
+
) -> None:
|
|
190
|
+
if concurrent_downloads is None:
|
|
191
|
+
concurrent_downloads = {}
|
|
192
|
+
if prefer_languages is None:
|
|
193
|
+
prefer_languages = ["VOSTFR"]
|
|
194
|
+
if players_config is None:
|
|
195
|
+
players_config = PlayersConfig([], [])
|
|
196
|
+
|
|
197
|
+
"""
|
|
198
|
+
Not sure if you can use this function multiple times
|
|
199
|
+
"""
|
|
200
|
+
total_progress.add_task("Downloaded", total=len(episodes))
|
|
201
|
+
with Live(progress, console=console):
|
|
202
|
+
with ThreadPoolExecutor(
|
|
203
|
+
max_workers=concurrent_downloads.get("video", 1)
|
|
204
|
+
) as executor:
|
|
205
|
+
for episode in episodes:
|
|
206
|
+
executor.submit(
|
|
207
|
+
download,
|
|
208
|
+
episode,
|
|
209
|
+
path,
|
|
210
|
+
episode_path,
|
|
211
|
+
prefer_languages,
|
|
212
|
+
players_config,
|
|
213
|
+
concurrent_downloads.get("fragment", 1),
|
|
214
|
+
max_retry_time,
|
|
215
|
+
video_format,
|
|
216
|
+
format_sort,
|
|
217
|
+
)
|