feat: stuff happened

This commit is contained in:
Benexl
2025-07-15 23:37:15 +03:00
parent 5dde02570a
commit 490f8b0e8b
20 changed files with 1971 additions and 1399 deletions

View File

@@ -0,0 +1,5 @@
{
"allanime":{
"1p":"One Piece"
}
}

View File

@@ -31,6 +31,8 @@ commands = {
"search": ".search",
"download": ".download",
"anilist": ".anilist",
"queue": ".queue",
"service": ".service",
}

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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)

View File

@@ -1,358 +0,0 @@
import logging
from typing import TYPE_CHECKING
import click
from ...utils.completion_functions import downloaded_anime_titles
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ..config import Config
@click.command(
help="View and watch your downloads using mpv",
short_help="Watch downloads",
epilog="""
\b
\b\bExamples:
fastanime downloads
\b
# view individual episodes
fastanime downloads --view-episodes
# --- or ---
fastanime downloads -v
\b
# to set seek time when using ffmpegthumbnailer for local previews
# -1 means random and is the default
fastanime downloads --time-to-seek <intRange(-1,100)>
# --- or ---
fastanime downloads -t <intRange(-1,100)>
\b
# to watch a specific title
# be sure to get the completions for the best experience
fastanime downloads --title <title>
\b
# to get the path to the downloads folder set
fastanime downloads --path
# useful when you want to use the value for other programs
""",
)
@click.option("--path", "-p", help="print the downloads folder and exit", is_flag=True)
@click.option(
"--title",
"-T",
shell_complete=downloaded_anime_titles,
help="watch a specific title",
)
@click.option("--view-episodes", "-v", help="View individual episodes", is_flag=True)
@click.option(
"--ffmpegthumbnailer-seek-time",
"--time-to-seek",
"-t",
type=click.IntRange(-1, 100),
help="ffmpegthumbnailer seek time",
)
@click.pass_obj
def downloads(
config: "Config", path: bool, title, view_episodes, ffmpegthumbnailer_seek_time
):
import os
from ....cli.utils.mpv import run_mpv
from ....libs.fzf import fzf
from ....libs.rofi import Rofi
from ....Utility.utils import sort_by_episode_number
from ...utils.tools import exit_app
from ...utils.utils import fuzzy_inquirer
if not ffmpegthumbnailer_seek_time:
ffmpegthumbnailer_seek_time = config.ffmpegthumbnailer_seek_time
USER_VIDEOS_DIR = config.downloads_dir
if path:
print(USER_VIDEOS_DIR)
return
if not os.path.exists(USER_VIDEOS_DIR):
print("Downloads directory specified does not exist")
return
anime_downloads = sorted(
os.listdir(USER_VIDEOS_DIR),
)
anime_downloads.append("Exit")
def create_thumbnails(video_path, anime_title, downloads_thumbnail_cache_dir):
import os
import shutil
import subprocess
FFMPEG_THUMBNAILER = shutil.which("ffmpegthumbnailer")
if not FFMPEG_THUMBNAILER:
return
out = os.path.join(downloads_thumbnail_cache_dir, anime_title)
if ffmpegthumbnailer_seek_time == -1:
import random
seektime = str(random.randrange(0, 100))
else:
seektime = str(ffmpegthumbnailer_seek_time)
_ = subprocess.run(
[
FFMPEG_THUMBNAILER,
"-i",
video_path,
"-o",
out,
"-s",
"0",
"-t",
seektime,
],
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
check=False,
)
def get_previews_anime(workers=None, bg=True):
import concurrent.futures
import random
import shutil
from pathlib import Path
if not shutil.which("ffmpegthumbnailer"):
print("ffmpegthumbnailer not found")
logger.error("ffmpegthumbnailer not found")
return
from ....constants import APP_CACHE_DIR
from ...utils.scripts import bash_functions
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
def _worker():
# use concurrency to download the images as fast as possible
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
# load the jobs
future_to_url = {}
for anime_title in anime_downloads:
anime_path = os.path.join(USER_VIDEOS_DIR, anime_title)
if not os.path.isdir(anime_path):
continue
playlist = [
anime
for anime in sorted(
os.listdir(anime_path),
)
if "mp4" in anime
]
if playlist:
# actual link to download image from
video_path = os.path.join(anime_path, random.choice(playlist))
future_to_url[
executor.submit(
create_thumbnails,
video_path,
anime_title,
downloads_thumbnail_cache_dir,
)
] = anime_title
# execute the jobs
for future in concurrent.futures.as_completed(future_to_url):
url = future_to_url[future]
try:
future.result()
except Exception as e:
logger.error("%r generated an exception: %s" % (url, e))
if bg:
from threading import Thread
worker = Thread(target=_worker)
worker.daemon = True
worker.start()
else:
_worker()
os.environ["SHELL"] = shutil.which("bash") or "bash"
preview = """
%s
if [ -s %s/{} ]; then
if ! fzf-preview %s/{} 2>/dev/null; then
echo Loading...
fi
else echo Loading...
fi
""" % (
bash_functions,
downloads_thumbnail_cache_dir,
downloads_thumbnail_cache_dir,
)
return preview
def get_previews_episodes(anime_playlist_path, workers=None, bg=True):
import shutil
from pathlib import Path
from ....constants import APP_CACHE_DIR
from ...utils.scripts import bash_functions
if not shutil.which("ffmpegthumbnailer"):
print("ffmpegthumbnailer not found")
logger.error("ffmpegthumbnailer not found")
return
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
def _worker():
import concurrent.futures
# use concurrency to download the images as fast as possible
# anime_playlist_path = os.path.join(USER_VIDEOS_DIR, anime_playlist_path)
if not os.path.isdir(anime_playlist_path):
return
anime_episodes = sorted(
os.listdir(anime_playlist_path), key=sort_by_episode_number
)
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
# load the jobs
future_to_url = {}
for episode_title in anime_episodes:
episode_path = os.path.join(anime_playlist_path, episode_title)
# actual link to download image from
future_to_url[
executor.submit(
create_thumbnails,
episode_path,
episode_title,
downloads_thumbnail_cache_dir,
)
] = episode_title
# execute the jobs
for future in concurrent.futures.as_completed(future_to_url):
url = future_to_url[future]
try:
future.result()
except Exception as e:
logger.error("%r generated an exception: %s" % (url, e))
if bg:
from threading import Thread
worker = Thread(target=_worker)
worker.daemon = True
worker.start()
else:
_worker()
os.environ["SHELL"] = shutil.which("bash") or "bash"
preview = """
%s
if [ -s %s/{} ]; then
if ! fzf-preview %s/{} 2>/dev/null; then
echo Loading...
fi
else echo Loading...
fi
""" % (
bash_functions,
downloads_thumbnail_cache_dir,
downloads_thumbnail_cache_dir,
)
return preview
def stream_episode(
anime_playlist_path,
):
if view_episodes:
if not os.path.isdir(anime_playlist_path):
print(anime_playlist_path, "is not dir")
exit_app(1)
return
episodes = sorted(
os.listdir(anime_playlist_path), key=sort_by_episode_number
)
downloaded_episodes = [*episodes, "Back"]
if config.use_fzf:
if not config.preview:
episode_title = fzf.run(
downloaded_episodes,
"Enter Episode ",
)
else:
preview = get_previews_episodes(anime_playlist_path)
episode_title = fzf.run(
downloaded_episodes,
"Enter Episode ",
preview=preview,
)
elif config.use_rofi:
episode_title = Rofi.run(downloaded_episodes, "Enter Episode")
else:
episode_title = fuzzy_inquirer(
downloaded_episodes,
"Enter Playlist Name",
)
if episode_title == "Back":
stream_anime()
return
episode_path = os.path.join(anime_playlist_path, episode_title)
if config.sync_play:
from ...utils.syncplay import SyncPlayer
SyncPlayer(episode_path)
else:
run_mpv(
episode_path,
player=config.player,
)
stream_episode(anime_playlist_path)
def stream_anime(title=None):
if title:
from thefuzz import fuzz
playlist_name = max(anime_downloads, key=lambda t: fuzz.ratio(title, t))
elif config.use_fzf:
if not config.preview:
playlist_name = fzf.run(
anime_downloads,
"Enter Playlist Name",
)
else:
preview = get_previews_anime()
playlist_name = fzf.run(
anime_downloads,
"Enter Playlist Name",
preview=preview,
)
elif config.use_rofi:
playlist_name = Rofi.run(anime_downloads, "Enter Playlist Name")
else:
playlist_name = fuzzy_inquirer(
anime_downloads,
"Enter Playlist Name",
)
if playlist_name == "Exit":
exit_app()
return
playlist = os.path.join(USER_VIDEOS_DIR, playlist_name)
if view_episodes:
stream_episode(
playlist,
)
elif config.sync_play:
from ...utils.syncplay import SyncPlayer
SyncPlayer(playlist)
else:
run_mpv(
playlist,
player=config.player,
)
stream_anime()
stream_anime(title)

