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.
- anime_sama_api/__init__.py +52 -0
- anime_sama_api/catalogue.py +252 -0
- anime_sama_api/cli/__main__.py +80 -0
- anime_sama_api/cli/config.py +119 -0
- anime_sama_api/cli/config.toml +35 -0
- anime_sama_api/cli/downloader.py +323 -0
- anime_sama_api/cli/error_handling.py +66 -0
- anime_sama_api/cli/internal_player.py +56 -0
- anime_sama_api/cli/play_menu.py +61 -0
- anime_sama_api/cli/utils.py +142 -0
- anime_sama_api/episode.py +207 -0
- anime_sama_api/langs.py +76 -0
- anime_sama_api/link_resolver.py +187 -0
- anime_sama_api/season.py +226 -0
- anime_sama_api/top_level.py +376 -0
- anime_sama_api/utils.py +54 -0
- anime_sama_cli_api-0.4.0.dist-info/METADATA +103 -0
- anime_sama_cli_api-0.4.0.dist-info/RECORD +21 -0
- anime_sama_cli_api-0.4.0.dist-info/WHEEL +4 -0
- anime_sama_cli_api-0.4.0.dist-info/entry_points.txt +2 -0
- anime_sama_cli_api-0.4.0.dist-info/licenses/LICENSE +674 -0
|
@@ -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
|