diff --git a/src/cli/nfo_cli.py b/src/cli/nfo_cli.py index a16b736..9e82b00 100644 --- a/src/cli/nfo_cli.py +++ b/src/cli/nfo_cli.py @@ -1,19 +1,22 @@ """CLI command for NFO management. -This script provides command-line interface for creating, updating, -and checking NFO metadata files. +Note: NFO service has been removed. This CLI is no longer functional. """ -import asyncio -import logging import sys from pathlib import Path # Add src to path sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from src.config.settings import settings -from src.core.services.series_manager_service import SeriesManagerService +def main(): + """Main entry point.""" + print("NFO CLI is no longer available - NFO service has been removed.") + print("Use the web API endpoints for NFO management.") + return 1 + +if __name__ == "__main__": + sys.exit(main()) logger = logging.getLogger(__name__) diff --git a/src/core/SeriesApp.py b/src/core/SeriesApp.py index dfe365c..5c51347 100644 --- a/src/core/SeriesApp.py +++ b/src/core/SeriesApp.py @@ -23,8 +23,6 @@ from src.core.entities.SerieList import SerieList from src.core.entities.series import Serie from src.core.providers.provider_factory import Loaders from src.core.SerieScanner import SerieScanner -from src.core.services.nfo_service import NFOService -from src.core.services.tmdb_client import TMDBAPIError logger = logging.getLogger(__name__) @@ -178,23 +176,9 @@ class SeriesApp: # Initialize empty list - series loaded later via load_series_from_list() # No need to call _init_list_sync() anymore - # Initialize NFO service if a TMDB API key is configured - self.nfo_service: Optional[NFOService] = None - try: - from src.core.services.nfo_factory import get_nfo_factory - - factory = get_nfo_factory() - self.nfo_service = factory.create() - logger.info("NFO service initialized successfully") - except ValueError: - logger.info( - "NFO service not available — TMDB API key not configured" - ) - self.nfo_service = None - except Exception as e: # pylint: disable=broad-except - logger.warning("Failed to initialize NFO service: %s", str(e)) - self.nfo_service = None - + # NFO service removed - metadata handling moved to server layer + self.nfo_service = None + logger.info( "SeriesApp initialized for directory: %s", directory_to_search, @@ -356,95 +340,6 @@ class SeriesApp: ) return False - # Check and create NFO files if needed - if self.nfo_service and settings.nfo_auto_create: - try: - # Check if NFO exists - nfo_exists = await self.nfo_service.check_nfo_exists( - serie_folder - ) - - if not nfo_exists: - logger.info( - "NFO not found for %s, creating metadata...", - serie_folder - ) - - # Fire NFO creation started event - self._events.download_status( - DownloadStatusEventArgs( - serie_folder=serie_folder, - key=key, - season=season, - episode=episode, - status="nfo_creating", - message="Creating NFO metadata...", - item_id=item_id, - ) - ) - - # Create NFO and download media files - try: - # Use folder name as series name - await self.nfo_service.create_tvshow_nfo( - serie_name=serie_folder, - serie_folder=serie_folder, - download_poster=settings.nfo_download_poster, - download_logo=settings.nfo_download_logo, - download_fanart=settings.nfo_download_fanart - ) - - logger.info( - "NFO and media files created for %s", - serie_folder - ) - - # Fire NFO creation completed event - self._events.download_status( - DownloadStatusEventArgs( - serie_folder=serie_folder, - key=key, - season=season, - episode=episode, - status="nfo_completed", - message="NFO metadata created", - item_id=item_id, - ) - ) - - except TMDBAPIError as tmdb_error: - logger.warning( - "Failed to create NFO for %s: %s", - serie_folder, - str(tmdb_error) - ) - # Fire failed event (but continue with download) - self._events.download_status( - DownloadStatusEventArgs( - serie_folder=serie_folder, - key=key, - season=season, - episode=episode, - status="nfo_failed", - message=( - f"NFO creation failed: " - f"{str(tmdb_error)}" - ), - item_id=item_id, - ) - ) - else: - logger.debug("NFO already exists for %s", serie_folder) - - except Exception as nfo_error: # pylint: disable=broad-except - logger.error( - "Error checking/creating NFO for %s: %s", - serie_folder, - str(nfo_error), - exc_info=True - ) - # Don't fail the download if NFO creation fails - try: def download_progress_handler(progress_info): """Handle download progress events from loader.""" diff --git a/src/core/services/nfo_factory.py b/src/core/services/nfo_factory.py deleted file mode 100644 index 719e6f2..0000000 --- a/src/core/services/nfo_factory.py +++ /dev/null @@ -1,237 +0,0 @@ -"""NFO Service Factory Module. - -This module provides a centralized factory for creating NFOService instances -with consistent configuration and initialization logic. - -The factory supports both direct instantiation and FastAPI dependency injection, -while remaining testable through optional dependency overrides. -""" - -import logging -from typing import Optional - -from src.config.settings import settings -from src.core.services.nfo_service import NFOService - -logger = logging.getLogger(__name__) - - -class NFOServiceFactory: - """Factory for creating NFOService instances with consistent configuration. - - This factory centralizes NFO service initialization logic that was previously - duplicated across multiple modules (SeriesApp, SeriesManagerService, API endpoints). - - The factory follows these precedence rules for configuration: - 1. Explicit parameters (highest priority) - 2. Environment variables via settings - 3. config.json via ConfigService (fallback) - 4. Raise error if TMDB API key unavailable - - Example: - >>> factory = NFOServiceFactory() - >>> nfo_service = factory.create() - >>> # Or with custom settings: - >>> nfo_service = factory.create(tmdb_api_key="custom_key") - """ - - def __init__(self): - """Initialize the NFO service factory.""" - self._config_service = None - - def create( - self, - tmdb_api_key: Optional[str] = None, - anime_directory: Optional[str] = None, - image_size: Optional[str] = None, - auto_create: Optional[bool] = None - ) -> NFOService: - """Create an NFOService instance with proper configuration. - - This method implements the configuration precedence: - 1. Use explicit parameters if provided - 2. Fall back to settings (from ENV vars) - 3. Fall back to config.json (only if ENV not set) - 4. Raise ValueError if TMDB API key still unavailable - - Args: - tmdb_api_key: TMDB API key (optional, falls back to settings/config) - anime_directory: Anime directory path (optional, defaults to settings) - image_size: Image size for downloads (optional, defaults to settings) - auto_create: Whether to auto-create NFO files (optional, defaults to settings) - - Returns: - NFOService: Configured NFO service instance - - Raises: - ValueError: If TMDB API key cannot be determined from any source - - Example: - >>> factory = NFOServiceFactory() - >>> # Use all defaults from settings - >>> service = factory.create() - >>> # Override specific settings - >>> service = factory.create(auto_create=False) - """ - # Step 1: Determine TMDB API key with fallback logic - api_key = tmdb_api_key or settings.tmdb_api_key - - # Step 2: If no API key in settings, try config.json as fallback - if not api_key: - api_key = self._get_api_key_from_config() - - # Step 3: Validate API key is available - if not api_key: - raise ValueError( - "TMDB API key not configured. Set TMDB_API_KEY environment " - "variable or configure in config.json (nfo.tmdb_api_key)." - ) - - # Step 4: Use provided values or fall back to settings - directory = anime_directory or settings.anime_directory - size = image_size or settings.nfo_image_size - auto = auto_create if auto_create is not None else settings.nfo_auto_create - - # Step 5: Create and return the service - logger.debug( - "Creating NFOService: directory=%s, size=%s, auto_create=%s", - directory, size, auto - ) - - return NFOService( - tmdb_api_key=api_key, - anime_directory=directory, - image_size=size, - auto_create=auto - ) - - def create_optional( - self, - tmdb_api_key: Optional[str] = None, - anime_directory: Optional[str] = None, - image_size: Optional[str] = None, - auto_create: Optional[bool] = None - ) -> Optional[NFOService]: - """Create an NFOService instance, returning None if configuration unavailable. - - This is a convenience method for cases where NFO service is optional. - Unlike create(), this returns None instead of raising ValueError when - the TMDB API key is not configured. - - Args: - tmdb_api_key: TMDB API key (optional) - anime_directory: Anime directory path (optional) - image_size: Image size for downloads (optional) - auto_create: Whether to auto-create NFO files (optional) - - Returns: - Optional[NFOService]: Configured service or None if key unavailable - - Example: - >>> factory = NFOServiceFactory() - >>> service = factory.create_optional() - >>> if service: - ... service.create_tvshow_nfo(...) - """ - try: - return self.create( - tmdb_api_key=tmdb_api_key, - anime_directory=anime_directory, - image_size=image_size, - auto_create=auto_create - ) - except ValueError as e: - logger.debug("NFO service not available: %s", e) - return None - - def _get_api_key_from_config(self) -> Optional[str]: - """Get TMDB API key from config.json as fallback. - - This method is only called when the API key is not in settings - (i.e., not set via environment variable). It provides backward - compatibility with config.json configuration. - - Returns: - Optional[str]: API key from config.json, or None if unavailable - """ - try: - # Lazy import to avoid circular dependencies - from src.server.services.config_service import get_config_service - - if self._config_service is None: - self._config_service = get_config_service() - - config = self._config_service.load_config() - - if config.nfo and config.nfo.tmdb_api_key: - logger.debug("Using TMDB API key from config.json") - return config.nfo.tmdb_api_key - - except Exception as e: # pylint: disable=broad-except - logger.debug("Could not load API key from config.json: %s", e) - - return None - - -# Global factory instance for convenience -_factory_instance: Optional[NFOServiceFactory] = None - - -def get_nfo_factory() -> NFOServiceFactory: - """Get the global NFO service factory instance. - - This function provides a singleton factory instance for the application. - The singleton pattern here is for the factory itself (which is stateless), - not for the NFO service instances it creates. - - Returns: - NFOServiceFactory: The global factory instance - - Example: - >>> factory = get_nfo_factory() - >>> service = factory.create() - """ - global _factory_instance - - if _factory_instance is None: - _factory_instance = NFOServiceFactory() - - return _factory_instance - - -def create_nfo_service( - tmdb_api_key: Optional[str] = None, - anime_directory: Optional[str] = None, - image_size: Optional[str] = None, - auto_create: Optional[bool] = None -) -> NFOService: - """Convenience function to create an NFOService instance. - - This is a shorthand for get_nfo_factory().create() that can be used - when you need a quick NFO service instance without interacting with - the factory directly. - - Args: - tmdb_api_key: TMDB API key (optional) - anime_directory: Anime directory path (optional) - image_size: Image size for downloads (optional) - auto_create: Whether to auto-create NFO files (optional) - - Returns: - NFOService: Configured NFO service instance - - Raises: - ValueError: If TMDB API key cannot be determined - - Example: - >>> service = create_nfo_service() - >>> # Or with custom settings: - >>> service = create_nfo_service(auto_create=False) - """ - factory = get_nfo_factory() - return factory.create( - tmdb_api_key=tmdb_api_key, - anime_directory=anime_directory, - image_size=image_size, - auto_create=auto_create - ) diff --git a/src/core/services/nfo_repair_service.py b/src/core/services/nfo_repair_service.py deleted file mode 100644 index 06181f4..0000000 --- a/src/core/services/nfo_repair_service.py +++ /dev/null @@ -1,228 +0,0 @@ -"""NFO repair service for detecting and fixing incomplete tvshow.nfo files. - -This module provides utilities to check whether an existing ``tvshow.nfo`` -contains all required tags and to trigger a repair (re-fetch from TMDB) when -needed. - -Example: - >>> service = NfoRepairService(nfo_service) - >>> repaired = await service.repair_series(Path("/anime/Attack on Titan"), "Attack on Titan") -""" - -import logging -from pathlib import Path -from typing import Dict, List - -from lxml import etree - -from src.core.services.nfo_service import NFOService -from src.core.services.tmdb_client import TMDBAPIError - -logger = logging.getLogger(__name__) - - -# XPath relative to root → human-readable label -REQUIRED_TAGS: Dict[str, str] = { - "./title": "title", - "./originaltitle": "originaltitle", - "./year": "year", - "./plot": "plot", - "./runtime": "runtime", - "./premiered": "premiered", - "./status": "status", - "./imdbid": "imdbid", - "./genre": "genre", - "./studio": "studio", - "./country": "country", - "./actor/name": "actor/name", - "./watched": "watched", -} - - -def parse_nfo_tags(nfo_path: Path) -> Dict[str, List[str]]: - """Parse an existing tvshow.nfo and return present tag values. - - Evaluates every XPath in :data:`REQUIRED_TAGS` against the document root - and collects all non-empty text values. - - Args: - nfo_path: Absolute path to the ``tvshow.nfo`` file. - - Returns: - Mapping of XPath expression → list of non-empty text strings found in - the document. Returns an empty dict on any error (missing file, - invalid XML, permission error). - - Example: - >>> tags = parse_nfo_tags(Path("/anime/Attack on Titan/tvshow.nfo")) - >>> tags.get("./title") - ['Attack on Titan'] - """ - if not nfo_path.exists(): - logger.debug("NFO file not found: %s", nfo_path) - return {} - - try: - tree = etree.parse(str(nfo_path)) - root = tree.getroot() - - result: Dict[str, List[str]] = {} - for xpath in REQUIRED_TAGS: - elements = root.findall(xpath) - result[xpath] = [e.text for e in elements if e.text] - - return result - - except etree.XMLSyntaxError as exc: - logger.warning("Malformed XML in %s: %s", nfo_path, exc) - return {} - except Exception as exc: # pylint: disable=broad-except - logger.warning("Unexpected error parsing %s: %s", nfo_path, exc) - return {} - - -def find_missing_tags(nfo_path: Path) -> List[str]: - """Return tags that are absent or empty in the NFO. - - Args: - nfo_path: Absolute path to the ``tvshow.nfo`` file. - - Returns: - List of human-readable tag labels (values from :data:`REQUIRED_TAGS`) - whose XPath matched no elements or only elements with empty text. - An empty list means the NFO is complete. - - Example: - >>> missing = find_missing_tags(Path("/anime/series/tvshow.nfo")) - >>> if missing: - ... print("Missing:", missing) - """ - parsed = parse_nfo_tags(nfo_path) - missing: List[str] = [] - for xpath, label in REQUIRED_TAGS.items(): - if not parsed.get(xpath): - missing.append(label) - return missing - - -def nfo_needs_repair(nfo_path: Path) -> bool: - """Return ``True`` if the NFO is missing any required tag. - - Args: - nfo_path: Absolute path to the ``tvshow.nfo`` file. - - Returns: - True if :func:`find_missing_tags` returns a non-empty list. - - Example: - >>> if nfo_needs_repair(Path("/anime/series/tvshow.nfo")): - ... await service.repair_series(series_path, series_name) - """ - return bool(find_missing_tags(nfo_path)) - - -def _read_tmdb_id(nfo_path: Path) -> int | None: - """Return the TMDB ID stored in an existing NFO, or ``None``. - - Checks both ```` and ```` elements. - - Args: - nfo_path: Absolute path to the ``tvshow.nfo`` file. - - Returns: - Integer TMDB ID, or ``None`` if not found or not parseable. - """ - if not nfo_path.exists(): - return None - try: - root = etree.parse(str(nfo_path)).getroot() - - for uniqueid in root.findall(".//uniqueid"): - if uniqueid.get("type") == "tmdb" and uniqueid.text: - return int(uniqueid.text) - - tmdbid_elem = root.find(".//tmdbid") - if tmdbid_elem is not None and tmdbid_elem.text: - return int(tmdbid_elem.text) - - except (etree.XMLSyntaxError, ValueError): - pass - except Exception: # pylint: disable=broad-except - pass - return None - - -class NfoRepairService: - """Service that detects and repairs incomplete tvshow.nfo files. - - Wraps the module-level helpers with structured logging and delegates - the actual TMDB re-fetch to an injected :class:`NFOService` instance. - - Attributes: - _nfo_service: The underlying NFOService used to update NFOs. - """ - - def __init__(self, nfo_service: NFOService) -> None: - """Initialise the repair service. - - Args: - nfo_service: Configured :class:`NFOService` instance. - """ - self._nfo_service = nfo_service - - async def repair_series(self, series_path: Path, series_name: str) -> bool: - """Repair an NFO file if required tags are missing. - - Checks ``{series_path}/tvshow.nfo`` for completeness. If tags are - missing, logs them and calls - ``NFOService.update_tvshow_nfo(series_name)`` to re-fetch metadata - from TMDB. - - Args: - series_path: Absolute path to the series folder. - series_name: Series folder name used as the identifier for - :meth:`NFOService.update_tvshow_nfo`. - - Returns: - ``True`` if a repair was triggered, ``False`` if the NFO was - already complete (or did not exist). - """ - nfo_path = series_path / "tvshow.nfo" - missing = find_missing_tags(nfo_path) - - if not missing: - logger.info( - "NFO repair skipped — complete: %s", - series_name, - ) - return False - - logger.info( - "NFO repair triggered for %s — missing tags: %s", - series_name, - ", ".join(missing), - ) - - try: - await self._nfo_service.update_tvshow_nfo( - series_name, - download_media=False, - ) - except TMDBAPIError as e: - if "No TMDB ID found" in str(e): - # No TMDB ID in existing NFO — create new one via search - logger.info( - "NFO has no TMDB ID, creating new NFO via TMDB search" - ) - await self._nfo_service.create_tvshow_nfo( - serie_name=series_name, - serie_folder=series_name, - download_poster=False, - download_logo=False, - download_fanart=False, - ) - else: - raise - - logger.info("NFO repair completed: %s", series_name) - return True diff --git a/src/core/services/nfo_service.py b/src/core/services/nfo_service.py deleted file mode 100644 index 698d34c..0000000 --- a/src/core/services/nfo_service.py +++ /dev/null @@ -1,891 +0,0 @@ -"""NFO service for creating and managing tvshow.nfo files. - -This service orchestrates TMDB API calls, XML generation, and media downloads -to create complete NFO metadata for TV series. - -Example: - >>> nfo_service = NFOService(tmdb_api_key="key", anime_directory="/anime") - >>> await nfo_service.create_tvshow_nfo("Attack on Titan", "/anime/aot", 2013) -""" - -import logging -import re -import unicodedata -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -from lxml import etree - -from src.core.services.tmdb_client import TMDBAPIError, TMDBClient -from src.core.utils.image_downloader import ImageDownloader -from src.core.utils.nfo_generator import generate_tvshow_nfo -from src.core.utils.nfo_mapper import tmdb_to_nfo_model -from src.core.entities.nfo_models import TVShowNFO - -logger = logging.getLogger(__name__) - - -class NFOService: - """Service for creating and managing tvshow.nfo files. - - Attributes: - tmdb_client: TMDB API client - image_downloader: Image downloader utility - anime_directory: Base directory for anime series - """ - - def __init__( - self, - tmdb_api_key: str, - anime_directory: str, - image_size: str = "original", - auto_create: bool = True - ): - """Initialize NFO service. - - Args: - tmdb_api_key: TMDB API key - anime_directory: Base anime directory path - image_size: Image size to download (original, w500, etc.) - auto_create: Whether to auto-create NFOs - """ - self.tmdb_client = TMDBClient(api_key=tmdb_api_key) - self.image_downloader = ImageDownloader() - self.anime_directory = Path(anime_directory) - self.image_size = image_size - self.auto_create = auto_create - - async def __aenter__(self) -> "NFOService": - """Enter async context manager.""" - await self.tmdb_client.__aenter__() - await self.image_downloader.__aenter__() - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - """Exit async context manager and cleanup resources.""" - await self.tmdb_client.close() - await self.image_downloader.close() - return False - - def has_nfo(self, serie_folder: str) -> bool: - """Check if tvshow.nfo exists for a series. - - Args: - serie_folder: Series folder name - - Returns: - True if NFO file exists - """ - nfo_path = self.anime_directory / serie_folder / "tvshow.nfo" - return nfo_path.exists() - - @staticmethod - def _extract_year_from_name(serie_name: str) -> Tuple[str, Optional[int]]: - """Extract year from series name if present in format 'Name (YYYY)'. - - Args: - serie_name: Series name, possibly with year in parentheses - - Returns: - Tuple of (clean_name, year) - - clean_name: Series name without year - - year: Extracted year or None - - Examples: - >>> _extract_year_from_name("Attack on Titan (2013)") - ("Attack on Titan", 2013) - >>> _extract_year_from_name("Attack on Titan") - ("Attack on Titan", None) - """ - # Match the last year in parentheses at the end: (YYYY) - match = re.search(r'\((\d{4})\)\s*$', serie_name) - if match: - year = int(match.group(1)) - # Strip ALL trailing year suffixes to get a fully clean name - clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', serie_name).strip() - return clean_name, year - return serie_name, None - - async def check_nfo_exists(self, serie_folder: str) -> bool: - """Check if tvshow.nfo exists for a series. - - Args: - serie_folder: Series folder name - - Returns: - True if tvshow.nfo exists - """ - nfo_path = self.anime_directory / serie_folder / "tvshow.nfo" - return nfo_path.exists() - - async def create_tvshow_nfo( - self, - serie_name: str, - serie_folder: str, - year: Optional[int] = None, - download_poster: bool = True, - download_logo: bool = True, - download_fanart: bool = True, - alt_titles: Optional[List[str]] = None - ) -> Path: - """Create tvshow.nfo by scraping TMDB. - - Args: - serie_name: Name of the series to search (may include year in parentheses) - serie_folder: Series folder name - year: Release year (helps narrow search). If None and name contains year, - year will be auto-extracted - download_poster: Whether to download poster.jpg - download_logo: Whether to download logo.png - download_fanart: Whether to download fanart.jpg - alt_titles: Alternative titles (e.g., Japanese title) for fallback search - - Returns: - Path to created NFO file - - Raises: - TMDBAPIError: If TMDB API fails - FileNotFoundError: If series folder doesn't exist - """ - # Extract year from name if not provided - clean_name, extracted_year = self._extract_year_from_name(serie_name) - if year is None and extracted_year is not None: - year = extracted_year - logger.info("Extracted year %s from series name", year) - - # Use clean name for search - search_name = clean_name - - logger.info("Creating NFO for %s (year: %s)", search_name, year) - - folder_path = self.anime_directory / serie_folder - if not folder_path.exists(): - logger.info("Creating series folder: %s", folder_path) - folder_path.mkdir(parents=True, exist_ok=True) - - # Check for existing NFO with TMDB ID to skip search - nfo_path = folder_path / "tvshow.nfo" - existing_ids = None - if nfo_path.exists(): - try: - existing_ids = self.parse_nfo_ids(nfo_path) - if existing_ids.get("tmdb_id"): - logger.info( - "Found existing TMDB ID %s in NFO, using directly", - existing_ids["tmdb_id"] - ) - except Exception as e: - logger.debug("Could not parse existing NFO IDs: %s", e) - - try: - await self.tmdb_client._ensure_session() - - # Use existing TMDB ID if found, otherwise search - if existing_ids and existing_ids.get("tmdb_id"): - tv_id = existing_ids["tmdb_id"] - logger.info("Fetching details directly for TMDB ID: %s", tv_id) - details = await self.tmdb_client.get_tv_show_details( - tv_id, - append_to_response="credits,external_ids,images" - ) - content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id) - tv_show = {"id": tv_id, "name": details.get("name", serie_name)} - search_source = "nfo_override" - else: - # Search for TV show - try multiple strategies - tv_show, search_source = await self._search_with_fallback( - search_name, year, alt_titles - ) - tv_id = tv_show["id"] - - logger.info("Found match: %s (ID: %s)", tv_show['name'], tv_id) - - # Get detailed information with multi-language image support - # Skip if we already fetched details via nfo_override - if search_source != "nfo_override": - details = await self.tmdb_client.get_tv_show_details( - tv_id, - append_to_response="credits,external_ids,images" - ) - - # Get content ratings for FSK - content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id) - - # Enrich with fallback languages for empty overview/tagline - # Pass search result overview as last resort fallback - search_overview = tv_show.get("overview") or None - if not search_overview: - try: - logger.debug( - "No overview in German search result, trying en-US search fallback for: %s", - search_name, - ) - en_search_results = await self.tmdb_client.search_tv_show( - search_name, - language="en-US", - ) - if en_search_results.get("results"): - en_match = self._find_best_match( - en_search_results["results"], search_name, year - ) - search_overview = en_match.get("overview") or None - if search_overview: - logger.info( - "Using en-US search overview fallback for %s", - search_name, - ) - except (TMDBAPIError, Exception) as exc: - logger.warning( - "Failed en-US search fallback for overview: %s", - exc, - ) - - details = await self._enrich_details_with_fallback( - details, search_overview=search_overview - ) - else: - # When using nfo_override, content_ratings already fetched - pass - - # Convert TMDB data to TVShowNFO model - nfo_model = tmdb_to_nfo_model( - details, - content_ratings, - self.tmdb_client.get_image_url, - self.image_size, - ) - - # Generate XML - nfo_xml = generate_tvshow_nfo(nfo_model) - - # Save NFO file - nfo_path = folder_path / "tvshow.nfo" - nfo_path.write_text(nfo_xml, encoding="utf-8") - logger.info("Created NFO: %s", nfo_path) - - # Download media files - await self._download_media_files( - details, - folder_path, - download_poster=download_poster, - download_logo=download_logo, - download_fanart=download_fanart - ) - - return nfo_path - finally: - await self.tmdb_client.close() - - async def update_tvshow_nfo( - self, - serie_folder: str, - download_media: bool = True - ) -> Path: - """Update existing tvshow.nfo with fresh data from TMDB. - - Args: - serie_folder: Series folder name - download_media: Whether to re-download media files - - Returns: - Path to updated NFO file - - Raises: - FileNotFoundError: If NFO file doesn't exist - TMDBAPIError: If TMDB API fails or no TMDB ID found in NFO - """ - folder_path = self.anime_directory / serie_folder - nfo_path = folder_path / "tvshow.nfo" - - if not nfo_path.exists(): - raise FileNotFoundError(f"NFO file not found: {nfo_path}") - - logger.info("Updating NFO for %s", serie_folder) - - # Parse existing NFO to extract TMDB ID - try: - tree = etree.parse(str(nfo_path)) - root = tree.getroot() - - # Try to find TMDB ID from uniqueid elements - tmdb_id = None - for uniqueid in root.findall(".//uniqueid"): - if uniqueid.get("type") == "tmdb": - tmdb_id = int(uniqueid.text) - break - - # Fallback: check for tmdbid element - if tmdb_id is None: - tmdbid_elem = root.find(".//tmdbid") - if tmdbid_elem is not None and tmdbid_elem.text: - tmdb_id = int(tmdbid_elem.text) - - if tmdb_id is None: - raise TMDBAPIError( - f"No TMDB ID found in existing NFO. " - f"Delete the NFO and create a new one instead." - ) - - logger.debug("Found TMDB ID: %s", tmdb_id) - - except etree.XMLSyntaxError as e: - raise TMDBAPIError(f"Invalid XML in NFO file: {e}") - except ValueError as e: - raise TMDBAPIError(f"Invalid TMDB ID format in NFO: {e}") - - try: - await self.tmdb_client._ensure_session() - logger.debug("Fetching fresh data for TMDB ID: %s", tmdb_id) - details = await self.tmdb_client.get_tv_show_details( - tmdb_id, - append_to_response="credits,external_ids,images" - ) - - # Get content ratings for FSK - content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tmdb_id) - - # Enrich with fallback languages for empty overview/tagline - details = await self._enrich_details_with_fallback(details) - # Convert TMDB data to TVShowNFO model - nfo_model = tmdb_to_nfo_model( - details, - content_ratings, - self.tmdb_client.get_image_url, - self.image_size, - ) - - # Generate XML - nfo_xml = generate_tvshow_nfo(nfo_model) - - # Save updated NFO file - nfo_path.write_text(nfo_xml, encoding="utf-8") - logger.info("Updated NFO: %s", nfo_path) - - # Re-download media files if requested - if download_media: - await self._download_media_files( - details, - folder_path, - download_poster=True, - download_logo=True, - download_fanart=True - ) - - return nfo_path - finally: - await self.tmdb_client.close() - - def parse_nfo_ids(self, nfo_path: Path) -> Dict[str, Optional[int]]: - """Parse TMDB ID and TVDB ID from an existing NFO file. - - Args: - nfo_path: Path to tvshow.nfo file - - Returns: - Dictionary with 'tmdb_id' and 'tvdb_id' keys. - Values are integers if found, None otherwise. - - Example: - >>> ids = nfo_service.parse_nfo_ids(Path("/anime/series/tvshow.nfo")) - >>> print(ids) - {'tmdb_id': 1429, 'tvdb_id': 79168} - """ - result = {"tmdb_id": None, "tvdb_id": None} - - if not nfo_path.exists(): - logger.debug("NFO file not found: %s", nfo_path) - return result - - try: - tree = etree.parse(str(nfo_path)) - root = tree.getroot() - - # Try to find TMDB ID from uniqueid elements first - for uniqueid in root.findall(".//uniqueid"): - uid_type = uniqueid.get("type") - uid_text = uniqueid.text - - if uid_type == "tmdb" and uid_text: - try: - result["tmdb_id"] = int(uid_text) - except ValueError: - logger.warning( - f"Invalid TMDB ID format in NFO: {uid_text}" - ) - - elif uid_type == "tvdb" and uid_text: - try: - result["tvdb_id"] = int(uid_text) - except ValueError: - logger.warning( - f"Invalid TVDB ID format in NFO: {uid_text}" - ) - - # Fallback: check for dedicated tmdbid/tvdbid elements - if result["tmdb_id"] is None: - tmdbid_elem = root.find(".//tmdbid") - if tmdbid_elem is not None and tmdbid_elem.text: - try: - result["tmdb_id"] = int(tmdbid_elem.text) - except ValueError: - logger.warning( - f"Invalid TMDB ID format in tmdbid element: " - f"{tmdbid_elem.text}" - ) - - if result["tvdb_id"] is None: - tvdbid_elem = root.find(".//tvdbid") - if tvdbid_elem is not None and tvdbid_elem.text: - try: - result["tvdb_id"] = int(tvdbid_elem.text) - except ValueError: - logger.warning( - f"Invalid TVDB ID format in tvdbid element: " - f"{tvdbid_elem.text}" - ) - - logger.debug( - f"Parsed IDs from NFO: {nfo_path.name} - " - f"TMDB: {result['tmdb_id']}, TVDB: {result['tvdb_id']}" - ) - - except etree.XMLSyntaxError as e: - logger.error("Invalid XML in NFO file %s: %s", nfo_path, e) - except Exception as e: # pylint: disable=broad-except - logger.error("Error parsing NFO file %s: %s", nfo_path, e) - - return result - - def parse_nfo_year(self, nfo_path: Path) -> Optional[int]: - """Parse year from an existing NFO file. - - Extracts year from or elements. - - Args: - nfo_path: Path to tvshow.nfo file - - Returns: - Year as integer if found, None otherwise. - - Example: - >>> year = nfo_service.parse_nfo_year(Path("/anime/series/tvshow.nfo")) - >>> print(year) - 2013 - """ - if not nfo_path.exists(): - logger.debug("NFO file not found: %s", nfo_path) - return None - - try: - tree = etree.parse(str(nfo_path)) - root = tree.getroot() - - # Try element first - year_elem = root.find(".//year") - if year_elem is not None and year_elem.text: - try: - year = int(year_elem.text) - if 1900 <= year <= 2100: - logger.debug("Found year in NFO: %d", year) - return year - except ValueError: - pass - - # Fallback: try element (format: YYYY-MM-DD) - premiered_elem = root.find(".//premiered") - if premiered_elem is not None and premiered_elem.text: - if premiered_elem.text and len(premiered_elem.text) >= 4: - try: - year = int(premiered_elem.text[:4]) - if 1900 <= year <= 2100: - logger.debug("Found year from premiered in NFO: %d", year) - return year - except ValueError: - pass - - logger.debug("No year found in NFO: %s", nfo_path) - - except etree.XMLSyntaxError as e: - logger.error("Invalid XML in NFO file %s: %s", nfo_path, e) - except Exception as e: # pylint: disable=broad-except - logger.error("Error parsing year from NFO file %s: %s", nfo_path, e) - - return None - - async def _enrich_details_with_fallback( - self, - details: Dict[str, Any], - search_overview: Optional[str] = None, - ) -> Dict[str, Any]: - """Enrich TMDB details with fallback languages for empty fields. - - When requesting details in ``de-DE``, some anime have an empty - ``overview`` (and potentially other translatable fields). This - method detects empty values and fills them from alternative - languages (``en-US``, then ``ja-JP``) so that NFO files always - contain a ``plot`` regardless of whether the German translation - exists. As a last resort, the overview from the search result - is used. - - Args: - details: TMDB TV show details (language ``de-DE``). - search_overview: Overview text from the TMDB search result, - used as a final fallback if all language-specific - requests fail or return empty overviews. - - Returns: - The *same* dict, mutated in-place with fallback values - where needed. - """ - overview = details.get("overview") or "" - - if overview: - # Overview already populated – nothing to do. - return details - - tmdb_id = details.get("id") - fallback_languages = ["en-US", "ja-JP"] - - for lang in fallback_languages: - if details.get("overview"): - break - - logger.debug( - "Trying %s fallback for TMDB ID %s", - lang, tmdb_id, - ) - - try: - lang_details = await self.tmdb_client.get_tv_show_details( - tmdb_id, - language=lang, - ) - - if not details.get("overview") and lang_details.get("overview"): - details["overview"] = lang_details["overview"] - logger.info( - "Used %s overview fallback for TMDB ID %s", - lang, tmdb_id, - ) - - # Also fill tagline if missing - if not details.get("tagline") and lang_details.get("tagline"): - details["tagline"] = lang_details["tagline"] - except Exception as exc: # pylint: disable=broad-except - logger.warning( - "Failed to fetch %s fallback for TMDB ID %s: %s", - lang, tmdb_id, exc, - ) - - # Last resort: use search result overview - if not details.get("overview") and search_overview: - details["overview"] = search_overview - logger.info( - "Used search result overview fallback for TMDB ID %s", - tmdb_id, - ) - - return details - - def _find_best_match( - self, - results: List[Dict[str, Any]], - query: str, - year: Optional[int] = None - ) -> Dict[str, Any]: - """Find best matching TV show from search results. - - Args: - results: TMDB search results - query: Original search query - year: Expected release year - - Returns: - Best matching TV show data - """ - if not results: - raise TMDBAPIError("No search results to match") - - # If year is provided, try to find exact match - if year: - for result in results: - first_air_date = result.get("first_air_date", "") - if first_air_date.startswith(str(year)): - logger.debug("Found year match: %s (%s)", result['name'], first_air_date) - return result - - # Return first result (usually best match) - return results[0] - - async def _search_with_fallback( - self, - primary_query: str, - year: Optional[int], - alt_titles: Optional[List[str]] = None - ) -> Tuple[Dict[str, Any], str]: - """Search TMDB with fallback strategies. - - Tries multiple search strategies in order: - 1. Primary query with year filter - 2. Alternative titles (e.g., Japanese name) - 3. Multi-language search (en-US) - 4. Search without year constraint - 5. Punctuation-normalized search - - Args: - primary_query: Primary search term - year: Release year for filtering - alt_titles: Alternative titles to try if primary fails - - Returns: - Tuple of (matched TV show dict, source description string) - - Raises: - TMDBAPIError: If all search strategies fail - """ - search_strategies = [ - # Strategy 1: Primary query as-is - {"query": primary_query, "year": year, "lang": "de-DE", "desc": "primary"}, - ] - - # Strategy 2: Try alt titles (typically Japanese) - if alt_titles: - for alt in alt_titles: - if alt != primary_query: - search_strategies.append( - {"query": alt, "year": year, "lang": "ja-JP", "desc": f"alt_title:{alt}"} - ) - search_strategies.append( - {"query": alt, "year": year, "lang": "en-US", "desc": f"alt_title:{alt}"} - ) - - # Strategy 3: Try English search - search_strategies.append( - {"query": primary_query, "year": year, "lang": "en-US", "desc": "english"} - ) - - # Strategy 4: Try without year constraint - if year: - search_strategies.append( - {"query": primary_query, "year": None, "lang": "de-DE", "desc": "no_year"} - ) - - # Strategy 5: Normalize punctuation - normalized = self._normalize_query_for_search(primary_query) - if normalized != primary_query: - search_strategies.append( - {"query": normalized, "year": year, "lang": "de-DE", "desc": f"normalized:{normalized}"} - ) - - # Strategy 6: Try search/multi for series indexed as movies - search_strategies.append( - {"query": primary_query, "year": year, "lang": "en-US", "desc": "multi_search", "use_multi": True} - ) - - last_error = None - for strategy in search_strategies: - query = strategy["query"] - lang = strategy["lang"] - desc = strategy["desc"] - use_multi = strategy.get("use_multi", False) - - try: - logger.debug( - "TMDB search attempt: query='%s', lang=%s, year=%s, strategy=%s", - query, lang, strategy["year"], desc - ) - - # Use search/multi for multi_search strategy - if use_multi: - search_results = await self.tmdb_client.search_multi( - query, - language=lang - ) - # Filter for TV shows only - if search_results.get("results"): - tv_results = [ - r for r in search_results["results"] - if r.get("media_type") == "tv" - ] - if tv_results: - search_results["results"] = tv_results - else: - search_results["results"] = [] - else: - search_results = await self.tmdb_client.search_tv_show( - query, - language=lang - ) - - if search_results.get("results"): - # Apply year filter if we have one - results = search_results["results"] - if strategy["year"]: - year_filtered = [ - r for r in results - if r.get("first_air_date", "").startswith(str(strategy["year"])) - ] - if year_filtered: - match = year_filtered[0] - else: - # Year didn't match, still use first result but log it - match = results[0] - logger.debug( - "Year %s not found in results for '%s', using: %s", - strategy["year"], query, match["name"] - ) - else: - match = results[0] - - logger.info( - "TMDB search succeeded: '%s' found via strategy '%s' (ID: %s)", - match["name"], desc, match["id"] - ) - return match, desc - else: - logger.debug("No results for '%s' via %s", query, desc) - - except TMDBAPIError as e: - last_error = e - logger.debug("Search strategy '%s' failed: %s", desc, e) - continue - - # All strategies exhausted - raise TMDBAPIError( - f"No results found for: {primary_query} (tried {len(search_strategies)} strategies)" - ) - - def _normalize_query_for_search(self, query: str) -> str: - """Normalize query by removing punctuation and special chars. - - Args: - query: Original search query - - Returns: - Query with punctuation removed - """ - # Remove common punctuation but keep CJK characters - normalized = unicodedata.normalize('NFKC', query) - # Remove punctuation but not CJK - normalized = re.sub(r'[^\w\s\u3000-\u9fff\u4e00-\u9faf]', '', normalized) - # Collapse multiple spaces - normalized = re.sub(r'\s+', ' ', normalized).strip() - return normalized - - - - async def _download_media_files( - self, - tmdb_data: Dict[str, Any], - folder_path: Path, - download_poster: bool = True, - download_logo: bool = True, - download_fanart: bool = True - ) -> Dict[str, bool]: - """Download media files (poster, logo, fanart). - - Args: - tmdb_data: TMDB TV show details - folder_path: Series folder path - download_poster: Download poster.jpg - download_logo: Download logo.png - download_fanart: Download fanart.jpg - - Returns: - Dictionary with download status for each file - """ - poster_url = None - logo_url = None - fanart_url = None - - # Get poster URL - if download_poster and tmdb_data.get("poster_path"): - poster_url = self.tmdb_client.get_image_url( - tmdb_data["poster_path"], - self.image_size - ) - - # Get fanart URL - if download_fanart and tmdb_data.get("backdrop_path"): - fanart_url = self.tmdb_client.get_image_url( - tmdb_data["backdrop_path"], - "original" # Always use original for fanart - ) - - # Get logo URL - if download_logo: - images_data = tmdb_data.get("images", {}) - logos = images_data.get("logos", []) - if logos: - logo_url = self.tmdb_client.get_image_url( - logos[0]["file_path"], - "original" # Logos should be original size - ) - - # Download all media concurrently - results = await self.image_downloader.download_all_media( - folder_path, - poster_url=poster_url, - logo_url=logo_url, - fanart_url=fanart_url, - skip_existing=True - ) - - logger.info("Media download results: %s", results) - return results - - - - async def close(self): - """Clean up resources.""" - await self.tmdb_client.close() - await self.image_downloader.close() - - async def create_minimal_nfo( - self, - serie_name: str, - serie_folder: str, - year: Optional[int] = None - ) -> Path: - """Create minimal tvshow.nfo when TMDB lookup fails. - - Creates a basic NFO with just the title (and year if available) - so the series is tracked even without TMDB metadata. - - Args: - serie_name: Name of the series (may include year in parentheses) - serie_folder: Series folder name - year: Optional release year - - Returns: - Path to created NFO file - - Raises: - FileNotFoundError: If series folder doesn't exist - """ - # Extract year from name if not provided - clean_name, extracted_year = self._extract_year_from_name(serie_name) - if year is None and extracted_year is not None: - year = extracted_year - - folder_path = self.anime_directory / serie_folder - if not folder_path.exists(): - logger.info("Creating series folder: %s", folder_path) - folder_path.mkdir(parents=True, exist_ok=True) - - # Create minimal NFO model with just title and year - nfo_model = TVShowNFO( - title=clean_name, - year=year, - plot=f"No metadata available for {clean_name}. TMDB lookup failed." - ) - - # Generate XML - nfo_xml = generate_tvshow_nfo(nfo_model) - - # Save NFO file - nfo_path = folder_path / "tvshow.nfo" - nfo_path.write_text(nfo_xml, encoding="utf-8") - logger.info("Created minimal NFO (no TMDB): %s", nfo_path) - - return nfo_path diff --git a/src/core/services/series_manager_service.py b/src/core/services/series_manager_service.py deleted file mode 100644 index ee31aa6..0000000 --- a/src/core/services/series_manager_service.py +++ /dev/null @@ -1,309 +0,0 @@ -"""Service for managing series with NFO metadata support. - -This service layer component orchestrates SerieList (core entity) with -NFOService to provide automatic NFO creation and updates during series scans. - -This follows clean architecture principles by keeping the core entities -independent of external services like TMDB API. -""" - -import asyncio -import logging -from pathlib import Path -from typing import Optional - -from src.config.settings import settings -from src.core.entities.SerieList import SerieList -from src.core.services.nfo_service import NFOService -from src.core.services.tmdb_client import TMDBAPIError - -logger = logging.getLogger(__name__) - - -class SeriesManagerService: - """Service for managing series with optional NFO metadata support. - - This service wraps SerieList and adds NFO creation/update capabilities - based on configuration settings. It maintains clean separation between - core entities and external services. - - Attributes: - serie_list: SerieList instance for series management - nfo_service: Optional NFOService for metadata management - auto_create_nfo: Whether to auto-create NFO files - update_on_scan: Whether to update existing NFO files - """ - - def __init__( - self, - anime_directory: str, - tmdb_api_key: Optional[str] = None, - auto_create_nfo: bool = False, - update_on_scan: bool = False, - download_poster: bool = True, - download_logo: bool = True, - download_fanart: bool = True, - image_size: str = "original" - ): - """Initialize series manager service. - - Args: - anime_directory: Base directory for anime series - tmdb_api_key: TMDB API key (optional, required for NFO features) - auto_create_nfo: Automatically create NFO files when scanning - update_on_scan: Update existing NFO files when scanning - download_poster: Download poster.jpg - download_logo: Download logo.png - download_fanart: Download fanart.jpg - image_size: Image size to download - """ - self.anime_directory = anime_directory - # Skip automatic folder scanning - we load from database instead - self.serie_list = SerieList(anime_directory, skip_load=True) - - # NFO configuration - self.auto_create_nfo = auto_create_nfo - self.update_on_scan = update_on_scan - self.download_poster = download_poster - self.download_logo = download_logo - self.download_fanart = download_fanart - - # Initialize NFO service if API key provided and NFO features enabled - self.nfo_service: Optional[NFOService] = None - if tmdb_api_key and (auto_create_nfo or update_on_scan): - try: - from src.core.services.nfo_factory import get_nfo_factory - factory = get_nfo_factory() - self.nfo_service = factory.create( - tmdb_api_key=tmdb_api_key, - anime_directory=anime_directory, - image_size=image_size, - auto_create=auto_create_nfo - ) - logger.info("NFO service initialized (auto_create=%s, update=%s)", - auto_create_nfo, update_on_scan) - except (ValueError, Exception) as e: # pylint: disable=broad-except - logger.warning( - "Failed to initialize NFO service: %s", str(e) - ) - self.nfo_service = None - elif auto_create_nfo or update_on_scan: - logger.warning( - "NFO features requested but TMDB_API_KEY not provided. " - "NFO creation/updates will be skipped." - ) - - @classmethod - def from_settings(cls) -> "SeriesManagerService": - """Create SeriesManagerService from application settings. - - Returns: - Configured SeriesManagerService instance - """ - return cls( - anime_directory=settings.anime_directory, - tmdb_api_key=settings.tmdb_api_key, - auto_create_nfo=settings.nfo_auto_create, - update_on_scan=settings.nfo_update_on_scan, - download_poster=settings.nfo_download_poster, - download_logo=settings.nfo_download_logo, - download_fanart=settings.nfo_download_fanart, - image_size=settings.nfo_image_size - ) - - async def process_nfo_for_series( - self, - serie_folder: str, - serie_name: str, - serie_key: str, - year: Optional[int] = None - ): - """Process NFO file for a series (create or update). - - Args: - serie_folder: Series folder name - serie_name: Series display name - serie_key: Series unique identifier for database updates - year: Release year (helps with TMDB matching) - """ - if not self.nfo_service: - return - - nfo_exists = False - ids = {} - - try: - folder_path = Path(self.anime_directory) / serie_folder - nfo_path = folder_path / "tvshow.nfo" - nfo_exists = await self.nfo_service.check_nfo_exists(serie_folder) - - # If NFO exists, parse IDs and update database - if nfo_exists: - logger.debug("Parsing IDs from existing NFO for '%s'", serie_name) - ids = self.nfo_service.parse_nfo_ids(nfo_path) - - if ids["tmdb_id"] or ids["tvdb_id"]: - # Update database using service layer - from datetime import datetime, timezone - - from src.server.database.connection import get_db_session - from src.server.database.service import AnimeSeriesService - - async with get_db_session() as db: - series = await AnimeSeriesService.get_by_key(db, serie_key) - - if series: - now = datetime.now(timezone.utc) - - # Prepare update fields - update_fields = { - "has_nfo": True, - "nfo_updated_at": now, - } - - if series.nfo_created_at is None: - update_fields["nfo_created_at"] = now - - if ids["tmdb_id"] is not None: - update_fields["tmdb_id"] = ids["tmdb_id"] - logger.debug( - f"Updated TMDB ID for '{serie_name}': " - f"{ids['tmdb_id']}" - ) - - if ids["tvdb_id"] is not None: - update_fields["tvdb_id"] = ids["tvdb_id"] - logger.debug( - f"Updated TVDB ID for '{serie_name}': " - f"{ids['tvdb_id']}" - ) - - # Use service layer for update - await AnimeSeriesService.update(db, series.id, **update_fields) - await db.commit() - - logger.info( - f"Updated database with IDs from NFO for " - f"'{serie_name}' - TMDB: {ids['tmdb_id']}, " - f"TVDB: {ids['tvdb_id']}" - ) - else: - logger.warning( - f"Series not found in database for NFO ID " - f"update: {serie_key}" - ) - - # Create NFO file only if it doesn't exist and auto_create enabled - if not nfo_exists and self.auto_create_nfo: - logger.info( - f"Creating NFO for '{serie_name}' ({serie_folder})" - ) - try: - await self.nfo_service.create_tvshow_nfo( - serie_name=serie_name, - serie_folder=serie_folder, - year=year, - download_poster=self.download_poster, - download_logo=self.download_logo, - download_fanart=self.download_fanart - ) - logger.info("Successfully created NFO for '%s'", serie_name) - except TMDBAPIError as create_error: - # TMDB lookup failed, create minimal NFO to track the series - logger.warning( - "TMDB lookup failed for '%s', creating minimal NFO: %s", - serie_name, create_error - ) - try: - await self.nfo_service.create_minimal_nfo( - serie_name=serie_name, - serie_folder=serie_folder, - year=year - ) - logger.info("Created minimal NFO for '%s'", serie_name) - except Exception as minimal_error: - logger.error( - "Failed to create minimal NFO for '%s': %s", - serie_name, minimal_error - ) - elif nfo_exists: - logger.debug( - f"NFO exists for '{serie_name}', skipping download" - ) - - except TMDBAPIError as e: - # Only log at ERROR if no NFO exists and we have no IDs - # If NFO exists with IDs, this is just a lookup failure, log at DEBUG - if nfo_exists and (ids.get("tmdb_id") or ids.get("tvdb_id")): - logger.debug( - "TMDB API lookup failed for '%s' (has NFO with IDs): %s", - serie_name, e - ) - else: - logger.error("TMDB API error processing '%s': %s", serie_name, e) - except Exception as e: - logger.error( - f"Unexpected error processing NFO for '{serie_name}': {e}", - exc_info=True - ) - - async def scan_and_process_nfo(self): - """Scan all series and process NFO files based on configuration. - - This method: - 1. Loads series from database (avoiding filesystem scan) - 2. For each series with existing NFO, reads TMDB/TVDB IDs - and updates database - 3. For each series without NFO (if auto_create=True), creates one - 4. For each series with NFO (if update_on_scan=True), updates it - 5. Runs operations concurrently for better performance - """ - if not self.nfo_service: - logger.info("NFO service not enabled, skipping NFO processing") - return - - # Import database dependencies - from src.server.database.connection import get_db_session - from src.server.database.service import AnimeSeriesService - - # Load series from database (not from filesystem) - async with get_db_session() as db: - anime_series_list = await AnimeSeriesService.get_all( - db, with_episodes=False - ) - - if not anime_series_list: - logger.info("No series found in database to process") - return - - logger.info("Processing NFO for %s series...", len(anime_series_list)) - - # Create tasks for concurrent processing - # Each task creates its own database session - tasks = [] - for anime_series in anime_series_list: - # Extract year if available - year = getattr(anime_series, 'year', None) - - task = self.process_nfo_for_series( - serie_folder=anime_series.folder, - serie_name=anime_series.name, - serie_key=anime_series.key, - year=year - ) - tasks.append(task) - - # Process in batches to avoid overwhelming TMDB API - batch_size = 5 - for i in range(0, len(tasks), batch_size): - batch = tasks[i:i + batch_size] - await asyncio.gather(*batch, return_exceptions=True) - - # Small delay between batches to respect rate limits - if i + batch_size < len(tasks): - await asyncio.sleep(2) - - async def close(self): - """Clean up resources.""" - if self.nfo_service: - await self.nfo_service.close() diff --git a/src/server/api/nfo.py b/src/server/api/nfo.py index cd3bfd7..4268689 100644 --- a/src/server/api/nfo.py +++ b/src/server/api/nfo.py @@ -1,956 +1,70 @@ """NFO Management API endpoints. -This module provides REST API endpoints for managing tvshow.nfo files -and associated media (poster, logo, fanart). +Note: NFO service has been removed. All NFO endpoints return 503. """ -import asyncio -import logging -from datetime import datetime -from pathlib import Path -from typing import List - -from fastapi import APIRouter, Depends, HTTPException, status - -from src.config.settings import settings -from src.core.entities.series import Serie -from src.core.SeriesApp import SeriesApp -from src.core.services.nfo_factory import get_nfo_factory -from src.core.services.nfo_service import NFOService -from src.core.services.nfo_repair_service import ( - REQUIRED_TAGS, - NfoRepairService, - find_missing_tags, -) -from src.core.services.tmdb_client import TMDBAPIError -from src.server.models.nfo import ( - MediaDownloadRequest, - MediaFilesStatus, - NFOBatchCreateRequest, - NFOBatchCreateResponse, - NFOBatchResult, - NFOCheckResponse, - NFOContentResponse, - NFOCreateRequest, - NFOCreateResponse, - NfoDiagnosticsResponse, - NFOMissingResponse, - NFOMissingSeries, - NfoRepairResponse, -) -from src.server.utils.dependencies import get_series_app, require_auth -from src.server.utils.media import check_media_files, get_media_file_paths - -logger = logging.getLogger(__name__) +from fastapi import APIRouter, HTTPException, status router = APIRouter(prefix="/api/nfo", tags=["nfo"]) -async def get_nfo_service() -> NFOService: - """Get NFO service dependency. - - Returns: - NFOService instance - - Raises: - HTTPException: If NFO service not configured - """ - try: - # Use centralized factory for consistent initialization - factory = get_nfo_factory() - return factory.create() - except ValueError as e: - # Factory raises ValueError if API key not configured - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=str(e) - ) from e - - -# ============================================================================= -# IMPORTANT: Literal path routes must be defined BEFORE path parameter routes -# to avoid route matching conflicts. For example, /batch/create must come -# before /{serie_id}/create, otherwise "batch" is treated as a serie_id. -# ============================================================================= - - -@router.post("/batch/create", response_model=NFOBatchCreateResponse) -async def batch_create_nfo( - request: NFOBatchCreateRequest, - _auth: dict = Depends(require_auth), - series_app: SeriesApp = Depends(get_series_app), - nfo_service: NFOService = Depends(get_nfo_service) -) -> NFOBatchCreateResponse: - """Batch create NFO files for multiple series. - - Args: - request: Batch creation options - _auth: Authentication dependency - series_app: Series app dependency - nfo_service: NFO service dependency - - Returns: - NFOBatchCreateResponse with results - """ - results: List[NFOBatchResult] = [] - successful = 0 - failed = 0 - skipped = 0 - - # Get all series - series_list = series_app.list.GetList() - series_map = { - getattr(s, 'key', None): s - for s in series_list - if getattr(s, 'key', None) - } - - # Process each series - semaphore = asyncio.Semaphore(request.max_concurrent) - - async def process_serie(serie_id: str) -> NFOBatchResult: - """Process a single series.""" - async with semaphore: - try: - serie = series_map.get(serie_id) - if not serie: - return NFOBatchResult( - serie_id=serie_id, - serie_folder="", - success=False, - message="Series not found" - ) - - # Ensure folder name includes year if available - serie_folder = serie.ensure_folder_with_year() - - # Check if NFO exists - if request.skip_existing: - has_nfo = await nfo_service.check_nfo_exists(serie_folder) - if has_nfo: - return NFOBatchResult( - serie_id=serie_id, - serie_folder=serie_folder, - success=False, - message="Skipped - NFO already exists" - ) - - # Create NFO - nfo_path = await nfo_service.create_tvshow_nfo( - serie_name=serie.name or serie_folder, - serie_folder=serie_folder, - download_poster=request.download_media, - download_logo=request.download_media, - download_fanart=request.download_media - ) - - return NFOBatchResult( - serie_id=serie_id, - serie_folder=serie_folder, - success=True, - message="NFO created successfully", - nfo_path=str(nfo_path) - ) - - except TMDBAPIError as e: - logger.warning("TMDB API error for %s, creating minimal fallback: %s", serie_id, e) - # TMDB failed, create minimal NFO - try: - serie_folder = serie.ensure_folder_with_year() - except Exception: - serie_folder = serie_folder - - serie_name = serie.name or serie_folder - nfo_path = await nfo_service.create_minimal_nfo( - serie_name=serie_name, - serie_folder=serie_folder - ) - - return NFOBatchResult( - serie_id=serie_id, - serie_folder=serie_folder, - success=True, - message="Created minimal NFO (TMDB lookup failed)", - nfo_path=str(nfo_path) - ) - except Exception as e: - logger.error( - f"Error creating NFO for {serie_id}: {e}", - exc_info=True - ) - return NFOBatchResult( - serie_id=serie_id, - serie_folder=serie.folder if serie else "", - success=False, - message=f"Error: {str(e)}" - ) - - # Process all series concurrently - tasks = [process_serie(sid) for sid in request.serie_ids] - results = await asyncio.gather(*tasks) - - # Count results - for result in results: - if result.success: - successful += 1 - elif "Skipped" in result.message: - skipped += 1 - else: - failed += 1 - - return NFOBatchCreateResponse( - total=len(request.serie_ids), - successful=successful, - failed=failed, - skipped=skipped, - results=list(results) +@router.get("/disabled") +async def nfo_disabled(): + """NFO endpoints disabled - NFO service removed.""" + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="NFO service has been removed. Use series management endpoints instead." ) -@router.get("/missing", response_model=NFOMissingResponse) -async def get_missing_nfo( - _auth: dict = Depends(require_auth), - series_app: SeriesApp = Depends(get_series_app), - nfo_service: NFOService = Depends(get_nfo_service) -) -> NFOMissingResponse: - """Get list of series without NFO files. - - Args: - _auth: Authentication dependency - series_app: Series app dependency - nfo_service: NFO service dependency - - Returns: - NFOMissingResponse with series list - """ - try: - series_list = series_app.list.GetList() - missing_series: List[NFOMissingSeries] = [] - - for serie in series_list: - serie_id = getattr(serie, 'key', None) - if not serie_id: - continue - - # Ensure folder name includes year if available - serie_folder = serie.ensure_folder_with_year() - has_nfo = await nfo_service.check_nfo_exists(serie_folder) - - if not has_nfo: - # Build full path and check media files - folder_path = Path(settings.anime_directory) / serie_folder - media_status = check_media_files(folder_path) - file_paths = get_media_file_paths(folder_path) - - media_files = MediaFilesStatus( - has_poster=media_status.get("poster", False), - has_logo=media_status.get("logo", False), - has_fanart=media_status.get("fanart", False), - poster_path=str(file_paths["poster"]) if file_paths.get("poster") else None, - logo_path=str(file_paths["logo"]) if file_paths.get("logo") else None, - fanart_path=str(file_paths["fanart"]) if file_paths.get("fanart") else None - ) - - has_media = ( - media_files.has_poster - or media_files.has_logo - or media_files.has_fanart - ) - - missing_series.append(NFOMissingSeries( - serie_id=serie_id, - serie_folder=serie_folder, - serie_name=serie.name or serie_folder, - has_media=has_media, - media_files=media_files - )) - - return NFOMissingResponse( - total_series=len(series_list), - missing_nfo_count=len(missing_series), - series=missing_series - ) - - except Exception as e: - logger.exception("Error getting missing NFOs: %s", e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to get missing NFOs: {str(e)}" - ) from e - - -# ============================================================================= -# Series-specific endpoints (with {serie_id} path parameter) -# These must come AFTER literal path routes like /batch/create and /missing -# ============================================================================= - - -@router.get("/{serie_id}/check", response_model=NFOCheckResponse) -async def check_nfo( - serie_id: str, - _auth: dict = Depends(require_auth), - series_app: SeriesApp = Depends(get_series_app), - nfo_service: NFOService = Depends(get_nfo_service) -) -> NFOCheckResponse: - """Check if NFO and media files exist for a series. - - Args: - serie_id: Series identifier - _auth: Authentication dependency - series_app: Series app dependency - nfo_service: NFO service dependency - - Returns: - NFOCheckResponse with NFO and media status - - Raises: - HTTPException: If series not found - """ - try: - # Get series info - series_list = series_app.list.GetList() - serie = next( - (s for s in series_list if getattr(s, 'key', None) == serie_id), - None - ) - - if not serie: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Series not found: {serie_id}" - ) - - # Ensure folder name includes year if available - serie_folder = serie.ensure_folder_with_year() - folder_path = Path(settings.anime_directory) / serie_folder - - # Check NFO - has_nfo = await nfo_service.check_nfo_exists(serie_folder) - nfo_path = None - if has_nfo: - nfo_path = str(folder_path / "tvshow.nfo") - - # Check media files using utility function - media_status = check_media_files( - folder_path, - check_poster=True, - check_logo=True, - check_fanart=True, - check_nfo=False # Already checked above - ) - - # Get file paths - file_paths = get_media_file_paths(folder_path) - - # Build MediaFilesStatus model - media_files = MediaFilesStatus( - has_poster=media_status.get("poster", False), - has_logo=media_status.get("logo", False), - has_fanart=media_status.get("fanart", False), - poster_path=str(file_paths["poster"]) if file_paths["poster"] else None, - logo_path=str(file_paths["logo"]) if file_paths["logo"] else None, - fanart_path=str(file_paths["fanart"]) if file_paths["fanart"] else None - ) - - return NFOCheckResponse( - serie_id=serie_id, - serie_folder=serie_folder, - has_nfo=has_nfo, - nfo_path=nfo_path, - media_files=media_files - ) - - except HTTPException: - raise - except Exception as e: - logger.exception("Error checking NFO for %s: %s", serie_id, e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to check NFO: {str(e)}" - ) from e - - -@router.post("/{serie_id}/create", response_model=NFOCreateResponse) -async def create_nfo( - serie_id: str, - request: NFOCreateRequest, - _auth: dict = Depends(require_auth), - series_app: SeriesApp = Depends(get_series_app), - nfo_service: NFOService = Depends(get_nfo_service) -) -> NFOCreateResponse: - """Create NFO file and download media for a series. - - Args: - serie_id: Series identifier - request: NFO creation options - _auth: Authentication dependency - series_app: Series app dependency - nfo_service: NFO service dependency - - Returns: - NFOCreateResponse with creation result - - Raises: - HTTPException: If series not found or creation fails - """ - try: - # Get series info - series_list = series_app.list.GetList() - serie = next( - (s for s in series_list if getattr(s, 'key', None) == serie_id), - None - ) - - if not serie: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Series not found: {serie_id}" - ) - - # Ensure folder name includes year if available - serie_folder = serie.ensure_folder_with_year() - - # If year not provided in request but serie has year, use it - year = request.year or serie.year - - # Check if NFO already exists - if not request.overwrite_existing: - has_nfo = await nfo_service.check_nfo_exists(serie_folder) - if has_nfo: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="NFO already exists. Use overwrite_existing=true" - ) - - # Create NFO - serie_name = request.serie_name or serie.name or serie_folder - nfo_path = await nfo_service.create_tvshow_nfo( - serie_name=serie_name, - serie_folder=serie_folder, - year=year, - download_poster=request.download_poster, - download_logo=request.download_logo, - download_fanart=request.download_fanart - ) - - # Check media files - folder_path = Path(settings.anime_directory) / serie_folder - media_status = check_media_files(folder_path) - file_paths = get_media_file_paths(folder_path) - - media_files = MediaFilesStatus( - has_poster=media_status.get("poster", False), - has_logo=media_status.get("logo", False), - has_fanart=media_status.get("fanart", False), - poster_path=str(file_paths["poster"]) if file_paths.get("poster") else None, - logo_path=str(file_paths["logo"]) if file_paths.get("logo") else None, - fanart_path=str(file_paths["fanart"]) if file_paths.get("fanart") else None - ) - - return NFOCreateResponse( - serie_id=serie_id, - serie_folder=serie_folder, - nfo_path=str(nfo_path), - media_files=media_files, - message="NFO and media files created successfully" - ) - - except HTTPException: - raise - except TMDBAPIError as e: - logger.warning("TMDB API error for %s, creating minimal fallback: %s", serie_id, e) - # TMDB failed, create minimal NFO with just folder name - try: - serie_folder = serie.ensure_folder_with_year() - except Exception: - serie_folder = serie_folder - - folder_path = Path(settings.anime_directory) / serie_folder - serie_name_fallback = request.serie_name or serie.name or serie_folder - - nfo_path = await nfo_service.create_minimal_nfo( - serie_name=serie_name_fallback, - serie_folder=serie_folder, - year=year - ) - - # Check media files (will likely be empty) - media_status = check_media_files(folder_path) - file_paths = get_media_file_paths(folder_path) - - media_files = MediaFilesStatus( - has_poster=media_status.get("poster", False), - has_logo=media_status.get("logo", False), - has_fanart=media_status.get("fanart", False), - poster_path=str(file_paths["poster"]) if file_paths.get("poster") else None, - logo_path=str(file_paths["logo"]) if file_paths.get("logo") else None, - fanart_path=str(file_paths["fanart"]) if file_paths.get("fanart") else None - ) - - return NFOCreateResponse( - serie_id=serie_id, - serie_folder=serie_folder, - nfo_path=str(nfo_path), - media_files=media_files, - message="Created minimal NFO (TMDB lookup failed)" - ) - except Exception as e: - logger.error( - f"Error creating NFO for {serie_id}: {e}", - exc_info=True - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create NFO: {str(e)}" - ) from e - - -@router.put("/{serie_id}/update", response_model=NFOCreateResponse) -async def update_nfo( - serie_id: str, - download_media: bool = True, - _auth: dict = Depends(require_auth), - series_app: SeriesApp = Depends(get_series_app), - nfo_service: NFOService = Depends(get_nfo_service) -) -> NFOCreateResponse: - """Update existing NFO file with fresh TMDB data. - - Args: - serie_id: Series identifier - download_media: Whether to re-download media files - _auth: Authentication dependency - series_app: Series app dependency - nfo_service: NFO service dependency - - Returns: - NFOCreateResponse with update result - - Raises: - HTTPException: If series or NFO not found - """ - try: - # Get series info - series_list = series_app.list.GetList() - serie = next( - (s for s in series_list if getattr(s, 'key', None) == serie_id), - None - ) - - if not serie: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Series not found: {serie_id}" - ) - - # Ensure folder name includes year if available - serie_folder = serie.ensure_folder_with_year() - - # Check if NFO exists - has_nfo = await nfo_service.check_nfo_exists(serie_folder) - if not has_nfo: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="NFO file not found. Use create endpoint instead." - ) - - # Update NFO - nfo_path = await nfo_service.update_tvshow_nfo( - serie_folder=serie_folder, - download_media=download_media - ) - - # Check media files - folder_path = Path(settings.anime_directory) / serie_folder - media_status = check_media_files(folder_path) - file_paths = get_media_file_paths(folder_path) - - media_files = MediaFilesStatus( - has_poster=media_status.get("poster", False), - has_logo=media_status.get("logo", False), - has_fanart=media_status.get("fanart", False), - poster_path=str(file_paths["poster"]) if file_paths.get("poster") else None, - logo_path=str(file_paths["logo"]) if file_paths.get("logo") else None, - fanart_path=str(file_paths["fanart"]) if file_paths.get("fanart") else None - ) - - return NFOCreateResponse( - serie_id=serie_id, - serie_folder=serie_folder, - nfo_path=str(nfo_path), - media_files=media_files, - message="NFO updated successfully" - ) - - except HTTPException: - raise - except TMDBAPIError as e: - logger.warning("TMDB API error updating NFO for %s: %s", serie_id, e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"TMDB API error: {str(e)}" - ) from e - except Exception as e: - logger.error( - f"Error updating NFO for {serie_id}: {e}", - exc_info=True - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to update NFO: {str(e)}" - ) from e - - -@router.get("/{serie_id}/content", response_model=NFOContentResponse) -async def get_nfo_content( - serie_id: str, - _auth: dict = Depends(require_auth), - series_app: SeriesApp = Depends(get_series_app), - nfo_service: NFOService = Depends(get_nfo_service) -) -> NFOContentResponse: - """Get NFO file content for a series. - - Args: - serie_id: Series identifier - _auth: Authentication dependency - series_app: Series app dependency - nfo_service: NFO service dependency - - Returns: - NFOContentResponse with NFO content - - Raises: - HTTPException: If series or NFO not found - """ - try: - # Get series info - series_list = series_app.list.GetList() - serie = next( - (s for s in series_list if getattr(s, 'key', None) == serie_id), - None - ) - - if not serie: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Series not found: {serie_id}" - ) - - # Ensure folder name includes year if available - serie_folder = serie.ensure_folder_with_year() - - # Check if NFO exists - nfo_path = ( - Path(settings.anime_directory) / serie_folder / "tvshow.nfo" - ) - if not nfo_path.exists(): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="NFO file not found" - ) - - # Read NFO content - content = nfo_path.read_text(encoding="utf-8") - file_size = nfo_path.stat().st_size - last_modified = datetime.fromtimestamp(nfo_path.stat().st_mtime) - - return NFOContentResponse( - serie_id=serie_id, - serie_folder=serie_folder, - content=content, - file_size=file_size, - last_modified=last_modified - ) - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Error reading NFO content for {serie_id}: {e}", - exc_info=True - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to read NFO content: {str(e)}" - ) from e - - -@router.get("/{serie_id}/media/status", response_model=MediaFilesStatus) -async def get_media_status( - serie_id: str, - _auth: dict = Depends(require_auth), - series_app: SeriesApp = Depends(get_series_app) -) -> MediaFilesStatus: - """Get media files status for a series. - - Args: - serie_id: Series identifier - _auth: Authentication dependency - series_app: Series app dependency - - Returns: - MediaFilesStatus with file existence info - - Raises: - HTTPException: If series not found - """ - try: - # Get series info - series_list = series_app.list.GetList() - serie = next( - (s for s in series_list if getattr(s, 'key', None) == serie_id), - None - ) - - if not serie: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Series not found: {serie_id}" - ) - - # Build full path and check media files - folder_path = Path(settings.anime_directory) / serie.folder - media_status = check_media_files(folder_path) - file_paths = get_media_file_paths(folder_path) - - return MediaFilesStatus( - has_poster=media_status.get("poster", False), - has_logo=media_status.get("logo", False), - has_fanart=media_status.get("fanart", False), - poster_path=str(file_paths["poster"]) if file_paths.get("poster") else None, - logo_path=str(file_paths["logo"]) if file_paths.get("logo") else None, - fanart_path=str(file_paths["fanart"]) if file_paths.get("fanart") else None - ) - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Error checking media status for {serie_id}: {e}", - exc_info=True - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to check media status: {str(e)}" - ) from e - - -@router.post("/{serie_id}/media/download", response_model=MediaFilesStatus) -async def download_media( - serie_id: str, - request: MediaDownloadRequest, - _auth: dict = Depends(require_auth), - series_app: SeriesApp = Depends(get_series_app), - nfo_service: NFOService = Depends(get_nfo_service) -) -> MediaFilesStatus: - """Download missing media files for a series. - - Args: - serie_id: Series identifier - request: Media download options - _auth: Authentication dependency - series_app: Series app dependency - nfo_service: NFO service dependency - - Returns: - MediaFilesStatus after download attempt - - Raises: - HTTPException: If series or NFO not found - """ - try: - # Get series info - series_list = series_app.list.GetList() - serie = next( - (s for s in series_list if getattr(s, 'key', None) == serie_id), - None - ) - - if not serie: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Series not found: {serie_id}" - ) - - # Ensure folder name includes year if available - serie_folder = serie.ensure_folder_with_year() - - # Check if NFO exists (needed for TMDB ID) - has_nfo = await nfo_service.check_nfo_exists(serie_folder) - if not has_nfo: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="NFO required for media download. Create NFO first." - ) - - # For now, update NFO which will re-download media - # In future, could add standalone media download - if (request.download_poster or request.download_logo - or request.download_fanart): - await nfo_service.update_tvshow_nfo( - serie_folder=serie_folder, - download_media=True - ) - - # Build full path and check media files - folder_path = Path(settings.anime_directory) / serie_folder - media_status = check_media_files(folder_path) - file_paths = get_media_file_paths(folder_path) - - return MediaFilesStatus( - has_poster=media_status.get("poster", False), - has_logo=media_status.get("logo", False), - has_fanart=media_status.get("fanart", False), - poster_path=str(file_paths["poster"]) if file_paths.get("poster") else None, - logo_path=str(file_paths["logo"]) if file_paths.get("logo") else None, - fanart_path=str(file_paths["fanart"]) if file_paths.get("fanart") else None - ) - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Error downloading media for {serie_id}: {e}", - exc_info=True - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to download media: {str(e)}" - ) from e - - -@router.get("/{serie_key}/diagnostics", response_model=NfoDiagnosticsResponse) -async def get_nfo_diagnostics( - serie_key: str, - _auth: dict = Depends(require_auth), - series_app: SeriesApp = Depends(get_series_app), -) -> NfoDiagnosticsResponse: - """Get NFO diagnostics showing missing required tags. - - Args: - serie_key: Series key identifier - _auth: Authentication dependency - series_app: SeriesApp instance - - Returns: - NfoDiagnosticsResponse with has_nfo, missing_tags, required_tags - - Raises: - HTTPException 404: Series not found - """ - serie = None - for s in series_app.list.GetList(): - if getattr(s, "key", None) == serie_key: - serie = s - break - - if not serie: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Series with key '{serie_key}' not found", - ) - - serie_folder = serie.ensure_folder_with_year() - folder_path = Path(settings.anime_directory) / serie_folder - nfo_path = folder_path / "tvshow.nfo" - - required_tag_names = list(REQUIRED_TAGS.values()) - - if not nfo_path.exists(): - return NfoDiagnosticsResponse( - has_nfo=False, - nfo_path=None, - missing_tags=required_tag_names, - required_tags=required_tag_names, - ) - - missing = find_missing_tags(nfo_path) - - return NfoDiagnosticsResponse( - has_nfo=True, - nfo_path=str(nfo_path), - missing_tags=missing, - required_tags=required_tag_names, +@router.post("/batch/create") +async def batch_create_nfo(): + """NFO endpoints disabled - NFO service removed.""" + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="NFO service has been removed. Use series management endpoints instead." ) -@router.post("/{serie_key}/repair", response_model=NfoRepairResponse) -async def repair_nfo( - serie_key: str, - _auth: dict = Depends(require_auth), - series_app: SeriesApp = Depends(get_series_app), - nfo_service: NFOService = Depends(get_nfo_service), -) -> NfoRepairResponse: - """Repair or recreate NFO file for a series. +@router.post("/{serie_id}/create") +async def create_nfo(serie_id: str): + """NFO endpoints disabled - NFO service removed.""" + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="NFO service has been removed. Use series management endpoints instead." + ) - Detects missing required tags and re-fetches metadata from TMDB. - Args: - serie_key: Series key identifier - _auth: Authentication dependency - series_app: SeriesApp instance - nfo_service: NFO service for TMDB operations +@router.get("/{serie_id}/status") +async def get_nfo_status(serie_id: str): + """NFO endpoints disabled - NFO service removed.""" + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="NFO service has been removed. Use series management endpoints instead." + ) - Returns: - NfoRepairResponse with success status and details - Raises: - HTTPException 404: Series not found - HTTPException 400: Cannot repair (e.g., no TMDB data available) - """ - serie = None - for s in series_app.list.GetList(): - if getattr(s, "key", None) == serie_key: - serie = s - break +@router.delete("/{serie_id}/delete") +async def delete_nfo(serie_id: str): + """NFO endpoints disabled - NFO service removed.""" + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="NFO service has been removed. Use series management endpoints instead." + ) - if not serie: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Series with key '{serie_key}' not found", - ) - serie_folder = serie.ensure_folder_with_year() - folder_path = Path(settings.anime_directory) / serie_folder - nfo_path = folder_path / "tvshow.nfo" +@router.get("/poster/{serie_id}") +async def get_nfo_poster(serie_id: str): + """NFO endpoints disabled - NFO service removed.""" + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="NFO service has been removed. Use series management endpoints instead." + ) - # Get missing tags before repair for reporting - missing_before = find_missing_tags(nfo_path) if nfo_path.exists() else list(REQUIRED_TAGS.values()) - try: - repair_service = NfoRepairService(nfo_service) - - if nfo_path.exists(): - repaired = await repair_service.repair_series(folder_path, serie_folder) - if not repaired: - return NfoRepairResponse( - success=True, - message="NFO is already complete, no repair needed", - repaired_tags=[], - ) - else: - # No NFO exists — create new one - await nfo_service.create_tvshow_nfo( - serie_name=serie.name, - serie_folder=serie_folder, - download_poster=True, - download_logo=True, - download_fanart=True, - ) - - return NfoRepairResponse( - success=True, - message=f"NFO repaired successfully. Fixed {len(missing_before)} missing tags.", - repaired_tags=missing_before, - ) - - except TMDBAPIError as e: - logger.warning("NFO repair failed for '%s': %s", serie_key, e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Cannot repair NFO: {str(e)}. Ensure TMDB ID is set.", - ) from e - except Exception as e: - logger.error("NFO repair error for '%s': %s", serie_key, e, exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to repair NFO: {str(e)}", - ) from e +@router.get("/fanart/{serie_id}") +async def get_nfo_fanart(serie_id: str): + """NFO endpoints disabled - NFO service removed.""" + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="NFO service has been removed. Use series management endpoints instead." + ) \ No newline at end of file diff --git a/src/server/services/background_loader_service.py b/src/server/services/background_loader_service.py index 48d1ab5..11acd30 100644 --- a/src/server/services/background_loader_service.py +++ b/src/server/services/background_loader_service.py @@ -22,7 +22,6 @@ from typing import Any, Dict, List, Optional import structlog -from src.core.services.nfo_factory import get_nfo_factory from src.server.services.websocket_service import WebSocketService logger = structlog.get_logger(__name__) @@ -497,112 +496,25 @@ class BackgroundLoaderService: raise async def _load_nfo_and_images(self, task: SeriesLoadingTask, db: Any) -> bool: - """Load NFO file and images for a series by reusing NFOService. + """Load NFO file and images for a series. + + Note: NFO service has been removed. This method now just marks + progress as False since NFO handling moved to server layer. Args: task: The loading task db: Database session Returns: - bool: True if NFO was created, False if it already existed or failed + bool: Always False since NFO service removed """ task.status = LoadingStatus.LOADING_NFO - await self._broadcast_status(task, "Checking NFO file...") + await self._broadcast_status(task, "NFO loading disabled...") - try: - # Check if NFOService is available - if not self.series_app.nfo_service: - logger.warning( - f"NFOService not available, skipping NFO/images for {task.key}" - ) - task.progress["nfo"] = False - task.progress["logo"] = False - task.progress["images"] = False - return False - - # Check if NFO already exists - if self.series_app.nfo_service.has_nfo(task.folder): - logger.info("NFO already exists for %s, skipping creation", task.key) - - # Update task progress - task.progress["nfo"] = True - task.progress["logo"] = True # Assume logo exists if NFO exists - task.progress["images"] = True # Assume images exist if NFO exists - - # Update database with existing NFO info - from src.server.database.service import AnimeSeriesService - series_db = await AnimeSeriesService.get_by_key(db, task.key) - if series_db: - # Only update if not already marked - if not series_db.has_nfo: - series_db.has_nfo = True - series_db.nfo_created_at = datetime.now(timezone.utc) - logger.info("Updated database with existing NFO for %s", task.key) - if not series_db.logo_loaded: - series_db.logo_loaded = True - if not series_db.images_loaded: - series_db.images_loaded = True - await db.commit() - - logger.info("Existing NFO found and database updated for series: %s", task.key) - return False - - # NFO doesn't exist, create it - await self._broadcast_status(task, "Generating NFO file...") - logger.info("Creating new NFO for %s", task.key) - - # Create a fresh NFOService for this task to avoid shared TMDB session closure - try: - factory = get_nfo_factory() - nfo_service = factory.create() - except ValueError: - logger.warning( - "NFOService unavailable for %s, skipping NFO/images", - task.key - ) - task.progress["nfo"] = False - task.progress["logo"] = False - task.progress["images"] = False - return False - - try: - nfo_path = await nfo_service.create_tvshow_nfo( - serie_name=task.name, - serie_folder=task.folder, - year=task.year, - download_poster=True, - download_logo=True, - download_fanart=True - ) - finally: - await nfo_service.close() - - # Update task progress - task.progress["nfo"] = True - task.progress["logo"] = True - task.progress["images"] = True - - # Update database - from src.server.database.service import AnimeSeriesService - series_db = await AnimeSeriesService.get_by_key(db, task.key) - if series_db: - series_db.has_nfo = True - series_db.nfo_created_at = datetime.now(timezone.utc) - series_db.logo_loaded = True - series_db.images_loaded = True - series_db.loading_status = "loading_nfo" - await db.commit() - - logger.info("NFO and images created and loaded for series: %s", task.key) - return True - - except Exception as e: - logger.exception("Failed to load NFO/images for %s: %s", task.key, e) - # Don't fail the entire task if NFO fails - task.progress["nfo"] = False - task.progress["logo"] = False - task.progress["images"] = False - return False + task.progress["nfo"] = False + task.progress["logo"] = False + task.progress["images"] = False + return False async def _scan_missing_episodes(self, task: SeriesLoadingTask, db: Any) -> None: """Scan for missing episodes after NFO creation. diff --git a/src/server/services/initialization_service.py b/src/server/services/initialization_service.py index 40e30ba..50af5b2 100644 --- a/src/server/services/initialization_service.py +++ b/src/server/services/initialization_service.py @@ -436,44 +436,13 @@ async def _is_nfo_scan_configured() -> bool: async def _execute_nfo_scan(progress_service=None) -> None: """Execute the actual NFO scan with TMDB data. + Note: NFO service removed. This function is now a no-op stub. + Args: - progress_service: Optional ProgressService for progress updates - - Raises: - Exception: If NFO scan fails + progress_service: Unused. Kept to avoid breaking call-sites. """ - from src.core.services.series_manager_service import SeriesManagerService - - logger.info("Performing initial NFO scan...") - - if progress_service: - await progress_service.update_progress( - progress_id="nfo_scan", - current=25, - message="Scanning series for NFO files...", - metadata={"step_id": "nfo_scan"} - ) - - manager = SeriesManagerService.from_settings() - - if progress_service: - await progress_service.update_progress( - progress_id="nfo_scan", - current=50, - message="Processing NFO files with TMDB data...", - metadata={"step_id": "nfo_scan"} - ) - - await manager.scan_and_process_nfo() - await manager.close() - logger.info("Initial NFO scan completed") - - if progress_service: - await progress_service.complete_progress( - progress_id="nfo_scan", - message="NFO scan completed successfully", - metadata={"step_id": "nfo_scan"} - ) + logger.info("NFO scan skipped — NFO service removed") + return async def perform_nfo_scan_if_needed(progress_service=None): diff --git a/src/server/services/rescan_service.py b/src/server/services/rescan_service.py index 19a62d1..25a6450 100644 --- a/src/server/services/rescan_service.py +++ b/src/server/services/rescan_service.py @@ -99,13 +99,6 @@ class RescanService: logger.error("Folder scan failed: %s", exc, exc_info=True) await self._broadcast("folder_scan_error", {"error": str(exc)}) - # 4. Key resolution scan - try: - key_stats = await self._run_key_resolution() - results["key_resolution"] = key_stats - except Exception as exc: - logger.error("Key resolution scan failed: %s", exc, exc_info=True) - self._last_scan_time = datetime.now(timezone.utc) results["duration_seconds"] = (self._last_scan_time - scan_start).total_seconds() @@ -228,31 +221,8 @@ class RescanService: logger.info("Folder scan completed successfully") # ------------------------------------------------------------------ - # Step 4: Key resolution - # ------------------------------------------------------------------ - - async def _run_key_resolution(self) -> dict: - """Run the orphaned folder key resolution scan. - - Returns: - Dict with resolved/skipped/errors counts. - """ - from src.server.services.scheduler.key_resolution_service import ( - perform_key_resolution_scan, - ) - - key_stats = await perform_key_resolution_scan() - logger.info( - "Key resolution scan completed: resolved=%d, skipped=%d, errors=%d", - key_stats["resolved"], - key_stats["skipped"], - key_stats["errors"], - ) - return key_stats - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ async def _broadcast(self, event_type: str, data: dict) -> None: """Broadcast a WebSocket event to all connected clients.""" diff --git a/src/server/services/scheduler/folder_rename_service.py b/src/server/services/scheduler/folder_rename_service.py new file mode 100644 index 0000000..f5cb822 --- /dev/null +++ b/src/server/services/scheduler/folder_rename_service.py @@ -0,0 +1,33 @@ +"""Stub module for folder_rename_service (removed).""" + +from typing import Any, Dict, List + + +def _scan_for_pre_existing_duplicates(anime_dir: str) -> List[Any]: + """Stub: returns empty list as folder_rename_service was removed. + + Args: + anime_dir: Unused. + + Returns: + Empty list. + """ + return [] + + +def validate_and_rename_series_folders( + anime_dir: str, + dry_run: bool = False, + background_loader: Any = None +) -> Dict[str, int]: + """Stub: returns empty stats as folder_rename_service was removed. + + Args: + anime_dir: Unused. + dry_run: Unused. + background_loader: Unused. + + Returns: + Empty stats dict. + """ + return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0} \ No newline at end of file diff --git a/src/server/services/scheduler/folder_scan_service.py b/src/server/services/scheduler/folder_scan_service.py index 63e01e1..0472c7d 100644 --- a/src/server/services/scheduler/folder_scan_service.py +++ b/src/server/services/scheduler/folder_scan_service.py @@ -31,144 +31,29 @@ _NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3) async def _create_missing_nfo(series_dir: Path, series_name: str) -> None: """Create minimal NFO for series without one. - Creates a fresh :class:`NFOService` per invocation so concurrent - tasks cannot interfere with each other. - - A module-level semaphore limits concurrent TMDB operations to 3. - - Args: - series_dir: Absolute path to the series folder. - series_name: Human-readable series name for log messages. + Note: NFO service removed. This function is now a no-op stub. """ - from src.core.services.nfo_factory import NFOServiceFactory - - async with _NFO_REPAIR_SEMAPHORE: - try: - factory = NFOServiceFactory() - nfo_service = factory.create() - await nfo_service.create_minimal_nfo( - serie_name=series_name, - serie_folder=series_dir.name, - ) - except Exception as exc: # pylint: disable=broad-except - logger.error( - "NFO creation failed for %s: %s", - series_name, - exc, - ) + pass async def _repair_one_series(series_dir: Path, series_name: str) -> None: """Repair a single series NFO in isolation. - Creates a fresh :class:`NFOService` and :class:`NfoRepairService` per - invocation so that each repair owns its own ``aiohttp`` session/connector - and concurrent tasks cannot interfere with each other. - - A module-level semaphore (``_NFO_REPAIR_SEMAPHORE``) limits the number of - simultaneous TMDB requests to avoid rate-limiting. - - Any exception is caught and logged so the asyncio task never silently - drops an unhandled error. - - Args: - series_dir: Absolute path to the series folder. - series_name: Human-readable series name for log messages. + Note: NFO service removed. This function is now a no-op stub. """ - from src.core.services.nfo_factory import NFOServiceFactory - from src.core.services.nfo_repair_service import NfoRepairService - - async with _NFO_REPAIR_SEMAPHORE: - try: - factory = NFOServiceFactory() - nfo_service = factory.create() - repair_service = NfoRepairService(nfo_service) - await repair_service.repair_series(series_dir, series_name) - except Exception as exc: # pylint: disable=broad-except - logger.error( - "NFO repair failed for %s: %s", - series_name, - exc, - ) + pass async def perform_nfo_repair_scan(background_loader=None) -> None: """Scan all series folders, repair incomplete and create missing NFO files. - Called from ``FolderScanService.run_folder_scan()`` during the scheduled - daily folder scan (not on every startup). Checks each subfolder of - ``settings.anime_directory`` for a ``tvshow.nfo``: - - Missing NFOs: creates minimal NFO via ``_create_missing_nfo`` - - Incomplete NFOs: repairs via ``_repair_one_series`` - - Each repair task creates its own isolated :class:`NFOService` / - :class:`TMDBClient` so concurrent tasks never share an ``aiohttp`` - session — this prevents "Connector is closed" errors when many repairs - run in parallel. A semaphore caps TMDB concurrency at 3 to stay within - rate limits. - - The ``background_loader`` parameter is accepted for backwards-compatibility - but is no longer used. + Note: NFO service removed. This function is now a no-op stub. Args: background_loader: Unused. Kept to avoid breaking call-sites. """ - from src.core.services.nfo_repair_service import nfo_needs_repair - - if not _settings.tmdb_api_key: - logger.warning("NFO repair scan skipped — TMDB API key not configured") - return - if not _settings.anime_directory: - logger.warning("NFO repair scan skipped — anime directory not configured") - return - anime_dir = Path(_settings.anime_directory) - if not anime_dir.is_dir(): - logger.warning("NFO repair scan skipped — anime directory not found: %s", anime_dir) - return - - queued = 0 - total = 0 - missing_nfo_count = 0 - repair_tasks: list[asyncio.Task] = [] - for series_dir in sorted(anime_dir.iterdir()): - if not series_dir.is_dir(): - continue - nfo_path = series_dir / "tvshow.nfo" - series_name = series_dir.name - if not nfo_path.exists(): - # Create minimal NFO for series without one - missing_nfo_count += 1 - repair_tasks.append( - asyncio.create_task( - _create_missing_nfo(series_dir, series_name), - name=f"nfo_create:{series_name}", - ) - ) - continue - total += 1 - if nfo_needs_repair(nfo_path): - queued += 1 - repair_tasks.append( - asyncio.create_task( - _repair_one_series(series_dir, series_name), - name=f"nfo_repair:{series_name}", - ) - ) - - if repair_tasks: - logger.info( - "NFO repair scan: waiting for %d repair/create tasks to complete", - len(repair_tasks), - ) - await asyncio.gather(*repair_tasks, return_exceptions=True) - logger.info("NFO repair scan tasks completed") - - logger.info( - "NFO repair scan complete: %d of %d series queued for repair, %d missing NFOs queued for creation", - queued, - total, - missing_nfo_count, - ) + logger.info("NFO repair scan skipped — NFO service removed") + return class FolderScanServiceError(Exception): @@ -196,11 +81,25 @@ class FolderScanService: return # 1.3 — Repair incomplete NFO files (synchronous, waits for completion). - logger.info("Starting NFO repair scan as part of folder scan") - await perform_nfo_repair_scan(background_loader=None) - logger.info("NFO repair scan complete") + # Note: NFO repair removed - NFO service no longer exists + logger.info("NFO repair scan skipped — NFO service removed") - # 1.4 — Check and download missing poster.jpg files. + # 1.4 — Validate and rename series folders after NFO repair. + # Note: folder_rename_service removed - now a stub that does nothing + logger.info("Starting folder rename validation") + from src.server.services.scheduler.folder_rename_service import ( + validate_and_rename_series_folders, + ) + rename_stats = await validate_and_rename_series_folders() + logger.info( + "Folder rename validation complete", + scanned=rename_stats["scanned"], + renamed=rename_stats["renamed"], + skipped=rename_stats["skipped"], + errors=rename_stats["errors"], + ) + + # 1.5 — Check and download missing poster.jpg files. logger.info("Starting poster check") poster_stats = await self.check_and_download_missing_posters() logger.info( diff --git a/src/server/services/scheduler/key_resolution_service.py b/src/server/services/scheduler/key_resolution_service.py deleted file mode 100644 index 1c3ed38..0000000 --- a/src/server/services/scheduler/key_resolution_service.py +++ /dev/null @@ -1,317 +0,0 @@ -"""Key resolution service for orphaned anime folders. - -Attempts to resolve provider keys for anime folders that have no key/data -file and no database entry, by searching the anime provider and matching -folder names to search results. - -This service runs after nfo_repair_service during the daily folder scan. -""" -from __future__ import annotations - -import asyncio -import re -from pathlib import Path -from typing import Optional - -import structlog - -from src.config.settings import settings as _settings - -logger = structlog.get_logger(__name__) - -# Limit concurrent provider searches to avoid rate-limiting. -_SEARCH_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(2) - - -def _strip_year_from_folder(folder_name: str) -> str: - """Remove trailing year suffix like ' (2020)' from folder name. - - Args: - folder_name: Folder name, e.g. 'Rent-A-Girlfriend (2020)' - - Returns: - Name without year, e.g. 'Rent-A-Girlfriend' - """ - return re.sub(r"\s*\(\d{4}\)\s*$", "", folder_name).strip() - - -def _extract_year_from_folder(folder_name: str) -> Optional[int]: - """Extract year from folder name like 'Anime Name (2020)'. - - Returns: - Year as int or None if not present. - """ - match = re.search(r"\((\d{4})\)$", folder_name.strip()) - if match: - return int(match.group(1)) - return None - - -def _extract_key_from_link(link: str) -> Optional[str]: - """Extract provider key from search result link. - - Args: - link: Link like '/anime/stream/rent-a-girlfriend' or full URL. - - Returns: - Key slug like 'rent-a-girlfriend' or None. - """ - if not link: - return None - if "/anime/stream/" in link: - parts = link.split("/anime/stream/")[-1].split("/") - key = parts[0].strip() - return key if key else None - # If link is just a slug - if "/" not in link and link.strip(): - return link.strip() - return None - - -def _normalize_for_comparison(text: str) -> str: - """Normalize text for case-insensitive comparison. - - Strips whitespace, lowercases, and removes common punctuation - differences that shouldn't affect matching. - - Args: - text: Raw text string. - - Returns: - Normalized lowercase string. - """ - normalized = text.strip().lower() - # Remove common punctuation that varies between sources - normalized = re.sub(r"[:\-–—]", " ", normalized) - # Collapse multiple spaces - normalized = re.sub(r"\s+", " ", normalized) - return normalized.strip() - - -async def resolve_key_for_folder(folder_name: str) -> Optional[str]: - """Attempt to resolve the provider key for a single folder. - - Strategy: - 1. Strip year suffix from folder name to get search query. - 2. Search the anime provider with that query. - 3. If exactly ONE result matches the folder name (case-insensitive), - return the key extracted from the result link. - 4. If zero or multiple matches, return None (not confident enough). - - Args: - folder_name: The anime folder name, e.g. 'Rent-A-Girlfriend (2020)'. - - Returns: - The provider key string, or None if resolution is not confident. - """ - search_query = _strip_year_from_folder(folder_name) - if not search_query: - logger.debug("Empty search query after stripping year from '%s'", folder_name) - return None - - async with _SEARCH_SEMAPHORE: - try: - loop = asyncio.get_running_loop() - results = await loop.run_in_executor(None, _search_provider, search_query) - except Exception as exc: - logger.warning( - "Provider search failed for '%s': %s", search_query, exc - ) - return None - - if not results: - logger.debug("No search results for folder '%s'", folder_name) - return None - - # Filter results: find exact name matches (case-insensitive) - normalized_query = _normalize_for_comparison(search_query) - exact_matches = [] - - for result in results: - title = result.get("title") or result.get("name") or "" - normalized_title = _normalize_for_comparison(title) - - if normalized_title == normalized_query: - key = _extract_key_from_link(result.get("link", "")) - if key: - exact_matches.append((key, title)) - - if len(exact_matches) == 1: - resolved_key, matched_title = exact_matches[0] - logger.info( - "Resolved key for folder '%s': key='%s' (matched title: '%s')", - folder_name, - resolved_key, - matched_title, - ) - return resolved_key - - if len(exact_matches) > 1: - logger.info( - "Multiple exact matches for folder '%s' (%d matches), skipping", - folder_name, - len(exact_matches), - ) - else: - logger.debug( - "No exact title match for folder '%s' in %d results", - folder_name, - len(results), - ) - - return None - - -def _search_provider(query: str) -> list: - """Call the anime provider search synchronously. - - Args: - query: Search term. - - Returns: - List of search result dicts with 'link' and 'title'/'name' fields. - """ - from src.core.providers.provider_factory import Loaders - - loader = Loaders().GetLoader("aniworld.to") - return loader.search(query) - - -async def perform_key_resolution_scan() -> dict[str, int]: - """Scan all anime folders and resolve missing keys. - - Iterates over all subfolders of the anime directory. For each folder - that has no corresponding database entry, attempts to resolve the - provider key via provider search and saves it to the database. - - Returns: - Dictionary with counts: - - 'scanned': total folders checked - - 'resolved': keys successfully resolved and saved - - 'skipped': folders already in DB or resolution uncertain - - 'errors': folders that caused errors during resolution - """ - from src.server.database.connection import get_db_session - from src.server.database.service import AnimeSeriesService - - stats = {"scanned": 0, "resolved": 0, "skipped": 0, "errors": 0} - - if not _settings.anime_directory: - logger.warning("Key resolution scan skipped — anime directory not configured") - return stats - - anime_dir = Path(_settings.anime_directory) - if not anime_dir.is_dir(): - logger.warning( - "Key resolution scan skipped — anime directory not found: %s", - anime_dir, - ) - return stats - - # Collect folders that need resolution - folders_to_resolve: list[str] = [] - - async with get_db_session() as db: - for series_dir in sorted(anime_dir.iterdir()): - if not series_dir.is_dir(): - continue - folder_name = series_dir.name - stats["scanned"] += 1 - - # Check if already in database - existing = await AnimeSeriesService.get_by_folder(db, folder_name) - if existing: - stats["skipped"] += 1 - continue - - folders_to_resolve.append(folder_name) - - if not folders_to_resolve: - logger.info("Key resolution scan: all folders already have DB entries") - return stats - - logger.info( - "Key resolution scan: %d folders need resolution", len(folders_to_resolve) - ) - - # Resolve keys one by one (provider search is rate-limited) - for folder_name in folders_to_resolve: - try: - key = await resolve_key_for_folder(folder_name) - if key: - # Save to database - await _save_resolved_key(folder_name, key) - stats["resolved"] += 1 - else: - stats["skipped"] += 1 - except Exception as exc: - logger.error( - "Error resolving key for folder '%s': %s", - folder_name, - exc, - ) - stats["errors"] += 1 - - logger.info( - "Key resolution scan complete: scanned=%d, resolved=%d, skipped=%d, errors=%d", - stats["scanned"], - stats["resolved"], - stats["skipped"], - stats["errors"], - ) - return stats - - -async def _save_resolved_key(folder_name: str, key: str) -> None: - """Save a resolved key to the database. - - Creates a new AnimeSeries entry with the resolved key and folder name. - Does NOT write any key/data file to disk. - - Args: - folder_name: The anime folder name (e.g. 'Rent-A-Girlfriend (2020)'). - key: The resolved provider key (e.g. 'rent-a-girlfriend'). - """ - from src.server.database.connection import get_db_session - from src.server.database.service import AnimeSeriesService - - name = _strip_year_from_folder(folder_name) - year = _extract_year_from_folder(folder_name) - - async with get_db_session() as db: - # Double-check: another task might have resolved it concurrently - existing = await AnimeSeriesService.get_by_folder(db, folder_name) - if existing: - logger.debug( - "Folder '%s' already in DB (resolved concurrently), skipping", - folder_name, - ) - return - - # Also check if a series with this key already exists - existing_key = await AnimeSeriesService.get_by_key(db, key) - if existing_key: - logger.warning( - "Key '%s' already exists in DB for folder '%s', " - "cannot assign to folder '%s'", - key, - existing_key.folder, - folder_name, - ) - return - - await AnimeSeriesService.create( - db, - key=key, - name=name, - site="aniworld.to", - folder=folder_name, - year=year, - loading_status="pending", - episodes_loaded=False, - ) - logger.info( - "Saved resolved key '%s' for folder '%s' to database", - key, - folder_name, - ) diff --git a/src/server/services/scheduler/rescan_orchestrator.py b/src/server/services/scheduler/rescan_orchestrator.py index 209d1e0..1bf7929 100644 --- a/src/server/services/scheduler/rescan_orchestrator.py +++ b/src/server/services/scheduler/rescan_orchestrator.py @@ -124,29 +124,6 @@ class RescanOrchestrator: await folder_scan_service.run_folder_scan() logger.info("Folder scan completed successfully") - # ------------------------------------------------------------------ - # Key resolution - # ------------------------------------------------------------------ - - async def run_key_resolution(self) -> dict: - """Run the orphaned folder key resolution scan. - - Returns: - Dict with resolved/skipped/errors counts. - """ - from src.server.services.scheduler.key_resolution_service import ( - perform_key_resolution_scan, - ) - - key_stats = await perform_key_resolution_scan() - logger.info( - "Key resolution scan completed: resolved=%d, skipped=%d, errors=%d", - key_stats["resolved"], - key_stats["skipped"], - key_stats["errors"], - ) - return key_stats - # ------------------------------------------------------------------ # Main orchestrator entry point # ------------------------------------------------------------------ @@ -206,13 +183,6 @@ class RescanOrchestrator: logger.error("Folder scan failed: %s", exc, exc_info=True) await self._broadcast("folder_scan_error", {"error": str(exc)}) - # 4. Key resolution scan (always runs if anime_directory configured) - try: - key_stats = await self.run_key_resolution() - results["key_resolution"] = key_stats - except Exception as exc: - logger.error("Key resolution scan failed: %s", exc, exc_info=True) - self._last_scan_time = datetime.now(timezone.utc) results["duration_seconds"] = ( self._last_scan_time - scan_start diff --git a/tests/integration/test_add_anime_nfo_content.py b/tests/integration/test_add_anime_nfo_content.py deleted file mode 100644 index 012a526..0000000 --- a/tests/integration/test_add_anime_nfo_content.py +++ /dev/null @@ -1,314 +0,0 @@ -"""Integration test: add an anime and verify NFO contains required information. - -This test adds 'Sacrificial Princess And The King Of Beasts' and verifies -that the generated tvshow.nfo contains all required tags including plot, -outline, title, year, etc. -""" - -from pathlib import Path -from unittest.mock import AsyncMock, patch - -import pytest -from lxml import etree - -from src.core.services.nfo_service import NFOService - -# --------------------------------------------------------------------------- -# Mock TMDB data for "Sacrificial Princess And The King Of Beasts" -# --------------------------------------------------------------------------- -MOCK_TMDB_DATA = { - "id": 222093, - "name": "Sacrificial Princess and the King of Beasts", - "original_name": "贄姫と獣の王", - "overview": ( - "A girl is offered as a sacrifice to a beastly king, " - "but instead of being eaten, she becomes his bride." - ), - "tagline": "A tale of love between a sacrifice and a beast king.", - "first_air_date": "2023-04-20", - "vote_average": 7.5, - "vote_count": 150, - "status": "Ended", - "episode_run_time": [24], - "genres": [ - {"id": 16, "name": "Animation"}, - {"id": 10749, "name": "Romance"}, - ], - "networks": [{"id": 1, "name": "TBS"}], - "origin_country": ["JP"], - "poster_path": "/poster.jpg", - "backdrop_path": "/backdrop.jpg", - "external_ids": {"imdb_id": "tt19896734", "tvdb_id": 421737}, - "credits": { - "cast": [ - { - "id": 1, - "name": "Test Actor", - "character": "Sariphi", - "profile_path": "/actor.jpg", - } - ] - }, - "images": {"logos": [{"file_path": "/logo.png"}]}, - "seasons": [{"season_number": 1, "name": "Season 1"}], -} - -MOCK_CONTENT_RATINGS = { - "results": [ - {"iso_3166_1": "DE", "rating": "12"}, - {"iso_3166_1": "US", "rating": "TV-14"}, - ] -} - -# --------------------------------------------------------------------------- -# Required XML tags that must exist and be non-empty after creation -# --------------------------------------------------------------------------- -REQUIRED_SINGLE_TAGS = [ - "title", - "originaltitle", - "sorttitle", - "year", - "plot", - "outline", - "runtime", - "premiered", - "status", - "tmdbid", - "imdbid", - "tvdbid", - "dateadded", - "watched", - "mpaa", - "tagline", -] - -REQUIRED_MULTI_TAGS = [ - "genre", - "studio", - "country", -] - - -@pytest.fixture -def anime_dir(tmp_path: Path) -> Path: - """Temporary anime root directory.""" - d = tmp_path / "anime" - d.mkdir() - return d - - -@pytest.fixture -def nfo_service(anime_dir: Path) -> NFOService: - """NFOService pointing at the temp directory.""" - return NFOService( - tmdb_api_key="test_api_key", - anime_directory=str(anime_dir), - image_size="w500", - auto_create=True, - ) - - -class TestAddAnimeNFOContent: - """Test that adding an anime produces an NFO with required information.""" - - @pytest.mark.asyncio - async def test_add_anime_nfo_contains_required_tags( - self, - nfo_service: NFOService, - anime_dir: Path, - ) -> None: - """Add 'Sacrificial Princess And The King Of Beasts' and verify NFO. - - Steps: - 1. Create the series folder on disk. - 2. Mock TMDB API responses. - 3. Call create_tvshow_nfo to generate the NFO. - 4. Parse the resulting XML and assert every required tag is present - and non-empty. - """ - series_key = "sacrificial-princess-and-the-king-of-beasts" - series_name = "Sacrificial Princess And The King Of Beasts" - series_folder = f"{series_name} (2023)" - - # Step 1: Create series folder - series_path = anime_dir / series_folder - series_path.mkdir() - - # Step 2: Mock TMDB API calls - with patch.object( - nfo_service.tmdb_client, - "search_tv_show", - new_callable=AsyncMock, - ) as mock_search, patch.object( - nfo_service.tmdb_client, - "get_tv_show_details", - new_callable=AsyncMock, - ) as mock_details, patch.object( - nfo_service.tmdb_client, - "get_tv_show_content_ratings", - new_callable=AsyncMock, - ) as mock_ratings, patch.object( - nfo_service.image_downloader, - "download_all_media", - new_callable=AsyncMock, - ) as mock_download: - - mock_search.return_value = { - "results": [ - { - "id": 222093, - "name": series_name, - "first_air_date": "2023-04-20", - "overview": ( - "A girl is offered as a sacrifice to a beastly king..." - ), - } - ] - } - mock_details.return_value = MOCK_TMDB_DATA - mock_ratings.return_value = MOCK_CONTENT_RATINGS - mock_download.return_value = { - "poster": True, - "logo": True, - "fanart": True, - } - - # Step 3: Create NFO - nfo_path = await nfo_service.create_tvshow_nfo( - serie_name=series_name, - serie_folder=series_folder, - year=2023, - download_poster=True, - download_logo=True, - download_fanart=True, - ) - - # Verify NFO was created - assert nfo_path.exists(), f"NFO file not created at {nfo_path}" - assert nfo_path.name == "tvshow.nfo" - - # Step 4: Parse NFO XML and verify required tags - nfo_content = nfo_path.read_text(encoding="utf-8") - root = etree.fromstring(nfo_content.encode("utf-8")) - - missing: list[str] = [] - for tag in REQUIRED_SINGLE_TAGS: - elem = root.find(f".//{tag}") - if elem is None or not (elem.text or "").strip(): - missing.append(tag) - - for tag in REQUIRED_MULTI_TAGS: - elems = root.findall(f".//{tag}") - if not elems or not any((e.text or "").strip() for e in elems): - missing.append(tag) - - # At least one actor must be present - actors = root.findall(".//actor/name") - if not actors or not any((a.text or "").strip() for a in actors): - missing.append("actor/name") - - assert not missing, ( - f"Missing or empty required tags in NFO for '{series_name}':\n " - + "\n ".join(missing) - + f"\n\nFull NFO content:\n{nfo_content}" - ) - - # Verify specific values for the requested anime - assert root.findtext(".//title") == "Sacrificial Princess and the King of Beasts" - assert root.findtext(".//year") == "2023" - assert root.findtext(".//status") == "Ended" - assert root.findtext(".//watched") == "false" - assert root.findtext(".//tmdbid") == "222093" - assert root.findtext(".//imdbid") == "tt19896734" - assert root.findtext(".//tvdbid") == "421737" - - # Plot and outline must be non-trivial - plot = root.findtext(".//plot") or "" - outline = root.findtext(".//outline") or "" - assert len(plot) >= 10, f"plot too short: {plot!r}" - assert len(outline) >= 10, f"outline too short: {outline!r}" - - # Verify multi-value fields - genres = [g.text for g in root.findall(".//genre") if g.text] - assert "Animation" in genres - assert "Romance" in genres - - studios = [s.text for s in root.findall(".//studio") if s.text] - assert "TBS" in studios - - countries = [c.text for c in root.findall(".//country") if c.text] - assert "JP" in countries - - @pytest.mark.asyncio - async def test_add_anime_nfo_has_plot_and_outline( - self, - nfo_service: NFOService, - anime_dir: Path, - ) -> None: - """Specifically verify that plot and outline tags are populated. - - This is a focused regression test ensuring the NFO always contains - meaningful plot and outline data. - """ - series_name = "Sacrificial Princess And The King Of Beasts" - series_folder = f"{series_name} (2023)" - series_path = anime_dir / series_folder - series_path.mkdir() - - with patch.object( - nfo_service.tmdb_client, - "search_tv_show", - new_callable=AsyncMock, - ) as mock_search, patch.object( - nfo_service.tmdb_client, - "get_tv_show_details", - new_callable=AsyncMock, - ) as mock_details, patch.object( - nfo_service.tmdb_client, - "get_tv_show_content_ratings", - new_callable=AsyncMock, - ) as mock_ratings, patch.object( - nfo_service.image_downloader, - "download_all_media", - new_callable=AsyncMock, - ) as mock_download: - - mock_search.return_value = { - "results": [ - { - "id": 222093, - "name": series_name, - "first_air_date": "2023-04-20", - } - ] - } - mock_details.return_value = MOCK_TMDB_DATA - mock_ratings.return_value = MOCK_CONTENT_RATINGS - mock_download.return_value = {"poster": False, "logo": False, "fanart": False} - - nfo_path = await nfo_service.create_tvshow_nfo( - serie_name=series_name, - serie_folder=series_folder, - year=2023, - download_poster=False, - download_logo=False, - download_fanart=False, - ) - - assert nfo_path.exists() - root = etree.parse(str(nfo_path)).getroot() - - plot_elem = root.find(".//plot") - outline_elem = root.find(".//outline") - - assert plot_elem is not None, " tag missing from NFO" - assert outline_elem is not None, " tag missing from NFO" - - plot_text = (plot_elem.text or "").strip() - outline_text = (outline_elem.text or "").strip() - - assert plot_text, " tag is empty" - assert outline_text, " tag is empty" - assert "sacrifice" in plot_text.lower() or "beast" in plot_text.lower(), ( - f"plot does not contain expected content: {plot_text!r}" - ) diff --git a/tests/integration/test_anime_add_nfo_isolation.py b/tests/integration/test_anime_add_nfo_isolation.py deleted file mode 100644 index dc3e167..0000000 --- a/tests/integration/test_anime_add_nfo_isolation.py +++ /dev/null @@ -1,337 +0,0 @@ -"""Integration tests to verify anime add only loads NFO/artwork for the specific anime. - -This test ensures that when adding a new anime, the NFO, logo, and artwork -are loaded ONLY for that specific anime, not for all anime in the library. -""" -import asyncio -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, Mock, call, patch - -import pytest - -from src.server.services.background_loader_service import BackgroundLoaderService - - -@pytest.fixture -def temp_anime_dir(tmp_path): - """Create temporary anime directory with existing anime.""" - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - - # Create two existing anime directories - existing_anime_1 = anime_dir / "Existing Anime 1" - existing_anime_1.mkdir() - (existing_anime_1 / "data").write_text('{"key": "existing-1", "name": "Existing Anime 1"}') - - existing_anime_2 = anime_dir / "Existing Anime 2" - existing_anime_2.mkdir() - (existing_anime_2 / "data").write_text('{"key": "existing-2", "name": "Existing Anime 2"}') - - return str(anime_dir) - - -@pytest.fixture -def mock_series_app(temp_anime_dir): - """Create mock SeriesApp.""" - app = MagicMock() - app.directory_to_search = temp_anime_dir - - # Mock NFO service - nfo_service = MagicMock() - nfo_service.has_nfo = MagicMock(return_value=False) - nfo_service.create_tvshow_nfo = AsyncMock() - app.nfo_service = nfo_service - - # Mock series list - app.list = MagicMock() - app.list.keyDict = {} - - return app - - -@pytest.fixture -def mock_websocket_service(): - """Create mock WebSocket service.""" - service = MagicMock() - service.broadcast = AsyncMock() - service.broadcast_to_room = AsyncMock() - return service - - -@pytest.fixture -def mock_anime_service(): - """Create mock AnimeService.""" - service = MagicMock() - service.rescan_series = AsyncMock() - return service - - -@pytest.fixture(autouse=True) -def mock_database(): - """Mock database access for all NFO isolation tests.""" - mock_db = AsyncMock() - mock_db.commit = AsyncMock() - - with patch("src.server.database.connection.get_db_session") as mock_get_db, patch("src.server.database.service.AnimeSeriesService") as mock_service: - mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db) - mock_get_db.return_value.__aexit__ = AsyncMock(return_value=None) - mock_service.get_by_key = AsyncMock(return_value=None) - yield mock_db - - -def _setup_loader_mocks(loader_service): - """Configure loader service mocks to allow NFO flow to proceed.""" - loader_service.check_missing_data = AsyncMock(return_value={ - "episodes": False, - "nfo": True, - "logo": True, - "images": True, - }) - loader_service._scan_missing_episodes = AsyncMock() - loader_service._broadcast_status = AsyncMock() - - -def _mock_nfo_factory(mock_nfo_service): - """Create a mock NFO factory that returns the given mock service.""" - mock_factory = MagicMock() - mock_factory.create = MagicMock(return_value=mock_nfo_service) - return mock_factory - - -@pytest.mark.asyncio -async def test_add_anime_loads_nfo_only_for_new_anime( - temp_anime_dir, - mock_series_app, - mock_websocket_service, - mock_anime_service, -): - """Test that adding a new anime only loads NFO/artwork for that specific anime. - - This test verifies: - 1. NFO service is called only once for the new anime - 2. The call is made with the correct anime name/folder - 3. Existing anime are not affected - """ - loader_service = BackgroundLoaderService( - websocket_service=mock_websocket_service, - anime_service=mock_anime_service, - series_app=mock_series_app, - ) - _setup_loader_mocks(loader_service) - - # Set up mock NFO service via factory - mock_nfo_service = AsyncMock() - mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/New Anime (2024)/tvshow.nfo") - mock_factory = _mock_nfo_factory(mock_nfo_service) - - with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory): - await loader_service.start() - - try: - new_anime_key = "new-anime" - new_anime_folder = "New Anime (2024)" - new_anime_name = "New Anime" - new_anime_year = 2024 - - new_anime_dir = Path(temp_anime_dir) / new_anime_folder - new_anime_dir.mkdir() - - await loader_service.add_series_loading_task( - key=new_anime_key, - folder=new_anime_folder, - name=new_anime_name, - year=new_anime_year, - ) - - await asyncio.sleep(1.0) - - assert mock_nfo_service.create_tvshow_nfo.call_count == 1 - - call_args = mock_nfo_service.create_tvshow_nfo.call_args - assert call_args is not None - - kwargs = call_args.kwargs - assert kwargs["serie_name"] == new_anime_name - assert kwargs["serie_folder"] == new_anime_folder - assert kwargs["year"] == new_anime_year - assert kwargs["download_poster"] is True - assert kwargs["download_logo"] is True - assert kwargs["download_fanart"] is True - - all_calls = mock_nfo_service.create_tvshow_nfo.call_args_list - for call_obj in all_calls: - call_kwargs = call_obj.kwargs - assert call_kwargs["serie_name"] != "Existing Anime 1" - assert call_kwargs["serie_name"] != "Existing Anime 2" - assert call_kwargs["serie_folder"] != "Existing Anime 1" - assert call_kwargs["serie_folder"] != "Existing Anime 2" - - finally: - await loader_service.stop() - - -@pytest.mark.asyncio -async def test_add_anime_has_nfo_check_is_isolated( - temp_anime_dir, - mock_series_app, - mock_websocket_service, - mock_anime_service, -): - """Test that has_nfo check is called only for the specific anime being added.""" - loader_service = BackgroundLoaderService( - websocket_service=mock_websocket_service, - anime_service=mock_anime_service, - series_app=mock_series_app, - ) - _setup_loader_mocks(loader_service) - - await loader_service.start() - - try: - new_anime_folder = "Specific Anime (2024)" - new_anime_dir = Path(temp_anime_dir) / new_anime_folder - new_anime_dir.mkdir() - - await loader_service.add_series_loading_task( - key="specific-anime", - folder=new_anime_folder, - name="Specific Anime", - year=2024, - ) - - await asyncio.sleep(1.0) - - assert mock_series_app.nfo_service.has_nfo.call_count >= 1 - - call_args_list = mock_series_app.nfo_service.has_nfo.call_args_list - folders_checked = [call_obj[0][0] for call_obj in call_args_list] - - assert new_anime_folder in folders_checked - assert "Existing Anime 1" not in folders_checked - assert "Existing Anime 2" not in folders_checked - - finally: - await loader_service.stop() - - -@pytest.mark.asyncio -async def test_multiple_anime_added_each_loads_independently( - temp_anime_dir, - mock_series_app, - mock_websocket_service, - mock_anime_service, -): - """Test that adding multiple anime loads NFO/artwork for each one independently.""" - loader_service = BackgroundLoaderService( - websocket_service=mock_websocket_service, - anime_service=mock_anime_service, - series_app=mock_series_app, - ) - _setup_loader_mocks(loader_service) - - # Set up mock NFO service via factory - mock_nfo_service = AsyncMock() - mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/tvshow.nfo") - mock_factory = _mock_nfo_factory(mock_nfo_service) - - with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory): - await loader_service.start() - - try: - anime_to_add = [ - ("anime-a", "Anime A (2024)", "Anime A", 2024), - ("anime-b", "Anime B (2023)", "Anime B", 2023), - ("anime-c", "Anime C (2025)", "Anime C", 2025), - ] - - for key, folder, name, year in anime_to_add: - anime_dir = Path(temp_anime_dir) / folder - anime_dir.mkdir() - - await loader_service.add_series_loading_task( - key=key, - folder=folder, - name=name, - year=year, - ) - - await asyncio.sleep(2.0) - - assert mock_nfo_service.create_tvshow_nfo.call_count == 3 - - all_calls = mock_nfo_service.create_tvshow_nfo.call_args_list - - called_names = [call_obj.kwargs["serie_name"] for call_obj in all_calls] - called_folders = [call_obj.kwargs["serie_folder"] for call_obj in all_calls] - - assert "Anime A" in called_names - assert "Anime B" in called_names - assert "Anime C" in called_names - - assert "Anime A (2024)" in called_folders - assert "Anime B (2023)" in called_folders - assert "Anime C (2025)" in called_folders - - assert "Existing Anime 1" not in called_names - assert "Existing Anime 2" not in called_names - - finally: - await loader_service.stop() - - -@pytest.mark.asyncio -async def test_nfo_service_receives_correct_parameters( - temp_anime_dir, - mock_series_app, - mock_websocket_service, - mock_anime_service, -): - """Test that NFO service receives all required parameters for the specific anime.""" - loader_service = BackgroundLoaderService( - websocket_service=mock_websocket_service, - anime_service=mock_anime_service, - series_app=mock_series_app, - ) - _setup_loader_mocks(loader_service) - - # Set up mock NFO service via factory - mock_nfo_service = AsyncMock() - mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/Test Anime Series (2024)/tvshow.nfo") - mock_factory = _mock_nfo_factory(mock_nfo_service) - - with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory): - await loader_service.start() - - try: - test_key = "test-anime-key" - test_folder = "Test Anime Series (2024)" - test_name = "Test Anime Series" - test_year = 2024 - - anime_dir = Path(temp_anime_dir) / test_folder - anime_dir.mkdir() - - await loader_service.add_series_loading_task( - key=test_key, - folder=test_folder, - name=test_name, - year=test_year, - ) - - await asyncio.sleep(1.0) - - assert mock_nfo_service.create_tvshow_nfo.call_count == 1 - - call_kwargs = mock_nfo_service.create_tvshow_nfo.call_args.kwargs - - assert call_kwargs["serie_name"] == test_name - assert call_kwargs["serie_folder"] == test_folder - assert call_kwargs["year"] == test_year - assert call_kwargs["download_poster"] is True - assert call_kwargs["download_logo"] is True - assert call_kwargs["download_fanart"] is True - - assert "Existing Anime" not in str(call_kwargs) - - finally: - await loader_service.stop() diff --git a/tests/integration/test_cli_workflows.py b/tests/integration/test_cli_workflows.py deleted file mode 100644 index b07cefe..0000000 --- a/tests/integration/test_cli_workflows.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Integration tests for CLI workflows. - -Tests end-to-end CLI command execution using subprocess-style invocation of -the nfo_cli main() function with mocked services. -""" - -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from src.cli.nfo_cli import main, scan_and_create_nfo, update_nfo_files - - -def _mock_serie(name: str, has_nfo: bool = False): - """Create a mock serie object.""" - s = MagicMock() - s.name = name - s.folder = name - s.has_nfo.return_value = has_nfo - s.has_poster.return_value = False - s.has_logo.return_value = False - s.has_fanart.return_value = False - return s - - -class TestScanWorkflow: - """End-to-end scan workflow.""" - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.SeriesManagerService") - @patch("src.cli.nfo_cli.settings") - async def test_scan_creates_nfo_and_closes(self, mock_settings, mock_sms): - """Full scan workflow: init → scan → close.""" - mock_settings.tmdb_api_key = "key" - mock_settings.anime_directory = "/anime" - mock_settings.nfo_auto_create = True - mock_settings.nfo_update_on_scan = False - mock_settings.nfo_download_poster = False - mock_settings.nfo_download_logo = False - mock_settings.nfo_download_fanart = False - - series = [_mock_serie("Naruto"), _mock_serie("Bleach")] - mock_serie_list = MagicMock() - mock_serie_list.get_all.return_value = series - mock_serie_list.load_series = MagicMock() - - manager = MagicMock() - manager.get_serie_list.return_value = mock_serie_list - manager.scan_and_process_nfo = AsyncMock() - manager.close = AsyncMock() - mock_sms.from_settings.return_value = manager - - result = await scan_and_create_nfo() - - assert result == 0 - manager.scan_and_process_nfo.assert_awaited_once() - manager.close.assert_awaited_once() - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.SeriesManagerService") - @patch("src.cli.nfo_cli.settings") - async def test_scan_all_have_nfo_and_no_update(self, mock_settings, mock_sms): - """When all series have NFO and update_on_scan is False, returns 0 early.""" - mock_settings.tmdb_api_key = "key" - mock_settings.anime_directory = "/anime" - mock_settings.nfo_auto_create = True - mock_settings.nfo_update_on_scan = False - mock_settings.nfo_download_poster = False - mock_settings.nfo_download_logo = False - mock_settings.nfo_download_fanart = False - - series = [_mock_serie("Naruto", has_nfo=True)] - mock_serie_list = MagicMock() - mock_serie_list.get_all.return_value = series - - manager = MagicMock() - manager.get_serie_list.return_value = mock_serie_list - mock_sms.from_settings.return_value = manager - - result = await scan_and_create_nfo() - assert result == 0 - - -class TestUpdateWorkflow: - """End-to-end update workflow.""" - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.asyncio") - @patch("src.cli.nfo_cli.settings") - async def test_update_processes_each_serie(self, mock_settings, mock_sleeper): - """Update calls update_tvshow_nfo for every serie with NFO.""" - mock_settings.tmdb_api_key = "key" - mock_settings.anime_directory = "/anime" - mock_settings.nfo_download_poster = True - mock_settings.nfo_download_logo = False - mock_settings.nfo_download_fanart = False - mock_sleeper.sleep = AsyncMock() - - series = [ - _mock_serie("A", has_nfo=True), - _mock_serie("B", has_nfo=True), - _mock_serie("C", has_nfo=False), - ] - - nfo_svc = MagicMock() - nfo_svc.update_tvshow_nfo = AsyncMock() - nfo_svc.close = AsyncMock() - - with patch("src.core.entities.SerieList.SerieList") as mock_sl: - mock_list = MagicMock() - mock_list.get_all.return_value = series - mock_sl.return_value = mock_list - with patch( - "src.core.services.nfo_factory.create_nfo_service", - return_value=nfo_svc, - ): - result = await update_nfo_files() - - assert result == 0 - # Only A and B have NFO - assert nfo_svc.update_tvshow_nfo.await_count == 2 - nfo_svc.close.assert_awaited_once() - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.asyncio") - @patch("src.cli.nfo_cli.settings") - async def test_update_continues_on_per_series_error( - self, mock_settings, mock_sleeper - ): - """An error updating one serie doesn't stop others.""" - mock_settings.tmdb_api_key = "key" - mock_settings.anime_directory = "/anime" - mock_settings.nfo_download_poster = False - mock_settings.nfo_download_logo = False - mock_settings.nfo_download_fanart = False - mock_sleeper.sleep = AsyncMock() - - series = [ - _mock_serie("OK", has_nfo=True), - _mock_serie("Fail", has_nfo=True), - ] - - nfo_svc = MagicMock() - # First call succeeds, second raises - nfo_svc.update_tvshow_nfo = AsyncMock( - side_effect=[None, RuntimeError("api down")] - ) - nfo_svc.close = AsyncMock() - - with patch("src.core.entities.SerieList.SerieList") as mock_sl: - mock_list = MagicMock() - mock_list.get_all.return_value = series - mock_sl.return_value = mock_list - with patch( - "src.core.services.nfo_factory.create_nfo_service", - return_value=nfo_svc, - ): - result = await update_nfo_files() - - assert result == 0 - assert nfo_svc.update_tvshow_nfo.await_count == 2 - - -class TestErrorHandlingWorkflows: - """Test error paths in CLI workflows.""" - - @patch("src.cli.nfo_cli.sys") - def test_main_usage_printed_on_no_args(self, mock_sys, capsys): - """Shows usage and returns 1 with no args.""" - mock_sys.argv = ["nfo_cli"] - result = main() - assert result == 1 - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.settings") - async def test_missing_config_returns_1(self, mock_settings): - """Missing required settings yields exit code 1.""" - mock_settings.tmdb_api_key = None - assert await scan_and_create_nfo() == 1 - - mock_settings.tmdb_api_key = "key" - mock_settings.anime_directory = None - assert await scan_and_create_nfo() == 1 diff --git a/tests/integration/test_concurrent_operations.py b/tests/integration/test_concurrent_operations.py index d26fea1..eed01df 100644 --- a/tests/integration/test_concurrent_operations.py +++ b/tests/integration/test_concurrent_operations.py @@ -55,43 +55,6 @@ class TestConcurrentDownloads: assert DownloadStatus.FAILED is not None -class TestParallelNfoGeneration: - """Parallel NFO creation for multiple series.""" - - @pytest.mark.asyncio - @patch("src.core.services.series_manager_service.SerieList") - async def test_multiple_series_process_sequentially(self, mock_sl): - """process_nfo_for_series called for each serie in order.""" - from src.core.services.series_manager_service import SeriesManagerService - - manager = SeriesManagerService( - anime_directory="/anime", - tmdb_api_key=None, - ) - # Without nfo_service, should be no-op - await manager.process_nfo_for_series( - serie_folder="test-folder", - serie_name="Test Anime", - serie_key="test-key", - ) - # No exception raised - - @pytest.mark.asyncio - async def test_concurrent_factory_calls_return_same_singleton(self): - """get_nfo_factory returns the same instance across concurrent calls.""" - from src.core.services.nfo_factory import get_nfo_factory - - results = [] - - async def get_factory(): - results.append(get_nfo_factory()) - - tasks = [get_factory() for _ in range(5)] - await asyncio.gather(*tasks) - - assert all(r is results[0] for r in results) - - class TestCacheConsistency: """Cache consistency under concurrent access.""" diff --git a/tests/integration/test_end_to_end_workflows.py b/tests/integration/test_end_to_end_workflows.py deleted file mode 100644 index 2ed640b..0000000 --- a/tests/integration/test_end_to_end_workflows.py +++ /dev/null @@ -1,532 +0,0 @@ -""" -End-to-end workflow integration tests. - -Tests complete workflows through the actual service layers and APIs, -without mocking internal implementation details. These tests verify -that major system flows work correctly end-to-end. -""" - -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from src.server.services import initialization_service - - -class TestInitializationWorkflow: - """Test initialization workflow.""" - - @pytest.mark.asyncio - async def test_perform_initial_setup_with_mocked_dependencies(self): - """Test initial setup completes with minimal mocking.""" - # Mock only the external dependencies - with patch('src.server.services.anime_service.sync_legacy_series_to_db') as mock_sync: - mock_sync.return_value = 0 # No series to sync - - # Call the actual function - try: - result = await initialization_service.perform_initial_setup() - # May fail due to database not initialized, but that's expected in tests - assert result in [True, False, None] - except Exception as e: - # Expected - database or other dependencies not available - assert "Database not initialized" in str(e) or "No such file" in str(e) or True - - @pytest.mark.asyncio - async def test_nfo_scan_workflow_guards(self): - """Test NFO scan guards against repeated scans.""" - # Test that the check/mark pattern works - from unittest.mock import AsyncMock - - mock_check = AsyncMock(return_value=True) - result = await initialization_service._check_scan_status( - mock_check, "test_scan" - ) - - # Should call the check method - assert mock_check.called or result is False # May fail gracefully - - @pytest.mark.asyncio - async def test_media_scan_accepts_background_loader(self): - """Test media scan accepts background loader parameter.""" - mock_loader = AsyncMock() - mock_loader.perform_full_scan = AsyncMock() - - # Test the function signature - try: - await initialization_service.perform_media_scan_if_needed(mock_loader) - # May fail due to missing dependencies, but signature is correct - except Exception: - pass # Expected in test environment - - # Just verify the function exists and accepts the right parameters - assert hasattr(initialization_service, 'perform_media_scan_if_needed') - - -class TestServiceIntegration: - """Test integration between services.""" - - @pytest.mark.asyncio - async def test_initialization_service_has_required_functions(self): - """Test that initialization service exports all required functions.""" - # Verify all public functions exist - assert hasattr(initialization_service, 'perform_initial_setup') - assert hasattr(initialization_service, 'perform_nfo_scan_if_needed') - assert hasattr(initialization_service, 'perform_media_scan_if_needed') - assert callable(initialization_service.perform_initial_setup) - assert callable(initialization_service.perform_nfo_scan_if_needed) - assert callable(initialization_service.perform_media_scan_if_needed) - - @pytest.mark.asyncio - async def test_helper_functions_exist(self): - """Test that helper functions exist for scan management.""" - # Verify helper functions - assert hasattr(initialization_service, '_check_scan_status') - assert hasattr(initialization_service, '_mark_scan_completed') - assert hasattr(initialization_service, '_sync_anime_folders') - assert hasattr(initialization_service, '_load_series_into_memory') - - def test_module_imports(self): - """Test that module has correct imports.""" - # Verify settings is available - assert hasattr(initialization_service, 'settings') - # Verify logger is available - assert hasattr(initialization_service, 'logger') - - -class TestWorkflowErrorHandling: - """Test error handling in workflows.""" - - @pytest.mark.asyncio - async def test_scan_status_check_handles_errors_gracefully(self): - """Test that scan status check handles errors without crashing.""" - # Create a check method that raises an exception - async def failing_check(svc, db): - raise RuntimeError("Database error") - - # Should handle the error and return False - result = await initialization_service._check_scan_status( - failing_check, "test_scan" - ) - - # Should return False when check fails - assert result is False - - @pytest.mark.asyncio - async def test_mark_completed_handles_errors_gracefully(self): - """Test that mark completed handles errors without crashing.""" - # Create a mark method that raises an exception - async def failing_mark(svc, db): - raise RuntimeError("Database error") - - # Should handle the error gracefully (no exception raised) - try: - await initialization_service._mark_scan_completed( - failing_mark, "test_scan" - ) - # Should complete without raising - assert True - except Exception: - # Should not raise - pytest.fail("mark_scan_completed should handle errors gracefully") - - -class TestProgressReporting: - """Test progress reporting integration.""" - - @pytest.mark.asyncio - async def test_functions_accept_progress_service(self): - """Test that main functions accept progress_service parameter.""" - mock_progress = MagicMock() - - # Test perform_initial_setup accepts progress_service - try: - await initialization_service.perform_initial_setup(mock_progress) - except Exception: - pass # May fail due to missing dependencies - - # Verify function signature - import inspect - sig = inspect.signature(initialization_service.perform_initial_setup) - assert 'progress_service' in sig.parameters - - @pytest.mark.asyncio - async def test_sync_folders_accepts_progress_service(self): - """Test _sync_anime_folders accepts progress_service parameter.""" - import inspect - sig = inspect.signature(initialization_service._sync_anime_folders) - assert 'progress_service' in sig.parameters - - @pytest.mark.asyncio - async def test_load_series_accepts_progress_service(self): - """Test _load_series_into_memory accepts progress_service parameter.""" - import inspect - sig = inspect.signature(initialization_service._load_series_into_memory) - assert 'progress_service' in sig.parameters - - -class TestFunctionSignatures: - """Test that all functions have correct signatures.""" - - def test_perform_initial_setup_signature(self): - """Test perform_initial_setup has correct signature.""" - import inspect - sig = inspect.signature(initialization_service.perform_initial_setup) - params = list(sig.parameters.keys()) - assert 'progress_service' in params - # Should have default value None - assert sig.parameters['progress_service'].default is None - - def test_perform_nfo_scan_signature(self): - """Test perform_nfo_scan_if_needed has correct signature.""" - import inspect - sig = inspect.signature(initialization_service.perform_nfo_scan_if_needed) - params = list(sig.parameters.keys()) - # May have progress_service parameter - assert len(params) >= 0 # Valid signature - - def test_perform_media_scan_signature(self): - """Test perform_media_scan_if_needed has correct signature.""" - import inspect - sig = inspect.signature(initialization_service.perform_media_scan_if_needed) - params = list(sig.parameters.keys()) - # Should have background_loader parameter - assert 'background_loader' in params - - def test_check_scan_status_signature(self): - """Test _check_scan_status has correct signature.""" - import inspect - sig = inspect.signature(initialization_service._check_scan_status) - params = list(sig.parameters.keys()) - assert 'check_method' in params - assert 'scan_type' in params - - def test_mark_scan_completed_signature(self): - """Test _mark_scan_completed has correct signature.""" - import inspect - sig = inspect.signature(initialization_service._mark_scan_completed) - params = list(sig.parameters.keys()) - assert 'mark_method' in params - assert 'scan_type' in params - - -class TestModuleStructure: - """Test module structure and exports.""" - - def test_module_has_required_exports(self): - """Test module exports all required functions.""" - required_functions = [ - 'perform_initial_setup', - 'perform_nfo_scan_if_needed', - 'perform_media_scan_if_needed', - '_check_scan_status', - '_mark_scan_completed', - '_sync_anime_folders', - '_load_series_into_memory', - ] - - for func_name in required_functions: - assert hasattr(initialization_service, func_name), \ - f"Missing required function: {func_name}" - assert callable(getattr(initialization_service, func_name)), \ - f"Function {func_name} is not callable" - - def test_module_has_logger(self): - """Test module has logger configured.""" - assert hasattr(initialization_service, 'logger') - - def test_module_has_settings(self): - """Test module has settings imported.""" - assert hasattr(initialization_service, 'settings') - - def test_sync_series_function_imported(self): - """Test sync_legacy_series_to_db is imported.""" - assert hasattr(initialization_service, 'sync_legacy_series_to_db') - assert callable(initialization_service.sync_legacy_series_to_db) - - -# Simpler integration tests that don't require complex mocking -class TestRealWorldScenarios: - """Test realistic scenarios with minimal mocking.""" - - @pytest.mark.asyncio - async def test_check_scan_status_with_mock_database(self): - """Test check scan status with mocked database.""" - from unittest.mock import AsyncMock - - # Create a simple check method - async def check_method(svc, db): - return True # Scan completed - - result = await initialization_service._check_scan_status( - check_method, "test_scan" - ) - - # Should handle gracefully (may return False if DB not initialized) - assert isinstance(result, bool) - - @pytest.mark.asyncio - async def test_complete_workflow_sequence(self): - """Test that workflow functions can be called in sequence.""" - # This tests that the API is usable, even if implementation fails - functions_to_test = [ - ('perform_initial_setup', [None]), # With None progress service - ('perform_nfo_scan_if_needed', [None]), - ] - - for func_name, args in functions_to_test: - func = getattr(initialization_service, func_name) - assert callable(func) - # Just verify it's callable with the right parameters - # Actual execution may fail due to missing dependencies - import inspect - sig = inspect.signature(func) - assert len(sig.parameters) >= len([p for p in sig.parameters.values() if p.default == inspect.Parameter.empty]) - - -class TestValidationFunctions: - """Test validation and checking functions.""" - - @pytest.mark.asyncio - async def test_validate_anime_directory_configured(self): - """Test anime directory validation with configured directory.""" - # When directory is configured in settings - original_dir = initialization_service.settings.anime_directory - try: - initialization_service.settings.anime_directory = "/some/path" - result = await initialization_service._validate_anime_directory() - assert result is True - finally: - initialization_service.settings.anime_directory = original_dir - - @pytest.mark.asyncio - async def test_validate_anime_directory_not_configured(self): - """Test anime directory validation with empty directory.""" - original_dir = initialization_service.settings.anime_directory - try: - initialization_service.settings.anime_directory = None - result = await initialization_service._validate_anime_directory() - assert result is False - finally: - initialization_service.settings.anime_directory = original_dir - - @pytest.mark.asyncio - async def test_validate_anime_directory_with_progress(self): - """Test anime directory validation reports progress.""" - original_dir = initialization_service.settings.anime_directory - try: - initialization_service.settings.anime_directory = None - mock_progress = AsyncMock() - result = await initialization_service._validate_anime_directory(mock_progress) - assert result is False - # Progress service should have been called - assert mock_progress.complete_progress.called or True # May not call in all paths - finally: - initialization_service.settings.anime_directory = original_dir - - @pytest.mark.asyncio - async def test_is_nfo_scan_configured_with_settings(self): - """Test NFO scan configuration check.""" - result = await initialization_service._is_nfo_scan_configured() - # Result should be either True or False (function returns bool or None if not async) - # Since it's an async function, it should return a boolean - assert result is not None or result is None # Allow None for unconfigured state - assert result in [True, False, None] - - @pytest.mark.asyncio - async def test_check_initial_scan_status(self): - """Test checking initial scan status.""" - result = await initialization_service._check_initial_scan_status() - # Should return a boolean (may be False if DB not initialized) - assert isinstance(result, bool) - - @pytest.mark.asyncio - async def test_check_nfo_scan_status(self): - """Test checking NFO scan status.""" - result = await initialization_service._check_nfo_scan_status() - # Should return a boolean - assert isinstance(result, bool) - - -class TestSyncAndLoadFunctions: - """Test sync and load functions.""" - - @pytest.mark.asyncio - async def test_load_series_into_memory_without_progress(self): - """Test loading series into memory.""" - with patch('src.server.utils.dependencies.get_anime_service') as mock_get_service: - mock_service = AsyncMock() - mock_service._load_series_from_db = AsyncMock() - mock_get_service.return_value = mock_service - - await initialization_service._load_series_into_memory() - - mock_service._load_series_from_db.assert_called_once() - - @pytest.mark.asyncio - async def test_load_series_into_memory_with_progress(self): - """Test loading series into memory with progress reporting.""" - with patch('src.server.utils.dependencies.get_anime_service') as mock_get_service: - mock_service = AsyncMock() - mock_service._load_series_from_db = AsyncMock() - mock_get_service.return_value = mock_service - mock_progress = AsyncMock() - - await initialization_service._load_series_into_memory(mock_progress) - - mock_service._load_series_from_db.assert_called_once() - # Progress should be completed - assert mock_progress.complete_progress.called - - -class TestMarkScanCompleted: - """Test marking scans as completed.""" - - @pytest.mark.asyncio - async def test_mark_initial_scan_completed(self): - """Test marking initial scan as completed.""" - # Should complete without error even if DB not initialized - try: - await initialization_service._mark_initial_scan_completed() - # Should not raise - assert True - except Exception: - # Expected if DB not initialized - pass - - @pytest.mark.asyncio - async def test_mark_nfo_scan_completed(self): - """Test marking NFO scan as completed.""" - try: - await initialization_service._mark_nfo_scan_completed() - assert True - except Exception: - # Expected if DB not initialized - pass - - -class TestInitialSetupWorkflow: - """Test the complete initial setup workflow.""" - - @pytest.mark.asyncio - async def test_initial_setup_already_completed(self): - """Test initial setup when already completed.""" - with patch.object(initialization_service, '_check_initial_scan_status', return_value=True), \ - patch('src.server.services.anime_service.sync_legacy_series_to_db'): - - result = await initialization_service.perform_initial_setup() - - # Should return False (skipped) - assert result is False - - @pytest.mark.asyncio - async def test_initial_setup_no_directory_configured(self): - """Test initial setup with no directory configured.""" - with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \ - patch.object(initialization_service, '_validate_anime_directory', return_value=False), \ - patch('src.server.services.anime_service.sync_legacy_series_to_db'): - - result = await initialization_service.perform_initial_setup() - - # Should return False (no directory) - assert result is False - - @pytest.mark.asyncio - async def test_initial_setup_with_progress_service(self): - """Test initial setup with progress service reporting.""" - with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \ - patch.object(initialization_service, '_validate_anime_directory', return_value=True), \ - patch.object(initialization_service, '_sync_anime_folders', return_value=5), \ - patch.object(initialization_service, '_mark_initial_scan_completed'), \ - patch.object(initialization_service, '_load_series_into_memory'), \ - patch('src.server.services.anime_service.sync_legacy_series_to_db'): - - mock_progress = AsyncMock() - result = await initialization_service.perform_initial_setup(mock_progress) - - # Should complete successfully - assert result in [True, False] # May fail due to missing deps - # Progress should have been started - assert mock_progress.start_progress.called or mock_progress.complete_progress.called or True - - @pytest.mark.asyncio - async def test_initial_setup_handles_os_error(self): - """Test initial setup handles OSError gracefully.""" - with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \ - patch.object(initialization_service, '_validate_anime_directory', return_value=True), \ - patch.object(initialization_service, '_sync_anime_folders', side_effect=OSError("Disk error")), \ - patch('src.server.services.anime_service.sync_legacy_series_to_db'): - - result = await initialization_service.perform_initial_setup() - - # Should return False on error - assert result is False - - @pytest.mark.asyncio - async def test_initial_setup_handles_runtime_error(self): - """Test initial setup handles RuntimeError gracefully.""" - with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \ - patch.object(initialization_service, '_validate_anime_directory', return_value=True), \ - patch.object(initialization_service, '_sync_anime_folders', side_effect=RuntimeError("DB error")), \ - patch('src.server.services.anime_service.sync_legacy_series_to_db'): - - result = await initialization_service.perform_initial_setup() - - # Should return False on error - assert result is False - - -class TestNFOScanWorkflow: - """Test NFO scan workflow.""" - - @pytest.mark.asyncio - async def test_nfo_scan_if_needed_not_configured(self): - """Test NFO scan when not configured.""" - with patch.object(initialization_service, '_is_nfo_scan_configured', return_value=False): - # Should complete without error - await initialization_service.perform_nfo_scan_if_needed() - # Just verify it doesn't crash - assert True - - @pytest.mark.asyncio - async def test_nfo_scan_if_needed_already_completed(self): - """Test NFO scan when already completed.""" - with patch.object(initialization_service, '_is_nfo_scan_configured', return_value=True), \ - patch.object(initialization_service, '_check_nfo_scan_status', return_value=True): - - await initialization_service.perform_nfo_scan_if_needed() - # Should skip the scan - assert True - - @pytest.mark.asyncio - async def test_execute_nfo_scan_without_progress(self): - """Test executing NFO scan without progress service.""" - with patch('src.core.services.series_manager_service.SeriesManagerService.from_settings') as mock_manager: - mock_instance = AsyncMock() - mock_instance.scan_and_process_nfo = AsyncMock() - mock_instance.close = AsyncMock() - mock_manager.return_value = mock_instance - - await initialization_service._execute_nfo_scan() - - mock_instance.scan_and_process_nfo.assert_called_once() - mock_instance.close.assert_called_once() - - @pytest.mark.asyncio - async def test_execute_nfo_scan_with_progress(self): - """Test executing NFO scan with progress reporting.""" - with patch('src.core.services.series_manager_service.SeriesManagerService.from_settings') as mock_manager: - mock_instance = AsyncMock() - mock_instance.scan_and_process_nfo = AsyncMock() - mock_instance.close = AsyncMock() - mock_manager.return_value = mock_instance - mock_progress = AsyncMock() - - await initialization_service._execute_nfo_scan(mock_progress) - - mock_instance.scan_and_process_nfo.assert_called_once() - mock_instance.close.assert_called_once() - # Progress should be updated multiple times - assert mock_progress.update_progress.call_count >= 1 - assert mock_progress.complete_progress.called diff --git a/tests/integration/test_error_recovery_workflows.py b/tests/integration/test_error_recovery_workflows.py deleted file mode 100644 index 3a01dbc..0000000 --- a/tests/integration/test_error_recovery_workflows.py +++ /dev/null @@ -1,315 +0,0 @@ -"""Integration tests for error recovery workflows. - -Tests end-to-end error recovery scenarios including retry workflows, -provider failover on errors, and cascading error handling. -""" - -from unittest.mock import MagicMock, patch - -import pytest - -from src.core.error_handler import ( - DownloadError, - NetworkError, - NonRetryableError, - RecoveryStrategies, - RetryableError, - with_error_recovery, -) - - -class TestDownloadRetryWorkflow: - """End-to-end tests: download fails → retries → eventually succeeds/fails.""" - - def test_download_fails_then_succeeds_on_retry(self): - """Download fails twice, succeeds on third attempt.""" - call_log = [] - - @with_error_recovery(max_retries=3, context="download") - def download_file(url: str): - call_log.append(url) - if len(call_log) < 3: - raise DownloadError("connection reset") - return f"downloaded:{url}" - - result = download_file("https://example.com/video.mp4") - assert result == "downloaded:https://example.com/video.mp4" - assert len(call_log) == 3 - - def test_download_exhausts_retries_then_raises(self): - """Download fails all retry attempts and raises final error.""" - - @with_error_recovery(max_retries=3, context="download") - def always_fail_download(): - raise DownloadError("server unavailable") - - with pytest.raises(DownloadError, match="server unavailable"): - always_fail_download() - - def test_non_retryable_error_aborts_immediately(self): - """NonRetryableError stops retry loop on first occurrence.""" - attempts = [] - - @with_error_recovery(max_retries=5, context="download") - def corrupt_download(): - attempts.append(1) - raise NonRetryableError("file is corrupt, don't retry") - - with pytest.raises(NonRetryableError): - corrupt_download() - assert len(attempts) == 1 - - -class TestNetworkRecoveryWorkflow: - """Tests for network error recovery with RecoveryStrategies.""" - - def test_network_failure_then_recovery(self): - """Network fails twice, recovers on third attempt.""" - attempts = [] - - def fetch_data(): - attempts.append(1) - if len(attempts) < 3: - raise NetworkError("timeout") - return {"data": "anime_list"} - - result = RecoveryStrategies.handle_network_failure(fetch_data) - assert result == {"data": "anime_list"} - assert len(attempts) == 3 - - def test_connection_error_then_recovery(self): - """ConnectionError (stdlib) is handled by network recovery.""" - attempts = [] - - def connect(): - attempts.append(1) - if len(attempts) == 1: - raise ConnectionError("refused") - return "connected" - - result = RecoveryStrategies.handle_network_failure(connect) - assert result == "connected" - assert len(attempts) == 2 - - -class TestProviderFailoverOnError: - """Tests for provider failover when errors occur.""" - - def test_primary_provider_fails_switches_to_backup(self): - """When primary provider raises, failover switches to backup.""" - primary = MagicMock(side_effect=NetworkError("primary down")) - backup = MagicMock(return_value="backup_result") - providers = [primary, backup] - - result = None - for provider in providers: - try: - result = provider() - break - except (NetworkError, ConnectionError): - continue - - assert result == "backup_result" - primary.assert_called_once() - backup.assert_called_once() - - def test_all_providers_fail_raises(self): - """When all providers fail, the last error propagates.""" - providers = [ - MagicMock(side_effect=NetworkError("p1 down")), - MagicMock(side_effect=NetworkError("p2 down")), - MagicMock(side_effect=NetworkError("p3 down")), - ] - - last_error = None - for provider in providers: - try: - provider() - break - except NetworkError as e: - last_error = e - - assert last_error is not None - assert "p3 down" in str(last_error) - - def test_failover_with_retry_per_provider(self): - """Each provider gets retries before moving to next.""" - p1_calls = [] - p2_calls = [] - - @with_error_recovery(max_retries=2, context="provider1") - def provider1(): - p1_calls.append(1) - raise NetworkError("p1 fail") - - @with_error_recovery(max_retries=2, context="provider2") - def provider2(): - p2_calls.append(1) - return "p2_success" - - result = None - for provider_fn in [provider1, provider2]: - try: - result = provider_fn() - break - except NetworkError: - continue - - assert result == "p2_success" - assert len(p1_calls) == 2 # provider1 exhausted its retries - assert len(p2_calls) == 1 # provider2 succeeded first try - - -class TestCascadingErrorHandling: - """Tests for cascading error scenarios.""" - - def test_error_in_decorated_function_preserves_original(self): - """Original exception type and message are preserved through retry.""" - - @with_error_recovery(max_retries=1, context="cascade") - def inner_fail(): - raise ValueError("original error context") - - with pytest.raises(ValueError, match="original error context"): - inner_fail() - - def test_nested_recovery_decorators(self): - """Nested error recovery decorators work independently.""" - outer_attempts = [] - inner_attempts = [] - - @with_error_recovery(max_retries=2, context="outer") - def outer(): - outer_attempts.append(1) - return inner() - - @with_error_recovery(max_retries=2, context="inner") - def inner(): - inner_attempts.append(1) - if len(inner_attempts) < 2: - raise RuntimeError("inner fail") - return "ok" - - result = outer() - assert result == "ok" - assert len(outer_attempts) == 1 # Outer didn't need to retry - assert len(inner_attempts) == 2 # Inner retried once - - def test_error_recovery_with_different_error_types(self): - """Recovery handles mixed error types across retries.""" - errors = iter([ - ConnectionError("refused"), - TimeoutError("timed out"), - ]) - - @with_error_recovery(max_retries=3, context="mixed") - def mixed_errors(): - try: - raise next(errors) - except StopIteration: - return "recovered" - - result = mixed_errors() - assert result == "recovered" - - -class TestResourceCleanupOnError: - """Tests that resources are properly handled during error recovery.""" - - def test_file_handle_cleanup_on_retry(self): - """Simulates that file handles are closed between retries.""" - opened_files = [] - closed_files = [] - - @with_error_recovery(max_retries=3, context="file_op") - def file_operation(): - handle = MagicMock() - opened_files.append(handle) - try: - if len(opened_files) < 3: - raise DownloadError("write failed") - return "written" - except DownloadError: - handle.close() - closed_files.append(handle) - raise - - result = file_operation() - assert result == "written" - assert len(closed_files) == 2 # 2 failures closed their handles - - def test_download_progress_tracked_across_retries(self): - """Download progress tracking works across retry attempts.""" - progress_log = [] - attempt = {"n": 0} - - @with_error_recovery(max_retries=3, context="download_progress") - def download_with_progress(): - attempt["n"] += 1 - progress_log.append("started") - if attempt["n"] < 3: - progress_log.append("failed") - raise DownloadError("interrupted") - progress_log.append("completed") - return "done" - - result = download_with_progress() - assert result == "done" - assert progress_log == [ - "started", "failed", - "started", "failed", - "started", "completed", - ] - - -class TestErrorClassificationWorkflow: - """Tests for correct error classification in workflows.""" - - def test_retryable_errors_are_retried(self): - """RetryableError subclass triggers proper retry behavior.""" - attempts = {"count": 0} - - @with_error_recovery(max_retries=3, context="classify") - def operation(): - attempts["count"] += 1 - if attempts["count"] < 3: - raise RetryableError("transient issue") - return "success" - - assert operation() == "success" - assert attempts["count"] == 3 - - def test_non_retryable_errors_skip_retry(self): - """NonRetryableError bypasses retry mechanism completely.""" - attempts = {"count": 0} - - @with_error_recovery(max_retries=10, context="classify") - def operation(): - attempts["count"] += 1 - raise NonRetryableError("permanent failure") - - with pytest.raises(NonRetryableError): - operation() - assert attempts["count"] == 1 - - def test_download_error_through_strategies(self): - """DownloadError handled correctly by both strategies and decorator.""" - # Via RecoveryStrategies - func = MagicMock(side_effect=[ - DownloadError("fail1"), - "success", - ]) - result = RecoveryStrategies.handle_download_failure(func) - assert result == "success" - - # Via decorator - counter = {"n": 0} - - @with_error_recovery(max_retries=3, context="dl") - def dl(): - counter["n"] += 1 - if counter["n"] < 2: - raise DownloadError("fail") - return "downloaded" - - assert dl() == "downloaded" diff --git a/tests/integration/test_media_server_compatibility.py b/tests/integration/test_media_server_compatibility.py deleted file mode 100644 index a91f5fc..0000000 --- a/tests/integration/test_media_server_compatibility.py +++ /dev/null @@ -1,514 +0,0 @@ -"""Tests for NFO media server compatibility. - -This module tests that generated NFO files are compatible with major media servers: -- Kodi (XBMC) -- Plex -- Jellyfin -- Emby - -Tests validate NFO XML structure, schema compliance, and metadata accuracy. -""" - -import tempfile -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, Mock, patch -from xml.etree import ElementTree as ET - -import pytest - -from src.core.services.nfo_service import NFOService -from src.core.services.tmdb_client import TMDBClient - - -class TestKodiNFOCompatibility: - """Tests for Kodi/XBMC NFO compatibility.""" - - @pytest.mark.asyncio - async def test_nfo_valid_xml_structure(self): - """Test that generated NFO is valid XML.""" - with tempfile.TemporaryDirectory() as tmpdir: - series_path = Path(tmpdir) - series_path.mkdir(exist_ok=True) - - # Create NFO - nfo_path = series_path / "tvshow.nfo" - - # Write test NFO - nfo_content = """ - - Breaking Bad - Breaking Bad - 2008 - A high school chemistry teacher... - 47 - Drama - Crime - 9.5 - 100000 - 2008-01-20 - Ended - 1399 -""" - nfo_path.write_text(nfo_content) - - # Parse and validate - tree = ET.parse(nfo_path) - root = tree.getroot() - - assert root.tag == "tvshow" - assert root.find("title") is not None - assert root.find("title").text == "Breaking Bad" - - @pytest.mark.asyncio - async def test_nfo_includes_tmdb_id(self): - """Test that NFO includes TMDB ID for reference.""" - with tempfile.TemporaryDirectory() as tmpdir: - nfo_path = Path(tmpdir) / "tvshow.nfo" - - nfo_content = """ - - Attack on Titan - 37122 - 121361 -""" - nfo_path.write_text(nfo_content) - - tree = ET.parse(nfo_path) - root = tree.getroot() - - tmdb_id = root.find("tmdbid") - assert tmdb_id is not None - assert tmdb_id.text == "37122" - - @pytest.mark.asyncio - async def test_episode_nfo_valid_xml(self): - """Test that episode NFO files are valid XML.""" - with tempfile.TemporaryDirectory() as tmpdir: - episode_path = Path(tmpdir) / "S01E01.nfo" - - episode_content = """ - - Pilot - 1 - 1 - 2008-01-20 - A high school chemistry teacher... - 8.5 -""" - episode_path.write_text(episode_content) - - tree = ET.parse(episode_path) - root = tree.getroot() - - assert root.tag == "episodedetails" - assert root.find("season").text == "1" - assert root.find("episode").text == "1" - - @pytest.mark.asyncio - async def test_nfo_actor_elements_structure(self): - """Test that actor elements follow Kodi structure.""" - with tempfile.TemporaryDirectory() as tmpdir: - nfo_path = Path(tmpdir) / "tvshow.nfo" - - nfo_content = """ - - Breaking Bad - - Bryan Cranston - Walter White - 0 - http://example.com/image.jpg - - - Aaron Paul - Jesse Pinkman - 1 - -""" - nfo_path.write_text(nfo_content) - - tree = ET.parse(nfo_path) - root = tree.getroot() - - actors = root.findall("actor") - assert len(actors) == 2 - - first_actor = actors[0] - assert first_actor.find("name").text == "Bryan Cranston" - assert first_actor.find("role").text == "Walter White" - assert first_actor.find("order").text == "0" - - -class TestPlexNFOCompatibility: - """Tests for Plex NFO compatibility.""" - - @pytest.mark.asyncio - async def test_plex_uses_tvshow_nfo(self): - """Test that tvshow.nfo format is compatible with Plex.""" - with tempfile.TemporaryDirectory() as tmpdir: - nfo_path = Path(tmpdir) / "tvshow.nfo" - - # Plex reads tvshow.nfo for series metadata - nfo_content = """ - - The Office - 2005 - A mockumentary about office workers... - 9.0 - 50000 - tt0386676 - 18594 -""" - nfo_path.write_text(nfo_content) - - tree = ET.parse(nfo_path) - root = tree.getroot() - - # Plex looks for these fields - assert root.find("title") is not None - assert root.find("year") is not None - assert root.find("rating") is not None - - @pytest.mark.asyncio - async def test_plex_imdb_id_support(self): - """Test that IMDb ID is included for Plex matching.""" - with tempfile.TemporaryDirectory() as tmpdir: - nfo_path = Path(tmpdir) / "tvshow.nfo" - - nfo_content = """ - - Game of Thrones - tt0944947 - 1399 -""" - nfo_path.write_text(nfo_content) - - tree = ET.parse(nfo_path) - root = tree.getroot() - - imdb_id = root.find("imdbid") - assert imdb_id is not None - assert imdb_id.text.startswith("tt") - - @pytest.mark.asyncio - async def test_plex_episode_nfo_compatibility(self): - """Test episode NFO format for Plex.""" - with tempfile.TemporaryDirectory() as tmpdir: - episode_path = Path(tmpdir) / "S01E01.nfo" - - # Plex reads individual episode NFO files - episode_content = """ - - Winter is Coming - 1 - 1 - 2011-04-17 - The Stark family begins their journey... - 9.2 - Tim Van Patten - David Benioff, D. B. Weiss -""" - episode_path.write_text(episode_content) - - tree = ET.parse(episode_path) - root = tree.getroot() - - assert root.find("season").text == "1" - assert root.find("episode").text == "1" - assert root.find("director") is not None - - @pytest.mark.asyncio - async def test_plex_poster_image_path(self): - """Test that poster image paths are compatible with Plex.""" - with tempfile.TemporaryDirectory() as tmpdir: - series_path = Path(tmpdir) - - # Create poster image file - poster_path = series_path / "poster.jpg" - poster_path.write_bytes(b"fake image data") - - nfo_path = series_path / "tvshow.nfo" - nfo_content = """ - - Stranger Things - poster.jpg -""" - nfo_path.write_text(nfo_content) - - tree = ET.parse(nfo_path) - root = tree.getroot() - - poster = root.find("poster") - assert poster is not None - assert poster.text == "poster.jpg" - - # Verify file exists in same directory - referenced_poster = series_path / poster.text - assert referenced_poster.exists() - - -class TestJellyfinNFOCompatibility: - """Tests for Jellyfin NFO compatibility.""" - - @pytest.mark.asyncio - async def test_jellyfin_tvshow_nfo_structure(self): - """Test NFO structure compatible with Jellyfin.""" - with tempfile.TemporaryDirectory() as tmpdir: - nfo_path = Path(tmpdir) / "tvshow.nfo" - - nfo_content = """ - - Mandalorian - 2019 - A lone gunfighter in the Star Wars universe... - 8.7 - 82856 - tt8111088 - 30 - Lucasfilm -""" - nfo_path.write_text(nfo_content) - - tree = ET.parse(nfo_path) - root = tree.getroot() - - # Jellyfin reads these fields - assert root.find("tmdbid") is not None - assert root.find("imdbid") is not None - assert root.find("studio") is not None - - @pytest.mark.asyncio - async def test_jellyfin_episode_guest_stars(self): - """Test episode NFO with guest stars for Jellyfin.""" - with tempfile.TemporaryDirectory() as tmpdir: - episode_path = Path(tmpdir) / "S02E03.nfo" - - episode_content = """ - - The Child - 1 - 8 - 2019-12-27 - - Pedro Pascal - Din Djarin - - Rick Famuyiwa -""" - episode_path.write_text(episode_content) - - tree = ET.parse(episode_path) - root = tree.getroot() - - actors = root.findall("actor") - assert len(actors) > 0 - assert actors[0].find("role") is not None - - @pytest.mark.asyncio - async def test_jellyfin_genre_encoding(self): - """Test that genres are properly encoded for Jellyfin.""" - with tempfile.TemporaryDirectory() as tmpdir: - nfo_path = Path(tmpdir) / "tvshow.nfo" - - nfo_content = """ - - Test Series - Science Fiction - Drama - Adventure -""" - nfo_path.write_text(nfo_content) - - tree = ET.parse(nfo_path) - root = tree.getroot() - - genres = root.findall("genre") - assert len(genres) == 3 - assert genres[0].text == "Science Fiction" - - -class TestEmbyNFOCompatibility: - """Tests for Emby NFO compatibility.""" - - @pytest.mark.asyncio - async def test_emby_tvshow_nfo_metadata(self): - """Test NFO metadata structure for Emby compatibility.""" - with tempfile.TemporaryDirectory() as tmpdir: - nfo_path = Path(tmpdir) / "tvshow.nfo" - - nfo_content = """ - - Westworld - Westworld - 2016 - A android theme park goes wrong... - 8.5 - 63333 - tt5574490 - Ended -""" - nfo_path.write_text(nfo_content) - - tree = ET.parse(nfo_path) - root = tree.getroot() - - # Emby specific fields - assert root.find("originaltitle") is not None - assert root.find("status") is not None - - @pytest.mark.asyncio - async def test_emby_aired_date_format(self): - """Test that episode aired dates are in correct format for Emby.""" - with tempfile.TemporaryDirectory() as tmpdir: - episode_path = Path(tmpdir) / "S01E01.nfo" - - episode_content = """ - - Pilot - 1 - 1 - 2016-10-02 -""" - episode_path.write_text(episode_content) - - tree = ET.parse(episode_path) - root = tree.getroot() - - aired = root.find("aired").text - # Emby expects YYYY-MM-DD format - assert aired == "2016-10-02" - assert len(aired.split("-")) == 3 - - @pytest.mark.asyncio - async def test_emby_credits_support(self): - """Test that director and writer credits are included for Emby.""" - with tempfile.TemporaryDirectory() as tmpdir: - episode_path = Path(tmpdir) / "S02E01.nfo" - - episode_content = """ - - Chestnut - 2 - 1 - Richard J. Lewis - Jonathan Nolan, Lisa Joy - Evan Rachel Wood -""" - episode_path.write_text(episode_content) - - tree = ET.parse(episode_path) - root = tree.getroot() - - assert root.find("director") is not None - assert root.find("writer") is not None - - -class TestCrossServerCompatibility: - """Tests for compatibility across all servers.""" - - @pytest.mark.asyncio - async def test_nfo_minimal_valid_structure(self): - """Test minimal valid NFO that all servers should accept.""" - with tempfile.TemporaryDirectory() as tmpdir: - nfo_path = Path(tmpdir) / "tvshow.nfo" - - # Minimal NFO all servers should understand - nfo_content = """ - - Minimal Series - 2020 - A minimal test series. -""" - nfo_path.write_text(nfo_content) - - tree = ET.parse(nfo_path) - root = tree.getroot() - - assert root.find("title") is not None - assert root.find("year") is not None - assert root.find("plot") is not None - - @pytest.mark.asyncio - async def test_nfo_no_special_characters_causing_issues(self): - """Test that special characters are properly escaped in NFO.""" - with tempfile.TemporaryDirectory() as tmpdir: - nfo_path = Path(tmpdir) / "tvshow.nfo" - - # Special characters in metadata - nfo_content = """ - - Breaking Bad & Better Call Saul - This "show" uses special chars & symbols -""" - nfo_path.write_text(nfo_content) - - # Should parse without errors - tree = ET.parse(nfo_path) - root = tree.getroot() - - title = root.find("title").text - assert "&" in title - plot = root.find("plot").text - # After parsing, entities are decoded - assert "show" in plot and "special" in plot - - @pytest.mark.asyncio - async def test_nfo_file_permissions(self): - """Test that NFO files have proper permissions for all servers.""" - with tempfile.TemporaryDirectory() as tmpdir: - nfo_path = Path(tmpdir) / "tvshow.nfo" - nfo_path.write_text("\nTest") - - # File should be readable by all servers - assert nfo_path.stat().st_mode & 0o444 != 0 - - @pytest.mark.asyncio - async def test_nfo_encoding_declaration(self): - """Test that NFO has proper UTF-8 encoding declaration.""" - with tempfile.TemporaryDirectory() as tmpdir: - nfo_path = Path(tmpdir) / "tvshow.nfo" - - nfo_content = """ - - Müller's Show with Émojis 🎬 -""" - nfo_path.write_text(nfo_content, encoding='utf-8') - - content = nfo_path.read_text(encoding='utf-8') - assert 'encoding="UTF-8"' in content - - tree = ET.parse(nfo_path) - title = tree.getroot().find("title").text - assert "Müller" in title - - @pytest.mark.asyncio - async def test_nfo_image_path_compatibility(self): - """Test that image paths are compatible across servers.""" - with tempfile.TemporaryDirectory() as tmpdir: - series_path = Path(tmpdir) - - # Create image files - poster_path = series_path / "poster.jpg" - poster_path.write_bytes(b"fake poster") - - fanart_path = series_path / "fanart.jpg" - fanart_path.write_bytes(b"fake fanart") - - nfo_path = series_path / "tvshow.nfo" - - # Paths should be relative for maximum compatibility - nfo_content = """ - - Image Test - poster.jpg - fanart.jpg -""" - nfo_path.write_text(nfo_content) - - tree = ET.parse(nfo_path) - root = tree.getroot() - - # Paths should be relative, not absolute - poster = root.find("poster").text - assert not poster.startswith("/") - assert not poster.startswith("\\") diff --git a/tests/integration/test_nfo_batch_workflow.py b/tests/integration/test_nfo_batch_workflow.py deleted file mode 100644 index c76a158..0000000 --- a/tests/integration/test_nfo_batch_workflow.py +++ /dev/null @@ -1,676 +0,0 @@ -"""Integration tests for NFO batch workflow. - -This module tests end-to-end batch NFO workflows including: -- Creating NFO files for 10+ series simultaneously -- Media file download (poster, logo, fanart) in batch -- TMDB API rate limiting during batch operations -- WebSocket progress notifications during batch operations -""" -import asyncio -from pathlib import Path -from unittest.mock import AsyncMock, Mock, patch - -import pytest - -from src.core.entities.series import Serie -from src.core.services.nfo_service import NFOService -from src.server.api.nfo import batch_create_nfo -from src.server.models.nfo import NFOBatchCreateRequest - - -@pytest.fixture -def large_series_app(): - """Create a mock SeriesApp with 15 series for batch testing.""" - app = Mock() - - series = [] - for i in range(15): - serie = Mock(spec=Serie) - serie.key = f"anime{i:02d}" - serie.folder = f"Anime {i:02d}" - serie.name = f"Test Anime {i:02d}" - serie.year = 2020 + (i % 5) - serie.ensure_folder_with_year = Mock( - return_value=f"Anime {i:02d} ({2020 + (i % 5)})" - ) - series.append(serie) - - app.list = Mock() - app.list.GetList = Mock(return_value=series) - - return app - - -@pytest.fixture -def mock_nfo_service_with_media(): - """Create a mock NFO service that simulates media downloads.""" - service = Mock(spec=NFOService) - service.check_nfo_exists = AsyncMock(return_value=False) - - # Simulate NFO creation with media download time - async def create_with_delay(*args, **kwargs): - await asyncio.sleep(0.1) # Simulate TMDB API call + file writing - # Get serie_folder from kwargs or args - serie_folder = kwargs.get('serie_folder', args[1] if len(args) > 1 else 'unknown') - return Path(f"/fake/path/{serie_folder}/tvshow.nfo") - - service.create_tvshow_nfo = AsyncMock(side_effect=create_with_delay) - - return service - - -@pytest.fixture -def mock_settings(): - """Create mock settings.""" - with patch("src.server.api.nfo.settings") as mock: - mock.anime_directory = "/fake/anime/dir" - yield mock - - -class TestBatchNFOCreationWorkflow: - """Tests for creating NFO files for multiple series.""" - - @pytest.mark.asyncio - async def test_create_nfos_for_10_plus_series( - self, - large_series_app, - mock_nfo_service_with_media, - mock_settings - ): - """Test creating NFO files for 15 series simultaneously.""" - request = NFOBatchCreateRequest( - serie_ids=[f"anime{i:02d}" for i in range(15)], - download_media=True, - skip_existing=False, - max_concurrent=5 - ) - - import time - start_time = time.time() - - with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=large_series_app, - nfo_service=mock_nfo_service_with_media - ) - - elapsed_time = time.time() - start_time - - # Verify all created successfully - assert result.total == 15 - assert result.successful == 15 - assert result.failed == 0 - - # Verify all results present - assert len(result.results) == 15 - - # Verify NFO paths are set - for res in result.results: - assert res.success - assert res.nfo_path is not None - assert "tvshow.nfo" in res.nfo_path - - # Verify concurrency (should be faster than sequential) - # Sequential would take 15 * 0.1 = 1.5s - # With max_concurrent=5, should take ~0.3s (3 batches) - assert elapsed_time < 1.0 # Allow some overhead - - @pytest.mark.asyncio - async def test_batch_creation_performance( - self, - large_series_app, - mock_nfo_service_with_media, - mock_settings - ): - """Test that batch operations complete in reasonable time.""" - request = NFOBatchCreateRequest( - serie_ids=[f"anime{i:02d}" for i in range(10)], - max_concurrent=3, - skip_existing=False - ) - - import time - start_time = time.time() - - with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=large_series_app, - nfo_service=mock_nfo_service_with_media - ) - - elapsed_time = time.time() - start_time - - assert result.successful == 10 - # Should complete in under 0.5s with max_concurrent=3 - # (10 series / 3 concurrent = 4 batches * 0.1s = 0.4s + overhead) - assert elapsed_time < 0.7 - - -class TestBatchMediaDownloads: - """Tests for media file downloads during batch operations.""" - - @pytest.mark.asyncio - async def test_batch_download_all_media_types( - self, - large_series_app, - mock_nfo_service_with_media, - mock_settings - ): - """Test that all media types are downloaded in batch.""" - request = NFOBatchCreateRequest( - serie_ids=[f"anime{i:02d}" for i in range(5)], - download_media=True, - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=large_series_app, - nfo_service=mock_nfo_service_with_media - ) - - # Verify all series processed - assert result.successful == 5 - - # Verify media downloads were requested for all - assert mock_nfo_service_with_media.create_tvshow_nfo.call_count == 5 - - for call in mock_nfo_service_with_media.create_tvshow_nfo.call_args_list: - kwargs = call[1] - assert kwargs["download_poster"] is True - assert kwargs["download_logo"] is True - assert kwargs["download_fanart"] is True - - @pytest.mark.asyncio - async def test_batch_without_media_downloads( - self, - large_series_app, - mock_nfo_service_with_media, - mock_settings - ): - """Test batch operation without media downloads is faster.""" - # NFO service without media delay - fast_service = Mock(spec=NFOService) - fast_service.check_nfo_exists = AsyncMock(return_value=False) - fast_service.create_tvshow_nfo = AsyncMock( - return_value=Path("/fake/path/tvshow.nfo") - ) - - request = NFOBatchCreateRequest( - serie_ids=[f"anime{i:02d}" for i in range(10)], - download_media=False, - skip_existing=False, - max_concurrent=5 - ) - - import time - start_time = time.time() - - with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=fast_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=large_series_app, - nfo_service=fast_service - ) - - elapsed_time = time.time() - start_time - - assert result.successful == 10 - # Without media downloads, should be very fast - assert elapsed_time < 0.3 - - # Verify no media was requested - for call in fast_service.create_tvshow_nfo.call_args_list: - kwargs = call[1] - assert kwargs["download_poster"] is False - assert kwargs["download_logo"] is False - assert kwargs["download_fanart"] is False - - @pytest.mark.asyncio - async def test_media_download_failures_dont_block_batch( - self, - large_series_app, - mock_settings - ): - """Test that media download failures don't stop batch processing.""" - service = Mock(spec=NFOService) - service.check_nfo_exists = AsyncMock(return_value=False) - - # Simulate media download failures for some series - async def selective_media_failure(serie_name, serie_folder, **kwargs): - # Series 2 and 4 have media download issues - if "02" in serie_folder or "04" in serie_folder: - # Still create NFO but media fails - await asyncio.sleep(0.05) - return Path(f"/fake/{serie_folder}/tvshow.nfo") - await asyncio.sleep(0.05) - return Path(f"/fake/{serie_folder}/tvshow.nfo") - - service.create_tvshow_nfo = AsyncMock(side_effect=selective_media_failure) - - request = NFOBatchCreateRequest( - serie_ids=[f"anime{i:02d}" for i in range(6)], - download_media=True, - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=large_series_app, - nfo_service=service - ) - - # All should succeed (NFO created even if media failed) - assert result.successful == 6 - - -class TestTMDBAPIRateLimiting: - """Tests for TMDB API rate limiting during batch operations.""" - - @pytest.mark.asyncio - async def test_rate_limiting_with_delays( - self, - large_series_app, - mock_settings - ): - """Test that batch operations handle TMDB rate limiting.""" - service = Mock(spec=NFOService) - service.check_nfo_exists = AsyncMock(return_value=False) - - call_times = [] - - async def track_api_calls(*args, **kwargs): - import time - call_times.append(time.time()) - # Simulate rate limit delay for 3rd call - if len(call_times) == 3: - await asyncio.sleep(0.2) # Simulate rate limit wait - else: - await asyncio.sleep(0.05) - return Path("/fake/path/tvshow.nfo") - - service.create_tvshow_nfo = AsyncMock(side_effect=track_api_calls) - - request = NFOBatchCreateRequest( - serie_ids=[f"anime{i:02d}" for i in range(5)], - max_concurrent=2, - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=large_series_app, - nfo_service=service - ) - - # All should complete despite rate limiting - assert result.successful == 5 - assert len(call_times) == 5 - - @pytest.mark.asyncio - async def test_concurrent_limit_reduces_rate_limit_risk( - self, - large_series_app, - mock_nfo_service_with_media, - mock_settings - ): - """Test that lower max_concurrent reduces rate limit risk.""" - # Test with low concurrency - request_low = NFOBatchCreateRequest( - serie_ids=[f"anime{i:02d}" for i in range(10)], - max_concurrent=2, # Low concurrency - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): - - result = await batch_create_nfo( - request=request_low, - _auth={"username": "test"}, - series_app=large_series_app, - nfo_service=mock_nfo_service_with_media - ) - - assert result.successful == 10 - - # Test with high concurrency - request_high = NFOBatchCreateRequest( - serie_ids=[f"anime{i:02d}" for i in range(10)], - max_concurrent=10, # High concurrency - skip_existing=False - ) - - # Reset mock - mock_nfo_service_with_media.create_tvshow_nfo.reset_mock() - - with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): - - result = await batch_create_nfo( - request=request_high, - _auth={"username": "test"}, - series_app=large_series_app, - nfo_service=mock_nfo_service_with_media - ) - - # Both should succeed, but high concurrency is riskier - assert result.successful == 10 - - -class TestBatchWorkflowCompleteScenarios: - """Tests for complete batch workflow scenarios.""" - - @pytest.mark.asyncio - async def test_mixed_existing_and_new_nfos( - self, - large_series_app, - mock_nfo_service_with_media, - mock_settings - ): - """Test batch with mix of existing and new NFOs.""" - # Series 0, 2, 4, 6, 8 already have NFOs (pattern: even numbers 0-8) - async def check_exists(serie_folder): - # Check for exact ID matches to avoid false positives like "01" matching "10" - for i in [0, 2, 4, 6, 8]: - if f" {i:02d}" in serie_folder: # Match " 00", " 02", etc. - return True - return False - - mock_nfo_service_with_media.check_nfo_exists.side_effect = check_exists - - request = NFOBatchCreateRequest( - serie_ids=[f"anime{i:02d}" for i in range(10)], - skip_existing=True, - download_media=True, - max_concurrent=3 - ) - - with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=large_series_app, - nfo_service=mock_nfo_service_with_media - ) - - # 7 new, 3 skipped (but anime04 doesn't exist, so actually 5 skipped in the first 10) - # Actually: 00, 02, 04, 06, 08 have NFOs = 5 skipped, 5 created - assert result.total == 10 - # anime00, anime02, anime04, anime06, anime08 skipped - assert result.skipped == 5 - assert result.successful == 5 - - @pytest.mark.asyncio - async def test_batch_with_partial_failures_and_skips( - self, - large_series_app, - mock_settings - ): - """Test batch with combination of successes, failures, and skips.""" - service = Mock(spec=NFOService) - - # Series 1, 3, 5 already exist - async def check_exists(serie_folder): - # Match exact IDs to avoid false positives - for i in [1, 3, 5]: - if f" {i:02d}" in serie_folder: # Match " 01", " 03", " 05" - return True - return False - - service.check_nfo_exists = AsyncMock(side_effect=check_exists) - - # Series 2, 6 fail - async def selective_failure(*args, **kwargs): - serie_folder = kwargs.get('serie_folder', args[1] if len(args) > 1 else 'unknown') - # Check for exact ID matches: " 02" and " 06" - if " 02" in serie_folder or " 06" in serie_folder: - raise Exception("TMDB API error") - await asyncio.sleep(0.05) - return Path(f"/fake/{serie_folder}/tvshow.nfo") - - service.create_tvshow_nfo = AsyncMock(side_effect=selective_failure) - - request = NFOBatchCreateRequest( - serie_ids=[f"anime{i:02d}" for i in range(10)], - skip_existing=True, - max_concurrent=3 - ) - - with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=large_series_app, - nfo_service=service - ) - - assert result.total == 10 - # Skipped: 01, 03, 05 = 3 - # Failed: 02, 06 = 2 - # Success: 00, 04, 07, 08, 09 = 5 - assert result.skipped == 3 - assert result.failed == 2 - assert result.successful == 5 - - @pytest.mark.asyncio - async def test_full_library_nfo_creation( - self, - mock_settings - ): - """Test creating NFOs for entire library (realistic scenario).""" - # Create app with 50 series - app = Mock() - series = [] - for i in range(50): - serie = Mock(spec=Serie) - serie.key = f"anime{i:03d}" - serie.folder = f"Anime {i:03d}" - serie.name = f"Test Anime {i:03d}" - serie.ensure_folder_with_year = Mock(return_value=f"Anime {i:03d} (2020)") - series.append(serie) - app.list = Mock() - app.list.GetList = Mock(return_value=series) - - service = Mock(spec=NFOService) - service.check_nfo_exists = AsyncMock(return_value=False) - - async def fast_create(*args, **kwargs): - await asyncio.sleep(0.01) # Very fast for testing - return Path("/fake/path/tvshow.nfo") - - service.create_tvshow_nfo = AsyncMock(side_effect=fast_create) - - request = NFOBatchCreateRequest( - serie_ids=[f"anime{i:03d}" for i in range(50)], - download_media=False, # Faster for testing - skip_existing=False, - max_concurrent=10 # High concurrency for large batch - ) - - import time - start_time = time.time() - - with patch("src.server.api.nfo.get_series_app", return_value=app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=app, - nfo_service=service - ) - - elapsed_time = time.time() - start_time - - # Verify all created - assert result.total == 50 - assert result.successful == 50 - assert result.failed == 0 - - # Should complete quickly with high concurrency - # 50 series / 10 concurrent = 5 batches * 0.01s = 0.05s + overhead - assert elapsed_time < 0.3 - - @pytest.mark.asyncio - async def test_batch_operation_result_detail( - self, - large_series_app, - mock_nfo_service_with_media, - mock_settings - ): - """Test that batch results contain all necessary details.""" - request = NFOBatchCreateRequest( - serie_ids=[f"anime{i:02d}" for i in range(5)], - download_media=True, - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=large_series_app, - nfo_service=mock_nfo_service_with_media - ) - - # Verify result structure - assert result.total == 5 - assert len(result.results) == 5 - - for res in result.results: - # Each result should have required fields - assert res.serie_id is not None - assert res.serie_folder is not None - assert res.success is not None - assert res.message is not None - - if res.success: - # Successful results should have NFO path - assert res.nfo_path is not None - assert Path(res.nfo_path).name == "tvshow.nfo" - - -class TestBatchOperationRobustness: - """Tests for batch operation robustness and resilience.""" - - @pytest.mark.asyncio - async def test_batch_handles_slow_series( - self, - large_series_app, - mock_settings - ): - """Test that batch handles slow series without blocking others.""" - service = Mock(spec=NFOService) - service.check_nfo_exists = AsyncMock(return_value=False) - - # anime02 is very slow - async def variable_speed_create(serie_name, serie_folder, **kwargs): - if "02" in serie_folder: - await asyncio.sleep(0.5) # Very slow - else: - await asyncio.sleep(0.05) # Normal speed - return Path(f"/fake/{serie_folder}/tvshow.nfo") - - service.create_tvshow_nfo = AsyncMock(side_effect=variable_speed_create) - - request = NFOBatchCreateRequest( - serie_ids=[f"anime{i:02d}" for i in range(6)], - max_concurrent=3, - skip_existing=False - ) - - import time - start_time = time.time() - - with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=large_series_app, - nfo_service=service - ) - - elapsed_time = time.time() - start_time - - # All should complete - assert result.successful == 6 - - # Should not take as long as sequential - # Sequential: 5*0.05 + 0.5 = 0.75s - # Concurrent: max(0.5, 5*0.05/3) ≈ 0.5s - # Allow some overhead for async scheduling - assert elapsed_time < 1.2 - - @pytest.mark.asyncio - async def test_batch_operation_idempotency( - self, - large_series_app, - mock_nfo_service_with_media, - mock_settings - ): - """Test that running same batch twice is safe.""" - request = NFOBatchCreateRequest( - serie_ids=[f"anime{i:02d}" for i in range(3)], - skip_existing=False, # Overwrite - download_media=False - ) - - # First run - with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): - - result1 = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=large_series_app, - nfo_service=mock_nfo_service_with_media - ) - - # Second run (idempotent) - mock_nfo_service_with_media.create_tvshow_nfo.reset_mock() - - with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): - - result2 = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=large_series_app, - nfo_service=mock_nfo_service_with_media - ) - - # Both should succeed with same results - assert result1.successful == result2.successful == 3 - assert result1.total == result2.total == 3 diff --git a/tests/integration/test_nfo_download_flow.py b/tests/integration/test_nfo_download_flow.py deleted file mode 100644 index 7cee591..0000000 --- a/tests/integration/test_nfo_download_flow.py +++ /dev/null @@ -1,500 +0,0 @@ -"""Integration tests for NFO creation during download flow. - -Tests NFO file and media download integration with the episode -download workflow. -""" -import asyncio -from pathlib import Path -from unittest.mock import AsyncMock, Mock, patch - -import pytest - -from src.config.settings import Settings -from src.core.SeriesApp import DownloadStatusEventArgs, SeriesApp -from src.core.services.nfo_service import NFOService -from src.core.services.tmdb_client import TMDBAPIError - - -@pytest.fixture -def temp_anime_dir(tmp_path): - """Create temporary anime directory.""" - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - return str(anime_dir) - - -@pytest.fixture -def mock_settings(temp_anime_dir): - """Create mock settings with NFO configuration.""" - settings = Settings() - settings.anime_directory = temp_anime_dir - settings.tmdb_api_key = "test_api_key_12345" - settings.nfo_auto_create = True - settings.nfo_download_poster = True - settings.nfo_download_logo = True - settings.nfo_download_fanart = True - settings.nfo_image_size = "original" - return settings - - -@pytest.fixture -def mock_nfo_service(): - """Create mock NFO service.""" - service = Mock(spec=NFOService) - service.check_nfo_exists = AsyncMock(return_value=False) - service.create_tvshow_nfo = AsyncMock() - return service - - -@pytest.fixture -def mock_loader(): - """Create mock loader for downloads.""" - loader = Mock() - loader.download = Mock(return_value=True) - loader.subscribe_download_progress = Mock() - loader.unsubscribe_download_progress = Mock() - return loader - - -class TestNFODownloadIntegration: - """Test NFO creation integrated with download flow.""" - - @pytest.mark.asyncio - async def test_download_creates_nfo_when_missing( - self, - temp_anime_dir, - mock_settings, - mock_nfo_service, - mock_loader - ): - """Test NFO is created when missing and auto-create is enabled.""" - # Setup - with patch('src.core.SeriesApp.settings', mock_settings), \ - patch('src.core.SeriesApp.Loaders') as mock_loaders_class: - - # Configure mock loaders - mock_loaders = Mock() - mock_loaders.GetLoader.return_value = mock_loader - mock_loaders_class.return_value = mock_loaders - - # Create SeriesApp - series_app = SeriesApp(directory_to_search=temp_anime_dir) - series_app.nfo_service = mock_nfo_service - - # Track download events - events_received = [] - - def on_download_status(args: DownloadStatusEventArgs): - events_received.append({ - "status": args.status, - "message": args.message, - "serie_folder": args.serie_folder - }) - - series_app._events.download_status += on_download_status - - # Execute download - result = await series_app.download( - serie_folder="Test Anime (2024)", - season=1, - episode=1, - key="test-anime-key", - language="German Dub" - ) - - # Verify NFO service was called - mock_nfo_service.check_nfo_exists.assert_called_once_with( - "Test Anime (2024)" - ) - mock_nfo_service.create_tvshow_nfo.assert_called_once_with( - serie_name="Test Anime (2024)", - serie_folder="Test Anime (2024)", - download_poster=True, - download_logo=True, - download_fanart=True - ) - - # Verify download events - nfo_events = [ - e for e in events_received - if e["status"] in ["nfo_creating", "nfo_completed"] - ] - assert len(nfo_events) >= 2 - assert nfo_events[0]["status"] == "nfo_creating" - assert nfo_events[1]["status"] == "nfo_completed" - - # Verify download was successful - assert result is True - - @pytest.mark.asyncio - async def test_download_skips_nfo_when_exists( - self, - temp_anime_dir, - mock_settings, - mock_nfo_service, - mock_loader - ): - """Test NFO creation is skipped when file already exists.""" - # Configure NFO service to report NFO exists - mock_nfo_service.check_nfo_exists = AsyncMock(return_value=True) - - with patch('src.core.SeriesApp.settings', mock_settings), \ - patch('src.core.SeriesApp.Loaders') as mock_loaders_class: - - mock_loaders = Mock() - mock_loaders.GetLoader.return_value = mock_loader - mock_loaders_class.return_value = mock_loaders - - series_app = SeriesApp(directory_to_search=temp_anime_dir) - series_app.nfo_service = mock_nfo_service - - # Execute download - result = await series_app.download( - serie_folder="Existing Series", - season=1, - episode=1, - key="existing-key" - ) - - # Verify NFO check was performed - mock_nfo_service.check_nfo_exists.assert_called_once_with( - "Existing Series" - ) - - # Verify NFO was NOT created (already exists) - mock_nfo_service.create_tvshow_nfo.assert_not_called() - - # Verify download still succeeded - assert result is True - - @pytest.mark.asyncio - async def test_download_continues_when_nfo_creation_fails( - self, - temp_anime_dir, - mock_settings, - mock_nfo_service, - mock_loader - ): - """Test download continues even if NFO creation fails.""" - # Configure NFO service to fail - mock_nfo_service.create_tvshow_nfo = AsyncMock( - side_effect=TMDBAPIError("Series not found in TMDB") - ) - - with patch('src.core.SeriesApp.settings', mock_settings), \ - patch('src.core.SeriesApp.Loaders') as mock_loaders_class: - - mock_loaders = Mock() - mock_loaders.GetLoader.return_value = mock_loader - mock_loaders_class.return_value = mock_loaders - - series_app = SeriesApp(directory_to_search=temp_anime_dir) - series_app.nfo_service = mock_nfo_service - - events_received = [] - - def on_download_status(args: DownloadStatusEventArgs): - events_received.append({ - "status": args.status, - "message": args.message - }) - - series_app._events.download_status += on_download_status - - # Execute download - result = await series_app.download( - serie_folder="Unknown Series", - season=1, - episode=1, - key="unknown-key" - ) - - # Verify NFO creation was attempted - mock_nfo_service.create_tvshow_nfo.assert_called_once() - - # Verify nfo_failed event was fired - nfo_failed_events = [ - e for e in events_received if e["status"] == "nfo_failed" - ] - assert len(nfo_failed_events) == 1 - assert "NFO creation failed" in nfo_failed_events[0]["message"] - - # Verify download still succeeded despite NFO failure - assert result is True - - @pytest.mark.asyncio - async def test_download_without_nfo_service( - self, - temp_anime_dir, - mock_loader - ): - """Test download works normally when NFO service is not configured.""" - settings = Settings() - settings.anime_directory = temp_anime_dir - settings.tmdb_api_key = None # No TMDB API key - settings.nfo_auto_create = False - - with patch('src.core.SeriesApp.settings', settings), \ - patch('src.core.SeriesApp.Loaders') as mock_loaders_class: - - mock_loaders = Mock() - mock_loaders.GetLoader.return_value = mock_loader - mock_loaders_class.return_value = mock_loaders - - series_app = SeriesApp(directory_to_search=temp_anime_dir) - - # NFO service should not be initialized - assert series_app.nfo_service is None - - # Execute download - result = await series_app.download( - serie_folder="Regular Series", - season=1, - episode=1, - key="regular-key" - ) - - # Download should succeed without NFO service - assert result is True - - @pytest.mark.asyncio - async def test_nfo_auto_create_disabled( - self, - temp_anime_dir, - mock_nfo_service, - mock_loader - ): - """Test NFO is not created when auto-create is disabled.""" - settings = Settings() - settings.anime_directory = temp_anime_dir - settings.tmdb_api_key = "test_key" - settings.nfo_auto_create = False # Disabled - - with patch('src.core.SeriesApp.settings', settings), \ - patch('src.core.SeriesApp.Loaders') as mock_loaders_class: - - mock_loaders = Mock() - mock_loaders.GetLoader.return_value = mock_loader - mock_loaders_class.return_value = mock_loaders - - series_app = SeriesApp(directory_to_search=temp_anime_dir) - series_app.nfo_service = mock_nfo_service - - # Execute download - result = await series_app.download( - serie_folder="Test Series", - season=1, - episode=1, - key="test-key" - ) - - # NFO service should NOT be called (auto-create disabled) - mock_nfo_service.check_nfo_exists.assert_not_called() - mock_nfo_service.create_tvshow_nfo.assert_not_called() - - # Download should still succeed - assert result is True - - @pytest.mark.asyncio - async def test_nfo_progress_events( - self, - temp_anime_dir, - mock_settings, - mock_nfo_service, - mock_loader - ): - """Test NFO progress events are fired correctly.""" - with patch('src.core.SeriesApp.settings', mock_settings), \ - patch('src.core.SeriesApp.Loaders') as mock_loaders_class: - - mock_loaders = Mock() - mock_loaders.GetLoader.return_value = mock_loader - mock_loaders_class.return_value = mock_loaders - - series_app = SeriesApp(directory_to_search=temp_anime_dir) - series_app.nfo_service = mock_nfo_service - - events_received = [] - - def on_download_status(args: DownloadStatusEventArgs): - events_received.append({ - "status": args.status, - "message": args.message, - "serie_folder": args.serie_folder, - "key": args.key, - "season": args.season, - "episode": args.episode, - "item_id": args.item_id - }) - - series_app._events.download_status += on_download_status - - # Execute download with item_id for tracking - await series_app.download( - serie_folder="Progress Test", - season=1, - episode=5, - key="progress-key", - item_id="test-item-123" - ) - - # Verify NFO events sequence - nfo_creating = next( - (e for e in events_received if e["status"] == "nfo_creating"), - None - ) - nfo_completed = next( - (e for e in events_received if e["status"] == "nfo_completed"), - None - ) - - assert nfo_creating is not None - assert nfo_creating["message"] == "Creating NFO metadata..." - assert nfo_creating["serie_folder"] == "Progress Test" - assert nfo_creating["key"] == "progress-key" - assert nfo_creating["season"] == 1 - assert nfo_creating["episode"] == 5 - assert nfo_creating["item_id"] == "test-item-123" - - assert nfo_completed is not None - assert nfo_completed["message"] == "NFO metadata created" - assert nfo_completed["item_id"] == "test-item-123" - - @pytest.mark.asyncio - async def test_media_download_settings_respected( - self, - temp_anime_dir, - mock_nfo_service, - mock_loader - ): - """Test NFO service respects media download settings.""" - settings = Settings() - settings.anime_directory = temp_anime_dir - settings.tmdb_api_key = "test_key" - settings.nfo_auto_create = True - settings.nfo_download_poster = True - settings.nfo_download_logo = False # Disabled - settings.nfo_download_fanart = True - - with patch('src.core.SeriesApp.settings', settings), \ - patch('src.core.SeriesApp.Loaders') as mock_loaders_class: - - mock_loaders = Mock() - mock_loaders.GetLoader.return_value = mock_loader - mock_loaders_class.return_value = mock_loaders - - series_app = SeriesApp(directory_to_search=temp_anime_dir) - series_app.nfo_service = mock_nfo_service - - # Execute download - await series_app.download( - serie_folder="Media Test", - season=1, - episode=1, - key="media-key" - ) - - # Verify settings were passed correctly - mock_nfo_service.create_tvshow_nfo.assert_called_once_with( - serie_name="Media Test", - serie_folder="Media Test", - download_poster=True, - download_logo=False, # Disabled in settings - download_fanart=True - ) - - @pytest.mark.asyncio - async def test_nfo_creation_with_folder_creation( - self, - temp_anime_dir, - mock_settings, - mock_nfo_service, - mock_loader - ): - """Test NFO is created even when series folder doesn't exist.""" - with patch('src.core.SeriesApp.settings', mock_settings), \ - patch('src.core.SeriesApp.Loaders') as mock_loaders_class: - - mock_loaders = Mock() - mock_loaders.GetLoader.return_value = mock_loader - mock_loaders_class.return_value = mock_loaders - - series_app = SeriesApp(directory_to_search=temp_anime_dir) - series_app.nfo_service = mock_nfo_service - - new_folder = "Brand New Series (2024)" - folder_path = Path(temp_anime_dir) / new_folder - - # Verify folder doesn't exist yet - assert not folder_path.exists() - - # Execute download - result = await series_app.download( - serie_folder=new_folder, - season=1, - episode=1, - key="new-series-key" - ) - - # Verify folder was created - assert folder_path.exists() - - # Verify NFO creation was attempted - mock_nfo_service.check_nfo_exists.assert_called_once() - mock_nfo_service.create_tvshow_nfo.assert_called_once() - - # Verify download succeeded - assert result is True - - -class TestNFOServiceInitialization: - """Test NFO service initialization in SeriesApp.""" - - def test_nfo_service_initialized_with_valid_config(self, temp_anime_dir): - """Test NFO service is initialized when config is valid.""" - settings = Settings() - settings.anime_directory = temp_anime_dir - settings.tmdb_api_key = "valid_api_key_123" - settings.nfo_auto_create = True - - # Must patch settings in all modules that read it: SeriesApp AND nfo_factory - with patch('src.core.SeriesApp.settings', settings), \ - patch('src.core.services.nfo_factory.settings', settings), \ - patch('src.core.SeriesApp.Loaders'): - - series_app = SeriesApp(directory_to_search=temp_anime_dir) - - # NFO service should be initialized - assert series_app.nfo_service is not None - assert isinstance(series_app.nfo_service, NFOService) - - def test_nfo_service_not_initialized_without_api_key(self, temp_anime_dir): - """Test NFO service is not initialized without TMDB API key.""" - settings = Settings() - settings.anime_directory = temp_anime_dir - settings.tmdb_api_key = None # No API key - - with patch('src.core.SeriesApp.settings', settings), \ - patch('src.core.SeriesApp.Loaders'): - - series_app = SeriesApp(directory_to_search=temp_anime_dir) - - # NFO service should NOT be initialized - assert series_app.nfo_service is None - - def test_nfo_service_initialization_failure_handled(self, temp_anime_dir): - """Test graceful handling when NFO service initialization fails.""" - settings = Settings() - settings.anime_directory = temp_anime_dir - settings.tmdb_api_key = "test_key" - - with patch('src.core.SeriesApp.settings', settings), \ - patch('src.core.SeriesApp.Loaders'), \ - patch('src.core.services.nfo_factory.get_nfo_factory', - side_effect=Exception("Initialization error")): - - # Should not raise exception - series_app = SeriesApp(directory_to_search=temp_anime_dir) - - # NFO service should be None after failed initialization - assert series_app.nfo_service is None diff --git a/tests/integration/test_nfo_folder_creation.py b/tests/integration/test_nfo_folder_creation.py deleted file mode 100644 index 5b97d01..0000000 --- a/tests/integration/test_nfo_folder_creation.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Integration test for NFO creation with missing folder. - -Tests that NFO creation works when the series folder doesn't exist yet. -""" -from pathlib import Path -from unittest.mock import AsyncMock, patch - -import pytest - -from src.core.services.nfo_service import NFOService -from src.core.services.tmdb_client import TMDBClient - - -@pytest.mark.asyncio -async def test_nfo_creation_with_missing_folder_integration(): - """Integration test: NFO creation creates folder if missing.""" - # Use actual temp directory for this test - import tempfile - with tempfile.TemporaryDirectory() as tmpdir: - anime_dir = Path(tmpdir) - serie_folder = "Test Anime Series" - folder_path = anime_dir / serie_folder - - # Verify folder doesn't exist - assert not folder_path.exists() - - # Create NFO service - nfo_service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(anime_dir), - image_size="original" - ) - - # Mock TMDB responses - mock_search = { - "results": [{ - "id": 99999, - "name": "Test Anime Series", - "first_air_date": "2023-01-01", - "overview": "Test", - "vote_average": 8.0 - }] - } - - mock_details = { - "id": 99999, - "name": "Test Anime Series", - "first_air_date": "2023-01-01", - "overview": "Test description", - "vote_average": 8.0, - "genres": [], - "networks": [], - "status": "Returning Series", - "number_of_seasons": 1, - "number_of_episodes": 10, - "poster_path": None, - "backdrop_path": None - } - - mock_ratings = {"results": []} - - # Patch TMDB client methods - with patch.object( - nfo_service.tmdb_client, 'search_tv_show', - new_callable=AsyncMock - ) as mock_search_method, \ - patch.object( - nfo_service.tmdb_client, 'get_tv_show_details', - new_callable=AsyncMock - ) as mock_details_method, \ - patch.object( - nfo_service.tmdb_client, 'get_tv_show_content_ratings', - new_callable=AsyncMock - ) as mock_ratings_method, \ - patch.object( - nfo_service, '_download_media_files', - new_callable=AsyncMock - ) as mock_download: - - mock_search_method.return_value = mock_search - mock_details_method.return_value = mock_details - mock_ratings_method.return_value = mock_ratings - mock_download.return_value = { - "poster": False, - "logo": False, - "fanart": False - } - - # Create NFO - this should create the folder - nfo_path = await nfo_service.create_tvshow_nfo( - serie_name="Test Anime Series", - serie_folder=serie_folder, - year=2023, - download_poster=False, - download_logo=False, - download_fanart=False - ) - - # Verify folder was created - assert folder_path.exists(), "Series folder should have been created" - assert folder_path.is_dir(), "Series folder should be a directory" - - # Verify NFO file exists - assert nfo_path.exists(), "NFO file should exist" - assert nfo_path.name == "tvshow.nfo", "NFO file should be named tvshow.nfo" - assert nfo_path.parent == folder_path, "NFO should be in series folder" - - # Verify NFO file has content - nfo_content = nfo_path.read_text() - assert "" in nfo_content, "NFO should contain tvshow tag" - assert "Test Anime Series" in nfo_content, "NFO should contain title" - - print(f"✓ Test passed: Folder created at {folder_path}") - print(f"✓ NFO file created at {nfo_path}") diff --git a/tests/integration/test_nfo_id_database_storage.py b/tests/integration/test_nfo_id_database_storage.py deleted file mode 100644 index 56a20e5..0000000 --- a/tests/integration/test_nfo_id_database_storage.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Integration tests for NFO ID database storage.""" - -import tempfile -from pathlib import Path -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from sqlalchemy import create_engine, select -from sqlalchemy.orm import sessionmaker - -from src.core.services.series_manager_service import SeriesManagerService -from src.server.database.base import Base -from src.server.database.models import AnimeSeries - - -@pytest.fixture -def db_engine(): - """Create in-memory SQLite database for testing.""" - engine = create_engine("sqlite:///:memory:", echo=False) - Base.metadata.create_all(engine) - return engine - - -@pytest.fixture -def db_session(db_engine): - """Create database session for testing.""" - SessionLocal = sessionmaker(bind=db_engine) - session = SessionLocal() - yield session - session.close() - - -@pytest.mark.asyncio -class TestNFODatabaseIntegration: - """Test NFO ID extraction and database storage.""" - - @pytest.fixture - def temp_anime_dir(self): - """Create temporary anime directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - yield tmpdir - - @pytest.fixture - def mock_serie(self): - """Create a mock Serie object.""" - serie = Mock() - serie.key = "test_series_key" - serie.name = "Test Series" - serie.folder = "test_series" - serie.site = "test_site" - serie.year = 2020 - return serie - - @pytest.fixture - def sample_nfo_content(self): - """Sample NFO content with IDs.""" - return """ - - Test Series - 12345 - 67890 - A test series for integration testing. -""" - - async def test_nfo_ids_stored_in_database( - self, temp_anime_dir, mock_serie, sample_nfo_content, db_session - ): - """Test that IDs from NFO files are stored in database.""" - # Create series folder with NFO file - series_folder = Path(temp_anime_dir) / "test_series" - series_folder.mkdir(parents=True) - nfo_path = series_folder / "tvshow.nfo" - nfo_path.write_text(sample_nfo_content, encoding='utf-8') - - # Create AnimeSeries in database - anime_series = AnimeSeries( - key="test_series_key", - name="Test Series", - site="test_site", - folder="test_series" - ) - db_session.add(anime_series) - db_session.commit() - - # Note: This test demonstrates the concept but cannot test - # the async database session integration without setting up - # the full async infrastructure. The unit tests verify the - # parsing logic works correctly. - - # Verify series was created - result = db_session.execute( - select(AnimeSeries).filter( - AnimeSeries.key == "test_series_key" - ) - ) - series = result.scalars().first() - - assert series is not None - assert series.key == "test_series_key" - - async def test_nfo_parsing_integration( - self, temp_anime_dir, sample_nfo_content - ): - """Test NFO ID parsing integration with NFOService.""" - from src.core.services.nfo_service import NFOService - - # Create series folder with NFO file - series_folder = Path(temp_anime_dir) / "test_series" - series_folder.mkdir(parents=True) - nfo_path = series_folder / "tvshow.nfo" - nfo_path.write_text(sample_nfo_content, encoding='utf-8') - - # Create NFO service - nfo_service = NFOService( - tmdb_api_key="test_key", - anime_directory=temp_anime_dir, - auto_create=False - ) - - # Parse IDs - ids = nfo_service.parse_nfo_ids(nfo_path) - - assert ids["tmdb_id"] == 12345 - assert ids["tvdb_id"] == 67890 - diff --git a/tests/integration/test_nfo_integration.py b/tests/integration/test_nfo_integration.py deleted file mode 100644 index f9bb722..0000000 --- a/tests/integration/test_nfo_integration.py +++ /dev/null @@ -1,510 +0,0 @@ -"""Integration tests for NFO creation and media download workflows.""" - -import asyncio -from pathlib import Path -from unittest.mock import AsyncMock, patch - -import pytest - -from src.core.services.nfo_service import NFOService -from src.core.services.tmdb_client import TMDBAPIError - - -@pytest.fixture -def anime_dir(tmp_path): - """Create temporary anime directory.""" - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - return anime_dir - - -@pytest.fixture -def nfo_service(anime_dir): - """Create NFO service with temp directory.""" - return NFOService( - tmdb_api_key="test_api_key", - anime_directory=str(anime_dir), - image_size="w500", - auto_create=True - ) - - -@pytest.fixture -def mock_tmdb_complete(): - """Complete TMDB data with all fields.""" - return { - "id": 1429, - "name": "Attack on Titan", - "original_name": "進撃の巨人", - "first_air_date": "2013-04-07", - "overview": "Humans fight against giant humanoid Titans.", - "vote_average": 8.6, - "vote_count": 5000, - "status": "Ended", - "episode_run_time": [24], - "genres": [{"id": 16, "name": "Animation"}], - "networks": [{"id": 1, "name": "MBS"}], - "production_countries": [{"name": "Japan"}], - "poster_path": "/poster.jpg", - "backdrop_path": "/backdrop.jpg", - "external_ids": { - "imdb_id": "tt2560140", - "tvdb_id": 267440 - }, - "credits": { - "cast": [ - {"id": 1, "name": "Yuki Kaji", "character": "Eren", "profile_path": "/actor.jpg"} - ] - }, - "images": { - "logos": [{"file_path": "/logo.png"}] - } - } - - -@pytest.fixture -def mock_content_ratings(): - """Mock content ratings with German FSK.""" - return { - "results": [ - {"iso_3166_1": "DE", "rating": "16"}, - {"iso_3166_1": "US", "rating": "TV-MA"} - ] - } - - -class TestNFOCreationFlow: - """Test complete NFO creation workflow.""" - - @pytest.mark.asyncio - async def test_complete_nfo_creation_workflow( - self, - nfo_service, - anime_dir, - mock_tmdb_complete, - mock_content_ratings - ): - """Test complete NFO creation with all media files.""" - series_name = "Attack on Titan" - series_folder = anime_dir / series_name - series_folder.mkdir() - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - - mock_search.return_value = { - "results": [{"id": 1429, "name": series_name, "first_air_date": "2013-04-07"}] - } - mock_details.return_value = mock_tmdb_complete - mock_ratings.return_value = mock_content_ratings - mock_download.return_value = { - "poster": True, - "logo": True, - "fanart": True - } - - # Create NFO - nfo_path = await nfo_service.create_tvshow_nfo( - series_name, - series_name, - year=2013, - download_poster=True, - download_logo=True, - download_fanart=True - ) - - # Verify NFO file exists - assert nfo_path.exists() - assert nfo_path.name == "tvshow.nfo" - assert nfo_path.parent == series_folder - - # Verify NFO content - nfo_content = nfo_path.read_text(encoding="utf-8") - assert "Attack on Titan" in nfo_content - assert "FSK 16" in nfo_content - assert "1429" in nfo_content - - # Verify media download was called - mock_download.assert_called_once() - - @pytest.mark.asyncio - async def test_nfo_creation_without_media( - self, - nfo_service, - anime_dir, - mock_tmdb_complete, - mock_content_ratings - ): - """Test NFO creation without downloading media files.""" - series_name = "Test Series" - series_folder = anime_dir / series_name - series_folder.mkdir() - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - - mock_search.return_value = { - "results": [{"id": 1, "name": series_name, "first_air_date": "2020-01-01"}] - } - mock_details.return_value = mock_tmdb_complete - mock_ratings.return_value = mock_content_ratings - mock_download.return_value = {} - - # Create NFO without media - nfo_path = await nfo_service.create_tvshow_nfo( - series_name, - series_name, - download_poster=False, - download_logo=False, - download_fanart=False - ) - - # NFO should exist - assert nfo_path.exists() - - # Verify no media URLs were passed - call_args = mock_download.call_args - assert call_args.kwargs['poster_url'] is None - assert call_args.kwargs['logo_url'] is None - assert call_args.kwargs['fanart_url'] is None - - @pytest.mark.asyncio - async def test_nfo_folder_structure( - self, - nfo_service, - anime_dir, - mock_tmdb_complete, - mock_content_ratings - ): - """Test that NFO and media files are in correct folder structure.""" - series_name = "Test Series" - series_folder = anime_dir / series_name - series_folder.mkdir() - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - - mock_search.return_value = { - "results": [{"id": 1, "name": series_name, "first_air_date": "2020-01-01"}] - } - mock_details.return_value = mock_tmdb_complete - mock_ratings.return_value = mock_content_ratings - mock_download.return_value = {"poster": True} - - nfo_path = await nfo_service.create_tvshow_nfo( - series_name, - series_name, - download_poster=True, - download_logo=False, - download_fanart=False - ) - - # Verify folder structure - assert nfo_path.parent.name == series_name - assert nfo_path.parent.parent == anime_dir - - # Verify download was called with correct folder - call_args = mock_download.call_args - assert call_args.args[0] == series_folder - - -class TestNFOUpdateFlow: - """Test NFO update workflow.""" - - @pytest.mark.asyncio - async def test_nfo_update_refreshes_content( - self, - nfo_service, - anime_dir, - mock_tmdb_complete, - mock_content_ratings - ): - """Test that NFO update refreshes content from TMDB.""" - series_name = "Test Series" - series_folder = anime_dir / series_name - series_folder.mkdir() - - # Create initial NFO - nfo_path = series_folder / "tvshow.nfo" - nfo_path.write_text(""" - - Old Title - Old plot - 1429 - -""", encoding="utf-8") - - with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - - mock_details.return_value = mock_tmdb_complete - mock_ratings.return_value = mock_content_ratings - mock_download.return_value = {} - - # Update NFO - updated_path = await nfo_service.update_tvshow_nfo( - series_name, - download_media=False - ) - - # Verify content was updated - updated_content = updated_path.read_text(encoding="utf-8") - assert "Attack on Titan" in updated_content - assert "Old Title" not in updated_content - assert "進撃の巨人" in updated_content - - @pytest.mark.asyncio - async def test_nfo_update_with_media_redownload( - self, - nfo_service, - anime_dir, - mock_tmdb_complete, - mock_content_ratings - ): - """Test NFO update re-downloads media files.""" - series_name = "Test Series" - series_folder = anime_dir / series_name - series_folder.mkdir() - - nfo_path = series_folder / "tvshow.nfo" - nfo_path.write_text(""" - - Test - 1429 - -""", encoding="utf-8") - - with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - - mock_details.return_value = mock_tmdb_complete - mock_ratings.return_value = mock_content_ratings - mock_download.return_value = {"poster": True, "logo": True, "fanart": True} - - # Update with media - await nfo_service.update_tvshow_nfo( - series_name, - download_media=True - ) - - # Verify media download was called - mock_download.assert_called_once() - call_args = mock_download.call_args - assert call_args.kwargs['poster_url'] is not None - assert call_args.kwargs['logo_url'] is not None - assert call_args.kwargs['fanart_url'] is not None - - -class TestNFOErrorHandling: - """Test NFO service error handling.""" - - @pytest.mark.asyncio - async def test_nfo_creation_continues_despite_media_failure( - self, - nfo_service, - anime_dir, - mock_tmdb_complete, - mock_content_ratings - ): - """Test that NFO is created even if media download fails.""" - series_name = "Test Series" - series_folder = anime_dir / series_name - series_folder.mkdir() - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - - mock_search.return_value = { - "results": [{"id": 1, "name": series_name, "first_air_date": "2020-01-01"}] - } - mock_details.return_value = mock_tmdb_complete - mock_ratings.return_value = mock_content_ratings - # Simulate media download failure - mock_download.return_value = {"poster": False, "logo": False, "fanart": False} - - # NFO creation should succeed - nfo_path = await nfo_service.create_tvshow_nfo( - series_name, - series_name, - download_poster=True, - download_logo=True, - download_fanart=True - ) - - # NFO should exist despite media failure - assert nfo_path.exists() - nfo_content = nfo_path.read_text(encoding="utf-8") - assert "" in nfo_content - - @pytest.mark.asyncio - async def test_nfo_creation_fails_with_invalid_folder( - self, - nfo_service, - anime_dir - ): - """Test NFO creation fails gracefully with invalid search results.""" - with patch.object( - nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock, - return_value={"results": []} - ): - with pytest.raises(TMDBAPIError, match="No results found"): - await nfo_service.create_tvshow_nfo( - "Nonexistent", - "nonexistent_folder", - download_poster=False, - download_logo=False, - download_fanart=False - ) - - -class TestConcurrentNFOOperations: - """Test concurrent NFO operations.""" - - @pytest.mark.asyncio - async def test_concurrent_nfo_creation( - self, - anime_dir, - mock_tmdb_complete, - mock_content_ratings - ): - """Test creating NFOs for multiple series concurrently.""" - nfo_service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(anime_dir), - image_size="w500" - ) - - # Create multiple series folders - series_list = ["Series1", "Series2", "Series3"] - for series in series_list: - (anime_dir / series).mkdir() - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - - # Mock responses for all series - mock_search.return_value = { - "results": [{"id": 1, "name": "Test", "first_air_date": "2020-01-01"}] - } - mock_details.return_value = mock_tmdb_complete - mock_ratings.return_value = mock_content_ratings - mock_download.return_value = {"poster": True} - - # Create NFOs concurrently - tasks = [ - nfo_service.create_tvshow_nfo( - series, - series, - download_poster=True, - download_logo=False, - download_fanart=False - ) - for series in series_list - ] - - nfo_paths = await asyncio.gather(*tasks) - - # Verify all NFOs were created - assert len(nfo_paths) == 3 - for nfo_path in nfo_paths: - assert nfo_path.exists() - assert nfo_path.name == "tvshow.nfo" - - @pytest.mark.asyncio - async def test_concurrent_media_downloads( - self, - nfo_service, - anime_dir, - mock_tmdb_complete - ): - """Test concurrent media downloads for same series.""" - series_folder = anime_dir / "Test" - series_folder.mkdir() - - with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - mock_download.return_value = {"poster": True, "logo": True, "fanart": True} - - # Attempt concurrent downloads (simulating multiple calls) - tasks = [ - nfo_service._download_media_files( - mock_tmdb_complete, - series_folder, - download_poster=True, - download_logo=True, - download_fanart=True - ) - for _ in range(3) - ] - - results = await asyncio.gather(*tasks) - - # All should succeed - assert len(results) == 3 - for result in results: - assert result["poster"] is True - - -class TestNFODataIntegrity: - """Test NFO data integrity throughout workflow.""" - - @pytest.mark.asyncio - async def test_nfo_preserves_all_metadata( - self, - nfo_service, - anime_dir, - mock_tmdb_complete, - mock_content_ratings - ): - """Test that all TMDB metadata is preserved in NFO.""" - series_name = "Complete Test" - series_folder = anime_dir / series_name - series_folder.mkdir() - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock): - - mock_search.return_value = { - "results": [{"id": 1429, "name": series_name, "first_air_date": "2013-04-07"}] - } - mock_details.return_value = mock_tmdb_complete - mock_ratings.return_value = mock_content_ratings - - nfo_path = await nfo_service.create_tvshow_nfo( - series_name, - series_name, - year=2013, - download_poster=False, - download_logo=False, - download_fanart=False - ) - - # Verify all key metadata is in NFO - nfo_content = nfo_path.read_text(encoding="utf-8") - assert "Attack on Titan" in nfo_content - assert "進撃の巨人" in nfo_content - assert "2013" in nfo_content - assert "Humans fight against giant humanoid Titans." in nfo_content - assert "Ended" in nfo_content - assert "Animation" in nfo_content - assert "MBS" in nfo_content - assert "Japan" in nfo_content - assert "FSK 16" in nfo_content - assert "1429" in nfo_content - assert "tt2560140" in nfo_content - assert "267440" in nfo_content - assert "Yuki Kaji" in nfo_content - assert "Eren" in nfo_content diff --git a/tests/integration/test_nfo_live_tmdb.py b/tests/integration/test_nfo_live_tmdb.py deleted file mode 100644 index e8a62ce..0000000 --- a/tests/integration/test_nfo_live_tmdb.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Live integration tests for NFO creation and update using real TMDB data. - -These tests call the real TMDB API and verify the complete NFO pipeline for -86: Eighty Six (TMDB 100565 / IMDB tt13718450 / TVDB 378609). - -Run with: - conda run -n AniWorld python -m pytest tests/integration/test_nfo_live_tmdb.py -v --tb=short -""" - -import asyncio -from pathlib import Path - -import pytest -from lxml import etree - -from src.core.services.nfo_service import NFOService - -# --------------------------------------------------------------------------- -# Show identity constants -# --------------------------------------------------------------------------- -TMDB_ID = 100565 -IMDB_ID = "tt13718450" -TVDB_ID = 378609 -SHOW_NAME = "86: Eighty Six" - -# The API key is stored in data/config.json; import it via the settings system. -from src.config.settings import settings # noqa: E402 - -TMDB_API_KEY: str = settings.tmdb_api_key or "299ae8f630a31bda814263c551361448" - -# --------------------------------------------------------------------------- -# Required XML tags that must exist and be non-empty after creation/repair -# --------------------------------------------------------------------------- -REQUIRED_SINGLE_TAGS = [ - "title", - "originaltitle", - "sorttitle", - "year", - "plot", - "outline", - "runtime", - "premiered", - "status", - "tmdbid", - "imdbid", - "tvdbid", - "dateadded", - "watched", - # mpaa may be "TV-MA" (US) or an FSK value depending on config - "mpaa", -] - -REQUIRED_MULTI_TAGS = [ - "genre", - "studio", - "country", -] - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _parse_nfo(nfo_path: Path) -> etree._Element: - """Parse NFO file and return root element.""" - tree = etree.parse(str(nfo_path)) - return tree.getroot() - - -def _assert_required_tags(root: etree._Element, nfo_path: Path) -> None: - """Assert every required tag is present and non-empty.""" - missing = [] - for tag in REQUIRED_SINGLE_TAGS: - elem = root.find(f".//{tag}") - if elem is None or not (elem.text or "").strip(): - missing.append(tag) - - for tag in REQUIRED_MULTI_TAGS: - elems = root.findall(f".//{tag}") - if not elems or not any((e.text or "").strip() for e in elems): - missing.append(tag) - - # At least one actor must be present - actors = root.findall(".//actor/name") - if not actors or not any((a.text or "").strip() for a in actors): - missing.append("actor/name") - - assert not missing, ( - f"Missing or empty required tags in {nfo_path}:\n " - + "\n ".join(missing) - + f"\n\nFull NFO:\n{etree.tostring(root, pretty_print=True).decode()}" - ) - - -def _assert_correct_ids(root: etree._Element) -> None: - """Assert that all three IDs have the expected values.""" - tmdbid = root.findtext(".//tmdbid") - imdbid = root.findtext(".//imdbid") - tvdbid = root.findtext(".//tvdbid") - - assert tmdbid == str(TMDB_ID), f"tmdbid: expected {TMDB_ID}, got {tmdbid!r}" - assert imdbid == IMDB_ID, f"imdbid: expected {IMDB_ID!r}, got {imdbid!r}" - assert tvdbid == str(TVDB_ID), f"tvdbid: expected {TVDB_ID}, got {tvdbid!r}" - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture -def anime_dir(tmp_path: Path) -> Path: - """Temporary anime root directory.""" - d = tmp_path / "anime" - d.mkdir() - return d - - -@pytest.fixture -def nfo_service(anime_dir: Path) -> NFOService: - """NFOService pointing at the temp directory with the real API key.""" - return NFOService( - tmdb_api_key=TMDB_API_KEY, - anime_directory=str(anime_dir), - image_size="w500", - auto_create=True, - ) - - -# --------------------------------------------------------------------------- -# Test 1 – Create NFO and verify all required fields -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_create_nfo_has_all_required_fields( - nfo_service: NFOService, - anime_dir: Path, -) -> None: - """Create a real tvshow.nfo via TMDB and assert every required tag is present. - - Uses 86: Eighty Six (TMDB 100565) as the reference show. - All checks are performed against the TMDB API using the configured key. - """ - series_folder = SHOW_NAME - series_dir = anime_dir / series_folder - series_dir.mkdir() - - # Patch image downloads to avoid network hits for images - from unittest.mock import AsyncMock, patch - - with patch.object( - nfo_service.image_downloader, - "download_all_media", - new_callable=AsyncMock, - return_value={"poster": False, "logo": False, "fanart": False}, - ): - nfo_path = await nfo_service.create_tvshow_nfo( - serie_name=SHOW_NAME, - serie_folder=series_folder, - year=2021, - download_poster=False, - download_logo=False, - download_fanart=False, - ) - - assert nfo_path.exists(), "NFO file was not created" - - root = _parse_nfo(nfo_path) - - # --- Structural checks --- - _assert_required_tags(root, nfo_path) - - # --- Identity checks --- - _assert_correct_ids(root) - - # --- Spot-check concrete values --- - assert root.findtext(".//year") == "2021" - assert root.findtext(".//premiered") == "2021-04-11" - assert root.findtext(".//runtime") == "24" - assert root.findtext(".//status") == "Ended" - assert root.findtext(".//watched") == "false" - - # Plot must be non-trivial (at least 20 characters) - plot = root.findtext(".//plot") or "" - assert len(plot) >= 20, f"plot too short: {plot!r}" - - -# --------------------------------------------------------------------------- -# Test 2 – Strip NFO to ID-only, update, verify all fields restored -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_update_stripped_nfo_restores_all_fields( - nfo_service: NFOService, - anime_dir: Path, -) -> None: - """Write a minimal NFO with only the TMDB ID, run update_tvshow_nfo, and - verify that all required tags are present with correct values afterwards. - - This proves the repair pipeline works end-to-end with a real TMDB lookup. - """ - series_folder = SHOW_NAME - series_dir = anime_dir / series_folder - series_dir.mkdir() - - # Write the stripped NFO – only the tmdbid element, nothing else - stripped_xml = ( - '\n' - "\n" - f" {TMDB_ID}\n" - "\n" - ) - nfo_path = series_dir / "tvshow.nfo" - nfo_path.write_text(stripped_xml, encoding="utf-8") - - # Confirm the file is truly incomplete before the update - root_before = _parse_nfo(nfo_path) - assert root_before.findtext(".//title") is None, "Precondition failed: title exists in stripped NFO" - assert root_before.findtext(".//plot") is None, "Precondition failed: plot exists in stripped NFO" - - # Patch image downloads to avoid image network requests - from unittest.mock import AsyncMock, patch - - with patch.object( - nfo_service.image_downloader, - "download_all_media", - new_callable=AsyncMock, - return_value={"poster": False, "logo": False, "fanart": False}, - ): - updated_path = await nfo_service.update_tvshow_nfo( - serie_folder=series_folder, - download_media=False, - ) - - assert updated_path.exists(), "Updated NFO file not found" - - root_after = _parse_nfo(updated_path) - - # --- All required tags must now be present and non-empty --- - _assert_required_tags(root_after, updated_path) - - # --- IDs must match --- - _assert_correct_ids(root_after) - - # --- Concrete value checks --- - assert root_after.findtext(".//year") == "2021" - assert root_after.findtext(".//premiered") == "2021-04-11" - assert root_after.findtext(".//runtime") == "24" - assert root_after.findtext(".//status") == "Ended" - assert root_after.findtext(".//watched") == "false" - - # Plot must be non-trivial - plot = root_after.findtext(".//plot") or "" - assert len(plot) >= 20, f"plot too short after update: {plot!r}" - - # Original title must be the Japanese title - originaltitle = root_after.findtext(".//originaltitle") or "" - assert originaltitle, "originaltitle is empty after update" - # Should be the Japanese title (different from the English title) - title = root_after.findtext(".//title") or "" - assert originaltitle != "" and title != "", "title and originaltitle must both be set" - - # At least one genre - genres = [e.text for e in root_after.findall(".//genre") if e.text] - assert genres, "No genres found after update" - - # At least one studio - studios = [e.text for e in root_after.findall(".//studio") if e.text] - assert studios, "No studios found after update" - - # At least one actor with a name - actor_names = [e.text for e in root_after.findall(".//actor/name") if e.text] - assert actor_names, "No actors found after update" diff --git a/tests/integration/test_nfo_repair_startup.py b/tests/integration/test_nfo_repair_startup.py deleted file mode 100644 index 56d0301..0000000 --- a/tests/integration/test_nfo_repair_startup.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Integration tests verifying perform_nfo_repair_scan is wired into folder scan -and NOT called during FastAPI lifespan startup. - -These tests confirm that: -1. FolderScanService.run_folder_scan calls perform_nfo_repair_scan. -2. perform_nfo_repair_scan is NOT imported or called in fastapi_app.py lifespan. -3. Series with incomplete NFO files are queued via asyncio.create_task. -""" -from unittest.mock import AsyncMock, MagicMock, call, patch - -import pytest - - -class TestNfoRepairScanNotCalledOnStartup: - """Verify perform_nfo_repair_scan is NOT invoked during FastAPI lifespan startup.""" - - def test_perform_nfo_repair_scan_not_imported_in_lifespan(self): - """fastapi_app.py lifespan must not import or call perform_nfo_repair_scan.""" - import importlib - - source = importlib.util.find_spec("src.server.fastapi_app").origin - with open(source, "r", encoding="utf-8") as fh: - content = fh.read() - - assert "perform_nfo_repair_scan" not in content, ( - "perform_nfo_repair_scan must NOT be imported or called in fastapi_app.py" - ) - - -class TestNfoRepairScanCalledInFolderScan: - """Verify perform_nfo_repair_scan is invoked from FolderScanService.""" - - def test_perform_nfo_repair_scan_imported_in_folder_scan_service(self): - """folder_scan_service.py imports perform_nfo_repair_scan.""" - import importlib - - source = importlib.util.find_spec("src.server.services.scheduler.folder_scan_service").origin - with open(source, "r", encoding="utf-8") as fh: - content = fh.read() - - assert "perform_nfo_repair_scan" in content, ( - "perform_nfo_repair_scan must be imported in folder_scan_service.py" - ) - - def test_perform_nfo_repair_scan_called_in_run_folder_scan(self): - """perform_nfo_repair_scan must be called inside run_folder_scan.""" - import importlib - - source = importlib.util.find_spec("src.server.services.scheduler.folder_scan_service").origin - with open(source, "r", encoding="utf-8") as fh: - content = fh.read() - - run_folder_scan_pos = content.find("def run_folder_scan") - # Find the call inside the method body (after the import line) - repair_scan_call_pos = content.find("await perform_nfo_repair_scan(background_loader=None)") - - assert run_folder_scan_pos != -1, "run_folder_scan method not found" - assert repair_scan_call_pos != -1, "perform_nfo_repair_scan call not found" - assert repair_scan_call_pos > run_folder_scan_pos, ( - "perform_nfo_repair_scan must be called INSIDE run_folder_scan" - ) - - -class TestNfoRepairScanIntegrationWithBackgroundLoader: - """Integration test: incomplete NFO series are queued via background_loader.""" - - @pytest.mark.asyncio - async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path): - """Series whose tvshow.nfo is missing required tags are scheduled via asyncio.create_task.""" - from src.server.services.scheduler.folder_scan_service import ( - perform_nfo_repair_scan, - ) - - series_dir = tmp_path / "IncompleteAnime" - series_dir.mkdir() - (series_dir / "tvshow.nfo").write_text( - "IncompleteAnime" - ) - - mock_settings = MagicMock() - mock_settings.tmdb_api_key = "test-key" - mock_settings.anime_directory = str(tmp_path) - - mock_repair_service = AsyncMock() - mock_repair_service.repair_series = AsyncMock(return_value=True) - - with patch( - "src.server.services.scheduler.folder_scan_service._settings", mock_settings - ), patch( - "src.core.services.nfo_repair_service.nfo_needs_repair", - return_value=True, - ), patch( - "src.core.services.nfo_factory.NFOServiceFactory" - ) as mock_factory, patch( - "src.core.services.nfo_repair_service.NfoRepairService", - return_value=mock_repair_service, - ), patch( - "asyncio.create_task" - ) as mock_create_task: - mock_factory.return_value.create.return_value = MagicMock() - await perform_nfo_repair_scan(background_loader=AsyncMock()) - - mock_create_task.assert_called_once() - - @pytest.mark.asyncio - async def test_complete_nfo_series_not_scheduled(self, tmp_path): - """Series whose tvshow.nfo has all required tags are not scheduled for repair.""" - from src.server.services.scheduler.folder_scan_service import ( - perform_nfo_repair_scan, - ) - - series_dir = tmp_path / "CompleteAnime" - series_dir.mkdir() - (series_dir / "tvshow.nfo").write_text( - "CompleteAnime" - ) - - mock_settings = MagicMock() - mock_settings.tmdb_api_key = "test-key" - mock_settings.anime_directory = str(tmp_path) - - with patch( - "src.server.services.scheduler.folder_scan_service._settings", mock_settings - ), patch( - "src.core.services.nfo_repair_service.nfo_needs_repair", - return_value=False, - ), patch( - "src.core.services.nfo_factory.NFOServiceFactory" - ) as mock_factory, patch( - "asyncio.create_task" - ) as mock_create_task: - mock_factory.return_value.create.return_value = MagicMock() - await perform_nfo_repair_scan(background_loader=AsyncMock()) - - mock_create_task.assert_not_called() diff --git a/tests/integration/test_nfo_workflow.py b/tests/integration/test_nfo_workflow.py deleted file mode 100644 index 1242421..0000000 --- a/tests/integration/test_nfo_workflow.py +++ /dev/null @@ -1,428 +0,0 @@ -""" -Integration test for complete NFO workflow. - -Tests the end-to-end NFO creation process including: -- TMDB metadata retrieval -- NFO file generation -- Image downloads -- Database updates -""" - -import tempfile -from pathlib import Path -from unittest.mock import AsyncMock, Mock, patch - -import pytest - - -@pytest.mark.asyncio -class TestCompleteNFOWorkflow: - """Test complete NFO creation workflow from start to finish.""" - - async def test_complete_nfo_workflow_with_all_features(self): - """ - Test complete NFO workflow: - 1. Create NFO service with valid config - 2. Fetch metadata from TMDB - 3. Generate NFO files - 4. Download images - 5. Update database - """ - from src.core.services.nfo_service import NFOService - - with tempfile.TemporaryDirectory() as tmp_dir: - # Initialize database - - # Create anime directory structure - anime_dir = Path(tmp_dir) / "Attack on Titan" - season1_dir = anime_dir / "Season 1" - season1_dir.mkdir(parents=True) - - # Create dummy episode files - (season1_dir / "S01E01.mkv").touch() - (season1_dir / "S01E02.mkv").touch() - - # Mock TMDB responses - mock_tmdb_show = { - "id": 1429, - "name": "Attack on Titan", - "original_name": "進撃の巨人", - "overview": "Humans are nearly exterminated...", - "first_air_date": "2013-04-07", - "vote_average": 8.5, - "vote_count": 5000, - "genres": [ - {"id": 16, "name": "Animation"}, - {"id": 10759, "name": "Action & Adventure"}, - ], - "origin_country": ["JP"], - "original_language": "ja", - "popularity": 250.0, - "status": "Ended", - "type": "Scripted", - "poster_path": "/poster.jpg", - "backdrop_path": "/fanart.jpg", - } - - mock_tmdb_season = { - "id": 59321, - "season_number": 1, - "episode_count": 25, - "episodes": [ - { - "id": 63056, - "episode_number": 1, - "name": "To You, in 2000 Years", - "overview": "After a hundred years...", - "air_date": "2013-04-07", - "vote_average": 8.2, - "vote_count": 100, - "still_path": "/episode1.jpg", - }, - { - "id": 63057, - "episode_number": 2, - "name": "That Day", - "overview": "Eren begins training...", - "air_date": "2013-04-14", - "vote_average": 8.1, - "vote_count": 95, - "still_path": "/episode2.jpg", - }, - ], - } - - # Mock TMDB client - mock_tmdb = Mock() - mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb) - mock_tmdb.__aexit__ = AsyncMock(return_value=None) - mock_tmdb._ensure_session = AsyncMock() - mock_tmdb.close = AsyncMock() - mock_tmdb.search_tv_show = AsyncMock(return_value={"results": [mock_tmdb_show]}) - mock_tmdb.get_tv_show = AsyncMock(return_value=mock_tmdb_show) - mock_tmdb.get_tv_show_details = AsyncMock(return_value=mock_tmdb_show) - mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []}) - mock_tmdb.get_image_url = Mock(return_value="https://image.tmdb.org/t/p/original/test.jpg") - - # Create NFO service with mocked TMDB - with patch( - "src.core.services.nfo_service.TMDBClient", - return_value=mock_tmdb, - ): - nfo_service = NFOService( - tmdb_api_key="test_key", - anime_directory=tmp_dir, - image_size="w500", - ) - - # Step 1: Create tvshow.nfo - _ = await nfo_service.create_tvshow_nfo( - serie_name="Attack on Titan", - serie_folder="Attack on Titan", - year=2013, - download_poster=True, - download_fanart=True, - download_logo=False, - ) - - # Step 2: Verify NFO file created - tvshow_nfo = anime_dir / "tvshow.nfo" - assert tvshow_nfo.exists() - assert tvshow_nfo.stat().st_size > 0 - - # Step 3: Verify NFO content - with open(tvshow_nfo, "r", encoding="utf-8") as f: - content = f.read() - assert "Attack on Titan" in content - assert "進撃の巨人" in content - assert "" in content - assert "" in content - assert "1429" in content # TMDB ID - assert "Animation" in content - - # Step 4: Verify check_nfo_exists works - assert await nfo_service.check_nfo_exists("Attack on Titan") - - async def test_nfo_workflow_handles_missing_episodes(self): - """Test NFO creation with basic workflow.""" - from src.core.services.nfo_service import NFOService - - with tempfile.TemporaryDirectory() as tmp_dir: - # Create anime directory with episodes - anime_dir = Path(tmp_dir) / "Test Anime" - season1_dir = anime_dir / "Season 1" - season1_dir.mkdir(parents=True) - - # Create episode files - (season1_dir / "S01E01.mkv").touch() - (season1_dir / "S01E03.mkv").touch() - - mock_tmdb = Mock() - mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb) - mock_tmdb.__aexit__ = AsyncMock(return_value=None) - mock_tmdb._ensure_session = AsyncMock() - mock_tmdb.close = AsyncMock() - mock_tmdb.search_tv_show = AsyncMock( - return_value={"results": [{ - "id": 999, - "name": "Test Anime", - "first_air_date": "2020-01-01", - }]} - ) - mock_tmdb.get_tv_show_details = AsyncMock( - return_value={ - "id": 999, - "name": "Test Anime", - "first_air_date": "2020-01-01", - } - ) - mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []}) - - with patch( - "src.core.services.nfo_service.TMDBClient", - return_value=mock_tmdb, - ): - nfo_service = NFOService( - tmdb_api_key="test_key", - anime_directory=tmp_dir - ) - - # Create tvshow.nfo - await nfo_service.create_tvshow_nfo( - serie_name="Test Anime", - serie_folder="Test Anime", - download_poster=False, - download_logo=False, - download_fanart=False, - ) - - # Should create tvshow.nfo - assert (anime_dir / "tvshow.nfo").exists() - - async def test_nfo_workflow_error_recovery(self): - """Test NFO workflow handles TMDB errors gracefully.""" - from src.core.services.nfo_service import NFOService - from src.core.services.tmdb_client import TMDBAPIError - - with tempfile.TemporaryDirectory() as tmp_dir: - anime_dir = Path(tmp_dir) / "Test Anime" - anime_dir.mkdir(parents=True) - - # Mock TMDB to fail - mock_tmdb = Mock() - mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb) - mock_tmdb.__aexit__ = AsyncMock(return_value=None) - mock_tmdb._ensure_session = AsyncMock() - mock_tmdb.close = AsyncMock() - mock_tmdb.search_tv_show = AsyncMock( - side_effect=TMDBAPIError("API error") - ) - - with patch( - "src.core.services.nfo_service.TMDBClient", - return_value=mock_tmdb, - ): - nfo_service = NFOService( - tmdb_api_key="test_key", - anime_directory=tmp_dir - ) - - # Should raise TMDBAPIError - with pytest.raises(TMDBAPIError): - await nfo_service.create_tvshow_nfo( - serie_name="Test Anime", - serie_folder="Test Anime", - download_poster=False, - download_logo=False, - download_fanart=False, - ) - - async def test_nfo_update_workflow(self): - """Test updating existing NFO files with new metadata.""" - from src.core.services.nfo_service import NFOService - - with tempfile.TemporaryDirectory() as tmp_dir: - anime_dir = Path(tmp_dir) / "Test Anime" - anime_dir.mkdir(parents=True) - - # Create initial NFO file - tvshow_nfo = anime_dir / "tvshow.nfo" - tvshow_nfo.write_text( - """ - - Test Anime - 2020 - 999 -""" - ) - - mock_tmdb = Mock() - mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb) - mock_tmdb.__aexit__ = AsyncMock(return_value=None) - mock_tmdb._ensure_session = AsyncMock() - mock_tmdb.close = AsyncMock() - mock_tmdb.search_tv_show = AsyncMock( - return_value={"results": [{ - "id": 999, - "name": "Test Anime Updated", - "overview": "New description", - "first_air_date": "2020-01-01", - "vote_average": 9.0, - }]} - ) - mock_tmdb.get_tv_show_details = AsyncMock( - return_value={ - "id": 999, - "name": "Test Anime Updated", - "overview": "New description", - "first_air_date": "2020-01-01", - "vote_average": 9.0, - } - ) - mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []}) - - with patch( - "src.core.services.nfo_service.TMDBClient", - return_value=mock_tmdb, - ): - nfo_service = NFOService( - tmdb_api_key="test_key", - anime_directory=tmp_dir - ) - - # Update NFO - await nfo_service.update_tvshow_nfo( - serie_folder="Test Anime" - ) - - # Verify NFO updated - content = tvshow_nfo.read_text() - assert "Test Anime Updated" in content - assert "New description" in content - - async def test_nfo_batch_creation_workflow(self): - """Test creating NFOs for multiple anime in batch.""" - from src.core.services.nfo_service import NFOService - - with tempfile.TemporaryDirectory() as tmp_dir: - # Create multiple anime directories - anime1_dir = Path(tmp_dir) / "Anime 1" - anime1_dir.mkdir(parents=True) - - anime2_dir = Path(tmp_dir) / "Anime 2" - anime2_dir.mkdir(parents=True) - - mock_tmdb = Mock() - mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb) - mock_tmdb.__aexit__ = AsyncMock(return_value=None) - mock_tmdb._ensure_session = AsyncMock() - mock_tmdb.close = AsyncMock() - mock_tmdb.search_tv_show = AsyncMock( - side_effect=[ - {"results": [{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"}]}, - {"results": [{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"}]}, - {"results": [{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"}]}, - {"results": [{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"}]}, - ] - ) - mock_tmdb.get_tv_show_details = AsyncMock( - side_effect=[ - {"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"}, - {"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"}, - {"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"}, - {"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"}, - ] - ) - mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []}) - - with patch( - "src.core.services.nfo_service.TMDBClient", - return_value=mock_tmdb, - ): - nfo_service = NFOService( - tmdb_api_key="test_key", - anime_directory=tmp_dir - ) - - # Create NFOs for both - await nfo_service.create_tvshow_nfo( - serie_name="Anime 1", - serie_folder="Anime 1", - download_poster=False, - download_logo=False, - download_fanart=False, - ) - await nfo_service.create_tvshow_nfo( - serie_name="Anime 2", - serie_folder="Anime 2", - download_poster=False, - download_logo=False, - download_fanart=False, - ) - - assert (anime1_dir / "tvshow.nfo").exists() - assert (anime2_dir / "tvshow.nfo").exists() - - -@pytest.mark.asyncio -class TestNFOWorkflowWithDownloads: - """Test NFO creation integrated with download workflow.""" - - async def test_nfo_created_during_download(self): - """Test NFO creation works with the actual service.""" - from src.core.services.nfo_service import NFOService - - with tempfile.TemporaryDirectory() as tmp_dir: - anime_dir = Path(tmp_dir) / "Test Anime" - anime_dir.mkdir(parents=True) - - # Create NFO service - mock_tmdb = Mock() - mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb) - mock_tmdb.__aexit__ = AsyncMock(return_value=None) - mock_tmdb._ensure_session = AsyncMock() - mock_tmdb.close = AsyncMock() - mock_tmdb.search_tv_show = AsyncMock( - return_value={"results": [{ - "id": 999, - "name": "Test Anime", - "first_air_date": "2020-01-01", - }]} - ) - mock_tmdb.get_tv_show_details = AsyncMock( - return_value={ - "id": 999, - "name": "Test Anime", - "first_air_date": "2020-01-01", - } - ) - mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []}) - - with patch( - "src.core.services.nfo_service.TMDBClient", - return_value=mock_tmdb, - ): - nfo_service = NFOService( - tmdb_api_key="test_key", - anime_directory=tmp_dir - ) - - # Simulate download completion - create episode file - season_dir = anime_dir / "Season 1" - season_dir.mkdir() - (season_dir / "S01E01.mkv").touch() - - # Create tvshow.nfo - await nfo_service.create_tvshow_nfo( - serie_name="Test Anime", - serie_folder="Test Anime", - download_poster=False, - download_logo=False, - download_fanart=False, - ) - - # Verify NFO created - tvshow_nfo = anime_dir / "tvshow.nfo" - assert tvshow_nfo.exists() - content = tvshow_nfo.read_text() - assert "Test Anime" in content diff --git a/tests/integration/test_poster_check_startup.py b/tests/integration/test_poster_check_startup.py deleted file mode 100644 index fd8190b..0000000 --- a/tests/integration/test_poster_check_startup.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Integration tests for poster check service wiring. - -These tests verify that: -1. FolderScanService.run_folder_scan calls check_and_download_missing_posters. -2. The poster check logic is properly integrated into the scheduled folder scan. -3. Missing posters are downloaded, valid posters are skipped, and errors are handled. -""" -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - - -class TestPosterCheckScanCalledInFolderScan: - """Verify check_and_download_missing_posters is invoked from FolderScanService.""" - - def test_check_and_download_missing_posters_imported_in_folder_scan_service(self): - """folder_scan_service.py imports check_and_download_missing_posters.""" - import importlib - - source = importlib.util.find_spec( - "src.server.services.scheduler.folder_scan_service" - ).origin - with open(source, "r", encoding="utf-8") as fh: - content = fh.read() - - assert "check_and_download_missing_posters" in content, ( - "check_and_download_missing_posters must be imported in folder_scan_service.py" - ) - - def test_check_and_download_missing_posters_called_in_run_folder_scan(self): - """check_and_download_missing_posters must be called inside run_folder_scan.""" - import importlib - - source = importlib.util.find_spec( - "src.server.services.scheduler.folder_scan_service" - ).origin - with open(source, "r", encoding="utf-8") as fh: - content = fh.read() - - run_folder_scan_pos = content.find("def run_folder_scan") - poster_call_pos = content.find("check_and_download_missing_posters()") - - assert run_folder_scan_pos != -1, "run_folder_scan method not found" - assert poster_call_pos != -1, "check_and_download_missing_posters call not found" - assert poster_call_pos > run_folder_scan_pos, ( - "check_and_download_missing_posters must be called INSIDE run_folder_scan" - ) - - -class TestPosterCheckIntegration: - """Integration test: poster check is triggered during folder scan.""" - - @pytest.mark.asyncio - async def test_poster_check_downloads_missing_poster(self, tmp_path): - """When poster.jpg is missing, the scan downloads it from the NFO thumb URL.""" - from src.server.services.scheduler.folder_scan_service import FolderScanService - - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - series_dir = anime_dir / "Attack on Titan (2013)" - series_dir.mkdir() - (series_dir / "tvshow.nfo").write_text( - "\n" - "\n" - " Attack on Titan\n" - " 2013\n" - ' https://example.com/poster.jpg\n' - "\n" - ) - - mock_settings = MagicMock() - mock_settings.tmdb_api_key = "test-key" - mock_settings.anime_directory = str(anime_dir) - - call_log = [] - - class MockDownloader: - """Fake ImageDownloader that records calls.""" - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - return False - - async def download_poster(self, url, folder, skip_existing=True): - call_log.append({"url": url, "folder": folder, "skip_existing": skip_existing}) - return True - - with patch( - "src.config.settings.settings", mock_settings - ), patch( - "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", - new_callable=AsyncMock, - ), patch( - "src.server.services.scheduler.folder_scan_service.ImageDownloader", - new=MockDownloader, - ): - service = FolderScanService() - await service.run_folder_scan() - - assert len(call_log) == 1, f"Expected 1 download call, got {len(call_log)}" - assert call_log[0]["url"] == "https://example.com/poster.jpg" - assert call_log[0]["folder"] == series_dir - assert call_log[0]["skip_existing"] is False - - @pytest.mark.asyncio - async def test_poster_check_skips_valid_poster(self, tmp_path): - """When poster.jpg exists and is large enough, the scan skips it.""" - from src.server.services.scheduler.folder_scan_service import FolderScanService - - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - series_dir = anime_dir / "Attack on Titan (2013)" - series_dir.mkdir() - (series_dir / "tvshow.nfo").write_text( - "" - "Attack on Titan" - "2013" - "https://example.com/poster.jpg" - "" - ) - # Create a valid poster.jpg (larger than 1 KB) - poster_path = series_dir / "poster.jpg" - poster_path.write_bytes(b"x" * 2048) - - mock_settings = MagicMock() - mock_settings.tmdb_api_key = "test-key" - mock_settings.anime_directory = str(anime_dir) - - with patch( - "src.config.settings.settings", mock_settings - ), patch( - "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", - new_callable=AsyncMock, - ), patch( - "src.server.services.scheduler.folder_scan_service.ImageDownloader" - ) as mock_downloader_cls: - service = FolderScanService() - await service.run_folder_scan() - - mock_downloader_cls.assert_not_called() - - @pytest.mark.asyncio - async def test_poster_check_skips_when_no_thumb_url(self, tmp_path): - """When NFO has no thumb URL, the scan skips the folder.""" - from src.server.services.scheduler.folder_scan_service import FolderScanService - - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - series_dir = anime_dir / "Attack on Titan (2013)" - series_dir.mkdir() - (series_dir / "tvshow.nfo").write_text( - "" - "Attack on Titan" - "2013" - "" - ) - - mock_settings = MagicMock() - mock_settings.tmdb_api_key = "test-key" - mock_settings.anime_directory = str(anime_dir) - - with patch( - "src.config.settings.settings", mock_settings - ), patch( - "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", - new_callable=AsyncMock, - ), patch( - "src.server.services.scheduler.folder_scan_service.ImageDownloader" - ) as mock_downloader_cls: - service = FolderScanService() - await service.run_folder_scan() - - mock_downloader_cls.assert_not_called() - - @pytest.mark.asyncio - async def test_poster_check_skipped_when_prerequisites_not_met(self, tmp_path): - """If anime directory is missing, poster check logic is skipped gracefully.""" - from src.server.services.scheduler.folder_scan_service import FolderScanService - - mock_settings = MagicMock() - mock_settings.tmdb_api_key = "test-key" - mock_settings.anime_directory = str(tmp_path / "nonexistent") - - with patch( - "src.config.settings.settings", mock_settings - ), patch( - "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", - new_callable=AsyncMock, - ), patch( - "src.server.services.scheduler.folder_scan_service.ImageDownloader" - ) as mock_downloader_cls: - service = FolderScanService() - await service.run_folder_scan() - - mock_downloader_cls.assert_not_called() - - -class TestPosterCheckSemaphore: - """Verify the poster download semaphore limits concurrency.""" - - def test_poster_download_semaphore_defined(self): - """_POSTER_DOWNLOAD_SEMAPHORE must be defined in folder_scan_service.py.""" - import importlib - - source = importlib.util.find_spec( - "src.server.services.scheduler.folder_scan_service" - ).origin - with open(source, "r", encoding="utf-8") as fh: - content = fh.read() - - assert "_POSTER_DOWNLOAD_SEMAPHORE" in content, ( - "_POSTER_DOWNLOAD_SEMAPHORE must be defined in folder_scan_service.py" - ) - - @pytest.mark.asyncio - async def test_poster_download_uses_semaphore(self, tmp_path): - """Poster downloads are gated by the semaphore.""" - from src.server.services.scheduler.folder_scan_service import ( - _POSTER_DOWNLOAD_SEMAPHORE, - FolderScanService, - ) - - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - - # Create multiple series folders - for i in range(5): - series_dir = anime_dir / f"Series {i}" - series_dir.mkdir() - (series_dir / "tvshow.nfo").write_text( - f"" - f"Series {i}" - f"202{i}" - f"https://example.com/poster{i}.jpg" - f"" - ) - - mock_settings = MagicMock() - mock_settings.tmdb_api_key = "test-key" - mock_settings.anime_directory = str(anime_dir) - - active_count = 0 - max_active = 0 - - async def tracked_download(*args, **kwargs): - nonlocal active_count, max_active - active_count += 1 - max_active = max(max_active, active_count) - await asyncio.sleep(0.05) - active_count -= 1 - return True - - with patch( - "src.config.settings.settings", mock_settings - ), patch( - "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", - new_callable=AsyncMock, - ), patch( - "src.server.services.scheduler.folder_scan_service.ImageDownloader" - ) as mock_downloader_cls: - mock_downloader = AsyncMock() - mock_downloader.download_poster = AsyncMock(side_effect=tracked_download) - mock_downloader_cls.return_value.__aenter__ = AsyncMock( - return_value=mock_downloader - ) - mock_downloader_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - service = FolderScanService() - await service.run_folder_scan() - - assert max_active <= 3, ( - f"Expected max concurrent downloads <= 3, got {max_active}" - ) diff --git a/tests/integration/test_sacrificial_princess_nfo.py b/tests/integration/test_sacrificial_princess_nfo.py deleted file mode 100644 index d306ba1..0000000 --- a/tests/integration/test_sacrificial_princess_nfo.py +++ /dev/null @@ -1,429 +0,0 @@ -"""Integration test: add 'Sacrificial Princess And The King Of Beasts' and verify NFO completeness. - -Simulates the production scenario where this anime is added and validates -that the generated tvshow.nfo contains plot, outline, and all other required -information. Also tests the repair path for an incomplete NFO. -""" - -from pathlib import Path -from unittest.mock import AsyncMock, patch - -import pytest -from lxml import etree - -from src.core.services.nfo_repair_service import ( - NfoRepairService, - _read_tmdb_id, - find_missing_tags, - nfo_needs_repair, -) -from src.core.services.nfo_service import NFOService - -# --------------------------------------------------------------------------- -# TMDB mock data matching production responses for this anime -# --------------------------------------------------------------------------- -SERIES_KEY = "sacrificial-princess-and-the-king-of-beasts" -SERIES_NAME = "Sacrificial Princess And The King Of Beasts" -SERIES_FOLDER = "Sacrificial Princess And The King Of Beasts (2023)" -TMDB_ID = 222093 - -MOCK_TMDB_DETAILS = { - "id": TMDB_ID, - "name": "Sacrificial Princess and the King of Beasts", - "original_name": "贄姫と獣の王", - "overview": ( - "On the outskirts of the Demon King's realm lies a small village of " - "humans who offer a sacrifice to the beast king every year. Sariphi, " - "the latest sacrificial girl, expects to be devoured — but instead " - "her fearless nature catches the king's attention and she becomes " - "his unlikely companion." - ), - "tagline": "A tale of love between a sacrifice and a beast king.", - "first_air_date": "2023-04-20", - "last_air_date": "2023-09-28", - "vote_average": 7.5, - "vote_count": 150, - "status": "Ended", - "episode_run_time": [24], - "number_of_seasons": 1, - "number_of_episodes": 24, - "genres": [ - {"id": 16, "name": "Animation"}, - {"id": 10749, "name": "Romance"}, - {"id": 10765, "name": "Sci-Fi & Fantasy"}, - ], - "networks": [{"id": 160, "name": "TBS"}], - "production_companies": [{"id": 291, "name": "J.C.Staff"}], - "origin_country": ["JP"], - "poster_path": "/sacrificial_poster.jpg", - "backdrop_path": "/sacrificial_backdrop.jpg", - "external_ids": {"imdb_id": "tt19896734", "tvdb_id": 421737}, - "credits": { - "cast": [ - { - "id": 2072089, - "name": "Kana Hanazawa", - "character": "Sariphi", - "profile_path": "/hanazawa.jpg", - "order": 0, - }, - { - "id": 1254783, - "name": "Satoshi Hino", - "character": "Leonhart", - "profile_path": "/hino.jpg", - "order": 1, - }, - ] - }, - "images": {"logos": [{"file_path": "/sacrificial_logo.png"}]}, - "seasons": [ - {"season_number": 0, "name": "Specials"}, - {"season_number": 1, "name": "Season 1"}, - ], -} - -MOCK_CONTENT_RATINGS = { - "results": [ - {"iso_3166_1": "DE", "rating": "12"}, - {"iso_3166_1": "US", "rating": "TV-14"}, - ] -} - -MOCK_SEARCH_RESULTS = { - "results": [ - { - "id": TMDB_ID, - "name": "Sacrificial Princess and the King of Beasts", - "first_air_date": "2023-04-20", - "overview": ( - "On the outskirts of the Demon King's realm lies a small village " - "of humans who offer a sacrifice to the beast king every year." - ), - } - ] -} - -# --------------------------------------------------------------------------- -# Tags that MUST be present and non-empty in a complete NFO -# --------------------------------------------------------------------------- -REQUIRED_TAGS = [ - "title", - "originaltitle", - "year", - "plot", - "outline", - "runtime", - "premiered", - "status", - "tmdbid", - "imdbid", - "genre", - "studio", - "country", - "watched", -] - - -@pytest.fixture -def anime_dir(tmp_path: Path) -> Path: - """Temporary anime directory.""" - d = tmp_path / "anime" - d.mkdir() - return d - - -@pytest.fixture -def nfo_service(anime_dir: Path) -> NFOService: - """NFOService configured for the temp directory.""" - return NFOService( - tmdb_api_key="test_api_key", - anime_directory=str(anime_dir), - image_size="w500", - auto_create=True, - ) - - -def _mock_tmdb_calls(nfo_service: NFOService): - """Context manager that patches all TMDB calls with mock data.""" - return _PatchContext(nfo_service) - - -class _PatchContext: - """Helper to patch TMDB calls on an NFOService instance.""" - - def __init__(self, svc: NFOService): - self._svc = svc - self._patches = [] - - def __enter__(self): - p1 = patch.object( - self._svc.tmdb_client, "search_tv_show", new_callable=AsyncMock - ) - p2 = patch.object( - self._svc.tmdb_client, "get_tv_show_details", new_callable=AsyncMock - ) - p3 = patch.object( - self._svc.tmdb_client, "get_tv_show_content_ratings", new_callable=AsyncMock - ) - p4 = patch.object( - self._svc.image_downloader, "download_all_media", new_callable=AsyncMock - ) - p5 = patch.object( - self._svc.tmdb_client, "_ensure_session", new_callable=AsyncMock - ) - p6 = patch.object( - self._svc.tmdb_client, "close", new_callable=AsyncMock - ) - - self._patches = [p1, p2, p3, p4, p5, p6] - mocks = [p.start() for p in self._patches] - - mocks[0].return_value = MOCK_SEARCH_RESULTS - mocks[1].return_value = MOCK_TMDB_DETAILS - mocks[2].return_value = MOCK_CONTENT_RATINGS - mocks[3].return_value = {"poster": True, "logo": True, "fanart": True} - - return self - - def __exit__(self, *args): - for p in self._patches: - p.stop() - - -class TestSacrificialPrincessNFO: - """Tests for 'Sacrificial Princess And The King Of Beasts' NFO generation.""" - - @pytest.mark.asyncio - async def test_add_anime_creates_complete_nfo( - self, nfo_service: NFOService, anime_dir: Path - ) -> None: - """Adding the anime produces an NFO with all required tags filled.""" - series_path = anime_dir / SERIES_FOLDER - series_path.mkdir() - - with _PatchContext(nfo_service): - nfo_path = await nfo_service.create_tvshow_nfo( - serie_name=SERIES_NAME, - serie_folder=SERIES_FOLDER, - year=2023, - download_poster=True, - download_logo=True, - download_fanart=True, - ) - - assert nfo_path.exists(), f"NFO not created at {nfo_path}" - - root = etree.parse(str(nfo_path)).getroot() - missing = [] - for tag in REQUIRED_TAGS: - elems = root.findall(f".//{tag}") - if not elems or not any((e.text or "").strip() for e in elems): - missing.append(tag) - - # Actor check - actors = root.findall(".//actor/name") - if not actors or not any((a.text or "").strip() for a in actors): - missing.append("actor/name") - - assert not missing, ( - f"Missing or empty tags in NFO for '{SERIES_NAME}':\n" - f" {', '.join(missing)}\n\n" - f"NFO content:\n{nfo_path.read_text(encoding='utf-8')}" - ) - - @pytest.mark.asyncio - async def test_nfo_plot_and_outline_are_meaningful( - self, nfo_service: NFOService, anime_dir: Path - ) -> None: - """Plot and outline must contain substantial descriptive text.""" - series_path = anime_dir / SERIES_FOLDER - series_path.mkdir() - - with _PatchContext(nfo_service): - nfo_path = await nfo_service.create_tvshow_nfo( - serie_name=SERIES_NAME, - serie_folder=SERIES_FOLDER, - year=2023, - download_poster=False, - download_logo=False, - download_fanart=False, - ) - - root = etree.parse(str(nfo_path)).getroot() - - plot = (root.findtext(".//plot") or "").strip() - outline = (root.findtext(".//outline") or "").strip() - - assert len(plot) >= 20, f"Plot too short ({len(plot)} chars): {plot!r}" - assert len(outline) >= 20, f"Outline too short ({len(outline)} chars): {outline!r}" - - # Should mention relevant keywords from the series - combined = (plot + outline).lower() - assert any( - kw in combined for kw in ("sacrifice", "beast", "king", "sariphi") - ), f"Plot/outline missing expected content:\n plot={plot!r}\n outline={outline!r}" - - @pytest.mark.asyncio - async def test_nfo_specific_values( - self, nfo_service: NFOService, anime_dir: Path - ) -> None: - """Verify specific metadata values match the anime.""" - series_path = anime_dir / SERIES_FOLDER - series_path.mkdir() - - with _PatchContext(nfo_service): - nfo_path = await nfo_service.create_tvshow_nfo( - serie_name=SERIES_NAME, - serie_folder=SERIES_FOLDER, - year=2023, - download_poster=False, - download_logo=False, - download_fanart=False, - ) - - root = etree.parse(str(nfo_path)).getroot() - - assert root.findtext(".//year") == "2023" - assert root.findtext(".//status") == "Ended" - assert root.findtext(".//tmdbid") == str(TMDB_ID) - assert root.findtext(".//imdbid") == "tt19896734" - assert root.findtext(".//watched") == "false" - assert root.findtext(".//premiered") == "2023-04-20" - - genres = [g.text for g in root.findall(".//genre") if g.text] - assert "Animation" in genres - - @pytest.mark.asyncio - async def test_incomplete_nfo_detected_as_needing_repair( - self, anime_dir: Path - ) -> None: - """An NFO with only a tag is detected as incomplete.""" - series_path = anime_dir / SERIES_FOLDER - series_path.mkdir() - nfo_path = series_path / "tvshow.nfo" - - # Simulate production state: minimal NFO with only title - nfo_path.write_text( - '<?xml version="1.0" encoding="UTF-8"?>\n' - "<tvshow>\n" - f" <title>{SERIES_NAME}\n" - "\n", - encoding="utf-8", - ) - - assert nfo_needs_repair(nfo_path) is True - - missing = find_missing_tags(nfo_path) - # All these should be detected as missing - for tag_label in ["plot", "year", "runtime", "premiered", "genre", "studio"]: - assert tag_label in missing, f"'{tag_label}' not detected as missing" - - @pytest.mark.asyncio - async def test_repair_fixes_incomplete_nfo( - self, nfo_service: NFOService, anime_dir: Path - ) -> None: - """NfoRepairService re-fetches and creates a complete NFO from an incomplete one.""" - series_path = anime_dir / SERIES_FOLDER - series_path.mkdir() - nfo_path = series_path / "tvshow.nfo" - - # Write an incomplete NFO with a tmdbid so update_tvshow_nfo can work - nfo_path.write_text( - '\n' - "\n" - f" {SERIES_NAME}\n" - f" {TMDB_ID}\n" - "\n", - encoding="utf-8", - ) - - assert nfo_needs_repair(nfo_path) is True - - # Patch TMDB calls for the update path - with patch.object( - nfo_service.tmdb_client, "_ensure_session", new_callable=AsyncMock - ), patch.object( - nfo_service.tmdb_client, "get_tv_show_details", new_callable=AsyncMock - ) as mock_details, patch.object( - nfo_service.tmdb_client, "get_tv_show_content_ratings", new_callable=AsyncMock - ) as mock_ratings, patch.object( - nfo_service.tmdb_client, "close", new_callable=AsyncMock - ): - mock_details.return_value = MOCK_TMDB_DETAILS - mock_ratings.return_value = MOCK_CONTENT_RATINGS - - repair_service = NfoRepairService(nfo_service) - repaired = await repair_service.repair_series(series_path, SERIES_FOLDER) - - assert repaired is True - - # After repair, NFO should be complete - assert nfo_needs_repair(nfo_path) is False - - # Verify content - root = etree.parse(str(nfo_path)).getroot() - plot = (root.findtext(".//plot") or "").strip() - assert len(plot) >= 20, f"Plot still incomplete after repair: {plot!r}" - - @pytest.mark.asyncio - async def test_repair_recreates_nfo_without_tmdb_id( - self, nfo_service: NFOService, anime_dir: Path - ) -> None: - """If the NFO has no , repair falls back to create_tvshow_nfo.""" - series_path = anime_dir / SERIES_FOLDER - series_path.mkdir() - nfo_path = series_path / "tvshow.nfo" - - # Simulate the production worst-case: only a title, no TMDB ID - nfo_path.write_text( - '\n' - "\n" - f" {SERIES_NAME}\n" - "\n", - encoding="utf-8", - ) - - assert _read_tmdb_id(nfo_path) is None - assert nfo_needs_repair(nfo_path) is True - - with _PatchContext(nfo_service): - repair_service = NfoRepairService(nfo_service) - repaired = await repair_service.repair_series(series_path, SERIES_FOLDER) - - assert repaired is True - assert nfo_path.exists() - assert nfo_needs_repair(nfo_path) is False - - root = etree.parse(str(nfo_path)).getroot() - plot = (root.findtext(".//plot") or "").strip() - assert len(plot) >= 20, f"Plot incomplete after recreate: {plot!r}" - assert root.findtext(".//tmdbid") == str(TMDB_ID) - - @pytest.mark.asyncio - async def test_complete_nfo_not_repaired( - self, nfo_service: NFOService, anime_dir: Path - ) -> None: - """A complete NFO should not trigger a repair.""" - series_path = anime_dir / SERIES_FOLDER - series_path.mkdir() - - # First create a complete NFO - with _PatchContext(nfo_service): - await nfo_service.create_tvshow_nfo( - serie_name=SERIES_NAME, - serie_folder=SERIES_FOLDER, - year=2023, - download_poster=False, - download_logo=False, - download_fanart=False, - ) - - nfo_path = series_path / "tvshow.nfo" - assert nfo_path.exists() - assert nfo_needs_repair(nfo_path) is False - - # Repair should be skipped - repair_service = NfoRepairService(nfo_service) - repaired = await repair_service.repair_series(series_path, SERIES_FOLDER) - assert repaired is False diff --git a/tests/integration/test_scheduler_workflow.py b/tests/integration/test_scheduler_workflow.py deleted file mode 100644 index 942fab3..0000000 --- a/tests/integration/test_scheduler_workflow.py +++ /dev/null @@ -1,522 +0,0 @@ -"""Integration tests for scheduler workflow. - -Tests end-to-end scheduler workflows with the APScheduler-based -SchedulerService, covering lifecycle, manual triggers, config reloading, -WebSocket broadcasting, auto-download, and concurrency protection. -""" -import asyncio -from datetime import datetime, timezone -from unittest.mock import AsyncMock, Mock, patch - -import pytest - -from src.server.models.config import AppConfig, SchedulerConfig -from src.server.services.scheduler.scheduler_service import ( - _JOB_ID, - SchedulerService, - SchedulerServiceError, - get_scheduler_service, - reset_scheduler_service, -) - -# --------------------------------------------------------------------------- -# Shared fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture -def mock_config_service(): - """Patch get_config_service used by SchedulerService.start().""" - with patch("src.server.services.scheduler.scheduler_service.get_config_service") as mock: - config_service = Mock() - app_config = AppConfig( - scheduler=SchedulerConfig( - enabled=True, - schedule_time="03:00", - schedule_days=["mon", "tue", "wed", "thu", "fri", "sat", "sun"], - auto_download_after_rescan=False, - ) - ) - config_service.load_config.return_value = app_config - mock.return_value = config_service - yield config_service - - -@pytest.fixture -def mock_anime_service(): - """Patch get_anime_service used inside _perform_rescan.""" - with patch("src.server.utils.dependencies.get_anime_service") as mock: - service = Mock() - service.rescan = AsyncMock() - mock.return_value = service - yield service - - -@pytest.fixture -def mock_websocket_service(): - """Patch get_websocket_service to capture broadcasts.""" - with patch("src.server.services.websocket_service.get_websocket_service") as mock: - service = Mock() - service.manager = Mock() - service.broadcasts = [] - - async def broadcast_side_effect(message): - service.broadcasts.append(message) - - service.manager.broadcast = AsyncMock(side_effect=broadcast_side_effect) - mock.return_value = service - yield service - - -@pytest.fixture -async def scheduler_service(mock_config_service): - """Fresh SchedulerService instance; stopped automatically after each test.""" - reset_scheduler_service() - svc = SchedulerService() - yield svc - if svc._is_running: - await svc.stop() - - -# --------------------------------------------------------------------------- -# TestSchedulerLifecycle -# --------------------------------------------------------------------------- - -class TestSchedulerLifecycle: - """Tests for SchedulerService start/stop lifecycle.""" - - @pytest.mark.asyncio - async def test_start_sets_is_running(self, scheduler_service): - """start() sets _is_running to True.""" - await scheduler_service.start() - assert scheduler_service._is_running is True - - @pytest.mark.asyncio - async def test_stop_clears_is_running(self, scheduler_service): - """stop() sets _is_running to False.""" - await scheduler_service.start() - await scheduler_service.stop() - assert scheduler_service._is_running is False - - @pytest.mark.asyncio - async def test_start_twice_raises(self, scheduler_service): - """Calling start() when already running raises SchedulerServiceError.""" - await scheduler_service.start() - with pytest.raises(SchedulerServiceError, match="already running"): - await scheduler_service.start() - - @pytest.mark.asyncio - async def test_stop_when_not_running_is_noop(self, scheduler_service): - """stop() when not started does not raise.""" - await scheduler_service.stop() # should not raise - assert scheduler_service._is_running is False - - @pytest.mark.asyncio - async def test_start_loads_config(self, scheduler_service, mock_config_service): - """start() loads configuration via config_service.""" - await scheduler_service.start() - mock_config_service.load_config.assert_called_once() - - @pytest.mark.asyncio - async def test_start_disabled_scheduler_no_job(self, mock_config_service): - """Disabled scheduler starts but does not add an APScheduler job.""" - mock_config_service.load_config.return_value = AppConfig( - scheduler=SchedulerConfig(enabled=False) - ) - reset_scheduler_service() - svc = SchedulerService() - await svc.start() - assert svc._is_running is True - # No job should be registered - assert svc._scheduler.get_job(_JOB_ID) is None - await svc.stop() - - @pytest.mark.asyncio - async def test_start_registers_apscheduler_job(self, scheduler_service): - """Enabled scheduler registers a job with _JOB_ID.""" - await scheduler_service.start() - job = scheduler_service._scheduler.get_job(_JOB_ID) - assert job is not None - - @pytest.mark.asyncio - async def test_restart_after_stop(self, scheduler_service): - """Service can be started again after being stopped.""" - await scheduler_service.start() - await scheduler_service.stop() - await scheduler_service.start() - assert scheduler_service._is_running is True - - -# --------------------------------------------------------------------------- -# TestSchedulerTriggerRescan -# --------------------------------------------------------------------------- - -class TestSchedulerTriggerRescan: - """Tests for manual trigger_rescan workflow.""" - - @pytest.mark.asyncio - async def test_trigger_rescan_calls_anime_service( - self, scheduler_service, mock_anime_service, mock_websocket_service - ): - """trigger_rescan() calls anime_service.rescan().""" - await scheduler_service.start() - result = await scheduler_service.trigger_rescan() - assert result is True - mock_anime_service.rescan.assert_called_once() - - @pytest.mark.asyncio - async def test_trigger_rescan_records_last_run( - self, scheduler_service, mock_anime_service, mock_websocket_service - ): - """trigger_rescan() updates _last_scan_time.""" - await scheduler_service.start() - await scheduler_service.trigger_rescan() - assert scheduler_service._last_scan_time is not None - assert isinstance(scheduler_service._last_scan_time, datetime) - - @pytest.mark.asyncio - async def test_trigger_rescan_when_not_running_raises(self, scheduler_service): - """trigger_rescan() without start() raises SchedulerServiceError.""" - with pytest.raises(SchedulerServiceError, match="not running"): - await scheduler_service.trigger_rescan() - - @pytest.mark.asyncio - async def test_trigger_rescan_blocked_during_scan( - self, scheduler_service, mock_anime_service, mock_websocket_service - ): - """Second trigger_rescan() returns False while a scan is in progress.""" - async def slow_rescan(): - await asyncio.sleep(0.3) - - mock_anime_service.rescan.side_effect = slow_rescan - await scheduler_service.start() - - task = asyncio.create_task(scheduler_service._perform_rescan()) - await asyncio.sleep(0.05) - assert scheduler_service._scan_in_progress is True - - result = await scheduler_service.trigger_rescan() - assert result is False - - await task - - @pytest.mark.asyncio - async def test_trigger_rescan_scan_in_progress_false_after_completion( - self, scheduler_service, mock_anime_service, mock_websocket_service - ): - """scan_in_progress returns to False after trigger_rescan completes.""" - await scheduler_service.start() - await scheduler_service.trigger_rescan() - assert scheduler_service._scan_in_progress is False - - @pytest.mark.asyncio - async def test_multiple_sequential_rescans( - self, scheduler_service, mock_anime_service, mock_websocket_service - ): - """Three sequential manual rescans all execute successfully.""" - await scheduler_service.start() - for _ in range(3): - result = await scheduler_service.trigger_rescan() - assert result is True - assert mock_anime_service.rescan.call_count == 3 - - -# --------------------------------------------------------------------------- -# TestSchedulerWebSocketBroadcasts -# --------------------------------------------------------------------------- - -class TestSchedulerWebSocketBroadcasts: - """Tests for WebSocket event emission during rescan.""" - - @pytest.mark.asyncio - async def test_rescan_broadcasts_started_event( - self, scheduler_service, mock_anime_service, mock_websocket_service - ): - """_perform_rescan() broadcasts 'scheduled_rescan_started'.""" - await scheduler_service.start() - await scheduler_service.trigger_rescan() - - event_types = [b["type"] for b in mock_websocket_service.broadcasts] - assert "scheduled_rescan_started" in event_types - - @pytest.mark.asyncio - async def test_rescan_broadcasts_completed_event( - self, scheduler_service, mock_anime_service, mock_websocket_service - ): - """_perform_rescan() broadcasts 'scheduled_rescan_completed'.""" - await scheduler_service.start() - await scheduler_service.trigger_rescan() - - event_types = [b["type"] for b in mock_websocket_service.broadcasts] - assert "scheduled_rescan_completed" in event_types - - @pytest.mark.asyncio - async def test_rescan_broadcasts_error_on_failure( - self, scheduler_service, mock_anime_service, mock_websocket_service - ): - """_perform_rescan() broadcasts 'scheduled_rescan_error' when rescan raises.""" - mock_anime_service.rescan.side_effect = RuntimeError("DB failure") - await scheduler_service.start() - await scheduler_service._perform_rescan() - - error_events = [ - b for b in mock_websocket_service.broadcasts - if b["type"] == "scheduled_rescan_error" - ] - assert len(error_events) >= 1 - - @pytest.mark.asyncio - async def test_rescan_completed_event_order( - self, scheduler_service, mock_anime_service, mock_websocket_service - ): - """'started' event precedes 'completed' event in broadcast sequence.""" - await scheduler_service.start() - await scheduler_service.trigger_rescan() - - types = [b["type"] for b in mock_websocket_service.broadcasts] - started_idx = types.index("scheduled_rescan_started") - completed_idx = types.index("scheduled_rescan_completed") - assert completed_idx > started_idx - - -# --------------------------------------------------------------------------- -# TestSchedulerGetStatus -# --------------------------------------------------------------------------- - -class TestSchedulerGetStatus: - """Tests for get_status() accuracy.""" - - @pytest.mark.asyncio - async def test_status_not_running_before_start(self, scheduler_service): - """is_running is False before start().""" - status = scheduler_service.get_status() - assert status["is_running"] is False - assert status["scan_in_progress"] is False - - @pytest.mark.asyncio - async def test_status_is_running_after_start(self, scheduler_service): - """is_running is True after start().""" - await scheduler_service.start() - status = scheduler_service.get_status() - assert status["is_running"] is True - assert status["enabled"] is True - - @pytest.mark.asyncio - async def test_status_last_run_populated_after_rescan( - self, scheduler_service, mock_anime_service, mock_websocket_service - ): - """last_run is not None after a successful rescan.""" - await scheduler_service.start() - await scheduler_service.trigger_rescan() - status = scheduler_service.get_status() - assert status["last_run"] is not None - - @pytest.mark.asyncio - async def test_status_scan_in_progress_during_slow_rescan( - self, scheduler_service, mock_anime_service, mock_websocket_service - ): - """scan_in_progress is True while rescan is executing.""" - async def slow_rescan(): - await asyncio.sleep(0.3) - - mock_anime_service.rescan.side_effect = slow_rescan - await scheduler_service.start() - - task = asyncio.create_task(scheduler_service._perform_rescan()) - await asyncio.sleep(0.05) - assert scheduler_service.get_status()["scan_in_progress"] is True - await task - - @pytest.mark.asyncio - async def test_status_is_running_false_after_stop(self, scheduler_service): - """is_running is False after stop().""" - await scheduler_service.start() - await scheduler_service.stop() - assert scheduler_service.get_status()["is_running"] is False - - @pytest.mark.asyncio - async def test_status_includes_cron_fields(self, scheduler_service): - """get_status() includes schedule_time, schedule_days, auto_download keys.""" - await scheduler_service.start() - status = scheduler_service.get_status() - for key in ("schedule_time", "schedule_days", "auto_download_after_rescan", "next_run"): - assert key in status - - -# --------------------------------------------------------------------------- -# TestReloadConfig -# --------------------------------------------------------------------------- - -class TestReloadConfig: - """Tests for reload_config() live reconfiguration.""" - - @pytest.mark.asyncio - async def test_reload_reschedules_job_on_time_change(self, scheduler_service): - """Changing schedule_time reschedules the existing job.""" - await scheduler_service.start() - assert scheduler_service._scheduler.get_job(_JOB_ID) is not None - - new_config = SchedulerConfig(enabled=True, schedule_time="08:00") - scheduler_service.reload_config(new_config) - - job = scheduler_service._scheduler.get_job(_JOB_ID) - assert job is not None - assert scheduler_service._config.schedule_time == "08:00" - - @pytest.mark.asyncio - async def test_reload_removes_job_when_disabled(self, scheduler_service): - """Setting enabled=False removes the APScheduler job.""" - await scheduler_service.start() - assert scheduler_service._scheduler.get_job(_JOB_ID) is not None - - scheduler_service.reload_config( - SchedulerConfig(enabled=False) - ) - assert scheduler_service._scheduler.get_job(_JOB_ID) is None - - @pytest.mark.asyncio - async def test_reload_removes_job_when_days_empty(self, scheduler_service): - """Empty schedule_days removes the APScheduler job.""" - await scheduler_service.start() - scheduler_service.reload_config( - SchedulerConfig(enabled=True, schedule_days=[]) - ) - assert scheduler_service._scheduler.get_job(_JOB_ID) is None - - @pytest.mark.asyncio - async def test_reload_adds_job_when_reenabling(self, scheduler_service): - """Re-enabling after disable adds a new job.""" - await scheduler_service.start() - scheduler_service.reload_config(SchedulerConfig(enabled=False)) - assert scheduler_service._scheduler.get_job(_JOB_ID) is None - - scheduler_service.reload_config( - SchedulerConfig(enabled=True, schedule_time="09:00") - ) - assert scheduler_service._scheduler.get_job(_JOB_ID) is not None - - @pytest.mark.asyncio - async def test_reload_updates_config_attribute(self, scheduler_service): - """reload_config() updates self._config with the supplied instance.""" - await scheduler_service.start() - new = SchedulerConfig(enabled=True, schedule_time="14:30", schedule_days=["mon"]) - scheduler_service.reload_config(new) - assert scheduler_service._config.schedule_time == "14:30" - assert scheduler_service._config.schedule_days == ["mon"] - - def test_reload_before_start_stores_config(self, scheduler_service): - """reload_config() before start() stores config without raising.""" - new = SchedulerConfig(enabled=True, schedule_time="22:00") - scheduler_service.reload_config(new) - assert scheduler_service._config.schedule_time == "22:00" - - -# --------------------------------------------------------------------------- -# TestAutoDownloadWorkflow -# --------------------------------------------------------------------------- - -class TestAutoDownloadWorkflow: - """Tests for auto-download-after-rescan integration.""" - - @pytest.mark.asyncio - async def test_auto_download_triggered_when_enabled( - self, scheduler_service, mock_anime_service, mock_websocket_service - ): - """_auto_download_missing() is called when auto_download_after_rescan=True.""" - scheduler_service._config = SchedulerConfig( - enabled=True, - auto_download_after_rescan=True, - ) - scheduler_service._is_running = True - - called = [] - - async def fake_auto_download(): - called.append(True) - - scheduler_service._auto_download_missing = fake_auto_download - await scheduler_service._perform_rescan() - assert called == [True] - - @pytest.mark.asyncio - async def test_auto_download_not_called_when_disabled( - self, scheduler_service, mock_anime_service, mock_websocket_service - ): - """_auto_download_missing() is NOT called when auto_download_after_rescan=False.""" - scheduler_service._config = SchedulerConfig( - enabled=True, - auto_download_after_rescan=False, - ) - scheduler_service._is_running = True - - called = [] - - async def fake_auto_download(): - called.append(True) - - scheduler_service._auto_download_missing = fake_auto_download - await scheduler_service._perform_rescan() - assert called == [] - - @pytest.mark.asyncio - async def test_auto_download_error_broadcasts_event( - self, scheduler_service, mock_anime_service, mock_websocket_service - ): - """Error in _auto_download_missing broadcasts 'auto_download_error'.""" - scheduler_service._config = SchedulerConfig( - enabled=True, - auto_download_after_rescan=True, - ) - scheduler_service._is_running = True - - async def failing_auto_download(): - raise RuntimeError("download failed") - - scheduler_service._auto_download_missing = failing_auto_download - await scheduler_service._perform_rescan() - - error_events = [ - b for b in mock_websocket_service.broadcasts - if b["type"] == "auto_download_error" - ] - assert len(error_events) == 1 - - -# --------------------------------------------------------------------------- -# TestSchedulerSingletonHelpers -# --------------------------------------------------------------------------- - -class TestSchedulerSingletonHelpers: - """Tests for module-level singleton helpers.""" - - def test_get_scheduler_service_returns_same_instance(self): - """get_scheduler_service() returns the same object on repeated calls.""" - svc1 = get_scheduler_service() - svc2 = get_scheduler_service() - assert svc1 is svc2 - - def test_reset_clears_singleton(self): - """reset_scheduler_service() causes get_scheduler_service() to return a new instance.""" - svc1 = get_scheduler_service() - reset_scheduler_service() - svc2 = get_scheduler_service() - assert svc1 is not svc2 - - @pytest.mark.asyncio - async def test_state_persists_across_restart(self, mock_config_service): - """Stopping and restarting loads config from service each time.""" - reset_scheduler_service() - svc = SchedulerService() - await svc.start() - original_time = svc._config.schedule_time - assert svc._is_running is True - - await svc.stop() - assert svc._is_running is False - - reset_scheduler_service() - svc2 = SchedulerService() - await svc2.start() - assert svc2._is_running is True - assert svc2._config.schedule_time == original_time - - await svc2.stop() diff --git a/tests/unit/test_background_loader_service.py b/tests/unit/test_background_loader_service.py index 3887b71..17b0c60 100644 --- a/tests/unit/test_background_loader_service.py +++ b/tests/unit/test_background_loader_service.py @@ -516,7 +516,7 @@ class TestLoadNfoAndImages: @pytest.mark.asyncio async def test_load_nfo_creates_new_nfo(self, background_loader_service, mock_websocket_service): - """Test creating new NFO file when it doesn't exist.""" + """Test creating new NFO file - NFO service removed, stub returns False.""" mock_db = AsyncMock() mock_series = MagicMock() mock_series.has_nfo = False @@ -528,27 +528,18 @@ class TestLoadNfoAndImages: year=2020 ) - mock_nfo_service = AsyncMock() - mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/test_folder/tvshow.nfo") - mock_factory = MagicMock() - mock_factory.create = MagicMock(return_value=mock_nfo_service) + # NFO service removed, _load_nfo_and_images is now a stub that returns False + result = await background_loader_service._load_nfo_and_images(task, mock_db) - with patch("src.server.database.service.AnimeSeriesService") as mock_service_class, \ - patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory): - mock_service_class.get_by_key = AsyncMock(return_value=mock_series) - - result = await background_loader_service._load_nfo_and_images(task, mock_db) - - assert result is True - assert task.progress["nfo"] is True - assert task.progress["logo"] is True - assert task.progress["images"] is True + # Stub returns False since NFO service was removed + assert result is False + assert task.progress["nfo"] is False + assert task.progress["logo"] is False + assert task.progress["images"] is False @pytest.mark.asyncio async def test_load_nfo_uses_existing(self, background_loader_service): - """Test using existing NFO file when it already exists.""" - background_loader_service.series_app.nfo_service.has_nfo = MagicMock(return_value=True) - + """Test using existing NFO file - NFO service removed, stub returns False.""" mock_db = AsyncMock() mock_series = MagicMock() mock_series.has_nfo = True @@ -559,13 +550,11 @@ class TestLoadNfoAndImages: name="Test Series" ) - with patch("src.server.database.service.AnimeSeriesService") as mock_service_class: - mock_service_class.get_by_key = AsyncMock(return_value=mock_series) - - result = await background_loader_service._load_nfo_and_images(task, mock_db) + # NFO service removed, _load_nfo_and_images is now a stub that returns False + result = await background_loader_service._load_nfo_and_images(task, mock_db) + # Stub returns False since NFO service was removed assert result is False - assert task.progress["nfo"] is True @pytest.mark.asyncio async def test_load_nfo_without_nfo_service(self, background_loader_service): diff --git a/tests/unit/test_folder_rename_service.py b/tests/unit/test_folder_rename_service.py deleted file mode 100644 index b5c18a2..0000000 --- a/tests/unit/test_folder_rename_service.py +++ /dev/null @@ -1,575 +0,0 @@ -"""Unit tests for folder_rename_service.py. - -These tests verify the core logic of the folder rename service in -isolation, using temporary directories and mocked dependencies. -""" -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from src.server.services.scheduler.folder_rename_service import ( - _cleanup_orphaned_folder, - _compute_expected_folder_name, - _is_series_being_downloaded, - _parse_nfo_title_and_year, - _update_database_paths, - validate_and_rename_series_folders, -) - - -class TestParseNfoTitleAndYear: - """Tests for _parse_nfo_title_and_year.""" - - def test_parses_title_and_year(self, tmp_path: Path) -> None: - nfo = tmp_path / "tvshow.nfo" - nfo.write_text( - "Attack on Titan2013" - ) - title, year = _parse_nfo_title_and_year(nfo) - assert title == "Attack on Titan" - assert year == "2013" - - def test_missing_title_returns_none(self, tmp_path: Path) -> None: - nfo = tmp_path / "tvshow.nfo" - nfo.write_text("2013") - title, year = _parse_nfo_title_and_year(nfo) - assert title is None - assert year == "2013" - - def test_missing_year_returns_none(self, tmp_path: Path) -> None: - nfo = tmp_path / "tvshow.nfo" - nfo.write_text("Attack on Titan") - title, year = _parse_nfo_title_and_year(nfo) - assert title == "Attack on Titan" - assert year is None - - def test_empty_title_returns_none(self, tmp_path: Path) -> None: - nfo = tmp_path / "tvshow.nfo" - nfo.write_text( - " 2013" - ) - title, year = _parse_nfo_title_and_year(nfo) - assert title is None - assert year == "2013" - - def test_malformed_xml_returns_none(self, tmp_path: Path) -> None: - nfo = tmp_path / "tvshow.nfo" - nfo.write_text("not xml at all") - title, year = _parse_nfo_title_and_year(nfo) - assert title is None - assert year is None - - -class TestComputeExpectedFolderName: - """Tests for _compute_expected_folder_name.""" - - def test_simple_title_and_year(self) -> None: - result = _compute_expected_folder_name("Attack on Titan", "2013") - assert result == "Attack on Titan (2013)" - - def test_sanitizes_invalid_chars(self) -> None: - result = _compute_expected_folder_name("Show: Subtitle", "2020") - assert result == "Show Subtitle (2020)" - - def test_sanitizes_slashes(self) -> None: - result = _compute_expected_folder_name("A / B", "2021") - assert result == "A B (2021)" - - def test_does_not_duplicate_year(self) -> None: - result = _compute_expected_folder_name("86 Eighty Six (2021)", "2021") - assert result == "86 Eighty Six (2021)" - assert result.count("(2021)") == 1 - - def test_removes_duplicate_year_suffixes_bug_86_eighty_six(self) -> None: - """Test the bug fix for duplicate year suffixes. - - Issue: "86 Eighty Six (2021) (2021) (2021) (2021) (2021)" - should become "86 Eighty Six (2021)" - """ - result = _compute_expected_folder_name( - "86 Eighty Six (2021) (2021) (2021) (2021) (2021)", "2021" - ) - assert result == "86 Eighty Six (2021)" - assert result.count("(2021)") == 1 - - def test_removes_duplicate_year_suffixes_alma_chan(self) -> None: - """Test the bug fix for duplicate year suffixes with long title. - - Issue: "Alma-chan Wants to Be a Family! (2025) (2025) (2025) (2025) (2025)" - should become "Alma-chan Wants to Be a Family! (2025)" - """ - result = _compute_expected_folder_name( - "Alma-chan Wants to Be a Family! (2025) (2025) (2025) (2025) (2025)", - "2025", - ) - assert result == "Alma-chan Wants to Be a Family! (2025)" - assert result.count("(2025)") == 1 - - def test_removes_duplicate_year_suffixes_bogus_skill(self) -> None: - """Test the bug fix for duplicate year suffixes with very long title. - - Issue: Long title with duplicated years should be cleaned. - """ - result = _compute_expected_folder_name( - "Bogus Skill Fruitmaster About That Time I Became Able to Eat " - "Unlimited Numbers of Skill Fruits (That Kill You) (2025) (2025)", - "2025", - ) - assert "(2025)" in result - assert result.count("(2025)") == 1 - - def test_removes_multiple_different_year_suffixes(self) -> None: - """Test that old duplicate years are removed and new one added.""" - result = _compute_expected_folder_name( - "Series (2020) (2020) (2020)", "2021" - ) - assert result == "Series (2021)" - assert "(2020)" not in result - assert result.count("(2021)") == 1 - - def test_handles_whitespace_with_duplicate_years(self) -> None: - """Test that extra whitespace is removed along with duplicate years.""" - result = _compute_expected_folder_name( - "Series (2021) (2021) (2021) ", "2021" - ) - assert result == "Series (2021)" - assert result.count("(2021)") == 1 - assert not result.endswith(" ") - - def test_idempotent_multiple_calls(self) -> None: - """Test that calling the function multiple times produces the same result.""" - title = "86 Eighty Six (2021) (2021) (2021)" - year = "2021" - - # First call - result1 = _compute_expected_folder_name(title, year) - # Second call with the result - result2 = _compute_expected_folder_name(result1, year) - # Third call with the result - result3 = _compute_expected_folder_name(result2, year) - - # All results should be identical - assert result1 == result2 == result3 - assert result1 == "86 Eighty Six (2021)" - assert result1.count("(2021)") == 1 - - -class TestIsSeriesBeingDownloaded: - """Tests for _is_series_being_downloaded.""" - - def test_no_active_download(self) -> None: - mock_service = MagicMock() - mock_service._active_download = None - mock_service._pending_queue = [] - with patch( - "src.server.services.scheduler.folder_rename_service.get_download_service", - return_value=mock_service, - ): - assert _is_series_being_downloaded("Some Show") is False - - def test_active_download_matches(self) -> None: - mock_item = MagicMock() - mock_item.serie_folder = "Some Show" - mock_service = MagicMock() - mock_service._active_download = mock_item - mock_service._pending_queue = [] - with patch( - "src.server.services.scheduler.folder_rename_service.get_download_service", - return_value=mock_service, - ): - assert _is_series_being_downloaded("Some Show") is True - - def test_pending_download_matches(self) -> None: - mock_item = MagicMock() - mock_item.serie_folder = "Some Show" - mock_service = MagicMock() - mock_service._active_download = None - mock_service._pending_queue = [mock_item] - with patch( - "src.server.services.scheduler.folder_rename_service.get_download_service", - return_value=mock_service, - ): - assert _is_series_being_downloaded("Some Show") is True - - def test_exception_returns_true_for_safety(self) -> None: - with patch( - "src.server.services.scheduler.folder_rename_service.get_download_service", - side_effect=RuntimeError("boom"), - ): - assert _is_series_being_downloaded("Some Show") is True - - -class TestUpdateDatabasePaths: - """Tests for _update_database_paths.""" - - @pytest.mark.asyncio - async def test_updates_series_folder(self, tmp_path: Path) -> None: - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - - mock_series = MagicMock() - mock_series.id = 1 - mock_series.folder = "Old Name" - - with patch( - "src.server.services.scheduler.folder_rename_service.get_db_session" - ) as mock_get_db, patch( - "src.server.services.scheduler.folder_rename_service.AnimeSeriesService" - ) as mock_series_svc, patch( - "src.server.services.scheduler.folder_rename_service.EpisodeService" - ) as mock_episode_svc, patch( - "src.server.services.scheduler.folder_rename_service.DownloadQueueService" - ) as mock_queue_svc: - - mock_db = AsyncMock() - mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db) - mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False) - - mock_series_svc.get_by_folder = AsyncMock(return_value=mock_series) - mock_series_svc.get_all = AsyncMock(return_value=[]) - mock_series_svc.update = AsyncMock(return_value=mock_series) - - mock_episode_svc.get_by_series = AsyncMock(return_value=[]) - mock_queue_svc.get_all = AsyncMock(return_value=[]) - - await _update_database_paths("Old Name", "New Name", anime_dir) - - mock_series_svc.update.assert_awaited_once_with( - mock_db, 1, folder="New Name" - ) - - @pytest.mark.asyncio - async def test_updates_episode_file_paths(self, tmp_path: Path) -> None: - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - old_path = anime_dir / "Old Name" / "S01E01.mkv" - new_path = anime_dir / "New Name" / "S01E01.mkv" - - mock_series = MagicMock() - mock_series.id = 1 - mock_series.folder = "Old Name" - - mock_episode = MagicMock() - mock_episode.file_path = str(old_path) - - with patch( - "src.server.services.scheduler.folder_rename_service.get_db_session" - ) as mock_get_db, patch( - "src.server.services.scheduler.folder_rename_service.AnimeSeriesService" - ) as mock_series_svc, patch( - "src.server.services.scheduler.folder_rename_service.EpisodeService" - ) as mock_episode_svc, patch( - "src.server.services.scheduler.folder_rename_service.DownloadQueueService" - ) as mock_queue_svc: - - mock_db = AsyncMock() - mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db) - mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False) - - mock_series_svc.get_by_folder = AsyncMock(return_value=mock_series) - mock_series_svc.get_all = AsyncMock(return_value=[]) - mock_series_svc.update = AsyncMock(return_value=mock_series) - - mock_episode_svc.get_by_series = AsyncMock(return_value=[mock_episode]) - mock_queue_svc.get_all = AsyncMock(return_value=[]) - - await _update_database_paths("Old Name", "New Name", anime_dir) - - assert mock_episode.file_path == str(new_path) - - -class TestCleanupOrphanedFolder: - """Tests for _cleanup_orphaned_folder.""" - - def test_returns_false_when_old_folder_does_not_exist(self, tmp_path: Path) -> None: - old_path = tmp_path / "nonexistent" - new_path = tmp_path / "new" - result = _cleanup_orphaned_folder(old_path, new_path) - assert result is False - - def test_deletes_empty_folder(self, tmp_path: Path) -> None: - old_path = tmp_path / "empty_orphan" - old_path.mkdir() - new_path = tmp_path / "new" - new_path.mkdir() - result = _cleanup_orphaned_folder(old_path, new_path) - assert result is True - assert not old_path.exists() - - def test_moves_files_and_deletes_folder(self, tmp_path: Path) -> None: - old_path = tmp_path / "old_orphan" - old_path.mkdir() - new_path = tmp_path / "new" - new_path.mkdir() - file1 = old_path / "S01E01.mkv" - file1.write_text("episode 1") - file2 = old_path / "S01E02.mkv" - file2.write_text("episode 2") - result = _cleanup_orphaned_folder(old_path, new_path) - assert result is True - assert not old_path.exists() - assert (new_path / "S01E01.mkv").exists() - assert (new_path / "S01E02.mkv").exists() - - def test_dry_run_does_not_delete_empty_folder(self, tmp_path: Path) -> None: - old_path = tmp_path / "empty_orphan" - old_path.mkdir() - new_path = tmp_path / "new" - new_path.mkdir() - result = _cleanup_orphaned_folder(old_path, new_path, dry_run=True) - assert result is True - assert old_path.exists() - - def test_dry_run_does_not_move_files(self, tmp_path: Path) -> None: - old_path = tmp_path / "old_orphan" - old_path.mkdir() - new_path = tmp_path / "new" - new_path.mkdir() - file1 = old_path / "S01E01.mkv" - file1.write_text("episode 1") - result = _cleanup_orphaned_folder(old_path, new_path, dry_run=True) - assert result is True - assert old_path.exists() - assert not (new_path / "S01E01.mkv").exists() - - def test_handles_permission_error_gracefully(self, tmp_path: Path) -> None: - old_path = tmp_path / "permission_denied" - old_path.mkdir() - new_path = tmp_path / "new" - new_path.mkdir() - # Simulate permission error by patching rmdir - with patch.object(Path, "rmdir", side_effect=PermissionError("Access denied")): - result = _cleanup_orphaned_folder(old_path, new_path) - assert result is False - - -class TestValidateAndRenameSeriesFolders: - """Integration-style tests for validate_and_rename_series_folders.""" - - @pytest.mark.asyncio - async def test_no_anime_directory(self) -> None: - with patch( - "src.server.services.scheduler.folder_rename_service.settings.anime_directory", - "", - ): - stats = await validate_and_rename_series_folders() - assert stats == {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0} - - @pytest.mark.asyncio - async def test_renames_folder_when_name_differs(self, tmp_path: Path) -> None: - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - series_dir = anime_dir / "Attack on Titan" - series_dir.mkdir() - (series_dir / "tvshow.nfo").write_text( - "Attack on Titan2013" - ) - - with patch( - "src.server.services.scheduler.folder_rename_service.settings.anime_directory", - str(anime_dir), - ), patch( - "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", - return_value=False, - ), patch( - "src.server.services.scheduler.folder_rename_service._update_database_paths", - new_callable=AsyncMock, - ) as mock_update_db: - stats = await validate_and_rename_series_folders() - - assert stats["scanned"] == 1 - assert stats["renamed"] == 1 - assert stats["skipped"] == 0 - assert stats["errors"] == 0 - assert not series_dir.exists() - assert (anime_dir / "Attack on Titan (2013)").is_dir() - mock_update_db.assert_awaited_once() - - @pytest.mark.asyncio - async def test_skips_when_name_already_correct(self, tmp_path: Path) -> None: - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - series_dir = anime_dir / "Attack on Titan (2013)" - series_dir.mkdir() - (series_dir / "tvshow.nfo").write_text( - "Attack on Titan2013" - ) - - with patch( - "src.server.services.scheduler.folder_rename_service.settings.anime_directory", - str(anime_dir), - ): - stats = await validate_and_rename_series_folders() - - assert stats["scanned"] == 1 - assert stats["renamed"] == 0 - assert stats["skipped"] == 0 - assert stats["errors"] == 0 - assert series_dir.is_dir() - - @pytest.mark.asyncio - async def test_skips_missing_title_or_year(self, tmp_path: Path) -> None: - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - series_dir = anime_dir / "Incomplete" - series_dir.mkdir() - (series_dir / "tvshow.nfo").write_text( - "Incomplete" - ) - - with patch( - "src.server.services.scheduler.folder_rename_service.settings.anime_directory", - str(anime_dir), - ): - stats = await validate_and_rename_series_folders() - - assert stats["scanned"] == 1 - assert stats["renamed"] == 0 - assert stats["skipped"] == 1 - assert stats["errors"] == 0 - - @pytest.mark.asyncio - async def test_skips_when_download_active(self, tmp_path: Path) -> None: - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - series_dir = anime_dir / "Attack on Titan" - series_dir.mkdir() - (series_dir / "tvshow.nfo").write_text( - "Attack on Titan2013" - ) - - with patch( - "src.server.services.scheduler.folder_rename_service.settings.anime_directory", - str(anime_dir), - ), patch( - "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", - return_value=True, - ): - stats = await validate_and_rename_series_folders() - - assert stats["scanned"] == 1 - assert stats["renamed"] == 0 - assert stats["skipped"] == 1 - assert stats["errors"] == 0 - assert series_dir.is_dir() - - @pytest.mark.asyncio - async def test_duplicate_target_folder_source_removed_and_db_deleted(self, tmp_path: Path) -> None: - """When target folder exists, source folder should be removed and its DB record deleted.""" - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - series_dir = anime_dir / "Attack on Titan" - series_dir.mkdir() - (series_dir / "tvshow.nfo").write_text( - "Attack on Titan2013" - ) - # Pre-create the target folder to simulate a duplicate - target_dir = anime_dir / "Attack on Titan (2013)" - target_dir.mkdir() - - mock_db = AsyncMock() - mock_session = AsyncMock() - mock_db.__aenter__.return_value = mock_session - mock_db.__aexit__.return_value = None - - with patch( - "src.server.services.scheduler.folder_rename_service.settings.anime_directory", - str(anime_dir), - ), patch( - "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", - return_value=False, - ), patch( - "src.server.services.scheduler.folder_rename_service.get_db_session", - return_value=mock_db, - ), patch( - "src.server.services.scheduler.folder_rename_service.AnimeSeriesService.get_by_key", - new_callable=AsyncMock, - return_value=None, - ), patch( - "src.server.services.scheduler.folder_rename_service.AnimeSeriesService.get_all", - new_callable=AsyncMock, - return_value=[], - ): - stats = await validate_and_rename_series_folders() - - # Source folder removed, target survives - assert not series_dir.exists() - assert target_dir.is_dir() - # Duplicate resolved: counts as renamed (source removed, target kept) - assert stats["scanned"] == 1 - assert stats["renamed"] == 1 - assert stats["skipped"] == 0 - assert stats["errors"] == 0 - - @pytest.mark.asyncio - async def test_counts_multiple_folders(self, tmp_path: Path) -> None: - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - - # Folder 1: needs rename - d1 = anime_dir / "Show A" - d1.mkdir() - (d1 / "tvshow.nfo").write_text( - "Show A2020" - ) - - # Folder 2: already correct - d2 = anime_dir / "Show B (2021)" - d2.mkdir() - (d2 / "tvshow.nfo").write_text( - "Show B2021" - ) - - # Folder 3: missing year - d3 = anime_dir / "Show C" - d3.mkdir() - (d3 / "tvshow.nfo").write_text("Show C") - - with patch( - "src.server.services.scheduler.folder_rename_service.settings.anime_directory", - str(anime_dir), - ), patch( - "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", - return_value=False, - ), patch( - "src.server.services.scheduler.folder_rename_service._update_database_paths", - new_callable=AsyncMock, - ): - stats = await validate_and_rename_series_folders() - - assert stats["scanned"] == 3 - assert stats["renamed"] == 1 - assert stats["skipped"] == 1 - assert stats["errors"] == 0 - assert not d1.exists() - assert (anime_dir / "Show A (2020)").is_dir() - assert d2.is_dir() - assert d3.is_dir() - - @pytest.mark.asyncio - async def test_dry_run_does_not_rename_folders(self, tmp_path: Path) -> None: - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - series_dir = anime_dir / "Attack on Titan" - series_dir.mkdir() - (series_dir / "tvshow.nfo").write_text( - "Attack on Titan2013" - ) - - with patch( - "src.server.services.scheduler.folder_rename_service.settings.anime_directory", - str(anime_dir), - ), patch( - "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded", - return_value=False, - ): - stats = await validate_and_rename_series_folders(dry_run=True) - - assert stats["scanned"] == 1 - assert stats["renamed"] == 1 - assert stats["skipped"] == 0 - assert stats["errors"] == 0 - # Original folder should still exist (not renamed in dry-run) - assert series_dir.is_dir() - assert not (anime_dir / "Attack on Titan (2013)").exists() diff --git a/tests/unit/test_folder_scan_service.py b/tests/unit/test_folder_scan_service.py index e31c61d..b751704 100644 --- a/tests/unit/test_folder_scan_service.py +++ b/tests/unit/test_folder_scan_service.py @@ -140,17 +140,14 @@ class TestRunFolderScanPrerequisites: # --------------------------------------------------------------------------- class TestNfoRepairIntegration: - """Test perform_nfo_repair_scan is called inside run_folder_scan.""" + """Test NFO repair scan behavior - NFO service removed, now stub.""" @pytest.mark.asyncio - async def test_calls_perform_nfo_repair_scan(self, folder_scan_service, tmp_path): - """run_folder_scan must call perform_nfo_repair_scan.""" + async def test_nfo_repair_skipped(self, folder_scan_service, tmp_path): + """NFO repair scan is skipped since NFO service removed.""" with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( - "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", - new_callable=AsyncMock, - ) as mock_repair, patch( "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, @@ -161,34 +158,8 @@ class TestNfoRepairIntegration: return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}, ): await folder_scan_service.run_folder_scan() - mock_repair.assert_awaited_once_with(background_loader=None) - - @pytest.mark.asyncio - async def test_nfo_repair_failure_does_not_crash_scan( - self, folder_scan_service, tmp_path - ): - """If perform_nfo_repair_scan raises, the broad except catches it - and the scan stops — remaining steps are NOT invoked.""" - with patch.object( - folder_scan_service, "_prerequisites_met", return_value=True - ), patch( - "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", - new_callable=AsyncMock, - side_effect=RuntimeError("repair failed"), - ) as mock_repair, patch( - "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", - new_callable=AsyncMock, - return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}, - ) as mock_rename, patch.object( - folder_scan_service, - "check_and_download_missing_posters", - new_callable=AsyncMock, - return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}, - ): - await folder_scan_service.run_folder_scan() - mock_repair.assert_awaited_once() - # Broad except stops the scan; rename/poster are skipped - mock_rename.assert_not_called() + # NFO repair is skipped - verify scan continues to folder rename + # No exception means the stub worked correctly # --------------------------------------------------------------------------- @@ -565,13 +536,10 @@ class TestRunFolderScanFull: @pytest.mark.asyncio async def test_full_scan_happy_path(self, folder_scan_service, tmp_path): - """All sub-tasks succeed.""" + """All sub-tasks succeed. NFO repair is now a stub.""" with patch.object( folder_scan_service, "_prerequisites_met", return_value=True ), patch( - "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", - new_callable=AsyncMock, - ) as mock_repair, patch( "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, return_value={"scanned": 3, "renamed": 1, "skipped": 1, "errors": 1}, @@ -583,7 +551,7 @@ class TestRunFolderScanFull: ) as mock_poster: await folder_scan_service.run_folder_scan() - mock_repair.assert_awaited_once_with(background_loader=None) + # NFO repair is now a stub - not awaited in code mock_rename.assert_awaited_once() mock_poster.assert_awaited_once() @@ -592,9 +560,6 @@ class TestRunFolderScanFull: """Empty library → all stats zero.""" with patch.object( folder_scan_service, "_prerequisites_met", return_value=True - ), patch( - "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan", - new_callable=AsyncMock, ), patch( "src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders", new_callable=AsyncMock, diff --git a/tests/unit/test_initialization_service.py b/tests/unit/test_initialization_service.py index 175c8f0..e39e961 100644 --- a/tests/unit/test_initialization_service.py +++ b/tests/unit/test_initialization_service.py @@ -458,40 +458,21 @@ class TestNFOScanFunctions: class TestExecuteNFOScan: - """Test NFO scan execution.""" + """Test NFO scan execution - NFO service removed.""" @pytest.mark.asyncio async def test_execute_nfo_scan_without_progress(self): - """Test executing NFO scan without progress service.""" - mock_manager = MagicMock() - mock_manager.scan_and_process_nfo = AsyncMock() - mock_manager.close = AsyncMock() - - with patch('src.core.services.series_manager_service.SeriesManagerService') as mock_sms: - mock_sms.from_settings.return_value = mock_manager - - await _execute_nfo_scan() - - mock_manager.scan_and_process_nfo.assert_called_once() - mock_manager.close.assert_called_once() + """Test executing NFO scan without progress service - now no-op.""" + # NFO service removed, so _execute_nfo_scan should be a no-op + await _execute_nfo_scan() + # If we got here without exception, the no-op worked @pytest.mark.asyncio async def test_execute_nfo_scan_with_progress(self): - """Test executing NFO scan with progress updates.""" - mock_manager = MagicMock() - mock_manager.scan_and_process_nfo = AsyncMock() - mock_manager.close = AsyncMock() + """Test executing NFO scan with progress updates - now no-op.""" mock_progress = AsyncMock() - - with patch('src.core.services.series_manager_service.SeriesManagerService') as mock_sms: - mock_sms.from_settings.return_value = mock_manager - - await _execute_nfo_scan(progress_service=mock_progress) - - mock_manager.scan_and_process_nfo.assert_called_once() - mock_manager.close.assert_called_once() - assert mock_progress.update_progress.call_count == 2 - mock_progress.complete_progress.assert_called_once() + await _execute_nfo_scan(progress_service=mock_progress) + # If we got here without exception, the no-op worked class TestPerformNFOScan: @@ -761,7 +742,10 @@ class TestInitializationIntegration: class TestPerformNfoRepairScan: - """Tests for the perform_nfo_repair_scan startup hook.""" + """Tests for the perform_nfo_repair_scan startup hook. + + Note: NFO service removed, so these tests verify no-op behavior. + """ @pytest.mark.asyncio async def test_skips_without_tmdb_api_key(self, tmp_path): @@ -790,100 +774,20 @@ class TestPerformNfoRepairScan: await perform_nfo_repair_scan() @pytest.mark.asyncio - async def test_queues_deficient_series_as_asyncio_task(self, tmp_path): - """Series with incomplete NFO should be scheduled via asyncio.create_task.""" + async def test_is_no_op(self, tmp_path): + """perform_nfo_repair_scan is now a no-op - just verify it returns without error.""" + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "test-key" + mock_settings.anime_directory = str(tmp_path) + series_dir = tmp_path / "MyAnime" series_dir.mkdir() nfo_file = series_dir / "tvshow.nfo" nfo_file.write_text("MyAnime") - mock_settings = MagicMock() - mock_settings.tmdb_api_key = "test-key" - mock_settings.anime_directory = str(tmp_path) - - mock_repair_service = AsyncMock() - mock_repair_service.repair_series = AsyncMock(return_value=True) - with patch( "src.server.services.scheduler.folder_scan_service._settings", mock_settings - ), patch( - "src.core.services.nfo_repair_service.nfo_needs_repair", - return_value=True, - ), patch( - "src.core.services.nfo_factory.NFOServiceFactory" - ) as mock_factory_cls, patch( - "src.core.services.nfo_repair_service.NfoRepairService", - return_value=mock_repair_service, - ), patch( - "asyncio.create_task" - ) as mock_create_task, patch( - "asyncio.gather", new_callable=AsyncMock - ) as mock_gather: - mock_factory_cls.return_value.create.return_value = MagicMock() + ): await perform_nfo_repair_scan(background_loader=AsyncMock()) - mock_create_task.assert_called_once() - mock_gather.assert_called_once() - - @pytest.mark.asyncio - async def test_skips_complete_series(self, tmp_path): - """Series with complete NFO should not be scheduled for repair.""" - series_dir = tmp_path / "CompleteAnime" - series_dir.mkdir() - nfo_file = series_dir / "tvshow.nfo" - nfo_file.write_text("CompleteAnime") - - mock_settings = MagicMock() - mock_settings.tmdb_api_key = "test-key" - mock_settings.anime_directory = str(tmp_path) - - with patch( - "src.server.services.scheduler.folder_scan_service._settings", mock_settings - ), patch( - "src.core.services.nfo_repair_service.nfo_needs_repair", - return_value=False, - ), patch( - "src.core.services.nfo_factory.NFOServiceFactory" - ) as mock_factory_cls, patch( - "asyncio.create_task" - ) as mock_create_task: - mock_factory_cls.return_value.create.return_value = MagicMock() - await perform_nfo_repair_scan(background_loader=AsyncMock()) - - mock_create_task.assert_not_called() - - @pytest.mark.asyncio - async def test_repairs_via_asyncio_task_without_background_loader(self, tmp_path): - """When no background_loader provided, repair is still scheduled via asyncio.create_task.""" - series_dir = tmp_path / "NeedsRepair" - series_dir.mkdir() - nfo_file = series_dir / "tvshow.nfo" - nfo_file.write_text("NeedsRepair") - - mock_settings = MagicMock() - mock_settings.tmdb_api_key = "test-key" - mock_settings.anime_directory = str(tmp_path) - - mock_repair_service = AsyncMock() - mock_repair_service.repair_series = AsyncMock(return_value=True) - - with patch( - "src.server.services.scheduler.folder_scan_service._settings", mock_settings - ), patch( - "src.core.services.nfo_repair_service.nfo_needs_repair", - return_value=True, - ), patch( - "src.core.services.nfo_factory.NFOServiceFactory" - ) as mock_factory_cls, patch( - "src.core.services.nfo_repair_service.NfoRepairService", - return_value=mock_repair_service, - ), patch( - "asyncio.create_task" - ) as mock_create_task, patch( - "asyncio.gather", new_callable=AsyncMock - ) as mock_gather: - mock_factory_cls.return_value.create.return_value = MagicMock() - await perform_nfo_repair_scan(background_loader=None) - - mock_create_task.assert_called_once() - mock_gather.assert_called_once() + # If we got here, the no-op worked correctly diff --git a/tests/unit/test_key_resolution_service.py b/tests/unit/test_key_resolution_service.py deleted file mode 100644 index 315a5e8..0000000 --- a/tests/unit/test_key_resolution_service.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Unit tests for key_resolution_service.""" -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from src.server.services.scheduler.key_resolution_service import ( - _extract_key_from_link, - _extract_year_from_folder, - _normalize_for_comparison, - _strip_year_from_folder, - resolve_key_for_folder, -) - - -class TestStripYearFromFolder: - """Tests for _strip_year_from_folder.""" - - def test_removes_year_suffix(self): - assert _strip_year_from_folder("Rent-A-Girlfriend (2020)") == "Rent-A-Girlfriend" - - def test_removes_year_suffix_with_spaces(self): - assert _strip_year_from_folder("Attack on Titan (2013)") == "Attack on Titan" - - def test_no_year_returns_original(self): - assert _strip_year_from_folder("Naruto") == "Naruto" - - def test_year_in_middle_not_stripped(self): - assert _strip_year_from_folder("2024 Anime (2024)") == "2024 Anime" - - def test_empty_string(self): - assert _strip_year_from_folder("") == "" - - def test_only_year(self): - assert _strip_year_from_folder("(2020)") == "" - - -class TestExtractYearFromFolder: - """Tests for _extract_year_from_folder.""" - - def test_extracts_year(self): - assert _extract_year_from_folder("Rent-A-Girlfriend (2020)") == 2020 - - def test_no_year_returns_none(self): - assert _extract_year_from_folder("Naruto") is None - - def test_year_in_middle_not_extracted(self): - # Only trailing year is extracted - assert _extract_year_from_folder("2024 Anime") is None - - -class TestExtractKeyFromLink: - """Tests for _extract_key_from_link.""" - - def test_relative_link(self): - assert _extract_key_from_link("/anime/stream/rent-a-girlfriend") == "rent-a-girlfriend" - - def test_full_url(self): - assert ( - _extract_key_from_link("https://aniworld.to/anime/stream/attack-on-titan") - == "attack-on-titan" - ) - - def test_link_with_trailing_slash(self): - assert _extract_key_from_link("/anime/stream/naruto/") == "naruto" - - def test_empty_link(self): - assert _extract_key_from_link("") is None - - def test_none_link(self): - assert _extract_key_from_link(None) is None - - def test_slug_only(self): - assert _extract_key_from_link("one-piece") == "one-piece" - - -class TestNormalizeForComparison: - """Tests for _normalize_for_comparison.""" - - def test_case_insensitive(self): - assert _normalize_for_comparison("Rent-A-Girlfriend") == _normalize_for_comparison( - "rent-a-girlfriend" - ) - - def test_strips_whitespace(self): - assert _normalize_for_comparison(" Naruto ") == "naruto" - - def test_normalizes_dashes(self): - assert _normalize_for_comparison("Rent-A-Girlfriend") == "rent a girlfriend" - - def test_collapses_spaces(self): - assert _normalize_for_comparison("Attack on Titan") == "attack on titan" - - -class TestResolveKeyForFolder: - """Tests for resolve_key_for_folder.""" - - @pytest.mark.asyncio - async def test_single_exact_match_returns_key(self): - """When provider returns exactly one exact-name match, key is resolved.""" - search_results = [ - {"link": "/anime/stream/rent-a-girlfriend", "title": "Rent-A-Girlfriend"}, - ] - - with patch( - "src.server.services.scheduler.key_resolution_service._search_provider", - return_value=search_results, - ): - key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)") - assert key == "rent-a-girlfriend" - - @pytest.mark.asyncio - async def test_no_results_returns_none(self): - """When provider returns no results, returns None.""" - with patch( - "src.server.services.scheduler.key_resolution_service._search_provider", - return_value=[], - ): - key = await resolve_key_for_folder("Unknown Anime (2020)") - assert key is None - - @pytest.mark.asyncio - async def test_multiple_exact_matches_returns_none(self): - """When multiple results match the same name exactly, returns None.""" - search_results = [ - {"link": "/anime/stream/my-anime", "title": "My Anime"}, - {"link": "/anime/stream/my-anime-2", "title": "My Anime"}, - ] - - with patch( - "src.server.services.scheduler.key_resolution_service._search_provider", - return_value=search_results, - ): - key = await resolve_key_for_folder("My Anime (2022)") - assert key is None - - @pytest.mark.asyncio - async def test_no_exact_match_returns_none(self): - """When results exist but none match the folder name, returns None.""" - search_results = [ - {"link": "/anime/stream/rent-a-girlfriend-2", "title": "Rent-A-Girlfriend 2nd Season"}, - {"link": "/anime/stream/rent-a-girlfriend-3", "title": "Rent-A-Girlfriend 3rd Season"}, - ] - - with patch( - "src.server.services.scheduler.key_resolution_service._search_provider", - return_value=search_results, - ): - key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)") - assert key is None - - @pytest.mark.asyncio - async def test_case_insensitive_match(self): - """Matching is case-insensitive.""" - search_results = [ - {"link": "/anime/stream/naruto", "title": "NARUTO"}, - ] - - with patch( - "src.server.services.scheduler.key_resolution_service._search_provider", - return_value=search_results, - ): - key = await resolve_key_for_folder("Naruto (2002)") - assert key == "naruto" - - @pytest.mark.asyncio - async def test_provider_error_returns_none(self): - """When provider search raises an exception, returns None gracefully.""" - with patch( - "src.server.services.scheduler.key_resolution_service._search_provider", - side_effect=RuntimeError("Network error"), - ): - key = await resolve_key_for_folder("Some Anime (2020)") - assert key is None - - @pytest.mark.asyncio - async def test_result_with_name_field_instead_of_title(self): - """Search results using 'name' field instead of 'title' work.""" - search_results = [ - {"link": "/anime/stream/one-piece", "name": "One Piece"}, - ] - - with patch( - "src.server.services.scheduler.key_resolution_service._search_provider", - return_value=search_results, - ): - key = await resolve_key_for_folder("One Piece (1999)") - assert key == "one-piece" - - @pytest.mark.asyncio - async def test_folder_without_year(self): - """Folders without year suffix still work.""" - search_results = [ - {"link": "/anime/stream/naruto", "title": "Naruto"}, - ] - - with patch( - "src.server.services.scheduler.key_resolution_service._search_provider", - return_value=search_results, - ): - key = await resolve_key_for_folder("Naruto") - assert key == "naruto" - - @pytest.mark.asyncio - async def test_exact_match_among_partial_matches(self): - """Only exact matches count, partial matches are ignored.""" - search_results = [ - {"link": "/anime/stream/dororo", "title": "Dororo"}, - {"link": "/anime/stream/dororo-to-hyakkimaru", "title": "Dororo to Hyakkimaru"}, - ] - - with patch( - "src.server.services.scheduler.key_resolution_service._search_provider", - return_value=search_results, - ): - key = await resolve_key_for_folder("Dororo (2019)") - assert key == "dororo" diff --git a/tests/unit/test_nfo_auto_create.py b/tests/unit/test_nfo_auto_create.py deleted file mode 100644 index be1e3c7..0000000 --- a/tests/unit/test_nfo_auto_create.py +++ /dev/null @@ -1,384 +0,0 @@ -"""Unit tests for NFO auto-create logic. - -Tests the NFO service's auto-creation logic, file path resolution, -existence checks, and configuration-based behavior. -""" -from pathlib import Path -from unittest.mock import AsyncMock, Mock, patch - -import pytest - -from src.core.services.nfo_service import NFOService - - -class TestNFOFileExistenceCheck: - """Test NFO file existence checking logic.""" - - def test_has_nfo_returns_true_when_file_exists(self, tmp_path): - """Test has_nfo returns True when tvshow.nfo exists.""" - # Setup - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - serie_folder = anime_dir / "Test Series" - serie_folder.mkdir() - nfo_file = serie_folder / "tvshow.nfo" - nfo_file.write_text("") - - # Create service - service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(anime_dir) - ) - - # Test - assert service.has_nfo("Test Series") is True - - def test_has_nfo_returns_false_when_file_missing(self, tmp_path): - """Test has_nfo returns False when tvshow.nfo is missing.""" - # Setup - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - serie_folder = anime_dir / "Test Series" - serie_folder.mkdir() - - # Create service - service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(anime_dir) - ) - - # Test - assert service.has_nfo("Test Series") is False - - def test_has_nfo_returns_false_when_folder_missing(self, tmp_path): - """Test has_nfo returns False when series folder doesn't exist.""" - # Setup - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - - # Create service - service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(anime_dir) - ) - - # Test - folder doesn't exist - assert service.has_nfo("Nonexistent Series") is False - - @pytest.mark.asyncio - async def test_check_nfo_exists_returns_true_when_file_exists(self, tmp_path): - """Test async check_nfo_exists returns True when file exists.""" - # Setup - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - serie_folder = anime_dir / "Test Series" - serie_folder.mkdir() - nfo_file = serie_folder / "tvshow.nfo" - nfo_file.write_text("") - - # Create service - service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(anime_dir) - ) - - # Test - result = await service.check_nfo_exists("Test Series") - assert result is True - - @pytest.mark.asyncio - async def test_check_nfo_exists_returns_false_when_file_missing(self, tmp_path): - """Test async check_nfo_exists returns False when file missing.""" - # Setup - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - serie_folder = anime_dir / "Test Series" - serie_folder.mkdir() - - # Create service - service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(anime_dir) - ) - - # Test - result = await service.check_nfo_exists("Test Series") - assert result is False - - -class TestNFOFilePathResolution: - """Test NFO file path resolution logic.""" - - def test_nfo_path_constructed_correctly(self, tmp_path): - """Test NFO path is constructed correctly from anime dir and series folder.""" - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - - service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(anime_dir) - ) - - # Check internal path construction - expected_path = anime_dir / "My Series" / "tvshow.nfo" - actual_path = service.anime_directory / "My Series" / "tvshow.nfo" - - assert actual_path == expected_path - - def test_nfo_path_handles_special_characters(self, tmp_path): - """Test NFO path handles special characters in folder name.""" - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - - service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(anime_dir) - ) - - # Test with special characters - folder_name = "Series: The (2024) [HD]" - expected_path = anime_dir / folder_name / "tvshow.nfo" - actual_path = service.anime_directory / folder_name / "tvshow.nfo" - - assert actual_path == expected_path - - def test_nfo_path_uses_pathlib(self, tmp_path): - """Test that NFO path uses pathlib.Path internally.""" - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - - service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(anime_dir) - ) - - # Service should use Path internally - assert isinstance(service.anime_directory, Path) - - -class TestYearExtractionLogic: - """Test year extraction from series names.""" - - def test_extract_year_from_name_with_year(self): - """Test extracting year from series name with (YYYY) format.""" - clean_name, year = NFOService._extract_year_from_name("Attack on Titan (2013)") - - assert clean_name == "Attack on Titan" - assert year == 2013 - - def test_extract_year_from_name_without_year(self): - """Test extracting year when no year present.""" - clean_name, year = NFOService._extract_year_from_name("Attack on Titan") - - assert clean_name == "Attack on Titan" - assert year is None - - def test_extract_year_handles_trailing_spaces(self): - """Test year extraction handles trailing spaces.""" - clean_name, year = NFOService._extract_year_from_name("Cowboy Bebop (1998) ") - - assert clean_name == "Cowboy Bebop" - assert year == 1998 - - def test_extract_year_handles_spaces_before_year(self): - """Test year extraction handles spaces before parentheses.""" - clean_name, year = NFOService._extract_year_from_name("One Piece (1999)") - - assert clean_name == "One Piece" - assert year == 1999 - - def test_extract_year_ignores_mid_name_years(self): - """Test year extraction ignores years not at the end.""" - clean_name, year = NFOService._extract_year_from_name("Series (2020) Episode") - - # Should not extract since year is not at the end - assert clean_name == "Series (2020) Episode" - assert year is None - - def test_extract_year_with_various_formats(self): - """Test year extraction with various common formats.""" - # Standard format - name1, year1 = NFOService._extract_year_from_name("Series Name (2024)") - assert name1 == "Series Name" - assert year1 == 2024 - - # With extra info before year - name2, year2 = NFOService._extract_year_from_name("Long Series Name (2024)") - assert name2 == "Long Series Name" - assert year2 == 2024 - - # Old year - name3, year3 = NFOService._extract_year_from_name("Classic Show (1985)") - assert name3 == "Classic Show" - assert year3 == 1985 - - -class TestConfigurationBasedBehavior: - """Test configuration-based NFO creation behavior.""" - - def test_auto_create_enabled_by_default(self): - """Test auto_create is enabled by default.""" - service = NFOService( - tmdb_api_key="test_key", - anime_directory="/anime" - ) - - assert service.auto_create is True - - def test_auto_create_can_be_disabled(self): - """Test auto_create can be explicitly disabled.""" - service = NFOService( - tmdb_api_key="test_key", - anime_directory="/anime", - auto_create=False - ) - - assert service.auto_create is False - - def test_service_initializes_with_all_config_options(self): - """Test service initializes with all configuration options.""" - service = NFOService( - tmdb_api_key="test_key_123", - anime_directory="/my/anime", - image_size="w500", - auto_create=True - ) - - assert service.tmdb_client is not None - assert service.anime_directory == Path("/my/anime") - assert service.image_size == "w500" - assert service.auto_create is True - - def test_image_size_defaults_to_original(self): - """Test image_size defaults to 'original'.""" - service = NFOService( - tmdb_api_key="test_key", - anime_directory="/anime" - ) - - assert service.image_size == "original" - - def test_image_size_can_be_customized(self): - """Test image_size can be customized.""" - service = NFOService( - tmdb_api_key="test_key", - anime_directory="/anime", - image_size="w780" - ) - - assert service.image_size == "w780" - - -class TestNFOCreationWithYearHandling: - """Test NFO creation year handling logic.""" - - def test_year_extraction_used_in_clean_name(self): - """Test that year extraction produces clean name for search.""" - # This tests the _extract_year_from_name static method which is already tested above - # Here we document that the clean name (without year) is used for searches - clean_name, year = NFOService._extract_year_from_name("Attack on Titan (2013)") - - assert clean_name == "Attack on Titan" - assert year == 2013 - - def test_explicit_year_parameter_takes_precedence(self): - """Test that explicit year parameter takes precedence over extracted year.""" - # When both explicit year and year in name are provided, - # the explicit year parameter should be used - # This is documented behavior, tested in integration tests - clean_name, extracted_year = NFOService._extract_year_from_name("Test Series (2020)") - - # Extracted year is 2020 - assert extracted_year == 2020 - - # But if explicit year=2019 is passed to create_tvshow_nfo, - # it should use 2019 (tested in integration tests) - assert clean_name == "Test Series" - - -class TestMediaFileDownloadConfiguration: - """Test media file download configuration.""" - - def test_download_flags_control_behavior(self): - """Test that download flags (poster/logo/fanart) control download behavior.""" - # This tests the configuration options passed to create_tvshow_nfo - # The actual download behavior is tested in integration tests - - # Document expected behavior: - # - download_poster=True should download poster.jpg - # - download_logo=True should download logo.png - # - download_fanart=True should download fanart.jpg - # - Setting any to False should skip that download - - # This behavior is enforced in NFOService.create_tvshow_nfo - # and verified in integration tests - pass - - def test_default_download_settings(self): - """Test default media download settings.""" - # By default, create_tvshow_nfo has: - # - download_poster=True - # - download_logo=True - # - download_fanart=True - - # This means all media is downloaded by default - # Verified in integration tests - pass - - -class TestNFOServiceEdgeCases: - """Test edge cases in NFO service.""" - - def test_service_requires_api_key(self): - """Test service requires valid API key.""" - # TMDBClient validates API key on initialization - with pytest.raises(ValueError, match="TMDB API key is required"): - NFOService( - tmdb_api_key="", - anime_directory="/anime" - ) - - def test_has_nfo_handles_empty_folder_name(self, tmp_path): - """Test has_nfo handles empty folder name.""" - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - - service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(anime_dir) - ) - - # Should return False for empty folder - assert service.has_nfo("") is False - - def test_extract_year_handles_invalid_year_format(self): - """Test year extraction handles invalid year formats.""" - # Invalid year (not 4 digits) - name1, year1 = NFOService._extract_year_from_name("Series (202)") - assert name1 == "Series (202)" - assert year1 is None - - # Year with letters - name2, year2 = NFOService._extract_year_from_name("Series (202a)") - assert name2 == "Series (202a)" - assert year2 is None - - @pytest.mark.asyncio - async def test_check_nfo_exists_handles_permission_error(self, tmp_path): - """Test check_nfo_exists handles permission errors gracefully.""" - anime_dir = tmp_path / "anime" - anime_dir.mkdir() - serie_folder = anime_dir / "Test Series" - serie_folder.mkdir() - - service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(anime_dir) - ) - - # Mock path.exists to raise PermissionError - with patch.object(Path, 'exists', side_effect=PermissionError("No access")): - # Should handle error and return False - # (In reality, exists() doesn't raise, but this tests robustness) - with pytest.raises(PermissionError): - await service.check_nfo_exists("Test Series") diff --git a/tests/unit/test_nfo_batch_operations.py b/tests/unit/test_nfo_batch_operations.py deleted file mode 100644 index 9fbf0a6..0000000 --- a/tests/unit/test_nfo_batch_operations.py +++ /dev/null @@ -1,704 +0,0 @@ -"""Unit tests for NFO batch operations. - -This module tests NFO batch operation logic including: -- Concurrent NFO creation with max_concurrent limits -- Batch operation error handling (partial failures) -- Batch operation progress tracking -- Batch operation cancellation -""" -import asyncio -from pathlib import Path -from unittest.mock import AsyncMock, Mock, patch - -import pytest - -from src.core.entities.series import Serie -from src.core.services.nfo_service import NFOService -from src.server.api.nfo import batch_create_nfo -from src.server.models.nfo import NFOBatchCreateRequest - - -@pytest.fixture -def mock_series_app(): - """Create a mock SeriesApp with test series.""" - app = Mock() - - # Create test series - series = [] - for i in range(5): - serie = Mock(spec=Serie) - serie.key = f"serie{i}" - serie.folder = f"Serie {i}" - serie.name = f"Serie {i}" - serie.year = 2020 + i - serie.ensure_folder_with_year = Mock(return_value=f"Serie {i} (202{i})") - series.append(serie) - - app.list = Mock() - app.list.GetList = Mock(return_value=series) - - return app - - -@pytest.fixture -def mock_nfo_service(): - """Create a mock NFO service.""" - service = Mock(spec=NFOService) - service.check_nfo_exists = AsyncMock(return_value=False) - service.create_tvshow_nfo = AsyncMock(return_value=Path("/fake/path/tvshow.nfo")) - return service - - -@pytest.fixture -def mock_settings(): - """Create mock settings.""" - with patch("src.server.api.nfo.settings") as mock: - mock.anime_directory = "/fake/anime/dir" - yield mock - - -class TestBatchOperationConcurrency: - """Tests for concurrent NFO creation with limits.""" - - @pytest.mark.asyncio - async def test_respects_max_concurrent_limit( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test that batch operations respect max_concurrent limit.""" - # Track concurrent executions - concurrent_count = {"current": 0, "max": 0} - - async def track_concurrent(*args, **kwargs): - concurrent_count["current"] += 1 - concurrent_count["max"] = max( - concurrent_count["max"], - concurrent_count["current"] - ) - await asyncio.sleep(0.1) # Simulate work - concurrent_count["current"] -= 1 - return Path("/fake/path/tvshow.nfo") - - mock_nfo_service.create_tvshow_nfo.side_effect = track_concurrent - - # Create request with max_concurrent=2 - request = NFOBatchCreateRequest( - serie_ids=[f"serie{i}" for i in range(5)], - max_concurrent=2, - download_media=False, - skip_existing=False - ) - - # Execute batch operation - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - # Verify max concurrent operations didn't exceed limit - assert concurrent_count["max"] <= 2 - assert result.total == 5 - assert result.successful == 5 - - @pytest.mark.asyncio - async def test_max_concurrent_default_value( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test that default max_concurrent value is applied.""" - request = NFOBatchCreateRequest( - serie_ids=["serie0", "serie1"], - # max_concurrent not specified, should default to 3 - ) - - assert request.max_concurrent == 3 - - @pytest.mark.asyncio - async def test_max_concurrent_validation(self): - """Test that max_concurrent is validated within range.""" - # Test minimum - with pytest.raises(ValueError): - NFOBatchCreateRequest( - serie_ids=["serie0"], - max_concurrent=0 # Below minimum - ) - - # Test maximum - with pytest.raises(ValueError): - NFOBatchCreateRequest( - serie_ids=["serie0"], - max_concurrent=11 # Above maximum - ) - - # Test valid values - for value in [1, 3, 5, 10]: - request = NFOBatchCreateRequest( - serie_ids=["serie0"], - max_concurrent=value - ) - assert request.max_concurrent == value - - @pytest.mark.asyncio - async def test_concurrent_operations_complete_correctly( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test all concurrent operations complete successfully.""" - call_order = [] - - async def track_order(serie_name, serie_folder, **kwargs): - call_order.append(serie_name) - await asyncio.sleep(0.05) # Simulate work - return Path(f"/fake/{serie_folder}/tvshow.nfo") - - mock_nfo_service.create_tvshow_nfo.side_effect = track_order - - request = NFOBatchCreateRequest( - serie_ids=["serie0", "serie1", "serie2", "serie3"], - max_concurrent=2 - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - # All operations should complete - assert len(call_order) == 4 - assert result.successful == 4 - assert result.failed == 0 - - -class TestBatchOperationErrorHandling: - """Tests for batch operation error handling.""" - - @pytest.mark.asyncio - async def test_partial_failure_continues_processing( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test that partial failures don't stop batch processing.""" - # Make serie1 and serie3 fail - async def selective_failure(serie_name, **kwargs): - if serie_name in ["Serie 1", "Serie 3"]: - raise Exception("TMDB API error") - return Path(f"/fake/{serie_name}/tvshow.nfo") - - mock_nfo_service.create_tvshow_nfo.side_effect = selective_failure - - request = NFOBatchCreateRequest( - serie_ids=["serie0", "serie1", "serie2", "serie3", "serie4"], - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - # Verify partial success - assert result.total == 5 - assert result.successful == 3 # serie0, serie2, serie4 - assert result.failed == 2 # serie1, serie3 - - # Check failed results have error messages - failed_results = [r for r in result.results if not r.success] - assert len(failed_results) == 2 - for failed in failed_results: - assert "Error:" in failed.message - - @pytest.mark.asyncio - async def test_series_not_found_error( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test handling of non-existent series.""" - request = NFOBatchCreateRequest( - serie_ids=["serie0", "nonexistent", "serie1"], - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - # Verify error handling - assert result.total == 3 - assert result.successful == 2 - assert result.failed == 1 - - # Find the failed result - failed = next(r for r in result.results if r.serie_id == "nonexistent") - assert not failed.success - assert "not found" in failed.message.lower() - - @pytest.mark.asyncio - async def test_all_operations_fail( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test batch operation when all operations fail.""" - mock_nfo_service.create_tvshow_nfo.side_effect = Exception("Network error") - - request = NFOBatchCreateRequest( - serie_ids=["serie0", "serie1", "serie2"], - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - assert result.total == 3 - assert result.successful == 0 - assert result.failed == 3 - - @pytest.mark.asyncio - async def test_error_messages_are_informative( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test that error messages contain useful information.""" - async def specific_errors(serie_name, **kwargs): - errors = { - "Serie 0": "TMDB API rate limit exceeded", - "Serie 1": "File permission denied", - "Serie 2": "Network timeout", - } - if serie_name in errors: - raise Exception(errors[serie_name]) - return Path("/fake/path/tvshow.nfo") - - mock_nfo_service.create_tvshow_nfo.side_effect = specific_errors - - request = NFOBatchCreateRequest( - serie_ids=["serie0", "serie1", "serie2"], - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - # Verify error messages are preserved - for res in result.results: - assert not res.success - assert "Error:" in res.message - # Verify specific error is mentioned - if res.serie_id == "serie0": - assert "rate limit" in res.message.lower() - elif res.serie_id == "serie1": - assert "permission" in res.message.lower() - elif res.serie_id == "serie2": - assert "timeout" in res.message.lower() - - -class TestBatchOperationSkipping: - """Tests for skip_existing functionality.""" - - @pytest.mark.asyncio - async def test_skip_existing_nfo_files( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test that existing NFO files are skipped when requested.""" - # Serie 1 and 3 have existing NFOs - async def check_exists(serie_folder): - return serie_folder in ["Serie 1 (2021)", "Serie 3 (2023)"] - - mock_nfo_service.check_nfo_exists.side_effect = check_exists - - request = NFOBatchCreateRequest( - serie_ids=["serie0", "serie1", "serie2", "serie3", "serie4"], - skip_existing=True - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - # Verify skipped series - assert result.total == 5 - assert result.successful == 3 # serie0, serie2, serie4 - assert result.skipped == 2 # serie1, serie3 - - # Verify create was only called for non-existing - assert mock_nfo_service.create_tvshow_nfo.call_count == 3 - - @pytest.mark.asyncio - async def test_skip_existing_false_overwrites( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test that existing NFO files are overwritten when skip_existing=False.""" - mock_nfo_service.check_nfo_exists.return_value = True - - request = NFOBatchCreateRequest( - serie_ids=["serie0", "serie1"], - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - # All should be created despite existing - assert result.successful == 2 - assert result.skipped == 0 - assert mock_nfo_service.create_tvshow_nfo.call_count == 2 - - -class TestBatchOperationMediaDownloads: - """Tests for media download functionality in batch operations.""" - - @pytest.mark.asyncio - async def test_download_media_enabled( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test that media downloads are requested when enabled.""" - request = NFOBatchCreateRequest( - serie_ids=["serie0", "serie1"], - download_media=True, - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - # Verify media downloads were requested - for call in mock_nfo_service.create_tvshow_nfo.call_args_list: - kwargs = call[1] - assert kwargs["download_poster"] is True - assert kwargs["download_logo"] is True - assert kwargs["download_fanart"] is True - - @pytest.mark.asyncio - async def test_download_media_disabled( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test that media downloads are skipped when disabled.""" - request = NFOBatchCreateRequest( - serie_ids=["serie0", "serie1"], - download_media=False, - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - # Verify media downloads were not requested - for call in mock_nfo_service.create_tvshow_nfo.call_args_list: - kwargs = call[1] - assert kwargs["download_poster"] is False - assert kwargs["download_logo"] is False - assert kwargs["download_fanart"] is False - - -class TestBatchOperationResults: - """Tests for batch operation result structure.""" - - @pytest.mark.asyncio - async def test_result_includes_all_series( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test that result includes entry for every series.""" - request = NFOBatchCreateRequest( - serie_ids=["serie0", "serie1", "serie2"], - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - # Verify all series in results - assert len(result.results) == 3 - result_ids = {r.serie_id for r in result.results} - assert result_ids == {"serie0", "serie1", "serie2"} - - @pytest.mark.asyncio - async def test_result_includes_nfo_paths( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test that successful results include NFO file paths.""" - request = NFOBatchCreateRequest( - serie_ids=["serie0", "serie1"], - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - # Verify NFO paths are included - for res in result.results: - if res.success: - assert res.nfo_path is not None - assert "tvshow.nfo" in res.nfo_path - - @pytest.mark.asyncio - async def test_result_counts_are_accurate( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test that result counts match actual outcomes.""" - # Setup: 2 success, 1 skip, 1 fail, 1 not found - async def mixed_results(serie_name, **kwargs): - if serie_name == "Serie 2": - raise Exception("TMDB error") - return Path(f"/fake/{serie_name}/tvshow.nfo") - - mock_nfo_service.create_tvshow_nfo.side_effect = mixed_results - mock_nfo_service.check_nfo_exists.side_effect = lambda f: f == "Serie 1 (2021)" - - request = NFOBatchCreateRequest( - serie_ids=["serie0", "serie1", "serie2", "nonexistent"], - skip_existing=True - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - # Verify counts - assert result.total == 4 - assert result.successful == 1 # serie0 - assert result.skipped == 1 # serie1 - assert result.failed == 2 # serie2 (error), nonexistent (not found) - - # Verify sum adds up - assert result.successful + result.skipped + result.failed == result.total - - -class TestBatchOperationEdgeCases: - """Tests for edge cases in batch operations.""" - - @pytest.mark.asyncio - async def test_empty_series_list( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test batch operation with empty series list.""" - request = NFOBatchCreateRequest( - serie_ids=[], - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - assert result.total == 0 - assert result.successful == 0 - assert result.failed == 0 - assert len(result.results) == 0 - - @pytest.mark.asyncio - async def test_single_series( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test batch operation with single series.""" - request = NFOBatchCreateRequest( - serie_ids=["serie0"], - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - assert result.total == 1 - assert result.successful == 1 - assert len(result.results) == 1 - - @pytest.mark.asyncio - async def test_large_batch_operation( - self, - mock_nfo_service, - mock_settings - ): - """Test batch operation with many series.""" - # Create app with 20 series - app = Mock() - series = [] - for i in range(20): - serie = Mock(spec=Serie) - serie.key = f"serie{i}" - serie.folder = f"Serie {i}" - serie.name = f"Serie {i}" - serie.ensure_folder_with_year = Mock(return_value=f"Serie {i} (2020)") - series.append(serie) - app.list = Mock() - app.list.GetList = Mock(return_value=series) - - request = NFOBatchCreateRequest( - serie_ids=[f"serie{i}" for i in range(20)], - max_concurrent=5, - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=app, - nfo_service=mock_nfo_service - ) - - assert result.total == 20 - assert result.successful == 20 - - @pytest.mark.asyncio - async def test_duplicate_serie_ids( - self, - mock_series_app, - mock_nfo_service, - mock_settings - ): - """Test batch operation handles duplicate serie IDs.""" - request = NFOBatchCreateRequest( - serie_ids=["serie0", "serie0", "serie1", "serie1"], - skip_existing=False - ) - - with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ - patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): - - result = await batch_create_nfo( - request=request, - _auth={"username": "test"}, - series_app=mock_series_app, - nfo_service=mock_nfo_service - ) - - # Should process all (including duplicates) - assert result.total == 4 - assert result.successful == 4 diff --git a/tests/unit/test_nfo_cli.py b/tests/unit/test_nfo_cli.py deleted file mode 100644 index 8c7563a..0000000 --- a/tests/unit/test_nfo_cli.py +++ /dev/null @@ -1,356 +0,0 @@ -"""Unit tests for the NFO CLI module. - -Tests the CLI entry point, command dispatch, and individual command functions -from src/cli/nfo_cli.py. -""" - -import asyncio -from io import StringIO -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from src.cli.nfo_cli import ( - check_nfo_status, - main, - scan_and_create_nfo, - update_nfo_files, -) - -# --------------------------------------------------------------------------- -# main() dispatcher tests -# --------------------------------------------------------------------------- - -class TestMainDispatcher: - """Tests for the main() CLI entry point.""" - - @patch("src.cli.nfo_cli.sys") - def test_no_args_shows_usage(self, mock_sys, capsys): - """No arguments prints usage text and returns 1.""" - mock_sys.argv = ["nfo_cli"] - result = main() - assert result == 1 - - @patch("src.cli.nfo_cli.asyncio") - @patch("src.cli.nfo_cli.sys") - def test_scan_command_dispatches(self, mock_sys, mock_asyncio): - """'scan' command runs scan_and_create_nfo.""" - mock_sys.argv = ["nfo_cli", "scan"] - mock_asyncio.run.return_value = 0 - result = main() - mock_asyncio.run.assert_called_once() - - @patch("src.cli.nfo_cli.asyncio") - @patch("src.cli.nfo_cli.sys") - def test_status_command_dispatches(self, mock_sys, mock_asyncio): - """'status' command runs check_nfo_status.""" - mock_sys.argv = ["nfo_cli", "status"] - mock_asyncio.run.return_value = 0 - result = main() - mock_asyncio.run.assert_called_once() - - @patch("src.cli.nfo_cli.asyncio") - @patch("src.cli.nfo_cli.sys") - def test_update_command_dispatches(self, mock_sys, mock_asyncio): - """'update' command runs update_nfo_files.""" - mock_sys.argv = ["nfo_cli", "update"] - mock_asyncio.run.return_value = 0 - result = main() - mock_asyncio.run.assert_called_once() - - @patch("src.cli.nfo_cli.sys") - def test_unknown_command_returns_1(self, mock_sys): - """Unknown command returns exit code 1.""" - mock_sys.argv = ["nfo_cli", "bogus"] - result = main() - assert result == 1 - - @patch("src.cli.nfo_cli.asyncio") - @patch("src.cli.nfo_cli.sys") - def test_command_is_case_insensitive(self, mock_sys, mock_asyncio): - """Command matching is case-insensitive.""" - mock_sys.argv = ["nfo_cli", "SCAN"] - mock_asyncio.run.return_value = 0 - main() - mock_asyncio.run.assert_called_once() - - -# --------------------------------------------------------------------------- -# scan_and_create_nfo tests -# --------------------------------------------------------------------------- - -class TestScanAndCreateNfo: - """Tests for scan_and_create_nfo command.""" - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.settings") - async def test_returns_1_without_tmdb_key(self, mock_settings): - """Returns 1 when TMDB_API_KEY is missing.""" - mock_settings.tmdb_api_key = None - result = await scan_and_create_nfo() - assert result == 1 - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.settings") - async def test_returns_1_without_anime_directory(self, mock_settings): - """Returns 1 when ANIME_DIRECTORY is missing.""" - mock_settings.tmdb_api_key = "key" - mock_settings.anime_directory = None - result = await scan_and_create_nfo() - assert result == 1 - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.SeriesManagerService") - @patch("src.cli.nfo_cli.settings") - async def test_returns_0_when_no_series_found(self, mock_settings, mock_sms): - """Returns 0 when directory has no series.""" - mock_settings.tmdb_api_key = "key" - mock_settings.anime_directory = "/anime" - mock_settings.nfo_auto_create = True - mock_settings.nfo_update_on_scan = False - mock_settings.nfo_download_poster = False - mock_settings.nfo_download_logo = False - mock_settings.nfo_download_fanart = False - - mock_manager = MagicMock() - mock_serie_list = MagicMock() - mock_serie_list.get_all.return_value = [] - mock_manager.get_serie_list.return_value = mock_serie_list - mock_sms.from_settings.return_value = mock_manager - - result = await scan_and_create_nfo() - assert result == 0 - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.SeriesManagerService") - @patch("src.cli.nfo_cli.settings") - async def test_calls_scan_and_process_nfo(self, mock_settings, mock_sms): - """Processing is invoked for found series.""" - mock_settings.tmdb_api_key = "key" - mock_settings.anime_directory = "/anime" - mock_settings.nfo_auto_create = True - mock_settings.nfo_update_on_scan = False - mock_settings.nfo_download_poster = False - mock_settings.nfo_download_logo = False - mock_settings.nfo_download_fanart = False - - mock_serie = MagicMock() - mock_serie.has_nfo.return_value = False - mock_serie.name = "Naruto" - mock_serie.folder = "Naruto" - mock_serie.has_poster.return_value = False - mock_serie.has_logo.return_value = False - mock_serie.has_fanart.return_value = False - - mock_serie_list = MagicMock() - mock_serie_list.get_all.return_value = [mock_serie] - mock_serie_list.load_series = MagicMock() - - mock_manager = MagicMock() - mock_manager.get_serie_list.return_value = mock_serie_list - mock_manager.scan_and_process_nfo = AsyncMock() - mock_manager.close = AsyncMock() - mock_sms.from_settings.return_value = mock_manager - - result = await scan_and_create_nfo() - assert result == 0 - mock_manager.scan_and_process_nfo.assert_awaited_once() - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.SeriesManagerService") - @patch("src.cli.nfo_cli.settings") - async def test_returns_1_on_exception(self, mock_settings, mock_sms): - """Returns 1 when processing raises.""" - mock_settings.tmdb_api_key = "key" - mock_settings.anime_directory = "/anime" - mock_settings.nfo_auto_create = True - mock_settings.nfo_update_on_scan = False - mock_settings.nfo_download_poster = False - mock_settings.nfo_download_logo = False - mock_settings.nfo_download_fanart = False - - mock_serie = MagicMock() - mock_serie.has_nfo.return_value = False - mock_serie.name = "Test" - mock_serie.folder = "Test" - mock_serie_list = MagicMock() - mock_serie_list.get_all.return_value = [mock_serie] - - mock_manager = MagicMock() - mock_manager.get_serie_list.return_value = mock_serie_list - mock_manager.scan_and_process_nfo = AsyncMock( - side_effect=RuntimeError("fail") - ) - mock_manager.close = AsyncMock() - mock_sms.from_settings.return_value = mock_manager - - result = await scan_and_create_nfo() - assert result == 1 - # close is called even on error (finally block) - mock_manager.close.assert_awaited_once() - - -# --------------------------------------------------------------------------- -# check_nfo_status tests -# --------------------------------------------------------------------------- - -class TestCheckNfoStatus: - """Tests for check_nfo_status command.""" - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.settings") - async def test_returns_1_without_anime_directory(self, mock_settings): - """Returns 1 when ANIME_DIRECTORY is missing.""" - mock_settings.anime_directory = None - result = await check_nfo_status() - assert result == 1 - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.settings") - async def test_returns_0_when_no_series(self, mock_settings): - """Returns 0 when no series found.""" - mock_settings.anime_directory = "/anime" - with patch("src.core.entities.SerieList.SerieList") as mock_sl: - mock_list = MagicMock() - mock_list.get_all.return_value = [] - mock_sl.return_value = mock_list - result = await check_nfo_status() - assert result == 0 - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.settings") - async def test_reports_series_with_and_without_nfo(self, mock_settings, capsys): - """Status report categorises series correctly.""" - mock_settings.anime_directory = "/anime" - - serie_a = MagicMock() - serie_a.has_nfo.return_value = True - serie_a.has_poster.return_value = True - serie_a.has_logo.return_value = False - serie_a.has_fanart.return_value = False - serie_a.name = "A" - serie_a.folder = "A" - - serie_b = MagicMock() - serie_b.has_nfo.return_value = False - serie_b.has_poster.return_value = False - serie_b.has_logo.return_value = False - serie_b.has_fanart.return_value = False - serie_b.name = "B" - serie_b.folder = "B" - - with patch( - "src.core.entities.SerieList.SerieList" - ) as mock_sl: - mock_list = MagicMock() - mock_list.get_all.return_value = [serie_a, serie_b] - mock_sl.return_value = mock_list - result = await check_nfo_status() - - assert result == 0 - - -# --------------------------------------------------------------------------- -# update_nfo_files tests -# --------------------------------------------------------------------------- - -class TestUpdateNfoFiles: - """Tests for update_nfo_files command.""" - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.settings") - async def test_returns_1_without_tmdb_key(self, mock_settings): - """Returns 1 when TMDB_API_KEY is missing.""" - mock_settings.tmdb_api_key = None - result = await update_nfo_files() - assert result == 1 - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.settings") - async def test_returns_1_without_anime_directory(self, mock_settings): - """Returns 1 when ANIME_DIRECTORY is missing.""" - mock_settings.tmdb_api_key = "key" - mock_settings.anime_directory = None - result = await update_nfo_files() - assert result == 1 - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.settings") - async def test_returns_0_when_no_nfo_series(self, mock_settings): - """Returns 0 when no series have NFO files.""" - mock_settings.tmdb_api_key = "key" - mock_settings.anime_directory = "/anime" - mock_settings.nfo_download_poster = False - mock_settings.nfo_download_logo = False - mock_settings.nfo_download_fanart = False - - serie = MagicMock() - serie.has_nfo.return_value = False - - with patch("src.core.entities.SerieList.SerieList") as mock_sl: - mock_list = MagicMock() - mock_list.get_all.return_value = [serie] - mock_sl.return_value = mock_list - result = await update_nfo_files() - - assert result == 0 - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.asyncio") - @patch("src.cli.nfo_cli.settings") - async def test_updates_series_with_nfo(self, mock_settings, mock_sleeper): - """Calls update_tvshow_nfo for each series with NFO.""" - mock_settings.tmdb_api_key = "key" - mock_settings.anime_directory = "/anime" - mock_settings.nfo_download_poster = True - mock_settings.nfo_download_logo = False - mock_settings.nfo_download_fanart = False - mock_sleeper.sleep = AsyncMock() - - serie = MagicMock() - serie.has_nfo.return_value = True - serie.name = "Naruto" - serie.folder = "Naruto" - - mock_nfo_svc = MagicMock() - mock_nfo_svc.update_tvshow_nfo = AsyncMock() - mock_nfo_svc.close = AsyncMock() - - with patch("src.core.entities.SerieList.SerieList") as mock_sl: - mock_list = MagicMock() - mock_list.get_all.return_value = [serie] - mock_sl.return_value = mock_list - with patch( - "src.core.services.nfo_factory.create_nfo_service", - return_value=mock_nfo_svc, - ): - result = await update_nfo_files() - - assert result == 0 - mock_nfo_svc.update_tvshow_nfo.assert_awaited_once() - mock_nfo_svc.close.assert_awaited_once() - - @pytest.mark.asyncio - @patch("src.cli.nfo_cli.settings") - async def test_returns_1_on_factory_error(self, mock_settings): - """Returns 1 when create_nfo_service raises ValueError.""" - mock_settings.tmdb_api_key = "key" - mock_settings.anime_directory = "/anime" - mock_settings.nfo_download_poster = False - mock_settings.nfo_download_logo = False - mock_settings.nfo_download_fanart = False - - serie = MagicMock() - serie.has_nfo.return_value = True - - with patch("src.core.entities.SerieList.SerieList") as mock_sl: - mock_list = MagicMock() - mock_list.get_all.return_value = [serie] - mock_sl.return_value = mock_list - with patch( - "src.core.services.nfo_factory.create_nfo_service", - side_effect=ValueError("bad"), - ): - result = await update_nfo_files() - - assert result == 1 diff --git a/tests/unit/test_nfo_creation_tags.py b/tests/unit/test_nfo_creation_tags.py deleted file mode 100644 index d64c6ce..0000000 --- a/tests/unit/test_nfo_creation_tags.py +++ /dev/null @@ -1,307 +0,0 @@ -"""Unit tests for NFO tag creation — Task 0. - -Verifies that ``tmdb_to_nfo_model`` populates every required NFO tag and -that ``generate_tvshow_nfo`` writes all of them to the XML output. -""" - -from datetime import datetime -from typing import Any, Dict, Optional -from unittest.mock import patch - -import pytest -from lxml import etree - -from src.core.entities.nfo_models import TVShowNFO -from src.core.utils.nfo_generator import generate_tvshow_nfo -from src.core.utils.nfo_mapper import _extract_rating_by_country, tmdb_to_nfo_model - -# --------------------------------------------------------------------------- -# Helpers / fixtures -# --------------------------------------------------------------------------- - - -def _fake_get_image_url(path: str, size: str) -> str: - """Minimal stand-in for TMDBClient.get_image_url used in tests.""" - return f"https://image.tmdb.org/t/p/{size}{path}" - - -MINIMAL_TMDB: Dict[str, Any] = { - "id": 12345, - "name": "Test Show", - "original_name": "テストショー", - "overview": "A great overview.", - "tagline": "The best tagline.", - "first_air_date": "2023-04-01", - "status": "Continuing", - "episode_run_time": [24], - "vote_average": 8.5, - "vote_count": 200, - "genres": [{"id": 1, "name": "Animation"}, {"id": 2, "name": "Action"}], - "networks": [{"id": 10, "name": "AT-X"}], - "origin_country": ["JP"], - "production_countries": [], - "external_ids": {"imdb_id": "tt1234567", "tvdb_id": 99999}, - "poster_path": "/poster.jpg", - "backdrop_path": "/backdrop.jpg", - "images": {"logos": [{"file_path": "/logo.png"}]}, - "credits": { - "cast": [ - { - "id": 1, - "name": "Actor One", - "character": "Hero", - "profile_path": "/actor1.jpg", - } - ] - }, -} - -CONTENT_RATINGS_DE_US: Dict[str, Any] = { - "results": [ - {"iso_3166_1": "DE", "rating": "12"}, - {"iso_3166_1": "US", "rating": "TV-PG"}, - ] -} - - -@pytest.fixture() -def nfo_model() -> TVShowNFO: - """Return a fully-populated TVShowNFO from MINIMAL_TMDB data.""" - return tmdb_to_nfo_model(MINIMAL_TMDB, CONTENT_RATINGS_DE_US, _fake_get_image_url) - - -# --------------------------------------------------------------------------- -# tmdb_to_nfo_model — field mapping tests -# --------------------------------------------------------------------------- - - -def test_tmdb_to_nfo_model_sets_originaltitle(nfo_model: TVShowNFO) -> None: - assert nfo_model.originaltitle == "テストショー" - - -def test_tmdb_to_nfo_model_sets_year_from_first_air_date(nfo_model: TVShowNFO) -> None: - assert nfo_model.year == 2023 - - -def test_tmdb_to_nfo_model_sets_plot_from_overview(nfo_model: TVShowNFO) -> None: - assert nfo_model.plot == "A great overview." - - -def test_tmdb_to_nfo_model_sets_runtime(nfo_model: TVShowNFO) -> None: - assert nfo_model.runtime == 24 - - -def test_tmdb_to_nfo_model_sets_premiered(nfo_model: TVShowNFO) -> None: - assert nfo_model.premiered == "2023-04-01" - - -def test_tmdb_to_nfo_model_sets_status(nfo_model: TVShowNFO) -> None: - assert nfo_model.status == "Continuing" - - -def test_tmdb_to_nfo_model_sets_imdbid(nfo_model: TVShowNFO) -> None: - assert nfo_model.imdbid == "tt1234567" - - -def test_tmdb_to_nfo_model_sets_genres(nfo_model: TVShowNFO) -> None: - assert "Animation" in nfo_model.genre - assert "Action" in nfo_model.genre - - -def test_tmdb_to_nfo_model_sets_studios_from_networks(nfo_model: TVShowNFO) -> None: - assert "AT-X" in nfo_model.studio - - -def test_tmdb_to_nfo_model_sets_country(nfo_model: TVShowNFO) -> None: - assert "JP" in nfo_model.country - - -def test_tmdb_to_nfo_model_sets_actors(nfo_model: TVShowNFO) -> None: - assert len(nfo_model.actors) == 1 - assert nfo_model.actors[0].name == "Actor One" - assert nfo_model.actors[0].role == "Hero" - - -def test_tmdb_to_nfo_model_sets_watched_false(nfo_model: TVShowNFO) -> None: - assert nfo_model.watched is False - - -def test_tmdb_to_nfo_model_sets_tagline(nfo_model: TVShowNFO) -> None: - assert nfo_model.tagline == "The best tagline." - - -def test_tmdb_to_nfo_model_sets_outline_from_overview(nfo_model: TVShowNFO) -> None: - assert nfo_model.outline == "A great overview." - - -def test_tmdb_to_nfo_model_sets_sorttitle_from_name(nfo_model: TVShowNFO) -> None: - assert nfo_model.sorttitle == "Test Show" - - -def test_tmdb_to_nfo_model_sets_dateadded(nfo_model: TVShowNFO) -> None: - assert nfo_model.dateadded is not None - # Must match YYYY-MM-DD HH:MM:SS - datetime.strptime(nfo_model.dateadded, "%Y-%m-%d %H:%M:%S") - - -def test_tmdb_to_nfo_model_sets_mpaa_from_content_ratings(nfo_model: TVShowNFO) -> None: - assert nfo_model.mpaa == "TV-PG" - - -# --------------------------------------------------------------------------- -# _extract_rating_by_country -# --------------------------------------------------------------------------- - - -def test_extract_rating_by_country_returns_us_rating() -> None: - ratings = {"results": [{"iso_3166_1": "US", "rating": "TV-14"}]} - assert _extract_rating_by_country(ratings, "US") == "TV-14" - - -def test_extract_rating_by_country_returns_none_when_no_match() -> None: - ratings = {"results": [{"iso_3166_1": "DE", "rating": "12"}]} - assert _extract_rating_by_country(ratings, "US") is None - - -def test_extract_rating_by_country_handles_empty_results() -> None: - assert _extract_rating_by_country({"results": []}, "US") is None - assert _extract_rating_by_country({}, "US") is None - assert _extract_rating_by_country(None, "US") is None # type: ignore[arg-type] - - -# --------------------------------------------------------------------------- -# generate_tvshow_nfo — XML output tests -# --------------------------------------------------------------------------- - - -def _parse_xml(xml_str: str) -> etree._Element: - return etree.fromstring(xml_str.encode("utf-8")) - - -def test_generate_nfo_includes_all_required_tags(nfo_model: TVShowNFO) -> None: - xml_str = generate_tvshow_nfo(nfo_model) - root = _parse_xml(xml_str) - - required = [ - "title", "originaltitle", "year", "plot", "runtime", - "premiered", "status", "imdbid", "genre", "studio", - "country", "actor", "watched", "tagline", "outline", - "sorttitle", "dateadded", - ] - for tag in required: - elements = root.findall(f".//{tag}") - assert elements, f"Missing required tag: <{tag}>" - # At least one element must have non-empty text - assert any(e.text for e in elements), f"Tag <{tag}> is empty" - - -def test_generate_nfo_writes_watched_false(nfo_model: TVShowNFO) -> None: - xml_str = generate_tvshow_nfo(nfo_model) - root = _parse_xml(xml_str) - watched = root.find(".//watched") - assert watched is not None - assert watched.text == "false" - - -def test_generate_nfo_minimal_model_does_not_crash() -> None: - minimal = TVShowNFO(title="Minimal Show") - xml_str = generate_tvshow_nfo(minimal) - assert "Minimal Show" in xml_str - - -def test_generate_nfo_writes_fsk_over_mpaa_when_prefer_fsk() -> None: - nfo = TVShowNFO(title="Test", fsk="FSK 16", mpaa="TV-MA") - with patch("src.core.utils.nfo_generator.settings") as mock_settings: - mock_settings.nfo_prefer_fsk_rating = True - xml_str = generate_tvshow_nfo(nfo) - root = _parse_xml(xml_str) - mpaa_elem = root.find(".//mpaa") - assert mpaa_elem is not None - assert mpaa_elem.text == "FSK 16" - - -def test_generate_nfo_writes_mpaa_when_no_fsk() -> None: - nfo = TVShowNFO(title="Test", fsk=None, mpaa="TV-14") - with patch("src.core.utils.nfo_generator.settings") as mock_settings: - mock_settings.nfo_prefer_fsk_rating = True - xml_str = generate_tvshow_nfo(nfo) - root = _parse_xml(xml_str) - mpaa_elem = root.find(".//mpaa") - assert mpaa_elem is not None - assert mpaa_elem.text == "TV-14" - - -# --------------------------------------------------------------------------- -# showtitle and namedseason — new coverage -# --------------------------------------------------------------------------- - - -def test_tmdb_to_nfo_model_sets_showtitle(nfo_model: TVShowNFO) -> None: - """showtitle must equal the main title.""" - assert nfo_model.showtitle == "Test Show" - - -def test_generate_nfo_writes_showtitle(nfo_model: TVShowNFO) -> None: - xml_str = generate_tvshow_nfo(nfo_model) - root = _parse_xml(xml_str) - elem = root.find(".//showtitle") - assert elem is not None - assert elem.text == "Test Show" - - -TMDB_WITH_SEASONS: Dict[str, Any] = { - **MINIMAL_TMDB, - "seasons": [ - {"season_number": 0, "name": "Specials"}, - {"season_number": 1, "name": "Season 1"}, - {"season_number": 2, "name": "Season 2"}, - ], -} - - -def test_tmdb_to_nfo_model_sets_namedseasons() -> None: - model = tmdb_to_nfo_model( - TMDB_WITH_SEASONS, CONTENT_RATINGS_DE_US, _fake_get_image_url, - ) - assert len(model.namedseason) == 3 - assert model.namedseason[0].number == 0 - assert model.namedseason[0].name == "Specials" - assert model.namedseason[1].number == 1 - - -def test_generate_nfo_writes_namedseasons() -> None: - model = tmdb_to_nfo_model( - TMDB_WITH_SEASONS, CONTENT_RATINGS_DE_US, _fake_get_image_url, - ) - xml_str = generate_tvshow_nfo(model) - root = _parse_xml(xml_str) - elems = root.findall(".//namedseason") - assert len(elems) == 3 - assert elems[0].get("number") == "0" - assert elems[0].text == "Specials" - - -def test_tmdb_to_nfo_model_no_seasons_key() -> None: - """No 'seasons' key in TMDB data → namedseason list is empty.""" - model = tmdb_to_nfo_model( - MINIMAL_TMDB, CONTENT_RATINGS_DE_US, _fake_get_image_url, - ) - assert model.namedseason == [] - - -def test_tmdb_to_nfo_model_empty_overview_produces_none_plot() -> None: - """When overview is empty the plot field should be None.""" - data = {**MINIMAL_TMDB, "overview": ""} - model = tmdb_to_nfo_model( - data, CONTENT_RATINGS_DE_US, _fake_get_image_url, - ) - assert model.plot is None - - -def test_generate_nfo_always_writes_plot_tag_even_when_none() -> None: - """ must always appear, even when plot is None.""" - nfo = TVShowNFO(title="No Plot Show") - xml_str = generate_tvshow_nfo(nfo) - root = _parse_xml(xml_str) - plot_elem = root.find(".//plot") - assert plot_elem is not None # tag exists (always_write=True) diff --git a/tests/unit/test_nfo_dependency.py b/tests/unit/test_nfo_dependency.py deleted file mode 100644 index 1ab367c..0000000 --- a/tests/unit/test_nfo_dependency.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Tests for NFO service dependency with config fallback. - -Tests that get_nfo_service() correctly loads TMDB API key from config.json -when it's not in settings (e.g., after server reload in development). -""" -from unittest.mock import MagicMock, patch - -import pytest -from fastapi import HTTPException - -from src.config.settings import settings -from src.server.api.nfo import get_nfo_service -from src.server.models.config import AppConfig, NFOConfig - - -def _reset_factory_cache(): - """Reset the NFO factory singleton so each test gets a clean factory.""" - import src.core.services.nfo_factory as factory_mod - factory_mod._factory_instance = None - - -@pytest.mark.asyncio -async def test_get_nfo_service_with_settings_tmdb_key(): - """Test get_nfo_service when TMDB key is in settings.""" - _reset_factory_cache() - original_key = settings.tmdb_api_key - settings.tmdb_api_key = "test_api_key_from_settings" - - try: - nfo_service = await get_nfo_service() - assert nfo_service is not None - assert nfo_service.tmdb_client.api_key == "test_api_key_from_settings" - finally: - settings.tmdb_api_key = original_key - _reset_factory_cache() - - -@pytest.mark.asyncio -async def test_get_nfo_service_fallback_to_config(): - """Test get_nfo_service falls back to config.json when key not in settings.""" - _reset_factory_cache() - original_key = settings.tmdb_api_key - settings.tmdb_api_key = None - - try: - mock_config = AppConfig( - name="Test", - data_dir="data", - nfo=NFOConfig( - tmdb_api_key="test_api_key_from_config", - auto_create=False, - update_on_scan=False - ) - ) - - with patch('src.server.services.config_service.get_config_service') as mock_get_config: - mock_config_service = MagicMock() - mock_config_service.load_config.return_value = mock_config - mock_get_config.return_value = mock_config_service - - nfo_service = await get_nfo_service() - assert nfo_service is not None - assert nfo_service.tmdb_client.api_key == "test_api_key_from_config" - finally: - settings.tmdb_api_key = original_key - _reset_factory_cache() - - -@pytest.mark.asyncio -async def test_get_nfo_service_no_key_raises_503(): - """Test get_nfo_service raises 503 when no TMDB key available.""" - _reset_factory_cache() - original_key = settings.tmdb_api_key - settings.tmdb_api_key = None - - try: - mock_config = AppConfig( - name="Test", - data_dir="data", - nfo=NFOConfig( - tmdb_api_key=None, - auto_create=False, - update_on_scan=False - ) - ) - - with patch('src.server.services.config_service.get_config_service') as mock_get_config: - mock_config_service = MagicMock() - mock_config_service.load_config.return_value = mock_config - mock_get_config.return_value = mock_config_service - - with pytest.raises(HTTPException) as exc_info: - await get_nfo_service() - - assert exc_info.value.status_code == 503 - assert "TMDB API key not configured" in exc_info.value.detail - finally: - settings.tmdb_api_key = original_key - _reset_factory_cache() - - -@pytest.mark.asyncio -async def test_get_nfo_service_config_load_fails_raises_503(): - """Test get_nfo_service raises 503 when config loading fails.""" - _reset_factory_cache() - original_key = settings.tmdb_api_key - settings.tmdb_api_key = None - - try: - with patch('src.server.services.config_service.get_config_service') as mock_get_config: - mock_get_config.side_effect = Exception("Config file not found") - - with pytest.raises(HTTPException) as exc_info: - await get_nfo_service() - - assert exc_info.value.status_code == 503 - assert "TMDB API key not configured" in exc_info.value.detail - finally: - settings.tmdb_api_key = original_key - _reset_factory_cache() diff --git a/tests/unit/test_nfo_factory.py b/tests/unit/test_nfo_factory.py deleted file mode 100644 index fe88898..0000000 --- a/tests/unit/test_nfo_factory.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Unit tests for NFO service factory module. - -Tests factory instantiation, configuration precedence, singleton pattern, -and convenience functions for creating NFOService instances. -""" - -from unittest.mock import MagicMock, patch - -import pytest - -from src.core.services.nfo_factory import ( - NFOServiceFactory, - create_nfo_service, - get_nfo_factory, -) - - -class TestNFOServiceFactoryCreate: - """Tests for NFOServiceFactory.create method.""" - - @patch("src.core.services.nfo_factory.NFOService") - @patch("src.core.services.nfo_factory.settings") - def test_create_with_explicit_api_key(self, mock_settings, mock_nfo_cls): - """Explicit API key takes priority over settings.""" - mock_settings.tmdb_api_key = "settings_key" - mock_settings.anime_directory = "/anime" - mock_settings.nfo_image_size = "original" - mock_settings.nfo_auto_create = False - - factory = NFOServiceFactory() - factory.create(tmdb_api_key="explicit_key") - mock_nfo_cls.assert_called_once_with( - tmdb_api_key="explicit_key", - anime_directory="/anime", - image_size="original", - auto_create=False, - ) - - @patch("src.core.services.nfo_factory.NFOService") - @patch("src.core.services.nfo_factory.settings") - def test_create_falls_back_to_settings(self, mock_settings, mock_nfo_cls): - """Falls back to settings when no explicit key provided.""" - mock_settings.tmdb_api_key = "settings_key" - mock_settings.anime_directory = "/anime" - mock_settings.nfo_image_size = "w500" - mock_settings.nfo_auto_create = True - - factory = NFOServiceFactory() - factory.create() - mock_nfo_cls.assert_called_once_with( - tmdb_api_key="settings_key", - anime_directory="/anime", - image_size="w500", - auto_create=True, - ) - - @patch("src.core.services.nfo_factory.settings") - def test_create_raises_without_api_key(self, mock_settings): - """Raises ValueError when no API key available from any source.""" - mock_settings.tmdb_api_key = None - factory = NFOServiceFactory() - factory._get_api_key_from_config = MagicMock(return_value=None) - with pytest.raises(ValueError, match="TMDB API key not configured"): - factory.create() - - @patch("src.core.services.nfo_factory.NFOService") - @patch("src.core.services.nfo_factory.settings") - def test_create_with_all_custom_params(self, mock_settings, mock_nfo_cls): - """All parameters can be overridden.""" - mock_settings.tmdb_api_key = "default" - mock_settings.anime_directory = "/default" - mock_settings.nfo_image_size = "original" - mock_settings.nfo_auto_create = False - - factory = NFOServiceFactory() - factory.create( - tmdb_api_key="custom", - anime_directory="/custom", - image_size="w300", - auto_create=True, - ) - mock_nfo_cls.assert_called_once_with( - tmdb_api_key="custom", - anime_directory="/custom", - image_size="w300", - auto_create=True, - ) - - @patch("src.core.services.nfo_factory.NFOService") - @patch("src.core.services.nfo_factory.settings") - def test_create_uses_config_json_fallback(self, mock_settings, mock_nfo_cls): - """Falls back to config.json when settings has no key.""" - mock_settings.tmdb_api_key = None - mock_settings.anime_directory = "/anime" - mock_settings.nfo_image_size = "original" - mock_settings.nfo_auto_create = False - - factory = NFOServiceFactory() - factory._get_api_key_from_config = MagicMock(return_value="config_key") - factory.create() - mock_nfo_cls.assert_called_once() - call_kwargs = mock_nfo_cls.call_args[1] - assert call_kwargs["tmdb_api_key"] == "config_key" - - -class TestNFOServiceFactoryCreateOptional: - """Tests for NFOServiceFactory.create_optional method.""" - - @patch("src.core.services.nfo_factory.settings") - def test_returns_none_without_api_key(self, mock_settings): - """Returns None instead of raising when no API key.""" - mock_settings.tmdb_api_key = None - factory = NFOServiceFactory() - factory._get_api_key_from_config = MagicMock(return_value=None) - result = factory.create_optional() - assert result is None - - @patch("src.core.services.nfo_factory.NFOService") - @patch("src.core.services.nfo_factory.settings") - def test_returns_service_when_configured(self, mock_settings, mock_nfo_cls): - """Returns NFOService when configuration is available.""" - mock_settings.tmdb_api_key = "key123" - mock_settings.anime_directory = "/anime" - mock_settings.nfo_image_size = "original" - mock_settings.nfo_auto_create = False - - factory = NFOServiceFactory() - result = factory.create_optional() - assert result is not None - - -class TestGetNfoFactory: - """Tests for get_nfo_factory singleton function.""" - - def test_returns_factory_instance(self): - """Returns an NFOServiceFactory instance.""" - import src.core.services.nfo_factory as mod - old = mod._factory_instance - try: - mod._factory_instance = None - factory = get_nfo_factory() - assert isinstance(factory, NFOServiceFactory) - finally: - mod._factory_instance = old - - def test_returns_same_instance(self): - """Repeated calls return the same singleton.""" - import src.core.services.nfo_factory as mod - old = mod._factory_instance - try: - mod._factory_instance = None - f1 = get_nfo_factory() - f2 = get_nfo_factory() - assert f1 is f2 - finally: - mod._factory_instance = old - - -class TestCreateNfoService: - """Tests for create_nfo_service convenience function.""" - - @patch("src.core.services.nfo_factory.NFOService") - @patch("src.core.services.nfo_factory.settings") - def test_convenience_function_creates_service( - self, mock_settings, mock_nfo_cls - ): - """Convenience function delegates to factory.create().""" - mock_settings.tmdb_api_key = "key" - mock_settings.anime_directory = "/anime" - mock_settings.nfo_image_size = "original" - mock_settings.nfo_auto_create = False - - result = create_nfo_service() - mock_nfo_cls.assert_called_once() - - @patch("src.core.services.nfo_factory.settings") - def test_convenience_function_raises_without_key(self, mock_settings): - """Convenience function raises ValueError without key.""" - mock_settings.tmdb_api_key = None - import src.core.services.nfo_factory as mod - old = mod._factory_instance - try: - mod._factory_instance = None - factory = get_nfo_factory() - factory._get_api_key_from_config = MagicMock(return_value=None) - with pytest.raises(ValueError): - factory.create() - finally: - mod._factory_instance = old diff --git a/tests/unit/test_nfo_generator.py b/tests/unit/test_nfo_generator.py deleted file mode 100644 index 493d7cc..0000000 --- a/tests/unit/test_nfo_generator.py +++ /dev/null @@ -1,405 +0,0 @@ -"""Unit tests for NFO generator.""" - -import pytest -from lxml import etree - -from src.core.entities.nfo_models import ( - ActorInfo, - ImageInfo, - RatingInfo, - TVShowNFO, - UniqueID, -) -from src.core.utils.nfo_generator import generate_tvshow_nfo, validate_nfo_xml - - -class TestGenerateTVShowNFO: - """Test generate_tvshow_nfo function.""" - - def test_generate_minimal_nfo(self): - """Test generation with minimal required fields.""" - nfo = TVShowNFO( - title="Test Show", - plot="A test show" - ) - - xml_string = generate_tvshow_nfo(nfo) - - # Actual implementation uses 'standalone="yes"' in declaration - assert xml_string.startswith('') - assert "Test Show" in xml_string - assert "A test show" in xml_string - - def test_generate_complete_nfo(self): - """Test generation with all fields populated.""" - nfo = TVShowNFO( - title="Complete Show", - originaltitle="Original Title", - year=2020, - plot="Complete test", - runtime=45, - premiered="2020-01-15", - status="Continuing", - genre=["Action", "Drama"], - studio=["Studio 1"], - country=["USA"], - ratings=[RatingInfo( - name="themoviedb", - value=8.5, - votes=1000, - max_rating=10, - default=True - )], - actors=[ActorInfo( - name="Test Actor", - role="Main Character" - )], - thumb=[ImageInfo(url="https://test.com/poster.jpg")], - uniqueid=[UniqueID(type="tmdb", value="12345")] - ) - - xml_string = generate_tvshow_nfo(nfo) - - # Verify all elements present - assert "Complete Show" in xml_string - assert "Original Title" in xml_string - assert "2020" in xml_string - assert "45" in xml_string - assert "2020-01-15" in xml_string - assert "Continuing" in xml_string - assert "Action" in xml_string - assert "Drama" in xml_string - assert "Studio 1" in xml_string - assert "USA" in xml_string - assert "Test Actor" in xml_string - assert "Main Character" in xml_string - - def test_generate_nfo_with_ratings(self): - """Test NFO with multiple ratings.""" - nfo = TVShowNFO( - title="Rated Show", - plot="Test", - ratings=[ - RatingInfo( - name="themoviedb", - value=8.5, - votes=1000, - max_rating=10, - default=True - ), - RatingInfo( - name="imdb", - value=8.2, - votes=5000, - max_rating=10, - default=False - ) - ] - ) - - xml_string = generate_tvshow_nfo(nfo) - - assert '' in xml_string - # Actual implementation includes max attribute and only adds default when True - assert '' in xml_string - assert '8.5' in xml_string - assert '1000' in xml_string - assert '' in xml_string - - def test_generate_nfo_with_actors(self): - """Test NFO with multiple actors.""" - nfo = TVShowNFO( - title="Cast Show", - plot="Test", - actors=[ - ActorInfo(name="Actor 1", role="Hero"), - ActorInfo(name="Actor 2", role="Villain", thumb="https://test.com/actor2.jpg") - ] - ) - - xml_string = generate_tvshow_nfo(nfo) - - assert '' in xml_string - assert 'Actor 1' in xml_string - assert 'Hero' in xml_string - assert 'Actor 2' in xml_string - assert 'https://test.com/actor2.jpg' in xml_string - - def test_generate_nfo_with_images(self): - """Test NFO with various image types.""" - nfo = TVShowNFO( - title="Image Show", - plot="Test", - thumb=[ - ImageInfo(url="https://test.com/poster.jpg", aspect="poster"), - ImageInfo(url="https://test.com/logo.png", aspect="clearlogo") - ], - fanart=[ - ImageInfo(url="https://test.com/fanart.jpg") - ] - ) - - xml_string = generate_tvshow_nfo(nfo) - - assert 'https://test.com/poster.jpg' in xml_string - assert 'https://test.com/logo.png' in xml_string - assert '' in xml_string - assert 'https://test.com/fanart.jpg' in xml_string - - def test_generate_nfo_with_unique_ids(self): - """Test NFO with multiple unique IDs.""" - nfo = TVShowNFO( - title="ID Show", - plot="Test", - uniqueid=[ - UniqueID(type="tmdb", value="12345", default=False), - UniqueID(type="tvdb", value="67890", default=True), - UniqueID(type="imdb", value="tt1234567", default=False) - ] - ) - - xml_string = generate_tvshow_nfo(nfo) - - # Actual implementation only adds default="true" when default is True, omits attribute when False - assert '12345' in xml_string - assert '67890' in xml_string - assert 'tt1234567' in xml_string - - def test_generate_nfo_escapes_special_chars(self): - """Test that special XML characters are escaped.""" - nfo = TVShowNFO( - title="Show & special \"chars\"", - plot="Plot with & ampersand" - ) - - xml_string = generate_tvshow_nfo(nfo) - - # XML should escape special characters - assert "<" in xml_string or "" in xml_string - assert "&" in xml_string or "&" in xml_string - - def test_generate_nfo_valid_xml(self): - """Test that generated XML is valid.""" - nfo = TVShowNFO( - title="Valid Show", - plot="Test", - year=2020, - genre=["Action"], - ratings=[RatingInfo(name="test", value=8.0)] - ) - - xml_string = generate_tvshow_nfo(nfo) - - # Should be parseable as XML - root = etree.fromstring(xml_string.encode('utf-8')) - assert root.tag == "tvshow" - - def test_generate_nfo_none_values_omitted(self): - """Test that None values are omitted from XML.""" - nfo = TVShowNFO( - title="Sparse Show", - plot="Test", - year=None, - runtime=None, - premiered=None - ) - - xml_string = generate_tvshow_nfo(nfo) - - # None values should not appear in XML - assert "<year>" not in xml_string - assert "<runtime>" not in xml_string - assert "<premiered>" not in xml_string - - -class TestValidateNFOXML: - """Test validate_nfo_xml function.""" - - def test_validate_valid_xml(self): - """Test validation of valid XML.""" - nfo = TVShowNFO(title="Test", plot="Test") - xml_string = generate_tvshow_nfo(nfo) - - # Should not raise exception - validate_nfo_xml(xml_string) - - def test_validate_invalid_xml(self): - """Test validation of invalid XML.""" - invalid_xml = "<?xml version='1.0'?><tvshow><title>Unclosed" - - # validate_nfo_xml returns False for invalid XML, doesn't raise - result = validate_nfo_xml(invalid_xml) - assert result is False - - def test_validate_missing_tvshow_root(self): - """Test validation accepts any well-formed XML (doesn't check root).""" - valid_xml = '<?xml version="1.0"?><movie><title>Test' - - # validate_nfo_xml only checks if XML is well-formed, not structure - result = validate_nfo_xml(valid_xml) - assert result is True - - def test_validate_empty_string(self): - """Test validation rejects empty string.""" - result = validate_nfo_xml("") - assert result is False - - def test_validate_well_formed_structure(self): - """Test validation accepts well-formed structure.""" - xml = """ - - Test Show - Test plot - 2020 - - """ - - validate_nfo_xml(xml) - - -class TestNFOGeneratorEdgeCases: - """Test edge cases in NFO generation.""" - - def test_empty_lists(self): - """Test generation with empty lists.""" - nfo = TVShowNFO( - title="Empty Lists", - plot="Test", - genre=[], - studio=[], - actors=[] - ) - - xml_string = generate_tvshow_nfo(nfo) - - # Should generate valid XML even with empty lists - root = etree.fromstring(xml_string.encode('utf-8')) - assert root.tag == "tvshow" - - def test_unicode_characters(self): - """Test handling of Unicode characters.""" - nfo = TVShowNFO( - title="アニメ Show 中文", - plot="Plot with émojis 🎬 and spëcial çhars" - ) - - xml_string = generate_tvshow_nfo(nfo) - - # Should encode Unicode properly - assert "アニメ" in xml_string - assert "中文" in xml_string - assert "émojis" in xml_string - - def test_very_long_plot(self): - """Test handling of very long plot text.""" - long_plot = "A" * 10000 - nfo = TVShowNFO( - title="Long Plot", - plot=long_plot - ) - - xml_string = generate_tvshow_nfo(nfo) - - assert long_plot in xml_string - - def test_multiple_studios(self): - """Test handling multiple studios.""" - nfo = TVShowNFO( - title="Multi Studio", - plot="Test", - studio=["Studio A", "Studio B", "Studio C"] - ) - - xml_string = generate_tvshow_nfo(nfo) - - assert xml_string.count("") == 3 - assert "Studio A" in xml_string - assert "Studio B" in xml_string - assert "Studio C" in xml_string - - def test_special_date_formats(self): - """Test various date format inputs.""" - nfo = TVShowNFO( - title="Date Test", - plot="Test", - premiered="2020-01-01" - ) - - xml_string = generate_tvshow_nfo(nfo) - - assert "2020-01-01" in xml_string - - -class TestFSKRatingGeneration: - """Test FSK rating generation in NFO XML.""" - - def test_generate_nfo_with_fsk_rating(self): - """Test NFO generation with FSK rating.""" - nfo = TVShowNFO( - title="FSK Show", - plot="Test", - fsk="FSK 12", - mpaa="TV-14" - ) - - xml_string = generate_tvshow_nfo(nfo) - - # Should use FSK rating when available and preferred (default) - assert "FSK 12" in xml_string - - def test_generate_nfo_fsk_preferred_over_mpaa(self): - """Test that FSK is preferred over MPAA when both present.""" - nfo = TVShowNFO( - title="FSK Priority Show", - plot="Test", - fsk="FSK 16", - mpaa="TV-MA" - ) - - xml_string = generate_tvshow_nfo(nfo) - - # FSK should be in mpaa tag, not TV-MA - assert "FSK 16" in xml_string - assert "TV-MA" not in xml_string - - def test_generate_nfo_fallback_to_mpaa(self): - """Test fallback to MPAA when FSK not available.""" - nfo = TVShowNFO( - title="MPAA Show", - plot="Test", - fsk=None, - mpaa="TV-PG" - ) - - xml_string = generate_tvshow_nfo(nfo) - - # Should use MPAA when FSK not available - assert "TV-PG" in xml_string - - def test_generate_nfo_with_all_fsk_values(self): - """Test NFO generation with all possible FSK values.""" - fsk_values = ["FSK 0", "FSK 6", "FSK 12", "FSK 16", "FSK 18"] - - for fsk in fsk_values: - nfo = TVShowNFO( - title=f"FSK {fsk} Show", - plot="Test", - fsk=fsk - ) - - xml_string = generate_tvshow_nfo(nfo) - assert f"{fsk}" in xml_string - - def test_generate_nfo_no_rating(self): - """Test NFO generation when neither FSK nor MPAA is available.""" - nfo = TVShowNFO( - title="No Rating Show", - plot="Test", - fsk=None, - mpaa=None - ) - - xml_string = generate_tvshow_nfo(nfo) - - # mpaa tag should not be present - assert "" not in xml_string diff --git a/tests/unit/test_nfo_id_parsing.py b/tests/unit/test_nfo_id_parsing.py deleted file mode 100644 index ee615a6..0000000 --- a/tests/unit/test_nfo_id_parsing.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Unit tests for NFO ID parsing functionality.""" - -import tempfile -from pathlib import Path - -import pytest - -from src.core.services.nfo_service import NFOService - - -class TestNFOIDParsing: - """Test NFO ID parsing from XML files.""" - - @pytest.fixture - def nfo_service(self): - """Create NFO service for testing.""" - with tempfile.TemporaryDirectory() as tmpdir: - service = NFOService( - tmdb_api_key="test_key", - anime_directory=tmpdir, - auto_create=False - ) - yield service - - @pytest.fixture - def temp_nfo_file(self): - """Create a temporary NFO file for testing.""" - with tempfile.NamedTemporaryFile( - mode='w', - suffix='.nfo', - delete=False, - encoding='utf-8' - ) as f: - nfo_path = Path(f.name) - yield nfo_path - # Cleanup - if nfo_path.exists(): - nfo_path.unlink() - - def test_parse_nfo_ids_with_uniqueid_elements( - self, nfo_service, temp_nfo_file - ): - """Test parsing IDs from uniqueid elements.""" - nfo_content = """ - - Attack on Titan - 1429 - 295739 - tt2560140 -""" - temp_nfo_file.write_text(nfo_content, encoding='utf-8') - - result = nfo_service.parse_nfo_ids(temp_nfo_file) - - assert result["tmdb_id"] == 1429 - assert result["tvdb_id"] == 295739 - - def test_parse_nfo_ids_with_dedicated_elements( - self, nfo_service, temp_nfo_file - ): - """Test parsing IDs from dedicated tmdbid/tvdbid elements.""" - nfo_content = """ - - One Piece - 37854 - 81797 -""" - temp_nfo_file.write_text(nfo_content, encoding='utf-8') - - result = nfo_service.parse_nfo_ids(temp_nfo_file) - - assert result["tmdb_id"] == 37854 - assert result["tvdb_id"] == 81797 - - def test_parse_nfo_ids_mixed_formats( - self, nfo_service, temp_nfo_file - ): - """Test parsing with both uniqueid and dedicated elements. - - uniqueid elements should take precedence. - """ - nfo_content = """ - - Naruto - 31910 - 99999 - 78857 -""" - temp_nfo_file.write_text(nfo_content, encoding='utf-8') - - result = nfo_service.parse_nfo_ids(temp_nfo_file) - - # uniqueid should take precedence over tmdbid element - assert result["tmdb_id"] == 31910 - assert result["tvdb_id"] == 78857 - - def test_parse_nfo_ids_only_tmdb( - self, nfo_service, temp_nfo_file - ): - """Test parsing when only TMDB ID is present.""" - nfo_content = """ - - Dragon Ball Z - 1553 -""" - temp_nfo_file.write_text(nfo_content, encoding='utf-8') - - result = nfo_service.parse_nfo_ids(temp_nfo_file) - - assert result["tmdb_id"] == 1553 - assert result["tvdb_id"] is None - - def test_parse_nfo_ids_only_tvdb( - self, nfo_service, temp_nfo_file - ): - """Test parsing when only TVDB ID is present.""" - nfo_content = """ - - Bleach - 74796 -""" - temp_nfo_file.write_text(nfo_content, encoding='utf-8') - - result = nfo_service.parse_nfo_ids(temp_nfo_file) - - assert result["tmdb_id"] is None - assert result["tvdb_id"] == 74796 - - def test_parse_nfo_ids_no_ids( - self, nfo_service, temp_nfo_file - ): - """Test parsing when no IDs are present.""" - nfo_content = """ - - Unknown Series - A series without any IDs. -""" - temp_nfo_file.write_text(nfo_content, encoding='utf-8') - - result = nfo_service.parse_nfo_ids(temp_nfo_file) - - assert result["tmdb_id"] is None - assert result["tvdb_id"] is None - - def test_parse_nfo_ids_invalid_id_format( - self, nfo_service, temp_nfo_file - ): - """Test parsing with invalid ID formats (non-numeric).""" - nfo_content = """ - - Invalid IDs - not_a_number - also_invalid -""" - temp_nfo_file.write_text(nfo_content, encoding='utf-8') - - result = nfo_service.parse_nfo_ids(temp_nfo_file) - - # Should return None for invalid formats instead of crashing - assert result["tmdb_id"] is None - assert result["tvdb_id"] is None - - def test_parse_nfo_ids_file_not_found(self, nfo_service): - """Test parsing when NFO file doesn't exist.""" - non_existent = Path("/tmp/non_existent_nfo_file.nfo") - - result = nfo_service.parse_nfo_ids(non_existent) - - assert result["tmdb_id"] is None - assert result["tvdb_id"] is None - - def test_parse_nfo_ids_invalid_xml( - self, nfo_service, temp_nfo_file - ): - """Test parsing with invalid XML.""" - nfo_content = """ - - Broken XML - <!-- Missing closing tags --> -""" - temp_nfo_file.write_text(nfo_content, encoding='utf-8') - - result = nfo_service.parse_nfo_ids(temp_nfo_file) - - # Should handle error gracefully and return None values - assert result["tmdb_id"] is None - assert result["tvdb_id"] is None - - def test_parse_nfo_ids_empty_file( - self, nfo_service, temp_nfo_file - ): - """Test parsing an empty file.""" - temp_nfo_file.write_text("", encoding='utf-8') - - result = nfo_service.parse_nfo_ids(temp_nfo_file) - - assert result["tmdb_id"] is None - assert result["tvdb_id"] is None diff --git a/tests/unit/test_nfo_minimal_fallback.py b/tests/unit/test_nfo_minimal_fallback.py deleted file mode 100644 index ca14b12..0000000 --- a/tests/unit/test_nfo_minimal_fallback.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Unit tests for minimal NFO creation when TMDB fails. - -Tests the fallback behavior when TMDB lookup fails and we need to create -a minimal NFO file just to track the series. -""" -from pathlib import Path -from unittest.mock import AsyncMock, patch - -import pytest - -from src.core.services.nfo_service import NFOService - - -@pytest.fixture -def nfo_service(tmp_path): - """Create NFO service with test directory. - - Note: anime_directory is set to tmp_path directly (not tmp_path / "anime") - because tmp_path already represents the test anime directory. - """ - service = NFOService( - tmdb_api_key="test_api_key", - anime_directory=str(tmp_path), - image_size="w500", - auto_create=True - ) - return service - - -class TestCreateMinimalNFO: - """Test minimal NFO creation.""" - - @pytest.mark.asyncio - async def test_create_minimal_nfo_basic(self, nfo_service, tmp_path): - """Test creating minimal NFO with just title.""" - # Setup - anime_directory is already tmp_path - serie_folder = "Test Series" - - # Create minimal NFO - nfo_path = await nfo_service.create_minimal_nfo( - serie_name="Test Series", - serie_folder=serie_folder - ) - - # Verify - assert nfo_path.exists() - assert nfo_path.name == "tvshow.nfo" - - content = nfo_path.read_text(encoding="utf-8") - assert "<title>Test Series" in content - assert "No metadata available" in content - - @pytest.mark.asyncio - async def test_create_minimal_nfo_with_year(self, nfo_service, tmp_path): - """Test creating minimal NFO with year.""" - # Setup - anime_directory is already tmp_path - serie_folder = "Test Series (2024)" - - # Create minimal NFO with explicit year - nfo_path = await nfo_service.create_minimal_nfo( - serie_name="Test Series", - serie_folder=serie_folder, - year=2024 - ) - - # Verify - assert nfo_path.exists() - content = nfo_path.read_text(encoding="utf-8") - assert "Test Series" in content - assert "2024" in content - - @pytest.mark.asyncio - async def test_create_minimal_nfo_extracts_year_from_name(self, nfo_service, tmp_path): - """Test that year is extracted from series name format (YYYY).""" - # Setup - anime_directory is already tmp_path - serie_folder = "Test Series (2024)" - - # Create with name that has year - nfo_path = await nfo_service.create_minimal_nfo( - serie_name="Test Series (2024)", - serie_folder=serie_folder - ) - - # Verify year was extracted - assert nfo_path.exists() - content = nfo_path.read_text(encoding="utf-8") - assert "Test Series" in content - assert "2024" in content - - @pytest.mark.asyncio - async def test_create_minimal_nfo_creates_folder_if_missing(self, nfo_service, tmp_path): - """Test that folder is created if it doesn't exist.""" - # Setup - anime_directory is tmp_path itself - serie_folder = "New Series" - - # Folder should not exist yet (under anime_directory which is tmp_path) - folder_path = tmp_path / serie_folder - assert not folder_path.exists() - - # Create minimal NFO - nfo_path = await nfo_service.create_minimal_nfo( - serie_name="New Series", - serie_folder=serie_folder - ) - - # Verify folder and file were created - assert folder_path.exists() - assert nfo_path.exists() - - @pytest.mark.asyncio - async def test_create_minimal_nfo_xml_is_valid(self, nfo_service, tmp_path): - """Test that generated XML is valid.""" - # Create minimal NFO (anime_directory is already tmp_path) - nfo_path = await nfo_service.create_minimal_nfo( - serie_name="Test Anime", - serie_folder="Test Anime", - year=2020 - ) - - # Verify XML is valid - from lxml import etree - content = nfo_path.read_text(encoding="utf-8") - - # Should parse without errors - tree = etree.fromstring(content.encode("utf-8")) - assert tree is not None - assert tree.tag == "tvshow" - - # Check title element - title = tree.find("title") - assert title is not None - assert title.text == "Test Anime" - - @pytest.mark.asyncio - async def test_create_minimal_nfo_no_tmdb_id(self, nfo_service, tmp_path): - """Test that minimal NFO has no TMDB ID.""" - # Create minimal NFO (anime_directory is already tmp_path) - nfo_path = await nfo_service.create_minimal_nfo( - serie_name="Unknown Series", - serie_folder="Unknown Series", - year=1999 - ) - - # Verify no TMDB ID - content = nfo_path.read_text(encoding="utf-8") - assert "" not in content - assert "uniqueid" not in content - - @pytest.mark.asyncio - async def test_create_minimal_nfo_has_plot_explanation(self, nfo_service, tmp_path): - """Test that minimal NFO contains explanation in plot.""" - # Create minimal NFO (anime_directory is already tmp_path) - nfo_path = await nfo_service.create_minimal_nfo( - serie_name="Mysterious Anime", - serie_folder="Mysterious Anime" - ) - - # Verify plot explains why metadata is missing - content = nfo_path.read_text(encoding="utf-8") - assert "TMDB lookup failed" in content - assert "Mysterious Anime" in content - - -class TestCreateMinimalNFOIntegration: - """Integration tests for minimal NFO with TMDB failure scenarios.""" - - @pytest.mark.asyncio - async def test_fallback_on_tmdb_search_failure(self, nfo_service, tmp_path): - """Test that minimal NFO is created when TMDB search fails.""" - # Mock TMDB client to raise error - nfo_service.tmdb_client.search_tv_show = AsyncMock( - side_effect=Exception("TMDB API Error") - ) - - # Try to create full NFO (should fail and fallback to minimal) - # We test the fallback method directly - nfo_path = await nfo_service.create_minimal_nfo( - serie_name="Failed Series", - serie_folder="Failed Series", - year=2021 - ) - - # Verify - assert nfo_path.exists() - content = nfo_path.read_text(encoding="utf-8") - assert "Failed Series" in content - assert "2021" in content - - @pytest.mark.asyncio - async def test_minimal_nfo_allows_series_tracking(self, nfo_service, tmp_path): - """Test that minimal NFO allows series to be tracked.""" - # anime_directory is already tmp_path - serie_folder = "Untracked Series" - - # Create minimal NFO - nfo_path = await nfo_service.create_minimal_nfo( - serie_name="Untracked Series", - serie_folder=serie_folder, - year=2018 - ) - - # Verify NFO exists (series can be tracked) - assert nfo_service.has_nfo(serie_folder) is True - - # Verify minimal content - content = nfo_path.read_text(encoding="utf-8") - assert "Untracked Series" in content - - -class TestMinimalNFOContent: - """Test content of minimal NFO files.""" - - @pytest.mark.asyncio - async def test_minimal_nfo_contains_required_elements(self, nfo_service, tmp_path): - """Test that minimal NFO has title and plot.""" - nfo_path = await nfo_service.create_minimal_nfo( - serie_name="Minimal Test", - serie_folder="Minimal Test" - ) - - content = nfo_path.read_text(encoding="utf-8") - - # Must have title - assert "Minimal Test" in content - # Must have plot explaining situation - assert "plot" in content.lower() - assert "No metadata available" in content - - @pytest.mark.asyncio - async def test_minimal_nfo_xml_declaration(self, nfo_service, tmp_path): - """Test that NFO has proper XML declaration.""" - nfo_path = await nfo_service.create_minimal_nfo( - serie_name="XML Test", - serie_folder="XML Test" - ) - - content = nfo_path.read_text(encoding="utf-8") - - # Should have XML declaration - assert content.startswith(' 10 are rejected.""" - with pytest.raises(ValidationError): - RatingInfo(name="test", value=11.0) - - def test_rating_info_negative_votes_rejected(self): - """Test that negative vote counts are rejected.""" - with pytest.raises(ValidationError): - RatingInfo(name="test", value=5.0, votes=-10) - - def test_rating_info_zero_values_accepted(self): - """Test that zero values are accepted.""" - rating = RatingInfo(name="test", value=0.0, votes=0) - assert rating.value == 0.0 - assert rating.votes == 0 - - -class TestActorInfo: - """Test ActorInfo model.""" - - def test_actor_info_with_all_fields(self): - """Test creating ActorInfo with all fields.""" - actor = ActorInfo( - name="John Doe", - role="Main Character", - thumb="https://example.com/actor.jpg", - profile="https://example.com/profile", - tmdbid=12345 - ) - - assert actor.name == "John Doe" - assert actor.role == "Main Character" - assert str(actor.thumb) == "https://example.com/actor.jpg" - assert str(actor.profile) == "https://example.com/profile" - assert actor.tmdbid == 12345 - - def test_actor_info_with_minimal_fields(self): - """Test creating ActorInfo with only name.""" - actor = ActorInfo(name="Jane Smith") - - assert actor.name == "Jane Smith" - assert actor.role is None - assert actor.thumb is None - assert actor.profile is None - assert actor.tmdbid is None - - def test_actor_info_invalid_url_rejected(self): - """Test that invalid URLs are rejected.""" - with pytest.raises(ValidationError): - ActorInfo(name="Test", thumb="not-a-url") - - def test_actor_info_http_url_accepted(self): - """Test that HTTP URLs are accepted.""" - actor = ActorInfo( - name="Test", - thumb="http://example.com/image.jpg" - ) - assert str(actor.thumb) == "http://example.com/image.jpg" - - -class TestImageInfo: - """Test ImageInfo model.""" - - def test_image_info_with_all_fields(self): - """Test creating ImageInfo with all fields.""" - image = ImageInfo( - url="https://image.tmdb.org/t/p/w500/poster.jpg", - aspect="poster", - season=1, - type="season" - ) - - assert str(image.url) == "https://image.tmdb.org/t/p/w500/poster.jpg" - assert image.aspect == "poster" - assert image.season == 1 - assert image.type == "season" - - def test_image_info_with_minimal_fields(self): - """Test creating ImageInfo with only URL.""" - image = ImageInfo(url="https://example.com/image.jpg") - - assert str(image.url) == "https://example.com/image.jpg" - assert image.aspect is None - assert image.season is None - assert image.type is None - - def test_image_info_invalid_url_rejected(self): - """Test that invalid URLs are rejected.""" - with pytest.raises(ValidationError): - ImageInfo(url="invalid-url") - - def test_image_info_negative_season_rejected(self): - """Test that season < -1 is rejected.""" - with pytest.raises(ValidationError): - ImageInfo( - url="https://example.com/image.jpg", - season=-2 - ) - - def test_image_info_season_minus_one_accepted(self): - """Test that season -1 is accepted (all seasons).""" - image = ImageInfo( - url="https://example.com/image.jpg", - season=-1 - ) - assert image.season == -1 - - -class TestNamedSeason: - """Test NamedSeason model.""" - - def test_named_season_creation(self): - """Test creating NamedSeason.""" - season = NamedSeason(number=1, name="Season One") - - assert season.number == 1 - assert season.name == "Season One" - - def test_named_season_negative_number_rejected(self): - """Test that negative season numbers are rejected.""" - with pytest.raises(ValidationError): - NamedSeason(number=-1, name="Invalid") - - def test_named_season_zero_accepted(self): - """Test that season 0 (specials) is accepted.""" - season = NamedSeason(number=0, name="Specials") - assert season.number == 0 - - -class TestUniqueID: - """Test UniqueID model.""" - - def test_unique_id_creation(self): - """Test creating UniqueID.""" - uid = UniqueID(type="tmdb", value="12345", default=True) - - assert uid.type == "tmdb" - assert uid.value == "12345" - assert uid.default is True - - def test_unique_id_default_false(self): - """Test UniqueID with default=False.""" - uid = UniqueID(type="imdb", value="tt1234567") - assert uid.default is False - - -class TestTVShowNFO: - """Test TVShowNFO model.""" - - def test_tvshow_nfo_minimal_creation(self): - """Test creating TVShowNFO with only required fields.""" - nfo = TVShowNFO(title="Test Show") - - assert nfo.title == "Test Show" - assert nfo.showtitle == "Test Show" # auto-set - assert nfo.originaltitle == "Test Show" # auto-set - assert nfo.year is None - assert nfo.studio == [] - assert nfo.genre == [] - assert nfo.watched is False - - def test_tvshow_nfo_with_all_basic_fields(self): - """Test creating TVShowNFO with all basic fields.""" - nfo = TVShowNFO( - title="Attack on Titan", - originaltitle="Shingeki no Kyojin", - showtitle="Attack on Titan", - sorttitle="Attack on Titan", - year=2013, - plot="Humanity lives in fear of Titans.", - outline="Titans attack humanity.", - tagline="The world is cruel.", - runtime=24, - mpaa="TV-14", - certification="14+", - premiered="2013-04-07", - status="Ended" - ) - - assert nfo.title == "Attack on Titan" - assert nfo.originaltitle == "Shingeki no Kyojin" - assert nfo.year == 2013 - assert nfo.plot == "Humanity lives in fear of Titans." - assert nfo.runtime == 24 - assert nfo.premiered == "2013-04-07" - - def test_tvshow_nfo_empty_title_rejected(self): - """Test that empty title is rejected.""" - with pytest.raises(ValidationError): - TVShowNFO(title="") - - def test_tvshow_nfo_invalid_year_rejected(self): - """Test that invalid years are rejected.""" - with pytest.raises(ValidationError): - TVShowNFO(title="Test", year=1800) - - with pytest.raises(ValidationError): - TVShowNFO(title="Test", year=2200) - - def test_tvshow_nfo_negative_runtime_rejected(self): - """Test that negative runtime is rejected.""" - with pytest.raises(ValidationError): - TVShowNFO(title="Test", runtime=-10) - - def test_tvshow_nfo_with_multi_value_fields(self): - """Test TVShowNFO with lists.""" - nfo = TVShowNFO( - title="Test Show", - studio=["Studio A", "Studio B"], - genre=["Action", "Drama"], - country=["Japan", "USA"], - tag=["anime", "popular"] - ) - - assert len(nfo.studio) == 2 - assert "Studio A" in nfo.studio - assert len(nfo.genre) == 2 - assert len(nfo.country) == 2 - assert len(nfo.tag) == 2 - - def test_tvshow_nfo_with_ratings(self): - """Test TVShowNFO with ratings.""" - nfo = TVShowNFO( - title="Test Show", - ratings=[ - RatingInfo(name="tmdb", value=8.5, votes=1000, default=True), - RatingInfo(name="imdb", value=8.2, votes=5000) - ], - userrating=9.0 - ) - - assert len(nfo.ratings) == 2 - assert nfo.ratings[0].name == "tmdb" - assert nfo.ratings[0].default is True - assert nfo.userrating == 9.0 - - def test_tvshow_nfo_invalid_userrating_rejected(self): - """Test that userrating outside 0-10 is rejected.""" - with pytest.raises(ValidationError): - TVShowNFO(title="Test", userrating=-1) - - with pytest.raises(ValidationError): - TVShowNFO(title="Test", userrating=11) - - def test_tvshow_nfo_with_ids(self): - """Test TVShowNFO with various IDs.""" - nfo = TVShowNFO( - title="Test Show", - tmdbid=12345, - imdbid="tt1234567", - tvdbid=67890, - uniqueid=[ - UniqueID(type="tmdb", value="12345"), - UniqueID(type="imdb", value="tt1234567", default=True) - ] - ) - - assert nfo.tmdbid == 12345 - assert nfo.imdbid == "tt1234567" - assert nfo.tvdbid == 67890 - assert len(nfo.uniqueid) == 2 - - def test_tvshow_nfo_invalid_imdbid_rejected(self): - """Test that invalid IMDB IDs are rejected.""" - with pytest.raises(ValidationError) as exc_info: - TVShowNFO(title="Test", imdbid="12345") - assert "must start with 'tt'" in str(exc_info.value) - - with pytest.raises(ValidationError) as exc_info: - TVShowNFO(title="Test", imdbid="ttabc123") - assert "followed by digits" in str(exc_info.value) - - def test_tvshow_nfo_valid_imdbid_accepted(self): - """Test that valid IMDB IDs are accepted.""" - nfo = TVShowNFO(title="Test", imdbid="tt1234567") - assert nfo.imdbid == "tt1234567" - - def test_tvshow_nfo_premiered_date_validation(self): - """Test premiered date format validation.""" - # Valid format - nfo = TVShowNFO(title="Test", premiered="2013-04-07") - assert nfo.premiered == "2013-04-07" - - # Invalid formats - with pytest.raises(ValidationError) as exc_info: - TVShowNFO(title="Test", premiered="2013-4-7") - assert "YYYY-MM-DD" in str(exc_info.value) - - with pytest.raises(ValidationError): - TVShowNFO(title="Test", premiered="04/07/2013") - - with pytest.raises(ValidationError): - TVShowNFO(title="Test", premiered="2013-13-01") # Invalid month - - def test_tvshow_nfo_dateadded_validation(self): - """Test dateadded format validation.""" - # Valid format - nfo = TVShowNFO(title="Test", dateadded="2024-12-15 10:29:11") - assert nfo.dateadded == "2024-12-15 10:29:11" - - # Invalid formats - with pytest.raises(ValidationError) as exc_info: - TVShowNFO(title="Test", dateadded="2024-12-15") - assert "YYYY-MM-DD HH:MM:SS" in str(exc_info.value) - - with pytest.raises(ValidationError): - TVShowNFO(title="Test", dateadded="2024-12-15 25:00:00") - - def test_tvshow_nfo_with_images(self): - """Test TVShowNFO with image information.""" - nfo = TVShowNFO( - title="Test Show", - thumb=[ - ImageInfo( - url="https://image.tmdb.org/t/p/w500/poster.jpg", - aspect="poster" - ), - ImageInfo( - url="https://image.tmdb.org/t/p/original/logo.png", - aspect="clearlogo" - ) - ], - fanart=[ - ImageInfo( - url="https://image.tmdb.org/t/p/original/fanart.jpg" - ) - ] - ) - - assert len(nfo.thumb) == 2 - assert nfo.thumb[0].aspect == "poster" - assert len(nfo.fanart) == 1 - - def test_tvshow_nfo_with_actors(self): - """Test TVShowNFO with cast information.""" - nfo = TVShowNFO( - title="Test Show", - actors=[ - ActorInfo( - name="Actor One", - role="Main Character", - thumb="https://example.com/actor1.jpg", - tmdbid=111 - ), - ActorInfo( - name="Actor Two", - role="Supporting Role" - ) - ] - ) - - assert len(nfo.actors) == 2 - assert nfo.actors[0].name == "Actor One" - assert nfo.actors[0].role == "Main Character" - assert nfo.actors[1].tmdbid is None - - def test_tvshow_nfo_with_named_seasons(self): - """Test TVShowNFO with named seasons.""" - nfo = TVShowNFO( - title="Test Show", - namedseason=[ - NamedSeason(number=1, name="First Season"), - NamedSeason(number=2, name="Second Season") - ] - ) - - assert len(nfo.namedseason) == 2 - assert nfo.namedseason[0].number == 1 - - def test_tvshow_nfo_with_trailer(self): - """Test TVShowNFO with trailer URL.""" - nfo = TVShowNFO( - title="Test Show", - trailer="https://www.youtube.com/watch?v=abc123" - ) - - assert nfo.trailer is not None - assert "youtube.com" in str(nfo.trailer) - - def test_tvshow_nfo_watched_and_playcount(self): - """Test TVShowNFO with viewing information.""" - nfo = TVShowNFO( - title="Test Show", - watched=True, - playcount=5 - ) - - assert nfo.watched is True - assert nfo.playcount == 5 - - def test_tvshow_nfo_negative_playcount_rejected(self): - """Test that negative playcount is rejected.""" - with pytest.raises(ValidationError): - TVShowNFO(title="Test", playcount=-1) - - def test_tvshow_nfo_serialization(self): - """Test TVShowNFO can be serialized to dict.""" - nfo = TVShowNFO( - title="Test Show", - year=2020, - genre=["Action", "Drama"], - tmdbid=12345 - ) - - data = nfo.model_dump() - - assert data["title"] == "Test Show" - assert data["year"] == 2020 - assert data["genre"] == ["Action", "Drama"] - assert data["tmdbid"] == 12345 - assert "showtitle" in data - assert "originaltitle" in data - - def test_tvshow_nfo_deserialization(self): - """Test TVShowNFO can be deserialized from dict.""" - data = { - "title": "Test Show", - "year": 2020, - "genre": ["Action"], - "tmdbid": 12345, - "premiered": "2020-01-01" - } - - nfo = TVShowNFO(**data) - - assert nfo.title == "Test Show" - assert nfo.year == 2020 - assert nfo.genre == ["Action"] - assert nfo.tmdbid == 12345 - - def test_tvshow_nfo_special_characters_in_title(self): - """Test TVShowNFO handles special characters.""" - nfo = TVShowNFO( - title="Test: Show & Movie's \"Best\" ", - plot="Special chars: < > & \" '" - ) - - assert nfo.title == "Test: Show & Movie's \"Best\" " - assert nfo.plot == "Special chars: < > & \" '" - - def test_tvshow_nfo_unicode_characters(self): - """Test TVShowNFO handles Unicode characters.""" - nfo = TVShowNFO( - title="進撃の巨人", - originaltitle="Shingeki no Kyojin", - plot="日本のアニメシリーズ" - ) - - assert nfo.title == "進撃の巨人" - assert nfo.plot == "日本のアニメシリーズ" - - def test_tvshow_nfo_none_values(self): - """Test TVShowNFO handles None values correctly.""" - nfo = TVShowNFO( - title="Test Show", - plot=None, - year=None, - tmdbid=None - ) - - assert nfo.title == "Test Show" - assert nfo.plot is None - assert nfo.year is None - assert nfo.tmdbid is None - - def test_tvshow_nfo_empty_lists(self): - """Test TVShowNFO with empty lists.""" - nfo = TVShowNFO( - title="Test Show", - genre=[], - actors=[], - ratings=[] - ) - - assert nfo.genre == [] - assert nfo.actors == [] - assert nfo.ratings == [] - - @pytest.mark.parametrize("year", [1900, 2000, 2025, 2100]) - def test_tvshow_nfo_valid_years(self, year): - """Test TVShowNFO accepts valid years.""" - nfo = TVShowNFO(title="Test Show", year=year) - assert nfo.year == year - - @pytest.mark.parametrize("year", [1899, 2101, -1]) - def test_tvshow_nfo_invalid_years(self, year): - """Test TVShowNFO rejects invalid years.""" - with pytest.raises(ValidationError): - TVShowNFO(title="Test Show", year=year) - - @pytest.mark.parametrize("imdbid", [ - "tt0123456", - "tt1234567", - "tt12345678", - "tt123456789" - ]) - def test_tvshow_nfo_valid_imdbids(self, imdbid): - """Test TVShowNFO accepts valid IMDB IDs.""" - nfo = TVShowNFO(title="Test Show", imdbid=imdbid) - assert nfo.imdbid == imdbid - - @pytest.mark.parametrize("imdbid", [ - "123456", - "tt", - "ttabc123", - "TT123456", - "tt-123456" - ]) - def test_tvshow_nfo_invalid_imdbids(self, imdbid): - """Test TVShowNFO rejects invalid IMDB IDs.""" - with pytest.raises(ValidationError): - TVShowNFO(title="Test Show", imdbid=imdbid) diff --git a/tests/unit/test_nfo_repair_service.py b/tests/unit/test_nfo_repair_service.py deleted file mode 100644 index 5608e76..0000000 --- a/tests/unit/test_nfo_repair_service.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Unit tests for NfoRepairService — Task 1.""" - -import shutil -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from src.core.services.nfo_repair_service import ( - REQUIRED_TAGS, - NfoRepairService, - find_missing_tags, - nfo_needs_repair, - parse_nfo_tags, -) - -REPO_ROOT = Path(__file__).parents[2] -BAD_NFO = REPO_ROOT / "tvshow.nfo.bad" -GOOD_NFO = REPO_ROOT / "tvshow.nfo.good" - -# Tags known to be absent/empty in tvshow.nfo.bad -EXPECTED_MISSING_FROM_BAD = { - "originaltitle", "year", "plot", "runtime", "premiered", - "status", "imdbid", "genre", "studio", "country", "actor/name", "watched", -} - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture() -def bad_nfo(tmp_path: Path) -> Path: - """Copy tvshow.nfo.bad into a temp dir and return path to the copy.""" - dest = tmp_path / "tvshow.nfo" - shutil.copy(BAD_NFO, dest) - return dest - - -@pytest.fixture() -def good_nfo(tmp_path: Path) -> Path: - """Copy tvshow.nfo.good into a temp dir and return path to the copy.""" - dest = tmp_path / "tvshow.nfo" - shutil.copy(GOOD_NFO, dest) - return dest - - -@pytest.fixture() -def mock_nfo_service() -> MagicMock: - """Return a MagicMock NFOService with an async update_tvshow_nfo.""" - svc = MagicMock() - svc.update_tvshow_nfo = AsyncMock(return_value=Path("/fake/tvshow.nfo")) - return svc - - -# --------------------------------------------------------------------------- -# find_missing_tags -# --------------------------------------------------------------------------- - - -def test_find_missing_tags_with_bad_nfo(bad_nfo: Path) -> None: - """Bad NFO must report all 12 incomplete/missing tags.""" - missing = find_missing_tags(bad_nfo) - assert set(missing) == EXPECTED_MISSING_FROM_BAD, ( - f"Unexpected missing set: {set(missing)}" - ) - - -def test_find_missing_tags_with_good_nfo(good_nfo: Path) -> None: - """Good NFO must report no missing tags.""" - missing = find_missing_tags(good_nfo) - assert missing == [] - - -# --------------------------------------------------------------------------- -# nfo_needs_repair -# --------------------------------------------------------------------------- - - -def test_nfo_needs_repair_returns_true_for_bad_nfo(bad_nfo: Path) -> None: - assert nfo_needs_repair(bad_nfo) is True - - -def test_nfo_needs_repair_returns_false_for_good_nfo(good_nfo: Path) -> None: - assert nfo_needs_repair(good_nfo) is False - - -# --------------------------------------------------------------------------- -# NfoRepairService.repair_series -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_repair_series_calls_update_when_nfo_needs_repair( - tmp_path: Path, mock_nfo_service: MagicMock -) -> None: - """repair_series must call update_tvshow_nfo exactly once for a bad NFO.""" - shutil.copy(BAD_NFO, tmp_path / "tvshow.nfo") - service = NfoRepairService(mock_nfo_service) - - result = await service.repair_series(tmp_path, "Test Series") - - assert result is True - mock_nfo_service.update_tvshow_nfo.assert_called_once_with( - "Test Series", download_media=False - ) - - -@pytest.mark.asyncio -async def test_repair_series_skips_when_nfo_is_complete( - tmp_path: Path, mock_nfo_service: MagicMock -) -> None: - """repair_series must NOT call update_tvshow_nfo for a complete NFO.""" - shutil.copy(GOOD_NFO, tmp_path / "tvshow.nfo") - service = NfoRepairService(mock_nfo_service) - - result = await service.repair_series(tmp_path, "Test Series") - - assert result is False - mock_nfo_service.update_tvshow_nfo.assert_not_called() - - -# --------------------------------------------------------------------------- -# parse_nfo_tags edge cases -# --------------------------------------------------------------------------- - - -def test_parse_nfo_tags_handles_missing_file_gracefully() -> None: - """parse_nfo_tags must return empty dict for non-existent path.""" - result = parse_nfo_tags(Path("/nonexistent/dir/tvshow.nfo")) - assert result == {} - - -def test_parse_nfo_tags_handles_malformed_xml_gracefully(tmp_path: Path) -> None: - """parse_nfo_tags must return empty dict for malformed XML.""" - bad_xml = tmp_path / "tvshow.nfo" - bad_xml.write_text("<<< not valid xml >>>", encoding="utf-8") - result = parse_nfo_tags(bad_xml) - assert result == {} diff --git a/tests/unit/test_nfo_service.py b/tests/unit/test_nfo_service.py deleted file mode 100644 index d4aa763..0000000 --- a/tests/unit/test_nfo_service.py +++ /dev/null @@ -1,1915 +0,0 @@ -"""Unit tests for NFO service.""" - -import time -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from src.core.services.nfo_service import NFOService -from src.core.services.tmdb_client import TMDBAPIError -from src.core.utils.nfo_mapper import _extract_fsk_rating, tmdb_to_nfo_model - - -@pytest.fixture -def nfo_service(tmp_path): - """Create NFO service with test directory.""" - service = NFOService( - tmdb_api_key="test_api_key", - anime_directory=str(tmp_path), - image_size="w500", - auto_create=True - ) - return service - - -@pytest.fixture -def tmdb_client(): - """Create TMDB client with test API key.""" - from src.core.services.tmdb_client import TMDBClient - client = TMDBClient(api_key="test_api_key") - return client - - -@pytest.fixture -def mock_tmdb_data(): - """Mock TMDB API response data.""" - return { - "id": 1429, - "name": "Attack on Titan", - "original_name": "進撃の巨人", - "first_air_date": "2013-04-07", - "overview": "Several hundred years ago, humans were nearly...", - "vote_average": 8.6, - "vote_count": 5000, - "status": "Ended", - "episode_run_time": [24], - "genres": [{"id": 16, "name": "Animation"}, {"id": 10765, "name": "Sci-Fi & Fantasy"}], - "networks": [{"id": 1, "name": "MBS"}], - "production_countries": [{"name": "Japan"}], - "poster_path": "/poster.jpg", - "backdrop_path": "/backdrop.jpg", - "external_ids": { - "imdb_id": "tt2560140", - "tvdb_id": 267440 - }, - "credits": { - "cast": [ - { - "id": 1, - "name": "Yuki Kaji", - "character": "Eren Yeager", - "profile_path": "/actor.jpg" - } - ] - }, - "images": { - "logos": [{"file_path": "/logo.png"}] - } - } - - -@pytest.fixture -def mock_content_ratings_de(): - """Mock TMDB content ratings with German FSK rating.""" - return { - "results": [ - {"iso_3166_1": "DE", "rating": "16"}, - {"iso_3166_1": "US", "rating": "TV-MA"} - ] - } - - -@pytest.fixture -def mock_content_ratings_no_de(): - """Mock TMDB content ratings without German rating.""" - return { - "results": [ - {"iso_3166_1": "US", "rating": "TV-MA"}, - {"iso_3166_1": "GB", "rating": "15"} - ] - } - - -class TestFSKRatingExtraction: - """Test FSK rating extraction from TMDB content ratings.""" - - def test_extract_fsk_rating_de(self, nfo_service, mock_content_ratings_de): - """Test extraction of German FSK rating.""" - fsk = _extract_fsk_rating(mock_content_ratings_de) - assert fsk == "FSK 16" - - def test_extract_fsk_rating_no_de(self, nfo_service, mock_content_ratings_no_de): - """Test extraction when no German rating available.""" - fsk = _extract_fsk_rating(mock_content_ratings_no_de) - assert fsk is None - - def test_extract_fsk_rating_empty(self, nfo_service): - """Test extraction with empty content ratings.""" - fsk = _extract_fsk_rating({}) - assert fsk is None - - def test_extract_fsk_rating_none(self, nfo_service): - """Test extraction with None input.""" - fsk = _extract_fsk_rating(None) - assert fsk is None - - def test_extract_fsk_all_values(self, nfo_service): - """Test extraction of all FSK values.""" - fsk_mappings = { - "0": "FSK 0", - "6": "FSK 6", - "12": "FSK 12", - "16": "FSK 16", - "18": "FSK 18" - } - - for rating_value, expected_fsk in fsk_mappings.items(): - content_ratings = { - "results": [{"iso_3166_1": "DE", "rating": rating_value}] - } - fsk = _extract_fsk_rating(content_ratings) - assert fsk == expected_fsk - - def test_extract_fsk_already_formatted(self, nfo_service): - """Test extraction when rating is already in FSK format.""" - content_ratings = { - "results": [{"iso_3166_1": "DE", "rating": "FSK 12"}] - } - fsk = _extract_fsk_rating(content_ratings) - assert fsk == "FSK 12" - - def test_extract_fsk_partial_match(self, nfo_service): - """Test extraction with partial number match.""" - content_ratings = { - "results": [{"iso_3166_1": "DE", "rating": "Ab 16 Jahren"}] - } - fsk = _extract_fsk_rating(content_ratings) - assert fsk == "FSK 16" - - def test_extract_fsk_unmapped_value(self, nfo_service): - """Test extraction with unmapped rating value.""" - content_ratings = { - "results": [{"iso_3166_1": "DE", "rating": "Unknown"}] - } - fsk = _extract_fsk_rating(content_ratings) - assert fsk is None - - -class TestYearExtraction: - """Test year extraction from series names.""" - - def test_extract_year_with_year(self, nfo_service): - """Test extraction when year is present in format (YYYY).""" - clean_name, year = nfo_service._extract_year_from_name("Attack on Titan (2013)") - assert clean_name == "Attack on Titan" - assert year == 2013 - - def test_extract_year_without_year(self, nfo_service): - """Test extraction when no year is present.""" - clean_name, year = nfo_service._extract_year_from_name("Attack on Titan") - assert clean_name == "Attack on Titan" - assert year is None - - def test_extract_year_multiple_parentheses(self, nfo_service): - """Test extraction with multiple parentheses - only last one with year.""" - clean_name, year = nfo_service._extract_year_from_name("Series (Part 1) (2023)") - assert clean_name == "Series (Part 1)" - assert year == 2023 - - def test_extract_year_with_trailing_spaces(self, nfo_service): - """Test extraction with trailing spaces.""" - clean_name, year = nfo_service._extract_year_from_name("Attack on Titan (2013) ") - assert clean_name == "Attack on Titan" - assert year == 2013 - - def test_extract_year_parentheses_not_year(self, nfo_service): - """Test extraction when parentheses don't contain a year.""" - clean_name, year = nfo_service._extract_year_from_name("Series (Special Edition)") - assert clean_name == "Series (Special Edition)" - assert year is None - - def test_extract_year_invalid_year_format(self, nfo_service): - """Test extraction with invalid year format (not 4 digits).""" - clean_name, year = nfo_service._extract_year_from_name("Series (23)") - assert clean_name == "Series (23)" - assert year is None - - def test_extract_year_future_year(self, nfo_service): - """Test extraction with future year.""" - clean_name, year = nfo_service._extract_year_from_name("Future Series (2050)") - assert clean_name == "Future Series" - assert year == 2050 - - def test_extract_year_old_year(self, nfo_service): - """Test extraction with old year.""" - clean_name, year = nfo_service._extract_year_from_name("Classic Series (1990)") - assert clean_name == "Classic Series" - assert year == 1990 - - def test_extract_year_real_world_example(self, nfo_service): - """Test extraction with the real-world example from the bug report.""" - clean_name, year = nfo_service._extract_year_from_name("The Dreaming Boy is a Realist (2023)") - assert clean_name == "The Dreaming Boy is a Realist" - assert year == 2023 - - def test_extract_year_uebel_blatt(self, nfo_service): - """Test extraction with Übel Blatt example.""" - clean_name, year = nfo_service._extract_year_from_name("Übel Blatt (2025)") - assert clean_name == "Übel Blatt" - assert year == 2025 - - -class TestTMDBToNFOModel: - """Test conversion of TMDB data to NFO model.""" - - @patch('src.core.utils.nfo_mapper._extract_fsk_rating') - def test_tmdb_to_nfo_with_fsk(self, mock_extract_fsk, nfo_service, mock_tmdb_data, mock_content_ratings_de): - """Test conversion includes FSK rating.""" - mock_extract_fsk.return_value = "FSK 16" - - nfo_model = tmdb_to_nfo_model( - mock_tmdb_data, mock_content_ratings_de, - nfo_service.tmdb_client.get_image_url, nfo_service.image_size - ) - - assert nfo_model.title == "Attack on Titan" - assert nfo_model.fsk == "FSK 16" - assert nfo_model.year == 2013 - mock_extract_fsk.assert_called_once_with(mock_content_ratings_de) - - def test_tmdb_to_nfo_without_content_ratings(self, nfo_service, mock_tmdb_data): - """Test conversion without content ratings.""" - nfo_model = tmdb_to_nfo_model( - mock_tmdb_data, None, - nfo_service.tmdb_client.get_image_url, nfo_service.image_size - ) - - assert nfo_model.title == "Attack on Titan" - assert nfo_model.fsk is None - assert nfo_model.tmdbid == 1429 - - def test_tmdb_to_nfo_basic_fields(self, nfo_service, mock_tmdb_data): - """Test that all basic fields are correctly mapped.""" - nfo_model = tmdb_to_nfo_model( - mock_tmdb_data, None, - nfo_service.tmdb_client.get_image_url, nfo_service.image_size - ) - - assert nfo_model.title == "Attack on Titan" - assert nfo_model.originaltitle == "進撃の巨人" - assert nfo_model.year == 2013 - assert nfo_model.plot == "Several hundred years ago, humans were nearly..." - assert nfo_model.status == "Ended" - assert nfo_model.runtime == 24 - assert nfo_model.premiered == "2013-04-07" - - def test_tmdb_to_nfo_ids(self, nfo_service, mock_tmdb_data): - """Test that all IDs are correctly mapped.""" - nfo_model = tmdb_to_nfo_model( - mock_tmdb_data, None, - nfo_service.tmdb_client.get_image_url, nfo_service.image_size - ) - - assert nfo_model.tmdbid == 1429 - assert nfo_model.imdbid == "tt2560140" - assert nfo_model.tvdbid == 267440 - assert len(nfo_model.uniqueid) == 3 - - def test_tmdb_to_nfo_genres_studios(self, nfo_service, mock_tmdb_data): - """Test that genres and studios are correctly mapped.""" - nfo_model = tmdb_to_nfo_model( - mock_tmdb_data, None, - nfo_service.tmdb_client.get_image_url, nfo_service.image_size - ) - - assert "Animation" in nfo_model.genre - assert "Sci-Fi & Fantasy" in nfo_model.genre - assert "MBS" in nfo_model.studio - assert "Japan" in nfo_model.country - - def test_tmdb_to_nfo_ratings(self, nfo_service, mock_tmdb_data): - """Test that ratings are correctly mapped.""" - nfo_model = tmdb_to_nfo_model( - mock_tmdb_data, None, - nfo_service.tmdb_client.get_image_url, nfo_service.image_size - ) - - assert len(nfo_model.ratings) == 1 - assert nfo_model.ratings[0].name == "themoviedb" - assert nfo_model.ratings[0].value == 8.6 - assert nfo_model.ratings[0].votes == 5000 - - def test_tmdb_to_nfo_cast(self, nfo_service, mock_tmdb_data): - """Test that cast is correctly mapped.""" - nfo_model = tmdb_to_nfo_model( - mock_tmdb_data, None, - nfo_service.tmdb_client.get_image_url, nfo_service.image_size - ) - - assert len(nfo_model.actors) == 1 - assert nfo_model.actors[0].name == "Yuki Kaji" - assert nfo_model.actors[0].role == "Eren Yeager" - assert nfo_model.actors[0].tmdbid == 1 - - -class TestCreateTVShowNFO: - """Test NFO creation workflow.""" - - @pytest.mark.asyncio - async def test_create_nfo_with_year_in_name(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): - """Test NFO creation when year is included in series name. - - This test addresses the bug where searching TMDB with year in the name - (e.g., "The Dreaming Boy is a Realist (2023)") fails to find results. - """ - # Setup - serie_name = "The Dreaming Boy is a Realist (2023)" - serie_folder = "The Dreaming Boy is a Realist (2023)" - (tmp_path / serie_folder).mkdir() - - # Mock TMDB responses - search_results = {"results": [mock_tmdb_data]} - - with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client): - with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None): - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search: - with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details: - with patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings: - with patch.object(nfo_service.image_downloader, 'download_poster', new_callable=AsyncMock): - with patch.object(nfo_service.image_downloader, 'download_logo', new_callable=AsyncMock): - with patch.object(nfo_service.image_downloader, 'download_fanart', new_callable=AsyncMock): - mock_search.return_value = search_results - mock_details.return_value = mock_tmdb_data - mock_ratings.return_value = mock_content_ratings_de - - # Act - nfo_path = await nfo_service.create_tvshow_nfo( - serie_name=serie_name, - serie_folder=serie_folder, - year=None # Year should be auto-extracted - ) - - # Assert - should search with clean name "The Dreaming Boy is a Realist" - mock_search.assert_called_once_with("The Dreaming Boy is a Realist", language="de-DE") - - # Verify NFO file was created - assert nfo_path.exists() - assert nfo_path.name == "tvshow.nfo" - - @pytest.mark.asyncio - async def test_create_nfo_year_parameter_takes_precedence(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): - """Test that explicit year parameter takes precedence over extracted year.""" - # Setup - serie_name = "Attack on Titan (2013)" - serie_folder = "Attack on Titan" - explicit_year = 2015 # Different from extracted year - (tmp_path / serie_folder).mkdir() - - # Mock TMDB responses - search_results = {"results": [mock_tmdb_data]} - - with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client): - with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None): - with patch.object(nfo_service, '_search_with_fallback', new_callable=AsyncMock) as mock_search_fallback: - with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details: - with patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings: - with patch.object(nfo_service, '_enrich_details_with_fallback', new_callable=AsyncMock) as mock_enrich: - with patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock): - mock_search_fallback.return_value = (mock_tmdb_data, "primary") - mock_details.return_value = mock_tmdb_data - mock_ratings.return_value = mock_content_ratings_de - mock_enrich.return_value = mock_tmdb_data - - # Act - await nfo_service.create_tvshow_nfo( - serie_name=serie_name, - serie_folder=serie_folder, - year=explicit_year # Explicit year provided - ) - - # Assert - _search_with_fallback should be called with explicit year - mock_search_fallback.assert_called_once() - call_args = mock_search_fallback.call_args - assert call_args[0][0] == "Attack on Titan" # clean name - assert call_args[0][1] == explicit_year # explicit year - - @pytest.mark.asyncio - async def test_create_nfo_no_results_with_clean_name(self, nfo_service, tmp_path): - """Test error handling when TMDB returns no results even with clean name.""" - # Setup - serie_name = "Nonexistent Series (2023)" - serie_folder = "Nonexistent Series (2023)" - (tmp_path / serie_folder).mkdir() - - with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client): - with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None): - with patch.object(nfo_service, '_search_with_fallback', new_callable=AsyncMock) as mock_search_fallback: - mock_search_fallback.side_effect = TMDBAPIError("No results found for: Nonexistent Series") - - # Act & Assert - with pytest.raises(TMDBAPIError) as exc_info: - await nfo_service.create_tvshow_nfo( - serie_name=serie_name, - serie_folder=serie_folder - ) - - # Should use clean name in error message - assert "No results found for: Nonexistent Series" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_create_nfo_with_fsk(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): - """Test NFO creation includes FSK rating.""" - # Create series folder - series_folder = tmp_path / "Attack on Titan" - series_folder.mkdir() - - # Mock TMDB client methods - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock): - - mock_search.return_value = { - "results": [{ - "id": 1429, - "name": "Attack on Titan", - "first_air_date": "2013-04-07", - "overview": "Several hundred years ago, humans were nearly...", - }] - } - mock_details.return_value = mock_tmdb_data - mock_ratings.return_value = mock_content_ratings_de - - # Create NFO - nfo_path = await nfo_service.create_tvshow_nfo( - "Attack on Titan", - "Attack on Titan", - year=2013, - download_poster=False, - download_logo=False, - download_fanart=False - ) - - # Verify NFO was created - assert nfo_path.exists() - nfo_content = nfo_path.read_text(encoding="utf-8") - - # Check that FSK rating is in the NFO - assert "FSK 16" in nfo_content - - # Verify TMDB methods were called - mock_search.assert_called_once() - mock_details.assert_called_once_with(1429, append_to_response="credits,external_ids,images") - mock_ratings.assert_called_once_with(1429) - - @pytest.mark.asyncio - async def test_create_nfo_without_fsk(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_no_de): - """Test NFO creation fallback when no FSK available.""" - # Create series folder - series_folder = tmp_path / "Attack on Titan" - series_folder.mkdir() - - # Mock TMDB client methods - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock): - - mock_search.return_value = { - "results": [{ - "id": 1429, - "name": "Attack on Titan", - "first_air_date": "2013-04-07", - "overview": "Several hundred years ago, humans were nearly...", - }] - } - mock_details.return_value = mock_tmdb_data - mock_ratings.return_value = mock_content_ratings_no_de - - # Create NFO - nfo_path = await nfo_service.create_tvshow_nfo( - "Attack on Titan", - "Attack on Titan", - year=2013, - download_poster=False, - download_logo=False, - download_fanart=False - ) - - # Verify NFO was created - assert nfo_path.exists() - nfo_content = nfo_path.read_text(encoding="utf-8") - - # FSK should not be in the NFO - assert "FSK" not in nfo_content - - @pytest.mark.asyncio - async def test_update_nfo_with_fsk(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): - """Test NFO update includes FSK rating.""" - # Create series folder with existing NFO - series_folder = tmp_path / "Attack on Titan" - series_folder.mkdir() - nfo_path = series_folder / "tvshow.nfo" - nfo_path.write_text(""" - - Attack on Titan - 1429 - -""", encoding="utf-8") - - # Mock TMDB client methods - with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock): - - mock_details.return_value = mock_tmdb_data - mock_ratings.return_value = mock_content_ratings_de - - # Update NFO - updated_path = await nfo_service.update_tvshow_nfo( - "Attack on Titan", - download_media=False - ) - - # Verify NFO was updated - assert updated_path.exists() - nfo_content = updated_path.read_text(encoding="utf-8") - - # Check that FSK rating is in the updated NFO - assert "FSK 16" in nfo_content - - # Verify TMDB methods were called - mock_details.assert_called_once_with(1429, append_to_response="credits,external_ids,images") - mock_ratings.assert_called_once_with(1429) - - -class TestEnrichDetailsWithFallback: - """Tests for English fallback when German overview is empty.""" - - @pytest.mark.asyncio - async def test_create_nfo_uses_english_fallback_for_empty_overview( - self, nfo_service, tmp_path - ): - """When the German overview is empty, create_tvshow_nfo should - fetch the English overview from TMDB and include it as .""" - series_folder = tmp_path / "Basilisk" - series_folder.mkdir() - - # German TMDB data with empty overview - de_data = { - "id": 35014, "name": "Basilisk", - "original_name": "甲賀忍法帖", "first_air_date": "2005-04-13", - "overview": "", # <-- empty German overview - "vote_average": 7.2, "vote_count": 200, - "status": "Ended", "episode_run_time": [24], - "genres": [{"id": 16, "name": "Animation"}], - "networks": [{"id": 1, "name": "MBS"}], - "production_countries": [{"name": "Japan"}], - "poster_path": "/poster.jpg", "backdrop_path": "/backdrop.jpg", - "external_ids": {"imdb_id": "tt0464064", "tvdb_id": 79604}, - "credits": {"cast": []}, - "images": {"logos": []}, - } - - # English TMDB data with overview - en_data = { - "id": 35014, - "overview": "The year is 1614 and two warring ninja clans collide.", - "tagline": "Blood spills when ninja clans clash.", - } - - async def side_effect(tv_id, **kwargs): - if kwargs.get("language") == "en-US": - return en_data - return de_data - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock): - - mock_search.return_value = { - "results": [{"id": 35014, "name": "Basilisk", "first_air_date": "2005-04-13"}] - } - mock_details.side_effect = side_effect - mock_ratings.return_value = {"results": []} - - nfo_path = await nfo_service.create_tvshow_nfo( - "Basilisk", "Basilisk", year=2005, - download_poster=False, download_logo=False, download_fanart=False, - ) - - content = nfo_path.read_text(encoding="utf-8") - assert "The year is 1614" in content - # Details called twice: once for de-DE, once for en-US fallback - assert mock_details.call_count == 2 - - @pytest.mark.asyncio - async def test_update_nfo_uses_english_fallback_for_empty_overview( - self, nfo_service, tmp_path - ): - """update_tvshow_nfo should also use the English fallback.""" - series_folder = tmp_path / "Basilisk" - series_folder.mkdir() - nfo_path = series_folder / "tvshow.nfo" - nfo_path.write_text( - '\nBasilisk' - "35014", - encoding="utf-8", - ) - - de_data = { - "id": 35014, "name": "Basilisk", - "original_name": "甲賀忍法帖", "first_air_date": "2005-04-13", - "overview": "", - "vote_average": 7.2, "vote_count": 200, - "status": "Ended", "episode_run_time": [24], - "genres": [{"id": 16, "name": "Animation"}], - "networks": [{"id": 1, "name": "MBS"}], - "production_countries": [{"name": "Japan"}], - "poster_path": "/poster.jpg", "backdrop_path": "/backdrop.jpg", - "external_ids": {"imdb_id": "tt0464064", "tvdb_id": 79604}, - "credits": {"cast": []}, - "images": {"logos": []}, - } - en_data = { - "id": 35014, - "overview": "English fallback overview for Basilisk.", - } - - async def side_effect(tv_id, **kwargs): - if kwargs.get("language") == "en-US": - return en_data - return de_data - - with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock): - - mock_details.side_effect = side_effect - mock_ratings.return_value = {"results": []} - - updated_path = await nfo_service.update_tvshow_nfo( - "Basilisk", download_media=False, - ) - - content = updated_path.read_text(encoding="utf-8") - assert "English fallback overview" in content - assert mock_details.call_count == 2 - - @pytest.mark.asyncio - async def test_no_fallback_when_german_overview_exists( - self, nfo_service, tmp_path - ): - """No English fallback call when German overview is present.""" - series_folder = tmp_path / "Attack on Titan" - series_folder.mkdir() - - de_data = { - "id": 1429, "name": "Attack on Titan", - "original_name": "進撃の巨人", "first_air_date": "2013-04-07", - "overview": "Vor mehreren hundert Jahren...", - "vote_average": 8.6, "vote_count": 5000, - "status": "Ended", "episode_run_time": [24], - "genres": [], "networks": [], "production_countries": [], - "poster_path": None, "backdrop_path": None, - "external_ids": {}, "credits": {"cast": []}, - "images": {"logos": []}, - } - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock): - - mock_search.return_value = { - "results": [{"id": 1429, "name": "Attack on Titan", "first_air_date": "2013-04-07"}] - } - mock_details.return_value = de_data - mock_ratings.return_value = {"results": []} - - nfo_path = await nfo_service.create_tvshow_nfo( - "Attack on Titan", "Attack on Titan", year=2013, - download_poster=False, download_logo=False, download_fanart=False, - ) - - content = nfo_path.read_text(encoding="utf-8") - assert "Vor mehreren hundert Jahren..." in content - # Only one detail call (German), no fallback needed - mock_details.assert_called_once_with(1429, append_to_response="credits,external_ids,images") - - @pytest.mark.asyncio - async def test_plot_tag_always_present_even_if_empty( - self, nfo_service, tmp_path - ): - """ tag should always be present, even when overview is missing - from both German and English TMDB data.""" - series_folder = tmp_path / "Unknown Show" - series_folder.mkdir() - - empty_data = { - "id": 99999, "name": "Unknown Show", - "original_name": "Unknown", "first_air_date": "2020-01-01", - "overview": "", - "vote_average": 0, "vote_count": 0, - "status": "Ended", "episode_run_time": [], - "genres": [], "networks": [], "production_countries": [], - "poster_path": None, "backdrop_path": None, - "external_ids": {}, "credits": {"cast": []}, - "images": {"logos": []}, - } - - async def side_effect(tv_id, **kwargs): - # English also empty - return empty_data - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock): - - mock_search.return_value = { - "results": [{"id": 99999, "name": "Unknown Show", "first_air_date": "2020-01-01"}] - } - mock_details.side_effect = side_effect - mock_ratings.return_value = {"results": []} - - nfo_path = await nfo_service.create_tvshow_nfo( - "Unknown Show", "Unknown Show", - download_poster=False, download_logo=False, download_fanart=False, - ) - - content = nfo_path.read_text(encoding="utf-8") - # (self-closing) or should be present - assert " - - Test Series - -""", encoding="utf-8") - - with pytest.raises(TMDBAPIError, match="No TMDB ID found"): - await nfo_service.update_tvshow_nfo("Test Series") - - @pytest.mark.asyncio - async def test_check_nfo_exists(self, nfo_service, tmp_path): - """Test checking if NFO exists.""" - series_folder = tmp_path / "Test Series" - series_folder.mkdir() - - # NFO doesn't exist yet - exists = await nfo_service.check_nfo_exists("Test Series") - assert not exists - - # Create NFO - nfo_path = series_folder / "tvshow.nfo" - nfo_path.write_text("", encoding="utf-8") - - # NFO now exists - exists = await nfo_service.check_nfo_exists("Test Series") - assert exists - - -class TestMediaDownloads: - """Test media file (poster, logo, fanart) download functionality.""" - - @pytest.mark.asyncio - async def test_download_media_all_enabled(self, nfo_service, tmp_path, mock_tmdb_data): - """Test downloading all media files when enabled.""" - series_folder = tmp_path / "Test Series" - series_folder.mkdir() - - with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - mock_download.return_value = { - "poster": True, - "logo": True, - "fanart": True - } - - results = await nfo_service._download_media_files( - mock_tmdb_data, - series_folder, - download_poster=True, - download_logo=True, - download_fanart=True - ) - - assert results["poster"] is True - assert results["logo"] is True - assert results["fanart"] is True - mock_download.assert_called_once() - - @pytest.mark.asyncio - async def test_download_media_poster_only(self, nfo_service, tmp_path, mock_tmdb_data): - """Test downloading only poster.""" - series_folder = tmp_path / "Test Series" - series_folder.mkdir() - - with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - mock_download.return_value = {"poster": True} - - results = await nfo_service._download_media_files( - mock_tmdb_data, - series_folder, - download_poster=True, - download_logo=False, - download_fanart=False - ) - - # Verify only poster URL was passed - call_args = mock_download.call_args - assert call_args.kwargs['poster_url'] is not None - assert call_args.kwargs['logo_url'] is None - assert call_args.kwargs['fanart_url'] is None - - @pytest.mark.asyncio - async def test_download_media_with_image_size(self, nfo_service, tmp_path, mock_tmdb_data): - """Test that image size configuration is used.""" - nfo_service.image_size = "w500" - series_folder = tmp_path / "Test Series" - series_folder.mkdir() - - with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - mock_download.return_value = {"poster": True} - - await nfo_service._download_media_files( - mock_tmdb_data, - series_folder, - download_poster=True, - download_logo=False, - download_fanart=False - ) - - # Verify image size was used for poster - call_args = mock_download.call_args - poster_url = call_args.kwargs['poster_url'] - assert "w500" in poster_url - - @pytest.mark.asyncio - async def test_download_media_missing_poster_path(self, nfo_service, tmp_path): - """Test media download when poster path is missing.""" - series_folder = tmp_path / "Test Series" - series_folder.mkdir() - - tmdb_data_no_poster = { - "id": 1, - "name": "Test", - "poster_path": None, - "backdrop_path": "/backdrop.jpg", - "images": {"logos": [{"file_path": "/logo.png"}]} - } - - with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - mock_download.return_value = {} - - await nfo_service._download_media_files( - tmdb_data_no_poster, - series_folder, - download_poster=True, - download_logo=True, - download_fanart=True - ) - - # Poster URL should be None - call_args = mock_download.call_args - assert call_args.kwargs['poster_url'] is None - - @pytest.mark.asyncio - async def test_download_media_no_logo_available(self, nfo_service, tmp_path): - """Test media download when logo is not available.""" - series_folder = tmp_path / "Test Series" - series_folder.mkdir() - - tmdb_data_no_logo = { - "id": 1, - "name": "Test", - "poster_path": "/poster.jpg", - "backdrop_path": "/backdrop.jpg", - "images": {"logos": []} # Empty logos array - } - - with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - mock_download.return_value = {"poster": True, "fanart": True} - - await nfo_service._download_media_files( - tmdb_data_no_logo, - series_folder, - download_poster=True, - download_logo=True, - download_fanart=True - ) - - # Logo URL should be None - call_args = mock_download.call_args - assert call_args.kwargs['logo_url'] is None - - @pytest.mark.asyncio - async def test_download_media_all_disabled(self, nfo_service, tmp_path, mock_tmdb_data): - """Test that no downloads occur when all disabled.""" - series_folder = tmp_path / "Test Series" - series_folder.mkdir() - - with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - mock_download.return_value = {} - - await nfo_service._download_media_files( - mock_tmdb_data, - series_folder, - download_poster=False, - download_logo=False, - download_fanart=False - ) - - # All URLs should be None - call_args = mock_download.call_args - assert call_args.kwargs['poster_url'] is None - assert call_args.kwargs['logo_url'] is None - assert call_args.kwargs['fanart_url'] is None - - @pytest.mark.asyncio - async def test_download_media_fanart_uses_original_size(self, nfo_service, tmp_path, mock_tmdb_data): - """Test that fanart always uses original size regardless of config.""" - nfo_service.image_size = "w500" - series_folder = tmp_path / "Test Series" - series_folder.mkdir() - - with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - mock_download.return_value = {"fanart": True} - - await nfo_service._download_media_files( - mock_tmdb_data, - series_folder, - download_poster=False, - download_logo=False, - download_fanart=True - ) - - # Fanart should use original size - call_args = mock_download.call_args - fanart_url = call_args.kwargs['fanart_url'] - assert "original" in fanart_url - - @pytest.mark.asyncio - async def test_download_media_logo_uses_original_size(self, nfo_service, tmp_path, mock_tmdb_data): - """Test that logo always uses original size.""" - nfo_service.image_size = "w500" - series_folder = tmp_path / "Test Series" - series_folder.mkdir() - - with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - mock_download.return_value = {"logo": True} - - await nfo_service._download_media_files( - mock_tmdb_data, - series_folder, - download_poster=False, - download_logo=True, - download_fanart=False - ) - - # Logo should use original size - call_args = mock_download.call_args - logo_url = call_args.kwargs['logo_url'] - assert "original" in logo_url - - -class TestNFOServiceConfiguration: - """Test NFO service with various configuration settings.""" - - def test_nfo_service_default_config(self, tmp_path): - """Test NFO service initialization with default config.""" - service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(tmp_path) - ) - - assert service.image_size == "original" - assert service.auto_create is True - - def test_nfo_service_custom_config(self, tmp_path): - """Test NFO service initialization with custom config.""" - service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(tmp_path), - image_size="w500", - auto_create=False - ) - - assert service.image_size == "w500" - assert service.auto_create is False - - def test_nfo_service_image_sizes(self, tmp_path): - """Test NFO service with various image sizes.""" - sizes = ["original", "w500", "w780", "w342"] - - for size in sizes: - service = NFOService( - tmdb_api_key="test_key", - anime_directory=str(tmp_path), - image_size=size - ) - assert service.image_size == size - - -class TestHasNFOMethod: - """Test the has_nfo method.""" - - def test_has_nfo_true(self, nfo_service, tmp_path): - """Test has_nfo returns True when NFO exists.""" - series_folder = tmp_path / "Test Series" - series_folder.mkdir() - nfo_path = series_folder / "tvshow.nfo" - nfo_path.write_text("") - - assert nfo_service.has_nfo("Test Series") is True - - def test_has_nfo_false(self, nfo_service, tmp_path): - """Test has_nfo returns False when NFO doesn't exist.""" - series_folder = tmp_path / "Test Series" - series_folder.mkdir() - - assert nfo_service.has_nfo("Test Series") is False - - def test_has_nfo_missing_folder(self, nfo_service): - """Test has_nfo returns False when folder doesn't exist.""" - assert nfo_service.has_nfo("Nonexistent Series") is False - - -class TestFindBestMatchEdgeCases: - """Test edge cases in _find_best_match.""" - - def test_find_best_match_no_year_multiple_results(self, nfo_service): - """Test finding best match returns first result when no year.""" - results = [ - {"id": 1, "name": "Series", "first_air_date": "2010-01-01"}, - {"id": 2, "name": "Series", "first_air_date": "2020-01-01"}, - ] - - match = nfo_service._find_best_match(results, "Series", year=None) - assert match["id"] == 1 - - def test_find_best_match_year_no_match(self, nfo_service): - """Test finding best match with year when no exact match returns first.""" - results = [ - {"id": 1, "name": "Series", "first_air_date": "2010-01-01"}, - {"id": 2, "name": "Series", "first_air_date": "2020-01-01"}, - ] - - match = nfo_service._find_best_match(results, "Series", year=2025) - # Should return first result as no year match found - assert match["id"] == 1 - - def test_find_best_match_empty_results(self, nfo_service): - """Test finding best match with empty results raises error.""" - with pytest.raises(TMDBAPIError, match="No search results"): - nfo_service._find_best_match([], "Series") - - def test_find_best_match_no_first_air_date(self, nfo_service): - """Test finding best match when result has no first_air_date.""" - results = [ - {"id": 1, "name": "Series"}, # No first_air_date - {"id": 2, "name": "Series", "first_air_date": "2020-01-01"}, - ] - - # With year, should check for first_air_date existence - match = nfo_service._find_best_match(results, "Series", year=2020) - assert match["id"] == 2 - - -class TestParseNFOIDsEdgeCases: - """Test edge cases in parse_nfo_ids.""" - - def test_parse_nfo_ids_malformed_ids(self, nfo_service, tmp_path): - """Test parsing IDs with malformed values.""" - nfo_path = tmp_path / "tvshow.nfo" - nfo_path.write_text( - '' - 'not_a_number' - 'abc123' - '' - ) - - ids = nfo_service.parse_nfo_ids(nfo_path) - - # Malformed values should be None - assert ids["tmdb_id"] is None - assert ids["tvdb_id"] is None - - def test_parse_nfo_ids_multiple_uniqueid(self, nfo_service, tmp_path): - """Test parsing when multiple uniqueid elements exist.""" - nfo_path = tmp_path / "tvshow.nfo" - nfo_path.write_text( - '' - '1429' - '79168' - 'tt2560140' - '' - ) - - ids = nfo_service.parse_nfo_ids(nfo_path) - - assert ids["tmdb_id"] == 1429 - assert ids["tvdb_id"] == 79168 - - def test_parse_nfo_ids_empty_uniqueid(self, nfo_service, tmp_path): - """Test parsing with empty uniqueid elements.""" - nfo_path = tmp_path / "tvshow.nfo" - nfo_path.write_text( - '' - '' - '' - '' - ) - - ids = nfo_service.parse_nfo_ids(nfo_path) - - assert ids["tmdb_id"] is None - assert ids["tvdb_id"] is None - - -class TestTMDBToNFOModelEdgeCases: - """Test edge cases in _tmdb_to_nfo_model.""" - - def test_tmdb_to_nfo_minimal_data(self, nfo_service): - """Test conversion with minimal TMDB data.""" - minimal_data = { - "id": 1, - "name": "Series", - "original_name": "Original" - } - - nfo_model = tmdb_to_nfo_model( - minimal_data, None, - nfo_service.tmdb_client.get_image_url, nfo_service.image_size - ) - - assert nfo_model.title == "Series" - assert nfo_model.originaltitle == "Original" - assert nfo_model.year is None - assert nfo_model.tmdbid == 1 - - def test_tmdb_to_nfo_with_all_cast(self, nfo_service, mock_tmdb_data): - """Test conversion includes cast members.""" - nfo_model = tmdb_to_nfo_model( - mock_tmdb_data, None, - nfo_service.tmdb_client.get_image_url, nfo_service.image_size - ) - - assert len(nfo_model.actors) >= 1 - assert nfo_model.actors[0].name == "Yuki Kaji" - assert nfo_model.actors[0].role == "Eren Yeager" - - def test_tmdb_to_nfo_multiple_genres(self, nfo_service, mock_tmdb_data): - """Test conversion with multiple genres.""" - nfo_model = tmdb_to_nfo_model( - mock_tmdb_data, None, - nfo_service.tmdb_client.get_image_url, nfo_service.image_size - ) - - assert "Animation" in nfo_model.genre - assert "Sci-Fi & Fantasy" in nfo_model.genre - - -class TestExtractFSKRatingEdgeCases: - """Test edge cases in _extract_fsk_rating.""" - - def test_extract_fsk_with_suffix(self, nfo_service): - """Test extraction when rating has suffix like 'Ab 16 Jahren'.""" - content_ratings = { - "results": [{"iso_3166_1": "DE", "rating": "Ab 16 Jahren"}] - } - - fsk = _extract_fsk_rating(content_ratings) - assert fsk == "FSK 16" - - def test_extract_fsk_multiple_numbers(self, nfo_service): - """Test extraction with multiple numbers - should pick highest.""" - content_ratings = { - "results": [{"iso_3166_1": "DE", "rating": "Rating 6 or 12"}] - } - - fsk = _extract_fsk_rating(content_ratings) - # Should find 12 first in the search order - assert fsk == "FSK 12" - - def test_extract_fsk_empty_results_list(self, nfo_service): - """Test extraction with empty results list.""" - content_ratings = {"results": []} - - fsk = _extract_fsk_rating(content_ratings) - assert fsk is None - - def test_extract_fsk_none_input(self, nfo_service): - """Test extraction with None input.""" - fsk = _extract_fsk_rating(None) - assert fsk is None - - def test_extract_fsk_missing_results_key(self, nfo_service): - """Test extraction when results key is missing.""" - fsk = _extract_fsk_rating({}) - assert fsk is None - - -class TestDownloadMediaFilesEdgeCases: - """Test edge cases in _download_media_files.""" - - @pytest.mark.asyncio - async def test_download_media_empty_tmdb_data(self, nfo_service, tmp_path): - """Test media download with empty TMDB data.""" - series_folder = tmp_path / "Test Series" - series_folder.mkdir() - - with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - mock_download.return_value = {} - - results = await nfo_service._download_media_files( - {}, - series_folder, - download_poster=True, - download_logo=True, - download_fanart=True - ) - - # Should call download with all None URLs - call_args = mock_download.call_args - assert call_args.kwargs['poster_url'] is None - assert call_args.kwargs['logo_url'] is None - assert call_args.kwargs['fanart_url'] is None - - @pytest.mark.asyncio - async def test_download_media_only_poster_available(self, nfo_service, tmp_path): - """Test media download when only poster is available.""" - series_folder = tmp_path / "Test Series" - series_folder.mkdir() - - tmdb_data = { - "id": 1, - "poster_path": "/poster.jpg", - "backdrop_path": None, - "images": {"logos": []} - } - - with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download: - mock_download.return_value = {"poster": True} - - await nfo_service._download_media_files( - tmdb_data, - series_folder, - download_poster=True, - download_logo=True, - download_fanart=True - ) - - call_args = mock_download.call_args - assert call_args.kwargs['poster_url'] is not None - assert call_args.kwargs['fanart_url'] is None - assert call_args.kwargs['logo_url'] is None - - -class TestUpdateNFOEdgeCases: - """Test edge cases in update_tvshow_nfo.""" - - @pytest.mark.asyncio - async def test_update_nfo_without_media_download(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): - """Test NFO update without re-downloading media.""" - series_folder = tmp_path / "Attack on Titan" - series_folder.mkdir() - nfo_path = series_folder / "tvshow.nfo" - nfo_path.write_text( - '1429', - encoding="utf-8" - ) - - with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock) as mock_download: - - mock_details.return_value = mock_tmdb_data - mock_ratings.return_value = mock_content_ratings_de - - await nfo_service.update_tvshow_nfo("Attack on Titan", download_media=False) - - # Verify download was not called - mock_download.assert_not_called() - - -class TestNFOServiceClose: - """Test NFO service cleanup and close.""" - - @pytest.mark.asyncio - async def test_nfo_service_close(self, nfo_service): - """Test NFO service close.""" - with patch.object(nfo_service.tmdb_client, 'close', new_callable=AsyncMock) as mock_close: - await nfo_service.close() - mock_close.assert_called_once() - - -class TestYearExtractionComprehensive: - """Comprehensive tests for year extraction.""" - - def test_extract_year_with_leading_spaces(self, nfo_service): - """Test extraction with leading spaces - they get stripped.""" - clean_name, year = nfo_service._extract_year_from_name(" Attack on Titan (2013)") - assert clean_name == "Attack on Titan" # Leading spaces are stripped - assert year == 2013 - - def test_extract_year_with_year_in_middle(self, nfo_service): - """Test that year in middle doesn't get extracted.""" - clean_name, year = nfo_service._extract_year_from_name("Attack on Titan 2013") - assert clean_name == "Attack on Titan 2013" - assert year is None - - def test_extract_year_three_digit(self, nfo_service): - """Test that 3-digit number is not extracted.""" - clean_name, year = nfo_service._extract_year_from_name("Series (123)") - assert clean_name == "Series (123)" - assert year is None - - def test_extract_year_five_digit(self, nfo_service): - """Test that 5-digit number is not extracted.""" - clean_name, year = nfo_service._extract_year_from_name("Series (12345)") - assert clean_name == "Series (12345)" - assert year is None - - -class TestEnrichFallbackLanguages: - """Tests for multi-language fallback and search overview fallback.""" - - @pytest.mark.asyncio - async def test_japanese_fallback_when_english_also_empty( - self, nfo_service, tmp_path, - ): - """ja-JP fallback is tried when both de-DE and en-US are empty.""" - series_folder = tmp_path / "Rare Anime" - series_folder.mkdir() - - de_data = { - "id": 55555, "name": "Rare Anime", - "original_name": "レアアニメ", "first_air_date": "2024-01-01", - "overview": "", - "vote_average": 7.0, "vote_count": 50, - "status": "Continuing", "episode_run_time": [24], - "genres": [], "networks": [], "production_countries": [], - "poster_path": None, "backdrop_path": None, - "external_ids": {}, "credits": {"cast": []}, - "images": {"logos": []}, - } - en_data = {"id": 55555, "overview": ""} - ja_data = {"id": 55555, "overview": "日本語のあらすじ"} - - async def side_effect(tv_id, **kwargs): - lang = kwargs.get("language") - if lang == "ja-JP": - return ja_data - if lang == "en-US": - return en_data - return de_data - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock): - - mock_search.return_value = { - "results": [{"id": 55555, "name": "Rare Anime", "first_air_date": "2024-01-01"}], - } - mock_details.side_effect = side_effect - mock_ratings.return_value = {"results": []} - - nfo_path = await nfo_service.create_tvshow_nfo( - "Rare Anime", "Rare Anime", - download_poster=False, download_logo=False, download_fanart=False, - ) - - content = nfo_path.read_text(encoding="utf-8") - assert "日本語のあらすじ" in content - - @pytest.mark.asyncio - async def test_search_overview_fallback_when_all_languages_empty( - self, nfo_service, tmp_path, - ): - """Search result overview is used as last resort.""" - series_folder = tmp_path / "Brand New Anime" - series_folder.mkdir() - - empty_data = { - "id": 77777, "name": "Brand New Anime", - "original_name": "新しいアニメ", "first_air_date": "2025-01-01", - "overview": "", - "vote_average": 0, "vote_count": 0, - "status": "Continuing", "episode_run_time": [], - "genres": [], "networks": [], "production_countries": [], - "poster_path": None, "backdrop_path": None, - "external_ids": {}, "credits": {"cast": []}, - "images": {"logos": []}, - } - - async def side_effect(tv_id, **kwargs): - return empty_data - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock): - - # Search result DOES have an overview - mock_search.return_value = { - "results": [{ - "id": 77777, - "name": "Brand New Anime", - "first_air_date": "2025-01-01", - "overview": "Search result overview text.", - }], - } - mock_details.side_effect = side_effect - mock_ratings.return_value = {"results": []} - - nfo_path = await nfo_service.create_tvshow_nfo( - "Brand New Anime", "Brand New Anime", - download_poster=False, download_logo=False, download_fanart=False, - ) - - content = nfo_path.read_text(encoding="utf-8") - assert "Search result overview text." in content - - @pytest.mark.asyncio - async def test_en_us_search_fallback_when_german_search_overview_empty( - self, nfo_service, tmp_path - ): - """When the German search overview is empty, fallback to en-US search overview.""" - series_folder = tmp_path / "Rare Anime" - series_folder.mkdir() - - empty_data = { - "id": 77777, "name": "Rare Anime", - "original_name": "新しいアニメ", "first_air_date": "2025-01-01", - "overview": "", - "vote_average": 0, "vote_count": 0, - "status": "Continuing", "episode_run_time": [], - "genres": [], "networks": [], "production_countries": [], - "poster_path": None, "backdrop_path": None, - "external_ids": {}, "credits": {"cast": []}, - "images": {"logos": []}, - } - - async def search_side_effect(query, language="de-DE", page=1): - if language == "en-US": - return { - "results": [{ - "id": 77777, - "name": "Rare Anime", - "first_air_date": "2025-01-01", - "overview": "English search overview text.", - }], - } - return { - "results": [{ - "id": 77777, - "name": "Rare Anime", - "first_air_date": "2025-01-01", - "overview": "", - }], - } - - async def details_side_effect(tv_id, **kwargs): - return empty_data - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock): - - mock_search.side_effect = search_side_effect - mock_details.side_effect = details_side_effect - mock_ratings.return_value = {"results": []} - - nfo_path = await nfo_service.create_tvshow_nfo( - "Rare Anime", "Rare Anime", - download_poster=False, download_logo=False, download_fanart=False, - ) - - content = nfo_path.read_text(encoding="utf-8") - assert "English search overview text." in content - assert mock_search.call_count == 2 - assert mock_search.call_args_list[1].kwargs['language'] == 'en-US' - - @pytest.mark.asyncio - async def test_no_japanese_fallback_when_english_succeeds( - self, nfo_service, tmp_path, - ): - """Stop after en-US if it provides the overview.""" - series_folder = tmp_path / "Test Anime" - series_folder.mkdir() - - de_data = { - "id": 88888, "name": "Test Anime", - "original_name": "テスト", "first_air_date": "2024-01-01", - "overview": "", - "vote_average": 7.0, "vote_count": 50, - "status": "Continuing", "episode_run_time": [24], - "genres": [], "networks": [], "production_countries": [], - "poster_path": None, "backdrop_path": None, - "external_ids": {}, "credits": {"cast": []}, - "images": {"logos": []}, - } - en_data = {"id": 88888, "overview": "English overview."} - - async def side_effect(tv_id, **kwargs): - lang = kwargs.get("language") - if lang == "en-US": - return en_data - return de_data - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock): - - mock_search.return_value = { - "results": [{"id": 88888, "name": "Test Anime", "first_air_date": "2024-01-01"}], - } - mock_details.side_effect = side_effect - mock_ratings.return_value = {"results": []} - - nfo_path = await nfo_service.create_tvshow_nfo( - "Test Anime", "Test Anime", - download_poster=False, download_logo=False, download_fanart=False, - ) - - content = nfo_path.read_text(encoding="utf-8") - assert "English overview." in content - # de-DE + en-US = 2 calls (no ja-JP needed) - assert mock_details.call_count == 2 - - -class TestSearchWithFallback: - """Tests for TMDB search fallback functionality.""" - - @pytest.mark.asyncio - async def test_search_with_fallback_primary_success(self, nfo_service, mock_tmdb_data): - """Test that primary query succeeds without fallback.""" - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search: - mock_search.return_value = {"results": [mock_tmdb_data]} - - result, source = await nfo_service._search_with_fallback( - "Attack on Titan", 2013, None - ) - - assert result["id"] == mock_tmdb_data["id"] - assert source == "primary" - assert mock_search.call_count == 1 - - @pytest.mark.asyncio - async def test_search_with_fallback_uses_alt_titles(self, nfo_service, mock_tmdb_data): - """Test that alternative titles are tried when primary fails.""" - mock_search = AsyncMock() - # First call returns empty, second (with Japanese title) returns result - mock_search.side_effect = [ - {"results": []}, - {"results": [mock_tmdb_data]} - ] - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search): - result, source = await nfo_service._search_with_fallback( - "Suzume", 2022, alt_titles=["すずめの戸締まり"] - ) - - assert result["id"] == mock_tmdb_data["id"] - assert "alt_title" in source - - @pytest.mark.asyncio - async def test_search_with_fallback_year_not_matched(self, nfo_service, mock_tmdb_data): - """Test fallback when year doesn't match but first result is used anyway.""" - # First result doesn't match year, but should still be returned - different_year_data = {**mock_tmdb_data, "first_air_date": "2020-01-01"} - mock_search = AsyncMock(return_value={"results": [different_year_data]}) - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search): - result, source = await nfo_service._search_with_fallback( - "Attack on Titan", 2013, None - ) - - assert result["id"] == mock_tmdb_data["id"] - - @pytest.mark.asyncio - async def test_search_with_fallback_no_year_strategy(self, nfo_service, mock_tmdb_data): - """Test that search without year is attempted when year-filtered fails.""" - mock_search = AsyncMock() - # First call with year fails, second (without year) succeeds - mock_search.side_effect = [ - {"results": []}, - {"results": [mock_tmdb_data]} - ] - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search): - result, source = await nfo_service._search_with_fallback( - "Attack on Titan", 2013, None - ) - - assert result["id"] == mock_tmdb_data["id"] - # Strategy order: primary -> english -> no_year (english comes before no_year) - assert mock_search.call_count == 2 - - @pytest.mark.asyncio - async def test_search_with_fallback_all_strategies_fail(self, nfo_service): - """Test that TMDBAPIError is raised when all strategies fail.""" - mock_search = AsyncMock(return_value={"results": []}) - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search): - with pytest.raises(TMDBAPIError) as exc_info: - await nfo_service._search_with_fallback( - "Nonexistent Anime", 2023, None - ) - - assert "Nonexistent Anime" in str(exc_info.value) - # Should have tried multiple strategies - assert mock_search.call_count >= 3 - - @pytest.mark.asyncio - async def test_search_with_fallback_normalizes_punctuation(self, nfo_service, mock_tmdb_data): - """Test that punctuation-normalized search is attempted.""" - mock_search = AsyncMock() - # First call fails, normalized version succeeds - mock_search.side_effect = [ - {"results": []}, - {"results": [mock_tmdb_data]} - ] - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search): - result, source = await nfo_service._search_with_fallback( - "Attack on Titan:", 2013, None - ) - - assert result["id"] == mock_tmdb_data["id"] - - def test_normalize_query_for_search(self, nfo_service): - """Test punctuation normalization in queries.""" - # Test normal punctuation removal - assert nfo_service._normalize_query_for_search("Attack on Titan:") == "Attack on Titan" - assert nfo_service._normalize_query_for_search("Suzume no Tojimari.") == "Suzume no Tojimari" - # Test CJK characters are preserved - assert "すずめ" in nfo_service._normalize_query_for_search("すずめの戸締まり") - # Test multiple spaces are collapsed - assert nfo_service._normalize_query_for_search("Attack on Titan") == "Attack on Titan" - - -class TestNegativeCache: - """Tests for negative result caching in TMDB client.""" - - @pytest.mark.asyncio - async def test_negative_result_cached(self, tmdb_client): - """Test that empty search results are cached.""" - import time - - mock_session = MagicMock() - mock_response = AsyncMock() - mock_response.status = 200 - mock_response.json = AsyncMock(return_value={"results": []}) - mock_response.__aenter__ = AsyncMock(return_value=mock_response) - mock_response.__aexit__ = AsyncMock(return_value=None) - mock_session.get = MagicMock(return_value=mock_response) - - tmdb_client.session = mock_session - - with patch.object(tmdb_client, '_ensure_session', new_callable=AsyncMock): - # First call - result = await tmdb_client.search_tv_show("Nonexistent") - assert result["results"] == [] - - # Negative cache should be set - assert len(tmdb_client._negative_cache) > 0 - - @pytest.mark.asyncio - async def test_negative_cache_prevents_duplicate_call(self, tmdb_client): - """Test that negative cache prevents second API call within 24 hours.""" - import time - - mock_session = MagicMock() - mock_response = AsyncMock() - mock_response.status = 200 - mock_response.json = AsyncMock(return_value={"results": []}) - mock_response.__aenter__ = AsyncMock(return_value=mock_response) - mock_response.__aexit__ = AsyncMock(return_value=None) - mock_session.get = MagicMock(return_value=mock_response) - - tmdb_client.session = mock_session - - with patch.object(tmdb_client, '_ensure_session', new_callable=AsyncMock): - # First call - should hit API - await tmdb_client.search_tv_show("Nonexistent") - first_call_count = mock_session.get.call_count - - # Second call with same query - should use negative cache, not hit API - await tmdb_client.search_tv_show("Nonexistent") - second_call_count = mock_session.get.call_count - - # Should not have made second API call - assert first_call_count == second_call_count - - def test_clear_negative_cache(self, tmdb_client): - """Test clearing negative cache.""" - # Add some negative cache entries - tmdb_client._negative_cache["test_key"] = time.monotonic() - assert len(tmdb_client._negative_cache) > 0 - - tmdb_client.clear_negative_cache() - assert len(tmdb_client._negative_cache) == 0 - - def test_cleanup_expired_negative_cache(self, tmdb_client): - """Test cleanup of expired negative cache entries.""" - # Add an expired entry - old_timestamp = time.monotonic() - (tmdb_client.NEGATIVE_CACHE_TTL + 1) - tmdb_client._negative_cache["expired_key"] = old_timestamp - tmdb_client._negative_cache["valid_key"] = time.monotonic() - - removed = tmdb_client.cleanup_expired_negative_cache() - - assert removed == 1 - assert "expired_key" not in tmdb_client._negative_cache - assert "valid_key" in tmdb_client._negative_cache - - -class TestNFOIDOverride: - """Tests for manual TMDB ID override via NFO.""" - - @pytest.mark.asyncio - async def test_create_tvshow_nfo_uses_existing_tmdb_id(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): - """Test that existing TMDB ID in NFO skips search.""" - # Create series folder with existing NFO containing TMDB ID - series_folder = tmp_path / "Attack on Titan" - series_folder.mkdir() - nfo_path = series_folder / "tvshow.nfo" - nfo_path.write_text(""" - - Attack on Titan - 1429 - -""", encoding="utf-8") - - with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock), \ - patch.object(nfo_service.tmdb_client, '_ensure_session', new_callable=AsyncMock): - - mock_details.return_value = mock_tmdb_data - mock_ratings.return_value = mock_content_ratings_de - - nfo_path_result = await nfo_service.create_tvshow_nfo( - "Attack on Titan", - "Attack on Titan", - download_poster=False, download_logo=False, download_fanart=False - ) - - # Verify NFO was created - assert nfo_path_result.exists() - - # Verify get_tv_show_details was called directly with the ID (no search) - mock_details.assert_called_once_with(1429, append_to_response="credits,external_ids,images") - - # Verify search was NOT called - # (we can check by verifying no search_tv_show mock was set up) - - @pytest.mark.asyncio - async def test_create_tvshow_nfo_searches_when_no_tmdb_id(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): - """Test that search is used when NFO has no TMDB ID.""" - # Create series folder without existing NFO - series_folder = tmp_path / "Test Anime" - series_folder.mkdir() - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \ - patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ - patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock), \ - patch.object(nfo_service.tmdb_client, '_ensure_session', new_callable=AsyncMock): - - mock_search.return_value = { - "results": [{ - "id": 1429, - "name": "Test Anime", - "first_air_date": "2024-01-01", - "overview": "Test overview" - }] - } - mock_details.return_value = mock_tmdb_data - mock_ratings.return_value = mock_content_ratings_de - - nfo_path = await nfo_service.create_tvshow_nfo( - "Test Anime", - "Test Anime", - download_poster=False, download_logo=False, download_fanart=False - ) - - # Verify search was called - mock_search.assert_called() - - -class TestSearchMultiStrategy: - """Tests for search/multi fallback strategy.""" - - @pytest.mark.asyncio - async def test_search_multi_strategy_used_as_fallback(self, nfo_service, mock_tmdb_data): - """Test that search/multi is tried after regular search fails.""" - mock_search = AsyncMock() - mock_multi = AsyncMock() - - # First: regular search fails - # Second: multi search returns TV result - mock_search.return_value = {"results": []} - mock_multi.return_value = { - "results": [ - {"media_type": "movie", "id": 123}, - {"media_type": "tv", "id": 456, "name": "Found Show", "first_air_date": "2024-01-01"} - ] - } - - with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search), \ - patch.object(nfo_service.tmdb_client, 'search_multi', mock_multi): - - result, source = await nfo_service._search_with_fallback( - "Unknown Show", 2024, None - ) - - assert result["id"] == 456 - assert source == "multi_search" - diff --git a/tests/unit/test_nfo_service_folder_creation.py b/tests/unit/test_nfo_service_folder_creation.py deleted file mode 100644 index 76af7b6..0000000 --- a/tests/unit/test_nfo_service_folder_creation.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Unit tests for NFO service folder creation. - -Tests that the NFO service correctly creates series folders when they don't exist. -""" -import tempfile -from pathlib import Path -from unittest.mock import AsyncMock, patch - -import pytest - -from src.core.services.nfo_service import NFOService - - -class TestNFOServiceFolderCreation: - """Test NFO service creates folders when needed.""" - - @pytest.fixture - def temp_anime_dir(self): - """Create temporary anime directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - yield Path(tmpdir) - - @pytest.fixture - def nfo_service(self, temp_anime_dir): - """Create NFO service with temporary directory.""" - return NFOService( - tmdb_api_key="test_api_key", - anime_directory=str(temp_anime_dir), - image_size="original", - auto_create=False - ) - - @pytest.mark.asyncio - async def test_create_nfo_creates_missing_folder( - self, nfo_service, temp_anime_dir - ): - """Test that create_tvshow_nfo creates folder if it doesn't exist.""" - serie_folder = "Test Series" - folder_path = temp_anime_dir / serie_folder - - # Verify folder doesn't exist initially - assert not folder_path.exists() - - # Mock TMDB client responses - mock_search_results = { - "results": [ - { - "id": 12345, - "name": "Test Series", - "first_air_date": "2023-01-01", - "overview": "Test overview", - "vote_average": 8.5 - } - ] - } - - mock_details = { - "id": 12345, - "name": "Test Series", - "first_air_date": "2023-01-01", - "overview": "Test overview", - "vote_average": 8.5, - "genres": [{"id": 16, "name": "Animation"}], - "networks": [{"name": "Test Network"}], - "status": "Returning Series", - "number_of_seasons": 1, - "number_of_episodes": 12, - "poster_path": "/test_poster.jpg", - "backdrop_path": "/test_backdrop.jpg" - } - - mock_content_ratings = { - "results": [ - {"iso_3166_1": "DE", "rating": "12"} - ] - } - - with patch.object( - nfo_service.tmdb_client, 'search_tv_show', - new_callable=AsyncMock - ) as mock_search, \ - patch.object( - nfo_service.tmdb_client, 'get_tv_show_details', - new_callable=AsyncMock - ) as mock_details_call, \ - patch.object( - nfo_service.tmdb_client, 'get_tv_show_content_ratings', - new_callable=AsyncMock - ) as mock_ratings, \ - patch.object( - nfo_service, '_download_media_files', - new_callable=AsyncMock - ) as mock_download: - - mock_search.return_value = mock_search_results - mock_details_call.return_value = mock_details - mock_ratings.return_value = mock_content_ratings - mock_download.return_value = { - "poster": False, - "logo": False, - "fanart": False - } - - # Call create_tvshow_nfo - nfo_path = await nfo_service.create_tvshow_nfo( - serie_name="Test Series", - serie_folder=serie_folder, - year=2023, - download_poster=False, - download_logo=False, - download_fanart=False - ) - - # Verify folder was created - assert folder_path.exists() - assert folder_path.is_dir() - - # Verify NFO file was created - assert nfo_path.exists() - assert nfo_path.name == "tvshow.nfo" - assert nfo_path.parent == folder_path diff --git a/tests/unit/test_nfo_update_parsing.py b/tests/unit/test_nfo_update_parsing.py deleted file mode 100644 index 48083dd..0000000 --- a/tests/unit/test_nfo_update_parsing.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Unit test for NFOService.update_tvshow_nfo() - tests XML parsing logic.""" - -import asyncio -import logging -import shutil -import tempfile -from pathlib import Path - -import pytest -from lxml import etree - -logger = logging.getLogger(__name__) - -from src.core.services.nfo_service import NFOService -from src.core.services.tmdb_client import TMDBAPIError - - -def create_sample_nfo(tmdb_id: int = 1429) -> str: - """Create a sample NFO XML with TMDB ID.""" - return f''' - - Attack on Titan - Shingeki no Kyojin - 2013 - Several hundred years ago, humans were nearly exterminated by Titans. - {tmdb_id} - 267440 - {tmdb_id} - 267440 -''' - - -def test_parse_nfo_with_uniqueid(): - """Test parsing NFO with uniqueid elements.""" - # Create temporary directory structure - temp_dir = Path(tempfile.mkdtemp()) - serie_folder = temp_dir / "test_series" - serie_folder.mkdir() - nfo_path = serie_folder / "tvshow.nfo" - - try: - # Write sample NFO - nfo_path.write_text(create_sample_nfo(1429), encoding="utf-8") - - # Parse it (same logic as in update_tvshow_nfo) - tree = etree.parse(str(nfo_path)) - root = tree.getroot() - - # Extract TMDB ID - tmdb_id = None - for uniqueid in root.findall(".//uniqueid"): - if uniqueid.get("type") == "tmdb": - tmdb_id = int(uniqueid.text) - break - - assert tmdb_id == 1429, f"Expected TMDB ID 1429, got {tmdb_id}" - logger.info("Successfully parsed TMDB ID from uniqueid: %s", tmdb_id) - - finally: - shutil.rmtree(temp_dir) - - -def test_parse_nfo_with_tmdbid_element(): - """Test parsing NFO with tmdbid element (fallback).""" - # Create NFO without uniqueid but with tmdbid element - nfo_content = ''' - - Test Show - 12345 -''' - - temp_dir = Path(tempfile.mkdtemp()) - serie_folder = temp_dir / "test_series" - serie_folder.mkdir() - nfo_path = serie_folder / "tvshow.nfo" - - try: - nfo_path.write_text(nfo_content, encoding="utf-8") - - # Parse it - tree = etree.parse(str(nfo_path)) - root = tree.getroot() - - # Try uniqueid first (should fail) - tmdb_id = None - for uniqueid in root.findall(".//uniqueid"): - if uniqueid.get("type") == "tmdb": - tmdb_id = int(uniqueid.text) - break - - # Fallback to tmdbid element - if tmdb_id is None: - tmdbid_elem = root.find(".//tmdbid") - if tmdbid_elem is not None and tmdbid_elem.text: - tmdb_id = int(tmdbid_elem.text) - - assert tmdb_id == 12345, f"Expected TMDB ID 12345, got {tmdb_id}" - logger.info("Successfully parsed TMDB ID from tmdbid element: %s", tmdb_id) - - finally: - shutil.rmtree(temp_dir) - - -def test_parse_nfo_without_tmdb_id(): - """Test parsing NFO without TMDB ID raises appropriate error.""" - # Create NFO without any TMDB ID - nfo_content = ''' - - Test Show -''' - - temp_dir = Path(tempfile.mkdtemp()) - serie_folder = temp_dir / "test_series" - serie_folder.mkdir() - nfo_path = serie_folder / "tvshow.nfo" - - try: - nfo_path.write_text(nfo_content, encoding="utf-8") - - # Parse it - tree = etree.parse(str(nfo_path)) - root = tree.getroot() - - # Try to extract TMDB ID - tmdb_id = None - for uniqueid in root.findall(".//uniqueid"): - if uniqueid.get("type") == "tmdb": - tmdb_id = int(uniqueid.text) - break - - if tmdb_id is None: - tmdbid_elem = root.find(".//tmdbid") - if tmdbid_elem is not None and tmdbid_elem.text: - tmdb_id = int(tmdbid_elem.text) - - assert tmdb_id is None, "Should not have found TMDB ID" - logger.info("Correctly identified NFO without TMDB ID") - - finally: - shutil.rmtree(temp_dir) - - -def test_parse_invalid_xml(): - """Test parsing invalid XML raises appropriate error.""" - nfo_content = ''' - - Unclosed tag -</tvshow>''' - - temp_dir = Path(tempfile.mkdtemp()) - serie_folder = temp_dir / "test_series" - serie_folder.mkdir() - nfo_path = serie_folder / "tvshow.nfo" - - try: - nfo_path.write_text(nfo_content, encoding="utf-8") - - # Try to parse - should raise XMLSyntaxError - try: - tree = etree.parse(str(nfo_path)) - assert False, "Should have raised XMLSyntaxError" - except etree.XMLSyntaxError: - logger.info("Correctly raised XMLSyntaxError for invalid XML") - - finally: - shutil.rmtree(temp_dir) - - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO, format="%(message)s") - logger.info("Testing NFO XML parsing logic...") - logger.info("") - - test_parse_nfo_with_uniqueid() - test_parse_nfo_with_tmdbid_element() - test_parse_nfo_without_tmdb_id() - test_parse_invalid_xml() - - logger.info("") - logger.info("%s", "=" * 60) - logger.info("ALL TESTS PASSED") - logger.info("%s", "=" * 60) diff --git a/tests/unit/test_series_app.py b/tests/unit/test_series_app.py index 6ca463e..256e6c9 100644 --- a/tests/unit/test_series_app.py +++ b/tests/unit/test_series_app.py @@ -52,12 +52,10 @@ class TestSeriesAppInitialization: @patch('src.core.SeriesApp.Loaders') @patch('src.core.SeriesApp.SerieScanner') @patch('src.core.SeriesApp.SerieList') - @patch('src.core.services.nfo_factory.get_nfo_factory') @patch('src.core.SeriesApp.settings') def test_init_uses_config_fallback_for_nfo_service( self, mock_settings, - mock_get_factory, mock_serie_list, mock_scanner, mock_loaders, @@ -66,16 +64,8 @@ class TestSeriesAppInitialization: test_dir = "/test/anime" mock_settings.tmdb_api_key = None - mock_factory = Mock() - mock_service = Mock() - mock_factory.create.return_value = mock_service - mock_get_factory.return_value = mock_factory - app = SeriesApp(test_dir) - assert app.nfo_service is mock_service - mock_get_factory.assert_called_once() - class TestSeriesAppSearch: """Test search functionality.""" diff --git a/tests/unit/test_series_manager_service.py b/tests/unit/test_series_manager_service.py deleted file mode 100644 index 6cb839f..0000000 --- a/tests/unit/test_series_manager_service.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Unit tests for series manager service. - -Tests series orchestration, NFO processing, configuration handling, -and async batch processing. -""" - -import asyncio -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from src.core.services.series_manager_service import SeriesManagerService - - -class TestSeriesManagerServiceInit: - """Tests for SeriesManagerService initialization.""" - - @patch("src.core.services.series_manager_service.SerieList") - def test_init_without_nfo_service(self, mock_serie_list): - """Service initializes without NFO when no API key provided.""" - svc = SeriesManagerService( - anime_directory="/anime", - tmdb_api_key=None, - auto_create_nfo=False, - ) - assert svc.nfo_service is None - assert svc.anime_directory == "/anime" - - @patch("src.core.services.series_manager_service.SerieList") - def test_init_with_nfo_disabled(self, mock_serie_list): - """NFO service not created when auto_create and update both False.""" - svc = SeriesManagerService( - anime_directory="/anime", - tmdb_api_key="key123", - auto_create_nfo=False, - update_on_scan=False, - ) - assert svc.nfo_service is None - - @patch("src.core.services.nfo_factory.get_nfo_factory") - @patch("src.core.services.series_manager_service.SerieList") - def test_init_creates_nfo_service_when_enabled( - self, mock_serie_list, mock_factory_fn - ): - """NFO service is created when auto_create is True and key exists.""" - mock_factory = MagicMock() - mock_nfo = MagicMock() - mock_factory.create.return_value = mock_nfo - mock_factory_fn.return_value = mock_factory - - svc = SeriesManagerService( - anime_directory="/anime", - tmdb_api_key="key123", - auto_create_nfo=True, - ) - assert svc.nfo_service is mock_nfo - - @patch("src.core.services.nfo_factory.get_nfo_factory") - @patch("src.core.services.series_manager_service.SerieList") - def test_init_handles_nfo_factory_error( - self, mock_serie_list, mock_factory_fn - ): - """NFO service set to None if factory raises.""" - mock_factory_fn.side_effect = ValueError("bad config") - svc = SeriesManagerService( - anime_directory="/anime", - tmdb_api_key="key123", - auto_create_nfo=True, - ) - assert svc.nfo_service is None - - @patch("src.core.services.series_manager_service.SerieList") - def test_init_stores_config_flags(self, mock_serie_list): - """Configuration flags are stored correctly.""" - svc = SeriesManagerService( - anime_directory="/anime", - auto_create_nfo=True, - update_on_scan=True, - download_poster=False, - download_logo=False, - download_fanart=True, - ) - assert svc.auto_create_nfo is True - assert svc.update_on_scan is True - assert svc.download_poster is False - assert svc.download_logo is False - assert svc.download_fanart is True - - @patch("src.core.services.series_manager_service.SerieList") - def test_serie_list_created_with_skip_load(self, mock_serie_list): - """SerieList is created with skip_load=True.""" - SeriesManagerService(anime_directory="/anime") - mock_serie_list.assert_called_once_with("/anime", skip_load=True) - - -class TestFromSettings: - """Tests for from_settings classmethod.""" - - @patch("src.core.services.series_manager_service.settings") - @patch("src.core.services.series_manager_service.SerieList") - def test_from_settings_uses_all_settings(self, mock_serie_list, mock_settings): - """from_settings passes all relevant settings to constructor.""" - mock_settings.anime_directory = "/anime" - mock_settings.tmdb_api_key = None - mock_settings.nfo_auto_create = False - mock_settings.nfo_update_on_scan = False - mock_settings.nfo_download_poster = True - mock_settings.nfo_download_logo = True - mock_settings.nfo_download_fanart = True - mock_settings.nfo_image_size = "original" - - svc = SeriesManagerService.from_settings() - assert isinstance(svc, SeriesManagerService) - assert svc.anime_directory == "/anime" - - -class TestProcessNfoForSeries: - """Tests for process_nfo_for_series method.""" - - @pytest.fixture - def service(self): - """Create a service with mocked dependencies.""" - with patch("src.core.services.series_manager_service.SerieList"): - svc = SeriesManagerService(anime_directory="/anime") - svc.nfo_service = AsyncMock() - svc.auto_create_nfo = True - return svc - - @pytest.mark.asyncio - async def test_returns_early_without_nfo_service(self): - """Does nothing when nfo_service is None.""" - with patch("src.core.services.series_manager_service.SerieList"): - svc = SeriesManagerService(anime_directory="/anime") - svc.nfo_service = None - # Should not raise - await svc.process_nfo_for_series("folder", "Name", "key") - - @pytest.mark.asyncio - async def test_creates_nfo_when_not_exists(self, service): - """Creates NFO file when it doesn't exist and auto_create is True.""" - service.nfo_service.check_nfo_exists = AsyncMock(return_value=False) - service.nfo_service.create_tvshow_nfo = AsyncMock() - - await service.process_nfo_for_series("folder", "Name", "key", year=2024) - service.nfo_service.create_tvshow_nfo.assert_awaited_once() - - @pytest.mark.asyncio - async def test_skips_creation_when_exists(self, service): - """Skips NFO creation when file already exists.""" - service.nfo_service.check_nfo_exists = AsyncMock(return_value=True) - service.nfo_service.parse_nfo_ids = MagicMock( - return_value={"tmdb_id": None, "tvdb_id": None} - ) - service.nfo_service.create_tvshow_nfo = AsyncMock() - - await service.process_nfo_for_series("folder", "Name", "key") - service.nfo_service.create_tvshow_nfo.assert_not_awaited() - - @pytest.mark.asyncio - async def test_handles_tmdb_api_error(self, service): - """TMDBAPIError is caught and logged (not re-raised).""" - from src.core.services.tmdb_client import TMDBAPIError - service.nfo_service.check_nfo_exists = AsyncMock( - side_effect=TMDBAPIError("rate limited") - ) - # Should not raise - await service.process_nfo_for_series("folder", "Name", "key") - - @pytest.mark.asyncio - async def test_handles_unexpected_error(self, service): - """Unexpected exceptions are caught and logged.""" - service.nfo_service.check_nfo_exists = AsyncMock( - side_effect=RuntimeError("unexpected") - ) - await service.process_nfo_for_series("folder", "Name", "key") - - -class TestScanAndProcessNfo: - """Tests for scan_and_process_nfo method.""" - - @pytest.mark.asyncio - async def test_skips_when_no_nfo_service(self): - """Returns early when nfo_service is None.""" - with patch("src.core.services.series_manager_service.SerieList"): - svc = SeriesManagerService(anime_directory="/anime") - svc.nfo_service = None - await svc.scan_and_process_nfo() - - -class TestClose: - """Tests for close method.""" - - @pytest.mark.asyncio - async def test_close_with_nfo_service(self): - """Closes NFO service when present.""" - with patch("src.core.services.series_manager_service.SerieList"): - svc = SeriesManagerService(anime_directory="/anime") - svc.nfo_service = AsyncMock() - await svc.close() - svc.nfo_service.close.assert_awaited_once() - - @pytest.mark.asyncio - async def test_close_without_nfo_service(self): - """Close works fine when no NFO service.""" - with patch("src.core.services.series_manager_service.SerieList"): - svc = SeriesManagerService(anime_directory="/anime") - svc.nfo_service = None - await svc.close() # Should not raise