feat: new config logic

This commit is contained in:
Benexl
2025-07-05 03:04:48 +03:00
parent d106bf7c5d
commit 759889acd4
19 changed files with 1290 additions and 87 deletions

View File

@@ -0,0 +1,12 @@
--color=fg:#d0d0d0,fg+:#d0d0d0,bg:#121212,bg+:#262626
--color=hl:#5f87af,hl+:#5fd7ff,info:#afaf87,marker:#87ff00
--color=prompt:#d7005f,spinner:#af5fff,pointer:#af5fff,header:#87afaf
--color=border:#262626,label:#aeaeae,query:#d9d9d9
--border=rounded
--border-label=''
--preview-window=border-rounded
--prompt='>'
--marker='>'
--pointer='◆'
--separator='─'
--scrollbar='│'

View File

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

View File

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 197 KiB

View File

@@ -0,0 +1,4 @@
from .loader import ConfigLoader
from .model import AppConfig
__all__ = ["ConfigLoader", "AppConfig"]

View File

@@ -0,0 +1,146 @@
import configparser
import textwrap
from pathlib import Path
import click
from pydantic import ValidationError
from ..constants import USER_CONFIG_PATH
from .model import AppConfig
from ...core.exceptions import ConfigError
from ..constants import ASCII_ART
# The header for the config file.
config_asci = "\n".join([f"# {line}" for line in ASCII_ART.split()])
CONFIG_HEADER = f"""
# ==============================================================================
#
{config_asci}
#
# ==============================================================================
# This file was auto-generated from the application's configuration model.
# You can modify these values to customize the behavior of FastAnime.
# For path-based options, you can use '~' for your home directory.
""".lstrip()
class ConfigLoader:
"""
Handles loading the application configuration from an .ini file.
It ensures a default configuration exists, reads the .ini file,
and uses Pydantic to parse and validate the data into a type-safe
AppConfig object.
"""
def __init__(self, config_path: Path = USER_CONFIG_PATH):
"""
Initializes the loader with the path to the configuration file.
Args:
config_path: The path to the user's config.ini file.
"""
self.config_path = config_path
self.parser = configparser.ConfigParser(
interpolation=None,
# Allow boolean values without a corresponding value (e.g., `enabled` vs `enabled = true`)
allow_no_value=True,
# Behave like a dictionary, preserving case sensitivity of keys
dict_type=dict,
)
def _create_default_if_not_exists(self) -> None:
"""
Creates a default config file from the config model if it doesn't exist.
This is the only time we write to the user's config directory.
"""
if not self.config_path.exists():
default_config = AppConfig.model_validate({})
model_schema = AppConfig.model_json_schema()
config_ini_content = [CONFIG_HEADER]
for section_name, section_model in default_config:
section_class_name = model_schema["properties"][section_name][
"$ref"
].split("/")[-1]
section_comment = model_schema["$defs"][section_class_name][
"description"
]
config_ini_content.append(f"\n#\n# {section_comment}\n#")
config_ini_content.append(f"[{section_name}]")
for field_name, field_value in section_model:
description = model_schema["$defs"][section_class_name][
"properties"
][field_name].get("description", "")
if description:
# Wrap long comments for better readability in the .ini file
wrapped_comment = textwrap.fill(
description,
width=78,
initial_indent="# ",
subsequent_indent="# ",
)
config_ini_content.append(f"\n{wrapped_comment}")
if isinstance(field_value, bool):
value_str = str(field_value).lower()
elif isinstance(field_value, Path):
value_str = str(field_value)
elif field_value is None:
value_str = ""
else:
value_str = str(field_value)
config_ini_content.append(f"{field_name} = {value_str}")
try:
final_output = "\n".join(config_ini_content)
self.config_path.parent.mkdir(parents=True, exist_ok=True)
self.config_path.write_text(final_output, encoding="utf-8")
click.echo(f"Created default configuration file at: {self.config_path}")
except Exception as e:
raise ConfigError(
f"Could not create default configuration file at {str(self.config_path)}. Please check permissions. Error: {e}",
)
def load(self) -> AppConfig:
"""
Loads the configuration and returns a populated, validated AppConfig object.
Returns:
An instance of AppConfig with values from the user's .ini file.
Raises:
click.ClickException: If the configuration file contains validation errors.
"""
self._create_default_if_not_exists()
try:
self.parser.read(self.config_path, encoding="utf-8")
except configparser.Error as e:
raise ConfigError(
f"Error parsing configuration file '{self.config_path}':\n{e}"
)
# Convert the configparser object into a nested dictionary that mirrors
# the structure of our AppConfig Pydantic model.
config_dict = {
section: dict(self.parser.items(section))
for section in self.parser.sections()
}
try:
app_config = AppConfig.model_validate(config_dict)
return app_config
except ValidationError as e:
error_message = (
f"Configuration error in '{self.config_path}'!\n"
f"Please correct the following issues:\n\n{e}"
)
raise ConfigError(error_message)

