anime-sama-cli 1.1__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 +61 -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 +339 -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 +84 -0
  33. anime_sama_api/standalone/runner.py +175 -0
  34. anime_sama_api/standalone/system_deps.py +78 -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-1.1.dist-info/METADATA +222 -0
  39. anime_sama_cli-1.1.dist-info/RECORD +42 -0
  40. anime_sama_cli-1.1.dist-info/WHEEL +4 -0
  41. anime_sama_cli-1.1.dist-info/entry_points.txt +3 -0
  42. anime_sama_cli-1.1.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,136 @@
1
+ # Refactor is not a bad idea
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from functools import lru_cache
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from anime_sama_api.catalogue import Catalogue
11
+ from anime_sama_api.cli.utils import normalize
12
+ from anime_sama_api.episode import Episode
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class EpisodeWithExtraInfo:
17
+ warpped: Episode
18
+ release_date: datetime | None = None
19
+
20
+ def release_year_parentheses(self) -> str:
21
+ if self.release_date is None:
22
+ return ""
23
+ return f" ({self.release_date.year})"
24
+
25
+
26
+ def convert_with_extra_info(
27
+ episode: Episode, serie: Catalogue | None = None
28
+ ) -> EpisodeWithExtraInfo:
29
+ release_date = get_serie_release_date(serie) if serie is not None else None
30
+ return EpisodeWithExtraInfo(warpped=episode, release_date=release_date)
31
+
32
+
33
+ en2fr_genre = {
34
+ "Comedy": "Comédie",
35
+ "Gourmet": "Gastronomie",
36
+ "Drama": "Drame",
37
+ "Adventure": "Aventure",
38
+ "Mystery": "Mystère",
39
+ "Sci-Fi": "Science-fiction",
40
+ "Sports": "Tournois",
41
+ "Supernatural": "Surnaturel",
42
+ "Girls Love": "Yuri",
43
+ "Horror": "Horreur",
44
+ "Fantasy": "Fantastique",
45
+ }
46
+
47
+
48
+ def get_serie_release_date(serie: Catalogue) -> datetime | None:
49
+ try:
50
+ anime = _get_mal_listing(serie)
51
+ if anime is None:
52
+ return None
53
+
54
+ iso_date = anime.get("aired", {}).get("from")
55
+ if iso_date is None:
56
+ return None
57
+
58
+ return datetime.fromisoformat(iso_date)
59
+ except httpx.HTTPStatusError:
60
+ return None
61
+
62
+
63
+ @lru_cache(maxsize=128)
64
+ def _get_mal_listing(serie: Catalogue) -> None | Any:
65
+ if not serie.is_anime:
66
+ return None
67
+
68
+ for name in [serie.name] + list(serie.alternative_names):
69
+ i = 0
70
+ while True:
71
+ try:
72
+ response = httpx.get(
73
+ f"https://api.jikan.moe/v4/anime?q={name}&limit=5", timeout=10
74
+ )
75
+ except httpx.HTTPError:
76
+ return None
77
+
78
+ i += 1
79
+ if response.status_code != 429 or i > 9:
80
+ break
81
+
82
+ if response.status_code != 200:
83
+ return None
84
+
85
+ animes = response.json().get("data", [])
86
+
87
+ for anime in animes:
88
+ for title in anime.get("titles"):
89
+ name = normalize(name)
90
+ title = normalize(title.get("title"))
91
+ anime_genres = [genre.get("name") for genre in anime.get("genres")]
92
+ if name == title:
93
+ # Also guess work but eliminate edge case like fate
94
+ if len(anime_genres) == 0 and len(serie.genres) != 0:
95
+ continue
96
+
97
+ return anime
98
+
99
+ if name in title or title in name:
100
+ if len(anime_genres) == 0:
101
+ if len(serie.genres) != 0:
102
+ continue
103
+ else:
104
+ return anime
105
+
106
+ # Because this condition is not a guarantee, we do an additionnal screenning base on corresponding genres
107
+ not_corresponding_genres = [
108
+ genre
109
+ for genre in anime_genres
110
+ if genre not in serie.genres
111
+ and en2fr_genre.get(genre) not in serie.genres
112
+ ]
113
+
114
+ # Very scientific formula. I'm joking it just guess work
115
+ if len(not_corresponding_genres) / len(anime_genres) < 0.35 and (
116
+ len(anime_genres) != 0 or len(serie.genres) == 0
117
+ ):
118
+ return anime
119
+
120
+ return None
121
+
122
+
123
+ if __name__ == "__main__":
124
+ import asyncio
125
+ from anime_sama_api import find_site_url, AnimeSama
126
+
127
+ async def main():
128
+ url = await find_site_url()
129
+
130
+ if url is None:
131
+ raise
132
+
133
+ async for catalogue in AnimeSama(url).catalogues_iter():
134
+ print(catalogue.name, get_serie_release_date(catalogue))
135
+
136
+ asyncio.run(main())
@@ -0,0 +1,345 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Affichage et sélection des épisodes via le widget Tree de Textual.
3
+ ↑↓ déplacer · Espace sélection · Tab déplier/replier · Entrée valider.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from textual.app import App, ComposeResult
11
+ from rich.text import Text
12
+ from textual.binding import Binding
13
+ from textual.message import Message
14
+ from textual.widgets import Static, Tree
15
+
16
+ from anime_sama_api.episode import Episode
17
+ from anime_sama_api.season import Season
18
+
19
+
20
+ # Type pour les données d'un nœud : None (racine), liste d'épisodes (saison), ou un épisode (feuille)
21
+ NodeData = Episode | list[Episode] | None
22
+
23
+
24
+ class EpisodeTreeToggleSelection(Message):
25
+ """Message émis quand l'utilisateur appuie sur Espace pour (dé)sélectionner un nœud."""
26
+
27
+ def __init__(self, node: Any) -> None:
28
+ self.node = node
29
+ super().__init__()
30
+
31
+
32
+ class EpisodeTreeConfirmSelection(Message):
33
+ """Message émis quand l'utilisateur appuie sur Entrée pour valider la sélection."""
34
+
35
+ pass
36
+
37
+
38
+ class EpisodeTreeWidget(Tree[NodeData]):
39
+ """Tree personnalisé : Tab = déplier/replier, Espace = sélection, Entrée = valider."""
40
+
41
+ inherit_bindings = False
42
+
43
+ BINDINGS = [
44
+ Binding("up", "cursor_up", "Haut", show=False),
45
+ Binding("down", "cursor_down", "Bas", show=False),
46
+ Binding("tab", "toggle_node", "Déplier/Replier", show=False),
47
+ Binding("space", "toggle_selection", "Sélection", show=False),
48
+ Binding("enter", "confirm_selection", "Valider", show=False),
49
+ ]
50
+
51
+ def action_toggle_selection(self) -> None:
52
+ """Espace : ajoute ou retire le nœud courant de la sélection."""
53
+ node = self.cursor_node
54
+ if node is not None:
55
+ self.post_message(EpisodeTreeToggleSelection(node))
56
+
57
+ def action_confirm_selection(self) -> None:
58
+ """Entrée : valide la sélection et envoie le message à l'App."""
59
+ self.post_message(EpisodeTreeConfirmSelection())
60
+
61
+
62
+ class EpisodeTree(App[list[Episode] | None]):
63
+ """Application Textual : tree des épisodes, tous les parents repliés.
64
+ Sélection multiple : parent = tout télécharger, enfants = épisodes choisis.
65
+ """
66
+
67
+ BINDINGS = [
68
+ ("q", "quit", "Quitter"),
69
+ ]
70
+
71
+ SUB_TITLE = "↑↓ déplacer · Espace Sélection · Tab déplier/replier · Entrée valider"
72
+
73
+ def __init__(
74
+ self,
75
+ season: Season,
76
+ episodes: list[Episode],
77
+ *,
78
+ title: str | None = None,
79
+ ) -> None:
80
+ super().__init__()
81
+ self._season = season
82
+ self._episodes = episodes
83
+ self._title = title or f"{season.serie_name} - {season.name}"
84
+ self._all_episodes: list[Episode] = list(episodes)
85
+ self._selected_node_ids: set[int] = set()
86
+
87
+ def compose(self) -> ComposeResult:
88
+ root_label = self._title
89
+ tree = EpisodeTreeWidget(root_label, data=None, id="episode-tree")
90
+ # Tous les parents restent repliés (on n'appelle pas expand())
91
+ for ep in self._episodes:
92
+ tree.root.add_leaf(ep.fancy_name, ep)
93
+ yield tree
94
+
95
+ def on_episode_tree_toggle_selection(self, message: EpisodeTreeToggleSelection) -> None:
96
+ """Espace : bascule la sélection du nœud sous le curseur."""
97
+ node = message.node
98
+ nid = node.id
99
+ if nid in self._selected_node_ids:
100
+ self._selected_node_ids.discard(nid)
101
+ else:
102
+ self._selected_node_ids.add(nid)
103
+ self._refresh_tree_labels()
104
+
105
+ def on_episode_tree_confirm_selection(self, _: EpisodeTreeConfirmSelection) -> None:
106
+ """Entrée : construit la liste d'épisodes sélectionnés et quitte."""
107
+ result = self._build_selected_episodes()
108
+ self.exit(result)
109
+
110
+ def _refresh_tree_labels(self) -> None:
111
+ """Marque visuellement les nœuds sélectionnés (✓) en mettant à jour les libellés."""
112
+ tree = self.query_one(EpisodeTreeWidget)
113
+ from textual.widgets._tree import TreeNode
114
+
115
+ def visit(node: TreeNode[NodeData]) -> None:
116
+ try:
117
+ plain = node.label.plain if hasattr(node.label, "plain") else str(node.label)
118
+ if node.id in self._selected_node_ids:
119
+ if not plain.startswith("✓ "):
120
+ node.set_label("✓ " + plain)
121
+ else:
122
+ if plain.startswith("✓ "):
123
+ node.set_label(plain[2:].strip())
124
+ except Exception:
125
+ pass
126
+ for child in node.children:
127
+ visit(child)
128
+
129
+ visit(tree.root)
130
+ tree.refresh()
131
+
132
+ def _build_selected_episodes(self) -> list[Episode]:
133
+ """À partir des nœuds sélectionnés, produit la liste d'épisodes (sans doublon, ordre conservé)."""
134
+ tree = self.query_one(EpisodeTreeWidget)
135
+ result: list[Episode] = []
136
+ seen: set[tuple[str, int]] = set()
137
+
138
+ for node_id in self._selected_node_ids:
139
+ try:
140
+ node = tree.get_node_by_id(node_id)
141
+ except Exception:
142
+ continue
143
+ to_add: list[Episode] = []
144
+ if node.is_root:
145
+ to_add = list(self._all_episodes)
146
+ elif isinstance(node.data, list):
147
+ to_add = node.data
148
+ elif isinstance(node.data, Episode):
149
+ to_add = [node.data]
150
+ for ep in to_add:
151
+ key = (ep.season_name, ep.index)
152
+ if key not in seen:
153
+ seen.add(key)
154
+ result.append(ep)
155
+ return result
156
+
157
+
158
+ def run_episode_tree(
159
+ season: Season,
160
+ episodes: list[Episode],
161
+ *,
162
+ title: str | None = None,
163
+ ) -> list[Episode] | None:
164
+ """Affiche les épisodes en Tree (tous les parents repliés).
165
+ Espace = sélectionner/désélectionner (parent = toute la saison, enfants = épisodes).
166
+ Tab = déplier/replier un nœud. Entrée = valider. Retourne la liste des épisodes choisis ou None (q).
167
+ """
168
+ app = EpisodeTree(season, episodes, title=title)
169
+ app.run()
170
+ return app.return_value
171
+
172
+
173
+ # Données des nœuds pour le tree multi-saisons : (Season, list[Episode]) pour une saison, (Season, Episode) pour une feuille
174
+ MultiSeasonNodeData = tuple[Season, list[Episode]] | tuple[Season, Episode] | None
175
+
176
+
177
+ # Raccourcis affichés en haut de la fenêtre de sélection
178
+ TREE_HINTS = "↓↑ = naviguer · Esc = menu précédent · Tab = Déplier/Replier · Espace = Select · Entrée = Valider"
179
+
180
+
181
+ def _load_ascii_art() -> str:
182
+ """Charge le contenu ASCII art du package (anime-sama)."""
183
+ try:
184
+ from importlib.resources import files
185
+ return (files("anime_sama_api") / "assets" / "ascii_art").read_text(encoding="utf-8")
186
+ except Exception:
187
+ return ""
188
+
189
+
190
+ class MultiSeasonEpisodeTree(App[list[tuple[Season, Episode]] | None]):
191
+ """Tree Textual avec une saison par nœud parent collapsible.
192
+ Structure : Racine > Saison 1 (▶) > Episode 1, 2, … ; Saison 2 (▶) > …
193
+ """
194
+
195
+ TITLE = ""
196
+ SUB_TITLE = ""
197
+
198
+ BINDINGS = [
199
+ ("q", "quit", "Quitter"),
200
+ ("escape", "quit", "Menu précédent"),
201
+ ]
202
+
203
+ # Fond sombre type terminal ; ASCII art + indications
204
+ CSS = """
205
+ Screen {
206
+ background: #0c0c0c;
207
+ }
208
+ Header {
209
+ display: none;
210
+ }
211
+ #tree-ascii-art {
212
+ padding: 0 1 0 1;
213
+ padding-left: 2;
214
+ margin-top: 1;
215
+ margin-bottom: 1;
216
+ background: #0c0c0c;
217
+ color: #00bfff;
218
+ height: auto;
219
+ }
220
+ #tree-hints {
221
+ padding: 0 1 0 1;
222
+ background: #0c0c0c;
223
+ color: #a0a0a0;
224
+ text-style: dim;
225
+ height: auto;
226
+ }
227
+ #episode-tree {
228
+ background: #0c0c0c;
229
+ padding: 0 0 0 0;
230
+ }
231
+ """
232
+
233
+ def __init__(
234
+ self,
235
+ seasons_with_episodes: list[tuple[Season, list[Episode]]],
236
+ *,
237
+ title: str = "Saisons",
238
+ ) -> None:
239
+ super().__init__()
240
+ self._seasons_with_episodes = seasons_with_episodes
241
+ self._title = title
242
+ self._selected_node_ids: set[int] = set()
243
+
244
+ def compose(self) -> ComposeResult:
245
+ yield Static(TREE_HINTS, id="tree-hints")
246
+ ascii_art = _load_ascii_art()
247
+ if ascii_art:
248
+ yield Static(Text(ascii_art, style="bold cyan"), id="tree-ascii-art")
249
+ tree = EpisodeTreeWidget(self._title, data=None, id="episode-tree")
250
+ for season, episodes in self._seasons_with_episodes:
251
+ season_node = tree.root.add(
252
+ season.name,
253
+ data=(season, episodes),
254
+ expand=False,
255
+ )
256
+ for ep in episodes:
257
+ label = ep.name.strip() if getattr(ep, "name", None) else f"Episode {ep.index}"
258
+ season_node.add_leaf(label, (season, ep))
259
+ yield tree
260
+
261
+ def on_episode_tree_toggle_selection(self, message: EpisodeTreeToggleSelection) -> None:
262
+ node = message.node
263
+ nid = node.id
264
+ if nid in self._selected_node_ids:
265
+ self._selected_node_ids.discard(nid)
266
+ else:
267
+ self._selected_node_ids.add(nid)
268
+ self._refresh_tree_labels()
269
+
270
+ def on_episode_tree_confirm_selection(self, _: EpisodeTreeConfirmSelection) -> None:
271
+ result = self._build_selected()
272
+ self.exit(result)
273
+
274
+ def _refresh_tree_labels(self) -> None:
275
+ tree = self.query_one(EpisodeTreeWidget)
276
+ from textual.widgets._tree import TreeNode
277
+
278
+ def visit(node: TreeNode[MultiSeasonNodeData]) -> None:
279
+ try:
280
+ plain = node.label.plain if hasattr(node.label, "plain") else str(node.label)
281
+ if node.id in self._selected_node_ids:
282
+ if not plain.startswith("✓ "):
283
+ node.set_label("✓ " + plain)
284
+ else:
285
+ if plain.startswith("✓ "):
286
+ node.set_label(plain[2:].strip())
287
+ except Exception:
288
+ pass
289
+ for child in node.children:
290
+ visit(child)
291
+
292
+ visit(tree.root)
293
+ tree.refresh()
294
+
295
+ def _build_selected(self) -> list[tuple[Season, Episode]]:
296
+ tree = self.query_one(EpisodeTreeWidget)
297
+ result: list[tuple[Season, Episode]] = []
298
+ seen: set[tuple[str, int]] = set()
299
+
300
+ for node_id in self._selected_node_ids:
301
+ try:
302
+ node = tree.get_node_by_id(node_id)
303
+ except Exception:
304
+ continue
305
+ to_add: list[tuple[Season, Episode]] = []
306
+ if node.is_root:
307
+ for s, eps in self._seasons_with_episodes:
308
+ to_add.extend((s, ep) for ep in eps)
309
+ elif isinstance(node.data, tuple):
310
+ if len(node.data) == 2:
311
+ s, val = node.data
312
+ if isinstance(val, list):
313
+ to_add = [(s, ep) for ep in val]
314
+ else:
315
+ to_add = [(s, val)]
316
+ for s, ep in to_add:
317
+ key = (ep.season_name, ep.index)
318
+ if key not in seen:
319
+ seen.add(key)
320
+ result.append((s, ep))
321
+ return result
322
+
323
+
324
+ def run_multi_season_episode_tree(
325
+ seasons_with_episodes: list[tuple[Season, list[Episode]]],
326
+ *,
327
+ title: str = "Saisons",
328
+ ) -> list[tuple[Season, Episode]] | None:
329
+ """Tree collapsible multi-saisons (synchrone, utilise asyncio.run())."""
330
+ app = MultiSeasonEpisodeTree(seasons_with_episodes, title=title)
331
+ app.run()
332
+ return app.return_value
333
+
334
+
335
+ async def run_multi_season_episode_tree_async(
336
+ seasons_with_episodes: list[tuple[Season, list[Episode]]],
337
+ *,
338
+ title: str = "Saisons",
339
+ ) -> list[tuple[Season, Episode]] | None:
340
+ """Tree collapsible multi-saisons (async, pour boucle asyncio déjà en cours).
341
+ À utiliser depuis un flux async au lieu de run_multi_season_episode_tree.
342
+ """
343
+ app = MultiSeasonEpisodeTree(seasons_with_episodes, title=title)
344
+ await app.run_async()
345
+ return app.return_value
@@ -0,0 +1,79 @@
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
+ "Requested format is not available",
16
+ "Unable to extract player params",
17
+ "Unable to extract",
18
+ ),
19
+ "retry": (
20
+ "Unable to download webpage: HTTP Error 522",
21
+ "unable to download video data: HTTP Error 416",
22
+ "HTTPError 500: Internal Server Error",
23
+ "The read operation timed out",
24
+ "Waiting for vidmoly", # Custom error msg to tell the downloader to retry
25
+ "TransportError('timed out')",
26
+ "[Errno 54] Connection reset by peer",
27
+ "[Errno 104] Connection reset by peer",
28
+ "HTTPError 503: Service Temporarily Unavailable",
29
+ "[Errno 32] Broken pipe",
30
+ "[Errno 101] Network is unreachable",
31
+ "[Errno 51] Network is unreachable",
32
+ "[Errno 2] No such file or directory",
33
+ ),
34
+ "crash": (),
35
+ }
36
+
37
+
38
+ def reaction_to(msg: str | None) -> Reaction:
39
+ if msg is None:
40
+ return "continue"
41
+
42
+ for reaction, error_msgs in how_to_react.items():
43
+ for error_msg in error_msgs:
44
+ if error_msg in msg:
45
+ return reaction
46
+ return ""
47
+
48
+
49
+ def is_error_handle(msg: str) -> bool:
50
+ return bool(reaction_to(msg))
51
+
52
+
53
+ def YDL_log_filter(record: LogRecord) -> bool:
54
+ if record.filename != "YoutubeDL.py":
55
+ return True
56
+
57
+ match record.levelname:
58
+ case "WARNING":
59
+ if any(
60
+ msg in record.msg
61
+ for msg in (
62
+ "Falling back on generic information extractor",
63
+ "Live HLS streams are not supported by the native downloader.",
64
+ )
65
+ ):
66
+ return False
67
+ return True
68
+ case "ERROR":
69
+ # Ne pas afficher les erreurs qu'on gère (passage au lecteur suivant)
70
+ if is_error_handle(record.msg):
71
+ return False
72
+ return True
73
+ case "CRITICAL":
74
+ # Ne pas afficher CRITICAL quand on a simplement essayé un lecteur et on passe au suivant
75
+ if is_error_handle(record.msg):
76
+ return False
77
+ return True
78
+ case _:
79
+ return True
@@ -0,0 +1,61 @@
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 = list(config.internal_player_command) + [best]
38
+ # VLC : une instance par épisode ; fermeture de la fenêtre = VLC quitte complètement (pas de tray).
39
+ if player_command and "vlc" in player_command[0].lower():
40
+ for opt in ("--no-one-instance", "--no-qt-system-tray"):
41
+ if opt not in player_command:
42
+ player_command.insert(1, opt)
43
+ if args is not None:
44
+ player_command += args
45
+
46
+ # Check by the caller
47
+ # if isinstance(self._sub_proc, sp.Popen):
48
+ # self.kill_player()
49
+
50
+ return open_silent_process(player_command)
51
+
52
+ # self._write_hist(entry)
53
+ # self._start_dc_presence(entry)
54
+
55
+
56
+ def play_file(path: Path, args: list[str] | None = None) -> subprocess.Popen[bytes]:
57
+ player_command = config.internal_player_command + [str(path)]
58
+ if args is not None:
59
+ player_command += args
60
+
61
+ return open_silent_process(player_command)
@@ -0,0 +1,30 @@
1
+ from anime_sama_api.episode import Episode
2
+
3
+
4
+ class EpisodesManager:
5
+ def __init__(self, episodes: list[Episode], current_index: int = 0) -> None:
6
+ self.episodes = episodes
7
+ self.current_index = current_index
8
+
9
+ def __next__(self) -> Episode:
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) -> Episode:
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) -> Episode:
25
+ return self.episodes[self.current_index]
26
+
27
+
28
+ class PlayMenu:
29
+ def print_menu(self) -> None:
30
+ pass