View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1,299 @@
"""
Queue command for manual download queue management.
"""
import logging
import uuid
from typing import TYPE_CHECKING
import click
from rich.console import Console
from rich.progress import Progress
from rich.table import Table
if TYPE_CHECKING:
from fastanime.core.config import AppConfig
from ..utils.download_queue import DownloadJob, DownloadStatus, QueueManager
from ..utils.feedback import create_feedback_manager
logger = logging.getLogger(__name__)
@click.command(
help="Manage the download queue",
short_help="Download queue management",
epilog="""
\b
\b\bExamples:
# Show queue status
fastanime queue
# Add anime to download queue
fastanime queue --add "Attack on Titan" --episode "1"
# Add with specific quality and priority
fastanime queue --add "Demon Slayer" --episode "5" --quality "720" --priority 2
# Clear completed jobs
fastanime queue --clean
# Remove specific job
fastanime queue --remove <job-id>
# Show detailed queue information
fastanime queue --detailed
""",
)
@click.option(
"--add", "-a",
help="Add anime to download queue (anime title)"
)
@click.option(
"--episode", "-e",
help="Episode number to download (required with --add)"
)
@click.option(
"--quality", "-q",
type=click.Choice(["360", "480", "720", "1080"]),
default="1080",
help="Video quality preference"
)
@click.option(
"--priority", "-p",
type=click.IntRange(1, 10),
default=5,
help="Download priority (1=highest, 10=lowest)"
)
@click.option(
"--translation-type", "-t",
type=click.Choice(["sub", "dub"]),
default="sub",
help="Audio/subtitle preference"
)
@click.option(
"--remove", "-r",
help="Remove job from queue by ID"
)
@click.option(
"--clean", "-c",
is_flag=True,
help="Remove completed/failed jobs older than 7 days"
)
@click.option(
"--detailed", "-d",
is_flag=True,
help="Show detailed queue information"
)
@click.option(
"--cancel",
help="Cancel a specific job by ID"
)
@click.pass_obj
def queue(
config: "AppConfig",
add: str,
episode: str,
quality: str,
priority: int,
translation_type: str,
remove: str,
clean: bool,
detailed: bool,
cancel: str
):
"""Manage the download queue for automated and manual downloads."""
console = Console()
feedback = create_feedback_manager(config.general.icons)
queue_manager = QueueManager()
try:
# Add new job to queue
if add:
if not episode:
feedback.error("Episode number is required when adding to queue",
"Use --episode to specify the episode number")
raise click.Abort()
job_id = str(uuid.uuid4())
job = DownloadJob(
id=job_id,
anime_title=add,
episode=episode,
quality=quality,
translation_type=translation_type,
priority=priority,
auto_added=False
)
success = queue_manager.add_job(job)
if success:
feedback.success(
f"Added to queue: {add} Episode {episode}",
f"Job ID: {job_id[:8]}... Priority: {priority}"
)
else:
feedback.error("Failed to add job to queue", "Check logs for details")
raise click.Abort()
return
# Remove job from queue
if remove:
# Allow partial job ID matching
matching_jobs = [
job_id for job_id in queue_manager.queue.jobs.keys()
if job_id.startswith(remove)
]
if not matching_jobs:
feedback.error(f"No job found with ID starting with: {remove}")
raise click.Abort()
elif len(matching_jobs) > 1:
feedback.error(f"Multiple jobs match ID: {remove}",
f"Be more specific. Matches: {[job_id[:8] for job_id in matching_jobs]}")
raise click.Abort()
job_id = matching_jobs[0]
job = queue_manager.get_job_by_id(job_id)
success = queue_manager.remove_job(job_id)
if success:
feedback.success(
f"Removed from queue: {job.anime_title} Episode {job.episode}",
f"Job ID: {job_id[:8]}..."
)
else:
feedback.error("Failed to remove job from queue", "Check logs for details")
raise click.Abort()
return
# Cancel job
if cancel:
# Allow partial job ID matching
matching_jobs = [
job_id for job_id in queue_manager.queue.jobs.keys()
if job_id.startswith(cancel)
]
if not matching_jobs:
feedback.error(f"No job found with ID starting with: {cancel}")
raise click.Abort()
elif len(matching_jobs) > 1:
feedback.error(f"Multiple jobs match ID: {cancel}",
f"Be more specific. Matches: {[job_id[:8] for job_id in matching_jobs]}")
raise click.Abort()
job_id = matching_jobs[0]
job = queue_manager.get_job_by_id(job_id)
success = queue_manager.update_job_status(job_id, DownloadStatus.CANCELLED)
if success:
feedback.success(
f"Cancelled job: {job.anime_title} Episode {job.episode}",
f"Job ID: {job_id[:8]}..."
)
else:
feedback.error("Failed to cancel job", "Check logs for details")
raise click.Abort()
return
# Clean old completed jobs
if clean:
with Progress() as progress:
task = progress.add_task("Cleaning old jobs...", total=None)
cleaned_count = queue_manager.clean_completed_jobs()
progress.update(task, completed=True)
if cleaned_count > 0:
feedback.success(f"Cleaned {cleaned_count} old jobs from queue")
else:
feedback.info("No old jobs to clean")
return
# Show queue status (default action)
_display_queue_status(console, queue_manager, detailed, config.general.icons)
except Exception as e:
feedback.error("An error occurred while managing the queue", str(e))
logger.error(f"Queue command error: {e}")
raise click.Abort()
def _display_queue_status(console: Console, queue_manager: QueueManager, detailed: bool, icons: bool):
"""Display the current queue status."""
stats = queue_manager.get_queue_stats()
# Display summary
console.print()
console.print(f"{'📥 ' if icons else ''}[bold cyan]Download Queue Status[/bold cyan]")
console.print()
summary_table = Table(title="Queue Summary")
summary_table.add_column("Status", style="cyan")
summary_table.add_column("Count", justify="right", style="green")
summary_table.add_row("Total Jobs", str(stats["total"]))
summary_table.add_row("Pending", str(stats["pending"]))
summary_table.add_row("Downloading", str(stats["downloading"]))
summary_table.add_row("Completed", str(stats["completed"]))
summary_table.add_row("Failed", str(stats["failed"]))
summary_table.add_row("Cancelled", str(stats["cancelled"]))
console.print(summary_table)
console.print()
if detailed or stats["total"] > 0:
_display_detailed_queue(console, queue_manager, icons)
def _display_detailed_queue(console: Console, queue_manager: QueueManager, icons: bool):
"""Display detailed information about jobs in the queue."""
jobs = queue_manager.get_all_jobs()
if not jobs:
console.print(f"{' ' if icons else ''}[dim]No jobs in queue[/dim]")
return
# Sort jobs by status and creation time
jobs.sort(key=lambda x: (x.status.value, x.created_at))
table = Table(title="Job Details")
table.add_column("ID", width=8)
table.add_column("Anime", style="cyan")
table.add_column("Episode", justify="center")
table.add_column("Status", justify="center")
table.add_column("Priority", justify="center")
table.add_column("Quality", justify="center")
table.add_column("Type", justify="center")
table.add_column("Created", style="dim")
status_colors = {
DownloadStatus.PENDING: "yellow",
DownloadStatus.DOWNLOADING: "blue",
DownloadStatus.COMPLETED: "green",
DownloadStatus.FAILED: "red",
DownloadStatus.CANCELLED: "dim"
}
for job in jobs:
status_color = status_colors.get(job.status, "white")
auto_marker = f"{'🤖' if icons else 'A'}" if job.auto_added else f"{'👤' if icons else 'M'}"
table.add_row(
job.id[:8],
job.anime_title[:30] + "..." if len(job.anime_title) > 30 else job.anime_title,
job.episode,
f"[{status_color}]{job.status.value}[/{status_color}]",
str(job.priority),
job.quality,
f"{auto_marker} {job.translation_type}",
job.created_at.strftime("%m-%d %H:%M")
)
console.print(table)
if icons:
console.print()
console.print("[dim]🤖 = Auto-added, 👤 = Manual[/dim]")