View File

@@ -0,0 +1,152 @@
from pathlib import Path
from typing import List, Literal
from pydantic import BaseModel, Field, ValidationError, field_validator, ConfigDict
from ..constants import USER_VIDEOS_DIR, ASCII_ART
from ...core.constants import (
FZF_DEFAULT_OPTS,
ROFI_THEME_MAIN,
ROFI_THEME_INPUT,
ROFI_THEME_CONFIRM,
ROFI_THEME_PREVIEW,
)
from ...libs.anime_provider import SERVERS_AVAILABLE
from ...libs.anilist.constants import SORTS_AVAILABLE
class FzfConfig(BaseModel):
"""Configuration specific to the FZF selector."""
opts: str = Field(
default_factory=lambda: "\n"
+ "\n".join(
[
f"\t{line}"
for line in FZF_DEFAULT_OPTS.read_text(encoding="utf-8").split()
]
),
description="Command-line options to pass to FZF for theming and behavior.",
)
header_color: str = "95,135,175"
preview_header_color: str = "215,0,95"
preview_separator_color: str = "208,208,208"
class RofiConfig(BaseModel):
"""Configuration specific to the Rofi selector."""
theme_main: Path = Path(str(ROFI_THEME_MAIN))
theme_preview: Path = Path(str(ROFI_THEME_PREVIEW))
theme_confirm: Path = Path(str(ROFI_THEME_CONFIRM))
theme_input: Path = Path(str(ROFI_THEME_INPUT))
class MpvConfig(BaseModel):
"""Configuration specific to the MPV player integration."""
args: str = Field(
default="", description="Comma-separated arguments to pass to the MPV player."
)
pre_args: str = Field(
default="",
description="Comma-separated arguments to prepend before the MPV command.",
)
disable_popen: bool = Field(
default=True,
description="Disable using subprocess.Popen for MPV, which can be unstable on some systems.",
)
force_window: str = Field(
default="immediate", description="Value for MPV's --force-window option."
)
use_python_mpv: bool = Field(
default=False,
description="Use the python-mpv library for enhanced player control.",
)
class GeneralConfig(BaseModel):
"""Configuration for general application behavior and integrations."""
provider: Literal["allanime", "animepahe", "hianime", "nyaa", "yugen"] = "allanime"
selector: Literal["default", "fzf", "rofi"] = "default"
auto_select_anime_result: bool = True
# UI/UX Settings
icons: bool = False
preview: Literal["full", "text", "image", "none"] = "none"
image_renderer: Literal["icat", "chafa", "imgcat"] = "chafa"
preferred_language: Literal["english", "romaji"] = "english"
sub_lang: str = "eng"
manga_viewer: Literal["feh", "icat"] = "feh"
# Paths & Files
downloads_dir: Path = USER_VIDEOS_DIR
# Theming & Appearance
header_ascii_art: str = Field(
default="\n" + "\n".join([f"\t{line}" for line in ASCII_ART.split()]),
description="ASCII art for TUI headers.",
)
# Advanced / Developer
check_for_updates: bool = True
cache_requests: bool = True
max_cache_lifetime: str = "03:00:00"
normalize_titles: bool = True
discord: bool = False
class StreamConfig(BaseModel):
"""Configuration specific to video streaming and playback."""
player: Literal["mpv", "vlc"] = "mpv"
quality: Literal["360", "480", "720", "1080"] = "1080"
translation_type: Literal["sub", "dub"] = "sub"
server: str = "top"
# Playback Behavior
auto_next: bool = False
continue_from_watch_history: bool = True
preferred_watch_history: Literal["local", "remote"] = "local"
auto_skip: bool = False
episode_complete_at: int = Field(default=80, ge=0, le=100)
# Technical/Downloader Settings
ytdlp_format: str = "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best"
@field_validator("server")
@classmethod
def validate_server(cls, v: str) -> str:
if v not in SERVERS_AVAILABLE:
raise ValidationError(f"server must be one of {SERVERS_AVAILABLE}")
return v
class AnilistConfig(BaseModel):
"""Configuration for interacting with the AniList API."""
per_page: int = Field(default=15, gt=0, le=50)
sort_by: str = "SEARCH_MATCH"
default_media_list_tracking: Literal["track", "disabled", "prompt"] = "prompt"
force_forward_tracking: bool = True
recent: int = Field(default=50, ge=0)
@field_validator("sort_by")
@classmethod
def validate_sort_by(cls, v: str) -> str:
if v not in SORTS_AVAILABLE:
raise ValidationError(f"sort_by must be one of {SORTS_AVAILABLE}")
return v
class AppConfig(BaseModel):
"""The root configuration model for the FastAnime application."""
general: GeneralConfig = Field(default_factory=GeneralConfig)
stream: StreamConfig = Field(default_factory=StreamConfig)
anilist: AnilistConfig = Field(default_factory=AnilistConfig)
# Nested Tool-Specific Configs
fzf: FzfConfig = Field(default_factory=FzfConfig)
rofi: RofiConfig = Field(default_factory=RofiConfig)
mpv: MpvConfig = Field(default_factory=MpvConfig)

