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.
- anipy_api-3.0.0/PKG-INFO +35 -0
- anipy_api-3.0.0/README.md +4 -0
- anipy_api-3.0.0/pyproject.toml +34 -0
- anipy_api-3.0.0/src/anipy_api/__init__.py +2 -0
- anipy_api-3.0.0/src/anipy_api/anime.py +161 -0
- anipy_api-3.0.0/src/anipy_api/download.py +306 -0
- anipy_api-3.0.0/src/anipy_api/error.py +93 -0
- anipy_api-3.0.0/src/anipy_api/locallist.py +222 -0
- anipy_api-3.0.0/src/anipy_api/mal.py +658 -0
- anipy_api-3.0.0/src/anipy_api/player/__init__.py +4 -0
- anipy_api-3.0.0/src/anipy_api/player/base.py +174 -0
- anipy_api-3.0.0/src/anipy_api/player/player.py +52 -0
- anipy_api-3.0.0/src/anipy_api/player/players/__init__.py +6 -0
- anipy_api-3.0.0/src/anipy_api/player/players/mpv.py +37 -0
- anipy_api-3.0.0/src/anipy_api/player/players/mpv_control.py +67 -0
- anipy_api-3.0.0/src/anipy_api/player/players/syncplay.py +33 -0
- anipy_api-3.0.0/src/anipy_api/player/players/vlc.py +32 -0
- anipy_api-3.0.0/src/anipy_api/provider/__init__.py +32 -0
- anipy_api-3.0.0/src/anipy_api/provider/base.py +200 -0
- anipy_api-3.0.0/src/anipy_api/provider/filter.py +136 -0
- anipy_api-3.0.0/src/anipy_api/provider/provider.py +54 -0
- anipy_api-3.0.0/src/anipy_api/provider/providers/__init__.py +3 -0
- anipy_api-3.0.0/src/anipy_api/provider/providers/gogo_provider.py +363 -0
- anipy_api-3.0.0/src/anipy_api/provider/utils.py +64 -0
anipy_api-3.0.0/PKG-INFO
ADDED
|
@@ -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,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,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)
|