mirror of
https://github.com/Benexl/FastAnime.git
synced 2026-02-04 19:11:55 -08:00
feat: stuff happened
This commit is contained in:
5
fastanime/assets/normalizer.json
Normal file
5
fastanime/assets/normalizer.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"allanime":{
|
||||
"1p":"One Piece"
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ commands = {
|
||||
"search": ".search",
|
||||
"download": ".download",
|
||||
"anilist": ".anilist",
|
||||
"queue": ".queue",
|
||||
"service": ".service",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from .anilist import anilist
|
||||
from .config import config
|
||||
from .download import download
|
||||
from .queue import queue
|
||||
from .search import search
|
||||
from .service import service
|
||||
|
||||
__all__ = ["config", "search", "download", "anilist"]
|
||||
__all__ = ["config", "search", "download", "anilist", "queue", "service"]
|
||||
|
||||
@@ -1,477 +0,0 @@
|
||||
sorts_available = [
|
||||
"ID",
|
||||
"ID_DESC",
|
||||
"TITLE_ROMAJI",
|
||||
"TITLE_ROMAJI_DESC",
|
||||
"TITLE_ENGLISH",
|
||||
"TITLE_ENGLISH_DESC",
|
||||
"TITLE_NATIVE",
|
||||
"TITLE_NATIVE_DESC",
|
||||
"TYPE",
|
||||
"TYPE_DESC",
|
||||
"FORMAT",
|
||||
"FORMAT_DESC",
|
||||
"START_DATE",
|
||||
"START_DATE_DESC",
|
||||
"END_DATE",
|
||||
"END_DATE_DESC",
|
||||
"SCORE",
|
||||
"SCORE_DESC",
|
||||
"POPULARITY",
|
||||
"POPULARITY_DESC",
|
||||
"TRENDING",
|
||||
"TRENDING_DESC",
|
||||
"EPISODES",
|
||||
"EPISODES_DESC",
|
||||
"DURATION",
|
||||
"DURATION_DESC",
|
||||
"STATUS",
|
||||
"STATUS_DESC",
|
||||
"CHAPTERS",
|
||||
"CHAPTERS_DESC",
|
||||
"VOLUMES",
|
||||
"VOLUMES_DESC",
|
||||
"UPDATED_AT",
|
||||
"UPDATED_AT_DESC",
|
||||
"SEARCH_MATCH",
|
||||
"FAVOURITES",
|
||||
"FAVOURITES_DESC",
|
||||
]
|
||||
|
||||
media_statuses_available = [
|
||||
"FINISHED",
|
||||
"RELEASING",
|
||||
"NOT_YET_RELEASED",
|
||||
"CANCELLED",
|
||||
"HIATUS",
|
||||
]
|
||||
seasons_available = ["WINTER", "SPRING", "SUMMER", "FALL"]
|
||||
genres_available = [
|
||||
"Action",
|
||||
"Adventure",
|
||||
"Comedy",
|
||||
"Drama",
|
||||
"Ecchi",
|
||||
"Fantasy",
|
||||
"Horror",
|
||||
"Mahou Shoujo",
|
||||
"Mecha",
|
||||
"Music",
|
||||
"Mystery",
|
||||
"Psychological",
|
||||
"Romance",
|
||||
"Sci-Fi",
|
||||
"Slice of Life",
|
||||
"Sports",
|
||||
"Supernatural",
|
||||
"Thriller",
|
||||
"Hentai",
|
||||
]
|
||||
media_formats_available = [
|
||||
"TV",
|
||||
"TV_SHORT",
|
||||
"MOVIE",
|
||||
"SPECIAL",
|
||||
"OVA",
|
||||
"MUSIC",
|
||||
"NOVEL",
|
||||
"ONE_SHOT",
|
||||
]
|
||||
years_available = [
|
||||
"1900",
|
||||
"1910",
|
||||
"1920",
|
||||
"1930",
|
||||
"1940",
|
||||
"1950",
|
||||
"1960",
|
||||
"1970",
|
||||
"1980",
|
||||
"1990",
|
||||
"2000",
|
||||
"2004",
|
||||
"2005",
|
||||
"2006",
|
||||
"2007",
|
||||
"2008",
|
||||
"2009",
|
||||
"2010",
|
||||
"2011",
|
||||
"2012",
|
||||
"2013",
|
||||
"2014",
|
||||
"2015",
|
||||
"2016",
|
||||
"2017",
|
||||
"2018",
|
||||
"2019",
|
||||
"2020",
|
||||
"2021",
|
||||
"2022",
|
||||
"2023",
|
||||
"2024",
|
||||
"2025",
|
||||
]
|
||||
|
||||
tags_available = {
|
||||
"Cast": ["Polyamorous"],
|
||||
"Cast Main Cast": [
|
||||
"Anti-Hero",
|
||||
"Elderly Protagonist",
|
||||
"Ensemble Cast",
|
||||
"Estranged Family",
|
||||
"Female Protagonist",
|
||||
"Male Protagonist",
|
||||
"Primarily Adult Cast",
|
||||
"Primarily Animal Cast",
|
||||
"Primarily Child Cast",
|
||||
"Primarily Female Cast",
|
||||
"Primarily Male Cast",
|
||||
"Primarily Teen Cast",
|
||||
],
|
||||
"Cast Traits": [
|
||||
"Age Regression",
|
||||
"Agender",
|
||||
"Aliens",
|
||||
"Amnesia",
|
||||
"Angels",
|
||||
"Anthropomorphism",
|
||||
"Aromantic",
|
||||
"Arranged Marriage",
|
||||
"Artificial Intelligence",
|
||||
"Asexual",
|
||||
"Butler",
|
||||
"Centaur",
|
||||
"Chimera",
|
||||
"Chuunibyou",
|
||||
"Clone",
|
||||
"Cosplay",
|
||||
"Cowboys",
|
||||
"Crossdressing",
|
||||
"Cyborg",
|
||||
"Delinquents",
|
||||
"Demons",
|
||||
"Detective",
|
||||
"Dinosaurs",
|
||||
"Disability",
|
||||
"Dissociative Identities",
|
||||
"Dragons",
|
||||
"Dullahan",
|
||||
"Elf",
|
||||
"Fairy",
|
||||
"Femboy",
|
||||
"Ghost",
|
||||
"Goblin",
|
||||
"Gods",
|
||||
"Gyaru",
|
||||
"Hikikomori",
|
||||
"Homeless",
|
||||
"Idol",
|
||||
"Kemonomimi",
|
||||
"Kuudere",
|
||||
"Maids",
|
||||
"Mermaid",
|
||||
"Monster Boy",
|
||||
"Monster Girl",
|
||||
"Nekomimi",
|
||||
"Ninja",
|
||||
"Nudity",
|
||||
"Nun",
|
||||
"Office Lady",
|
||||
"Oiran",
|
||||
"Ojou-sama",
|
||||
"Orphan",
|
||||
"Pirates",
|
||||
"Robots",
|
||||
"Samurai",
|
||||
"Shrine Maiden",
|
||||
"Skeleton",
|
||||
"Succubus",
|
||||
"Tanned Skin",
|
||||
"Teacher",
|
||||
"Tomboy",
|
||||
"Transgender",
|
||||
"Tsundere",
|
||||
"Twins",
|
||||
"Vampire",
|
||||
"Veterinarian",
|
||||
"Vikings",
|
||||
"Villainess",
|
||||
"VTuber",
|
||||
"Werewolf",
|
||||
"Witch",
|
||||
"Yandere",
|
||||
"Zombie",
|
||||
],
|
||||
"Demographic": ["Josei", "Kids", "Seinen", "Shoujo", "Shounen"],
|
||||
"Setting": ["Matriarchy"],
|
||||
"Setting Scene": [
|
||||
"Bar",
|
||||
"Boarding School",
|
||||
"Circus",
|
||||
"Coastal",
|
||||
"College",
|
||||
"Desert",
|
||||
"Dungeon",
|
||||
"Foreign",
|
||||
"Inn",
|
||||
"Konbini",
|
||||
"Natural Disaster",
|
||||
"Office",
|
||||
"Outdoor",
|
||||
"Prison",
|
||||
"Restaurant",
|
||||
"Rural",
|
||||
"School",
|
||||
"School Club",
|
||||
"Snowscape",
|
||||
"Urban",
|
||||
"Work",
|
||||
],
|
||||
"Setting Time": [
|
||||
"Achronological Order",
|
||||
"Anachronism",
|
||||
"Ancient China",
|
||||
"Dystopian",
|
||||
"Historical",
|
||||
"Time Skip",
|
||||
],
|
||||
"Setting Universe": [
|
||||
"Afterlife",
|
||||
"Alternate Universe",
|
||||
"Augmented Reality",
|
||||
"Omegaverse",
|
||||
"Post-Apocalyptic",
|
||||
"Space",
|
||||
"Urban Fantasy",
|
||||
"Virtual World",
|
||||
],
|
||||
"Technical": [
|
||||
"4-koma",
|
||||
"Achromatic",
|
||||
"Advertisement",
|
||||
"Anthology",
|
||||
"CGI",
|
||||
"Episodic",
|
||||
"Flash",
|
||||
"Full CGI",
|
||||
"Full Color",
|
||||
"No Dialogue",
|
||||
"Non-fiction",
|
||||
"POV",
|
||||
"Puppetry",
|
||||
"Rotoscoping",
|
||||
"Stop Motion",
|
||||
],
|
||||
"Theme Action": [
|
||||
"Archery",
|
||||
"Battle Royale",
|
||||
"Espionage",
|
||||
"Fugitive",
|
||||
"Guns",
|
||||
"Martial Arts",
|
||||
"Spearplay",
|
||||
"Swordplay",
|
||||
],
|
||||
"Theme Arts": [
|
||||
"Acting",
|
||||
"Calligraphy",
|
||||
"Classic Literature",
|
||||
"Drawing",
|
||||
"Fashion",
|
||||
"Food",
|
||||
"Makeup",
|
||||
"Photography",
|
||||
"Rakugo",
|
||||
"Writing",
|
||||
],
|
||||
"Theme Arts-Music": [
|
||||
"Band",
|
||||
"Classical Music",
|
||||
"Dancing",
|
||||
"Hip-hop Music",
|
||||
"Jazz Music",
|
||||
"Metal Music",
|
||||
"Musical Theater",
|
||||
"Rock Music",
|
||||
],
|
||||
"Theme Comedy": ["Parody", "Satire", "Slapstick", "Surreal Comedy"],
|
||||
"Theme Drama": [
|
||||
"Bullying",
|
||||
"Class Struggle",
|
||||
"Coming of Age",
|
||||
"Conspiracy",
|
||||
"Eco-Horror",
|
||||
"Fake Relationship",
|
||||
"Kingdom Management",
|
||||
"Rehabilitation",
|
||||
"Revenge",
|
||||
"Suicide",
|
||||
"Tragedy",
|
||||
],
|
||||
"Theme Fantasy": [
|
||||
"Alchemy",
|
||||
"Body Swapping",
|
||||
"Cultivation",
|
||||
"Fairy Tale",
|
||||
"Henshin",
|
||||
"Isekai",
|
||||
"Kaiju",
|
||||
"Magic",
|
||||
"Mythology",
|
||||
"Necromancy",
|
||||
"Shapeshifting",
|
||||
"Steampunk",
|
||||
"Super Power",
|
||||
"Superhero",
|
||||
"Wuxia",
|
||||
"Youkai",
|
||||
],
|
||||
"Theme Game": ["Board Game", "E-Sports", "Video Games"],
|
||||
"Theme Game-Card & Board Game": [
|
||||
"Card Battle",
|
||||
"Go",
|
||||
"Karuta",
|
||||
"Mahjong",
|
||||
"Poker",
|
||||
"Shogi",
|
||||
],
|
||||
"Theme Game-Sport": [
|
||||
"Acrobatics",
|
||||
"Airsoft",
|
||||
"American Football",
|
||||
"Athletics",
|
||||
"Badminton",
|
||||
"Baseball",
|
||||
"Basketball",
|
||||
"Bowling",
|
||||
"Boxing",
|
||||
"Cheerleading",
|
||||
"Cycling",
|
||||
"Fencing",
|
||||
"Fishing",
|
||||
"Fitness",
|
||||
"Football",
|
||||
"Golf",
|
||||
"Handball",
|
||||
"Ice Skating",
|
||||
"Judo",
|
||||
"Lacrosse",
|
||||
"Parkour",
|
||||
"Rugby",
|
||||
"Scuba Diving",
|
||||
"Skateboarding",
|
||||
"Sumo",
|
||||
"Surfing",
|
||||
"Swimming",
|
||||
"Table Tennis",
|
||||
"Tennis",
|
||||
"Volleyball",
|
||||
"Wrestling",
|
||||
],
|
||||
"Theme Other": [
|
||||
"Adoption",
|
||||
"Animals",
|
||||
"Astronomy",
|
||||
"Autobiographical",
|
||||
"Biographical",
|
||||
"Body Horror",
|
||||
"Cannibalism",
|
||||
"Chibi",
|
||||
"Cosmic Horror",
|
||||
"Crime",
|
||||
"Crossover",
|
||||
"Death Game",
|
||||
"Denpa",
|
||||
"Drugs",
|
||||
"Economics",
|
||||
"Educational",
|
||||
"Environmental",
|
||||
"Ero Guro",
|
||||
"Filmmaking",
|
||||
"Found Family",
|
||||
"Gambling",
|
||||
"Gender Bending",
|
||||
"Gore",
|
||||
"Language Barrier",
|
||||
"LGBTQ+ Themes",
|
||||
"Lost Civilization",
|
||||
"Marriage",
|
||||
"Medicine",
|
||||
"Memory Manipulation",
|
||||
"Meta",
|
||||
"Mountaineering",
|
||||
"Noir",
|
||||
"Otaku Culture",
|
||||
"Pandemic",
|
||||
"Philosophy",
|
||||
"Politics",
|
||||
"Proxy Battle",
|
||||
"Psychosexual",
|
||||
"Reincarnation",
|
||||
"Religion",
|
||||
"Royal Affairs",
|
||||
"Slavery",
|
||||
"Software Development",
|
||||
"Survival",
|
||||
"Terrorism",
|
||||
"Torture",
|
||||
"Travel",
|
||||
"War",
|
||||
],
|
||||
"Theme Other-Organisations": [
|
||||
"Assassins",
|
||||
"Criminal Organization",
|
||||
"Cult",
|
||||
"Firefighters",
|
||||
"Gangs",
|
||||
"Mafia",
|
||||
"Military",
|
||||
"Police",
|
||||
"Triads",
|
||||
"Yakuza",
|
||||
],
|
||||
"Theme Other-Vehicle": [
|
||||
"Aviation",
|
||||
"Cars",
|
||||
"Mopeds",
|
||||
"Motorcycles",
|
||||
"Ships",
|
||||
"Tanks",
|
||||
"Trains",
|
||||
],
|
||||
"Theme Romance": [
|
||||
"Age Gap",
|
||||
"Bisexual",
|
||||
"Boys' Love",
|
||||
"Female Harem",
|
||||
"Heterosexual",
|
||||
"Love Triangle",
|
||||
"Male Harem",
|
||||
"Matchmaking",
|
||||
"Mixed Gender Harem",
|
||||
"Teens' Love",
|
||||
"Unrequited Love",
|
||||
"Yuri",
|
||||
],
|
||||
"Theme Sci Fi": [
|
||||
"Cyberpunk",
|
||||
"Space Opera",
|
||||
"Time Loop",
|
||||
"Time Manipulation",
|
||||
"Tokusatsu",
|
||||
],
|
||||
"Theme Sci Fi-Mecha": ["Real Robot", "Super Robot"],
|
||||
"Theme Slice of Life": [
|
||||
"Agriculture",
|
||||
"Cute Boys Doing Cute Things",
|
||||
"Cute Girls Doing Cute Things",
|
||||
"Family Life",
|
||||
"Horticulture",
|
||||
"Iyashikei",
|
||||
"Parenthood",
|
||||
],
|
||||
}
|
||||
tags_available_list = []
|
||||
for tag_category, tags_in_category in tags_available.items():
|
||||
tags_available_list.extend(tags_in_category)
|
||||
@@ -1,398 +0,0 @@
|
||||
import click
|
||||
|
||||
from ...utils.completion_functions import anime_titles_shell_complete
|
||||
from .data import (
|
||||
genres_available,
|
||||
media_formats_available,
|
||||
media_statuses_available,
|
||||
seasons_available,
|
||||
sorts_available,
|
||||
tags_available_list,
|
||||
years_available,
|
||||
)
|
||||
|
||||
|
||||
@click.command(
|
||||
help="download anime using anilists api to get the titles",
|
||||
short_help="download anime with anilist intergration",
|
||||
)
|
||||
@click.option("--title", "-t", shell_complete=anime_titles_shell_complete)
|
||||
@click.option(
|
||||
"--season",
|
||||
help="The season the media was released",
|
||||
type=click.Choice(seasons_available),
|
||||
)
|
||||
@click.option(
|
||||
"--status",
|
||||
"-S",
|
||||
help="The media status of the anime",
|
||||
multiple=True,
|
||||
type=click.Choice(media_statuses_available),
|
||||
)
|
||||
@click.option(
|
||||
"--sort",
|
||||
"-s",
|
||||
help="What to sort the search results on",
|
||||
type=click.Choice(sorts_available),
|
||||
)
|
||||
@click.option(
|
||||
"--genres",
|
||||
"-g",
|
||||
multiple=True,
|
||||
help="the genres to filter by",
|
||||
type=click.Choice(genres_available),
|
||||
)
|
||||
@click.option(
|
||||
"--tags",
|
||||
"-T",
|
||||
multiple=True,
|
||||
help="the tags to filter by",
|
||||
type=click.Choice(tags_available_list),
|
||||
)
|
||||
@click.option(
|
||||
"--media-format",
|
||||
"-f",
|
||||
multiple=True,
|
||||
help="Media format",
|
||||
type=click.Choice(media_formats_available),
|
||||
)
|
||||
@click.option(
|
||||
"--year",
|
||||
"-y",
|
||||
type=click.Choice(years_available),
|
||||
help="the year the media was released",
|
||||
)
|
||||
@click.option(
|
||||
"--on-list/--not-on-list",
|
||||
"-L/-no-L",
|
||||
help="Whether the anime should be in your list or not",
|
||||
type=bool,
|
||||
)
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
help="A range of episodes to download (start-end)",
|
||||
)
|
||||
@click.option(
|
||||
"--force-unknown-ext",
|
||||
"-F",
|
||||
help="This option forces yt-dlp to download extensions its not aware of",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--silent/--no-silent",
|
||||
"-q/-V",
|
||||
type=bool,
|
||||
help="Download silently (during download)",
|
||||
default=True,
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Download verbosely (everywhere)")
|
||||
@click.option(
|
||||
"--merge", "-m", is_flag=True, help="Merge the subfile with video using ffmpeg"
|
||||
)
|
||||
@click.option(
|
||||
"--clean",
|
||||
"-c",
|
||||
is_flag=True,
|
||||
help="After merging delete the original files",
|
||||
)
|
||||
@click.option(
|
||||
"--wait-time",
|
||||
"-w",
|
||||
type=int,
|
||||
help="The amount of time to wait after downloading is complete before the screen is completely cleared",
|
||||
default=60,
|
||||
)
|
||||
@click.option(
|
||||
"--prompt/--no-prompt",
|
||||
help="Whether to prompt for anything instead just do the best thing",
|
||||
default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--force-ffmpeg",
|
||||
is_flag=True,
|
||||
help="Force the use of FFmpeg for downloading (supports large variety of streams but slower)",
|
||||
)
|
||||
@click.option(
|
||||
"--hls-use-mpegts",
|
||||
is_flag=True,
|
||||
help="Use mpegts for hls streams, resulted in .ts file (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)",
|
||||
)
|
||||
@click.option(
|
||||
"--hls-use-h264",
|
||||
is_flag=True,
|
||||
help="Use H.264 (MP4) for hls streams, resulted in .mp4 file (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)",
|
||||
)
|
||||
@click.option(
|
||||
"--max-results", "-M", type=int, help="The maximum number of results to show"
|
||||
)
|
||||
@click.pass_obj
|
||||
def download(
|
||||
config,
|
||||
title,
|
||||
season,
|
||||
status,
|
||||
sort,
|
||||
genres,
|
||||
tags,
|
||||
media_format,
|
||||
year,
|
||||
on_list,
|
||||
episode_range,
|
||||
force_unknown_ext,
|
||||
silent,
|
||||
verbose,
|
||||
merge,
|
||||
clean,
|
||||
wait_time,
|
||||
prompt,
|
||||
force_ffmpeg,
|
||||
hls_use_mpegts,
|
||||
hls_use_h264,
|
||||
max_results,
|
||||
):
|
||||
from rich import print
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
force_ffmpeg |= hls_use_mpegts or hls_use_h264
|
||||
|
||||
success, anilist_search_results = AniList.search(
|
||||
query=title,
|
||||
sort=sort,
|
||||
status_in=list(status),
|
||||
genre_in=list(genres),
|
||||
season=season,
|
||||
tag_in=list(tags),
|
||||
seasonYear=year,
|
||||
format_in=list(media_format),
|
||||
on_list=on_list,
|
||||
max_results=max_results,
|
||||
)
|
||||
if success:
|
||||
import time
|
||||
|
||||
from rich.progress import Progress
|
||||
from thefuzz import fuzz
|
||||
|
||||
from ....BaseAnimeProvider import BaseAnimeProvider
|
||||
from ....libs.anime_provider.types import Anime
|
||||
from ....libs.fzf import fzf
|
||||
from ....Utility.data import anime_normalizer
|
||||
from ....Utility.downloader.downloader import downloader
|
||||
from ...utils.tools import exit_app
|
||||
from ...utils.utils import (
|
||||
filter_by_quality,
|
||||
fuzzy_inquirer,
|
||||
move_preferred_subtitle_lang_to_top,
|
||||
)
|
||||
|
||||
anime_provider = BaseAnimeProvider(config.provider)
|
||||
anilist_anime_info = None
|
||||
|
||||
translation_type = config.translation_type
|
||||
download_dir = config.downloads_dir
|
||||
anime_titles = [
|
||||
(anime["title"]["romaji"] or anime["title"]["english"])
|
||||
for anime in anilist_search_results["data"]["Page"]["media"]
|
||||
]
|
||||
print(f"[green bold]Queued:[/] {anime_titles}")
|
||||
for i, anime_title in enumerate(anime_titles):
|
||||
print(f"[green bold]Now Downloading: [/] {anime_title}")
|
||||
# ---- search for anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Search Results...", total=None)
|
||||
search_results = anime_provider.search_for_anime(
|
||||
anime_title, translation_type=translation_type
|
||||
)
|
||||
if not search_results:
|
||||
print(f"No search results found from provider for {anime_title}")
|
||||
continue
|
||||
search_results = search_results["results"]
|
||||
if not search_results:
|
||||
print("Nothing muches your search term")
|
||||
continue
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result
|
||||
for search_result in search_results
|
||||
}
|
||||
|
||||
if config.auto_select:
|
||||
selected_anime_title = max(
|
||||
search_results_.keys(),
|
||||
key=lambda title: fuzz.ratio(
|
||||
anime_normalizer.get(title, title), anime_title
|
||||
),
|
||||
)
|
||||
print("[cyan]Auto selecting:[/] ", selected_anime_title)
|
||||
else:
|
||||
choices = list(search_results_.keys())
|
||||
if config.use_fzf:
|
||||
selected_anime_title = fzf.run(
|
||||
choices, "Please Select title", "FastAnime"
|
||||
)
|
||||
else:
|
||||
selected_anime_title = fuzzy_inquirer(
|
||||
choices,
|
||||
"Please Select title",
|
||||
)
|
||||
|
||||
# ---- fetch anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Anime...", total=None)
|
||||
anime: Anime | None = anime_provider.get_anime(
|
||||
search_results_[selected_anime_title]["id"]
|
||||
)
|
||||
if not anime:
|
||||
print(f"Failed to fetch anime {selected_anime_title}")
|
||||
continue
|
||||
|
||||
episodes = sorted(
|
||||
anime["availableEpisodesDetail"][config.translation_type], key=float
|
||||
)
|
||||
# where the magic happens
|
||||
if episode_range:
|
||||
if ":" in episode_range:
|
||||
ep_range_tuple = episode_range.split(":")
|
||||
if len(ep_range_tuple) == 2 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end)
|
||||
]
|
||||
elif len(ep_range_tuple) == 3 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end, step = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end) : int(step)
|
||||
]
|
||||
else:
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
if episodes_start.strip():
|
||||
episodes_range = episodes[int(episodes_start) :]
|
||||
elif episodes_end.strip():
|
||||
episodes_range = episodes[: int(episodes_end)]
|
||||
else:
|
||||
episodes_range = episodes
|
||||
else:
|
||||
episodes_range = episodes[int(episode_range) :]
|
||||
print(f"[green bold]Downloading: [/] {episodes_range}")
|
||||
|
||||
else:
|
||||
episodes_range = sorted(episodes, key=float)
|
||||
|
||||
if config.normalize_titles:
|
||||
anilist_anime_info = anilist_search_results["data"]["Page"]["media"][i]
|
||||
|
||||
# lets download em
|
||||
for episode in episodes_range:
|
||||
try:
|
||||
episode = str(episode)
|
||||
if episode not in episodes:
|
||||
print(
|
||||
f"[cyan]Warning[/]: Episode {episode} not found, skipping"
|
||||
)
|
||||
continue
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Episode Streams...", total=None)
|
||||
streams = anime_provider.get_episode_streams(
|
||||
anime["id"], episode, config.translation_type
|
||||
)
|
||||
if not streams:
|
||||
print("No streams skipping")
|
||||
continue
|
||||
# ---- fetch servers ----
|
||||
if config.server == "top":
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching top server...", total=None)
|
||||
server_name = next(streams, None)
|
||||
if not server_name:
|
||||
print("Sth went wrong when fetching the server")
|
||||
continue
|
||||
stream_link = filter_by_quality(
|
||||
config.quality, server_name["links"]
|
||||
)
|
||||
if not stream_link:
|
||||
print("[yellow bold]WARNING:[/] No streams found")
|
||||
time.sleep(1)
|
||||
print("Continuing...")
|
||||
continue
|
||||
link = stream_link["link"]
|
||||
provider_headers = server_name["headers"]
|
||||
episode_title = server_name["episode_title"]
|
||||
subtitles = server_name["subtitles"]
|
||||
else:
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching servers", total=None)
|
||||
# prompt for server selection
|
||||
servers = {server["server"]: server for server in streams}
|
||||
servers_names = list(servers.keys())
|
||||
if config.server in servers_names:
|
||||
server_name = config.server
|
||||
elif config.use_fzf:
|
||||
server_name = fzf.run(servers_names, "Select an link")
|
||||
else:
|
||||
server_name = fuzzy_inquirer(
|
||||
servers_names,
|
||||
"Select link",
|
||||
)
|
||||
stream_link = filter_by_quality(
|
||||
config.quality, servers[server_name]["links"]
|
||||
)
|
||||
if not stream_link:
|
||||
print("[yellow bold]WARNING:[/] No streams found")
|
||||
time.sleep(1)
|
||||
print("Continuing...")
|
||||
continue
|
||||
link = stream_link["link"]
|
||||
provider_headers = servers[server_name]["headers"]
|
||||
|
||||
subtitles = servers[server_name]["subtitles"]
|
||||
episode_title = servers[server_name]["episode_title"]
|
||||
|
||||
if anilist_anime_info:
|
||||
selected_anime_title = (
|
||||
anilist_anime_info["title"][config.preferred_language]
|
||||
or anilist_anime_info["title"]["romaji"]
|
||||
or anilist_anime_info["title"]["english"]
|
||||
)
|
||||
import re
|
||||
|
||||
for episode_detail in anilist_anime_info["streamingEpisodes"]:
|
||||
if re.match(
|
||||
f".*Episode {episode} .*", episode_detail["title"]
|
||||
):
|
||||
episode_title = episode_detail["title"]
|
||||
break
|
||||
print(f"[purple]Now Downloading:[/] {episode_title}")
|
||||
subtitles = move_preferred_subtitle_lang_to_top(
|
||||
subtitles, config.sub_lang
|
||||
)
|
||||
downloader._download_file(
|
||||
link,
|
||||
selected_anime_title,
|
||||
episode_title,
|
||||
download_dir,
|
||||
silent,
|
||||
vid_format=config.format,
|
||||
force_unknown_ext=force_unknown_ext,
|
||||
verbose=verbose,
|
||||
headers=provider_headers,
|
||||
sub=subtitles[0]["url"] if subtitles else "",
|
||||
merge=merge,
|
||||
clean=clean,
|
||||
prompt=prompt,
|
||||
force_ffmpeg=force_ffmpeg,
|
||||
hls_use_mpegts=hls_use_mpegts,
|
||||
hls_use_h264=hls_use_h264,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
time.sleep(1)
|
||||
print("Continuing...")
|
||||
print("Done Downloading")
|
||||
time.sleep(wait_time)
|
||||
exit_app()
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
print("Failed to search for anime", anilist_search_results)
|
||||
exit(1)
|
||||
@@ -1,358 +0,0 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ...utils.completion_functions import downloaded_anime_titles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="View and watch your downloads using mpv",
|
||||
short_help="Watch downloads",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
fastanime downloads
|
||||
\b
|
||||
# view individual episodes
|
||||
fastanime downloads --view-episodes
|
||||
# --- or ---
|
||||
fastanime downloads -v
|
||||
\b
|
||||
# to set seek time when using ffmpegthumbnailer for local previews
|
||||
# -1 means random and is the default
|
||||
fastanime downloads --time-to-seek <intRange(-1,100)>
|
||||
# --- or ---
|
||||
fastanime downloads -t <intRange(-1,100)>
|
||||
\b
|
||||
# to watch a specific title
|
||||
# be sure to get the completions for the best experience
|
||||
fastanime downloads --title <title>
|
||||
\b
|
||||
# to get the path to the downloads folder set
|
||||
fastanime downloads --path
|
||||
# useful when you want to use the value for other programs
|
||||
""",
|
||||
)
|
||||
@click.option("--path", "-p", help="print the downloads folder and exit", is_flag=True)
|
||||
@click.option(
|
||||
"--title",
|
||||
"-T",
|
||||
shell_complete=downloaded_anime_titles,
|
||||
help="watch a specific title",
|
||||
)
|
||||
@click.option("--view-episodes", "-v", help="View individual episodes", is_flag=True)
|
||||
@click.option(
|
||||
"--ffmpegthumbnailer-seek-time",
|
||||
"--time-to-seek",
|
||||
"-t",
|
||||
type=click.IntRange(-1, 100),
|
||||
help="ffmpegthumbnailer seek time",
|
||||
)
|
||||
@click.pass_obj
|
||||
def downloads(
|
||||
config: "Config", path: bool, title, view_episodes, ffmpegthumbnailer_seek_time
|
||||
):
|
||||
import os
|
||||
|
||||
from ....cli.utils.mpv import run_mpv
|
||||
from ....libs.fzf import fzf
|
||||
from ....libs.rofi import Rofi
|
||||
from ....Utility.utils import sort_by_episode_number
|
||||
from ...utils.tools import exit_app
|
||||
from ...utils.utils import fuzzy_inquirer
|
||||
|
||||
if not ffmpegthumbnailer_seek_time:
|
||||
ffmpegthumbnailer_seek_time = config.ffmpegthumbnailer_seek_time
|
||||
USER_VIDEOS_DIR = config.downloads_dir
|
||||
if path:
|
||||
print(USER_VIDEOS_DIR)
|
||||
return
|
||||
if not os.path.exists(USER_VIDEOS_DIR):
|
||||
print("Downloads directory specified does not exist")
|
||||
return
|
||||
anime_downloads = sorted(
|
||||
os.listdir(USER_VIDEOS_DIR),
|
||||
)
|
||||
anime_downloads.append("Exit")
|
||||
|
||||
def create_thumbnails(video_path, anime_title, downloads_thumbnail_cache_dir):
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
FFMPEG_THUMBNAILER = shutil.which("ffmpegthumbnailer")
|
||||
if not FFMPEG_THUMBNAILER:
|
||||
return
|
||||
|
||||
out = os.path.join(downloads_thumbnail_cache_dir, anime_title)
|
||||
if ffmpegthumbnailer_seek_time == -1:
|
||||
import random
|
||||
|
||||
seektime = str(random.randrange(0, 100))
|
||||
else:
|
||||
seektime = str(ffmpegthumbnailer_seek_time)
|
||||
_ = subprocess.run(
|
||||
[
|
||||
FFMPEG_THUMBNAILER,
|
||||
"-i",
|
||||
video_path,
|
||||
"-o",
|
||||
out,
|
||||
"-s",
|
||||
"0",
|
||||
"-t",
|
||||
seektime,
|
||||
],
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def get_previews_anime(workers=None, bg=True):
|
||||
import concurrent.futures
|
||||
import random
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
if not shutil.which("ffmpegthumbnailer"):
|
||||
print("ffmpegthumbnailer not found")
|
||||
logger.error("ffmpegthumbnailer not found")
|
||||
return
|
||||
|
||||
from ....constants import APP_CACHE_DIR
|
||||
from ...utils.scripts import bash_functions
|
||||
|
||||
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
|
||||
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _worker():
|
||||
# use concurrency to download the images as fast as possible
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
for anime_title in anime_downloads:
|
||||
anime_path = os.path.join(USER_VIDEOS_DIR, anime_title)
|
||||
if not os.path.isdir(anime_path):
|
||||
continue
|
||||
playlist = [
|
||||
anime
|
||||
for anime in sorted(
|
||||
os.listdir(anime_path),
|
||||
)
|
||||
if "mp4" in anime
|
||||
]
|
||||
if playlist:
|
||||
# actual link to download image from
|
||||
video_path = os.path.join(anime_path, random.choice(playlist))
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
create_thumbnails,
|
||||
video_path,
|
||||
anime_title,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
] = anime_title
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
if bg:
|
||||
from threading import Thread
|
||||
|
||||
worker = Thread(target=_worker)
|
||||
worker.daemon = True
|
||||
worker.start()
|
||||
else:
|
||||
_worker()
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then
|
||||
if ! fzf-preview %s/{} 2>/dev/null; then
|
||||
echo Loading...
|
||||
fi
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
bash_functions,
|
||||
downloads_thumbnail_cache_dir,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
return preview
|
||||
|
||||
def get_previews_episodes(anime_playlist_path, workers=None, bg=True):
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from ....constants import APP_CACHE_DIR
|
||||
from ...utils.scripts import bash_functions
|
||||
|
||||
if not shutil.which("ffmpegthumbnailer"):
|
||||
print("ffmpegthumbnailer not found")
|
||||
logger.error("ffmpegthumbnailer not found")
|
||||
return
|
||||
|
||||
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
|
||||
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _worker():
|
||||
import concurrent.futures
|
||||
|
||||
# use concurrency to download the images as fast as possible
|
||||
# anime_playlist_path = os.path.join(USER_VIDEOS_DIR, anime_playlist_path)
|
||||
if not os.path.isdir(anime_playlist_path):
|
||||
return
|
||||
anime_episodes = sorted(
|
||||
os.listdir(anime_playlist_path), key=sort_by_episode_number
|
||||
)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
for episode_title in anime_episodes:
|
||||
episode_path = os.path.join(anime_playlist_path, episode_title)
|
||||
|
||||
# actual link to download image from
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
create_thumbnails,
|
||||
episode_path,
|
||||
episode_title,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
] = episode_title
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
if bg:
|
||||
from threading import Thread
|
||||
|
||||
worker = Thread(target=_worker)
|
||||
worker.daemon = True
|
||||
worker.start()
|
||||
else:
|
||||
_worker()
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then
|
||||
if ! fzf-preview %s/{} 2>/dev/null; then
|
||||
echo Loading...
|
||||
fi
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
bash_functions,
|
||||
downloads_thumbnail_cache_dir,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
return preview
|
||||
|
||||
def stream_episode(
|
||||
anime_playlist_path,
|
||||
):
|
||||
if view_episodes:
|
||||
if not os.path.isdir(anime_playlist_path):
|
||||
print(anime_playlist_path, "is not dir")
|
||||
exit_app(1)
|
||||
return
|
||||
episodes = sorted(
|
||||
os.listdir(anime_playlist_path), key=sort_by_episode_number
|
||||
)
|
||||
downloaded_episodes = [*episodes, "Back"]
|
||||
|
||||
if config.use_fzf:
|
||||
if not config.preview:
|
||||
episode_title = fzf.run(
|
||||
downloaded_episodes,
|
||||
"Enter Episode ",
|
||||
)
|
||||
else:
|
||||
preview = get_previews_episodes(anime_playlist_path)
|
||||
episode_title = fzf.run(
|
||||
downloaded_episodes,
|
||||
"Enter Episode ",
|
||||
preview=preview,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
episode_title = Rofi.run(downloaded_episodes, "Enter Episode")
|
||||
else:
|
||||
episode_title = fuzzy_inquirer(
|
||||
downloaded_episodes,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
if episode_title == "Back":
|
||||
stream_anime()
|
||||
return
|
||||
episode_path = os.path.join(anime_playlist_path, episode_title)
|
||||
if config.sync_play:
|
||||
from ...utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(episode_path)
|
||||
else:
|
||||
run_mpv(
|
||||
episode_path,
|
||||
player=config.player,
|
||||
)
|
||||
stream_episode(anime_playlist_path)
|
||||
|
||||
def stream_anime(title=None):
|
||||
if title:
|
||||
from thefuzz import fuzz
|
||||
|
||||
playlist_name = max(anime_downloads, key=lambda t: fuzz.ratio(title, t))
|
||||
elif config.use_fzf:
|
||||
if not config.preview:
|
||||
playlist_name = fzf.run(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
else:
|
||||
preview = get_previews_anime()
|
||||
playlist_name = fzf.run(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
preview=preview,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
playlist_name = Rofi.run(anime_downloads, "Enter Playlist Name")
|
||||
else:
|
||||
playlist_name = fuzzy_inquirer(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
if playlist_name == "Exit":
|
||||
exit_app()
|
||||
return
|
||||
playlist = os.path.join(USER_VIDEOS_DIR, playlist_name)
|
||||
if view_episodes:
|
||||
stream_episode(
|
||||
playlist,
|
||||
)
|
||||
elif config.sync_play:
|
||||
from ...utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(playlist)
|
||||
else:
|
||||
run_mpv(
|
||||
playlist,
|
||||
player=config.player,
|
||||
)
|
||||
stream_anime()
|
||||
|
||||
stream_anime(title)
|
||||
@@ -1,130 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="Check for notifications on anime you currently watching")
|
||||
@click.pass_obj
|
||||
def notifier(config: "Config"):
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from sys import exit
|
||||
|
||||
import requests
|
||||
|
||||
try:
|
||||
from plyer import notification
|
||||
except ImportError:
|
||||
print("Please install plyer to use this command")
|
||||
exit(1)
|
||||
|
||||
from ....anilist import AniList
|
||||
from ....constants import APP_CACHE_DIR, APP_DATA_DIR, APP_NAME, ICON_PATH, PLATFORM
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
notified = os.path.join(APP_DATA_DIR, "last_notification.json")
|
||||
anime_image_path = os.path.join(APP_CACHE_DIR, "notification_image")
|
||||
notification_duration = config.notification_duration
|
||||
notification_image_path = ""
|
||||
|
||||
if not config.user:
|
||||
print("Not Authenticated")
|
||||
print("Run the following to get started: fastanime anilist login")
|
||||
exit(1)
|
||||
run = True
|
||||
# WARNING: Mess around with this value at your own risk
|
||||
timeout = 2 # time is in minutes
|
||||
if os.path.exists(notified):
|
||||
with open(notified) as f:
|
||||
past_notifications = json.load(f)
|
||||
else:
|
||||
past_notifications = {}
|
||||
with open(notified, "w") as f:
|
||||
json.dump(past_notifications, f)
|
||||
|
||||
while run:
|
||||
try:
|
||||
logger.info("checking for notifications")
|
||||
result = AniList.get_notification()
|
||||
if not result[0]:
|
||||
logger.warning(
|
||||
"Something went wrong this could mean anilist is down or you have lost internet connection"
|
||||
)
|
||||
logger.info("sleeping...")
|
||||
time.sleep(timeout * 60)
|
||||
continue
|
||||
data = result[1]
|
||||
if not data:
|
||||
logger.warning(
|
||||
"Something went wrong this could mean anilist is down or you have lost internet connection"
|
||||
)
|
||||
logger.info("sleeping...")
|
||||
time.sleep(timeout * 60)
|
||||
continue
|
||||
|
||||
notifications = data["data"]["Page"]["notifications"]
|
||||
if not notifications:
|
||||
logger.info("Nothing to notify")
|
||||
else:
|
||||
for notification_ in notifications:
|
||||
anime_episode = notification_["episode"]
|
||||
anime_title = notification_["media"]["title"][
|
||||
config.preferred_language
|
||||
]
|
||||
title = f"{anime_title} Episode {anime_episode} just aired"
|
||||
# pyright:ignore
|
||||
message = "Be sure to watch so you are not left out of the loop."
|
||||
# message = str(textwrap.wrap(message, width=50))
|
||||
|
||||
id = notification_["media"]["id"]
|
||||
if past_notifications.get(str(id)) == notification_["episode"]:
|
||||
logger.info(
|
||||
f"skipping id={id} title={anime_title} episode={anime_episode} already notified"
|
||||
)
|
||||
|
||||
else:
|
||||
# windows only supports ico,
|
||||
# and you still ask why linux
|
||||
if PLATFORM != "Windows":
|
||||
image_link = notification_["media"]["coverImage"]["medium"]
|
||||
logger.info("Downloading image...")
|
||||
|
||||
resp = requests.get(image_link)
|
||||
if resp.status_code == 200:
|
||||
with open(anime_image_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
notification_image_path = anime_image_path
|
||||
else:
|
||||
logger.warn(
|
||||
f"Failed to get image response_status={resp.status_code} response_content={resp.content}"
|
||||
)
|
||||
notification_image_path = ICON_PATH
|
||||
else:
|
||||
notification_image_path = ICON_PATH
|
||||
|
||||
past_notifications[f"{id}"] = notification_["episode"]
|
||||
with open(notified, "w") as f:
|
||||
json.dump(past_notifications, f)
|
||||
logger.info(message)
|
||||
notification.notify( # pyright:ignore
|
||||
title=title,
|
||||
message=message,
|
||||
app_name=APP_NAME,
|
||||
app_icon=notification_image_path,
|
||||
hints={
|
||||
"image-path": notification_image_path,
|
||||
"desktop-entry": f"{APP_NAME}.desktop",
|
||||
},
|
||||
timeout=notification_duration,
|
||||
)
|
||||
time.sleep(30)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
logger.info("sleeping...")
|
||||
time.sleep(timeout * 60)
|
||||
@@ -63,7 +63,7 @@ def config(
|
||||
):
|
||||
from ...core.constants import USER_CONFIG_PATH
|
||||
from ..config.generate import generate_config_ini_from_app_model
|
||||
from ..config.interactive_editor import InteractiveConfigEditor
|
||||
from ..config.editor import InteractiveConfigEditor
|
||||
|
||||
if path:
|
||||
print(USER_CONFIG_PATH)
|
||||
|
||||
299
fastanime/cli/commands/queue.py
Normal file
299
fastanime/cli/commands/queue.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Queue command for manual download queue management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress
|
||||
from rich.table import Table
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
from ..utils.download_queue import DownloadJob, DownloadStatus, QueueManager
|
||||
from ..utils.feedback import create_feedback_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Manage the download queue",
|
||||
short_help="Download queue management",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# Show queue status
|
||||
fastanime queue
|
||||
|
||||
# Add anime to download queue
|
||||
fastanime queue --add "Attack on Titan" --episode "1"
|
||||
|
||||
# Add with specific quality and priority
|
||||
fastanime queue --add "Demon Slayer" --episode "5" --quality "720" --priority 2
|
||||
|
||||
# Clear completed jobs
|
||||
fastanime queue --clean
|
||||
|
||||
# Remove specific job
|
||||
fastanime queue --remove <job-id>
|
||||
|
||||
# Show detailed queue information
|
||||
fastanime queue --detailed
|
||||
""",
|
||||
)
|
||||
@click.option(
|
||||
"--add", "-a",
|
||||
help="Add anime to download queue (anime title)"
|
||||
)
|
||||
@click.option(
|
||||
"--episode", "-e",
|
||||
help="Episode number to download (required with --add)"
|
||||
)
|
||||
@click.option(
|
||||
"--quality", "-q",
|
||||
type=click.Choice(["360", "480", "720", "1080"]),
|
||||
default="1080",
|
||||
help="Video quality preference"
|
||||
)
|
||||
@click.option(
|
||||
"--priority", "-p",
|
||||
type=click.IntRange(1, 10),
|
||||
default=5,
|
||||
help="Download priority (1=highest, 10=lowest)"
|
||||
)
|
||||
@click.option(
|
||||
"--translation-type", "-t",
|
||||
type=click.Choice(["sub", "dub"]),
|
||||
default="sub",
|
||||
help="Audio/subtitle preference"
|
||||
)
|
||||
@click.option(
|
||||
"--remove", "-r",
|
||||
help="Remove job from queue by ID"
|
||||
)
|
||||
@click.option(
|
||||
"--clean", "-c",
|
||||
is_flag=True,
|
||||
help="Remove completed/failed jobs older than 7 days"
|
||||
)
|
||||
@click.option(
|
||||
"--detailed", "-d",
|
||||
is_flag=True,
|
||||
help="Show detailed queue information"
|
||||
)
|
||||
@click.option(
|
||||
"--cancel",
|
||||
help="Cancel a specific job by ID"
|
||||
)
|
||||
@click.pass_obj
|
||||
def queue(
|
||||
config: "AppConfig",
|
||||
add: str,
|
||||
episode: str,
|
||||
quality: str,
|
||||
priority: int,
|
||||
translation_type: str,
|
||||
remove: str,
|
||||
clean: bool,
|
||||
detailed: bool,
|
||||
cancel: str
|
||||
):
|
||||
"""Manage the download queue for automated and manual downloads."""
|
||||
|
||||
console = Console()
|
||||
feedback = create_feedback_manager(config.general.icons)
|
||||
queue_manager = QueueManager()
|
||||
|
||||
try:
|
||||
# Add new job to queue
|
||||
if add:
|
||||
if not episode:
|
||||
feedback.error("Episode number is required when adding to queue",
|
||||
"Use --episode to specify the episode number")
|
||||
raise click.Abort()
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
job = DownloadJob(
|
||||
id=job_id,
|
||||
anime_title=add,
|
||||
episode=episode,
|
||||
quality=quality,
|
||||
translation_type=translation_type,
|
||||
priority=priority,
|
||||
auto_added=False
|
||||
)
|
||||
|
||||
success = queue_manager.add_job(job)
|
||||
if success:
|
||||
feedback.success(
|
||||
f"Added to queue: {add} Episode {episode}",
|
||||
f"Job ID: {job_id[:8]}... Priority: {priority}"
|
||||
)
|
||||
else:
|
||||
feedback.error("Failed to add job to queue", "Check logs for details")
|
||||
raise click.Abort()
|
||||
return
|
||||
|
||||
# Remove job from queue
|
||||
if remove:
|
||||
# Allow partial job ID matching
|
||||
matching_jobs = [
|
||||
job_id for job_id in queue_manager.queue.jobs.keys()
|
||||
if job_id.startswith(remove)
|
||||
]
|
||||
|
||||
if not matching_jobs:
|
||||
feedback.error(f"No job found with ID starting with: {remove}")
|
||||
raise click.Abort()
|
||||
elif len(matching_jobs) > 1:
|
||||
feedback.error(f"Multiple jobs match ID: {remove}",
|
||||
f"Be more specific. Matches: {[job_id[:8] for job_id in matching_jobs]}")
|
||||
raise click.Abort()
|
||||
|
||||
job_id = matching_jobs[0]
|
||||
job = queue_manager.get_job_by_id(job_id)
|
||||
success = queue_manager.remove_job(job_id)
|
||||
|
||||
if success:
|
||||
feedback.success(
|
||||
f"Removed from queue: {job.anime_title} Episode {job.episode}",
|
||||
f"Job ID: {job_id[:8]}..."
|
||||
)
|
||||
else:
|
||||
feedback.error("Failed to remove job from queue", "Check logs for details")
|
||||
raise click.Abort()
|
||||
return
|
||||
|
||||
# Cancel job
|
||||
if cancel:
|
||||
# Allow partial job ID matching
|
||||
matching_jobs = [
|
||||
job_id for job_id in queue_manager.queue.jobs.keys()
|
||||
if job_id.startswith(cancel)
|
||||
]
|
||||
|
||||
if not matching_jobs:
|
||||
feedback.error(f"No job found with ID starting with: {cancel}")
|
||||
raise click.Abort()
|
||||
elif len(matching_jobs) > 1:
|
||||
feedback.error(f"Multiple jobs match ID: {cancel}",
|
||||
f"Be more specific. Matches: {[job_id[:8] for job_id in matching_jobs]}")
|
||||
raise click.Abort()
|
||||
|
||||
job_id = matching_jobs[0]
|
||||
job = queue_manager.get_job_by_id(job_id)
|
||||
success = queue_manager.update_job_status(job_id, DownloadStatus.CANCELLED)
|
||||
|
||||
if success:
|
||||
feedback.success(
|
||||
f"Cancelled job: {job.anime_title} Episode {job.episode}",
|
||||
f"Job ID: {job_id[:8]}..."
|
||||
)
|
||||
else:
|
||||
feedback.error("Failed to cancel job", "Check logs for details")
|
||||
raise click.Abort()
|
||||
return
|
||||
|
||||
# Clean old completed jobs
|
||||
if clean:
|
||||
with Progress() as progress:
|
||||
task = progress.add_task("Cleaning old jobs...", total=None)
|
||||
cleaned_count = queue_manager.clean_completed_jobs()
|
||||
progress.update(task, completed=True)
|
||||
|
||||
if cleaned_count > 0:
|
||||
feedback.success(f"Cleaned {cleaned_count} old jobs from queue")
|
||||
else:
|
||||
feedback.info("No old jobs to clean")
|
||||
return
|
||||
|
||||
# Show queue status (default action)
|
||||
_display_queue_status(console, queue_manager, detailed, config.general.icons)
|
||||
|
||||
except Exception as e:
|
||||
feedback.error("An error occurred while managing the queue", str(e))
|
||||
logger.error(f"Queue command error: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
def _display_queue_status(console: Console, queue_manager: QueueManager, detailed: bool, icons: bool):
|
||||
"""Display the current queue status."""
|
||||
|
||||
stats = queue_manager.get_queue_stats()
|
||||
|
||||
# Display summary
|
||||
console.print()
|
||||
console.print(f"{'📥 ' if icons else ''}[bold cyan]Download Queue Status[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
summary_table = Table(title="Queue Summary")
|
||||
summary_table.add_column("Status", style="cyan")
|
||||
summary_table.add_column("Count", justify="right", style="green")
|
||||
|
||||
summary_table.add_row("Total Jobs", str(stats["total"]))
|
||||
summary_table.add_row("Pending", str(stats["pending"]))
|
||||
summary_table.add_row("Downloading", str(stats["downloading"]))
|
||||
summary_table.add_row("Completed", str(stats["completed"]))
|
||||
summary_table.add_row("Failed", str(stats["failed"]))
|
||||
summary_table.add_row("Cancelled", str(stats["cancelled"]))
|
||||
|
||||
console.print(summary_table)
|
||||
console.print()
|
||||
|
||||
if detailed or stats["total"] > 0:
|
||||
_display_detailed_queue(console, queue_manager, icons)
|
||||
|
||||
|
||||
def _display_detailed_queue(console: Console, queue_manager: QueueManager, icons: bool):
|
||||
"""Display detailed information about jobs in the queue."""
|
||||
|
||||
jobs = queue_manager.get_all_jobs()
|
||||
if not jobs:
|
||||
console.print(f"{'ℹ️ ' if icons else ''}[dim]No jobs in queue[/dim]")
|
||||
return
|
||||
|
||||
# Sort jobs by status and creation time
|
||||
jobs.sort(key=lambda x: (x.status.value, x.created_at))
|
||||
|
||||
table = Table(title="Job Details")
|
||||
table.add_column("ID", width=8)
|
||||
table.add_column("Anime", style="cyan")
|
||||
table.add_column("Episode", justify="center")
|
||||
table.add_column("Status", justify="center")
|
||||
table.add_column("Priority", justify="center")
|
||||
table.add_column("Quality", justify="center")
|
||||
table.add_column("Type", justify="center")
|
||||
table.add_column("Created", style="dim")
|
||||
|
||||
status_colors = {
|
||||
DownloadStatus.PENDING: "yellow",
|
||||
DownloadStatus.DOWNLOADING: "blue",
|
||||
DownloadStatus.COMPLETED: "green",
|
||||
DownloadStatus.FAILED: "red",
|
||||
DownloadStatus.CANCELLED: "dim"
|
||||
}
|
||||
|
||||
for job in jobs:
|
||||
status_color = status_colors.get(job.status, "white")
|
||||
auto_marker = f"{'🤖' if icons else 'A'}" if job.auto_added else f"{'👤' if icons else 'M'}"
|
||||
|
||||
table.add_row(
|
||||
job.id[:8],
|
||||
job.anime_title[:30] + "..." if len(job.anime_title) > 30 else job.anime_title,
|
||||
job.episode,
|
||||
f"[{status_color}]{job.status.value}[/{status_color}]",
|
||||
str(job.priority),
|
||||
job.quality,
|
||||
f"{auto_marker} {job.translation_type}",
|
||||
job.created_at.strftime("%m-%d %H:%M")
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
if icons:
|
||||
console.print()
|
||||
console.print("[dim]🤖 = Auto-added, 👤 = Manual[/dim]")
|
||||
@@ -1,31 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Command that automates the starting of the builtin fastanime server",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# default
|
||||
fastanime serve
|
||||
|
||||
# specify host and port
|
||||
fastanime serve --host 127.0.0.1 --port 8080
|
||||
""",
|
||||
)
|
||||
@click.option("--host", "-H", help="Specify the host to run the server on")
|
||||
@click.option("--port", "-p", help="Specify the port to run the server on")
|
||||
def serve(host, port):
|
||||
import os
|
||||
import sys
|
||||
|
||||
from ...constants import APP_DIR
|
||||
|
||||
args = [sys.executable, "-m", "fastapi", "run"]
|
||||
if host:
|
||||
args.extend(["--host", host])
|
||||
|
||||
if port:
|
||||
args.extend(["--port", port])
|
||||
args.append(os.path.join(APP_DIR, "api"))
|
||||
os.execv(sys.executable, args)
|
||||
547
fastanime/cli/commands/service.py
Normal file
547
fastanime/cli/commands/service.py
Normal file
@@ -0,0 +1,547 @@
|
||||
"""
|
||||
Background service for automated download queue processing and episode monitoring.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Set, cast, Literal
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
from fastanime.libs.api.base import BaseApiClient
|
||||
from fastanime.libs.api.types import MediaItem
|
||||
|
||||
from ..utils.download_queue import DownloadJob, DownloadStatus, QueueManager
|
||||
from ..utils.feedback import create_feedback_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DownloadService:
|
||||
"""Background service for processing download queue and monitoring new episodes."""
|
||||
|
||||
def __init__(self, config: "AppConfig"):
|
||||
self.config = config
|
||||
self.queue_manager = QueueManager()
|
||||
self.console = Console()
|
||||
self.feedback = create_feedback_manager(config.general.icons)
|
||||
self._running = False
|
||||
self._shutdown_event = threading.Event()
|
||||
|
||||
# Service state
|
||||
self.last_watchlist_check = datetime.now() - timedelta(hours=1) # Force initial check
|
||||
self.known_episodes: Dict[int, Set[str]] = {} # media_id -> set of episode numbers
|
||||
self.last_notification_check = datetime.now() - timedelta(minutes=10)
|
||||
|
||||
# Configuration
|
||||
self.watchlist_check_interval = self.config.service.watchlist_check_interval * 60 # Convert to seconds
|
||||
self.queue_process_interval = self.config.service.queue_process_interval * 60 # Convert to seconds
|
||||
self.notification_check_interval = 2 * 60 # 2 minutes in seconds
|
||||
self.max_concurrent_downloads = self.config.service.max_concurrent_downloads
|
||||
|
||||
# State file for persistence
|
||||
from fastanime.core.constants import APP_DATA_DIR
|
||||
self.state_file = APP_DATA_DIR / "service_state.json"
|
||||
|
||||
def _load_state(self):
|
||||
"""Load service state from file."""
|
||||
try:
|
||||
if self.state_file.exists():
|
||||
with open(self.state_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
self.known_episodes = {
|
||||
int(k): set(v) for k, v in data.get('known_episodes', {}).items()
|
||||
}
|
||||
self.last_watchlist_check = datetime.fromisoformat(
|
||||
data.get('last_watchlist_check', datetime.now().isoformat())
|
||||
)
|
||||
logger.info("Service state loaded successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load service state: {e}")
|
||||
|
||||
def _save_state(self):
|
||||
"""Save service state to file."""
|
||||
try:
|
||||
data = {
|
||||
'known_episodes': {
|
||||
str(k): list(v) for k, v in self.known_episodes.items()
|
||||
},
|
||||
'last_watchlist_check': self.last_watchlist_check.isoformat(),
|
||||
'last_saved': datetime.now().isoformat()
|
||||
}
|
||||
with open(self.state_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save service state: {e}")
|
||||
|
||||
def start(self):
|
||||
"""Start the background service."""
|
||||
logger.info("Starting FastAnime download service...")
|
||||
self.console.print(f"{'🚀 ' if self.config.general.icons else ''}[bold green]Starting FastAnime Download Service[/bold green]")
|
||||
|
||||
# Load previous state
|
||||
self._load_state()
|
||||
|
||||
# Set up signal handlers for graceful shutdown
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
|
||||
self._running = True
|
||||
|
||||
# Start worker threads
|
||||
watchlist_thread = threading.Thread(target=self._watchlist_monitor, daemon=True)
|
||||
queue_thread = threading.Thread(target=self._queue_processor, daemon=True)
|
||||
|
||||
watchlist_thread.start()
|
||||
queue_thread.start()
|
||||
|
||||
self.console.print(f"{'✅ ' if self.config.general.icons else ''}Service started successfully")
|
||||
self.console.print(f"{'📊 ' if self.config.general.icons else ''}Monitoring watchlist every {self.watchlist_check_interval // 60} minutes")
|
||||
self.console.print(f"{'⚙️ ' if self.config.general.icons else ''}Processing queue every {self.queue_process_interval} seconds")
|
||||
self.console.print(f"{'🛑 ' if self.config.general.icons else ''}Press Ctrl+C to stop")
|
||||
|
||||
try:
|
||||
# Main loop - just wait for shutdown
|
||||
while self._running and not self._shutdown_event.wait(timeout=10):
|
||||
self._save_state() # Periodic state saving
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
self._shutdown()
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""Handle shutdown signals."""
|
||||
logger.info(f"Received signal {signum}, shutting down...")
|
||||
self._running = False
|
||||
self._shutdown_event.set()
|
||||
|
||||
def _shutdown(self):
|
||||
"""Gracefully shutdown the service."""
|
||||
logger.info("Shutting down download service...")
|
||||
self.console.print(f"{'🛑 ' if self.config.general.icons else ''}[yellow]Shutting down service...[/yellow]")
|
||||
|
||||
self._running = False
|
||||
self._shutdown_event.set()
|
||||
|
||||
# Save final state
|
||||
self._save_state()
|
||||
|
||||
# Cancel any running downloads
|
||||
active_jobs = self.queue_manager.get_active_jobs()
|
||||
for job in active_jobs:
|
||||
self.queue_manager.update_job_status(job.id, DownloadStatus.CANCELLED)
|
||||
|
||||
self.console.print(f"{'✅ ' if self.config.general.icons else ''}Service stopped")
|
||||
logger.info("Download service shutdown complete")
|
||||
|
||||
def _watchlist_monitor(self):
|
||||
"""Monitor user's AniList watching list for new episodes."""
|
||||
logger.info("Starting watchlist monitor thread")
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
if (datetime.now() - self.last_watchlist_check).total_seconds() >= self.watchlist_check_interval:
|
||||
self._check_for_new_episodes()
|
||||
self.last_watchlist_check = datetime.now()
|
||||
|
||||
# Check for notifications (like the existing notifier)
|
||||
if (datetime.now() - self.last_notification_check).total_seconds() >= self.notification_check_interval:
|
||||
self._check_notifications()
|
||||
self.last_notification_check = datetime.now()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in watchlist monitor: {e}")
|
||||
|
||||
# Sleep with check for shutdown
|
||||
if self._shutdown_event.wait(timeout=60):
|
||||
break
|
||||
|
||||
logger.info("Watchlist monitor thread stopped")
|
||||
|
||||
def _queue_processor(self):
|
||||
"""Process the download queue."""
|
||||
logger.info("Starting queue processor thread")
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
self._process_download_queue()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in queue processor: {e}")
|
||||
|
||||
# Sleep with check for shutdown
|
||||
if self._shutdown_event.wait(timeout=self.queue_process_interval):
|
||||
break
|
||||
|
||||
logger.info("Queue processor thread stopped")
|
||||
|
||||
def _check_for_new_episodes(self):
|
||||
"""Check user's watching list for newly released episodes."""
|
||||
try:
|
||||
logger.info("Checking for new episodes in watchlist...")
|
||||
|
||||
# Get authenticated API client
|
||||
from fastanime.libs.api.factory import create_api_client
|
||||
from fastanime.libs.api.params import UserListParams
|
||||
|
||||
api_client = create_api_client(self.config.general.api_client, self.config)
|
||||
|
||||
# Check if user is authenticated
|
||||
user_profile = api_client.get_viewer_profile()
|
||||
if not user_profile:
|
||||
logger.warning("User not authenticated, skipping watchlist check")
|
||||
return
|
||||
|
||||
# Fetch currently watching anime
|
||||
with Progress() as progress:
|
||||
task = progress.add_task("Checking watchlist...", total=None)
|
||||
|
||||
list_params = UserListParams(
|
||||
status="CURRENT", # Currently watching
|
||||
page=1,
|
||||
per_page=50
|
||||
)
|
||||
user_list = api_client.fetch_user_list(list_params)
|
||||
progress.update(task, completed=True)
|
||||
|
||||
if not user_list or not user_list.media:
|
||||
logger.info("No anime found in watching list")
|
||||
return
|
||||
|
||||
new_episodes_found = 0
|
||||
|
||||
for media_item in user_list.media:
|
||||
try:
|
||||
media_id = media_item.id
|
||||
|
||||
# Get available episodes from provider
|
||||
available_episodes = self._get_available_episodes(media_item)
|
||||
if not available_episodes:
|
||||
continue
|
||||
|
||||
# Check if we have new episodes
|
||||
known_eps = self.known_episodes.get(media_id, set())
|
||||
new_episodes = set(available_episodes) - known_eps
|
||||
|
||||
if new_episodes:
|
||||
logger.info(f"Found {len(new_episodes)} new episodes for {media_item.title.romaji or media_item.title.english}")
|
||||
|
||||
# Add new episodes to download queue
|
||||
for episode in sorted(new_episodes, key=lambda x: float(x) if x.isdigit() else 0):
|
||||
self._add_episode_to_queue(media_item, episode)
|
||||
new_episodes_found += 1
|
||||
|
||||
# Update known episodes
|
||||
self.known_episodes[media_id] = set(available_episodes)
|
||||
else:
|
||||
# Update known episodes even if no new ones (in case some were removed)
|
||||
self.known_episodes[media_id] = set(available_episodes)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking episodes for {media_item.title.romaji}: {e}")
|
||||
|
||||
if new_episodes_found > 0:
|
||||
logger.info(f"Added {new_episodes_found} new episodes to download queue")
|
||||
self.console.print(f"{'📺 ' if self.config.general.icons else ''}Found {new_episodes_found} new episodes, added to queue")
|
||||
else:
|
||||
logger.info("No new episodes found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking for new episodes: {e}")
|
||||
|
||||
def _get_available_episodes(self, media_item: "MediaItem") -> List[str]:
|
||||
"""Get available episodes for a media item from the provider."""
|
||||
try:
|
||||
from fastanime.libs.providers.anime.provider import create_provider
|
||||
from fastanime.libs.providers.anime.params import AnimeParams, SearchParams
|
||||
from httpx import Client
|
||||
|
||||
client = Client()
|
||||
provider = create_provider(self.config.general.provider)
|
||||
|
||||
# Search for the anime
|
||||
search_results = provider.search(SearchParams(
|
||||
query=media_item.title.romaji or media_item.title.english or "Unknown",
|
||||
translation_type=self.config.stream.translation_type
|
||||
))
|
||||
|
||||
if not search_results or not search_results.results:
|
||||
return []
|
||||
|
||||
# Get the first result (should be the best match)
|
||||
anime_result = search_results.results[0]
|
||||
|
||||
# Get anime details
|
||||
anime = provider.get(AnimeParams(id=anime_result.id))
|
||||
if not anime or not anime.episodes:
|
||||
return []
|
||||
|
||||
# Get episodes for the configured translation type
|
||||
episodes = getattr(anime.episodes, self.config.stream.translation_type, [])
|
||||
return sorted(episodes, key=lambda x: float(x) if x.replace('.', '').isdigit() else 0)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting available episodes: {e}")
|
||||
return []
|
||||
|
||||
def _add_episode_to_queue(self, media_item: "MediaItem", episode: str):
|
||||
"""Add an episode to the download queue."""
|
||||
try:
|
||||
job_id = str(uuid.uuid4())
|
||||
job = DownloadJob(
|
||||
id=job_id,
|
||||
anime_title=media_item.title.romaji or media_item.title.english or "Unknown",
|
||||
episode=episode,
|
||||
media_id=media_item.id,
|
||||
quality=self.config.stream.quality,
|
||||
translation_type=self.config.stream.translation_type,
|
||||
priority=1, # High priority for auto-added episodes
|
||||
auto_added=True
|
||||
)
|
||||
|
||||
success = self.queue_manager.add_job(job)
|
||||
if success:
|
||||
logger.info(f"Auto-queued: {job.anime_title} Episode {episode}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding episode to queue: {e}")
|
||||
|
||||
def _check_notifications(self):
|
||||
"""Check for AniList notifications (similar to existing notifier)."""
|
||||
try:
|
||||
# This is similar to the existing notifier functionality
|
||||
# We can reuse the notification logic here if needed
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking notifications: {e}")
|
||||
|
||||
def _process_download_queue(self):
|
||||
"""Process pending downloads in the queue."""
|
||||
try:
|
||||
# Get currently active downloads
|
||||
active_jobs = self.queue_manager.get_active_jobs()
|
||||
available_slots = max(0, self.max_concurrent_downloads - len(active_jobs))
|
||||
|
||||
if available_slots == 0:
|
||||
return # All slots busy
|
||||
|
||||
# Get pending jobs
|
||||
pending_jobs = self.queue_manager.get_pending_jobs(limit=available_slots)
|
||||
if not pending_jobs:
|
||||
return # No pending jobs
|
||||
|
||||
logger.info(f"Processing {len(pending_jobs)} download jobs")
|
||||
|
||||
# Process jobs concurrently
|
||||
with ThreadPoolExecutor(max_workers=available_slots) as executor:
|
||||
futures = {
|
||||
executor.submit(self._download_episode, job): job
|
||||
for job in pending_jobs
|
||||
}
|
||||
|
||||
for future in as_completed(futures):
|
||||
job = futures[future]
|
||||
try:
|
||||
success = future.result()
|
||||
if success:
|
||||
logger.info(f"Successfully downloaded: {job.anime_title} Episode {job.episode}")
|
||||
else:
|
||||
logger.error(f"Failed to download: {job.anime_title} Episode {job.episode}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading {job.anime_title} Episode {job.episode}: {e}")
|
||||
self.queue_manager.update_job_status(job.id, DownloadStatus.FAILED, str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing download queue: {e}")
|
||||
|
||||
def _download_episode(self, job: DownloadJob) -> bool:
|
||||
"""Download a specific episode."""
|
||||
try:
|
||||
logger.info(f"Starting download: {job.anime_title} Episode {job.episode}")
|
||||
|
||||
# Update job status to downloading
|
||||
self.queue_manager.update_job_status(job.id, DownloadStatus.DOWNLOADING)
|
||||
|
||||
# Import download functionality
|
||||
from fastanime.libs.providers.anime.provider import create_provider
|
||||
from fastanime.libs.providers.anime.params import AnimeParams, SearchParams, EpisodeStreamsParams
|
||||
from fastanime.libs.selectors.selector import create_selector
|
||||
from fastanime.libs.players.player import create_player
|
||||
from fastanime.core.downloader.downloader import create_downloader
|
||||
from httpx import Client
|
||||
|
||||
# Create required components
|
||||
client = Client()
|
||||
provider = create_provider(self.config.general.provider)
|
||||
selector = create_selector(self.config)
|
||||
player = create_player(self.config)
|
||||
downloader = create_downloader(self.config.downloads)
|
||||
|
||||
# Search for anime
|
||||
translation_type = cast(Literal["sub", "dub"], job.translation_type if job.translation_type in ["sub", "dub"] else "sub")
|
||||
search_results = provider.search(SearchParams(
|
||||
query=job.anime_title,
|
||||
translation_type=translation_type
|
||||
))
|
||||
|
||||
if not search_results or not search_results.results:
|
||||
raise Exception("No search results found")
|
||||
|
||||
# Get anime details
|
||||
anime_result = search_results.results[0]
|
||||
anime = provider.get(AnimeParams(id=anime_result.id))
|
||||
|
||||
if not anime:
|
||||
raise Exception("Failed to get anime details")
|
||||
|
||||
# Get episode streams
|
||||
# Ensure translation_type is valid Literal type
|
||||
valid_translation = cast(Literal["sub", "dub"],
|
||||
job.translation_type if job.translation_type in ["sub", "dub"] else "sub")
|
||||
|
||||
streams = provider.episode_streams(EpisodeStreamsParams(
|
||||
anime_id=anime.id,
|
||||
episode=job.episode,
|
||||
translation_type=valid_translation
|
||||
))
|
||||
|
||||
if not streams:
|
||||
raise Exception("No streams found")
|
||||
|
||||
# Get the first available server
|
||||
server = next(streams, None)
|
||||
if not server:
|
||||
raise Exception("No server available")
|
||||
|
||||
# Download using the first available link
|
||||
if server.links:
|
||||
link = server.links[0]
|
||||
logger.info(f"Starting download: {link.link} for {job.anime_title} Episode {job.episode}")
|
||||
|
||||
# Import downloader
|
||||
from fastanime.core.downloader import create_downloader, DownloadParams
|
||||
|
||||
# Create downloader with config
|
||||
downloader = create_downloader(self.config.downloads)
|
||||
|
||||
# Prepare download parameters
|
||||
download_params = DownloadParams(
|
||||
url=link.link,
|
||||
anime_title=job.anime_title,
|
||||
episode_title=f"Episode {job.episode}",
|
||||
silent=True, # Run silently in background
|
||||
headers=server.headers, # Use server headers
|
||||
subtitles=[sub.url for sub in server.subtitles], # Extract subtitle URLs
|
||||
merge=False, # Default to false
|
||||
clean=False, # Default to false
|
||||
prompt=False, # No prompts in background service
|
||||
force_ffmpeg=False, # Default to false
|
||||
hls_use_mpegts=False, # Default to false
|
||||
hls_use_h264=False # Default to false
|
||||
)
|
||||
|
||||
# Download the episode
|
||||
try:
|
||||
downloader.download(download_params)
|
||||
logger.info(f"Successfully downloaded: {job.anime_title} Episode {job.episode}")
|
||||
self.queue_manager.update_job_status(job.id, DownloadStatus.COMPLETED)
|
||||
return True
|
||||
except Exception as download_error:
|
||||
error_msg = f"Download failed: {str(download_error)}"
|
||||
raise Exception(error_msg)
|
||||
else:
|
||||
raise Exception("No download links available")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Download failed for {job.anime_title} Episode {job.episode}: {e}")
|
||||
|
||||
# Handle retry logic
|
||||
job.retry_count += 1
|
||||
if job.retry_count < self.queue_manager.queue.auto_retry_count:
|
||||
# Reset to pending for retry
|
||||
self.queue_manager.update_job_status(job.id, DownloadStatus.PENDING, f"Retry {job.retry_count}: {str(e)}")
|
||||
else:
|
||||
# Mark as failed after max retries
|
||||
self.queue_manager.update_job_status(job.id, DownloadStatus.FAILED, f"Max retries exceeded: {str(e)}")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Run background service for automated downloads and episode monitoring",
|
||||
short_help="Background download service",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# Start the service
|
||||
fastanime service
|
||||
|
||||
# Run in the background (Linux/macOS)
|
||||
nohup fastanime service > /dev/null 2>&1 &
|
||||
|
||||
# Run with logging
|
||||
fastanime --log service
|
||||
|
||||
# Run with file logging
|
||||
fastanime --log-to-file service
|
||||
""",
|
||||
)
|
||||
@click.option(
|
||||
"--watchlist-interval",
|
||||
type=int,
|
||||
help="Minutes between watchlist checks (default from config)"
|
||||
)
|
||||
@click.option(
|
||||
"--queue-interval",
|
||||
type=int,
|
||||
help="Minutes between queue processing (default from config)"
|
||||
)
|
||||
@click.option(
|
||||
"--max-concurrent",
|
||||
type=int,
|
||||
help="Maximum concurrent downloads (default from config)"
|
||||
)
|
||||
@click.pass_obj
|
||||
def service(config: "AppConfig", watchlist_interval: Optional[int], queue_interval: Optional[int], max_concurrent: Optional[int]):
|
||||
"""
|
||||
Run the FastAnime background service for automated downloads.
|
||||
|
||||
The service will:
|
||||
- Monitor your AniList watching list for new episodes
|
||||
- Automatically queue new episodes for download
|
||||
- Process the download queue
|
||||
- Provide notifications for new episodes
|
||||
"""
|
||||
|
||||
try:
|
||||
# Update configuration with command line options if provided
|
||||
service_instance = DownloadService(config)
|
||||
if watchlist_interval is not None:
|
||||
service_instance.watchlist_check_interval = watchlist_interval * 60
|
||||
if queue_interval is not None:
|
||||
service_instance.queue_process_interval = queue_interval * 60
|
||||
if max_concurrent is not None:
|
||||
service_instance.max_concurrent_downloads = max_concurrent
|
||||
|
||||
# Start the service
|
||||
service_instance.start()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Exception as e:
|
||||
console = Console()
|
||||
console.print(f"[red]Service error: {e}[/red]")
|
||||
logger.error(f"Service error: {e}")
|
||||
sys.exit(1)
|
||||
@@ -9,7 +9,7 @@ from ...core.config import AppConfig
|
||||
from ...core.constants import USER_CONFIG_PATH
|
||||
from ...core.exceptions import ConfigError
|
||||
from .generate import generate_config_ini_from_app_model
|
||||
from .interactive_editor import InteractiveConfigEditor
|
||||
from .editor import InteractiveConfigEditor
|
||||
|
||||
|
||||
class ConfigLoader:
|
||||
|
||||
821
fastanime/cli/interactive/menus/anilist_lists.py
Normal file
821
fastanime/cli/interactive/menus/anilist_lists.py
Normal file
@@ -0,0 +1,821 @@
|
||||
"""
|
||||
AniList Watch List Operations Menu
|
||||
Implements Step 8: Remote Watch List Operations
|
||||
|
||||
Provides comprehensive AniList list management including:
|
||||
- Viewing user lists (Watching, Completed, Planning, etc.)
|
||||
- Interactive list selection and navigation
|
||||
- Adding/removing anime from lists
|
||||
- List statistics and overview
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from ....libs.api.params import UpdateListEntryParams, UserListParams
|
||||
from ....libs.api.types import MediaItem, MediaSearchResult, UserListStatusType
|
||||
from ...utils.feedback import create_feedback_manager, execute_with_feedback
|
||||
from ..session import Context, session
|
||||
from ..state import ControlFlow, MediaApiState, State
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@session.menu
|
||||
def anilist_lists(ctx: Context, state: State) -> State | ControlFlow:
|
||||
"""
|
||||
Main AniList lists management menu.
|
||||
Shows all user lists with statistics and navigation options.
|
||||
"""
|
||||
icons = ctx.config.general.icons
|
||||
feedback = create_feedback_manager(icons)
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
# Check authentication
|
||||
if not ctx.media_api.user_profile:
|
||||
feedback.error(
|
||||
"Authentication Required",
|
||||
"You must be logged in to access your AniList lists. Please authenticate first."
|
||||
)
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return State(menu_name="AUTH")
|
||||
|
||||
# Display user profile and lists overview
|
||||
_display_lists_overview(console, ctx, icons)
|
||||
|
||||
# Menu options
|
||||
options = [
|
||||
f"{'📺 ' if icons else ''}Currently Watching",
|
||||
f"{'📋 ' if icons else ''}Planning to Watch",
|
||||
f"{'✅ ' if icons else ''}Completed",
|
||||
f"{'⏸️ ' if icons else ''}Paused",
|
||||
f"{'🚮 ' if icons else ''}Dropped",
|
||||
f"{'🔁 ' if icons else ''}Rewatching",
|
||||
f"{'📊 ' if icons else ''}View All Lists Statistics",
|
||||
f"{'🔍 ' if icons else ''}Search Across All Lists",
|
||||
f"{'➕ ' if icons else ''}Add Anime to List",
|
||||
f"{'↩️ ' if icons else ''}Back to Main Menu",
|
||||
]
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select List Action",
|
||||
choices=options,
|
||||
header=f"AniList Lists - {ctx.media_api.user_profile.name}",
|
||||
)
|
||||
|
||||
if not choice:
|
||||
return ControlFlow.BACK
|
||||
|
||||
# Handle menu choices
|
||||
if "Currently Watching" in choice:
|
||||
return _navigate_to_list(ctx, "CURRENT")
|
||||
elif "Planning to Watch" in choice:
|
||||
return _navigate_to_list(ctx, "PLANNING")
|
||||
elif "Completed" in choice:
|
||||
return _navigate_to_list(ctx, "COMPLETED")
|
||||
elif "Paused" in choice:
|
||||
return _navigate_to_list(ctx, "PAUSED")
|
||||
elif "Dropped" in choice:
|
||||
return _navigate_to_list(ctx, "DROPPED")
|
||||
elif "Rewatching" in choice:
|
||||
return _navigate_to_list(ctx, "REPEATING")
|
||||
elif "View All Lists Statistics" in choice:
|
||||
return _show_all_lists_stats(ctx, feedback, icons)
|
||||
elif "Search Across All Lists" in choice:
|
||||
return _search_all_lists(ctx, feedback, icons)
|
||||
elif "Add Anime to List" in choice:
|
||||
return _add_anime_to_list(ctx, feedback, icons)
|
||||
else: # Back to Main Menu
|
||||
return ControlFlow.BACK
|
||||
|
||||
|
||||
@session.menu
|
||||
def anilist_list_view(ctx: Context, state: State) -> State | ControlFlow:
|
||||
"""
|
||||
View and manage a specific AniList list (e.g., Watching, Completed).
|
||||
"""
|
||||
icons = ctx.config.general.icons
|
||||
feedback = create_feedback_manager(icons)
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
# Get list status from state data
|
||||
list_status = state.data.get("list_status") if state.data else "CURRENT"
|
||||
page = state.data.get("page", 1) if state.data else 1
|
||||
|
||||
# Fetch list data
|
||||
def fetch_list():
|
||||
return ctx.media_api.fetch_user_list(
|
||||
UserListParams(status=list_status, page=page, per_page=20)
|
||||
)
|
||||
|
||||
success, result = execute_with_feedback(
|
||||
fetch_list,
|
||||
feedback,
|
||||
f"fetch {_status_to_display_name(list_status)} list",
|
||||
loading_msg=f"Loading {_status_to_display_name(list_status)} list...",
|
||||
success_msg=f"Loaded {_status_to_display_name(list_status)} list",
|
||||
error_msg=f"Failed to load {_status_to_display_name(list_status)} list",
|
||||
)
|
||||
|
||||
if not success or not result:
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return ControlFlow.BACK
|
||||
|
||||
# Display list contents
|
||||
_display_list_contents(console, result, list_status, page, icons)
|
||||
|
||||
# Menu options
|
||||
options = [
|
||||
f"{'👁️ ' if icons else ''}View/Edit Anime Details",
|
||||
f"{'🔄 ' if icons else ''}Refresh List",
|
||||
f"{'➕ ' if icons else ''}Add New Anime",
|
||||
f"{'🗑️ ' if icons else ''}Remove from List",
|
||||
]
|
||||
|
||||
# Add pagination options
|
||||
if result.page_info.has_next_page:
|
||||
options.append(f"{'➡️ ' if icons else ''}Next Page")
|
||||
if page > 1:
|
||||
options.append(f"{'⬅️ ' if icons else ''}Previous Page")
|
||||
|
||||
options.extend([
|
||||
f"{'📊 ' if icons else ''}List Statistics",
|
||||
f"{'↩️ ' if icons else ''}Back to Lists Menu",
|
||||
])
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select Action",
|
||||
choices=options,
|
||||
header=f"{_status_to_display_name(list_status)} - Page {page}",
|
||||
)
|
||||
|
||||
if not choice:
|
||||
return ControlFlow.BACK
|
||||
|
||||
# Handle menu choices
|
||||
if "View/Edit Anime Details" in choice:
|
||||
return _select_anime_for_details(ctx, result, list_status, page)
|
||||
elif "Refresh List" in choice:
|
||||
return ControlFlow.CONTINUE
|
||||
elif "Add New Anime" in choice:
|
||||
return _add_anime_to_specific_list(ctx, list_status, feedback, icons)
|
||||
elif "Remove from List" in choice:
|
||||
return _remove_anime_from_list(ctx, result, list_status, page, feedback, icons)
|
||||
elif "Next Page" in choice:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": page + 1}
|
||||
)
|
||||
elif "Previous Page" in choice:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": page - 1}
|
||||
)
|
||||
elif "List Statistics" in choice:
|
||||
return _show_list_statistics(ctx, list_status, feedback, icons)
|
||||
else: # Back to Lists Menu
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
|
||||
@session.menu
|
||||
def anilist_anime_details(ctx: Context, state: State) -> State | ControlFlow:
|
||||
"""
|
||||
View and edit details for a specific anime in a user's list.
|
||||
"""
|
||||
icons = ctx.config.general.icons
|
||||
feedback = create_feedback_manager(icons)
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
# Get anime and list info from state
|
||||
if not state.data:
|
||||
return ControlFlow.BACK
|
||||
|
||||
anime = state.data.get("anime")
|
||||
list_status = state.data.get("list_status")
|
||||
return_page = state.data.get("return_page", 1)
|
||||
from_media_actions = state.data.get("from_media_actions", False)
|
||||
|
||||
if not anime:
|
||||
return ControlFlow.BACK
|
||||
|
||||
# Display anime details
|
||||
_display_anime_list_details(console, anime, icons)
|
||||
|
||||
# Menu options
|
||||
options = [
|
||||
f"{'✏️ ' if icons else ''}Edit Progress",
|
||||
f"{'⭐ ' if icons else ''}Edit Rating",
|
||||
f"{'📝 ' if icons else ''}Edit Status",
|
||||
f"{'🎬 ' if icons else ''}Watch/Stream",
|
||||
f"{'🗑️ ' if icons else ''}Remove from List",
|
||||
f"{'↩️ ' if icons else ''}Back to List",
|
||||
]
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select Action",
|
||||
choices=options,
|
||||
header=f"{anime.title.english or anime.title.romaji}",
|
||||
)
|
||||
|
||||
if not choice:
|
||||
# Return to appropriate menu based on how we got here
|
||||
if from_media_actions:
|
||||
return ControlFlow.BACK
|
||||
elif list_status:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": return_page}
|
||||
)
|
||||
else:
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
# Handle menu choices
|
||||
if "Edit Progress" in choice:
|
||||
return _edit_anime_progress(ctx, anime, list_status, return_page, feedback, from_media_actions)
|
||||
elif "Edit Rating" in choice:
|
||||
return _edit_anime_rating(ctx, anime, list_status, return_page, feedback, from_media_actions)
|
||||
elif "Edit Status" in choice:
|
||||
return _edit_anime_status(ctx, anime, list_status, return_page, feedback, from_media_actions)
|
||||
elif "Watch/Stream" in choice:
|
||||
return _stream_anime(ctx, anime)
|
||||
elif "Remove from List" in choice:
|
||||
return _confirm_remove_anime(ctx, anime, list_status, return_page, feedback, icons, from_media_actions)
|
||||
else: # Back to List/Media Actions
|
||||
# Return to appropriate menu based on how we got here
|
||||
if from_media_actions:
|
||||
return ControlFlow.BACK
|
||||
elif list_status:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": return_page}
|
||||
)
|
||||
else:
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
|
||||
def _display_lists_overview(console: Console, ctx: Context, icons: bool):
|
||||
"""Display overview of all user lists with counts."""
|
||||
user = ctx.media_api.user_profile
|
||||
|
||||
# Create overview panel
|
||||
overview_text = f"[bold cyan]{user.name}[/bold cyan]'s AniList Management\n"
|
||||
overview_text += f"User ID: {user.id}\n\n"
|
||||
overview_text += "Manage your anime lists, track progress, and sync with AniList"
|
||||
|
||||
panel = Panel(
|
||||
overview_text,
|
||||
title=f"{'📚 ' if icons else ''}AniList Lists Overview",
|
||||
border_style="cyan",
|
||||
)
|
||||
console.print(panel)
|
||||
console.print()
|
||||
|
||||
|
||||
def _display_list_contents(
|
||||
console: Console,
|
||||
result: MediaSearchResult,
|
||||
list_status: str,
|
||||
page: int,
|
||||
icons: bool
|
||||
):
|
||||
"""Display the contents of a specific list in a table."""
|
||||
if not result.media:
|
||||
console.print(f"[yellow]No anime found in {_status_to_display_name(list_status)} list[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title=f"{_status_to_display_name(list_status)} - Page {page}")
|
||||
table.add_column("Title", style="cyan", no_wrap=False, width=40)
|
||||
table.add_column("Episodes", justify="center", width=10)
|
||||
table.add_column("Progress", justify="center", width=10)
|
||||
table.add_column("Score", justify="center", width=8)
|
||||
table.add_column("Status", justify="center", width=12)
|
||||
|
||||
for i, anime in enumerate(result.media, 1):
|
||||
title = anime.title.english or anime.title.romaji or "Unknown Title"
|
||||
episodes = str(anime.episodes or "?")
|
||||
|
||||
# Get list entry details if available
|
||||
progress = "?"
|
||||
score = "?"
|
||||
status = _status_to_display_name(list_status)
|
||||
|
||||
# Note: In a real implementation, you'd get these from the MediaList entry
|
||||
# For now, we'll show placeholders
|
||||
if hasattr(anime, 'media_list_entry') and anime.media_list_entry:
|
||||
progress = str(anime.media_list_entry.progress or 0)
|
||||
score = str(anime.media_list_entry.score or "-")
|
||||
|
||||
table.add_row(
|
||||
f"{i}. {title}",
|
||||
episodes,
|
||||
progress,
|
||||
score,
|
||||
status
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print(f"\nShowing {len(result.media)} anime from {_status_to_display_name(list_status)} list")
|
||||
|
||||
# Show pagination info
|
||||
if result.page_info.has_next_page:
|
||||
console.print(f"[dim]More results available on next page[/dim]")
|
||||
|
||||
|
||||
def _display_anime_list_details(console: Console, anime: MediaItem, icons: bool):
|
||||
"""Display detailed information about an anime in the user's list."""
|
||||
title = anime.title.english or anime.title.romaji or "Unknown Title"
|
||||
|
||||
details_text = f"[bold]{title}[/bold]\n\n"
|
||||
details_text += f"Episodes: {anime.episodes or 'Unknown'}\n"
|
||||
details_text += f"Status: {anime.status or 'Unknown'}\n"
|
||||
details_text += f"Genres: {', '.join(anime.genres) if anime.genres else 'Unknown'}\n"
|
||||
|
||||
if anime.description:
|
||||
# Truncate description for display
|
||||
desc = anime.description[:300] + "..." if len(anime.description) > 300 else anime.description
|
||||
details_text += f"\nDescription:\n{desc}"
|
||||
|
||||
# Add list-specific information if available
|
||||
if hasattr(anime, 'media_list_entry') and anime.media_list_entry:
|
||||
entry = anime.media_list_entry
|
||||
details_text += f"\n\n[bold cyan]Your List Info:[/bold cyan]\n"
|
||||
details_text += f"Progress: {entry.progress or 0} episodes\n"
|
||||
details_text += f"Score: {entry.score or 'Not rated'}\n"
|
||||
details_text += f"Status: {_status_to_display_name(entry.status) if hasattr(entry, 'status') else 'Unknown'}\n"
|
||||
|
||||
panel = Panel(
|
||||
details_text,
|
||||
title=f"{'📺 ' if icons else ''}Anime Details",
|
||||
border_style="blue",
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
|
||||
def _navigate_to_list(ctx: Context, list_status: UserListStatusType) -> State:
|
||||
"""Navigate to a specific list view."""
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": 1}
|
||||
)
|
||||
|
||||
|
||||
def _select_anime_for_details(
|
||||
ctx: Context,
|
||||
result: MediaSearchResult,
|
||||
list_status: str,
|
||||
page: int
|
||||
) -> State | ControlFlow:
|
||||
"""Let user select an anime from the list to view/edit details."""
|
||||
if not result.media:
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Create choices from anime list
|
||||
choices = []
|
||||
for i, anime in enumerate(result.media, 1):
|
||||
title = anime.title.english or anime.title.romaji or "Unknown Title"
|
||||
choices.append(f"{i}. {title}")
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select anime to view/edit",
|
||||
choices=choices,
|
||||
header="Select Anime",
|
||||
)
|
||||
|
||||
if not choice:
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Extract index and get selected anime
|
||||
try:
|
||||
index = int(choice.split(".")[0]) - 1
|
||||
selected_anime = result.media[index]
|
||||
|
||||
return State(
|
||||
menu_name="ANILIST_ANIME_DETAILS",
|
||||
data={
|
||||
"anime": selected_anime,
|
||||
"list_status": list_status,
|
||||
"return_page": page
|
||||
}
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _edit_anime_progress(
|
||||
ctx: Context,
|
||||
anime: MediaItem,
|
||||
list_status: str,
|
||||
return_page: int,
|
||||
feedback,
|
||||
from_media_actions: bool = False
|
||||
) -> State | ControlFlow:
|
||||
"""Edit the progress (episodes watched) for an anime."""
|
||||
current_progress = 0
|
||||
if hasattr(anime, 'media_list_entry') and anime.media_list_entry:
|
||||
current_progress = anime.media_list_entry.progress or 0
|
||||
|
||||
max_episodes = anime.episodes or 999
|
||||
|
||||
try:
|
||||
new_progress = click.prompt(
|
||||
f"Enter new progress (0-{max_episodes}, current: {current_progress})",
|
||||
type=int,
|
||||
default=current_progress
|
||||
)
|
||||
|
||||
if new_progress < 0 or new_progress > max_episodes:
|
||||
feedback.error("Invalid progress", f"Progress must be between 0 and {max_episodes}")
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Update via API
|
||||
def update_progress():
|
||||
return ctx.media_api.update_list_entry(
|
||||
UpdateListEntryParams(media_id=anime.id, progress=new_progress)
|
||||
)
|
||||
|
||||
success, _ = execute_with_feedback(
|
||||
update_progress,
|
||||
feedback,
|
||||
"update progress",
|
||||
loading_msg="Updating progress...",
|
||||
success_msg=f"Progress updated to {new_progress} episodes",
|
||||
error_msg="Failed to update progress",
|
||||
)
|
||||
|
||||
if success:
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
|
||||
except click.Abort:
|
||||
pass
|
||||
|
||||
# Return to appropriate menu based on how we got here
|
||||
if from_media_actions:
|
||||
return ControlFlow.BACK
|
||||
elif list_status:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": return_page}
|
||||
)
|
||||
else:
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
|
||||
def _edit_anime_rating(
|
||||
ctx: Context,
|
||||
anime: MediaItem,
|
||||
list_status: str,
|
||||
return_page: int,
|
||||
feedback,
|
||||
from_media_actions: bool = False
|
||||
) -> State | ControlFlow:
|
||||
"""Edit the rating/score for an anime."""
|
||||
current_score = 0.0
|
||||
if hasattr(anime, 'media_list_entry') and anime.media_list_entry:
|
||||
current_score = anime.media_list_entry.score or 0.0
|
||||
|
||||
try:
|
||||
new_score = click.prompt(
|
||||
f"Enter new rating (0.0-10.0, current: {current_score})",
|
||||
type=float,
|
||||
default=current_score
|
||||
)
|
||||
|
||||
if new_score < 0.0 or new_score > 10.0:
|
||||
feedback.error("Invalid rating", "Rating must be between 0.0 and 10.0")
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Update via API
|
||||
def update_score():
|
||||
return ctx.media_api.update_list_entry(
|
||||
UpdateListEntryParams(media_id=anime.id, score=new_score)
|
||||
)
|
||||
|
||||
success, _ = execute_with_feedback(
|
||||
update_score,
|
||||
feedback,
|
||||
"update rating",
|
||||
loading_msg="Updating rating...",
|
||||
success_msg=f"Rating updated to {new_score}/10",
|
||||
error_msg="Failed to update rating",
|
||||
)
|
||||
|
||||
if success:
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
|
||||
except click.Abort:
|
||||
pass
|
||||
|
||||
# Return to appropriate menu based on how we got here
|
||||
if from_media_actions:
|
||||
return ControlFlow.BACK
|
||||
elif list_status:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": return_page}
|
||||
)
|
||||
else:
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
|
||||
def _edit_anime_status(
|
||||
ctx: Context,
|
||||
anime: MediaItem,
|
||||
list_status: str,
|
||||
return_page: int,
|
||||
feedback,
|
||||
from_media_actions: bool = False
|
||||
) -> State | ControlFlow:
|
||||
"""Edit the list status for an anime."""
|
||||
status_options = [
|
||||
"CURRENT (Currently Watching)",
|
||||
"PLANNING (Plan to Watch)",
|
||||
"COMPLETED (Completed)",
|
||||
"PAUSED (Paused)",
|
||||
"DROPPED (Dropped)",
|
||||
"REPEATING (Rewatching)",
|
||||
]
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select new status",
|
||||
choices=status_options,
|
||||
header="Change List Status",
|
||||
)
|
||||
|
||||
if not choice:
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
new_status = choice.split(" ")[0]
|
||||
|
||||
# Update via API
|
||||
def update_status():
|
||||
return ctx.media_api.update_list_entry(
|
||||
UpdateListEntryParams(media_id=anime.id, status=new_status)
|
||||
)
|
||||
|
||||
success, _ = execute_with_feedback(
|
||||
update_status,
|
||||
feedback,
|
||||
"update status",
|
||||
loading_msg="Updating status...",
|
||||
success_msg=f"Status updated to {_status_to_display_name(new_status)}",
|
||||
error_msg="Failed to update status",
|
||||
)
|
||||
|
||||
if success:
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
|
||||
# If status changed, return to main lists menu since the anime
|
||||
# is no longer in the current list
|
||||
if new_status != list_status:
|
||||
if from_media_actions:
|
||||
return ControlFlow.BACK
|
||||
else:
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
# Return to appropriate menu based on how we got here
|
||||
if from_media_actions:
|
||||
return ControlFlow.BACK
|
||||
elif list_status:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": return_page}
|
||||
)
|
||||
else:
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
|
||||
def _confirm_remove_anime(
|
||||
ctx: Context,
|
||||
anime: MediaItem,
|
||||
list_status: str,
|
||||
return_page: int,
|
||||
feedback,
|
||||
icons: bool,
|
||||
from_media_actions: bool = False
|
||||
) -> State | ControlFlow:
|
||||
"""Confirm and remove an anime from the user's list."""
|
||||
title = anime.title.english or anime.title.romaji or "Unknown Title"
|
||||
|
||||
if not feedback.confirm(
|
||||
f"Remove '{title}' from your {_status_to_display_name(list_status)} list?",
|
||||
default=False
|
||||
):
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Remove via API
|
||||
def remove_anime():
|
||||
return ctx.media_api.delete_list_entry(anime.id)
|
||||
|
||||
success, _ = execute_with_feedback(
|
||||
remove_anime,
|
||||
feedback,
|
||||
"remove anime",
|
||||
loading_msg="Removing anime from list...",
|
||||
success_msg=f"'{title}' removed from list",
|
||||
error_msg="Failed to remove anime from list",
|
||||
)
|
||||
|
||||
if success:
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
|
||||
# Return to appropriate menu based on how we got here
|
||||
if from_media_actions:
|
||||
return ControlFlow.BACK
|
||||
elif list_status:
|
||||
return State(
|
||||
menu_name="ANILIST_LIST_VIEW",
|
||||
data={"list_status": list_status, "page": return_page}
|
||||
)
|
||||
else:
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
|
||||
|
||||
def _stream_anime(ctx: Context, anime: MediaItem) -> State:
|
||||
"""Navigate to streaming interface for the selected anime."""
|
||||
return State(
|
||||
menu_name="RESULTS",
|
||||
data=MediaApiState(
|
||||
results=[anime], # Pass as single-item list
|
||||
query=anime.title.english or anime.title.romaji or "Unknown",
|
||||
page=1,
|
||||
api_params=None,
|
||||
user_list_params=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _show_all_lists_stats(ctx: Context, feedback, icons: bool) -> State | ControlFlow:
|
||||
"""Show comprehensive statistics across all user lists."""
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
# This would require fetching data from all lists
|
||||
# For now, show a placeholder implementation
|
||||
stats_text = "[bold cyan]📊 Your AniList Statistics[/bold cyan]\n\n"
|
||||
stats_text += "[dim]Loading comprehensive list statistics...[/dim]\n"
|
||||
stats_text += "[dim]This feature requires fetching data from all lists.[/dim]"
|
||||
|
||||
panel = Panel(
|
||||
stats_text,
|
||||
title=f"{'📊 ' if icons else ''}AniList Statistics",
|
||||
border_style="green",
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _search_all_lists(ctx: Context, feedback, icons: bool) -> State | ControlFlow:
|
||||
"""Search across all user lists."""
|
||||
try:
|
||||
query = click.prompt("Enter search query", type=str)
|
||||
if not query.strip():
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# This would require implementing search across all lists
|
||||
feedback.info("Search functionality", "Cross-list search will be implemented in a future update")
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
|
||||
except click.Abort:
|
||||
pass
|
||||
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _add_anime_to_list(ctx: Context, feedback, icons: bool) -> State | ControlFlow:
|
||||
"""Add a new anime to one of the user's lists."""
|
||||
try:
|
||||
query = click.prompt("Enter anime name to search", type=str)
|
||||
if not query.strip():
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Navigate to search with intent to add to list
|
||||
return State(
|
||||
menu_name="PROVIDER_SEARCH",
|
||||
data={"query": query, "add_to_list_mode": True}
|
||||
)
|
||||
|
||||
except click.Abort:
|
||||
pass
|
||||
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _add_anime_to_specific_list(
|
||||
ctx: Context,
|
||||
list_status: str,
|
||||
feedback,
|
||||
icons: bool
|
||||
) -> State | ControlFlow:
|
||||
"""Add a new anime to a specific list."""
|
||||
try:
|
||||
query = click.prompt("Enter anime name to search", type=str)
|
||||
if not query.strip():
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Navigate to search with specific list target
|
||||
return State(
|
||||
menu_name="PROVIDER_SEARCH",
|
||||
data={"query": query, "target_list": list_status}
|
||||
)
|
||||
|
||||
except click.Abort:
|
||||
pass
|
||||
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _remove_anime_from_list(
|
||||
ctx: Context,
|
||||
result: MediaSearchResult,
|
||||
list_status: str,
|
||||
page: int,
|
||||
feedback,
|
||||
icons: bool
|
||||
) -> State | ControlFlow:
|
||||
"""Select and remove an anime from the current list."""
|
||||
if not result.media:
|
||||
feedback.info("Empty list", "No anime to remove from this list")
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Create choices from anime list
|
||||
choices = []
|
||||
for i, anime in enumerate(result.media, 1):
|
||||
title = anime.title.english or anime.title.romaji or "Unknown Title"
|
||||
choices.append(f"{i}. {title}")
|
||||
|
||||
choice = ctx.selector.choose(
|
||||
prompt="Select anime to remove",
|
||||
choices=choices,
|
||||
header="Remove Anime from List",
|
||||
)
|
||||
|
||||
if not choice:
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Extract index and get selected anime
|
||||
try:
|
||||
index = int(choice.split(".")[0]) - 1
|
||||
selected_anime = result.media[index]
|
||||
|
||||
return _confirm_remove_anime(
|
||||
ctx, selected_anime, list_status, page, feedback, icons
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _show_list_statistics(
|
||||
ctx: Context,
|
||||
list_status: str,
|
||||
feedback,
|
||||
icons: bool
|
||||
) -> State | ControlFlow:
|
||||
"""Show statistics for a specific list."""
|
||||
console = Console()
|
||||
console.clear()
|
||||
|
||||
list_name = _status_to_display_name(list_status)
|
||||
|
||||
stats_text = f"[bold cyan]📊 {list_name} Statistics[/bold cyan]\n\n"
|
||||
stats_text += "[dim]Loading list statistics...[/dim]\n"
|
||||
stats_text += "[dim]This feature requires comprehensive list analysis.[/dim]"
|
||||
|
||||
panel = Panel(
|
||||
stats_text,
|
||||
title=f"{'📊 ' if icons else ''}{list_name} Stats",
|
||||
border_style="blue",
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
feedback.pause_for_user("Press Enter to continue")
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
|
||||
def _status_to_display_name(status: str) -> str:
|
||||
"""Convert API status to human-readable display name."""
|
||||
status_map = {
|
||||
"CURRENT": "Currently Watching",
|
||||
"PLANNING": "Planning to Watch",
|
||||
"COMPLETED": "Completed",
|
||||
"PAUSED": "Paused",
|
||||
"DROPPED": "Dropped",
|
||||
"REPEATING": "Rewatching",
|
||||
}
|
||||
return status_map.get(status, status)
|
||||
|
||||
|
||||
# Import click for user input
|
||||
import click
|
||||
@@ -58,7 +58,8 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
|
||||
f"{'🔁 ' if icons else ''}Rewatching": _create_user_list_action(
|
||||
ctx, "REPEATING"
|
||||
),
|
||||
# --- Local Watch History ---
|
||||
# --- List Management ---
|
||||
f"{'📚 ' if icons else ''}AniList Lists Manager": lambda: ("ANILIST_LISTS", None, None, None),
|
||||
f"{'📖 ' if icons else ''}Local Watch History": lambda: ("WATCH_HISTORY", None, None, None),
|
||||
# --- Authentication and Account Management ---
|
||||
f"{'🔐 ' if icons else ''}Authentication": lambda: ("AUTH", None, None, None),
|
||||
@@ -90,6 +91,8 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
|
||||
return State(menu_name="SESSION_MANAGEMENT")
|
||||
if next_menu_name == "AUTH":
|
||||
return State(menu_name="AUTH")
|
||||
if next_menu_name == "ANILIST_LISTS":
|
||||
return State(menu_name="ANILIST_LISTS")
|
||||
if next_menu_name == "WATCH_HISTORY":
|
||||
return State(menu_name="WATCH_HISTORY")
|
||||
if next_menu_name == "CONTINUE":
|
||||
|
||||
@@ -36,7 +36,8 @@ def media_actions(ctx: Context, state: State) -> State | ControlFlow:
|
||||
f"{'📼 ' if icons else ''}Watch Trailer": _watch_trailer(ctx, state),
|
||||
f"{'➕ ' if icons else ''}Add/Update List": _add_to_list(ctx, state),
|
||||
f"{'⭐ ' if icons else ''}Score Anime": _score_anime(ctx, state),
|
||||
f"{'📚 ' if icons else ''}Add to Local History": _add_to_local_history(ctx, state),
|
||||
f"{'<EFBFBD> ' if icons else ''}Manage in Lists": _manage_in_lists(ctx, state),
|
||||
f"{'<EFBFBD>📚 ' if icons else ''}Add to Local History": _add_to_local_history(ctx, state),
|
||||
f"{'ℹ️ ' if icons else ''}View Info": _view_info(ctx, state),
|
||||
f"{'🔙 ' if icons else ''}Back to Results": lambda: ControlFlow.BACK,
|
||||
}
|
||||
@@ -287,3 +288,30 @@ def _add_to_local_history(ctx: Context, state: State) -> MenuAction:
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def _manage_in_lists(ctx: Context, state: State) -> MenuAction:
|
||||
def action():
|
||||
feedback = create_feedback_manager(ctx.config.general.icons)
|
||||
anime = state.media_api.anime
|
||||
if not anime:
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Check authentication before proceeding
|
||||
if not check_authentication_required(
|
||||
ctx.media_api, feedback, "manage anime in your lists"
|
||||
):
|
||||
return ControlFlow.CONTINUE
|
||||
|
||||
# Navigate to AniList anime details with this specific anime
|
||||
return State(
|
||||
menu_name="ANILIST_ANIME_DETAILS",
|
||||
data={
|
||||
"anime": anime,
|
||||
"list_status": "CURRENT", # Default status, will be updated when loaded
|
||||
"return_page": 1,
|
||||
"from_media_actions": True # Flag to return here instead of lists
|
||||
}
|
||||
)
|
||||
|
||||
return action
|
||||
|
||||
208
fastanime/cli/utils/download_queue.py
Normal file
208
fastanime/cli/utils/download_queue.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Download queue management system for FastAnime.
|
||||
Handles queuing, processing, and tracking of download jobs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...core.constants import APP_DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DownloadStatus(str, Enum):
|
||||
"""Status of a download job."""
|
||||
PENDING = "pending"
|
||||
DOWNLOADING = "downloading"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class DownloadJob(BaseModel):
|
||||
"""Represents a single download job in the queue."""
|
||||
id: str = Field(description="Unique identifier for the job")
|
||||
anime_title: str = Field(description="Title of the anime")
|
||||
episode: str = Field(description="Episode number or identifier")
|
||||
media_id: Optional[int] = Field(default=None, description="AniList media ID if available")
|
||||
provider_id: Optional[str] = Field(default=None, description="Provider-specific anime ID")
|
||||
quality: str = Field(default="1080", description="Preferred quality")
|
||||
translation_type: str = Field(default="sub", description="sub or dub")
|
||||
priority: int = Field(default=5, description="Priority level (1-10, lower is higher priority)")
|
||||
status: DownloadStatus = Field(default=DownloadStatus.PENDING)
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
started_at: Optional[datetime] = Field(default=None)
|
||||
completed_at: Optional[datetime] = Field(default=None)
|
||||
error_message: Optional[str] = Field(default=None)
|
||||
retry_count: int = Field(default=0)
|
||||
auto_added: bool = Field(default=False, description="Whether this was auto-added by the service")
|
||||
|
||||
|
||||
class DownloadQueue(BaseModel):
|
||||
"""Container for all download jobs."""
|
||||
jobs: Dict[str, DownloadJob] = Field(default_factory=dict)
|
||||
max_concurrent: int = Field(default=3, description="Maximum concurrent downloads")
|
||||
auto_retry_count: int = Field(default=3, description="Maximum retry attempts")
|
||||
|
||||
|
||||
class QueueManager:
|
||||
"""Manages the download queue operations."""
|
||||
|
||||
def __init__(self, queue_file_path: Optional[Path] = None):
|
||||
self.queue_file_path = queue_file_path or APP_DATA_DIR / "download_queue.json"
|
||||
self._queue: Optional[DownloadQueue] = None
|
||||
|
||||
def _load_queue(self) -> DownloadQueue:
|
||||
"""Load queue from file."""
|
||||
if self.queue_file_path.exists():
|
||||
try:
|
||||
with open(self.queue_file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return DownloadQueue.model_validate(data)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.error(f"Failed to load queue from {self.queue_file_path}: {e}")
|
||||
return DownloadQueue()
|
||||
return DownloadQueue()
|
||||
|
||||
def _save_queue(self, queue: DownloadQueue) -> bool:
|
||||
"""Save queue to file."""
|
||||
try:
|
||||
with open(self.queue_file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(queue.model_dump(), f, indent=2, default=str)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save queue to {self.queue_file_path}: {e}")
|
||||
return False
|
||||
|
||||
@property
|
||||
def queue(self) -> DownloadQueue:
|
||||
"""Get the current queue, loading it if necessary."""
|
||||
if self._queue is None:
|
||||
self._queue = self._load_queue()
|
||||
return self._queue
|
||||
|
||||
def add_job(self, job: DownloadJob) -> bool:
|
||||
"""Add a new download job to the queue."""
|
||||
try:
|
||||
self.queue.jobs[job.id] = job
|
||||
success = self._save_queue(self.queue)
|
||||
if success:
|
||||
logger.info(f"Added download job: {job.anime_title} Episode {job.episode}")
|
||||
return success
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add job to queue: {e}")
|
||||
return False
|
||||
|
||||
def remove_job(self, job_id: str) -> bool:
|
||||
"""Remove a job from the queue."""
|
||||
try:
|
||||
if job_id in self.queue.jobs:
|
||||
job = self.queue.jobs.pop(job_id)
|
||||
success = self._save_queue(self.queue)
|
||||
if success:
|
||||
logger.info(f"Removed download job: {job.anime_title} Episode {job.episode}")
|
||||
return success
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove job from queue: {e}")
|
||||
return False
|
||||
|
||||
def update_job_status(self, job_id: str, status: DownloadStatus, error_message: Optional[str] = None) -> bool:
|
||||
"""Update the status of a job."""
|
||||
try:
|
||||
if job_id in self.queue.jobs:
|
||||
job = self.queue.jobs[job_id]
|
||||
job.status = status
|
||||
if error_message:
|
||||
job.error_message = error_message
|
||||
|
||||
if status == DownloadStatus.DOWNLOADING:
|
||||
job.started_at = datetime.now()
|
||||
elif status in (DownloadStatus.COMPLETED, DownloadStatus.FAILED, DownloadStatus.CANCELLED):
|
||||
job.completed_at = datetime.now()
|
||||
|
||||
return self._save_queue(self.queue)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update job status: {e}")
|
||||
return False
|
||||
|
||||
def get_pending_jobs(self, limit: Optional[int] = None) -> List[DownloadJob]:
|
||||
"""Get pending jobs sorted by priority and creation time."""
|
||||
pending = [
|
||||
job for job in self.queue.jobs.values()
|
||||
if job.status == DownloadStatus.PENDING
|
||||
]
|
||||
# Sort by priority (lower number = higher priority), then by creation time
|
||||
pending.sort(key=lambda x: (x.priority, x.created_at))
|
||||
|
||||
if limit:
|
||||
return pending[:limit]
|
||||
return pending
|
||||
|
||||
def get_active_jobs(self) -> List[DownloadJob]:
|
||||
"""Get currently downloading jobs."""
|
||||
return [
|
||||
job for job in self.queue.jobs.values()
|
||||
if job.status == DownloadStatus.DOWNLOADING
|
||||
]
|
||||
|
||||
def get_job_by_id(self, job_id: str) -> Optional[DownloadJob]:
|
||||
"""Get a specific job by ID."""
|
||||
return self.queue.jobs.get(job_id)
|
||||
|
||||
def get_all_jobs(self) -> List[DownloadJob]:
|
||||
"""Get all jobs."""
|
||||
return list(self.queue.jobs.values())
|
||||
|
||||
def clean_completed_jobs(self, max_age_days: int = 7) -> int:
|
||||
"""Remove completed jobs older than specified days."""
|
||||
cutoff_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
cutoff_date = cutoff_date.replace(day=cutoff_date.day - max_age_days)
|
||||
|
||||
jobs_to_remove = []
|
||||
for job_id, job in self.queue.jobs.items():
|
||||
if (job.status in (DownloadStatus.COMPLETED, DownloadStatus.FAILED, DownloadStatus.CANCELLED)
|
||||
and job.completed_at and job.completed_at < cutoff_date):
|
||||
jobs_to_remove.append(job_id)
|
||||
|
||||
for job_id in jobs_to_remove:
|
||||
del self.queue.jobs[job_id]
|
||||
|
||||
if jobs_to_remove:
|
||||
self._save_queue(self.queue)
|
||||
logger.info(f"Cleaned {len(jobs_to_remove)} old completed jobs")
|
||||
|
||||
return len(jobs_to_remove)
|
||||
|
||||
def get_queue_stats(self) -> Dict[str, int]:
|
||||
"""Get statistics about the queue."""
|
||||
stats = {
|
||||
"total": len(self.queue.jobs),
|
||||
"pending": 0,
|
||||
"downloading": 0,
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
"cancelled": 0
|
||||
}
|
||||
|
||||
for job in self.queue.jobs.values():
|
||||
if job.status == DownloadStatus.PENDING:
|
||||
stats["pending"] += 1
|
||||
elif job.status == DownloadStatus.DOWNLOADING:
|
||||
stats["downloading"] += 1
|
||||
elif job.status == DownloadStatus.COMPLETED:
|
||||
stats["completed"] += 1
|
||||
elif job.status == DownloadStatus.FAILED:
|
||||
stats["failed"] += 1
|
||||
elif job.status == DownloadStatus.CANCELLED:
|
||||
stats["cancelled"] += 1
|
||||
|
||||
return stats
|
||||
@@ -297,6 +297,49 @@ class StreamConfig(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class ServiceConfig(BaseModel):
|
||||
"""Configuration for the background download service."""
|
||||
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
description="Whether the background service should be enabled by default.",
|
||||
)
|
||||
watchlist_check_interval: int = Field(
|
||||
default=30,
|
||||
ge=5,
|
||||
le=180,
|
||||
description="Minutes between checking AniList watchlist for new episodes.",
|
||||
)
|
||||
queue_process_interval: int = Field(
|
||||
default=1,
|
||||
ge=1,
|
||||
le=60,
|
||||
description="Minutes between processing the download queue.",
|
||||
)
|
||||
max_concurrent_downloads: int = Field(
|
||||
default=3,
|
||||
ge=1,
|
||||
le=10,
|
||||
description="Maximum number of concurrent downloads.",
|
||||
)
|
||||
auto_retry_count: int = Field(
|
||||
default=3,
|
||||
ge=0,
|
||||
le=10,
|
||||
description="Number of times to retry failed downloads.",
|
||||
)
|
||||
cleanup_completed_days: int = Field(
|
||||
default=7,
|
||||
ge=1,
|
||||
le=30,
|
||||
description="Days to keep completed/failed jobs in queue before cleanup.",
|
||||
)
|
||||
notification_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Whether to show notifications for new episodes.",
|
||||
)
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
"""The root configuration model for the FastAnime application."""
|
||||
|
||||
@@ -315,6 +358,10 @@ class AppConfig(BaseModel):
|
||||
default_factory=AnilistConfig,
|
||||
description="Configuration for AniList API integration.",
|
||||
)
|
||||
service: ServiceConfig = Field(
|
||||
default_factory=ServiceConfig,
|
||||
description="Configuration for the background download service.",
|
||||
)
|
||||
|
||||
fzf: FzfConfig = Field(
|
||||
default_factory=FzfConfig,
|
||||
@@ -327,3 +374,7 @@ class AppConfig(BaseModel):
|
||||
mpv: MpvConfig = Field(
|
||||
default_factory=MpvConfig, description="Configuration for the MPV media player."
|
||||
)
|
||||
service: ServiceConfig = Field(
|
||||
default_factory=ServiceConfig,
|
||||
description="Configuration for the background download service.",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user