View File

@@ -0,0 +1,50 @@
import os
import sys
from pathlib import Path
from platform import system
import click
from ..core.constants import APP_NAME, ICONS_DIR
ASCII_ART = """
███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗
██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝
█████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░
██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░
██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗
╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝
"""
PLATFORM = system()
USER_NAME = os.environ.get("USERNAME", "Anime Fan")
APP_DATA_DIR = Path(click.get_app_dir(APP_NAME, roaming=False))
if sys.platform == "win32":
APP_CACHE_DIR = APP_DATA_DIR / "cache"
USER_VIDEOS_DIR = Path.home() / "Videos" / APP_NAME
elif sys.platform == "darwin":
APP_CACHE_DIR = Path.home() / "Library" / "Caches" / APP_NAME
USER_VIDEOS_DIR = Path.home() / "Movies" / APP_NAME
else:
xdg_cache_home = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
APP_CACHE_DIR = xdg_cache_home / APP_NAME
xdg_videos_dir = Path(os.environ.get("XDG_VIDEOS_DIR", Path.home() / "Videos"))
USER_VIDEOS_DIR = xdg_videos_dir / APP_NAME
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
APP_CACHE_DIR.mkdir(parents=True, exist_ok=True)
USER_VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
USER_DATA_PATH = APP_DATA_DIR / "user_data.json"
USER_WATCH_HISTORY_PATH = APP_DATA_DIR / "watch_history.json"
USER_CONFIG_PATH = APP_DATA_DIR / "config.ini"
LOG_FILE_PATH = APP_CACHE_DIR / "fastanime.log"
ICON_PATH = ICONS_DIR / ("logo.ico" if PLATFORM == "Windows" else "logo.png")

View File

