diff --git a/generate_completions.sh b/dev/generate_completions.sh similarity index 100% rename from generate_completions.sh rename to dev/generate_completions.sh diff --git a/make_release b/dev/make_release similarity index 100% rename from make_release rename to dev/make_release diff --git a/fastanime/assets/defaults/fzf-opts b/fastanime/assets/defaults/fzf-opts new file mode 100644 index 0000000..45d54ed --- /dev/null +++ b/fastanime/assets/defaults/fzf-opts @@ -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='│' diff --git a/fastanime/assets/rofi_theme_confirm.rasi b/fastanime/assets/defaults/rofi-themes/confirm.rasi similarity index 100% rename from fastanime/assets/rofi_theme_confirm.rasi rename to fastanime/assets/defaults/rofi-themes/confirm.rasi diff --git a/fastanime/assets/rofi_theme_input.rasi b/fastanime/assets/defaults/rofi-themes/input.rasi similarity index 100% rename from fastanime/assets/rofi_theme_input.rasi rename to fastanime/assets/defaults/rofi-themes/input.rasi diff --git a/fastanime/assets/rofi_theme.rasi b/fastanime/assets/defaults/rofi-themes/main.rasi similarity index 100% rename from fastanime/assets/rofi_theme.rasi rename to fastanime/assets/defaults/rofi-themes/main.rasi diff --git a/fastanime/assets/rofi_theme_preview.rasi b/fastanime/assets/defaults/rofi-themes/preview.rasi similarity index 100% rename from fastanime/assets/rofi_theme_preview.rasi rename to fastanime/assets/defaults/rofi-themes/preview.rasi diff --git a/fastanime/assets/logo.ico b/fastanime/assets/icons/logo.ico similarity index 100% rename from fastanime/assets/logo.ico rename to fastanime/assets/icons/logo.ico diff --git a/fastanime/assets/logo.png b/fastanime/assets/icons/logo.png similarity index 100% rename from fastanime/assets/logo.png rename to fastanime/assets/icons/logo.png diff --git a/fastanime/cli/config/__init__.py b/fastanime/cli/config/__init__.py new file mode 100644 index 0000000..198db2d --- /dev/null +++ b/fastanime/cli/config/__init__.py @@ -0,0 +1,4 @@ +from .loader import ConfigLoader +from .model import AppConfig + +__all__ = ["ConfigLoader", "AppConfig"] diff --git a/fastanime/cli/config/loader.py b/fastanime/cli/config/loader.py new file mode 100644 index 0000000..a766a72 --- /dev/null +++ b/fastanime/cli/config/loader.py @@ -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) diff --git a/fastanime/cli/config/model.py b/fastanime/cli/config/model.py new file mode 100644 index 0000000..e7fa065 --- /dev/null +++ b/fastanime/cli/config/model.py @@ -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) diff --git a/fastanime/cli/constants.py b/fastanime/cli/constants.py new file mode 100644 index 0000000..adc5277 --- /dev/null +++ b/fastanime/cli/constants.py @@ -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") diff --git a/fastanime/constants.py b/fastanime/constants.py deleted file mode 100644 index 8559605..0000000 --- a/fastanime/constants.py +++ /dev/null @@ -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") diff --git a/fastanime/core/constants.py b/fastanime/core/constants.py new file mode 100644 index 0000000..2b7abd3 --- /dev/null +++ b/fastanime/core/constants.py @@ -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" diff --git a/fastanime/core/exceptions.py b/fastanime/core/exceptions.py new file mode 100644 index 0000000..bec7d21 --- /dev/null +++ b/fastanime/core/exceptions.py @@ -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) diff --git a/fastanime/libs/anilist/constants.py b/fastanime/libs/anilist/constants.py new file mode 100644 index 0000000..dc12afd --- /dev/null +++ b/fastanime/libs/anilist/constants.py @@ -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) diff --git a/fastanime/libs/anime_provider/__init__.py b/fastanime/libs/anime_provider/__init__.py index 6c3155a..f6fc9e7 100644 --- a/fastanime/libs/anime_provider/__init__.py +++ b/fastanime/libs/anime_provider/__init__.py @@ -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] diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py new file mode 100644 index 0000000..66b5fc0 --- /dev/null +++ b/tests/test_config_loader.py @@ -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)