anipy-api 3.0.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.
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.1
2
+ Name: anipy-api
3
+ Version: 3.0.0
4
+ Summary: api for anipy-cli
5
+ Home-page: https://sdaqo.github.io/anipy-cli
6
+ License: GPL-3.0
7
+ Keywords: anime,api
8
+ Author: sdaqo
9
+ Author-email: sdaqo.dev@protonmail.com
10
+ Requires-Python: >=3.9,<4.0
11
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Requires-Dist: beautifulsoup4 (>=4.12.3,<5.0.0)
18
+ Requires-Dist: dataclasses-json (>=0.6.4,<0.7.0)
19
+ Requires-Dist: levenshtein (>=0.25.1,<0.26.0)
20
+ Requires-Dist: m3u8 (>=4.1.0,<5.0.0)
21
+ Requires-Dist: pycryptodomex (>=3.20.0,<4.0.0)
22
+ Requires-Dist: pypresence (>=4.3.0,<5.0.0)
23
+ Requires-Dist: python-ffmpeg (>=2.0.11,<3.0.0)
24
+ Requires-Dist: python-mpv (>=1.0.6,<2.0.0)
25
+ Requires-Dist: requests (>=2.31.0,<3.0.0)
26
+ Project-URL: Bug Tracker, https://github.com/sdaqo/anipy-cli/issues
27
+ Project-URL: Documentation, https://sdaqo.github.io/anipy-cli/getting-started-api
28
+ Project-URL: Repository, https://github.com/sdaqo/anipy-cli
29
+ Description-Content-Type: text/markdown
30
+
31
+ # anipy-api
32
+ This is the api package for [anipy-cli](https://pypi.org/project/anipy-cli/).
33
+
34
+ Find documentation here: [https://sdaqo.github.io/anipy-cli/getting-started-api](https://sdaqo.github.io/anipy-cli/getting-started-api)
35
+
@@ -0,0 +1,4 @@
1
+ # anipy-api
2
+ This is the api package for [anipy-cli](https://pypi.org/project/anipy-cli/).
3
+
4
+ Find documentation here: [https://sdaqo.github.io/anipy-cli/getting-started-api](https://sdaqo.github.io/anipy-cli/getting-started-api)
@@ -0,0 +1,34 @@
1
+ [tool.poetry]
2
+ name = "anipy-api"
3
+ version = "3.0.0"
4
+ description = "api for anipy-cli"
5
+ authors = ["sdaqo <sdaqo.dev@protonmail.com>"]
6
+ license = "GPL-3.0"
7
+ repository = "https://github.com/sdaqo/anipy-cli"
8
+ homepage = "https://sdaqo.github.io/anipy-cli"
9
+ documentation = "https://sdaqo.github.io/anipy-cli/getting-started-api"
10
+ keywords = ["anime", "api"]
11
+ readme = "README.md"
12
+ packages = [
13
+ {include = "anipy_api", from = "src"}
14
+ ]
15
+
16
+ [tool.poetry.dependencies]
17
+ python = "^3.9"
18
+ python-ffmpeg = "^2.0.11"
19
+ pycryptodomex = "^3.20.0"
20
+ requests = "^2.31.0"
21
+ pypresence = "^4.3.0"
22
+ m3u8 = "^4.1.0"
23
+ beautifulsoup4 = "^4.12.3"
24
+ python-mpv = "^1.0.6"
25
+ dataclasses-json = "^0.6.4"
26
+ levenshtein = "^0.25.1"
27
+
28
+ [tool.poetry.urls]
29
+ "Bug Tracker" = "https://github.com/sdaqo/anipy-cli/issues"
30
+
31
+ [build-system]
32
+ requires = ["poetry-core"]
33
+ build-backend = "poetry.core.masonry.api"
34
+
@@ -0,0 +1,2 @@
1
+ __appname__ = "anipy-api"
2
+ __version__ = "3.0.0"
@@ -0,0 +1,161 @@
1
+ from typing import TYPE_CHECKING, Optional, Set, Union, List
2
+
3
+ from anipy_api.provider import Episode, list_providers
4
+
5
+ if TYPE_CHECKING:
6
+ from anipy_api.locallist import LocalListEntry
7
+ from anipy_api.provider import (
8
+ BaseProvider,
9
+ LanguageTypeEnum,
10
+ ProviderSearchResult,
11
+ ProviderInfoResult,
12
+ ProviderStream,
13
+ )
14
+
15
+
16
+ class Anime:
17
+ """A wrapper class that represents an anime, it is pretty useful, but you
18
+ can also just use the [Provider][anipy_api.provider.base.BaseProvider] without the wrapper.
19
+
20
+ Args:
21
+ provider: The provider from which the identifier was retrieved
22
+ name: The name of the Anime
23
+ identifier: The identifier of the Anime
24
+ languages: Supported Language types of the Anime
25
+
26
+ Attributes:
27
+ provider: The from which the Anime comes from
28
+ name: The name of the Anime
29
+ identifier: The identifier of the Anime
30
+ languages: Set of supported Language types of the Anime
31
+ """
32
+
33
+ @staticmethod
34
+ def from_search_result(
35
+ provider: "BaseProvider", result: "ProviderSearchResult"
36
+ ) -> "Anime":
37
+ """Get Anime object from ProviderSearchResult.
38
+
39
+ Args:
40
+ provider: The provider from which the search result stems from
41
+ result: The search result
42
+
43
+ Returns:
44
+ Anime object
45
+ """
46
+ return Anime(provider, result.name, result.identifier, result.languages)
47
+
48
+ @staticmethod
49
+ def from_local_list_entry(entry: "LocalListEntry") -> "Anime":
50
+ """Get Anime object from [LocalListEntry][anipy_api.locallist.LocalListEntry]
51
+
52
+ Args:
53
+ entry: The local list entry
54
+
55
+ Returns:
56
+ Anime Object
57
+ """
58
+ provider = next(filter(lambda x: x.NAME == entry.provider, list_providers()))
59
+ return Anime(provider(), entry.name, entry.identifier, entry.languages)
60
+
61
+ def __init__(
62
+ self,
63
+ provider: "BaseProvider",
64
+ name: str,
65
+ identifier: str,
66
+ languages: Set["LanguageTypeEnum"],
67
+ ):
68
+ self.provider: "BaseProvider" = provider
69
+ self.name: str = name
70
+ self.identifier: str = identifier
71
+ self.languages: Set["LanguageTypeEnum"] = languages
72
+
73
+ def get_episodes(self, lang: "LanguageTypeEnum") -> List["Episode"]:
74
+ """Get a list of episodes from the Anime.
75
+
76
+ Args:
77
+ lang: Language type that determines if episodes are searched
78
+ for the dub or sub version of the Anime. Use the `languages`
79
+ attribute to get supported languages for this Anime.
80
+
81
+ Returns:
82
+ List of Episodes
83
+ """
84
+ return self.provider.get_episodes(self.identifier, lang)
85
+
86
+ def get_info(self) -> "ProviderInfoResult":
87
+ """Get information about the Anime.
88
+
89
+ Returns:
90
+ ProviderInfoResult object
91
+ """
92
+ return self.provider.get_info(self.identifier)
93
+
94
+ def get_video(
95
+ self,
96
+ episode: Episode,
97
+ lang: "LanguageTypeEnum",
98
+ preferred_quality: Optional[Union[str, int]] = None,
99
+ ) -> "ProviderStream":
100
+ """Get a video stream for the specified episode, the quality to return
101
+ is determined by the `preferred_quality` argument or if this is not
102
+ defined by the best quality found. To get a list of streams use
103
+ [get_videos][anipy_api.anime.Anime.get_videos].
104
+
105
+ Args:
106
+ episode: The episode to get the stream for
107
+ lang: Language type that determines if streams are searched for
108
+ the dub or sub version of the Anime. Use the `languages`
109
+ attribute to get supported languages for this Anime.
110
+ preferred_quality: This may be a integer (e.g. 1080, 720 etc.)
111
+ or the string "worst" or "best".
112
+
113
+ Returns:
114
+ A stream
115
+ """
116
+ streams = self.provider.get_video(self.identifier, episode, lang)
117
+ streams.sort(key=lambda s: s.resolution)
118
+
119
+ if preferred_quality == "worst":
120
+ stream = streams[0]
121
+ elif preferred_quality == "best":
122
+ stream = streams[-1]
123
+ elif preferred_quality is None:
124
+ stream = streams[-1]
125
+ else:
126
+ stream = next(
127
+ filter(lambda s: s.resolution == preferred_quality, streams), None
128
+ )
129
+
130
+ if stream is None:
131
+ stream = streams[-1]
132
+
133
+ return stream
134
+
135
+ def get_videos(
136
+ self, episode: Episode, lang: "LanguageTypeEnum"
137
+ ) -> List["ProviderStream"]:
138
+ """Get a list of video streams for the specified Episode.
139
+
140
+ Args:
141
+ episode: The episode to get the streams for
142
+ lang: Language type that determines if streams are searched for
143
+ the dub or sub version of the Anime. Use the `languages`
144
+ attribute to get supported languages for this Anime.
145
+
146
+ Returns:
147
+ A list of streams sorted by quality
148
+ """
149
+ streams = self.provider.get_video(self.identifier, episode, lang)
150
+ streams.sort(key=lambda s: s.resolution)
151
+
152
+ return streams
153
+
154
+ def __repr__(self) -> str:
155
+ available_langs = "/".join(
156
+ [lang.value.capitalize()[0] for lang in self.languages]
157
+ )
158
+ return f"{self.name} ({available_langs})"
159
+
160
+ def __hash__(self) -> int:
161
+ return hash(self.provider.NAME + self.identifier)
@@ -0,0 +1,306 @@
1
+ import json
2
+ import shutil
3
+ import sys
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+ from pathlib import Path
6
+ from typing import Optional, Protocol
7
+ from urllib.parse import urljoin
8
+
9
+ import m3u8
10
+ import requests
11
+ from ffmpeg import FFmpeg, Progress
12
+ from requests.adapters import HTTPAdapter, Retry
13
+
14
+ from anipy_api.error import DownloadError
15
+ from anipy_api.provider import ProviderStream
16
+
17
+
18
+ class ProgressCallback(Protocol):
19
+ """Callback that accepts a percentage argument."""
20
+
21
+ def __call__(self, percentage: float):
22
+ """
23
+ Args:
24
+ percentage: Percentage argument passed to the callback
25
+ """
26
+ ...
27
+
28
+
29
+ class InfoCallback(Protocol):
30
+ """Callback that accepts a message argument."""
31
+
32
+ def __call__(self, message: str):
33
+ """
34
+ Args:
35
+ message: Message argument passed to the callback
36
+ """
37
+ ...
38
+
39
+
40
+ class Downloader:
41
+ """Downloader class to download streams retrieved by the Providers."""
42
+
43
+ def __init__(
44
+ self,
45
+ progress_callback: Optional[ProgressCallback] = None,
46
+ info_callback: Optional[InfoCallback] = None,
47
+ ):
48
+ """__init__ of Downloader.
49
+
50
+ Args:
51
+ progress_callback: A callback with an percentage argument, that gets called on download progress.
52
+ info_callback: A callback with an message argument, that gets called on certain events.
53
+ """
54
+ self._progress_callback: ProgressCallback = progress_callback or (
55
+ lambda percentage: None
56
+ )
57
+ self._info_callback: InfoCallback = info_callback or (lambda message: None)
58
+
59
+ self._session = requests.Session()
60
+
61
+ adapter = HTTPAdapter(max_retries=Retry(connect=3, backoff_factor=0.5))
62
+ self._session.mount("http://", adapter)
63
+ self._session.mount("https://", adapter)
64
+
65
+ @staticmethod
66
+ def _get_valid_pathname(name: str):
67
+ if sys.platform == "win32":
68
+ WIN_INVALID_CHARS = ["\\", "/", ":", "*", "?", "<", ">", "|", '"']
69
+ name = "".join(["" if x in WIN_INVALID_CHARS else x for x in name])
70
+
71
+ name = "".join(
72
+ [i for i in name if i.isascii()]
73
+ ) # Verify all chars are ascii (eject if not)
74
+
75
+ return name
76
+
77
+ def m3u8_download(self, stream: "ProviderStream", download_path: Path) -> Path:
78
+ """Download a m3u8/hls stream to a specified download path in a ts container.
79
+
80
+ The suffix of the download path will be replaced (or added) with
81
+ ".ts", use the path returned instead of the passed path.
82
+
83
+ Args:
84
+ stream: The m3u8/hls stream
85
+ download_path: The path to save the downloaded stream to
86
+
87
+ Raises:
88
+ DownloadError: Raised on download error
89
+
90
+ Returns:
91
+ The path with a ".ts" suffix
92
+ """
93
+ temp_folder = download_path.parent / "temp"
94
+ temp_folder.mkdir(exist_ok=True)
95
+ download_path = download_path.with_suffix(".ts")
96
+
97
+ res = self._session.get(stream.url)
98
+ res.raise_for_status()
99
+
100
+ m3u8_content = m3u8.M3U8(res.text, base_uri=urljoin(res.url, "."))
101
+
102
+ assert m3u8_content.is_variant is False
103
+
104
+ counter = 0
105
+
106
+ def download_ts(segment: m3u8.Segment):
107
+ nonlocal counter
108
+ url = urljoin(segment.base_uri, segment.uri)
109
+ fname = temp_folder / self._get_valid_pathname(segment.uri)
110
+ try:
111
+ res = self._session.get(str(url))
112
+ res.raise_for_status()
113
+
114
+ with fname.open("wb") as fout:
115
+ fout.write(res.content)
116
+
117
+ counter += 1
118
+ self._progress_callback(counter / len(m3u8_content.segments) * 100)
119
+ except Exception as e:
120
+ # TODO: This gets ignored, because it's in a seperate thread...
121
+ raise DownloadError(
122
+ f"Encountered this error while downloading: {str(e)}"
123
+ )
124
+
125
+ try:
126
+ with ThreadPoolExecutor(max_workers=12) as pool_video:
127
+ futures = [
128
+ pool_video.submit(download_ts, s) for s in m3u8_content.segments
129
+ ]
130
+ try:
131
+ for future in as_completed(futures):
132
+ future.result()
133
+ except KeyboardInterrupt:
134
+ self._info_callback(
135
+ "Download Interrupted, cancelling futures, this may take a while..."
136
+ )
137
+ pool_video.shutdown(wait=False, cancel_futures=True)
138
+ raise
139
+
140
+ self._info_callback("Parts Downloaded")
141
+
142
+ self._info_callback("Merging Parts...")
143
+ with download_path.open("wb") as merged:
144
+ for segment in m3u8_content.segments:
145
+ fname = temp_folder / self._get_valid_pathname(segment.uri)
146
+ if not fname.is_file():
147
+ raise DownloadError(
148
+ f"Could not merge, missing a segment of this playlist: {stream.url}"
149
+ )
150
+
151
+ with fname.open("rb") as mergefile:
152
+ shutil.copyfileobj(mergefile, merged)
153
+
154
+ self._info_callback("Merge Finished")
155
+ shutil.rmtree(temp_folder)
156
+
157
+ return download_path
158
+ except KeyboardInterrupt:
159
+ self._info_callback("Download Interrupted, deleting partial file.")
160
+ download_path.unlink(missing_ok=True)
161
+ shutil.rmtree(temp_folder)
162
+ raise
163
+
164
+ def mp4_download(self, stream: "ProviderStream", download_path: Path) -> Path:
165
+ """Download a mp4 stream to a specified download path.
166
+
167
+ The suffix of the download path will be replaced (or added)
168
+ with ".mp4", use the path returned instead of the passed path.
169
+
170
+ Args:
171
+ stream: The mp4 stream
172
+ download_path: The path to download the stream to
173
+
174
+ Returns:
175
+ The download path with a ".mp4" suffix
176
+ """
177
+ r = self._session.get(stream.url, stream=True)
178
+ r.raise_for_status()
179
+ total = int(r.headers.get("content-length", 0))
180
+ try:
181
+ file_handle = download_path.with_suffix(".mp4").open("w")
182
+ for data in r.iter_content(chunk_size=1024):
183
+ size = file_handle.write(data)
184
+ self._progress_callback(size / total * 100)
185
+ except KeyboardInterrupt:
186
+ self._info_callback("Download Interrupted, deleting partial file.")
187
+ download_path.unlink()
188
+ raise
189
+
190
+ self._info_callback("Download finished.")
191
+
192
+ return download_path.with_suffix(".mp4")
193
+
194
+ def ffmpeg_download(self, stream: "ProviderStream", download_path: Path) -> Path:
195
+ """Download a stream with FFmpeg, FFmpeg needs to be installed on the
196
+ system. FFmpeg will be able to handle about any stream and it is also
197
+ able to remux the resulting video. By changing the suffix of the
198
+ `download_path` you are able to tell ffmpeg to remux to a specific
199
+ container.
200
+
201
+ Args:
202
+ stream: The stream
203
+ download_path: The path to download to including a specific suffix.
204
+
205
+ Returns:
206
+ The download path, this should be the same as the
207
+ passed one as ffmpeg will remux to about any container.
208
+ """
209
+ ffmpeg = FFmpeg(executable="ffprobe").input(
210
+ stream.url, print_format="json", show_format=None
211
+ )
212
+ meta = json.loads(ffmpeg.execute())
213
+ duration = float(meta["format"]["duration"])
214
+
215
+ ffmpeg = (
216
+ FFmpeg()
217
+ .option("y")
218
+ .option("v", "warning")
219
+ .option("stats")
220
+ .input(stream.url)
221
+ .output(
222
+ download_path,
223
+ {"c:v": "copy", "c:a": "copy", "c:s": "mov_text"},
224
+ )
225
+ )
226
+
227
+ @ffmpeg.on("progress")
228
+ def on_progress(progress: Progress):
229
+ self._progress_callback(progress.time.total_seconds() / duration * 100)
230
+
231
+ try:
232
+ ffmpeg.execute()
233
+ except KeyboardInterrupt:
234
+ self._info_callback("interrupted deleting partially downloaded file")
235
+ download_path.unlink()
236
+ raise
237
+
238
+ return download_path
239
+
240
+ def download(
241
+ self,
242
+ stream: "ProviderStream",
243
+ download_path: Path,
244
+ container: Optional[str] = None,
245
+ ffmpeg: bool = False,
246
+ ) -> Path:
247
+ """Generic download function that determines the best way to download a
248
+ specific stream and downloads it. The suffix should be omitted here,
249
+ you can instead use the `container` argument to remux the stream after
250
+ the download, note that this will trigger the `progress_callback`
251
+ again. This function assumes that ffmpeg is installed on the system,
252
+ because if the stream is neither m3u8 or mp4 it will default to
253
+ [ffmpeg_download][anipy_api.download.Downloader.ffmpeg_download].
254
+
255
+ Args:
256
+ stream: The stream to download
257
+ download_path: The path to download the stream to.
258
+ container: The container to remux the video to if it is not already
259
+ correctly muxed, the user must have ffmpeg installed on the system.
260
+ Containers may include all containers supported by FFmpeg e.g. ".mp4", ".mkv" etc...
261
+ ffmpeg: Wheter to automatically default to
262
+ [ffmpeg_download][anipy_api.download.Downloader.ffmpeg_download] for m3u8/hls streams.
263
+
264
+ Returns:
265
+ The path of the resulting file
266
+ """
267
+ download_path.parent.mkdir(parents=True, exist_ok=True)
268
+
269
+ for p in download_path.parent.iterdir():
270
+ if p.with_suffix("").name == download_path.name:
271
+ self._info_callback("Episode is already downloaded, skipping")
272
+ return p
273
+
274
+ if "m3u8" in stream.url:
275
+ if ffmpeg:
276
+ download_path = download_path.with_suffix(container or ".mp4")
277
+ self._info_callback("Using FFMPEG downloader")
278
+ self._info_callback(f"Saving to a {container or '.mp4'} container")
279
+ path = self.ffmpeg_download(stream, download_path)
280
+ else:
281
+ self._info_callback("Using internal M3U8 downloader")
282
+ path = self.m3u8_download(stream, download_path)
283
+ elif "mp4" in stream.url:
284
+ self._info_callback("Using internal MP4 downloader")
285
+ path = self.mp4_download(stream, download_path.with_suffix(".mp4"))
286
+ else:
287
+ self._info_callback(
288
+ "No fitting downloader available for stream, using FFMPEG downloader as fallback"
289
+ )
290
+ path = self.ffmpeg_download(stream, download_path)
291
+
292
+ if container:
293
+ if container == path.suffix:
294
+ return path
295
+ self._info_callback(f"Remuxing to {container} container")
296
+ new_path = path.with_suffix(container)
297
+ download = self.ffmpeg_download(
298
+ ProviderStream(
299
+ str(path), stream.resolution, stream.episode, stream.language
300
+ ),
301
+ new_path,
302
+ )
303
+ path.unlink()
304
+ return download
305
+
306
+ return path
@@ -0,0 +1,93 @@
1
+ from typing import Dict, Optional, TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from anipy_api.provider.base import LanguageTypeEnum
5
+
6
+
7
+ class BeautifulSoupLocationError(Exception):
8
+ """Error that gets raised in the Provider if there are errors with parsing
9
+ HTML content."""
10
+
11
+ def __init__(self, what: str, where: str):
12
+ """__init__ for BeautifulSoupLocationError.
13
+
14
+ Args:
15
+ what: What could not be parsed
16
+ where: The url of the to be parsed content
17
+ """
18
+ super().__init__(f"Could not locate {what} at {where}")
19
+
20
+
21
+ class LangTypeNotAvailableError(Exception):
22
+ """Error that gets raised in the Provider if the specified language type is
23
+ not available."""
24
+
25
+ def __init__(self, identifier: str, provider: str, lang: "LanguageTypeEnum"):
26
+ """__init__ for LangTypeNotAvailableError.
27
+
28
+ Args:
29
+ identifier: Identifier of the Anime
30
+ provider: Name of the Provider
31
+ lang: The language that is not available
32
+ """
33
+ super().__init__(
34
+ f"{str(lang).capitalize()} is not available for identifier `{identifier}` on provider `{provider}`"
35
+ )
36
+
37
+
38
+ class MyAnimeListError(Exception):
39
+ """Error that gets raised by [MyAnimeList][anipy_api.mal.MyAnimeList], this
40
+ may include authentication errors or other HTTP errors."""
41
+
42
+ def __init__(
43
+ self, url: str, status: int, mal_api_error: Optional[Dict] = None
44
+ ) -> None:
45
+ """__init__ for MyAnimeListError.
46
+
47
+ Args:
48
+ url: Requested URL that caused the error
49
+ status: HTTP status code
50
+ mal_api_error: MyAnimeList api error if returned
51
+ """
52
+ error_text = f"Error requesting {url}, status is {status}."
53
+ if mal_api_error:
54
+ error_text = f"{error_text} Additional info from api {mal_api_error}"
55
+
56
+ super().__init__(error_text)
57
+
58
+
59
+ class DownloadError(Exception):
60
+ """Error that gets raised by
61
+ [Downloader][anipy_api.download.Downloader]."""
62
+
63
+ def __init__(self, message: str):
64
+ """__init__ for DownloadError.
65
+
66
+ Args:
67
+ message: Failure reason
68
+ """
69
+ super().__init__(message)
70
+
71
+
72
+ class PlayerError(Exception):
73
+ """Error that gets throws by certain functions in the player module."""
74
+
75
+ def __init__(self, message: str):
76
+ """__init__ for PlayerError.
77
+
78
+ Args:
79
+ message: Failure reason
80
+ """
81
+ super().__init__(message)
82
+
83
+
84
+ class ArgumentError(Exception):
85
+ """Error that gets raised if something is wrong with the arguments provided to a callable."""
86
+
87
+ def __init__(self, message: str):
88
+ """__init__ for ArgumentError
89
+
90
+ Args:
91
+ message: Failure reason
92
+ """
93
+ super().__init__(message)