@@ -1,86 +0,0 @@
import os
import sys
from pathlib import Path
from platform import system
import click
from . import APP_NAME, __version__
PLATFORM = system()
# ---- app deps ----
APP_DIR = os.path.abspath(os.path.dirname(__file__))
ASSETS_DIR = os.path.join(APP_DIR, "assets")
# --- icon stuff ---
if PLATFORM == "Windows":
ICON_PATH = os.path.join(ASSETS_DIR, "logo.ico")
else:
ICON_PATH = os.path.join(ASSETS_DIR, "logo.png")
# PREVIEW_IMAGE = os.path.join(ASSETS_DIR, "preview")
# ----- user configs and data -----
S_PLATFORM = sys.platform
APP_DATA_DIR = click.get_app_dir(APP_NAME, roaming=False)
if S_PLATFORM == "win32":
# app data
# app_data_dir_base = os.getenv("LOCALAPPDATA")
# if not app_data_dir_base:
# raise RuntimeError("Could not determine app data dir please report to devs")
# APP_DATA_DIR = os.path.join(app_data_dir_base, AUTHOR, APP_NAME)
#
# cache dir
APP_CACHE_DIR = os.path.join(APP_DATA_DIR, "cache")
# videos dir
video_dir_base = os.path.join(Path().home(), "Videos")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
elif S_PLATFORM == "darwin":
# app data
# app_data_dir_base = os.path.expanduser("~/Library/Application Support")
# APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME, __version__)
#
# cache dir
cache_dir_base = os.path.expanduser("~/Library/Caches")
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME, __version__)
# videos dir
video_dir_base = os.path.expanduser("~/Movies")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
else:
# # app data
# app_data_dir_base = os.environ.get("XDG_CONFIG_HOME", "")
# if not app_data_dir_base.strip():
# app_data_dir_base = os.path.expanduser("~/.config")
# APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME)
#
# cache dir
cache_dir_base = os.environ.get("XDG_CACHE_HOME", "")
if not cache_dir_base.strip():
cache_dir_base = os.path.expanduser("~/.cache")
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME)
# videos dir
video_dir_base = os.environ.get("XDG_VIDEOS_DIR", "")
if not video_dir_base.strip():
video_dir_base = os.path.expanduser("~/Videos")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
# ensure paths exist
Path(APP_DATA_DIR).mkdir(parents=True, exist_ok=True)
Path(APP_CACHE_DIR).mkdir(parents=True, exist_ok=True)
Path(USER_VIDEOS_DIR).mkdir(parents=True, exist_ok=True)
# useful paths
USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json")
USER_WATCH_HISTORY_PATH = os.path.join(APP_DATA_DIR, "watch_history.json")
USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini")
LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "fastanime.log")
USER_NAME = os.environ.get("USERNAME", "Anime fun")

View File

@@ -0,0 +1,38 @@
from importlib import resources
import os
APP_NAME = os.environ.get("FASTANIME_APPNAME", "fastanime")
try:
pkg = resources.files("fastanime")
ASSETS_DIR = pkg / "assets"
DEFAULTS = ASSETS_DIR / "defaults"
ICONS_DIR = ASSETS_DIR / "icons"
# rofi files
ROFI_THEME_MAIN = DEFAULTS / "rofi" / "main.rasi"
ROFI_THEME_INPUT = DEFAULTS / "rofi" / "input.rasi"
ROFI_THEME_CONFIRM = DEFAULTS / "rofi" / "confirm.rasi"
ROFI_THEME_PREVIEW = DEFAULTS / "rofi" / "preview.rasi"
# fzf
FZF_DEFAULT_OPTS = DEFAULTS / "fzf-opts"
except ModuleNotFoundError:
from pathlib import Path
pkg = Path(__file__).resolve().parent.parent
ASSETS_DIR = pkg / "assets"
DEFAULTS = ASSETS_DIR / "defaults"
ICONS_DIR = ASSETS_DIR / "icons"
# rofi files
ROFI_THEME_MAIN = DEFAULTS / "rofi" / "main.rasi"
ROFI_THEME_INPUT = DEFAULTS / "rofi" / "input.rasi"
ROFI_THEME_CONFIRM = DEFAULTS / "rofi" / "confirm.rasi"
ROFI_THEME_PREVIEW = DEFAULTS / "rofi" / "preview.rasi"
# fzf
FZF_DEFAULT_OPTS = DEFAULTS / "fzf-opts"

