Docs - Anipy Cli API
Docs - Anipy Cli API
Installation¶
Via poetry:
Via pip:
Provider
What is a provider?¶
You can check which providers anipy-cli supports here or here (in the providers
drop-down).
Getting a provider instance¶
To get a provider you can use the list_providers function, get_provider to get a
provider by its string representation or just simply import it directly.
# List providers
for p in list_providers():
if p.NAME == "gogoanime":
# You have to instantiate the provider to use it.
provider = provider()
# If you know the name of the provider you could also do:
provider = get_provider("gogoanime", base_url_override="https://test.com") #
Searching¶
Searching can be done with the get_search method, you can even filter!
results = provider.get_search("frieren") #
Every provider has a FILTER_CAPS attribute, it specifies which filters the provider
supports.
if provider.FILTER_CAPS & ( #
FilterCapabilities.SEASON
| FilterCapabilities.YEAR
| FilterCapabilities.NO_QUERY #
):
filters = Filters( #
year=2023,
season=Season.FALL,
)
fall_2023_anime = provider.get_search("", filters=filters) #
provider = get_provider("gogoanime")
frieren = provider.get_search("frieren")[0]
if provider.FILTER_CAPS & (
FilterCapabilities.SEASON
| FilterCapabilities.YEAR
| FilterCapabilities.NO_QUERY
):
filters = Filters(
year=2023,
season=Season.FALL,
)
fall_2023_anime = provider.get_search("", filters=filters)
if frieren in fall_2023_anime:
print("Frieren is an fall 2023 anime!")
Those are covered in the second example page 2. Anime. The next page describes a
wrapper for the search results that represents an anime, you can still use the
functions directly from the provider but you would probably use them in the Anime
class, but feel free to do it how you want!
Anime
Representing an anime¶
On the last page we learned how to use the provider to search for anime. This page
covers the Anime class, it always represents a single anime.
How to get an Anime object¶
provider = ... #
results = provider.get_search("frieren")
anime = []
for r in results:
anime.append(Anime.from_search_result(provider, r))
# or if you really want:
anime.append(Anime(provider, r.name, r.identifier, r.languages))
Get episodes¶
anime = ...
# List of Episode-type numbers
episodes = anime.get_episodes(lang=LanguageTypeEnum.SUB) #
Either use get_video or get_videos, the difference is that one filters out 1 stream
based on quality. Both return ProviderStream objects.
anime = ...
episode_1_stream = anime.get_video(
episode=1,
lang=LanguageTypeEnum.SUB,
preferred_quality=720 #
)
# or get a list of streams that you can filter yourself
episode_1_streams = anime.get_videos(1, LanguageTypeEnum.SUB)
Downloader
Downloading¶
When downloading, you use the Downloader class, it can handle various stream
formats. When downloading you can choose between several download methods:
download: This method is just a generic method that supports m3u8, mp4 and many
more stream formats. It assumes ffmpeg is installed on the machine please look at
the reference for more info.
m3u8_download: Download a m3u8 playlist.
mp4_download: Download a mp4 stream.
ffmpeg_download: Download any stream supported by ffmpeg. This requires ffmpeg
to be installed on the system!
anime = ...
stream = anime.get_video(1, LanguageTypeEnum.SUB, preferred_quality=1080)
stream=stream,
download_path=Path("~/Downloads"),
container=".mkv", #
maxRetry=3 #
ffmpeg=False #
MyAnimeList
Why?¶
Uhm, I did not find a proper implementation of the MyAnimeList v2 api for python...
so yeah.
Authentication¶
# password
mal = MyAnimeList.from_password_grant( #
user="test",
password="test",
client_id=None #
)
# or refresh token
mal = MyAnimeList.from_rt_grant(
refresh_token="random-gibberish112mnsd8123109",
client_id="more-random-suff1231283123102938" #
Usage¶
frieren = mal.get_anime(52991)
print(frieren.title)
results = mal.get_search( #
"frieren",
limit=20, #
pages=2 #
)
print(results)
user = mal.get_user() #
print(user)
updated_entry = mal.update_anime_list( #
52991,
status=MALMyListStatusEnum.WATCHING,
watched_episodes=2,
tags=["currently-updated"] #
)
print(updated_entry)
frieren_now = mal.get_anime(52991) #
frieren_now.my_list_status.tags # -> ["currently-updated"]
user_watching = mal.get_anime_list(
status_filter=MALMyListStatusEnum.WATCHING #
)
print(user_list)
mal.remove_from_anime_list(52991)
Ok so this is the important part. Imagaine you have a provider anime and want to
add that to your myanimelist, but you do not know which mal anime that is. The
MyAnimeListAdapter class can handle that for you.
BUT this does not always work, there is a possibilty that the adapter can not match
the anime, this class uses the Levenshtein Distance algorithm to calculate the
similiarty between names, you can tweak its parameters and also other stuff to
ensure you get a match.
mal = ...
provider = ... #
use_alternative_names=True #
)
if mal_anime is not None:
print(mal_anime)
)
if anime is not None:
print(anime)
anilist
AniList(client_id=None) ¶
MyAnimeList api client that implements some of the endpoints documented here.
Attributes:
Name Type Description
API_BASE
Parameters:
Name Type Description Default
client_id Optional[str]
Args:
client_id: Overrides the default client id
Info:
Please note that that currently no complex oauth autentication scheme is
implemented, this client uses the client id of the official MyAnimeList
android app, this gives us the ability to login via a username/password
combination. If you pass your own client id you will not be able to use
the [from_implicit_grant][anipy_api.anilist.AniList.from_implicit_grant]
function.
"""
if client_id:
self.CLIENT_ID = client_id
self.AUTH_URL = f"https://anilist.co/api/v2/oauth/authorize?
client_id={self.CLIENT_ID}&response_type=token"
self._access_token = None
self._auth_expire_time = datetime.datetime.min
self._session = Session()
self._session.headers.update(
{
"Content-Type": "application/json",
"Accept": "application/json",
}
)
Returns:
Type Description
AniList
@staticmethod
def from_implicit_grant(
access_token: str,
client_id: Optional[str] = None
) -> "AniList":
"""Authenticate via Implicit Grant to retrieve access token
Returns:
The AniList client object
"""
anilist = AniList(client_id)
anilist._refresh_auth(access_token)
return anilist
get_anime(anime_id) ¶
Parameters:
Name Type Description Default
anime_id int
Returns:
Type Description
AniListAnime
Args:
anime_id: The id of the anime
Returns:
The anime that corresponds to the id
"""
query = """
query ($id: Int) {
Media (id: $id) {
id
media_type: format
num_episodes: episodes
title {
user_preferred: userPreferred
}
alternative_titles: title {
english
native
romaji
}
year: seasonYear
season
my_list_status: mediaListEntry {
entry_id: id
notes
num_episodes_watched: progress
status
score
}
}
}
"""
variables = { "id": anime_id }
request = Request("POST", self.API_BASE, json={ 'query': query, 'variables':
variables })
return AniListAnime.from_dict(self._make_request(request)["data"]["Media"])
get_anime_list(status_filter=None) ¶
Parameters:
Name Type Description Default
status_filter Optional[AniListMyListStatusEnum]
Returns:
Type Description
List[AniListAnime]
def get_anime_list(
self, status_filter: Optional[AniListMyListStatusEnum] = None
) -> List[AniListAnime]:
"""Get the anime list of the currently authenticated user.
Args:
status_filter: A filter that determines which list status is retrieved
Returns:
List of anime in the anime list
"""
query = """
query ($type: MediaType!, $userId: Int!) {
MediaListCollection(type: $type, userId: $userId) {
lists {
entries {
id
media {
id
media_type: format
num_episodes: episodes
title {
user_preferred: userPreferred
}
alternative_titles: title {
english
native
romaji
}
year: seasonYear
season
my_list_status: mediaListEntry {
entry_id: id
notes
num_episodes_watched: progress
status
score
}
}
}
}
}
}
"""
user_id = self.get_user().id
variables = { "type": "ANIME", "userId": user_id }
request = Request("POST", self.API_BASE, json={ 'query': query, 'variables':
variables })
anime_list = []
for group in self._make_request(request)["data"]["MediaListCollection"]
["lists"]:
for entry in group["entries"]:
anime = AniListAnime.from_dict(entry["media"])
user_status = anime.my_list_status.status if anime.my_list_status else
None
if status_filter is None or user_status == status_filter:
anime_list.append(anime)
return anime_list
Search AniList.
Parameters:
Name Type Description Default
search str
Search query
required
limit int
The amount of results per page
20
pages int
The amount of pages to return, note the total number of results is limit times
pages
1
Returns:
Type Description
List[AniListAnime]
def get_search(self, search: str, limit: int = 20, pages: int = 1) ->
List[AniListAnime]:
"""Search AniList.
Args:
search: Search query
limit: The amount of results per page
pages: The amount of pages to return,
note the total number of results is limit times pages
Returns:
A list of search results
"""
query = """
query ($search: String!, $page: Int, $perPage: Int) {
Page (page: $page, perPage: $perPage){
page_info: pageInfo {
currentPage
hasNextPage
}
media (search: $search, type: ANIME) {
id
media_type: format
num_episodes: episodes
title {
user_preferred: userPreferred
}
alternative_titles: title {
english
native
romaji
}
year: seasonYear
season
my_list_status: mediaListEntry {
entry_id: id
notes
num_episodes_watched: progress
status
score
}
}
}
}
"""
anime_list = []
next_page = True
next_page = response.page_info.has_next_page
return anime_list
get_user() ¶
Returns:
Type Description
AniListUser
Returns:
A object with user information
"""
query="""
query {
Viewer {
id
name
picture: avatar {
large
medium
}
}
}
"""
request = Request(
"POST", self.API_BASE, json={ 'query': query }
)
return AniListUser.from_dict(self._make_request(request)["data"]["Viewer"])
anime
Anime(provider, name, identifier, languages) ¶
A wrapper class that represents an anime, it is pretty useful, but you can also
just use the Provider without the wrapper.
Parameters:
Name Type Description Default
provider BaseProvider
Attributes:
Name Type Description
provider BaseProvider
def __init__(
self,
provider: "BaseProvider",
name: str,
identifier: str,
languages: Set["LanguageTypeEnum"],
):
self.provider: "BaseProvider" = provider
self.name: str = name
self.identifier: str = identifier
self.languages: Set["LanguageTypeEnum"] = languages
from_local_list_entry(entry) staticmethod ¶
Parameters:
Name Type Description Default
entry LocalListEntry
Anime Object
Source code in api/src/anipy_api/anime.py
@staticmethod
def from_local_list_entry(entry: "LocalListEntry") -> "Anime":
"""Get Anime object from [LocalListEntry][anipy_api.locallist.LocalListEntry]
Args:
entry: The local list entry
Returns:
Anime Object
"""
provider = next(
filter(lambda x: x.NAME == entry.provider, list_providers()), None
)
if provider is None:
raise ProviderNotAvailableError(entry.provider)
Parameters:
Name Type Description Default
provider BaseProvider
Returns:
Type Description
Anime
Anime object
Source code in api/src/anipy_api/anime.py
@staticmethod
def from_search_result(
provider: "BaseProvider", result: "ProviderSearchResult"
) -> "Anime":
"""Get Anime object from ProviderSearchResult.
Args:
provider: The provider from which the search result stems from
result: The search result
Returns:
Anime object
"""
return Anime(provider, result.name, result.identifier, result.languages)
get_episodes(lang) ¶
Parameters:
Name Type Description Default
lang LanguageTypeEnum
Language type that determines if episodes are searched for the dub or sub version
of the Anime. Use the languages attribute to get supported languages for this
Anime.
required
Returns:
Type Description
List[Episode]
List of Episodes
Source code in api/src/anipy_api/anime.py
Args:
lang: Language type that determines if episodes are searched
for the dub or sub version of the Anime. Use the `languages`
attribute to get supported languages for this Anime.
Returns:
List of Episodes
"""
return self.provider.get_episodes(self.identifier, lang)
get_info() ¶
Returns:
Type Description
ProviderInfoResult
ProviderInfoResult object
Source code in api/src/anipy_api/anime.py
Returns:
ProviderInfoResult object
"""
return self.provider.get_info(self.identifier)
Get a video stream for the specified episode, the quality to return is determined
by the preferred_quality argument or if this is not defined by the best quality
found. To get a list of streams use get_videos.
Parameters:
Name Type Description Default
episode Episode
Language type that determines if streams are searched for the dub or sub version of
the Anime. Use the languages attribute to get supported languages for this Anime.
required
preferred_quality Optional[Union[str, int]]
This may be a integer (e.g. 1080, 720 etc.) or the string "worst" or "best".
None
Returns:
Type Description
ProviderStream
A stream
def get_video(
self,
episode: Episode,
lang: "LanguageTypeEnum",
preferred_quality: Optional[Union[str, int]] = None,
) -> "ProviderStream":
"""Get a video stream for the specified episode, the quality to return
is determined by the `preferred_quality` argument or if this is not
defined by the best quality found. To get a list of streams use
[get_videos][anipy_api.anime.Anime.get_videos].
Args:
episode: The episode to get the stream for
lang: Language type that determines if streams are searched for
the dub or sub version of the Anime. Use the `languages`
attribute to get supported languages for this Anime.
preferred_quality: This may be a integer (e.g. 1080, 720 etc.)
or the string "worst" or "best".
Returns:
A stream
"""
streams = self.provider.get_video(self.identifier, episode, lang)
streams.sort(key=lambda s: s.resolution + (10 if s.subtitle else 0))
if preferred_quality == "worst":
stream = streams[0]
elif preferred_quality == "best":
stream = streams[-1]
elif preferred_quality is None:
stream = streams[-1]
else:
stream = next(
filter(lambda s: s.resolution == preferred_quality, streams), None
)
if stream is None:
stream = streams[-1]
return stream
get_videos(episode, lang) ¶
Parameters:
Name Type Description Default
episode Episode
Language type that determines if streams are searched for the dub or sub version of
the Anime. Use the languages attribute to get supported languages for this Anime.
required
Returns:
Type Description
List[ProviderStream]
def get_videos(
self, episode: Episode, lang: "LanguageTypeEnum"
) -> List["ProviderStream"]:
"""Get a list of video streams for the specified Episode.
Args:
episode: The episode to get the streams for
lang: Language type that determines if streams are searched for
the dub or sub version of the Anime. Use the `languages`
attribute to get supported languages for this Anime.
Returns:
A list of streams sorted by quality
"""
streams = self.provider.get_video(self.identifier, episode, lang)
streams.sort(key=lambda s: s.resolution)
return streams
Parameters:
Name Type Description Default
progress_callback Optional[ProgressCallback]
A callback with an percentage argument, that gets called on download progress.
None
info_callback Optional[InfoCallback]
A callback with a message argument, when certain events cause a non-fatal error (if
none given, alternative fallback is info_callback).
None
Source code in api/src/anipy_api/download.py
def __init__(
self,
progress_callback: Optional[ProgressCallback] = None,
info_callback: Optional[InfoCallback] = None,
soft_error_callback: Optional[InfoCallback] = None,
):
"""__init__ of Downloader.
Args:
progress_callback: A callback with an percentage argument, that gets called
on download progress.
info_callback: A callback with an message argument, that gets called on
certain events.
soft_error_callback: A callback with a message argument, when certain
events cause a non-fatal error (if none given, alternative fallback is
info_callback).
"""
self._progress_callback: ProgressCallback = progress_callback or (
lambda percentage: None
)
self._info_callback: InfoCallback = info_callback or (
lambda message, exc_info=None: None
)
self._soft_error_callback: InfoCallback = (
soft_error_callback
or info_callback
or (lambda message, exc_info=None: None)
)
self._session = requests.Session()
Generic download function that determines the best way to download a specific
stream and downloads it. The suffix should be omitted here, you can instead use the
container argument to remux the stream after the download, note that this will
trigger the progress_callback again. This function assumes that ffmpeg is installed
on the system, because if the stream is neither m3u8 or mp4 it will default to
ffmpeg_download.
Parameters:
Name Type Description Default
stream ProviderStream
The container to remux the video to if it is not already correctly muxed, the user
must have ffmpeg installed on the system. Containers may include all containers
supported by FFmpeg e.g. ".mp4", ".mkv" etc...
None
ffmpeg bool
Called when completing download, not called when file already exsists
None
Returns:
Type Description
Path
def download(
self,
stream: "ProviderStream",
download_path: Path,
container: Optional[str] = None,
ffmpeg: bool = False,
max_retry: int = 3,
post_dl_cb: Optional[PostDownloadCallback] = None,
) -> Path:
"""Generic download function that determines the best way to download a
specific stream and downloads it. The suffix should be omitted here,
you can instead use the `container` argument to remux the stream after
the download, note that this will trigger the `progress_callback`
again. This function assumes that ffmpeg is installed on the system,
because if the stream is neither m3u8 or mp4 it will default to
[ffmpeg_download][anipy_api.download.Downloader.ffmpeg_download].
Args:
stream: The stream to download
download_path: The path to download the stream to.
container: The container to remux the video to if it is not already
correctly muxed, the user must have ffmpeg installed on the system.
Containers may include all containers supported by FFmpeg e.g. ".mp4",
".mkv" etc...
ffmpeg: Wheter to automatically default to
[ffmpeg_download][anipy_api.download.Downloader.ffmpeg_download] for
m3u8/hls streams.
max_retry: The amount of times the API can retry the download
post_dl_cb: Called when completing download, not called when file already
exsists
Returns:
The path of the resulting file
"""
curr_exc: Exception | None = None
post_dl_cb = post_dl_cb or (lambda path, stream: None)
for i in range(max_retry):
try:
path = self._download_single_try(
stream, download_path, post_dl_cb, container, ffmpeg
)
return path
except DownloadError as e:
self._soft_error_callback(str(e))
curr_exc = e
except Exception as e:
self._soft_error_callback(f"An error occurred during download: {e}")
curr_exc = e
self._soft_error_callback(f"{max_retry-i-1} retries remain")
ffmpeg_download(stream, download_path) ¶
Download a stream with FFmpeg, FFmpeg needs to be installed on the system. FFmpeg
will be able to handle about any stream and it is also able to remux the resulting
video. By changing the suffix of the download_path you are able to tell ffmpeg to
remux to a specific container.
Parameters:
Name Type Description Default
stream ProviderStream
The stream
required
download_path Path
Returns:
Type Description
Path
Args:
stream: The stream
download_path: The path to download to including a specific suffix.
Returns:
The download path, this should be the same as the
passed one as ffmpeg will remux to about any container.
"""
ffprobe = (
FFmpeg(executable="ffprobe")
.option("v", 0)
.option("of", "json")
.option("show_program_version")
)
version = json.loads(ffprobe.execute())
version = [
int(''.join(c for c in v if c.isdigit())) for v in
version["program_version"]["version"].split("-")[0].split(".")
]
if len(version) < 3:
version.append(0)
ffprobe = FFmpeg(executable="ffprobe").input(
stream.url, print_format="json", show_format=None
)
if stream.referrer:
ffprobe.option("headers", f"Referer: {stream.referrer}")
if extension_picky:
ffprobe.option("extension_picky", 0)
meta = json.loads(ffprobe.execute())
duration = float(meta["format"]["duration"])
if extension_picky:
output_options.update({"extension_picky": 0})
ffmpeg = (
FFmpeg()
.option("y")
.option("v", "warning")
.option("stats")
.input(stream.url)
.output(
download_path,
output_options
)
)
if stream.referrer:
ffmpeg.option("headers", f"Referer: {stream.referrer}")
@ffmpeg.on("progress")
def on_progress(progress: Progress):
self._progress_callback(progress.time.total_seconds() / duration * 100)
try:
ffmpeg.execute()
except KeyboardInterrupt:
self._info_callback("interrupted deleting partially downloaded file")
download_path.unlink()
raise
return download_path
m3u8_download(stream, download_path) ¶
The suffix of the download path will be replaced (or added) with ".ts", use the
path returned instead of the passed path.
Parameters:
Name Type Description Default
stream ProviderStream
Raises:
Type Description
DownloadError
Returns:
Type Description
Path
The suffix of the download path will be replaced (or added) with
".ts", use the path returned instead of the passed path.
Args:
stream: The m3u8/hls stream
download_path: The path to save the downloaded stream to
Raises:
DownloadError: Raised on download error
Returns:
The path with a ".ts" suffix
"""
temp_folder = download_path.parent / "temp"
temp_folder.mkdir(exist_ok=True)
download_path = download_path.with_suffix(".ts")
res = self._session.get(stream.url, headers={"Referer": stream.referrer})
res.raise_for_status()
counter = 0
counter += 1
self._progress_callback(counter / len(m3u8_content.segments) * 100)
except Exception as e:
# TODO: This gets ignored, because it's in a seperate thread...
raise DownloadError(
f"Encountered this error while downloading: {str(e)}"
)
try:
with ThreadPoolExecutor(max_workers=12) as pool_video:
futures = [
pool_video.submit(download_ts, s) for s in m3u8_content.segments
]
try:
for future in as_completed(futures):
future.result()
except KeyboardInterrupt:
self._info_callback(
"Download Interrupted, cancelling futures, this may take a
while..."
)
pool_video.shutdown(wait=False, cancel_futures=True)
raise
self._info_callback("Parts Downloaded")
self._info_callback("Merging Parts...")
with download_path.open("wb") as merged:
for segment in m3u8_content.segments:
uri = Path(segment.uri)
fname = (
temp_folder / self._get_valid_pathname(uri.stem)
).with_suffix(uri.suffix)
if not fname.is_file():
raise DownloadError(
f"Could not merge, missing a segment of this playlist:
{stream.url}"
)
self._info_callback("Merge Finished")
shutil.rmtree(temp_folder)
return download_path
except KeyboardInterrupt:
self._info_callback("Download Interrupted, deleting partial file.")
download_path.unlink(missing_ok=True)
shutil.rmtree(temp_folder)
raise