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,323 @@
1
+ import asyncio
2
+ import logging
3
+ import random
4
+ import threading
5
+ import time
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from contextlib import suppress
8
+ from pathlib import Path
9
+ from typing import cast
10
+ from urllib.parse import urlparse
11
+
12
+ from rich import get_console
13
+ from rich.console import Group
14
+ from rich.live import Live
15
+ from rich.progress import (
16
+ BarColumn,
17
+ MofNCompleteColumn,
18
+ Progress,
19
+ ProgressColumn,
20
+ TextColumn,
21
+ TimeRemainingColumn,
22
+ TotalFileSizeColumn,
23
+ TransferSpeedColumn,
24
+ )
25
+ from rich.table import Column
26
+ from yt_dlp import YoutubeDL
27
+ from yt_dlp.utils import DownloadError
28
+
29
+ from anime_sama_api.cli.config import PlayersConfig, config
30
+ from anime_sama_api.cli.error_handling import YDL_log_filter, reaction_to
31
+ from anime_sama_api.episode import (
32
+ DeferredPlayers,
33
+ Episode,
34
+ iter_resolved_players,
35
+ resolve_reference,
36
+ )
37
+ from anime_sama_api.langs import Lang
38
+
39
+ from ..link_resolver import RateLimitedError
40
+
41
+ logger = logging.getLogger(__name__)
42
+ logger.addFilter(YDL_log_filter)
43
+ console = get_console()
44
+ download_progress_list: list[str | ProgressColumn] = [
45
+ "[bold blue]{task.fields[episode_name]}",
46
+ BarColumn(bar_width=None),
47
+ "[progress.percentage]{task.percentage:>3.1f}%",
48
+ TransferSpeedColumn(),
49
+ TotalFileSizeColumn(),
50
+ TimeRemainingColumn(compact=True, elapsed_when_finished=True),
51
+ ]
52
+ if config.show_players:
53
+ download_progress_list.insert(
54
+ 1,
55
+ TextColumn(
56
+ "[green]{task.fields[site]}",
57
+ table_column=Column(max_width=12),
58
+ justify="right",
59
+ ),
60
+ )
61
+ download_progress = Progress(*download_progress_list, console=console)
62
+ total_progress = Progress(
63
+ TextColumn("[bold cyan]{task.description}"),
64
+ BarColumn(bar_width=None),
65
+ MofNCompleteColumn(),
66
+ TimeRemainingColumn(elapsed_when_finished=True),
67
+ console=console,
68
+ )
69
+ progress = Group(total_progress, download_progress)
70
+
71
+
72
+ def download(
73
+ episode: Episode,
74
+ path: Path,
75
+ episode_path: str = "{episode}",
76
+ prefer_languages: list[Lang] | None = None,
77
+ players_config: PlayersConfig | None = None,
78
+ concurrent_fragment_downloads: int = 3,
79
+ max_retry_time: int = 1024,
80
+ video_format: str = "",
81
+ format_sort: str = "",
82
+ ep_year: int | None = None,
83
+ *,
84
+ _total_task_id=None,
85
+ ) -> bool:
86
+ if prefer_languages is None:
87
+ prefer_languages = ["VOSTFR"]
88
+ if players_config is None:
89
+ players_config = PlayersConfig([], [])
90
+ if not any(episode.languages.values()):
91
+ logger.error("No player available")
92
+ if _total_task_id is not None:
93
+ total_progress.update(_total_task_id, advance=1)
94
+ return False
95
+ me = download_progress.add_task(
96
+ "download", episode_name=episode.name, site="", total=None
97
+ )
98
+ task = next(t for t in download_progress.tasks if t.id == me)
99
+ try:
100
+ full_path = (
101
+ path
102
+ / episode_path.format(
103
+ series=episode.series_name,
104
+ season=episode.season_name,
105
+ episode=episode.name,
106
+ release_year_parentheses="" if ep_year is None else f" {ep_year}",
107
+ )
108
+ ).expanduser()
109
+
110
+ def hook(data: dict) -> None:
111
+ if data.get("status") != "downloading":
112
+ return
113
+ task.total = data.get("total_bytes") or data.get("total_bytes_estimate")
114
+ download_progress.start_task(me)
115
+ download_progress.update(me, completed=data.get("downloaded_bytes", 0))
116
+
117
+ option = {
118
+ "outtmpl": f"{full_path}.%(ext)s",
119
+ "concurrent_fragment_downloads": concurrent_fragment_downloads,
120
+ "progress_hooks": [hook],
121
+ "logger": logger,
122
+ "format": video_format,
123
+ "format_sort": format_sort.split(","),
124
+ "continuedl": False,
125
+ }
126
+ success = False
127
+ for player in episode.consume_player(
128
+ prefer_languages, players_config.prefers, players_config.bans
129
+ ):
130
+ download_progress.reset(
131
+ me,
132
+ start=False,
133
+ completed=0,
134
+ episode_name=episode.name,
135
+ site=urlparse(player).hostname,
136
+ )
137
+ task.total = None
138
+ retry_time = 1
139
+ success = False
140
+ download_progress.update(me, site=urlparse(player).hostname)
141
+ while True:
142
+ try:
143
+ with YoutubeDL(option) as ydl:
144
+ error_code = cast(int, ydl.download([player]))
145
+ if not error_code:
146
+ success = True
147
+ else:
148
+ logger.fatal(
149
+ "The download encountered an error code %s. Please report this to the developer with URL: %s",
150
+ error_code,
151
+ player,
152
+ )
153
+ break
154
+ except DownloadError as exception:
155
+ match reaction_to(exception.msg):
156
+ case "continue":
157
+ break
158
+ case "retry":
159
+ if retry_time >= max_retry_time:
160
+ break
161
+ logger.warning(
162
+ "%s interrupted (%s). Retrying in %ss.",
163
+ episode.name,
164
+ exception.msg,
165
+ retry_time,
166
+ )
167
+ time.sleep(retry_time * random.uniform(0.8, 1.2))
168
+ retry_time *= 2
169
+ case "crash":
170
+ raise
171
+ case "":
172
+ logger.fatal(
173
+ "The above error wasn't handle. Please report it to the developer with URL: %s",
174
+ player,
175
+ )
176
+ break
177
+ if success:
178
+ break
179
+ return success
180
+ finally:
181
+ download_progress.update(me, visible=False)
182
+ if _total_task_id is not None:
183
+ total_progress.update(_total_task_id, advance=1)
184
+
185
+
186
+ def multi_download(
187
+ episodes,
188
+ path,
189
+ episode_path="{episode}",
190
+ concurrent_downloads=None,
191
+ prefer_languages=None,
192
+ players_config=None,
193
+ max_retry_time=1024,
194
+ video_format="",
195
+ format_sort="",
196
+ eps_years=None,
197
+ ) -> list[bool]:
198
+ if not episodes:
199
+ return []
200
+ concurrent_downloads = concurrent_downloads or {}
201
+ if prefer_languages is None:
202
+ prefer_languages = ["VOSTFR"]
203
+ if players_config is None:
204
+ players_config = PlayersConfig([], [])
205
+ years = list(eps_years or [])
206
+ years += [None] * max(0, len(episodes) - len(years))
207
+ batch = total_progress.add_task("Épisodes traités", total=len(episodes))
208
+ with (
209
+ Live(progress, console=console),
210
+ ThreadPoolExecutor(
211
+ max_workers=max(1, concurrent_downloads.get("video", 1))
212
+ ) as executor,
213
+ ):
214
+ futures = [
215
+ executor.submit(
216
+ download,
217
+ episode,
218
+ path,
219
+ episode_path,
220
+ prefer_languages,
221
+ players_config,
222
+ concurrent_downloads.get("fragment", 1),
223
+ max_retry_time,
224
+ video_format,
225
+ format_sort,
226
+ ep_year=year,
227
+ _total_task_id=batch,
228
+ )
229
+ for episode, year in zip(episodes, years)
230
+ ]
231
+ return [future.result() for future in futures]
232
+
233
+
234
+ class LazyEpisode:
235
+ def __init__(self, episode, loop, resolve, stopped):
236
+ self._episode = episode
237
+ self._loop = loop
238
+ self._resolve = resolve
239
+ self._stopped = stopped
240
+ self.streams = []
241
+
242
+ def __getattr__(self, name):
243
+ return getattr(self._episode, name)
244
+
245
+ def consume_player(self, prefer_languages, prefer_players=None, ban_players=None):
246
+ stream = iter_resolved_players(
247
+ self._episode, prefer_languages, ban_players or (), self._resolve
248
+ )
249
+ self.streams.append(stream)
250
+
251
+ async def next_url():
252
+ if self._stopped.is_set():
253
+ raise StopAsyncIteration
254
+ return await anext(stream)
255
+
256
+ while not self._stopped.is_set():
257
+ future = asyncio.run_coroutine_threadsafe(next_url(), self._loop)
258
+ try:
259
+ url = future.result()
260
+ except StopAsyncIteration:
261
+ return
262
+ except RateLimitedError:
263
+ self._stopped.set()
264
+ raise
265
+ if self._stopped.is_set():
266
+ return
267
+ yield url
268
+
269
+
270
+ async def download_episodes(episodes, config, year=None):
271
+ episodes = list(episodes)
272
+ if not episodes:
273
+ return True
274
+ loop = asyncio.get_running_loop()
275
+ stopped = threading.Event()
276
+ api_slots = asyncio.Semaphore(3)
277
+ locks = {}
278
+
279
+ async def resolve(players, reference):
280
+ async with api_slots:
281
+ if stopped.is_set():
282
+ return ""
283
+ if not isinstance(players, DeferredPlayers):
284
+ return await resolve_reference(players, reference)
285
+ lock = locks.setdefault(id(players.resolver), asyncio.Lock())
286
+ async with lock:
287
+ if stopped.is_set():
288
+ return ""
289
+ try:
290
+ return await resolve_reference(players, reference)
291
+ except RateLimitedError:
292
+ stopped.set()
293
+ raise
294
+
295
+ proxies = [LazyEpisode(ep, loop, resolve, stopped) for ep in episodes]
296
+
297
+ def run():
298
+ return multi_download(
299
+ proxies,
300
+ config.download_path,
301
+ config.episode_path,
302
+ config.concurrent_downloads,
303
+ config.prefer_languages,
304
+ config.players_config,
305
+ config.max_retry_time,
306
+ config.format,
307
+ config.format_sort,
308
+ [year] * len(proxies),
309
+ )
310
+
311
+ worker = asyncio.create_task(asyncio.to_thread(run))
312
+ try:
313
+ results = await asyncio.shield(worker)
314
+ return all(results)
315
+ except asyncio.CancelledError:
316
+ stopped.set()
317
+ with suppress(Exception):
318
+ await asyncio.shield(worker)
319
+ raise
320
+ finally:
321
+ for proxy in proxies:
322
+ for stream in proxy.streams:
323
+ await stream.aclose()
@@ -0,0 +1,66 @@
1
+ from collections.abc import Sequence
2
+ from logging import LogRecord
3
+ from typing import Literal
4
+
5
+ Reaction = Literal["continue", "retry", "crash", ""]
6
+
7
+ how_to_react: dict[Reaction, Sequence[str]] = {
8
+ "continue": (
9
+ "[Errno 61] Connection refused",
10
+ "Remote end closed connection without response",
11
+ "HTTPError 404: Not Found",
12
+ "Unsupported URL",
13
+ "[Errno 7] No address associated with hostname",
14
+ "[Errno 11002] getaddrinfo failed",
15
+ ),
16
+ "retry": (
17
+ "Unable to download webpage: HTTP Error 522",
18
+ "unable to download video data: HTTP Error 416",
19
+ "HTTPError 500: Internal Server Error",
20
+ "The read operation timed out",
21
+ "Waiting for vidmoly", # Custom error msg to tell the downloader to retry
22
+ "TransportError('timed out')",
23
+ "[Errno 54] Connection reset by peer",
24
+ "[Errno 104] Connection reset by peer",
25
+ "HTTPError 503: Service Temporarily Unavailable",
26
+ "[Errno 32] Broken pipe",
27
+ "[Errno 101] Network is unreachable",
28
+ "[Errno 51] Network is unreachable",
29
+ "[Errno 2] No such file or directory",
30
+ ),
31
+ "crash": (),
32
+ }
33
+
34
+
35
+ def reaction_to(msg: str | None) -> Reaction:
36
+ if msg is None:
37
+ return "continue"
38
+
39
+ for reaction, error_msgs in how_to_react.items():
40
+ for error_msg in error_msgs:
41
+ if error_msg in msg:
42
+ return reaction
43
+ return ""
44
+
45
+
46
+ def is_error_handle(msg: str) -> bool:
47
+ return bool(reaction_to(msg))
48
+
49
+
50
+ def YDL_log_filter(record: LogRecord) -> bool:
51
+ if record.filename != "YoutubeDL.py":
52
+ return True
53
+
54
+ match record.levelname:
55
+ case "WARNING":
56
+ return all(
57
+ msg not in record.msg
58
+ for msg in (
59
+ "Falling back on generic information extractor",
60
+ "Live HLS streams are not supported by the native downloader.",
61
+ )
62
+ )
63
+ case "ERROR":
64
+ return not is_error_handle(record.msg)
65
+ case _:
66
+ return True
@@ -0,0 +1,56 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from rich import print
7
+
8
+ from anime_sama_api.cli.config import config
9
+ from anime_sama_api.episode import Episode
10
+ from anime_sama_api.langs import Lang
11
+
12
+
13
+ def open_silent_process(command: list[str]) -> subprocess.Popen[bytes]:
14
+ try:
15
+ if os.name in ("nt", "dos"):
16
+ return subprocess.Popen(command)
17
+ else:
18
+ return subprocess.Popen(
19
+ command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
20
+ )
21
+
22
+ except FileNotFoundError as e:
23
+ print(f"[red]Error: {e}")
24
+ sys.exit(404)
25
+
26
+
27
+ def play_episode(
28
+ episode: Episode,
29
+ prefer_languages: list[Lang],
30
+ args: list[str] | None = None,
31
+ ) -> subprocess.Popen[bytes] | None:
32
+ best = episode.best(prefer_languages)
33
+ if best is None:
34
+ print("[red]No player available")
35
+ return None
36
+
37
+ player_command = config.internal_player_command + [best]
38
+ if args is not None:
39
+ player_command += args
40
+
41
+ # Check by the caller
42
+ # if isinstance(self._sub_proc, sp.Popen):
43
+ # self.kill_player()
44
+
45
+ return open_silent_process(player_command)
46
+
47
+ # self._write_hist(entry)
48
+ # self._start_dc_presence(entry)
49
+
50
+
51
+ def play_file(path: Path, args: list[str] | None = None) -> subprocess.Popen[bytes]:
52
+ player_command = config.internal_player_command + [str(path)]
53
+ if args is not None:
54
+ player_command += args
55
+
56
+ return open_silent_process(player_command)
@@ -0,0 +1,61 @@
1
+ import asyncio
2
+
3
+ from anime_sama_api.episode import Episode, iter_resolved_players
4
+
5
+ from .config import config
6
+
7
+
8
+ class EpisodesManager:
9
+ def __init__(self, episodes: list[Episode], current_index: int = 0) -> None:
10
+ self.episodes = episodes
11
+ self.current_index = current_index
12
+
13
+ def __next__(self) -> Episode:
14
+ if self.current_index < len(self.episodes) - 1:
15
+ self.current_index += 1
16
+ return self.episodes[self.current_index]
17
+
18
+ raise StopIteration
19
+
20
+ def previous(self) -> Episode:
21
+ if self.current_index > 0:
22
+ self.current_index -= 1
23
+ return self.episodes[self.current_index]
24
+
25
+ raise StopIteration
26
+
27
+ @property
28
+ def current(self) -> Episode:
29
+ return self.episodes[self.current_index]
30
+
31
+
32
+ class PlayMenu:
33
+ def print_menu(self) -> None:
34
+ pass
35
+
36
+
37
+ async def play_episode(episode):
38
+ from . import internal_player
39
+
40
+ async for url in iter_resolved_players(
41
+ episode, config.prefer_languages, config.players_config.bans
42
+ ):
43
+
44
+ class PlayerEpisode:
45
+ def __getattr__(self, name):
46
+ return getattr(episode, name)
47
+
48
+ def best(self, prefer_languages, url=url):
49
+ return url
50
+
51
+ def consume_player(self, *args, url=url, **kwargs):
52
+ yield url
53
+
54
+ command = internal_player.play_episode(PlayerEpisode(), config.prefer_languages)
55
+ if command is not None and await asyncio.to_thread(command.wait) == 0:
56
+ return True
57
+ return False
58
+
59
+
60
+ async def async_main(episodes: list[Episode]):
61
+ return await play_episode(episodes[0])
@@ -0,0 +1,142 @@
1
+ import ctypes
2
+ import os
3
+ import re
4
+ import sys
5
+ from collections.abc import Callable
6
+ from ctypes import wintypes
7
+ from typing import Any, TypeVar
8
+
9
+ from rich import print as print_func
10
+
11
+ input_func = input
12
+ T = TypeVar("T")
13
+
14
+
15
+ def safe_input(
16
+ text: str,
17
+ transform: Callable[[str], T],
18
+ exceptions: tuple[type[Exception], ...] = (ValueError, IndexError),
19
+ ) -> T:
20
+ while True:
21
+ try:
22
+ print_func(text, end="")
23
+ output = input_func()
24
+ return transform(output)
25
+ except exceptions:
26
+ pass
27
+
28
+
29
+ def print_selection(
30
+ choices: list[Any], print_choices: bool = True, exit: bool = True
31
+ ) -> None:
32
+ if len(choices) == 0:
33
+ print_func("[red]No result")
34
+ if exit:
35
+ sys.exit(404)
36
+ return
37
+ if len(choices) == 1:
38
+ print_func(f"-> [blue]{choices[0]}")
39
+ return
40
+ if not print_choices:
41
+ return
42
+ for index, choice in enumerate(choices, start=1):
43
+ line_colors = "yellow" if index % 2 == 0 else "white"
44
+ print_func(
45
+ f"[green][{index:{len(str(len(choices)))}}]", f"[{line_colors}]{choice}"
46
+ )
47
+
48
+
49
+ def select_one(
50
+ choices: list[T], msg: str = "Choose a number", **_: dict[Any, Any]
51
+ ) -> T:
52
+ print_selection(choices)
53
+ if len(choices) == 1:
54
+ return choices[0]
55
+ return safe_input(
56
+ f"[white]{msg}[/white]: \x1b[0;34m", lambda string: choices[int(string) - 1]
57
+ )
58
+
59
+
60
+ def select_range(
61
+ choices: list[T], msg: str = "Choose a range", print_choices: bool = True
62
+ ) -> list[T]:
63
+ print_selection(choices, print_choices)
64
+ if len(choices) == 1:
65
+ return [choices[0]]
66
+
67
+ def transform(string: str) -> list[T]:
68
+ if string in ("*", "all"):
69
+ return choices
70
+ ints_set: set[int] = set()
71
+ for args in string.split(","):
72
+ ints = [int(num) if num.strip() else None for num in args.split("-")]
73
+ if len(ints) == 1 and ints[0] is not None:
74
+ ints_set.add(ints[0])
75
+ elif len(ints) == 2:
76
+ if ints[0] is None:
77
+ ints[0] = 1
78
+ if ints[1] is None:
79
+ ints[1] = len(choices)
80
+ ints_set.update(range(ints[0], ints[1] + 1))
81
+ else:
82
+ raise ValueError
83
+ return [choices[i - 1] for i in ints_set]
84
+
85
+ return safe_input(
86
+ f"[white]{msg}[/white] [green][1-{len(choices)}][/]: \x1b[0;34m", transform
87
+ )
88
+
89
+
90
+ def normalize(title: str) -> str:
91
+ return re.sub("[^\\w\\s]", "", title.lower().strip())
92
+
93
+
94
+ ANSI_ESCAPE = re.compile("\\x1b\\[[0-?]*[ -/]*[@-~]")
95
+
96
+
97
+ def enable_terminal_colors():
98
+ """Activer les séquences ANSI sous Windows si possible."""
99
+ if not sys.stdout.isatty():
100
+ return False
101
+ if os.name != "nt":
102
+ return True
103
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
104
+ kernel32.GetStdHandle.argtypes = [wintypes.DWORD]
105
+ kernel32.GetStdHandle.restype = wintypes.HANDLE
106
+ kernel32.GetConsoleMode.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
107
+ kernel32.GetConsoleMode.restype = wintypes.BOOL
108
+ kernel32.SetConsoleMode.argtypes = [wintypes.HANDLE, wintypes.DWORD]
109
+ kernel32.SetConsoleMode.restype = wintypes.BOOL
110
+ stdout_handle = kernel32.GetStdHandle(wintypes.DWORD(-11 & 4294967295))
111
+ mode = wintypes.DWORD()
112
+ if not kernel32.GetConsoleMode(stdout_handle, ctypes.byref(mode)):
113
+ return False
114
+ return bool(kernel32.SetConsoleMode(stdout_handle, mode.value | 4))
115
+
116
+
117
+ def install_clean_prompts():
118
+ global safe_input, input_func
119
+ if getattr(safe_input, "_blue_input_fix", False):
120
+ return
121
+ original_safe_input = safe_input
122
+ original_input_func = input_func
123
+ colors_enabled = enable_terminal_colors()
124
+
125
+ def blue_input(*args, **kwargs):
126
+ if not colors_enabled:
127
+ return original_input_func(*args, **kwargs)
128
+ sys.stdout.write("\x1b[0;34m")
129
+ sys.stdout.flush()
130
+ try:
131
+ return original_input_func(*args, **kwargs)
132
+ finally:
133
+ sys.stdout.write("\x1b[0m")
134
+ sys.stdout.flush()
135
+
136
+ def clean_safe_input(text, transform, exceptions=(ValueError, IndexError)):
137
+ clean_text = ANSI_ESCAPE.sub("", text)
138
+ return original_safe_input(clean_text, transform, exceptions)
139
+
140
+ clean_safe_input._blue_input_fix = True
141
+ input_func = blue_input
142
+ safe_input = clean_safe_input