View File

@@ -0,0 +1,130 @@
from typing import Optional
class FastAnimeError(Exception):
"""
Base exception for all custom errors raised by the FastAnime library and application.
Catching this exception will catch any error originating from within this project,
distinguishing it from built-in Python errors or third-party library errors.
"""
pass
# ==============================================================================
# Configuration and Initialization Errors
# ==============================================================================
class ConfigError(FastAnimeError):
"""
Represents an error found in the user's configuration file (config.ini).
This is typically raised by the ConfigLoader when validation fails.
"""
pass
class DependencyNotFoundError(FastAnimeError):
"""
A required external command-line tool (e.g., ffmpeg, fzf) was not found.
This indicates a problem with the user's environment setup.
"""
def __init__(self, dependency_name: str, hint: Optional[str] = None):
self.dependency_name = dependency_name
message = (
f"Required dependency '{dependency_name}' not found in your system's PATH."
)
if hint:
message += f"\nHint: {hint}"
super().__init__(message)
# ==============================================================================
# Provider and Network Errors
# ==============================================================================
class ProviderError(FastAnimeError):
"""
Base class for all errors related to an anime provider.
This allows for catching any provider-related issue while still allowing
for more specific error handling of its subclasses.
"""
def __init__(self, provider_name: str, message: str):
self.provider_name = provider_name
super().__init__(f"[{provider_name.capitalize()}] {message}")
class ProviderAPIError(ProviderError):
"""
An error occurred while communicating with the provider's API.
This typically corresponds to network issues, timeouts, or HTTP error
status codes like 4xx (client error) or 5xx (server error).
"""
def __init__(
self, provider_name: str, http_status: Optional[int] = None, details: str = ""
):
self.http_status = http_status
message = "An API communication error occurred."
if http_status:
message += f" (Status: {http_status})"
if details:
message += f" Details: {details}"
super().__init__(provider_name, message)
class ProviderParsingError(ProviderError):
"""
Failed to parse or find expected data in the provider's response.
This often indicates that the source website's HTML structure or API
response schema has changed, and the provider's parser needs to be updated.
"""
pass
# ==============================================================================
# Application Logic and Workflow Errors
# ==============================================================================
class DownloaderError(FastAnimeError):
"""
An error occurred during the file download or post-processing phase.
This can be raised by the YTDLPService for issues like failed downloads
or ffmpeg merging errors.
"""
pass
class InvalidEpisodeRangeError(FastAnimeError, ValueError):
"""
The user-provided episode range string is malformed or invalid.
Inherits from ValueError for semantic compatibility but allows for specific
catching as a FastAnimeError.
"""
pass
class NoStreamsFoundError(ProviderError):
"""
A provider was successfully queried, but no streamable links were returned.
"""
def __init__(self, provider_name: str, anime_title: str, episode: str):
message = f"No streams were found for '{anime_title}' episode {episode}."
super().__init__(provider_name, message)

View File

@@ -0,0 +1,477 @@
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

@@ -9,4 +9,4 @@ anime_sources = {
"nyaa": "api.Nyaa",
"yugen": "api.Yugen",
}
SERVERS_AVAILABLE = [*ALLANIME_SERVERS, *ANIMEPAHE_SERVERS, *HIANIME_SERVERS]
SERVERS_AVAILABLE = ["top", *ALLANIME_SERVERS, *ANIMEPAHE_SERVERS, *HIANIME_SERVERS]

280
tests/test_config_loader.py Normal file
View File

