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