View File

@@ -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)

View File

@@ -0,0 +1,547 @@
"""
Background service for automated download queue processing and episode monitoring.
"""
import json
import logging
import signal
import sys
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional, Set, cast, Literal
import click
from rich.console import Console
from rich.progress import Progress
if TYPE_CHECKING:
from fastanime.core.config import AppConfig
from fastanime.libs.api.base import BaseApiClient
from fastanime.libs.api.types import MediaItem
from ..utils.download_queue import DownloadJob, DownloadStatus, QueueManager
from ..utils.feedback import create_feedback_manager
logger = logging.getLogger(__name__)
class DownloadService:
"""Background service for processing download queue and monitoring new episodes."""
def __init__(self, config: "AppConfig"):
self.config = config
self.queue_manager = QueueManager()
self.console = Console()
self.feedback = create_feedback_manager(config.general.icons)
self._running = False
self._shutdown_event = threading.Event()
# Service state
self.last_watchlist_check = datetime.now() - timedelta(hours=1) # Force initial check
self.known_episodes: Dict[int, Set[str]] = {} # media_id -> set of episode numbers
self.last_notification_check = datetime.now() - timedelta(minutes=10)
# Configuration
self.watchlist_check_interval = self.config.service.watchlist_check_interval * 60 # Convert to seconds
self.queue_process_interval = self.config.service.queue_process_interval * 60 # Convert to seconds
self.notification_check_interval = 2 * 60 # 2 minutes in seconds
self.max_concurrent_downloads = self.config.service.max_concurrent_downloads
# State file for persistence
from fastanime.core.constants import APP_DATA_DIR
self.state_file = APP_DATA_DIR / "service_state.json"
def _load_state(self):
"""Load service state from file."""
try:
if self.state_file.exists():
with open(self.state_file, 'r') as f:
data = json.load(f)
self.known_episodes = {
int(k): set(v) for k, v in data.get('known_episodes', {}).items()
}
self.last_watchlist_check = datetime.fromisoformat(
data.get('last_watchlist_check', datetime.now().isoformat())
)
logger.info("Service state loaded successfully")
except Exception as e:
logger.warning(f"Failed to load service state: {e}")
def _save_state(self):
"""Save service state to file."""
try:
data = {
'known_episodes': {
str(k): list(v) for k, v in self.known_episodes.items()
},
'last_watchlist_check': self.last_watchlist_check.isoformat(),
'last_saved': datetime.now().isoformat()
}
with open(self.state_file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
logger.error(f"Failed to save service state: {e}")
def start(self):
"""Start the background service."""
logger.info("Starting FastAnime download service...")
self.console.print(f"{'🚀 ' if self.config.general.icons else ''}[bold green]Starting FastAnime Download Service[/bold green]")
# Load previous state
self._load_state()
# Set up signal handlers for graceful shutdown
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
self._running = True
# Start worker threads
watchlist_thread = threading.Thread(target=self._watchlist_monitor, daemon=True)
queue_thread = threading.Thread(target=self._queue_processor, daemon=True)
watchlist_thread.start()
queue_thread.start()
self.console.print(f"{'' if self.config.general.icons else ''}Service started successfully")
self.console.print(f"{'📊 ' if self.config.general.icons else ''}Monitoring watchlist every {self.watchlist_check_interval // 60} minutes")
self.console.print(f"{'⚙️ ' if self.config.general.icons else ''}Processing queue every {self.queue_process_interval} seconds")
self.console.print(f"{'🛑 ' if self.config.general.icons else ''}Press Ctrl+C to stop")
try:
# Main loop - just wait for shutdown
while self._running and not self._shutdown_event.wait(timeout=10):
self._save_state() # Periodic state saving
except KeyboardInterrupt:
pass
finally:
self._shutdown()
def _signal_handler(self, signum, frame):
"""Handle shutdown signals."""
logger.info(f"Received signal {signum}, shutting down...")
self._running = False
self._shutdown_event.set()
def _shutdown(self):
"""Gracefully shutdown the service."""
logger.info("Shutting down download service...")
self.console.print(f"{'🛑 ' if self.config.general.icons else ''}[yellow]Shutting down service...[/yellow]")
self._running = False
self._shutdown_event.set()
# Save final state
self._save_state()
# Cancel any running downloads
active_jobs = self.queue_manager.get_active_jobs()
for job in active_jobs:
self.queue_manager.update_job_status(job.id, DownloadStatus.CANCELLED)
self.console.print(f"{'' if self.config.general.icons else ''}Service stopped")
logger.info("Download service shutdown complete")
def _watchlist_monitor(self):
"""Monitor user's AniList watching list for new episodes."""
logger.info("Starting watchlist monitor thread")
while self._running:
try:
if (datetime.now() - self.last_watchlist_check).total_seconds() >= self.watchlist_check_interval:
self._check_for_new_episodes()
self.last_watchlist_check = datetime.now()
# Check for notifications (like the existing notifier)
if (datetime.now() - self.last_notification_check).total_seconds() >= self.notification_check_interval:
self._check_notifications()
self.last_notification_check = datetime.now()
except Exception as e:
logger.error(f"Error in watchlist monitor: {e}")
# Sleep with check for shutdown
if self._shutdown_event.wait(timeout=60):
break
logger.info("Watchlist monitor thread stopped")
def _queue_processor(self):
"""Process the download queue."""
logger.info("Starting queue processor thread")
while self._running:
try:
self._process_download_queue()
except Exception as e:
logger.error(f"Error in queue processor: {e}")
# Sleep with check for shutdown
if self._shutdown_event.wait(timeout=self.queue_process_interval):
break
logger.info("Queue processor thread stopped")
def _check_for_new_episodes(self):
"""Check user's watching list for newly released episodes."""
try:
logger.info("Checking for new episodes in watchlist...")
# Get authenticated API client
from fastanime.libs.api.factory import create_api_client
from fastanime.libs.api.params import UserListParams
api_client = create_api_client(self.config.general.api_client, self.config)
# Check if user is authenticated
user_profile = api_client.get_viewer_profile()
if not user_profile:
logger.warning("User not authenticated, skipping watchlist check")
return
# Fetch currently watching anime
with Progress() as progress:
task = progress.add_task("Checking watchlist...", total=None)
list_params = UserListParams(
status="CURRENT", # Currently watching
page=1,
per_page=50
)
user_list = api_client.fetch_user_list(list_params)
progress.update(task, completed=True)
if not user_list or not user_list.media:
logger.info("No anime found in watching list")
return
new_episodes_found = 0
for media_item in user_list.media:
try:
media_id = media_item.id
# Get available episodes from provider
available_episodes = self._get_available_episodes(media_item)
if not available_episodes:
continue
# Check if we have new episodes
known_eps = self.known_episodes.get(media_id, set())
new_episodes = set(available_episodes) - known_eps
if new_episodes:
logger.info(f"Found {len(new_episodes)} new episodes for {media_item.title.romaji or media_item.title.english}")
# Add new episodes to download queue
for episode in sorted(new_episodes, key=lambda x: float(x) if x.isdigit() else 0):
self._add_episode_to_queue(media_item, episode)
new_episodes_found += 1
# Update known episodes
self.known_episodes[media_id] = set(available_episodes)
else:
# Update known episodes even if no new ones (in case some were removed)
self.known_episodes[media_id] = set(available_episodes)
except Exception as e:
logger.error(f"Error checking episodes for {media_item.title.romaji}: {e}")
if new_episodes_found > 0:
logger.info(f"Added {new_episodes_found} new episodes to download queue")
self.console.print(f"{'📺 ' if self.config.general.icons else ''}Found {new_episodes_found} new episodes, added to queue")
else:
logger.info("No new episodes found")
except Exception as e:
logger.error(f"Error checking for new episodes: {e}")
def _get_available_episodes(self, media_item: "MediaItem") -> List[str]:
"""Get available episodes for a media item from the provider."""
try:
from fastanime.libs.providers.anime.provider import create_provider
from fastanime.libs.providers.anime.params import AnimeParams, SearchParams
from httpx import Client
client = Client()
provider = create_provider(self.config.general.provider)
# Search for the anime
search_results = provider.search(SearchParams(
query=media_item.title.romaji or media_item.title.english or "Unknown",
translation_type=self.config.stream.translation_type
))
if not search_results or not search_results.results:
return []
# Get the first result (should be the best match)
anime_result = search_results.results[0]
# Get anime details
anime = provider.get(AnimeParams(id=anime_result.id))
if not anime or not anime.episodes:
return []
# Get episodes for the configured translation type
episodes = getattr(anime.episodes, self.config.stream.translation_type, [])
return sorted(episodes, key=lambda x: float(x) if x.replace('.', '').isdigit() else 0)
except Exception as e:
logger.error(f"Error getting available episodes: {e}")
return []
def _add_episode_to_queue(self, media_item: "MediaItem", episode: str):
"""Add an episode to the download queue."""
try:
job_id = str(uuid.uuid4())
job = DownloadJob(
id=job_id,
anime_title=media_item.title.romaji or media_item.title.english or "Unknown",
episode=episode,
media_id=media_item.id,
quality=self.config.stream.quality,
translation_type=self.config.stream.translation_type,
priority=1, # High priority for auto-added episodes
auto_added=True
)
success = self.queue_manager.add_job(job)
if success:
logger.info(f"Auto-queued: {job.anime_title} Episode {episode}")
except Exception as e:
logger.error(f"Error adding episode to queue: {e}")
def _check_notifications(self):
"""Check for AniList notifications (similar to existing notifier)."""
try:
# This is similar to the existing notifier functionality
# We can reuse the notification logic here if needed
pass
except Exception as e:
logger.error(f"Error checking notifications: {e}")
def _process_download_queue(self):
"""Process pending downloads in the queue."""
try:
# Get currently active downloads
active_jobs = self.queue_manager.get_active_jobs()
available_slots = max(0, self.max_concurrent_downloads - len(active_jobs))
if available_slots == 0:
return # All slots busy
# Get pending jobs
pending_jobs = self.queue_manager.get_pending_jobs(limit=available_slots)
if not pending_jobs:
return # No pending jobs
logger.info(f"Processing {len(pending_jobs)} download jobs")
# Process jobs concurrently
with ThreadPoolExecutor(max_workers=available_slots) as executor:
futures = {
executor.submit(self._download_episode, job): job
for job in pending_jobs
}
for future in as_completed(futures):
job = futures[future]
try:
success = future.result()
if success:
logger.info(f"Successfully downloaded: {job.anime_title} Episode {job.episode}")
else:
logger.error(f"Failed to download: {job.anime_title} Episode {job.episode}")
except Exception as e:
logger.error(f"Error downloading {job.anime_title} Episode {job.episode}: {e}")
self.queue_manager.update_job_status(job.id, DownloadStatus.FAILED, str(e))
except Exception as e:
logger.error(f"Error processing download queue: {e}")
def _download_episode(self, job: DownloadJob) -> bool:
"""Download a specific episode."""
try:
logger.info(f"Starting download: {job.anime_title} Episode {job.episode}")
# Update job status to downloading
self.queue_manager.update_job_status(job.id, DownloadStatus.DOWNLOADING)
# Import download functionality
from fastanime.libs.providers.anime.provider import create_provider
from fastanime.libs.providers.anime.params import AnimeParams, SearchParams, EpisodeStreamsParams
from fastanime.libs.selectors.selector import create_selector
from fastanime.libs.players.player import create_player
from fastanime.core.downloader.downloader import create_downloader
from httpx import Client
# Create required components
client = Client()
provider = create_provider(self.config.general.provider)
selector = create_selector(self.config)
player = create_player(self.config)
downloader = create_downloader(self.config.downloads)
# Search for anime
translation_type = cast(Literal["sub", "dub"], job.translation_type if job.translation_type in ["sub", "dub"] else "sub")
search_results = provider.search(SearchParams(
query=job.anime_title,
translation_type=translation_type
))
if not search_results or not search_results.results:
raise Exception("No search results found")
# Get anime details
anime_result = search_results.results[0]
anime = provider.get(AnimeParams(id=anime_result.id))
if not anime:
raise Exception("Failed to get anime details")
# Get episode streams
# Ensure translation_type is valid Literal type
valid_translation = cast(Literal["sub", "dub"],
job.translation_type if job.translation_type in ["sub", "dub"] else "sub")
streams = provider.episode_streams(EpisodeStreamsParams(
anime_id=anime.id,
episode=job.episode,
translation_type=valid_translation
))
if not streams:
raise Exception("No streams found")
# Get the first available server
server = next(streams, None)
if not server:
raise Exception("No server available")
# Download using the first available link
if server.links:
link = server.links[0]
logger.info(f"Starting download: {link.link} for {job.anime_title} Episode {job.episode}")
# Import downloader
from fastanime.core.downloader import create_downloader, DownloadParams
# Create downloader with config
downloader = create_downloader(self.config.downloads)
# Prepare download parameters
download_params = DownloadParams(
url=link.link,
anime_title=job.anime_title,
episode_title=f"Episode {job.episode}",
silent=True, # Run silently in background
headers=server.headers, # Use server headers
subtitles=[sub.url for sub in server.subtitles], # Extract subtitle URLs
merge=False, # Default to false
clean=False, # Default to false
prompt=False, # No prompts in background service
force_ffmpeg=False, # Default to false
hls_use_mpegts=False, # Default to false
hls_use_h264=False # Default to false
)
# Download the episode
try:
downloader.download(download_params)
logger.info(f"Successfully downloaded: {job.anime_title} Episode {job.episode}")
self.queue_manager.update_job_status(job.id, DownloadStatus.COMPLETED)
return True
except Exception as download_error:
error_msg = f"Download failed: {str(download_error)}"
raise Exception(error_msg)
else:
raise Exception("No download links available")
except Exception as e:
logger.error(f"Download failed for {job.anime_title} Episode {job.episode}: {e}")
# Handle retry logic
job.retry_count += 1
if job.retry_count < self.queue_manager.queue.auto_retry_count:
# Reset to pending for retry
self.queue_manager.update_job_status(job.id, DownloadStatus.PENDING, f"Retry {job.retry_count}: {str(e)}")
else:
# Mark as failed after max retries
self.queue_manager.update_job_status(job.id, DownloadStatus.FAILED, f"Max retries exceeded: {str(e)}")
return False
@click.command(
help="Run background service for automated downloads and episode monitoring",
short_help="Background download service",
epilog="""
\b
\b\bExamples:
# Start the service
fastanime service
# Run in the background (Linux/macOS)
nohup fastanime service > /dev/null 2>&1 &
# Run with logging
fastanime --log service
# Run with file logging
fastanime --log-to-file service
""",
)
@click.option(
"--watchlist-interval",
type=int,
help="Minutes between watchlist checks (default from config)"
)
@click.option(
"--queue-interval",
type=int,
help="Minutes between queue processing (default from config)"
)
@click.option(
"--max-concurrent",
type=int,
help="Maximum concurrent downloads (default from config)"
)
@click.pass_obj
def service(config: "AppConfig", watchlist_interval: Optional[int], queue_interval: Optional[int], max_concurrent: Optional[int]):
"""
Run the FastAnime background service for automated downloads.
The service will:
- Monitor your AniList watching list for new episodes
- Automatically queue new episodes for download
- Process the download queue
- Provide notifications for new episodes
"""
try:
# Update configuration with command line options if provided
service_instance = DownloadService(config)
if watchlist_interval is not None:
service_instance.watchlist_check_interval = watchlist_interval * 60
if queue_interval is not None:
service_instance.queue_process_interval = queue_interval * 60
if max_concurrent is not None:
service_instance.max_concurrent_downloads = max_concurrent
# Start the service
service_instance.start()
except KeyboardInterrupt:
pass
except Exception as e:
console = Console()
console.print(f"[red]Service error: {e}[/red]")
logger.error(f"Service error: {e}")
sys.exit(1)

View File

@@ -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:

View File

@@ -0,0 +1,821 @@
"""
AniList Watch List Operations Menu
Implements Step 8: Remote Watch List Operations
Provides comprehensive AniList list management including:
- Viewing user lists (Watching, Completed, Planning, etc.)
- Interactive list selection and navigation
- Adding/removing anime from lists
- List statistics and overview
"""
import logging
from typing import Dict, List, Optional, Tuple
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from ....libs.api.params import UpdateListEntryParams, UserListParams
from ....libs.api.types import MediaItem, MediaSearchResult, UserListStatusType
from ...utils.feedback import create_feedback_manager, execute_with_feedback
from ..session import Context, session
from ..state import ControlFlow, MediaApiState, State
logger = logging.getLogger(__name__)
@session.menu
def anilist_lists(ctx: Context, state: State) -> State | ControlFlow:
"""
Main AniList lists management menu.
Shows all user lists with statistics and navigation options.
"""
icons = ctx.config.general.icons
feedback = create_feedback_manager(icons)
console = Console()
console.clear()
# Check authentication
if not ctx.media_api.user_profile:
feedback.error(
"Authentication Required",
"You must be logged in to access your AniList lists. Please authenticate first."
)
feedback.pause_for_user("Press Enter to continue")
return State(menu_name="AUTH")
# Display user profile and lists overview
_display_lists_overview(console, ctx, icons)
# Menu options
options = [
f"{'📺 ' if icons else ''}Currently Watching",
f"{'📋 ' if icons else ''}Planning to Watch",
f"{'' if icons else ''}Completed",
f"{'⏸️ ' if icons else ''}Paused",
f"{'🚮 ' if icons else ''}Dropped",
f"{'🔁 ' if icons else ''}Rewatching",
f"{'📊 ' if icons else ''}View All Lists Statistics",
f"{'🔍 ' if icons else ''}Search Across All Lists",
f"{' ' if icons else ''}Add Anime to List",
f"{'↩️ ' if icons else ''}Back to Main Menu",
]
choice = ctx.selector.choose(
prompt="Select List Action",
choices=options,
header=f"AniList Lists - {ctx.media_api.user_profile.name}",
)
if not choice:
return ControlFlow.BACK
# Handle menu choices
if "Currently Watching" in choice:
return _navigate_to_list(ctx, "CURRENT")
elif "Planning to Watch" in choice:
return _navigate_to_list(ctx, "PLANNING")
elif "Completed" in choice:
return _navigate_to_list(ctx, "COMPLETED")
elif "Paused" in choice:
return _navigate_to_list(ctx, "PAUSED")
elif "Dropped" in choice:
return _navigate_to_list(ctx, "DROPPED")
elif "Rewatching" in choice:
return _navigate_to_list(ctx, "REPEATING")
elif "View All Lists Statistics" in choice:
return _show_all_lists_stats(ctx, feedback, icons)
elif "Search Across All Lists" in choice:
return _search_all_lists(ctx, feedback, icons)
elif "Add Anime to List" in choice:
return _add_anime_to_list(ctx, feedback, icons)
else: # Back to Main Menu
return ControlFlow.BACK
@session.menu
def anilist_list_view(ctx: Context, state: State) -> State | ControlFlow:
"""
View and manage a specific AniList list (e.g., Watching, Completed).
"""
icons = ctx.config.general.icons
feedback = create_feedback_manager(icons)
console = Console()
console.clear()
# Get list status from state data
list_status = state.data.get("list_status") if state.data else "CURRENT"
page = state.data.get("page", 1) if state.data else 1
# Fetch list data
def fetch_list():
return ctx.media_api.fetch_user_list(
UserListParams(status=list_status, page=page, per_page=20)
)
success, result = execute_with_feedback(
fetch_list,
feedback,
f"fetch {_status_to_display_name(list_status)} list",
loading_msg=f"Loading {_status_to_display_name(list_status)} list...",
success_msg=f"Loaded {_status_to_display_name(list_status)} list",
error_msg=f"Failed to load {_status_to_display_name(list_status)} list",
)
if not success or not result:
feedback.pause_for_user("Press Enter to continue")
return ControlFlow.BACK
# Display list contents
_display_list_contents(console, result, list_status, page, icons)
# Menu options
options = [
f"{'👁️ ' if icons else ''}View/Edit Anime Details",
f"{'🔄 ' if icons else ''}Refresh List",
f"{' ' if icons else ''}Add New Anime",
f"{'🗑️ ' if icons else ''}Remove from List",
]
# Add pagination options
if result.page_info.has_next_page:
options.append(f"{'➡️ ' if icons else ''}Next Page")
if page > 1:
options.append(f"{'⬅️ ' if icons else ''}Previous Page")
options.extend([
f"{'📊 ' if icons else ''}List Statistics",
f"{'↩️ ' if icons else ''}Back to Lists Menu",
])
choice = ctx.selector.choose(
prompt="Select Action",
choices=options,
header=f"{_status_to_display_name(list_status)} - Page {page}",
)
if not choice:
return ControlFlow.BACK
# Handle menu choices
if "View/Edit Anime Details" in choice:
return _select_anime_for_details(ctx, result, list_status, page)
elif "Refresh List" in choice:
return ControlFlow.CONTINUE
elif "Add New Anime" in choice:
return _add_anime_to_specific_list(ctx, list_status, feedback, icons)
elif "Remove from List" in choice:
return _remove_anime_from_list(ctx, result, list_status, page, feedback, icons)
elif "Next Page" in choice:
return State(
menu_name="ANILIST_LIST_VIEW",
data={"list_status": list_status, "page": page + 1}
)
elif "Previous Page" in choice:
return State(
menu_name="ANILIST_LIST_VIEW",
data={"list_status": list_status, "page": page - 1}
)
elif "List Statistics" in choice:
return _show_list_statistics(ctx, list_status, feedback, icons)
else: # Back to Lists Menu
return State(menu_name="ANILIST_LISTS")
@session.menu
def anilist_anime_details(ctx: Context, state: State) -> State | ControlFlow:
"""
View and edit details for a specific anime in a user's list.
"""
icons = ctx.config.general.icons
feedback = create_feedback_manager(icons)
console = Console()
console.clear()
# Get anime and list info from state
if not state.data:
return ControlFlow.BACK
anime = state.data.get("anime")
list_status = state.data.get("list_status")
return_page = state.data.get("return_page", 1)
from_media_actions = state.data.get("from_media_actions", False)
if not anime:
return ControlFlow.BACK
# Display anime details
_display_anime_list_details(console, anime, icons)
# Menu options
options = [
f"{'✏️ ' if icons else ''}Edit Progress",
f"{'' if icons else ''}Edit Rating",
f"{'📝 ' if icons else ''}Edit Status",
f"{'🎬 ' if icons else ''}Watch/Stream",
f"{'🗑️ ' if icons else ''}Remove from List",
f"{'↩️ ' if icons else ''}Back to List",
]
choice = ctx.selector.choose(
prompt="Select Action",
choices=options,
header=f"{anime.title.english or anime.title.romaji}",
)
if not choice:
# Return to appropriate menu based on how we got here
if from_media_actions:
return ControlFlow.BACK
elif list_status:
return State(
menu_name="ANILIST_LIST_VIEW",
data={"list_status": list_status, "page": return_page}
)
else:
return State(menu_name="ANILIST_LISTS")
# Handle menu choices
if "Edit Progress" in choice:
return _edit_anime_progress(ctx, anime, list_status, return_page, feedback, from_media_actions)
elif "Edit Rating" in choice:
return _edit_anime_rating(ctx, anime, list_status, return_page, feedback, from_media_actions)
elif "Edit Status" in choice:
return _edit_anime_status(ctx, anime, list_status, return_page, feedback, from_media_actions)
elif "Watch/Stream" in choice:
return _stream_anime(ctx, anime)
elif "Remove from List" in choice:
return _confirm_remove_anime(ctx, anime, list_status, return_page, feedback, icons, from_media_actions)
else: # Back to List/Media Actions
# Return to appropriate menu based on how we got here
if from_media_actions:
return ControlFlow.BACK
elif list_status:
return State(
menu_name="ANILIST_LIST_VIEW",
data={"list_status": list_status, "page": return_page}
)
else:
return State(menu_name="ANILIST_LISTS")
def _display_lists_overview(console: Console, ctx: Context, icons: bool):
"""Display overview of all user lists with counts."""
user = ctx.media_api.user_profile
# Create overview panel
overview_text = f"[bold cyan]{user.name}[/bold cyan]'s AniList Management\n"
overview_text += f"User ID: {user.id}\n\n"
overview_text += "Manage your anime lists, track progress, and sync with AniList"
panel = Panel(
overview_text,
title=f"{'📚 ' if icons else ''}AniList Lists Overview",
border_style="cyan",
)
console.print(panel)
console.print()
def _display_list_contents(
console: Console,
result: MediaSearchResult,
list_status: str,
page: int,
icons: bool
):
"""Display the contents of a specific list in a table."""
if not result.media:
console.print(f"[yellow]No anime found in {_status_to_display_name(list_status)} list[/yellow]")
return
table = Table(title=f"{_status_to_display_name(list_status)} - Page {page}")
table.add_column("Title", style="cyan", no_wrap=False, width=40)
table.add_column("Episodes", justify="center", width=10)
table.add_column("Progress", justify="center", width=10)
table.add_column("Score", justify="center", width=8)
table.add_column("Status", justify="center", width=12)
for i, anime in enumerate(result.media, 1):
title = anime.title.english or anime.title.romaji or "Unknown Title"
episodes = str(anime.episodes or "?")
# Get list entry details if available
progress = "?"
score = "?"
status = _status_to_display_name(list_status)
# Note: In a real implementation, you'd get these from the MediaList entry
# For now, we'll show placeholders
if hasattr(anime, 'media_list_entry') and anime.media_list_entry:
progress = str(anime.media_list_entry.progress or 0)
score = str(anime.media_list_entry.score or "-")
table.add_row(
f"{i}. {title}",
episodes,
progress,
score,
status
)
console.print(table)
console.print(f"\nShowing {len(result.media)} anime from {_status_to_display_name(list_status)} list")
# Show pagination info
if result.page_info.has_next_page:
console.print(f"[dim]More results available on next page[/dim]")
def _display_anime_list_details(console: Console, anime: MediaItem, icons: bool):
"""Display detailed information about an anime in the user's list."""
title = anime.title.english or anime.title.romaji or "Unknown Title"
details_text = f"[bold]{title}[/bold]\n\n"
details_text += f"Episodes: {anime.episodes or 'Unknown'}\n"
details_text += f"Status: {anime.status or 'Unknown'}\n"
details_text += f"Genres: {', '.join(anime.genres) if anime.genres else 'Unknown'}\n"
if anime.description:
# Truncate description for display
desc = anime.description[:300] + "..." if len(anime.description) > 300 else anime.description
details_text += f"\nDescription:\n{desc}"
# Add list-specific information if available
if hasattr(anime, 'media_list_entry') and anime.media_list_entry:
entry = anime.media_list_entry
details_text += f"\n\n[bold cyan]Your List Info:[/bold cyan]\n"
details_text += f"Progress: {entry.progress or 0} episodes\n"
details_text += f"Score: {entry.score or 'Not rated'}\n"
details_text += f"Status: {_status_to_display_name(entry.status) if hasattr(entry, 'status') else 'Unknown'}\n"
panel = Panel(
details_text,
title=f"{'📺 ' if icons else ''}Anime Details",
border_style="blue",
)
console.print(panel)
def _navigate_to_list(ctx: Context, list_status: UserListStatusType) -> State:
"""Navigate to a specific list view."""
return State(
menu_name="ANILIST_LIST_VIEW",
data={"list_status": list_status, "page": 1}
)
def _select_anime_for_details(
ctx: Context,
result: MediaSearchResult,
list_status: str,
page: int
) -> State | ControlFlow:
"""Let user select an anime from the list to view/edit details."""
if not result.media:
return ControlFlow.CONTINUE
# Create choices from anime list
choices = []
for i, anime in enumerate(result.media, 1):
title = anime.title.english or anime.title.romaji or "Unknown Title"
choices.append(f"{i}. {title}")
choice = ctx.selector.choose(
prompt="Select anime to view/edit",
choices=choices,
header="Select Anime",
)
if not choice:
return ControlFlow.CONTINUE
# Extract index and get selected anime
try:
index = int(choice.split(".")[0]) - 1
selected_anime = result.media[index]
return State(
menu_name="ANILIST_ANIME_DETAILS",
data={
"anime": selected_anime,
"list_status": list_status,
"return_page": page
}
)
except (ValueError, IndexError):
return ControlFlow.CONTINUE
def _edit_anime_progress(
ctx: Context,
anime: MediaItem,
list_status: str,
return_page: int,
feedback,
from_media_actions: bool = False
) -> State | ControlFlow:
"""Edit the progress (episodes watched) for an anime."""
current_progress = 0
if hasattr(anime, 'media_list_entry') and anime.media_list_entry:
current_progress = anime.media_list_entry.progress or 0
max_episodes = anime.episodes or 999
try:
new_progress = click.prompt(
f"Enter new progress (0-{max_episodes}, current: {current_progress})",
type=int,
default=current_progress
)
if new_progress < 0 or new_progress > max_episodes:
feedback.error("Invalid progress", f"Progress must be between 0 and {max_episodes}")
feedback.pause_for_user("Press Enter to continue")
return ControlFlow.CONTINUE
# Update via API
def update_progress():
return ctx.media_api.update_list_entry(
UpdateListEntryParams(media_id=anime.id, progress=new_progress)
)
success, _ = execute_with_feedback(
update_progress,
feedback,
"update progress",
loading_msg="Updating progress...",
success_msg=f"Progress updated to {new_progress} episodes",
error_msg="Failed to update progress",
)
if success:
feedback.pause_for_user("Press Enter to continue")
except click.Abort:
pass
# Return to appropriate menu based on how we got here
if from_media_actions:
return ControlFlow.BACK
elif list_status:
return State(
menu_name="ANILIST_LIST_VIEW",
data={"list_status": list_status, "page": return_page}
)
else:
return State(menu_name="ANILIST_LISTS")
def _edit_anime_rating(
ctx: Context,
anime: MediaItem,
list_status: str,
return_page: int,
feedback,
from_media_actions: bool = False
) -> State | ControlFlow:
"""Edit the rating/score for an anime."""
current_score = 0.0
if hasattr(anime, 'media_list_entry') and anime.media_list_entry:
current_score = anime.media_list_entry.score or 0.0
try:
new_score = click.prompt(
f"Enter new rating (0.0-10.0, current: {current_score})",
type=float,
default=current_score
)
if new_score < 0.0 or new_score > 10.0:
feedback.error("Invalid rating", "Rating must be between 0.0 and 10.0")
feedback.pause_for_user("Press Enter to continue")
return ControlFlow.CONTINUE
# Update via API
def update_score():
return ctx.media_api.update_list_entry(
UpdateListEntryParams(media_id=anime.id, score=new_score)
)
success, _ = execute_with_feedback(
update_score,
feedback,
"update rating",
loading_msg="Updating rating...",
success_msg=f"Rating updated to {new_score}/10",
error_msg="Failed to update rating",
)
if success:
feedback.pause_for_user("Press Enter to continue")
except click.Abort:
pass
# Return to appropriate menu based on how we got here
if from_media_actions:
return ControlFlow.BACK
elif list_status:
return State(
menu_name="ANILIST_LIST_VIEW",
data={"list_status": list_status, "page": return_page}
)
else:
return State(menu_name="ANILIST_LISTS")
def _edit_anime_status(
ctx: Context,
anime: MediaItem,
list_status: str,
return_page: int,
feedback,
from_media_actions: bool = False
) -> State | ControlFlow:
"""Edit the list status for an anime."""
status_options = [
"CURRENT (Currently Watching)",
"PLANNING (Plan to Watch)",
"COMPLETED (Completed)",
"PAUSED (Paused)",
"DROPPED (Dropped)",
"REPEATING (Rewatching)",
]
choice = ctx.selector.choose(
prompt="Select new status",
choices=status_options,
header="Change List Status",
)
if not choice:
return ControlFlow.CONTINUE
new_status = choice.split(" ")[0]
# Update via API
def update_status():
return ctx.media_api.update_list_entry(
UpdateListEntryParams(media_id=anime.id, status=new_status)
)
success, _ = execute_with_feedback(
update_status,
feedback,
"update status",
loading_msg="Updating status...",
success_msg=f"Status updated to {_status_to_display_name(new_status)}",
error_msg="Failed to update status",
)
if success:
feedback.pause_for_user("Press Enter to continue")
# If status changed, return to main lists menu since the anime
# is no longer in the current list
if new_status != list_status:
if from_media_actions:
return ControlFlow.BACK
else:
return State(menu_name="ANILIST_LISTS")
# Return to appropriate menu based on how we got here
if from_media_actions:
return ControlFlow.BACK
elif list_status:
return State(
menu_name="ANILIST_LIST_VIEW",
data={"list_status": list_status, "page": return_page}
)
else:
return State(menu_name="ANILIST_LISTS")
def _confirm_remove_anime(
ctx: Context,
anime: MediaItem,
list_status: str,
return_page: int,
feedback,
icons: bool,
from_media_actions: bool = False
) -> State | ControlFlow:
"""Confirm and remove an anime from the user's list."""
title = anime.title.english or anime.title.romaji or "Unknown Title"
if not feedback.confirm(
f"Remove '{title}' from your {_status_to_display_name(list_status)} list?",
default=False
):
return ControlFlow.CONTINUE
# Remove via API
def remove_anime():
return ctx.media_api.delete_list_entry(anime.id)
success, _ = execute_with_feedback(
remove_anime,
feedback,
"remove anime",
loading_msg="Removing anime from list...",
success_msg=f"'{title}' removed from list",
error_msg="Failed to remove anime from list",
)
if success:
feedback.pause_for_user("Press Enter to continue")
# Return to appropriate menu based on how we got here
if from_media_actions:
return ControlFlow.BACK
elif list_status:
return State(
menu_name="ANILIST_LIST_VIEW",
data={"list_status": list_status, "page": return_page}
)
else:
return State(menu_name="ANILIST_LISTS")
def _stream_anime(ctx: Context, anime: MediaItem) -> State:
"""Navigate to streaming interface for the selected anime."""
return State(
menu_name="RESULTS",
data=MediaApiState(
results=[anime], # Pass as single-item list
query=anime.title.english or anime.title.romaji or "Unknown",
page=1,
api_params=None,
user_list_params=None,
)
)
def _show_all_lists_stats(ctx: Context, feedback, icons: bool) -> State | ControlFlow:
"""Show comprehensive statistics across all user lists."""
console = Console()
console.clear()
# This would require fetching data from all lists
# For now, show a placeholder implementation
stats_text = "[bold cyan]📊 Your AniList Statistics[/bold cyan]\n\n"
stats_text += "[dim]Loading comprehensive list statistics...[/dim]\n"
stats_text += "[dim]This feature requires fetching data from all lists.[/dim]"
panel = Panel(
stats_text,
title=f"{'📊 ' if icons else ''}AniList Statistics",
border_style="green",
)
console.print(panel)
feedback.pause_for_user("Press Enter to continue")
return ControlFlow.CONTINUE
def _search_all_lists(ctx: Context, feedback, icons: bool) -> State | ControlFlow:
"""Search across all user lists."""
try:
query = click.prompt("Enter search query", type=str)
if not query.strip():
return ControlFlow.CONTINUE
# This would require implementing search across all lists
feedback.info("Search functionality", "Cross-list search will be implemented in a future update")
feedback.pause_for_user("Press Enter to continue")
except click.Abort:
pass
return ControlFlow.CONTINUE
def _add_anime_to_list(ctx: Context, feedback, icons: bool) -> State | ControlFlow:
"""Add a new anime to one of the user's lists."""
try:
query = click.prompt("Enter anime name to search", type=str)
if not query.strip():
return ControlFlow.CONTINUE
# Navigate to search with intent to add to list
return State(
menu_name="PROVIDER_SEARCH",
data={"query": query, "add_to_list_mode": True}
)
except click.Abort:
pass
return ControlFlow.CONTINUE
def _add_anime_to_specific_list(
ctx: Context,
list_status: str,
feedback,
icons: bool
) -> State | ControlFlow:
"""Add a new anime to a specific list."""
try:
query = click.prompt("Enter anime name to search", type=str)
if not query.strip():
return ControlFlow.CONTINUE
# Navigate to search with specific list target
return State(
menu_name="PROVIDER_SEARCH",
data={"query": query, "target_list": list_status}
)
except click.Abort:
pass
return ControlFlow.CONTINUE
def _remove_anime_from_list(
ctx: Context,
result: MediaSearchResult,
list_status: str,
page: int,
feedback,
icons: bool
) -> State | ControlFlow:
"""Select and remove an anime from the current list."""
if not result.media:
feedback.info("Empty list", "No anime to remove from this list")
feedback.pause_for_user("Press Enter to continue")
return ControlFlow.CONTINUE
# Create choices from anime list
choices = []
for i, anime in enumerate(result.media, 1):
title = anime.title.english or anime.title.romaji or "Unknown Title"
choices.append(f"{i}. {title}")
choice = ctx.selector.choose(
prompt="Select anime to remove",
choices=choices,
header="Remove Anime from List",
)
if not choice:
return ControlFlow.CONTINUE
# Extract index and get selected anime
try:
index = int(choice.split(".")[0]) - 1
selected_anime = result.media[index]
return _confirm_remove_anime(
ctx, selected_anime, list_status, page, feedback, icons
)
except (ValueError, IndexError):
return ControlFlow.CONTINUE
def _show_list_statistics(
ctx: Context,
list_status: str,
feedback,
icons: bool
) -> State | ControlFlow:
"""Show statistics for a specific list."""
console = Console()
console.clear()
list_name = _status_to_display_name(list_status)
stats_text = f"[bold cyan]📊 {list_name} Statistics[/bold cyan]\n\n"
stats_text += "[dim]Loading list statistics...[/dim]\n"
stats_text += "[dim]This feature requires comprehensive list analysis.[/dim]"
panel = Panel(
stats_text,
title=f"{'📊 ' if icons else ''}{list_name} Stats",
border_style="blue",
)
console.print(panel)
feedback.pause_for_user("Press Enter to continue")
return ControlFlow.CONTINUE
def _status_to_display_name(status: str) -> str:
"""Convert API status to human-readable display name."""
status_map = {
"CURRENT": "Currently Watching",
"PLANNING": "Planning to Watch",
"COMPLETED": "Completed",
"PAUSED": "Paused",
"DROPPED": "Dropped",
"REPEATING": "Rewatching",
}
return status_map.get(status, status)
# Import click for user input
import click

View File

@@ -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":

View File

@@ -36,7 +36,8 @@ def media_actions(ctx: Context, state: State) -> State | ControlFlow:
f"{'📼 ' if icons else ''}Watch Trailer": _watch_trailer(ctx, state),
f"{' ' if icons else ''}Add/Update List": _add_to_list(ctx, state),
f"{'' if icons else ''}Score Anime": _score_anime(ctx, state),
f"{'📚 ' if icons else ''}Add to Local History": _add_to_local_history(ctx, state),
f"{'<EFBFBD> ' if icons else ''}Manage in Lists": _manage_in_lists(ctx, state),
f"{'<EFBFBD>📚 ' if icons else ''}Add to Local History": _add_to_local_history(ctx, state),
f"{' ' if icons else ''}View Info": _view_info(ctx, state),
f"{'🔙 ' if icons else ''}Back to Results": lambda: ControlFlow.BACK,
}
@@ -287,3 +288,30 @@ def _add_to_local_history(ctx: Context, state: State) -> MenuAction:
return ControlFlow.CONTINUE
return action
def _manage_in_lists(ctx: Context, state: State) -> MenuAction:
def action():
feedback = create_feedback_manager(ctx.config.general.icons)
anime = state.media_api.anime
if not anime:
return ControlFlow.CONTINUE
# Check authentication before proceeding
if not check_authentication_required(
ctx.media_api, feedback, "manage anime in your lists"
):
return ControlFlow.CONTINUE
# Navigate to AniList anime details with this specific anime
return State(
menu_name="ANILIST_ANIME_DETAILS",
data={
"anime": anime,
"list_status": "CURRENT", # Default status, will be updated when loaded
"return_page": 1,
"from_media_actions": True # Flag to return here instead of lists
}
)
return action

