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.
Files changed (42) hide show
  1. anime_sama_api/__init__.py +49 -0
  2. anime_sama_api/assets/ascii_art +6 -0
  3. anime_sama_api/catalogue.py +157 -0
  4. anime_sama_api/cli/__main__.py +96 -0
  5. anime_sama_api/cli/config.py +119 -0
  6. anime_sama_api/cli/config.toml +35 -0
  7. anime_sama_api/cli/downloader.py +217 -0
  8. anime_sama_api/cli/episode_extra_info.py +136 -0
  9. anime_sama_api/cli/episode_tree.py +345 -0
  10. anime_sama_api/cli/error_handeling.py +79 -0
  11. anime_sama_api/cli/internal_player.py +56 -0
  12. anime_sama_api/cli/play_menu.py +30 -0
  13. anime_sama_api/cli/utils.py +98 -0
  14. anime_sama_api/cli_standalone.py +20 -0
  15. anime_sama_api/episode.py +143 -0
  16. anime_sama_api/langs.py +76 -0
  17. anime_sama_api/season.py +227 -0
  18. anime_sama_api/standalone/__init__.py +8 -0
  19. anime_sama_api/standalone/anilist.py +308 -0
  20. anime_sama_api/standalone/api_helpers.py +43 -0
  21. anime_sama_api/standalone/catalogue_tui.py +90 -0
  22. anime_sama_api/standalone/completions.py +109 -0
  23. anime_sama_api/standalone/config.py +87 -0
  24. anime_sama_api/standalone/constants.py +47 -0
  25. anime_sama_api/standalone/download_utils.py +69 -0
  26. anime_sama_api/standalone/flows.py +361 -0
  27. anime_sama_api/standalone/fzf_utils.py +337 -0
  28. anime_sama_api/standalone/history.py +39 -0
  29. anime_sama_api/standalone/menus.py +404 -0
  30. anime_sama_api/standalone/planning.py +351 -0
  31. anime_sama_api/standalone/planning_tui.py +95 -0
  32. anime_sama_api/standalone/playback.py +81 -0
  33. anime_sama_api/standalone/runner.py +175 -0
  34. anime_sama_api/standalone/system_deps.py +75 -0
  35. anime_sama_api/standalone/terminal.py +100 -0
  36. anime_sama_api/top_level.py +353 -0
  37. anime_sama_api/utils.py +58 -0
  38. anime_sama_cli-2.0.0.dist-info/METADATA +254 -0
  39. anime_sama_cli-2.0.0.dist-info/RECORD +42 -0
  40. anime_sama_cli-2.0.0.dist-info/WHEEL +4 -0
  41. anime_sama_cli-2.0.0.dist-info/entry_points.txt +3 -0
  42. 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
+ )