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.
- anime_sama_api/__init__.py +49 -0
- anime_sama_api/assets/ascii_art +6 -0
- anime_sama_api/catalogue.py +157 -0
- anime_sama_api/cli/__main__.py +96 -0
- anime_sama_api/cli/config.py +119 -0
- anime_sama_api/cli/config.toml +35 -0
- anime_sama_api/cli/downloader.py +217 -0
- anime_sama_api/cli/episode_extra_info.py +136 -0
- anime_sama_api/cli/episode_tree.py +345 -0
- anime_sama_api/cli/error_handeling.py +79 -0
- anime_sama_api/cli/internal_player.py +61 -0
- anime_sama_api/cli/play_menu.py +30 -0
- anime_sama_api/cli/utils.py +98 -0
- anime_sama_api/cli_standalone.py +20 -0
- anime_sama_api/episode.py +143 -0
- anime_sama_api/langs.py +76 -0
- anime_sama_api/season.py +227 -0
- anime_sama_api/standalone/__init__.py +8 -0
- anime_sama_api/standalone/anilist.py +308 -0
- anime_sama_api/standalone/api_helpers.py +43 -0
- anime_sama_api/standalone/catalogue_tui.py +90 -0
- anime_sama_api/standalone/completions.py +109 -0
- anime_sama_api/standalone/config.py +87 -0
- anime_sama_api/standalone/constants.py +47 -0
- anime_sama_api/standalone/download_utils.py +69 -0
- anime_sama_api/standalone/flows.py +361 -0
- anime_sama_api/standalone/fzf_utils.py +339 -0
- anime_sama_api/standalone/history.py +39 -0
- anime_sama_api/standalone/menus.py +404 -0
- anime_sama_api/standalone/planning.py +351 -0
- anime_sama_api/standalone/planning_tui.py +95 -0
- anime_sama_api/standalone/playback.py +84 -0
- anime_sama_api/standalone/runner.py +175 -0
- anime_sama_api/standalone/system_deps.py +78 -0
- anime_sama_api/standalone/terminal.py +100 -0
- anime_sama_api/top_level.py +353 -0
- anime_sama_api/utils.py +58 -0
- anime_sama_cli-1.1.dist-info/METADATA +222 -0
- anime_sama_cli-1.1.dist-info/RECORD +42 -0
- anime_sama_cli-1.1.dist-info/WHEEL +4 -0
- anime_sama_cli-1.1.dist-info/entry_points.txt +3 -0
- 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
|