mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-05 20:40:09 -08:00
546 lines
20 KiB
Python
546 lines
20 KiB
Python
import os
|
|
import time
|
|
import re
|
|
import shutil
|
|
from subprocess import Popen, run, PIPE, CompletedProcess
|
|
from typing import Callable
|
|
|
|
from .extras import Logger
|
|
from .animdl_data_helper import (
|
|
filter_broken_streams,
|
|
filter_streams_by_quality,
|
|
path_parser,
|
|
search_output_parser,
|
|
anime_title_percentage_match,
|
|
parse_stream_urls_data,
|
|
)
|
|
from .animdl_exceptions import (
|
|
AnimdlAnimeUrlNotFoundException,
|
|
InvalidAnimdlCommandsException,
|
|
MPVNotFoundException,
|
|
NoValidAnimeStreamsException,
|
|
Python310NotFoundException,
|
|
)
|
|
from .animdl_types import AnimdlAnimeEpisode, AnimdlAnimeUrlAndTitle, AnimdlData
|
|
|
|
|
|
broken_link_pattern = r"https://tools.fast4speed.rsvp/\w*"
|
|
|
|
|
|
def run_mpv_command(*cmds) -> Popen:
|
|
if mpv := shutil.which("mpv"):
|
|
Logger.info({"Animdl Api: Started mpv command"})
|
|
child_process = Popen(
|
|
[mpv, *cmds],
|
|
stderr=PIPE,
|
|
text=True,
|
|
stdout=PIPE,
|
|
)
|
|
return child_process
|
|
else:
|
|
raise MPVNotFoundException("MPV is required to be on path for this to work")
|
|
|
|
|
|
# TODO: WRITE Docs for each method
|
|
class AnimdlApi:
|
|
@classmethod
|
|
def _run_animdl_command(cls, cmds: list[str], capture=True) -> CompletedProcess:
|
|
"""The core abstraction over the animdl cli that executes valid animdl commands
|
|
|
|
Args:
|
|
cmds (list): a list of valid animdl commands and options
|
|
capture (bool, optional): whether to capture the command output or not. Defaults to True.
|
|
|
|
Raises:
|
|
Python310NotFoundException: An exception raised when the machine doesn't have python 3.10 in path which is required by animdls dependencies
|
|
|
|
Returns:
|
|
CompletedProcess: the completed animdl process
|
|
"""
|
|
if py_path := shutil.which("python"):
|
|
Logger.info("Animdl Api: Started Animdl command")
|
|
if capture:
|
|
return run(
|
|
[py_path, "-m", "animdl", *cmds],
|
|
capture_output=True,
|
|
stdin=PIPE,
|
|
text=True,
|
|
)
|
|
else:
|
|
return run([py_path, "-m", "animdl", *cmds])
|
|
else:
|
|
raise Python310NotFoundException(
|
|
"Python 3.10 is required to be in path for this to work"
|
|
)
|
|
|
|
@classmethod
|
|
def _run_animdl_command_and_get_subprocess(cls, cmds: list[str]) -> Popen:
|
|
"""An abstraction over animdl cli but offers more control as compered to _run_animdl_command
|
|
|
|
Args:
|
|
cmds (list[str]): valid animdl commands and options
|
|
|
|
Raises:
|
|
Python310NotFoundException: An exception raised when the machine doesn't have python 3.10 in path which is required by animdls dependencies
|
|
|
|
Returns:
|
|
Popen: returns a subprocess in order to offer more control
|
|
"""
|
|
|
|
# TODO: parse the commands
|
|
parsed_cmds = list(cmds)
|
|
|
|
if py_path := shutil.which("python"):
|
|
Logger.info("Animdl Api: Started Animdl command")
|
|
base_cmds = [py_path, "-m", "animdl"]
|
|
cmds_ = [*base_cmds, *parsed_cmds]
|
|
child_process = Popen(cmds_)
|
|
return child_process
|
|
else:
|
|
raise Python310NotFoundException(
|
|
"Python 3.10 is required to be in path for this to work"
|
|
)
|
|
|
|
@classmethod
|
|
def get_anime_url_by_title(
|
|
cls, actual_user_requested_title: str
|
|
) -> AnimdlAnimeUrlAndTitle:
|
|
"""Searches for the title using animdl and gets the animdl anime url associated with a particular title which is used by animdl for scraping
|
|
|
|
Args:
|
|
actual_user_requested_title (str): any anime title the user wants
|
|
|
|
Raises:
|
|
AnimdlAnimeUrlNotFoundException: raised if no anime title is found
|
|
|
|
Returns:
|
|
AnimdlAnimeTitleAndUrl: The animdl anime url and title for the most likely one the user wants.NOTE: not always correct
|
|
"""
|
|
result = cls._run_animdl_command(["search", actual_user_requested_title])
|
|
possible_animes = search_output_parser(result.stderr)
|
|
if possible_animes:
|
|
most_likely_anime_url_and_title = max(
|
|
possible_animes,
|
|
key=lambda possible_data: anime_title_percentage_match(
|
|
possible_data.anime_title, actual_user_requested_title
|
|
),
|
|
)
|
|
return most_likely_anime_url_and_title # ("title","anime url")
|
|
else:
|
|
raise AnimdlAnimeUrlNotFoundException(
|
|
"The anime your searching for doesnt exist or animdl provider is broken or animdl not in your system path\nTry changing the default provider"
|
|
)
|
|
|
|
@classmethod
|
|
def stream_anime_by_title_on_animdl(
|
|
cls, title: str, episodes_range: str | None = None, quality: str = "best"
|
|
) -> Popen:
|
|
"""Streams the anime title on animdl
|
|
|
|
Args:
|
|
title (str): the anime title you want to stream
|
|
episodes_range (str, optional): the episodes you want to stream; should be a valid animdl range. Defaults to None.
|
|
quality (str, optional): the quality of the stream. Defaults to "best".
|
|
|
|
Returns:
|
|
Popen: the stream child subprocess for mor control
|
|
"""
|
|
|
|
anime = cls.get_anime_url_by_title(title)
|
|
|
|
base_cmds = ["stream", anime[1], "-q", quality]
|
|
cmd = [*base_cmds, "-r", episodes_range] if episodes_range else base_cmds
|
|
return cls._run_animdl_command_and_get_subprocess(cmd)
|
|
|
|
@classmethod
|
|
def stream_anime_with_mpv(
|
|
cls, title: str, episodes_range: str | None = None, quality: str = "best"
|
|
):
|
|
"""Stream an anime directly with mpv without having to interact with animdl cli
|
|
|
|
Args:
|
|
title (str): the anime title you want to stream
|
|
episodes_range (str | None, optional): a valid animdl episodes range you want ito watch. Defaults to None.
|
|
quality (str, optional): the quality of the stream. Defaults to "best".
|
|
|
|
Yields:
|
|
Popen: the child subprocess you currently are watching
|
|
"""
|
|
|
|
anime_data = cls.get_all_stream_urls_by_anime_title(title, episodes_range)
|
|
stream = []
|
|
for episode in anime_data.episodes:
|
|
if streams := filter_broken_streams(episode["streams"]):
|
|
stream = filter_streams_by_quality(streams, quality)
|
|
|
|
episode_title = str(episode["episode"])
|
|
if e_title := stream.get("title"):
|
|
episode_title = f"{episode_title}-{e_title}"
|
|
|
|
window_title = (
|
|
f"{anime_data.anime_title} episode {episode_title}".title()
|
|
)
|
|
|
|
cmds = [stream["stream_url"], f"--title={window_title}"]
|
|
if audio_tracks := stream.get("audio_tracks"):
|
|
tracks = ";".join(audio_tracks)
|
|
cmds = [*cmds, f"--audio-files={tracks}"]
|
|
|
|
if subtitles := stream.get("subtitle"):
|
|
subs = ";".join(subtitles)
|
|
cmds = [*cmds, f"--sub-files={subs}"]
|
|
|
|
Logger.info(
|
|
f"Animdl Api Mpv Streamer: Starting to stream on mpv with commands: {cmds}"
|
|
)
|
|
yield run_mpv_command(*cmds)
|
|
Logger.info(
|
|
f"Animdl Api Mpv Streamer: Finished to stream episode {episode['episode']} on mpv"
|
|
)
|
|
else:
|
|
Logger.info(
|
|
f"Animdl Api Mpv Streamer: Failed to stream episode {episode['episode']} no valid streams"
|
|
)
|
|
yield f"Epiosde {episode['episode']} doesnt have any valid stream links"
|
|
|
|
@classmethod
|
|
def get_all_anime_stream_urls_by_anime_url(
|
|
cls, anime_url: str, episodes_range: str | None = None
|
|
) -> list[AnimdlAnimeEpisode]:
|
|
"""gets all the streams for the animdl url
|
|
|
|
Args:
|
|
anime_url (str): an animdl url used in scraping
|
|
episodes_range (str | None, optional): a valid animdl episodes range. Defaults to None.
|
|
|
|
Returns:
|
|
list[AnimdlAnimeEpisode]: A list of anime episodes gotten from animdl
|
|
"""
|
|
|
|
cmd = (
|
|
["grab", anime_url, "-r", episodes_range]
|
|
if episodes_range
|
|
else ["grab", anime_url]
|
|
)
|
|
result = cls._run_animdl_command(cmd)
|
|
return parse_stream_urls_data(result.stdout) # type: ignore
|
|
|
|
@classmethod
|
|
def get_all_stream_urls_by_anime_title(
|
|
cls, title: str, episodes_range: str | None = None
|
|
) -> AnimdlData:
|
|
"""retrieves all anime stream urls of the given episode range from animdl
|
|
|
|
Args:
|
|
title (str): the anime title
|
|
episodes_range (str, optional): an animdl episodes range. Defaults to None.
|
|
|
|
Returns:
|
|
AnimdlData: The parsed data from animdl grab
|
|
"""
|
|
|
|
possible_anime = cls.get_anime_url_by_title(title)
|
|
return AnimdlData(
|
|
possible_anime.anime_title,
|
|
cls.get_all_anime_stream_urls_by_anime_url(
|
|
possible_anime.animdl_anime_url, episodes_range
|
|
),
|
|
)
|
|
|
|
# TODO: Should i finish??
|
|
@classmethod
|
|
def get_stream_urls_by_anime_title_and_quality(
|
|
cls, title: str, quality="best", episodes_range=None
|
|
):
|
|
(cls.get_all_stream_urls_by_anime_title(title))
|
|
|
|
@classmethod
|
|
def download_anime_by_title(
|
|
cls,
|
|
_anime_title: str,
|
|
on_episode_download_progress: Callable,
|
|
on_episode_download_complete: Callable,
|
|
on_complete: Callable,
|
|
output_path: str,
|
|
episodes_range: str | None = None,
|
|
quality: str = "best",
|
|
) -> tuple[list[int], list[int]]:
|
|
"""Downloads anime either adaptive, progressive, or .m3u streams and uses mpv to achieve this
|
|
|
|
Args:
|
|
_anime_title (str): the anime title you want to download
|
|
on_episode_download_progress (Callable): the callback when a chunk of an episode is downloaded
|
|
on_episode_download_complete (Callable): the callback when an episode has been successfully downloaded
|
|
on_complete (Callable): callback when the downloading process is complete
|
|
output_path (str): the directory | folder to download the anime
|
|
episodes_range (str | None, optional): a valid animdl episode range. Defaults to None.
|
|
quality (str, optional): the anime quality. Defaults to "best".
|
|
|
|
Raises:
|
|
NoValidAnimeStreamsException: raised when no valid streams were found for a particular episode
|
|
|
|
Returns:
|
|
tuple[list[int], list[int]]: a tuple containing successful, and failed downloads list
|
|
"""
|
|
|
|
anime_streams_data = cls.get_all_stream_urls_by_anime_title(
|
|
_anime_title, episodes_range
|
|
)
|
|
|
|
failed_downloads = []
|
|
successful_downloads = []
|
|
|
|
anime_title = anime_streams_data.anime_title.capitalize()
|
|
|
|
# determine and parse download location
|
|
parsed_anime_title = path_parser(anime_title)
|
|
download_location = os.path.join(output_path, parsed_anime_title)
|
|
|
|
if not os.path.exists(download_location):
|
|
os.mkdir(download_location)
|
|
|
|
Logger.info(f"Animdl Api Downloader: Started downloading: {anime_title}")
|
|
for episode in anime_streams_data.episodes:
|
|
episode_number = episode["episode"]
|
|
episode_title = f"Episode {episode_number}"
|
|
try:
|
|
streams = filter_broken_streams(episode["streams"])
|
|
|
|
# raises an exception if no streams for current episodes
|
|
if not streams:
|
|
raise NoValidAnimeStreamsException(
|
|
f"No valid streams were found for episode {episode_number}"
|
|
)
|
|
|
|
episode_stream = filter_streams_by_quality(streams, quality)
|
|
|
|
# determine episode_title
|
|
if _episode_title := episode_stream.get("title"):
|
|
episode_title = f"{episode_title} - {path_parser(_episode_title)}"
|
|
|
|
# determine episode download location
|
|
parsed_episode_title = path_parser(episode_title)
|
|
episode_download_location = os.path.join(
|
|
download_location, parsed_episode_title
|
|
)
|
|
if not os.path.exists(episode_download_location):
|
|
os.mkdir(episode_download_location)
|
|
|
|
# init download process
|
|
stream_url = episode_stream["stream_url"]
|
|
audio_tracks = episode_stream.get("audio_tracks")
|
|
subtitles = episode_stream.get("subtitle")
|
|
|
|
episode_info = {
|
|
"episode": episode_title,
|
|
"anime_title": anime_title,
|
|
}
|
|
|
|
# check if its adaptive or progressive and call the appropriate downloader
|
|
if stream_url and subtitles and audio_tracks:
|
|
Logger.info(
|
|
f"Animdl api Downloader: Downloading adaptive episode {anime_title}-{episode_title}"
|
|
)
|
|
cls.download_adaptive(
|
|
stream_url,
|
|
audio_tracks[0],
|
|
subtitles[0],
|
|
episode_download_location,
|
|
on_episode_download_progress,
|
|
episode_info,
|
|
)
|
|
elif stream_url and subtitles:
|
|
# probably wont occur
|
|
Logger.info(
|
|
f"Animdl api Downloader: downloading ? episode {anime_title}-{episode_title}"
|
|
)
|
|
cls.download_video_and_subtitles(
|
|
stream_url,
|
|
subtitles[0],
|
|
episode_download_location,
|
|
on_episode_download_progress,
|
|
episode_info,
|
|
)
|
|
else:
|
|
Logger.info(
|
|
f"Animdl api Downloader: Downloading progressive episode {anime_title}-{episode_title}"
|
|
)
|
|
cls.download_progressive(
|
|
stream_url,
|
|
episode_download_location,
|
|
episode_info,
|
|
on_episode_download_progress,
|
|
)
|
|
|
|
# epiosode download complete
|
|
on_episode_download_complete(anime_title, episode_title)
|
|
successful_downloads.append(episode_number)
|
|
Logger.info(
|
|
f"Animdl api Downloader: Success in dowloading {anime_title}-{episode_title}"
|
|
)
|
|
except Exception as e:
|
|
Logger.info(
|
|
f"Animdl api Downloader: Failed in dowloading {anime_title}-{episode_title}; reason {e}"
|
|
)
|
|
failed_downloads.append(episode_number)
|
|
|
|
Logger.info(
|
|
f"Animdl api Downloader: Completed in dowloading {anime_title}-{episodes_range}; Successful:{len(successful_downloads)}, Failed:{len(failed_downloads)}"
|
|
)
|
|
on_complete(successful_downloads, failed_downloads, anime_title)
|
|
return (successful_downloads, failed_downloads)
|
|
|
|
@classmethod
|
|
def download_with_mpv(cls, url: str, output_path: str, on_progress: Callable):
|
|
"""The method used to download a remote resource with mpv
|
|
|
|
Args:
|
|
url (str): the url of the remote resource to download
|
|
output_path (str): the location to download the resource to
|
|
on_progress (Callable): the callback when a chunk of the resource is downloaded
|
|
|
|
Returns:
|
|
subprocess return code: the return code of the mpv subprocess
|
|
"""
|
|
|
|
mpv_child_process = run_mpv_command(url, f"--stream-dump={output_path}")
|
|
progress_regex = re.compile(r"\d+/\d+") # eg Dumping 2044776/125359745
|
|
|
|
# extract progress info from mpv
|
|
for stream in mpv_child_process.stderr: # type: ignore
|
|
# Logger.info(f"Animdl Api Downloader: {stream}")
|
|
if progress_matches := progress_regex.findall(stream):
|
|
current_bytes, total_bytes = [
|
|
float(val) for val in progress_matches[0].split("/")
|
|
]
|
|
on_progress(current_bytes, total_bytes)
|
|
return mpv_child_process.returncode
|
|
|
|
@classmethod
|
|
def download_progressive(
|
|
cls,
|
|
video_url: str,
|
|
output_path: str,
|
|
episode_info: dict[str, str],
|
|
on_progress: Callable,
|
|
):
|
|
"""the progressive downloader of mpv
|
|
|
|
Args:
|
|
video_url (str): a video url
|
|
output_path (str): download location
|
|
episode_info (dict[str, str]): the details of the episode we downloading
|
|
on_progress (Callable): the callback when a chunk is downloaded
|
|
|
|
Raises:
|
|
Exception: exception raised when anything goes wrong
|
|
"""
|
|
|
|
episode = (
|
|
path_parser(episode_info["anime_title"])
|
|
+ " - "
|
|
+ path_parser(episode_info["episode"])
|
|
)
|
|
file_name = episode + ".mp4"
|
|
download_location = os.path.join(output_path, file_name)
|
|
on_progress_ = lambda current_bytes, total_bytes: on_progress(
|
|
current_bytes, total_bytes, episode_info
|
|
)
|
|
isfailure = cls.download_with_mpv(video_url, download_location, on_progress_)
|
|
if isfailure:
|
|
raise Exception
|
|
|
|
@classmethod
|
|
def download_adaptive(
|
|
cls,
|
|
video_url: str,
|
|
audio_url: str,
|
|
sub_url: str,
|
|
output_path: str,
|
|
on_progress: Callable,
|
|
episode_info: dict[str, str],
|
|
):
|
|
"""the adaptive downloader
|
|
|
|
Args:
|
|
video_url (str): url of video you want ot download
|
|
audio_url (str): url of audio file you want ot download
|
|
sub_url (str): url of sub file you want ot download
|
|
output_path (str): download location
|
|
on_progress (Callable): the callback when a chunk is downloaded
|
|
episode_info (dict[str, str]): episode details
|
|
|
|
Raises:
|
|
Exception: incase anything goes wrong
|
|
"""
|
|
|
|
on_progress_ = lambda current_bytes, total_bytes: on_progress(
|
|
current_bytes, total_bytes, episode_info
|
|
)
|
|
episode = (
|
|
path_parser(episode_info["anime_title"])
|
|
+ " - "
|
|
+ path_parser(episode_info["episode"])
|
|
)
|
|
sub_filename = episode + ".ass"
|
|
sub_filepath = os.path.join(output_path, sub_filename)
|
|
is_sub_failure = cls.download_with_mpv(sub_url, sub_filepath, on_progress_)
|
|
|
|
audio_filename = episode + ".mp3"
|
|
audio_filepath = os.path.join(output_path, audio_filename)
|
|
is_audio_failure = cls.download_with_mpv(
|
|
audio_url, audio_filepath, on_progress_
|
|
)
|
|
|
|
video_filename = episode + ".mp4"
|
|
video_filepath = os.path.join(output_path, video_filename)
|
|
is_video_failure = cls.download_with_mpv(
|
|
video_url, video_filepath, on_progress_
|
|
)
|
|
|
|
if is_video_failure:
|
|
raise Exception
|
|
|
|
@classmethod
|
|
def download_video_and_subtitles(
|
|
cls,
|
|
video_url: str,
|
|
sub_url: str,
|
|
output_path: str,
|
|
on_progress: Callable,
|
|
episode_info: dict[str, str],
|
|
):
|
|
"""only downloads video and subs
|
|
|
|
Args:
|
|
video_url (str): url of video you want ot download
|
|
sub_url (str): url of sub you want ot download
|
|
output_path (str): the download location
|
|
on_progress (Callable): the callback for when a chunk is downloaded
|
|
episode_info (dict[str, str]): episode details
|
|
|
|
Raises:
|
|
Exception: when anything goes wrong
|
|
"""
|
|
|
|
on_progress_ = lambda current_bytes, total_bytes: on_progress(
|
|
current_bytes, total_bytes, episode_info
|
|
)
|
|
episode = (
|
|
path_parser(episode_info["anime_title"])
|
|
+ " - "
|
|
+ path_parser(episode_info["episode"])
|
|
)
|
|
sub_filename = episode + ".ass"
|
|
sub_filepath = os.path.join(output_path, sub_filename)
|
|
is_sub_failure = cls.download_with_mpv(sub_url, sub_filepath, on_progress_)
|
|
|
|
video_filename = episode + ".mp4"
|
|
video_filepath = os.path.join(output_path, video_filename)
|
|
is_video_failure = cls.download_with_mpv(
|
|
video_url, video_filepath, on_progress_
|
|
)
|
|
|
|
if is_video_failure:
|
|
raise Exception
|