diff --git a/fastanime/assets/normalizer.json b/fastanime/assets/normalizer.json new file mode 100644 index 0000000..50cf9d2 --- /dev/null +++ b/fastanime/assets/normalizer.json @@ -0,0 +1,5 @@ +{ + "allanime":{ + "1p":"One Piece" + } +} \ No newline at end of file diff --git a/fastanime/cli/cli.py b/fastanime/cli/cli.py index b26b996..37718db 100644 --- a/fastanime/cli/cli.py +++ b/fastanime/cli/cli.py @@ -31,6 +31,8 @@ commands = { "search": ".search", "download": ".download", "anilist": ".anilist", + "queue": ".queue", + "service": ".service", } diff --git a/fastanime/cli/commands/__init__.py b/fastanime/cli/commands/__init__.py index 2eae318..41bd8f0 100644 --- a/fastanime/cli/commands/__init__.py +++ b/fastanime/cli/commands/__init__.py @@ -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"] diff --git a/fastanime/cli/commands/anilist/subcommands/login.py b/fastanime/cli/commands/anilist/subcommands/auth.py similarity index 100% rename from fastanime/cli/commands/anilist/subcommands/login.py rename to fastanime/cli/commands/anilist/subcommands/auth.py diff --git a/fastanime/cli/commands/anilist/subcommands/data.py b/fastanime/cli/commands/anilist/subcommands/data.py deleted file mode 100644 index b66a590..0000000 --- a/fastanime/cli/commands/anilist/subcommands/data.py +++ /dev/null @@ -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) diff --git a/fastanime/cli/commands/anilist/subcommands/download.py b/fastanime/cli/commands/anilist/subcommands/download.py deleted file mode 100644 index 4910b54..0000000 --- a/fastanime/cli/commands/anilist/subcommands/download.py +++ /dev/null @@ -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) diff --git a/fastanime/cli/commands/anilist/subcommands/downloads.py b/fastanime/cli/commands/anilist/subcommands/downloads.py deleted file mode 100644 index d302ce8..0000000 --- a/fastanime/cli/commands/anilist/subcommands/downloads.py +++ /dev/null @@ -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 - # --- or --- - fastanime downloads -t -\b - # to watch a specific title - # be sure to get the completions for the best experience - fastanime downloads --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) diff --git a/fastanime/cli/commands/anilist/subcommands/notifier.py b/fastanime/cli/commands/anilist/subcommands/notifier.py deleted file mode 100644 index 0da6f62..0000000 --- a/fastanime/cli/commands/anilist/subcommands/notifier.py +++ /dev/null @@ -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) diff --git a/fastanime/cli/commands/anilist/subcommands/random_anime.py b/fastanime/cli/commands/anilist/subcommands/random.py similarity index 100% rename from fastanime/cli/commands/anilist/subcommands/random_anime.py rename to fastanime/cli/commands/anilist/subcommands/random.py diff --git a/fastanime/cli/commands/config.py b/fastanime/cli/commands/config.py index e991983..8c8ab3d 100644 --- a/fastanime/cli/commands/config.py +++ b/fastanime/cli/commands/config.py @@ -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) diff --git a/fastanime/cli/commands/queue.py b/fastanime/cli/commands/queue.py new file mode 100644 index 0000000..c87c52e --- /dev/null +++ b/fastanime/cli/commands/queue.py @@ -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]") diff --git a/fastanime/cli/commands/serve.py b/fastanime/cli/commands/serve.py deleted file mode 100644 index 63eea7f..0000000 --- a/fastanime/cli/commands/serve.py +++ /dev/null @@ -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) diff --git a/fastanime/cli/commands/service.py b/fastanime/cli/commands/service.py new file mode 100644 index 0000000..7fc1721 --- /dev/null +++ b/fastanime/cli/commands/service.py @@ -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) diff --git a/fastanime/cli/config/interactive_editor.py b/fastanime/cli/config/editor.py similarity index 100% rename from fastanime/cli/config/interactive_editor.py rename to fastanime/cli/config/editor.py diff --git a/fastanime/cli/config/loader.py b/fastanime/cli/config/loader.py index 0539a03..0c2bf05 100644 --- a/fastanime/cli/config/loader.py +++ b/fastanime/cli/config/loader.py @@ -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: diff --git a/fastanime/cli/interactive/menus/anilist_lists.py b/fastanime/cli/interactive/menus/anilist_lists.py new file mode 100644 index 0000000..0d4c281 --- /dev/null +++ b/fastanime/cli/interactive/menus/anilist_lists.py @@ -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 diff --git a/fastanime/cli/interactive/menus/main.py b/fastanime/cli/interactive/menus/main.py index b58405f..0d1d130 100644 --- a/fastanime/cli/interactive/menus/main.py +++ b/fastanime/cli/interactive/menus/main.py @@ -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": diff --git a/fastanime/cli/interactive/menus/media_actions.py b/fastanime/cli/interactive/menus/media_actions.py index 80457f3..d448078 100644 --- a/fastanime/cli/interactive/menus/media_actions.py +++ b/fastanime/cli/interactive/menus/media_actions.py @@ -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"{'īŋŊ ' if icons else ''}Manage in Lists": _manage_in_lists(ctx, state), + f"{'īŋŊ📚 ' 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 diff --git a/fastanime/cli/utils/download_queue.py b/fastanime/cli/utils/download_queue.py new file mode 100644 index 0000000..bb68ac6 --- /dev/null +++ b/fastanime/cli/utils/download_queue.py @@ -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 diff --git a/fastanime/core/config/model.py b/fastanime/core/config/model.py index 8e98fb4..1e2ce86 100644 --- a/fastanime/core/config/model.py +++ b/fastanime/core/config/model.py @@ -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.", + )