View File

@@ -0,0 +1,208 @@
"""
Download queue management system for FastAnime.
Handles queuing, processing, and tracking of download jobs.
"""
import json
import logging
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
from ...core.constants import APP_DATA_DIR
logger = logging.getLogger(__name__)
class DownloadStatus(str, Enum):
"""Status of a download job."""
PENDING = "pending"
DOWNLOADING = "downloading"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class DownloadJob(BaseModel):
"""Represents a single download job in the queue."""
id: str = Field(description="Unique identifier for the job")
anime_title: str = Field(description="Title of the anime")
episode: str = Field(description="Episode number or identifier")
media_id: Optional[int] = Field(default=None, description="AniList media ID if available")
provider_id: Optional[str] = Field(default=None, description="Provider-specific anime ID")
quality: str = Field(default="1080", description="Preferred quality")
translation_type: str = Field(default="sub", description="sub or dub")
priority: int = Field(default=5, description="Priority level (1-10, lower is higher priority)")
status: DownloadStatus = Field(default=DownloadStatus.PENDING)
created_at: datetime = Field(default_factory=datetime.now)
started_at: Optional[datetime] = Field(default=None)
completed_at: Optional[datetime] = Field(default=None)
error_message: Optional[str] = Field(default=None)
retry_count: int = Field(default=0)
auto_added: bool = Field(default=False, description="Whether this was auto-added by the service")
class DownloadQueue(BaseModel):
"""Container for all download jobs."""
jobs: Dict[str, DownloadJob] = Field(default_factory=dict)
max_concurrent: int = Field(default=3, description="Maximum concurrent downloads")
auto_retry_count: int = Field(default=3, description="Maximum retry attempts")
class QueueManager:
"""Manages the download queue operations."""
def __init__(self, queue_file_path: Optional[Path] = None):
self.queue_file_path = queue_file_path or APP_DATA_DIR / "download_queue.json"
self._queue: Optional[DownloadQueue] = None
def _load_queue(self) -> DownloadQueue:
"""Load queue from file."""
if self.queue_file_path.exists():
try:
with open(self.queue_file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return DownloadQueue.model_validate(data)
except (json.JSONDecodeError, ValueError) as e:
logger.error(f"Failed to load queue from {self.queue_file_path}: {e}")
return DownloadQueue()
return DownloadQueue()
def _save_queue(self, queue: DownloadQueue) -> bool:
"""Save queue to file."""
try:
with open(self.queue_file_path, 'w', encoding='utf-8') as f:
json.dump(queue.model_dump(), f, indent=2, default=str)
return True
except Exception as e:
logger.error(f"Failed to save queue to {self.queue_file_path}: {e}")
return False
@property
def queue(self) -> DownloadQueue:
"""Get the current queue, loading it if necessary."""
if self._queue is None:
self._queue = self._load_queue()
return self._queue
def add_job(self, job: DownloadJob) -> bool:
"""Add a new download job to the queue."""
try:
self.queue.jobs[job.id] = job
success = self._save_queue(self.queue)
if success:
logger.info(f"Added download job: {job.anime_title} Episode {job.episode}")
return success
except Exception as e:
logger.error(f"Failed to add job to queue: {e}")
return False
def remove_job(self, job_id: str) -> bool:
"""Remove a job from the queue."""
try:
if job_id in self.queue.jobs:
job = self.queue.jobs.pop(job_id)
success = self._save_queue(self.queue)
if success:
logger.info(f"Removed download job: {job.anime_title} Episode {job.episode}")
return success
return False
except Exception as e:
logger.error(f"Failed to remove job from queue: {e}")
return False
def update_job_status(self, job_id: str, status: DownloadStatus, error_message: Optional[str] = None) -> bool:
"""Update the status of a job."""
try:
if job_id in self.queue.jobs:
job = self.queue.jobs[job_id]
job.status = status
if error_message:
job.error_message = error_message
if status == DownloadStatus.DOWNLOADING:
job.started_at = datetime.now()
elif status in (DownloadStatus.COMPLETED, DownloadStatus.FAILED, DownloadStatus.CANCELLED):
job.completed_at = datetime.now()
return self._save_queue(self.queue)
return False
except Exception as e:
logger.error(f"Failed to update job status: {e}")
return False
def get_pending_jobs(self, limit: Optional[int] = None) -> List[DownloadJob]:
"""Get pending jobs sorted by priority and creation time."""
pending = [
job for job in self.queue.jobs.values()
if job.status == DownloadStatus.PENDING
]
# Sort by priority (lower number = higher priority), then by creation time
pending.sort(key=lambda x: (x.priority, x.created_at))
if limit:
return pending[:limit]
return pending
def get_active_jobs(self) -> List[DownloadJob]:
"""Get currently downloading jobs."""
return [
job for job in self.queue.jobs.values()
if job.status == DownloadStatus.DOWNLOADING
]
def get_job_by_id(self, job_id: str) -> Optional[DownloadJob]:
"""Get a specific job by ID."""
return self.queue.jobs.get(job_id)
def get_all_jobs(self) -> List[DownloadJob]:
"""Get all jobs."""
return list(self.queue.jobs.values())
def clean_completed_jobs(self, max_age_days: int = 7) -> int:
"""Remove completed jobs older than specified days."""
cutoff_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
cutoff_date = cutoff_date.replace(day=cutoff_date.day - max_age_days)
jobs_to_remove = []
for job_id, job in self.queue.jobs.items():
if (job.status in (DownloadStatus.COMPLETED, DownloadStatus.FAILED, DownloadStatus.CANCELLED)
and job.completed_at and job.completed_at < cutoff_date):
jobs_to_remove.append(job_id)
for job_id in jobs_to_remove:
del self.queue.jobs[job_id]
if jobs_to_remove:
self._save_queue(self.queue)
logger.info(f"Cleaned {len(jobs_to_remove)} old completed jobs")
return len(jobs_to_remove)
def get_queue_stats(self) -> Dict[str, int]:
"""Get statistics about the queue."""
stats = {
"total": len(self.queue.jobs),
"pending": 0,
"downloading": 0,
"completed": 0,
"failed": 0,
"cancelled": 0
}
for job in self.queue.jobs.values():
if job.status == DownloadStatus.PENDING:
stats["pending"] += 1
elif job.status == DownloadStatus.DOWNLOADING:
stats["downloading"] += 1
elif job.status == DownloadStatus.COMPLETED:
stats["completed"] += 1
elif job.status == DownloadStatus.FAILED:
stats["failed"] += 1
elif job.status == DownloadStatus.CANCELLED:
stats["cancelled"] += 1
return stats

View File

@@ -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.",
)