anime-sama-api 0.2.2.post4__tar.gz → 0.3.0__tar.gz
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-0.2.2.post4 → anime_sama_api-0.3.0}/.github/workflows/ci.yaml +2 -2
- anime_sama_api-0.3.0/.python-version +1 -0
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/PKG-INFO +7 -9
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/README.md +1 -1
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/anime_sama_api/__init__.py +9 -9
- anime_sama_api-0.3.0/anime_sama_api/catalogue.py +252 -0
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/anime_sama_api/cli/__main__.py +7 -6
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/anime_sama_api/cli/config.py +1 -6
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/anime_sama_api/cli/config.toml +3 -3
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/anime_sama_api/cli/downloader.py +41 -34
- anime_sama_api-0.2.2.post4/anime_sama_api/cli/error_handeling.py → anime_sama_api-0.3.0/anime_sama_api/cli/error_handling.py +3 -5
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/anime_sama_api/cli/utils.py +2 -2
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/anime_sama_api/episode.py +4 -6
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/anime_sama_api/langs.py +2 -2
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/anime_sama_api/season.py +48 -21
- anime_sama_api-0.3.0/anime_sama_api/top_level.py +366 -0
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/anime_sama_api/utils.py +8 -4
- {anime_sama_api-0.2.2.post4/exemples → anime_sama_api-0.3.0/examples}/new_episodes_rss.py +4 -4
- {anime_sama_api-0.2.2.post4/exemples → anime_sama_api-0.3.0/examples}/run_cli_(for_debugging).py +0 -1
- anime_sama_api-0.3.0/examples/scan_catalogue.py +26 -0
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/pyproject.toml +14 -12
- anime_sama_api-0.3.0/tests/data/catalogue_data.py +5 -0
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/tests/data/episode_data.py +669 -573
- anime_sama_api-0.3.0/tests/data/season_data.py +32 -0
- anime_sama_api-0.3.0/tests/data/update_episode_data.py +11 -0
- anime_sama_api-0.3.0/tests/test_catalogue.py +83 -0
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/tests/test_cli_utils.py +15 -15
- anime_sama_api-0.3.0/tests/test_download.py +25 -0
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/tests/test_top_level.py +3 -2
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/tests/test_utils.py +1 -1
- anime_sama_api-0.3.0/uv.lock +746 -0
- anime_sama_api-0.2.2.post4/.python-version +0 -1
- anime_sama_api-0.2.2.post4/anime_sama_api/catalogue.py +0 -154
- anime_sama_api-0.2.2.post4/anime_sama_api/cli/episode_extra_info.py +0 -136
- anime_sama_api-0.2.2.post4/anime_sama_api/top_level.py +0 -250
- anime_sama_api-0.2.2.post4/debug_vidmoly.py +0 -42
- anime_sama_api-0.2.2.post4/tests/data/catalogue_data.py +0 -6
- anime_sama_api-0.2.2.post4/tests/data/season_data.py +0 -32
- anime_sama_api-0.2.2.post4/tests/test_catalogue.py +0 -35
- anime_sama_api-0.2.2.post4/tests/test_download.py +0 -27
- anime_sama_api-0.2.2.post4/uv.lock +0 -616
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/.github/workflows/release.yaml +0 -0
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/.gitignore +0 -0
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/LICENSE +0 -0
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/TODO.md +0 -0
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/anime_sama_api/cli/internal_player.py +0 -0
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/anime_sama_api/cli/play_menu.py +0 -0
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/tests/__init__.py +0 -0
- {anime_sama_api-0.2.2.post4 → anime_sama_api-0.3.0}/tests/test_seasons.py +0 -0
|
@@ -11,11 +11,11 @@ jobs:
|
|
|
11
11
|
matrix:
|
|
12
12
|
os: [ubuntu-latest, windows-latest]
|
|
13
13
|
python-version:
|
|
14
|
-
- "3.10"
|
|
15
14
|
- "3.11"
|
|
16
15
|
- "3.12"
|
|
17
16
|
- "3.13"
|
|
18
17
|
- "3.14"
|
|
18
|
+
- "3.15"
|
|
19
19
|
|
|
20
20
|
name: Test
|
|
21
21
|
runs-on: ${{ matrix.os }}
|
|
@@ -26,7 +26,7 @@ jobs:
|
|
|
26
26
|
- name: Install uv
|
|
27
27
|
uses: astral-sh/setup-uv@v5
|
|
28
28
|
with:
|
|
29
|
-
version: "0.
|
|
29
|
+
version: "0.13.3"
|
|
30
30
|
python-version: ${{ matrix.python-version }}
|
|
31
31
|
enable-cache: true
|
|
32
32
|
cache-dependency-glob: "uv.lock"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: anime-sama_api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Api for anime-sama
|
|
5
5
|
Project-URL: Repository, https://github.com/Sky-NiniKo/anime-sama_api/
|
|
6
6
|
Author-email: NiniKo <pcninik@gmail.com>
|
|
@@ -12,19 +12,17 @@ Classifier: Environment :: Console
|
|
|
12
12
|
Classifier: Intended Audience :: End Users/Desktop
|
|
13
13
|
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
14
14
|
Classifier: Programming Language :: Python
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
18
|
Classifier: Topic :: Multimedia :: Video :: Capture
|
|
20
19
|
Classifier: Topic :: System :: Archiving
|
|
21
|
-
Requires-Python: >=3.
|
|
20
|
+
Requires-Python: >=3.11
|
|
22
21
|
Requires-Dist: httpx>=0.28.1
|
|
23
22
|
Provides-Extra: cli
|
|
24
|
-
Requires-Dist: platformdirs>=4.5
|
|
25
|
-
Requires-Dist: rich>=
|
|
26
|
-
Requires-Dist:
|
|
27
|
-
Requires-Dist: yt-dlp>=2025.8.22; extra == 'cli'
|
|
23
|
+
Requires-Dist: platformdirs>=4.13.5; extra == 'cli'
|
|
24
|
+
Requires-Dist: rich>=15.0.0; extra == 'cli'
|
|
25
|
+
Requires-Dist: yt-dlp>=2026.8.19; extra == 'cli'
|
|
28
26
|
Description-Content-Type: text/markdown
|
|
29
27
|
|
|
30
28
|
This project is archive. I do not have the time to work on it anymore. I may make some small fixes if a breaking issue arises.
|
|
@@ -36,7 +34,7 @@ I have implemented all the features I care about. This project is now in mainten
|
|
|
36
34
|
|
|
37
35
|
# Installation
|
|
38
36
|
Requirements:
|
|
39
|
-
- Python 3.
|
|
37
|
+
- Python 3.11 or higher
|
|
40
38
|
|
|
41
39
|
You can simply install it with (note that you can use tools like pipx to isolate the installation):
|
|
42
40
|
```bash
|
|
@@ -7,7 +7,7 @@ I have implemented all the features I care about. This project is now in mainten
|
|
|
7
7
|
|
|
8
8
|
# Installation
|
|
9
9
|
Requirements:
|
|
10
|
-
- Python 3.
|
|
10
|
+
- Python 3.11 or higher
|
|
11
11
|
|
|
12
12
|
You can simply install it with (note that you can use tools like pipx to isolate the installation):
|
|
13
13
|
```bash
|
|
@@ -19,26 +19,26 @@ except ImportError:
|
|
|
19
19
|
|
|
20
20
|
sys.exit(1)
|
|
21
21
|
|
|
22
|
-
download = multi_download = main
|
|
22
|
+
download = multi_download = main
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
# __package__ = "anime-sama_api"
|
|
26
26
|
__all__ = [
|
|
27
27
|
"AnimeSama",
|
|
28
28
|
"Catalogue",
|
|
29
|
-
"Season",
|
|
30
|
-
"Players",
|
|
31
|
-
"Languages",
|
|
32
29
|
"Episode",
|
|
33
30
|
"Lang",
|
|
34
31
|
"LangId",
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
32
|
+
"Languages",
|
|
33
|
+
"Players",
|
|
34
|
+
"Season",
|
|
38
35
|
"download",
|
|
39
|
-
"multi_download",
|
|
40
|
-
"main",
|
|
41
36
|
"find_site_url",
|
|
37
|
+
"flags",
|
|
38
|
+
"id2lang",
|
|
39
|
+
"lang2ids",
|
|
40
|
+
"main",
|
|
41
|
+
"multi_download",
|
|
42
42
|
]
|
|
43
43
|
|
|
44
44
|
"""__locals = locals()
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from datetime import date
|
|
5
|
+
from html import unescape
|
|
6
|
+
from typing import Literal, get_args
|
|
7
|
+
|
|
8
|
+
from httpx import AsyncClient
|
|
9
|
+
|
|
10
|
+
from .langs import Lang, flags
|
|
11
|
+
from .season import Season
|
|
12
|
+
from .utils import remove_some_js_comments
|
|
13
|
+
|
|
14
|
+
Type = Literal["Anime", "Film", "Scans", "Webtoon", "Autres"]
|
|
15
|
+
State = Literal["À venir", "En cours", "Terminé", "Annulé", "En pause"]
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Catalogue:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
url: str,
|
|
24
|
+
name: str = "",
|
|
25
|
+
alternative_names: Sequence[str] | None = None,
|
|
26
|
+
genres: Sequence[str] | None = None,
|
|
27
|
+
types: set[Type] | None = None,
|
|
28
|
+
languages: set[Lang] | None = None,
|
|
29
|
+
image_url: str = "",
|
|
30
|
+
client: AsyncClient | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
if alternative_names is None:
|
|
33
|
+
alternative_names = []
|
|
34
|
+
if genres is None:
|
|
35
|
+
genres = []
|
|
36
|
+
if types is None:
|
|
37
|
+
types = set()
|
|
38
|
+
if languages is None:
|
|
39
|
+
languages = set()
|
|
40
|
+
|
|
41
|
+
self.url = url + "/" if url[-1] != "/" else url
|
|
42
|
+
self.site_url = "/".join(url.split("/")[:3]) + "/"
|
|
43
|
+
self.client = client or AsyncClient()
|
|
44
|
+
|
|
45
|
+
self.name = name or url.split("/")[-2]
|
|
46
|
+
|
|
47
|
+
self._page: str | None = None
|
|
48
|
+
self.alternative_names = alternative_names
|
|
49
|
+
self.genres = genres
|
|
50
|
+
self.types = types
|
|
51
|
+
self.languages = languages
|
|
52
|
+
self.image_url = image_url
|
|
53
|
+
|
|
54
|
+
async def page(self) -> str:
|
|
55
|
+
if self._page is not None:
|
|
56
|
+
return self._page
|
|
57
|
+
|
|
58
|
+
response = await self.client.get(self.url)
|
|
59
|
+
|
|
60
|
+
if response.is_error:
|
|
61
|
+
self._page = ""
|
|
62
|
+
else:
|
|
63
|
+
self._page = response.text
|
|
64
|
+
|
|
65
|
+
return self._page
|
|
66
|
+
|
|
67
|
+
async def seasons(self) -> list[Season]:
|
|
68
|
+
page_without_comments = remove_some_js_comments(string=await self.page())
|
|
69
|
+
|
|
70
|
+
seasons = re.findall(
|
|
71
|
+
r'panneauAnime\("(.+?)", *"(.+?)(?:vostfr|vf)"\);', page_without_comments
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
seasons = [
|
|
75
|
+
Season(
|
|
76
|
+
url=self.url + link,
|
|
77
|
+
name=name,
|
|
78
|
+
series_name=self.name,
|
|
79
|
+
client=self.client,
|
|
80
|
+
)
|
|
81
|
+
for name, link in seasons
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
return seasons
|
|
85
|
+
|
|
86
|
+
async def _info_values(self) -> dict[str, list[str]]:
|
|
87
|
+
values: dict[str, list[str]] = {}
|
|
88
|
+
page = await self.page()
|
|
89
|
+
info_blocks = re.findall(
|
|
90
|
+
r"""<dl\b[^>]*class=["'][^"']*\boe-infos\b[^"']*["'][^>]*>(.*?)</dl\s*>""",
|
|
91
|
+
page,
|
|
92
|
+
re.IGNORECASE | re.DOTALL,
|
|
93
|
+
)
|
|
94
|
+
for block in info_blocks:
|
|
95
|
+
for label, value in re.findall(
|
|
96
|
+
r"<dt\b[^>]*>(.*?)</dt\s*>\s*<dd\b[^>]*>(.*?)</dd\s*>",
|
|
97
|
+
block,
|
|
98
|
+
re.IGNORECASE | re.DOTALL,
|
|
99
|
+
):
|
|
100
|
+
clean_label = self._clean_html(label).casefold()
|
|
101
|
+
values.setdefault(clean_label, []).append(self._clean_html(value))
|
|
102
|
+
return values
|
|
103
|
+
|
|
104
|
+
# TODO: move to utils
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _clean_html(value: str) -> str:
|
|
107
|
+
return re.sub(r"\s+", " ", unescape(re.sub(r"<[^>]*>", " ", value))).strip()
|
|
108
|
+
|
|
109
|
+
async def news(self) -> str:
|
|
110
|
+
search = re.findall(r"Actualité.+?>(.+?)<", await self.page())
|
|
111
|
+
|
|
112
|
+
if not search:
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
return search[0].lstrip()
|
|
116
|
+
|
|
117
|
+
async def state(self) -> str:
|
|
118
|
+
info = await self._info_values()
|
|
119
|
+
states = info.get("statut", [])
|
|
120
|
+
if states:
|
|
121
|
+
if states[0] in get_args(State):
|
|
122
|
+
return states[0]
|
|
123
|
+
logger.warning(
|
|
124
|
+
f"Error while parsing '{states[0]}'. \nPlease report this to the developer with URL: {self.url}",
|
|
125
|
+
)
|
|
126
|
+
return ""
|
|
127
|
+
|
|
128
|
+
async def broadcasting_date(self) -> date | None:
|
|
129
|
+
raise NotImplementedError
|
|
130
|
+
|
|
131
|
+
info = await self._info_values()
|
|
132
|
+
diffusions = info.get("diffusion", [])
|
|
133
|
+
|
|
134
|
+
if not diffusions:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
import time
|
|
138
|
+
|
|
139
|
+
return date(
|
|
140
|
+
*(time.strptime(diffusions[0], "%d %b. %Y")[0:3])
|
|
141
|
+
) # issue with local: %b should be interpreted with french local
|
|
142
|
+
|
|
143
|
+
async def year(self) -> int | None:
|
|
144
|
+
info = await self._info_values()
|
|
145
|
+
for label in ("diffusion", "année", "année de sortie", "début"):
|
|
146
|
+
for value in info.get(label, []):
|
|
147
|
+
match = re.search(r"\b(?:19|20)\d{2}\b", value)
|
|
148
|
+
if match:
|
|
149
|
+
return int(match.group(0))
|
|
150
|
+
|
|
151
|
+
async def episode_count(self) -> int | None:
|
|
152
|
+
info = await self._info_values()
|
|
153
|
+
for value in info.get("épisodes", []):
|
|
154
|
+
match = re.search(r"\d+", value)
|
|
155
|
+
if match:
|
|
156
|
+
return int(match.group(0))
|
|
157
|
+
|
|
158
|
+
# Currently not provided by Anime Sama. We consider this as deprecated: TODO add a warning
|
|
159
|
+
async def chapter_count(self) -> int | None:
|
|
160
|
+
info = await self._info_values()
|
|
161
|
+
for value in info.get("chapitres", []):
|
|
162
|
+
match = re.search(r"\d+", value)
|
|
163
|
+
if match:
|
|
164
|
+
return int(match.group(0))
|
|
165
|
+
|
|
166
|
+
async def creator(self) -> str:
|
|
167
|
+
info = await self._info_values()
|
|
168
|
+
creators = info.get("auteur", [])
|
|
169
|
+
|
|
170
|
+
if not creators:
|
|
171
|
+
return ""
|
|
172
|
+
|
|
173
|
+
return creators[0]
|
|
174
|
+
|
|
175
|
+
async def studio(self) -> str:
|
|
176
|
+
info = await self._info_values()
|
|
177
|
+
studios = info.get("studio", [])
|
|
178
|
+
|
|
179
|
+
if not studios:
|
|
180
|
+
return ""
|
|
181
|
+
|
|
182
|
+
return studios[0]
|
|
183
|
+
|
|
184
|
+
async def correspondence(self) -> str:
|
|
185
|
+
page = await self.page()
|
|
186
|
+
match = re.search(
|
|
187
|
+
r"Correspondance\s*:.*?</strong>\s*(.*?)</span>",
|
|
188
|
+
page,
|
|
189
|
+
re.IGNORECASE | re.DOTALL,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if not match:
|
|
193
|
+
return ""
|
|
194
|
+
|
|
195
|
+
return self._clean_html(match.group(1))
|
|
196
|
+
|
|
197
|
+
async def synopsis(self) -> str:
|
|
198
|
+
page = await self.page()
|
|
199
|
+
synopsis = re.search(
|
|
200
|
+
r"""<p\b[^>]*id=["'][^"']*\bsynopsisText\b[^"']*["'][^>]*>(.*?)</p\s*>""",
|
|
201
|
+
page,
|
|
202
|
+
re.IGNORECASE | re.DOTALL,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if not synopsis:
|
|
206
|
+
return ""
|
|
207
|
+
|
|
208
|
+
return self._clean_html(synopsis.group(1))
|
|
209
|
+
|
|
210
|
+
async def is_mature(self) -> bool:
|
|
211
|
+
"""Return True if the catalogue contain a warning about adult content"""
|
|
212
|
+
return (
|
|
213
|
+
re.search(
|
|
214
|
+
r'<div class=".*?yellow.*?">[\W\w]+?public averti', await self.page()
|
|
215
|
+
)
|
|
216
|
+
is not None
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def is_anime(self) -> bool:
|
|
221
|
+
return "Anime" in self.types
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def is_manga(self) -> bool:
|
|
225
|
+
return "Scans" in self.types
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def is_film(self) -> bool:
|
|
229
|
+
return "Film" in self.types
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def is_other(self) -> bool:
|
|
233
|
+
return "Autres" in self.types
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def fancy_name(self) -> str:
|
|
237
|
+
names = [""] + list(self.alternative_names) if self.alternative_names else []
|
|
238
|
+
return f"{self.name}[bright_black]{' - '.join(names)} {' '.join(flags[lang] for lang in self.languages if lang != 'VOSTFR')}"
|
|
239
|
+
|
|
240
|
+
def __repr__(self) -> str:
|
|
241
|
+
return f"Catalogue({self.url!r}, {self.name!r})"
|
|
242
|
+
|
|
243
|
+
def __str__(self) -> str:
|
|
244
|
+
return self.fancy_name
|
|
245
|
+
|
|
246
|
+
def __eq__(self, value: object) -> bool:
|
|
247
|
+
if not isinstance(value, Catalogue):
|
|
248
|
+
return False
|
|
249
|
+
return self.url == value.url
|
|
250
|
+
|
|
251
|
+
def __hash__(self) -> int:
|
|
252
|
+
return hash(self.url + self.name + "".join(self.alternative_names))
|
|
@@ -9,8 +9,8 @@ from rich.status import Status
|
|
|
9
9
|
|
|
10
10
|
from anime_sama_api.cli import downloader, internal_player
|
|
11
11
|
from anime_sama_api.cli.config import config
|
|
12
|
-
from anime_sama_api.cli.episode_extra_info import convert_with_extra_info
|
|
13
12
|
from anime_sama_api.cli.utils import safe_input, select_one, select_range
|
|
13
|
+
from anime_sama_api.season import EpisodeDataUnavailableError
|
|
14
14
|
from anime_sama_api.top_level import AnimeSama, find_site_url
|
|
15
15
|
|
|
16
16
|
console = get_console()
|
|
@@ -53,17 +53,14 @@ async def async_main() -> None:
|
|
|
53
53
|
with spinner(f"Getting episode list for [blue]{season.name}"):
|
|
54
54
|
episodes = await season.episodes()
|
|
55
55
|
|
|
56
|
-
console.print(f"\n[cyan bold underline]{season.
|
|
56
|
+
console.print(f"\n[cyan bold underline]{season.series_name} - {season.name}")
|
|
57
57
|
selected_episodes = select_range(
|
|
58
58
|
episodes, msg="Choose episode(s)", print_choices=True
|
|
59
59
|
)
|
|
60
60
|
|
|
61
61
|
if config.download:
|
|
62
62
|
downloader.multi_download(
|
|
63
|
-
|
|
64
|
-
convert_with_extra_info(episode, catalogue)
|
|
65
|
-
for episode in selected_episodes
|
|
66
|
-
],
|
|
63
|
+
selected_episodes,
|
|
67
64
|
config.download_path,
|
|
68
65
|
config.episode_path,
|
|
69
66
|
config.concurrent_downloads,
|
|
@@ -72,6 +69,7 @@ async def async_main() -> None:
|
|
|
72
69
|
config.max_retry_time,
|
|
73
70
|
config.format,
|
|
74
71
|
config.format_sort,
|
|
72
|
+
[await catalogue.year()] * len(selected_episodes),
|
|
75
73
|
)
|
|
76
74
|
else:
|
|
77
75
|
command = internal_player.play_episode(
|
|
@@ -86,6 +84,9 @@ def main() -> int:
|
|
|
86
84
|
asyncio.run(async_main())
|
|
87
85
|
except (KeyboardInterrupt, asyncio.exceptions.CancelledError, EOFError):
|
|
88
86
|
console.print("\n[red]Exiting...")
|
|
87
|
+
except EpisodeDataUnavailableError as error:
|
|
88
|
+
console.print(f"\n[red]{error}")
|
|
89
|
+
return 1
|
|
89
90
|
|
|
90
91
|
return 0
|
|
91
92
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# TODO: Allway hard to read but I can't find a better way to do it
|
|
2
2
|
import logging
|
|
3
|
-
import
|
|
3
|
+
import tomllib
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
@@ -9,11 +9,6 @@ from platformdirs import user_config_dir, user_downloads_dir
|
|
|
9
9
|
|
|
10
10
|
from anime_sama_api.langs import Lang, lang2ids
|
|
11
11
|
|
|
12
|
-
if sys.version_info >= (3, 11):
|
|
13
|
-
import tomllib
|
|
14
|
-
else:
|
|
15
|
-
import tomli as tomllib
|
|
16
|
-
|
|
17
12
|
logger = logging.getLogger(__name__)
|
|
18
13
|
|
|
19
14
|
|
|
@@ -4,8 +4,8 @@ prefer_languages = ["VOSTFR"]
|
|
|
4
4
|
|
|
5
5
|
# Where to put the downloads
|
|
6
6
|
download_path = "~/Downloads/Anime-Sama"
|
|
7
|
-
# How to organize the downloads (you can use {
|
|
8
|
-
episode_path = "{
|
|
7
|
+
# How to organize the downloads (you can use {series} {season} {episode} {release_year_parentheses})
|
|
8
|
+
episode_path = "{series}/{season}/{episode}"
|
|
9
9
|
|
|
10
10
|
# If it downloads or plays the selected episodes
|
|
11
11
|
download = true
|
|
@@ -13,7 +13,7 @@ show_players = false
|
|
|
13
13
|
max_retry_time = 1024
|
|
14
14
|
|
|
15
15
|
# See https://github.com/yt-dlp/yt-dlp#format-selection
|
|
16
|
-
# For
|
|
16
|
+
# For example, if you want the lowest size, put "+size,+br,+res,+fps" in format_sort
|
|
17
17
|
format = "all"
|
|
18
18
|
format_sort = ""
|
|
19
19
|
|
|
@@ -24,10 +24,10 @@ from rich.table import Column
|
|
|
24
24
|
from yt_dlp import YoutubeDL
|
|
25
25
|
from yt_dlp.utils import DownloadError
|
|
26
26
|
|
|
27
|
-
from anime_sama_api.langs import Lang
|
|
28
27
|
from anime_sama_api.cli.config import PlayersConfig, config
|
|
29
|
-
from anime_sama_api.cli.
|
|
30
|
-
from anime_sama_api.
|
|
28
|
+
from anime_sama_api.cli.error_handling import YDL_log_filter, reaction_to
|
|
29
|
+
from anime_sama_api.episode import Episode
|
|
30
|
+
from anime_sama_api.langs import Lang
|
|
31
31
|
|
|
32
32
|
logger = logging.getLogger(__name__)
|
|
33
33
|
logger.addFilter(YDL_log_filter)
|
|
@@ -64,7 +64,7 @@ progress = Group(total_progress, download_progress)
|
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
def download(
|
|
67
|
-
episode:
|
|
67
|
+
episode: Episode,
|
|
68
68
|
path: Path,
|
|
69
69
|
episode_path: str = "{episode}",
|
|
70
70
|
prefer_languages: list[Lang] | None = None,
|
|
@@ -73,28 +73,29 @@ def download(
|
|
|
73
73
|
max_retry_time: int = 1024,
|
|
74
74
|
video_format: str = "",
|
|
75
75
|
format_sort: str = "",
|
|
76
|
+
ep_year: int | None = None,
|
|
76
77
|
) -> None:
|
|
77
78
|
if prefer_languages is None:
|
|
78
79
|
prefer_languages = ["VOSTFR"]
|
|
79
80
|
if players_config is None:
|
|
80
81
|
players_config = PlayersConfig([], [])
|
|
81
82
|
|
|
82
|
-
if not any(episode.
|
|
83
|
+
if not any(episode.languages.values()):
|
|
83
84
|
logger.error("No player available")
|
|
84
85
|
return
|
|
85
86
|
|
|
86
87
|
me = download_progress.add_task(
|
|
87
|
-
"download", episode_name=episode.
|
|
88
|
+
"download", episode_name=episode.name, site="", total=None
|
|
88
89
|
)
|
|
89
90
|
task = next(t for t in download_progress.tasks if t.id == me)
|
|
90
91
|
|
|
91
92
|
full_path = (
|
|
92
93
|
path
|
|
93
94
|
/ episode_path.format(
|
|
94
|
-
|
|
95
|
-
season=episode.
|
|
96
|
-
episode=episode.
|
|
97
|
-
release_year_parentheses=
|
|
95
|
+
series=episode.series_name,
|
|
96
|
+
season=episode.season_name,
|
|
97
|
+
episode=episode.name,
|
|
98
|
+
release_year_parentheses="" if ep_year is None else f" {ep_year}",
|
|
98
99
|
)
|
|
99
100
|
).expanduser()
|
|
100
101
|
|
|
@@ -115,11 +116,11 @@ def download(
|
|
|
115
116
|
"format_sort": format_sort.split(","),
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
for player in episode.
|
|
119
|
+
for player in episode.consume_player(
|
|
119
120
|
prefer_languages, players_config.prefers, players_config.bans
|
|
120
121
|
):
|
|
121
122
|
retry_time = 1
|
|
122
|
-
|
|
123
|
+
success = False
|
|
123
124
|
download_progress.update(me, site=urlparse(player).hostname)
|
|
124
125
|
|
|
125
126
|
while True:
|
|
@@ -128,7 +129,7 @@ def download(
|
|
|
128
129
|
error_code = cast(int, ydl.download([player]))
|
|
129
130
|
|
|
130
131
|
if not error_code:
|
|
131
|
-
|
|
132
|
+
success = True
|
|
132
133
|
else:
|
|
133
134
|
logger.fatal(
|
|
134
135
|
"The download encountered an error code %s. Please report this to the developer with URL: %s",
|
|
@@ -149,7 +150,7 @@ def download(
|
|
|
149
150
|
|
|
150
151
|
logger.warning(
|
|
151
152
|
"%s interrupted (%s). Retrying in %ss.",
|
|
152
|
-
episode.
|
|
153
|
+
episode.name,
|
|
153
154
|
exception.msg,
|
|
154
155
|
retry_time,
|
|
155
156
|
)
|
|
@@ -158,7 +159,7 @@ def download(
|
|
|
158
159
|
retry_time *= 2
|
|
159
160
|
|
|
160
161
|
case "crash":
|
|
161
|
-
raise exception
|
|
162
|
+
raise # Reraise the same exception
|
|
162
163
|
|
|
163
164
|
case "":
|
|
164
165
|
logger.fatal(
|
|
@@ -167,7 +168,7 @@ def download(
|
|
|
167
168
|
)
|
|
168
169
|
break
|
|
169
170
|
|
|
170
|
-
if
|
|
171
|
+
if success:
|
|
171
172
|
break
|
|
172
173
|
|
|
173
174
|
download_progress.update(me, visible=False)
|
|
@@ -176,7 +177,7 @@ def download(
|
|
|
176
177
|
|
|
177
178
|
|
|
178
179
|
def multi_download(
|
|
179
|
-
episodes: list[
|
|
180
|
+
episodes: list[Episode],
|
|
180
181
|
path: Path,
|
|
181
182
|
episode_path: str = "{episode}",
|
|
182
183
|
concurrent_downloads: dict[str, int] | None = None,
|
|
@@ -185,6 +186,7 @@ def multi_download(
|
|
|
185
186
|
max_retry_time: int = 1024,
|
|
186
187
|
video_format: str = "",
|
|
187
188
|
format_sort: str = "",
|
|
189
|
+
eps_years: list[int | None] | None = None,
|
|
188
190
|
) -> None:
|
|
189
191
|
if concurrent_downloads is None:
|
|
190
192
|
concurrent_downloads = {}
|
|
@@ -193,24 +195,29 @@ def multi_download(
|
|
|
193
195
|
if players_config is None:
|
|
194
196
|
players_config = PlayersConfig([], [])
|
|
195
197
|
|
|
198
|
+
# Ensure eps_years len
|
|
199
|
+
if eps_years is None:
|
|
200
|
+
eps_years = []
|
|
201
|
+
eps_years += [None] * (len(episodes) - len(eps_years))
|
|
202
|
+
eps_years = eps_years[: len(episodes)]
|
|
203
|
+
|
|
196
204
|
"""
|
|
197
205
|
Not sure if you can use this function multiple times
|
|
198
206
|
"""
|
|
199
207
|
total_progress.add_task("Downloaded", total=len(episodes))
|
|
200
|
-
with Live(progress, console=console)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
)
|
|
208
|
+
with Live(progress, console=console), ThreadPoolExecutor(
|
|
209
|
+
max_workers=concurrent_downloads.get("video", 1)
|
|
210
|
+
) as executor:
|
|
211
|
+
for episode, ep_year in zip(episodes, eps_years):
|
|
212
|
+
executor.submit(
|
|
213
|
+
download,
|
|
214
|
+
episode,
|
|
215
|
+
path,
|
|
216
|
+
episode_path,
|
|
217
|
+
prefer_languages,
|
|
218
|
+
players_config,
|
|
219
|
+
concurrent_downloads.get("fragment", 1),
|
|
220
|
+
max_retry_time,
|
|
221
|
+
video_format,
|
|
222
|
+
format_sort,
|
|
223
|
+
)
|
|
@@ -53,15 +53,13 @@ def YDL_log_filter(record: LogRecord) -> bool:
|
|
|
53
53
|
|
|
54
54
|
match record.levelname:
|
|
55
55
|
case "WARNING":
|
|
56
|
-
|
|
57
|
-
msg in record.msg
|
|
56
|
+
return all(
|
|
57
|
+
msg not in record.msg
|
|
58
58
|
for msg in (
|
|
59
59
|
"Falling back on generic information extractor",
|
|
60
60
|
"Live HLS streams are not supported by the native downloader.",
|
|
61
61
|
)
|
|
62
|
-
)
|
|
63
|
-
return False
|
|
64
|
-
return True
|
|
62
|
+
)
|
|
65
63
|
case "ERROR":
|
|
66
64
|
return not is_error_handle(record.msg)
|
|
67
65
|
case _:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import re
|
|
2
2
|
import sys
|
|
3
3
|
from collections.abc import Callable
|
|
4
|
-
from typing import Any, TypeVar
|
|
4
|
+
from typing import Any, TypeVar
|
|
5
5
|
|
|
6
6
|
from rich import print as print_func
|
|
7
7
|
|
|
@@ -82,7 +82,7 @@ def select_range(
|
|
|
82
82
|
ints[0] = 1
|
|
83
83
|
if ints[1] is None:
|
|
84
84
|
ints[1] = len(choices)
|
|
85
|
-
ints_set.update(range(
|
|
85
|
+
ints_set.update(range(ints[0], ints[1] + 1))
|
|
86
86
|
else:
|
|
87
87
|
raise ValueError
|
|
88
88
|
|