@@ -0,0 +1,280 @@
from pathlib import Path
import pytest
from unittest.mock import patch
from fastanime.cli.config.loader import ConfigLoader
from fastanime.cli.config.model import AppConfig, GeneralConfig
from fastanime.core.exceptions import ConfigError
# ==============================================================================
# Pytest Fixtures
# ==============================================================================
@pytest.fixture
def temp_config_dir(tmp_path: Path) -> Path:
"""Creates a temporary directory for config files for each test."""
config_dir = tmp_path / "config"
config_dir.mkdir()
return config_dir
@pytest.fixture
def valid_config_content() -> str:
"""Provides the content for a valid, complete config.ini file."""
return """
[general]
provider = hianime
selector = fzf
auto_select_anime_result = false
icons = true
preview = text
image_renderer = icat
preferred_language = romaji
sub_lang = jpn
manga_viewer = feh
downloads_dir = ~/MyAnimeDownloads
check_for_updates = false
cache_requests = false
max_cache_lifetime = 01:00:00
normalize_titles = false
discord = true
[stream]
player = vlc
quality = 720
translation_type = dub
server = gogoanime
auto_next = true
continue_from_watch_history = false
preferred_watch_history = remote
auto_skip = true
episode_complete_at = 95
ytdlp_format = best
[anilist]
per_page = 25
sort_by = TRENDING_DESC
default_media_list_tracking = track
force_forward_tracking = false
recent = 10
[fzf]
opts = --reverse --height=80%
header_color = 255,0,0
preview_header_color = 0,255,0
preview_separator_color = 0,0,255
[rofi]
theme_main = /path/to/main.rasi
theme_preview = /path/to/preview.rasi
theme_confirm = /path/to/confirm.rasi
theme_input = /path/to/input.rasi
[mpv]
args = --fullscreen
pre_args =
disable_popen = false
force_window = no
use_python_mpv = true
"""
@pytest.fixture
def partial_config_content() -> str:
"""Provides content for a partial config file to test default value handling."""
return """
[general]
provider = hianime
[stream]
quality = 720
"""
@pytest.fixture
def malformed_ini_content() -> str:
"""Provides content with invalid .ini syntax that configparser will fail on."""
return "[general\nkey = value"
# ==============================================================================
# Test Class for ConfigLoader
# ==============================================================================
class TestConfigLoader:
def test_load_creates_and_loads_default_config(self, temp_config_dir: Path):
"""
GIVEN no config file exists.
WHEN the ConfigLoader loads configuration.
THEN it should create a default config file and load default values.
"""
# ARRANGE
config_path = temp_config_dir / "config.ini"
assert not config_path.exists()
loader = ConfigLoader(config_path=config_path)
# ACT: Mock click.echo to prevent printing during tests
with patch("click.echo"):
config = loader.load()
# ASSERT: File creation and content
assert config_path.exists()
created_content = config_path.read_text(encoding="utf-8")
assert "[general]" in created_content
assert "# Configuration for general application behavior" in created_content
# ASSERT: Loaded object has default values.
# Direct object comparison can be brittle, so we test key attributes.
default_config = AppConfig.model_validate({})
assert config.general.provider == default_config.general.provider
assert config.stream.quality == default_config.stream.quality
assert config.anilist.per_page == default_config.anilist.per_page
# A full comparison might fail due to how Path objects or multi-line strings
# are instantiated vs. read from a file. Testing key values is more robust.
def test_load_from_valid_full_config(
self, temp_config_dir: Path, valid_config_content: str
):
"""
GIVEN a valid and complete config file exists.
WHEN the ConfigLoader loads it.
THEN it should return a correctly parsed AppConfig object with overridden values.
"""
# ARRANGE
config_path = temp_config_dir / "config.ini"
config_path.write_text(valid_config_content)
loader = ConfigLoader(config_path=config_path)
# ACT
config = loader.load()
# ASSERT
assert isinstance(config, AppConfig)
assert config.general.provider == "hianime"
assert config.general.auto_select_anime_result is False
assert config.general.downloads_dir == Path("~/MyAnimeDownloads")
assert config.stream.quality == "720"
assert config.stream.player == "vlc"
assert config.anilist.per_page == 25
assert config.fzf.opts == "--reverse --height=80%"
assert config.mpv.use_python_mpv is True
def test_load_from_partial_config(
self, temp_config_dir: Path, partial_config_content: str
):
"""
GIVEN a partial config file exists.
WHEN the ConfigLoader loads it.
THEN it should load specified values and use defaults for missing ones.
"""
# ARRANGE
config_path = temp_config_dir / "config.ini"
config_path.write_text(partial_config_content)
loader = ConfigLoader(config_path=config_path)
# ACT
config = loader.load()
# ASSERT: Specified values are loaded correctly
assert config.general.provider == "hianime"
assert config.stream.quality == "720"
# ASSERT: Other values fall back to defaults
default_general = GeneralConfig()
assert config.general.selector == default_general.selector
assert config.general.icons is False
assert config.stream.player == "mpv"
assert config.anilist.per_page == 15
@pytest.mark.parametrize(
"value, expected",
[
("true", True),
("false", False),
("yes", True),
("no", False),
("on", True),
("off", False),
("1", True),
("0", False),
],
)
def test_boolean_value_handling(
self, temp_config_dir: Path, value: str, expected: bool
):
"""
GIVEN a config file with various boolean string representations.
WHEN the ConfigLoader loads it.
THEN pydantic should correctly parse them into boolean values.
"""
# ARRANGE
content = f"[general]\nauto_select_anime_result = {value}\n"
config_path = temp_config_dir / "config.ini"
config_path.write_text(content)
loader = ConfigLoader(config_path=config_path)
# ACT
config = loader.load()
# ASSERT
assert config.general.auto_select_anime_result is expected
def test_load_raises_error_for_malformed_ini(
self, temp_config_dir: Path, malformed_ini_content: str
):
"""
GIVEN a config file has invalid .ini syntax that configparser will reject.
WHEN the ConfigLoader loads it.
THEN it should raise a ConfigError.
"""
# ARRANGE
config_path = temp_config_dir / "config.ini"
config_path.write_text(malformed_ini_content)
loader = ConfigLoader(config_path=config_path)
# ACT & ASSERT
with pytest.raises(ConfigError, match="Error parsing configuration file"):
loader.load()
def test_load_raises_error_for_invalid_value(self, temp_config_dir: Path):
"""
GIVEN a config file contains a value that fails model validation.
WHEN the ConfigLoader loads it.
THEN it should raise a ConfigError with a helpful message.
"""
# ARRANGE
invalid_content = "[stream]\nquality = 9001\n"
config_path = temp_config_dir / "config.ini"
config_path.write_text(invalid_content)
loader = ConfigLoader(config_path=config_path)
# ACT & ASSERT
with pytest.raises(ConfigError) as exc_info:
loader.load()
# Check for a user-friendly error message
assert "Configuration error" in str(exc_info.value)
assert "stream.quality" in str(exc_info.value)
def test_load_raises_error_if_default_config_cannot_be_written(
self, temp_config_dir: Path
):
"""
GIVEN the default config file cannot be written due to permissions.
WHEN the ConfigLoader attempts to create it.
THEN it should raise a ConfigError.
"""
# ARRANGE
config_path = temp_config_dir / "unwritable_dir" / "config.ini"
loader = ConfigLoader(config_path=config_path)
# ACT & ASSERT: Mock Path.write_text to simulate a permissions error
with patch("pathlib.Path.write_text", side_effect=PermissionError):
with patch("click.echo"): # Mock echo to keep test output clean
with pytest.raises(ConfigError) as exc_info:
loader.load()
assert "Could not create default configuration file" in str(exc_info.value)
assert "Please check permissions" in str(exc_info.value)