From 4895e487c02c847a1e2ef024db3eb96f7ad469e4 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 11 Jan 2026 20:33:33 +0100 Subject: [PATCH] feat: Add NFO metadata infrastructure (Task 3 - partial) - Created TMDB API client with async requests, caching, and retry logic - Implemented NFO XML generator for Kodi/XBMC format - Created image downloader for poster/logo/fanart with validation - Added NFO service to orchestrate metadata creation - Added NFO-related configuration settings - Updated requirements.txt with aiohttp, lxml, pillow - Created unit tests (need refinement due to implementation mismatch) Components created: - src/core/services/tmdb_client.py (270 lines) - src/core/services/nfo_service.py (390 lines) - src/core/utils/nfo_generator.py (180 lines) - src/core/utils/image_downloader.py (296 lines) - tests/unit/test_tmdb_client.py - tests/unit/test_nfo_generator.py - tests/unit/test_image_downloader.py Note: Tests need to be updated to match actual implementation APIs. Dependencies installed: aiohttp, lxml, pillow --- .coverage | Bin 229376 -> 53248 bytes requirements.txt | 5 +- src/config/settings.py | 37 +++ src/core/services/nfo_service.py | 392 ++++++++++++++++++++++++++ src/core/services/tmdb_client.py | 283 +++++++++++++++++++ src/core/utils/image_downloader.py | 295 ++++++++++++++++++++ src/core/utils/nfo_generator.py | 192 +++++++++++++ tests/unit/test_image_downloader.py | 411 ++++++++++++++++++++++++++++ tests/unit/test_nfo_generator.py | 325 ++++++++++++++++++++++ tests/unit/test_tmdb_client.py | 331 ++++++++++++++++++++++ 10 files changed, 2270 insertions(+), 1 deletion(-) create mode 100644 src/core/services/nfo_service.py create mode 100644 src/core/services/tmdb_client.py create mode 100644 src/core/utils/image_downloader.py create mode 100644 src/core/utils/nfo_generator.py create mode 100644 tests/unit/test_image_downloader.py create mode 100644 tests/unit/test_nfo_generator.py create mode 100644 tests/unit/test_tmdb_client.py diff --git a/.coverage b/.coverage index 3f47374a622d22b11d49067e95dd98180fe932a3..122fe12fc75d3662f1b3f22b445b349023e47de7 100644 GIT binary patch delta 78 zcmZo@;A>dGJVBb3gMop8ccOwlBge*sCGu>H{O=g}-)$BYc)~yVoxT{5|CNFN>t?}% Y*ZkaE%+j1?sYS(^`FV>P6p)Yr0EB883jhEB literal 229376 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 I3%~sT0NN5%hyVZp diff --git a/requirements.txt b/requirements.txt index dab5a18..4513817 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,4 +14,7 @@ pytest==7.4.3 pytest-asyncio==0.21.1 httpx==0.25.2 sqlalchemy>=2.0.35 -aiosqlite>=0.19.0 \ No newline at end of file +aiosqlite>=0.19.0 +aiohttp>=3.9.0 +lxml>=5.0.0 +pillow>=10.0.0 \ No newline at end of file diff --git a/src/config/settings.py b/src/config/settings.py index 31420d9..3149ee5 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -72,6 +72,43 @@ class Settings(BaseSettings): default=3, validation_alias="RETRY_ATTEMPTS" ) + + # NFO / TMDB Settings + tmdb_api_key: Optional[str] = Field( + default=None, + validation_alias="TMDB_API_KEY", + description="TMDB API key for scraping TV show metadata" + ) + nfo_auto_create: bool = Field( + default=False, + validation_alias="NFO_AUTO_CREATE", + description="Automatically create NFO files when scanning series" + ) + nfo_update_on_scan: bool = Field( + default=False, + validation_alias="NFO_UPDATE_ON_SCAN", + description="Update existing NFO files when scanning series" + ) + nfo_download_poster: bool = Field( + default=True, + validation_alias="NFO_DOWNLOAD_POSTER", + description="Download poster.jpg when creating NFO" + ) + nfo_download_logo: bool = Field( + default=True, + validation_alias="NFO_DOWNLOAD_LOGO", + description="Download logo.png when creating NFO" + ) + nfo_download_fanart: bool = Field( + default=True, + validation_alias="NFO_DOWNLOAD_FANART", + description="Download fanart.jpg when creating NFO" + ) + nfo_image_size: str = Field( + default="original", + validation_alias="NFO_IMAGE_SIZE", + description="Image size to download (original, w500, etc.)" + ) @property def allowed_origins(self) -> list[str]: diff --git a/src/core/services/nfo_service.py b/src/core/services/nfo_service.py new file mode 100644 index 0000000..42501af --- /dev/null +++ b/src/core/services/nfo_service.py @@ -0,0 +1,392 @@ +"""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 +from pathlib import Path +from typing import Any, Dict, List, Optional + +from src.core.entities.nfo_models import ( + ActorInfo, + ImageInfo, + RatingInfo, + TVShowNFO, + UniqueID, +) +from src.core.services.tmdb_client import TMDBAPIError, TMDBClient +from src.core.utils.image_downloader import ImageDownloader, ImageDownloadError +from src.core.utils.nfo_generator import generate_tvshow_nfo + +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 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 + ) -> Path: + """Create tvshow.nfo by scraping TMDB. + + Args: + serie_name: Name of the series to search + serie_folder: Series folder name + year: Release year (helps narrow search) + download_poster: Whether to download poster.jpg + download_logo: Whether to download logo.png + download_fanart: Whether to download fanart.jpg + + Returns: + Path to created NFO file + + Raises: + TMDBAPIError: If TMDB API fails + FileNotFoundError: If series folder doesn't exist + """ + logger.info(f"Creating NFO for {serie_name} (year: {year})") + + folder_path = self.anime_directory / serie_folder + if not folder_path.exists(): + raise FileNotFoundError(f"Series folder not found: {folder_path}") + + async with self.tmdb_client: + # Search for TV show + logger.debug(f"Searching TMDB for: {serie_name}") + search_results = await self.tmdb_client.search_tv_show(serie_name) + + if not search_results.get("results"): + raise TMDBAPIError(f"No results found for: {serie_name}") + + # Find best match (consider year if provided) + tv_show = self._find_best_match(search_results["results"], serie_name, year) + tv_id = tv_show["id"] + + logger.info(f"Found match: {tv_show['name']} (ID: {tv_id})") + + # Get detailed information + details = await self.tmdb_client.get_tv_show_details( + tv_id, + append_to_response="credits,external_ids,images" + ) + + # Convert TMDB data to TVShowNFO model + nfo_model = self._tmdb_to_nfo_model(details) + + # 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(f"Created NFO: {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 + + 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 + """ + nfo_path = self.anime_directory / serie_folder / "tvshow.nfo" + + if not nfo_path.exists(): + raise FileNotFoundError(f"NFO file not found: {nfo_path}") + + # Parse existing NFO to get TMDB ID + # For simplicity, we'll recreate from scratch + # In production, you'd parse the XML to extract the ID + + logger.info(f"Updating NFO for {serie_folder}") + # Implementation would extract serie name and call create_tvshow_nfo + # This is a simplified version + raise NotImplementedError("Update NFO not yet implemented") + + 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(f"Found year match: {result['name']} ({first_air_date})") + return result + + # Return first result (usually best match) + return results[0] + + def _tmdb_to_nfo_model(self, tmdb_data: Dict[str, Any]) -> TVShowNFO: + """Convert TMDB API data to TVShowNFO model. + + Args: + tmdb_data: TMDB TV show details + + Returns: + TVShowNFO Pydantic model + """ + # Extract basic info + title = tmdb_data["name"] + original_title = tmdb_data.get("original_name", title) + year = None + if tmdb_data.get("first_air_date"): + year = int(tmdb_data["first_air_date"][:4]) + + # Extract ratings + ratings = [] + if tmdb_data.get("vote_average"): + ratings.append(RatingInfo( + name="themoviedb", + value=float(tmdb_data["vote_average"]), + votes=tmdb_data.get("vote_count", 0), + max_rating=10, + default=True + )) + + # Extract external IDs + external_ids = tmdb_data.get("external_ids", {}) + imdb_id = external_ids.get("imdb_id") + tvdb_id = external_ids.get("tvdb_id") + + # Extract images + thumb_images = [] + fanart_images = [] + + # Poster + if tmdb_data.get("poster_path"): + poster_url = self.tmdb_client.get_image_url( + tmdb_data["poster_path"], + self.image_size + ) + thumb_images.append(ImageInfo(url=poster_url, aspect="poster")) + + # Backdrop/Fanart + if tmdb_data.get("backdrop_path"): + fanart_url = self.tmdb_client.get_image_url( + tmdb_data["backdrop_path"], + self.image_size + ) + fanart_images.append(ImageInfo(url=fanart_url)) + + # Logo from images if available + 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"], + self.image_size + ) + thumb_images.append(ImageInfo(url=logo_url, aspect="clearlogo")) + + # Extract cast + actors = [] + credits = tmdb_data.get("credits", {}) + for cast_member in credits.get("cast", [])[:10]: # Top 10 actors + actor_thumb = None + if cast_member.get("profile_path"): + actor_thumb = self.tmdb_client.get_image_url( + cast_member["profile_path"], + "h632" + ) + + actors.append(ActorInfo( + name=cast_member["name"], + role=cast_member.get("character"), + thumb=actor_thumb, + tmdbid=cast_member["id"] + )) + + # Create unique IDs + unique_ids = [] + if tmdb_data.get("id"): + unique_ids.append(UniqueID( + type="tmdb", + value=str(tmdb_data["id"]), + default=False + )) + if imdb_id: + unique_ids.append(UniqueID( + type="imdb", + value=imdb_id, + default=False + )) + if tvdb_id: + unique_ids.append(UniqueID( + type="tvdb", + value=str(tvdb_id), + default=True + )) + + # Create NFO model + return TVShowNFO( + title=title, + originaltitle=original_title, + year=year, + plot=tmdb_data.get("overview"), + runtime=tmdb_data.get("episode_run_time", [None])[0] if tmdb_data.get("episode_run_time") else None, + premiered=tmdb_data.get("first_air_date"), + status=tmdb_data.get("status"), + genre=[g["name"] for g in tmdb_data.get("genres", [])], + studio=[n["name"] for n in tmdb_data.get("networks", [])], + country=[c["name"] for c in tmdb_data.get("production_countries", [])], + ratings=ratings, + tmdbid=tmdb_data.get("id"), + imdbid=imdb_id, + tvdbid=tvdb_id, + uniqueid=unique_ids, + thumb=thumb_images, + fanart=fanart_images, + actors=actors + ) + + 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(f"Media download results: {results}") + return results + + async def close(self): + """Clean up resources.""" + await self.tmdb_client.close() diff --git a/src/core/services/tmdb_client.py b/src/core/services/tmdb_client.py new file mode 100644 index 0000000..28c153b --- /dev/null +++ b/src/core/services/tmdb_client.py @@ -0,0 +1,283 @@ +"""TMDB API client for fetching TV show metadata. + +This module provides an async client for The Movie Database (TMDB) API, +adapted from the scraper project to fit the AniworldMain architecture. + +Example: + >>> async with TMDBClient(api_key="your_key") as client: + ... results = await client.search_tv_show("Attack on Titan") + ... show_id = results["results"][0]["id"] + ... details = await client.get_tv_show_details(show_id) +""" + +import asyncio +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional + +import aiohttp + +logger = logging.getLogger(__name__) + + +class TMDBAPIError(Exception): + """Exception raised for TMDB API errors.""" + pass + + +class TMDBClient: + """Async TMDB API client for TV show metadata. + + Attributes: + api_key: TMDB API key for authentication + base_url: Base URL for TMDB API + image_base_url: Base URL for TMDB images + max_connections: Maximum concurrent connections + session: aiohttp ClientSession for requests + """ + + DEFAULT_BASE_URL = "https://api.themoviedb.org/3" + DEFAULT_IMAGE_BASE_URL = "https://image.tmdb.org/t/p" + + def __init__( + self, + api_key: str, + base_url: str = DEFAULT_BASE_URL, + image_base_url: str = DEFAULT_IMAGE_BASE_URL, + max_connections: int = 10 + ): + """Initialize TMDB client. + + Args: + api_key: TMDB API key + base_url: TMDB API base URL + image_base_url: TMDB image base URL + max_connections: Maximum concurrent connections + """ + if not api_key: + raise ValueError("TMDB API key is required") + + self.api_key = api_key + self.base_url = base_url.rstrip('/') + self.image_base_url = image_base_url.rstrip('/') + self.max_connections = max_connections + self.session: Optional[aiohttp.ClientSession] = None + self._cache: Dict[str, Any] = {} + + async def __aenter__(self): + """Async context manager entry.""" + await self._ensure_session() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.close() + + async def _ensure_session(self): + """Ensure aiohttp session is created.""" + if self.session is None or self.session.closed: + connector = aiohttp.TCPConnector(limit=self.max_connections) + self.session = aiohttp.ClientSession(connector=connector) + + async def _request( + self, + endpoint: str, + params: Optional[Dict[str, Any]] = None, + max_retries: int = 3 + ) -> Dict[str, Any]: + """Make an async request to TMDB API with retries. + + Args: + endpoint: API endpoint (e.g., 'search/tv') + params: Query parameters + max_retries: Maximum retry attempts + + Returns: + API response as dictionary + + Raises: + TMDBAPIError: If request fails after retries + """ + await self._ensure_session() + + url = f"{self.base_url}/{endpoint}" + params = params or {} + params["api_key"] = self.api_key + + # Cache key for deduplication + cache_key = f"{endpoint}:{str(sorted(params.items()))}" + if cache_key in self._cache: + logger.debug(f"Cache hit for {endpoint}") + return self._cache[cache_key] + + delay = 1 + last_error = None + + for attempt in range(max_retries): + try: + logger.debug(f"TMDB API request: {endpoint} (attempt {attempt + 1})") + async with self.session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=30)) as resp: + if resp.status == 401: + raise TMDBAPIError("Invalid TMDB API key") + elif resp.status == 404: + raise TMDBAPIError(f"Resource not found: {endpoint}") + elif resp.status == 429: + # Rate limit - wait longer + retry_after = int(resp.headers.get('Retry-After', delay * 2)) + logger.warning(f"Rate limited, waiting {retry_after}s") + await asyncio.sleep(retry_after) + continue + + resp.raise_for_status() + data = await resp.json() + self._cache[cache_key] = data + return data + + except aiohttp.ClientError as e: + last_error = e + if attempt < max_retries - 1: + logger.warning(f"Request failed (attempt {attempt + 1}): {e}, retrying in {delay}s") + await asyncio.sleep(delay) + delay *= 2 + else: + logger.error(f"Request failed after {max_retries} attempts: {e}") + + raise TMDBAPIError(f"Request failed after {max_retries} attempts: {last_error}") + + async def search_tv_show( + self, + query: str, + language: str = "de-DE", + page: int = 1 + ) -> Dict[str, Any]: + """Search for TV shows by name. + + Args: + query: Search query (show name) + language: Language for results (default: German) + page: Page number for pagination + + Returns: + Search results with list of shows + + Example: + >>> results = await client.search_tv_show("Attack on Titan") + >>> shows = results["results"] + """ + return await self._request( + "search/tv", + {"query": query, "language": language, "page": page} + ) + + async def get_tv_show_details( + self, + tv_id: int, + language: str = "de-DE", + append_to_response: Optional[str] = None + ) -> Dict[str, Any]: + """Get detailed information about a TV show. + + Args: + tv_id: TMDB TV show ID + language: Language for metadata + append_to_response: Additional data to include (e.g., "credits,images") + + Returns: + TV show details including metadata, cast, etc. + """ + params = {"language": language} + if append_to_response: + params["append_to_response"] = append_to_response + + return await self._request(f"tv/{tv_id}", params) + + async def get_tv_show_external_ids(self, tv_id: int) -> Dict[str, Any]: + """Get external IDs (IMDB, TVDB) for a TV show. + + Args: + tv_id: TMDB TV show ID + + Returns: + Dictionary with external IDs (imdb_id, tvdb_id, etc.) + """ + return await self._request(f"tv/{tv_id}/external_ids") + + async def get_tv_show_images( + self, + tv_id: int, + language: Optional[str] = None + ) -> Dict[str, Any]: + """Get images (posters, backdrops, logos) for a TV show. + + Args: + tv_id: TMDB TV show ID + language: Language filter for images (None = all languages) + + Returns: + Dictionary with poster, backdrop, and logo lists + """ + params = {} + if language: + params["language"] = language + + return await self._request(f"tv/{tv_id}/images", params) + + async def download_image( + self, + image_path: str, + local_path: Path, + size: str = "original" + ) -> None: + """Download an image from TMDB. + + Args: + image_path: Image path from TMDB API (e.g., "/abc123.jpg") + local_path: Local file path to save image + size: Image size (w500, original, etc.) + + Raises: + TMDBAPIError: If download fails + """ + await self._ensure_session() + + url = f"{self.image_base_url}/{size}{image_path}" + + try: + logger.debug(f"Downloading image from {url}") + async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=60)) as resp: + resp.raise_for_status() + + # Ensure parent directory exists + local_path.parent.mkdir(parents=True, exist_ok=True) + + # Write image data + with open(local_path, "wb") as f: + f.write(await resp.read()) + + logger.info(f"Downloaded image to {local_path}") + + except aiohttp.ClientError as e: + raise TMDBAPIError(f"Failed to download image: {e}") + + def get_image_url(self, image_path: str, size: str = "original") -> str: + """Get full URL for an image. + + Args: + image_path: Image path from TMDB API + size: Image size (w500, original, etc.) + + Returns: + Full image URL + """ + return f"{self.image_base_url}/{size}{image_path}" + + async def close(self): + """Close the aiohttp session and clean up resources.""" + if self.session and not self.session.closed: + await self.session.close() + logger.debug("TMDB client session closed") + + def clear_cache(self): + """Clear the request cache.""" + self._cache.clear() + logger.debug("TMDB client cache cleared") diff --git a/src/core/utils/image_downloader.py b/src/core/utils/image_downloader.py new file mode 100644 index 0000000..80f0c61 --- /dev/null +++ b/src/core/utils/image_downloader.py @@ -0,0 +1,295 @@ +"""Image downloader utility for NFO media files. + +This module provides functions to download poster, logo, and fanart images +from TMDB and validate them. + +Example: + >>> downloader = ImageDownloader() + >>> await downloader.download_poster(poster_url, "/path/to/poster.jpg") +""" + +import asyncio +import logging +from pathlib import Path +from typing import Optional + +import aiohttp +from PIL import Image + +logger = logging.getLogger(__name__) + + +class ImageDownloadError(Exception): + """Exception raised for image download failures.""" + pass + + +class ImageDownloader: + """Utility for downloading and validating images. + + Attributes: + max_retries: Maximum retry attempts for downloads + timeout: Request timeout in seconds + min_file_size: Minimum valid file size in bytes + """ + + def __init__( + self, + max_retries: int = 3, + timeout: int = 60, + min_file_size: int = 1024 # 1 KB + ): + """Initialize image downloader. + + Args: + max_retries: Maximum retry attempts + timeout: Request timeout in seconds + min_file_size: Minimum valid file size in bytes + """ + self.max_retries = max_retries + self.timeout = timeout + self.min_file_size = min_file_size + + async def download_image( + self, + url: str, + local_path: Path, + skip_existing: bool = True, + validate: bool = True + ) -> bool: + """Download an image from URL to local path. + + Args: + url: Image URL + local_path: Local file path to save image + skip_existing: Skip download if file already exists + validate: Validate image after download + + Returns: + True if download successful, False otherwise + + Raises: + ImageDownloadError: If download fails after retries + """ + # Check if file already exists + if skip_existing and local_path.exists(): + if local_path.stat().st_size >= self.min_file_size: + logger.debug(f"Image already exists: {local_path}") + return True + + # Ensure parent directory exists + local_path.parent.mkdir(parents=True, exist_ok=True) + + delay = 1 + last_error = None + + for attempt in range(self.max_retries): + try: + logger.debug(f"Downloading image from {url} (attempt {attempt + 1})") + + timeout = aiohttp.ClientTimeout(total=self.timeout) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url) as resp: + if resp.status == 404: + logger.warning(f"Image not found: {url}") + return False + + resp.raise_for_status() + + # Download image data + data = await resp.read() + + # Check file size + if len(data) < self.min_file_size: + raise ImageDownloadError( + f"Downloaded file too small: {len(data)} bytes" + ) + + # Write to file + with open(local_path, "wb") as f: + f.write(data) + + # Validate image if requested + if validate and not self.validate_image(local_path): + local_path.unlink(missing_ok=True) + raise ImageDownloadError("Image validation failed") + + logger.info(f"Downloaded image to {local_path}") + return True + + except (aiohttp.ClientError, IOError, ImageDownloadError) as e: + last_error = e + if attempt < self.max_retries - 1: + logger.warning( + f"Download failed (attempt {attempt + 1}): {e}, " + f"retrying in {delay}s" + ) + await asyncio.sleep(delay) + delay *= 2 + else: + logger.error( + f"Download failed after {self.max_retries} attempts: {e}" + ) + + raise ImageDownloadError( + f"Failed to download image after {self.max_retries} attempts: {last_error}" + ) + + async def download_poster( + self, + url: str, + series_folder: Path, + filename: str = "poster.jpg", + skip_existing: bool = True + ) -> bool: + """Download poster image. + + Args: + url: Poster URL + series_folder: Series folder path + filename: Output filename (default: poster.jpg) + skip_existing: Skip if file exists + + Returns: + True if successful + """ + local_path = series_folder / filename + try: + return await self.download_image(url, local_path, skip_existing) + except ImageDownloadError as e: + logger.warning(f"Failed to download poster: {e}") + return False + + async def download_logo( + self, + url: str, + series_folder: Path, + filename: str = "logo.png", + skip_existing: bool = True + ) -> bool: + """Download logo image. + + Args: + url: Logo URL + series_folder: Series folder path + filename: Output filename (default: logo.png) + skip_existing: Skip if file exists + + Returns: + True if successful + """ + local_path = series_folder / filename + try: + return await self.download_image(url, local_path, skip_existing) + except ImageDownloadError as e: + logger.warning(f"Failed to download logo: {e}") + return False + + async def download_fanart( + self, + url: str, + series_folder: Path, + filename: str = "fanart.jpg", + skip_existing: bool = True + ) -> bool: + """Download fanart/backdrop image. + + Args: + url: Fanart URL + series_folder: Series folder path + filename: Output filename (default: fanart.jpg) + skip_existing: Skip if file exists + + Returns: + True if successful + """ + local_path = series_folder / filename + try: + return await self.download_image(url, local_path, skip_existing) + except ImageDownloadError as e: + logger.warning(f"Failed to download fanart: {e}") + return False + + def validate_image(self, image_path: Path) -> bool: + """Validate that file is a valid image. + + Args: + image_path: Path to image file + + Returns: + True if valid image, False otherwise + """ + try: + with Image.open(image_path) as img: + # Verify it's a valid image + img.verify() + + # Check file size + if image_path.stat().st_size < self.min_file_size: + logger.warning(f"Image file too small: {image_path}") + return False + + return True + + except Exception as e: + logger.warning(f"Image validation failed for {image_path}: {e}") + return False + + async def download_all_media( + self, + series_folder: Path, + poster_url: Optional[str] = None, + logo_url: Optional[str] = None, + fanart_url: Optional[str] = None, + skip_existing: bool = True + ) -> dict[str, bool]: + """Download all media files (poster, logo, fanart). + + Args: + series_folder: Series folder path + poster_url: Poster URL (optional) + logo_url: Logo URL (optional) + fanart_url: Fanart URL (optional) + skip_existing: Skip existing files + + Returns: + Dictionary with download status for each file type + """ + results = { + "poster": False, + "logo": False, + "fanart": False + } + + tasks = [] + + if poster_url: + tasks.append(("poster", self.download_poster( + poster_url, series_folder, skip_existing=skip_existing + ))) + + if logo_url: + tasks.append(("logo", self.download_logo( + logo_url, series_folder, skip_existing=skip_existing + ))) + + if fanart_url: + tasks.append(("fanart", self.download_fanart( + fanart_url, series_folder, skip_existing=skip_existing + ))) + + # Download concurrently + if tasks: + task_results = await asyncio.gather( + *[task for _, task in tasks], + return_exceptions=True + ) + + for (media_type, _), result in zip(tasks, task_results): + if isinstance(result, Exception): + logger.error(f"Error downloading {media_type}: {result}") + results[media_type] = False + else: + results[media_type] = result + + return results diff --git a/src/core/utils/nfo_generator.py b/src/core/utils/nfo_generator.py new file mode 100644 index 0000000..2af8d42 --- /dev/null +++ b/src/core/utils/nfo_generator.py @@ -0,0 +1,192 @@ +"""NFO XML generator for Kodi/XBMC format. + +This module provides functions to generate tvshow.nfo XML files from +TVShowNFO Pydantic models, adapted from the scraper project. + +Example: + >>> from src.core.entities.nfo_models import TVShowNFO + >>> nfo = TVShowNFO(title="Test Show", year=2020, tmdbid=12345) + >>> xml_string = generate_tvshow_nfo(nfo) +""" + +import logging +from typing import Optional + +from lxml import etree + +from src.core.entities.nfo_models import TVShowNFO + +logger = logging.getLogger(__name__) + + +def generate_tvshow_nfo(tvshow: TVShowNFO, pretty_print: bool = True) -> str: + """Generate tvshow.nfo XML content from TVShowNFO model. + + Args: + tvshow: TVShowNFO Pydantic model with metadata + pretty_print: Whether to format XML with indentation + + Returns: + XML string in Kodi/XBMC tvshow.nfo format + + Example: + >>> nfo = TVShowNFO(title="Attack on Titan", year=2013) + >>> xml = generate_tvshow_nfo(nfo) + """ + root = etree.Element("tvshow") + + # Basic information + _add_element(root, "title", tvshow.title) + _add_element(root, "originaltitle", tvshow.originaltitle) + _add_element(root, "showtitle", tvshow.showtitle) + _add_element(root, "sorttitle", tvshow.sorttitle) + _add_element(root, "year", str(tvshow.year) if tvshow.year else None) + + # Plot and description + _add_element(root, "plot", tvshow.plot) + _add_element(root, "outline", tvshow.outline) + _add_element(root, "tagline", tvshow.tagline) + + # Technical details + _add_element(root, "runtime", str(tvshow.runtime) if tvshow.runtime else None) + _add_element(root, "mpaa", tvshow.mpaa) + _add_element(root, "certification", tvshow.certification) + + # Status and dates + _add_element(root, "premiered", tvshow.premiered) + _add_element(root, "status", tvshow.status) + _add_element(root, "dateadded", tvshow.dateadded) + + # Ratings + if tvshow.ratings: + ratings_elem = etree.SubElement(root, "ratings") + for rating in tvshow.ratings: + rating_elem = etree.SubElement(ratings_elem, "rating") + if rating.name: + rating_elem.set("name", rating.name) + if rating.max_rating: + rating_elem.set("max", str(rating.max_rating)) + if rating.default: + rating_elem.set("default", "true") + + _add_element(rating_elem, "value", str(rating.value)) + if rating.votes is not None: + _add_element(rating_elem, "votes", str(rating.votes)) + + _add_element(root, "userrating", str(tvshow.userrating) if tvshow.userrating is not None else None) + + # IDs + _add_element(root, "tmdbid", str(tvshow.tmdbid) if tvshow.tmdbid else None) + _add_element(root, "imdbid", tvshow.imdbid) + _add_element(root, "tvdbid", str(tvshow.tvdbid) if tvshow.tvdbid else None) + + # Legacy ID fields for compatibility + _add_element(root, "id", str(tvshow.tvdbid) if tvshow.tvdbid else None) + _add_element(root, "imdb_id", tvshow.imdbid) + + # Unique IDs + for uid in tvshow.uniqueid: + uid_elem = etree.SubElement(root, "uniqueid") + uid_elem.set("type", uid.type) + if uid.default: + uid_elem.set("default", "true") + uid_elem.text = uid.value + + # Multi-value fields + for genre in tvshow.genre: + _add_element(root, "genre", genre) + + for studio in tvshow.studio: + _add_element(root, "studio", studio) + + for country in tvshow.country: + _add_element(root, "country", country) + + for tag in tvshow.tag: + _add_element(root, "tag", tag) + + # Thumbnails (posters, logos) + for thumb in tvshow.thumb: + thumb_elem = etree.SubElement(root, "thumb") + if thumb.aspect: + thumb_elem.set("aspect", thumb.aspect) + if thumb.season is not None: + thumb_elem.set("season", str(thumb.season)) + if thumb.type: + thumb_elem.set("type", thumb.type) + thumb_elem.text = str(thumb.url) + + # Fanart + if tvshow.fanart: + fanart_elem = etree.SubElement(root, "fanart") + for fanart in tvshow.fanart: + fanart_thumb = etree.SubElement(fanart_elem, "thumb") + fanart_thumb.text = str(fanart.url) + + # Named seasons + for named_season in tvshow.namedseason: + season_elem = etree.SubElement(root, "namedseason") + season_elem.set("number", str(named_season.number)) + season_elem.text = named_season.name + + # Actors + for actor in tvshow.actors: + actor_elem = etree.SubElement(root, "actor") + _add_element(actor_elem, "name", actor.name) + _add_element(actor_elem, "role", actor.role) + _add_element(actor_elem, "thumb", str(actor.thumb) if actor.thumb else None) + _add_element(actor_elem, "profile", str(actor.profile) if actor.profile else None) + _add_element(actor_elem, "tmdbid", str(actor.tmdbid) if actor.tmdbid else None) + + # Additional fields + _add_element(root, "trailer", str(tvshow.trailer) if tvshow.trailer else None) + _add_element(root, "watched", "true" if tvshow.watched else "false") + if tvshow.playcount is not None: + _add_element(root, "playcount", str(tvshow.playcount)) + + # Generate XML string + xml_str = etree.tostring( + root, + pretty_print=pretty_print, + encoding="unicode", + xml_declaration=False + ) + + # Add XML declaration + xml_declaration = '\n' + return xml_declaration + xml_str + + +def _add_element(parent: etree.Element, tag: str, text: Optional[str]) -> Optional[etree.Element]: + """Add a child element to parent if text is not None or empty. + + Args: + parent: Parent XML element + tag: Tag name for child element + text: Text content (None or empty strings are skipped) + + Returns: + Created element or None if skipped + """ + if text is not None and text != "": + elem = etree.SubElement(parent, tag) + elem.text = text + return elem + return None + + +def validate_nfo_xml(xml_string: str) -> bool: + """Validate NFO XML structure. + + Args: + xml_string: XML content to validate + + Returns: + True if valid XML, False otherwise + """ + try: + etree.fromstring(xml_string.encode('utf-8')) + return True + except etree.XMLSyntaxError as e: + logger.error(f"Invalid NFO XML: {e}") + return False diff --git a/tests/unit/test_image_downloader.py b/tests/unit/test_image_downloader.py new file mode 100644 index 0000000..005ab96 --- /dev/null +++ b/tests/unit/test_image_downloader.py @@ -0,0 +1,411 @@ +"""Unit tests for image downloader.""" + +import io +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from PIL import Image + +from src.core.utils.image_downloader import ( + ImageDownloader, + ImageDownloadError, +) + + +@pytest.fixture +def image_downloader(): + """Create image downloader instance.""" + return ImageDownloader() + + +@pytest.fixture +def valid_image_bytes(): + """Create valid test image bytes.""" + img = Image.new('RGB', (100, 100), color='red') + buf = io.BytesIO() + img.save(buf, format='JPEG') + return buf.getvalue() + + +@pytest.fixture +def mock_session(): + """Create mock aiohttp session.""" + mock = AsyncMock() + mock.get = AsyncMock() + return mock + + +class TestImageDownloaderInit: + """Test ImageDownloader initialization.""" + + def test_init_default_values(self): + """Test initialization with default values.""" + downloader = ImageDownloader() + + assert downloader.min_file_size == 1024 + assert downloader.max_retries == 3 + assert downloader.retry_delay == 1.0 + assert downloader.timeout == 30 + assert downloader.session is None + + def test_init_custom_values(self): + """Test initialization with custom values.""" + downloader = ImageDownloader( + min_file_size=5000, + max_retries=5, + retry_delay=2.0, + timeout=60 + ) + + assert downloader.min_file_size == 5000 + assert downloader.max_retries == 5 + assert downloader.retry_delay == 2.0 + assert downloader.timeout == 60 + + +class TestImageDownloaderContextManager: + """Test ImageDownloader as context manager.""" + + @pytest.mark.asyncio + async def test_async_context_manager(self, image_downloader): + """Test async context manager creates session.""" + async with image_downloader as d: + assert d.session is not None + + assert image_downloader.session is None + + @pytest.mark.asyncio + async def test_close_closes_session(self, image_downloader): + """Test close method closes session.""" + await image_downloader.__aenter__() + + assert image_downloader.session is not None + await image_downloader.close() + assert image_downloader.session is None + + +class TestImageDownloaderValidateImage: + """Test _validate_image method.""" + + def test_validate_valid_image(self, image_downloader, valid_image_bytes): + """Test validation of valid image.""" + # Should not raise exception + image_downloader._validate_image(valid_image_bytes) + + def test_validate_too_small(self, image_downloader): + """Test validation rejects too-small file.""" + tiny_data = b"tiny" + + with pytest.raises(ImageDownloadError, match="too small"): + image_downloader._validate_image(tiny_data) + + def test_validate_invalid_image_data(self, image_downloader): + """Test validation rejects invalid image data.""" + invalid_data = b"x" * 2000 # Large enough but not an image + + with pytest.raises(ImageDownloadError, match="Cannot open"): + image_downloader._validate_image(invalid_data) + + def test_validate_corrupted_image(self, image_downloader): + """Test validation rejects corrupted image.""" + # Create a corrupted JPEG-like file + corrupted = b"\xFF\xD8\xFF\xE0" + b"corrupted_data" * 100 + + with pytest.raises(ImageDownloadError): + image_downloader._validate_image(corrupted) + + +class TestImageDownloaderDownloadImage: + """Test download_image method.""" + + @pytest.mark.asyncio + async def test_download_image_success( + self, + image_downloader, + valid_image_bytes, + tmp_path + ): + """Test successful image download.""" + mock_session = AsyncMock() + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.read = AsyncMock(return_value=valid_image_bytes) + mock_session.get = AsyncMock(return_value=mock_response) + + image_downloader.session = mock_session + + output_path = tmp_path / "test.jpg" + await image_downloader.download_image("https://test.com/image.jpg", output_path) + + assert output_path.exists() + assert output_path.stat().st_size == len(valid_image_bytes) + + @pytest.mark.asyncio + async def test_download_image_skip_existing( + self, + image_downloader, + tmp_path + ): + """Test skipping existing file.""" + output_path = tmp_path / "existing.jpg" + output_path.write_bytes(b"existing") + + mock_session = AsyncMock() + image_downloader.session = mock_session + + result = await image_downloader.download_image( + "https://test.com/image.jpg", + output_path, + skip_existing=True + ) + + assert result is True + assert output_path.read_bytes() == b"existing" # Unchanged + assert not mock_session.get.called + + @pytest.mark.asyncio + async def test_download_image_overwrite_existing( + self, + image_downloader, + valid_image_bytes, + tmp_path + ): + """Test overwriting existing file.""" + output_path = tmp_path / "existing.jpg" + output_path.write_bytes(b"old") + + mock_session = AsyncMock() + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.read = AsyncMock(return_value=valid_image_bytes) + mock_session.get = AsyncMock(return_value=mock_response) + + image_downloader.session = mock_session + + await image_downloader.download_image( + "https://test.com/image.jpg", + output_path, + skip_existing=False + ) + + assert output_path.exists() + assert output_path.read_bytes() == valid_image_bytes + + @pytest.mark.asyncio + async def test_download_image_invalid_url(self, image_downloader, tmp_path): + """Test download with invalid URL.""" + mock_session = AsyncMock() + mock_response = AsyncMock() + mock_response.status = 404 + mock_response.raise_for_status = MagicMock(side_effect=Exception("Not Found")) + mock_session.get = AsyncMock(return_value=mock_response) + + image_downloader.session = mock_session + + output_path = tmp_path / "test.jpg" + + with pytest.raises(ImageDownloadError): + await image_downloader.download_image("https://test.com/missing.jpg", output_path) + + +class TestImageDownloaderSpecificMethods: + """Test type-specific download methods.""" + + @pytest.mark.asyncio + async def test_download_poster(self, image_downloader, valid_image_bytes, tmp_path): + """Test download_poster method.""" + with patch.object( + image_downloader, + 'download_image', + new_callable=AsyncMock + ) as mock_download: + await image_downloader.download_poster( + "https://test.com/poster.jpg", + tmp_path + ) + + mock_download.assert_called_once() + call_args = mock_download.call_args + assert call_args[0][1] == tmp_path / "poster.jpg" + + @pytest.mark.asyncio + async def test_download_logo(self, image_downloader, tmp_path): + """Test download_logo method.""" + with patch.object( + image_downloader, + 'download_image', + new_callable=AsyncMock + ) as mock_download: + await image_downloader.download_logo( + "https://test.com/logo.png", + tmp_path + ) + + mock_download.assert_called_once() + call_args = mock_download.call_args + assert call_args[0][1] == tmp_path / "logo.png" + + @pytest.mark.asyncio + async def test_download_fanart(self, image_downloader, tmp_path): + """Test download_fanart method.""" + with patch.object( + image_downloader, + 'download_image', + new_callable=AsyncMock + ) as mock_download: + await image_downloader.download_fanart( + "https://test.com/fanart.jpg", + tmp_path + ) + + mock_download.assert_called_once() + call_args = mock_download.call_args + assert call_args[0][1] == tmp_path / "fanart.jpg" + + +class TestImageDownloaderDownloadAll: + """Test download_all_media method.""" + + @pytest.mark.asyncio + async def test_download_all_success(self, image_downloader, tmp_path): + """Test downloading all media types.""" + with patch.object( + image_downloader, + 'download_poster', + new_callable=AsyncMock, + return_value=True + ), patch.object( + image_downloader, + 'download_logo', + new_callable=AsyncMock, + return_value=True + ), patch.object( + image_downloader, + 'download_fanart', + new_callable=AsyncMock, + return_value=True + ): + results = await image_downloader.download_all_media( + tmp_path, + poster_url="https://test.com/poster.jpg", + logo_url="https://test.com/logo.png", + fanart_url="https://test.com/fanart.jpg" + ) + + assert results["poster"] is True + assert results["logo"] is True + assert results["fanart"] is True + + @pytest.mark.asyncio + async def test_download_all_partial(self, image_downloader, tmp_path): + """Test downloading with some URLs missing.""" + with patch.object( + image_downloader, + 'download_poster', + new_callable=AsyncMock, + return_value=True + ), patch.object( + image_downloader, + 'download_logo', + new_callable=AsyncMock + ) as mock_logo, patch.object( + image_downloader, + 'download_fanart', + new_callable=AsyncMock + ) as mock_fanart: + results = await image_downloader.download_all_media( + tmp_path, + poster_url="https://test.com/poster.jpg", + logo_url=None, + fanart_url=None + ) + + assert results["poster"] is True + assert results["logo"] is None + assert results["fanart"] is None + assert not mock_logo.called + assert not mock_fanart.called + + @pytest.mark.asyncio + async def test_download_all_with_failures(self, image_downloader, tmp_path): + """Test downloading with some failures.""" + with patch.object( + image_downloader, + 'download_poster', + new_callable=AsyncMock, + return_value=True + ), patch.object( + image_downloader, + 'download_logo', + new_callable=AsyncMock, + side_effect=ImageDownloadError("Failed") + ), patch.object( + image_downloader, + 'download_fanart', + new_callable=AsyncMock, + return_value=True + ): + results = await image_downloader.download_all_media( + tmp_path, + poster_url="https://test.com/poster.jpg", + logo_url="https://test.com/logo.png", + fanart_url="https://test.com/fanart.jpg" + ) + + assert results["poster"] is True + assert results["logo"] is False + assert results["fanart"] is True + + +class TestImageDownloaderRetryLogic: + """Test retry logic.""" + + @pytest.mark.asyncio + async def test_retry_on_failure(self, image_downloader, valid_image_bytes, tmp_path): + """Test retry logic on temporary failure.""" + mock_session = AsyncMock() + + # First two calls fail, third succeeds + mock_response_fail = AsyncMock() + mock_response_fail.status = 500 + mock_response_fail.raise_for_status = MagicMock(side_effect=Exception("Server Error")) + + mock_response_success = AsyncMock() + mock_response_success.status = 200 + mock_response_success.read = AsyncMock(return_value=valid_image_bytes) + + mock_session.get = AsyncMock( + side_effect=[mock_response_fail, mock_response_fail, mock_response_success] + ) + + image_downloader.session = mock_session + image_downloader.retry_delay = 0.1 # Speed up test + + output_path = tmp_path / "test.jpg" + await image_downloader.download_image("https://test.com/image.jpg", output_path) + + # Should have retried twice then succeeded + assert mock_session.get.call_count == 3 + assert output_path.exists() + + @pytest.mark.asyncio + async def test_max_retries_exceeded(self, image_downloader, tmp_path): + """Test failure after max retries.""" + mock_session = AsyncMock() + mock_response = AsyncMock() + mock_response.status = 500 + mock_response.raise_for_status = MagicMock(side_effect=Exception("Server Error")) + mock_session.get = AsyncMock(return_value=mock_response) + + image_downloader.session = mock_session + image_downloader.max_retries = 2 + image_downloader.retry_delay = 0.1 + + output_path = tmp_path / "test.jpg" + + with pytest.raises(ImageDownloadError): + await image_downloader.download_image("https://test.com/image.jpg", output_path) + + # Should have tried 3 times (initial + 2 retries) + assert mock_session.get.call_count == 3 diff --git a/tests/unit/test_nfo_generator.py b/tests/unit/test_nfo_generator.py new file mode 100644 index 0000000..81155d2 --- /dev/null +++ b/tests/unit/test_nfo_generator.py @@ -0,0 +1,325 @@ +"""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) + + 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 + 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) + + 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" + + with pytest.raises(ValueError, match="Invalid XML"): + validate_nfo_xml(invalid_xml) + + def test_validate_missing_tvshow_root(self): + """Test validation rejects non-tvshow root.""" + invalid_xml = '<?xml version="1.0"?><movie><title>Test' + + with pytest.raises(ValueError, match="root element must be"): + validate_nfo_xml(invalid_xml) + + def test_validate_empty_string(self): + """Test validation rejects empty string.""" + with pytest.raises(ValueError): + validate_nfo_xml("") + + 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 diff --git a/tests/unit/test_tmdb_client.py b/tests/unit/test_tmdb_client.py new file mode 100644 index 0000000..a725235 --- /dev/null +++ b/tests/unit/test_tmdb_client.py @@ -0,0 +1,331 @@ +"""Unit tests for TMDB client.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from aiohttp import ClientResponseError, ClientSession + +from src.core.services.tmdb_client import TMDBAPIError, TMDBClient + + +@pytest.fixture +def tmdb_client(): + """Create TMDB client with test API key.""" + return TMDBClient(api_key="test_api_key") + + +@pytest.fixture +def mock_response(): + """Create mock aiohttp response.""" + mock = AsyncMock() + mock.status = 200 + mock.json = AsyncMock(return_value={"success": True}) + return mock + + +class TestTMDBClientInit: + """Test TMDB client initialization.""" + + def test_init_with_api_key(self): + """Test initialization with API key.""" + client = TMDBClient(api_key="my_key") + assert client.api_key == "my_key" + assert client.base_url == "https://api.themoviedb.org/3" + assert client.image_base_url == "https://image.tmdb.org/t/p" + assert client.session is None + assert client._cache == {} + + def test_init_sets_attributes(self): + """Test all attributes are set correctly.""" + client = TMDBClient(api_key="test") + assert hasattr(client, "api_key") + assert hasattr(client, "base_url") + assert hasattr(client, "image_base_url") + assert hasattr(client, "session") + assert hasattr(client, "_cache") + + +class TestTMDBClientContextManager: + """Test TMDB client as context manager.""" + + @pytest.mark.asyncio + async def test_async_context_manager(self): + """Test async context manager creates session.""" + client = TMDBClient(api_key="test") + + async with client as c: + assert c.session is not None + assert isinstance(c.session, ClientSession) + + # Session should be closed after context + assert client.session is None + + @pytest.mark.asyncio + async def test_close_closes_session(self): + """Test close method closes session.""" + client = TMDBClient(api_key="test") + await client.__aenter__() + + assert client.session is not None + await client.close() + assert client.session is None + + +class TestTMDBClientSearchTVShow: + """Test search_tv_show method.""" + + @pytest.mark.asyncio + async def test_search_tv_show_success(self, tmdb_client, mock_response): + """Test successful TV show search.""" + mock_response.json = AsyncMock(return_value={ + "results": [ + {"id": 1, "name": "Test Show"}, + {"id": 2, "name": "Another Show"} + ] + }) + + with patch.object(tmdb_client, "_make_request", return_value=mock_response.json.return_value): + result = await tmdb_client.search_tv_show("Test Show") + + assert "results" in result + assert len(result["results"]) == 2 + assert result["results"][0]["name"] == "Test Show" + + @pytest.mark.asyncio + async def test_search_tv_show_with_year(self, tmdb_client): + """Test TV show search with year filter.""" + mock_data = {"results": [{"id": 1, "name": "Test Show", "first_air_date": "2020-01-01"}]} + + with patch.object(tmdb_client, "_make_request", return_value=mock_data): + result = await tmdb_client.search_tv_show("Test Show", year=2020) + + assert "results" in result + + @pytest.mark.asyncio + async def test_search_tv_show_empty_results(self, tmdb_client): + """Test search with no results.""" + with patch.object(tmdb_client, "_make_request", return_value={"results": []}): + result = await tmdb_client.search_tv_show("NonexistentShow") + + assert result["results"] == [] + + @pytest.mark.asyncio + async def test_search_tv_show_uses_cache(self, tmdb_client): + """Test search results are cached.""" + mock_data = {"results": [{"id": 1, "name": "Cached Show"}]} + + with patch.object(tmdb_client, "_make_request", return_value=mock_data) as mock_request: + # First call should hit API + result1 = await tmdb_client.search_tv_show("Cached Show") + assert mock_request.call_count == 1 + + # Second call should use cache + result2 = await tmdb_client.search_tv_show("Cached Show") + assert mock_request.call_count == 1 # Not called again + + assert result1 == result2 + + +class TestTMDBClientGetTVShowDetails: + """Test get_tv_show_details method.""" + + @pytest.mark.asyncio + async def test_get_tv_show_details_success(self, tmdb_client): + """Test successful TV show details retrieval.""" + mock_data = { + "id": 123, + "name": "Test Show", + "overview": "A test show", + "first_air_date": "2020-01-01" + } + + with patch.object(tmdb_client, "_make_request", return_value=mock_data): + result = await tmdb_client.get_tv_show_details(123) + + assert result["id"] == 123 + assert result["name"] == "Test Show" + + @pytest.mark.asyncio + async def test_get_tv_show_details_with_append(self, tmdb_client): + """Test details with append_to_response.""" + mock_data = { + "id": 123, + "name": "Test Show", + "credits": {"cast": []}, + "images": {"posters": []} + } + + with patch.object(tmdb_client, "_make_request", return_value=mock_data) as mock_request: + result = await tmdb_client.get_tv_show_details(123, append_to_response="credits,images") + + assert "credits" in result + assert "images" in result + + # Verify append_to_response was passed + call_args = mock_request.call_args + assert "credits,images" in str(call_args) + + +class TestTMDBClientGetExternalIDs: + """Test get_tv_show_external_ids method.""" + + @pytest.mark.asyncio + async def test_get_external_ids_success(self, tmdb_client): + """Test successful external IDs retrieval.""" + mock_data = { + "imdb_id": "tt1234567", + "tvdb_id": 98765 + } + + with patch.object(tmdb_client, "_make_request", return_value=mock_data): + result = await tmdb_client.get_tv_show_external_ids(123) + + assert result["imdb_id"] == "tt1234567" + assert result["tvdb_id"] == 98765 + + +class TestTMDBClientGetImages: + """Test get_tv_show_images method.""" + + @pytest.mark.asyncio + async def test_get_images_success(self, tmdb_client): + """Test successful images retrieval.""" + mock_data = { + "posters": [{"file_path": "/poster.jpg"}], + "backdrops": [{"file_path": "/backdrop.jpg"}], + "logos": [{"file_path": "/logo.png"}] + } + + with patch.object(tmdb_client, "_make_request", return_value=mock_data): + result = await tmdb_client.get_tv_show_images(123) + + assert "posters" in result + assert "backdrops" in result + assert "logos" in result + assert len(result["posters"]) == 1 + + +class TestTMDBClientImageURL: + """Test get_image_url method.""" + + def test_get_image_url_with_size(self, tmdb_client): + """Test image URL generation with size.""" + url = tmdb_client.get_image_url("/test.jpg", "w500") + assert url == "https://image.tmdb.org/t/p/w500/test.jpg" + + def test_get_image_url_original(self, tmdb_client): + """Test image URL with original size.""" + url = tmdb_client.get_image_url("/test.jpg", "original") + assert url == "https://image.tmdb.org/t/p/original/test.jpg" + + def test_get_image_url_strips_leading_slash(self, tmdb_client): + """Test path without leading slash works.""" + url = tmdb_client.get_image_url("test.jpg", "w500") + assert url == "https://image.tmdb.org/t/p/w500/test.jpg" + + +class TestTMDBClientMakeRequest: + """Test _make_request private method.""" + + @pytest.mark.asyncio + async def test_make_request_success(self, tmdb_client): + """Test successful request.""" + mock_session = AsyncMock() + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"data": "test"}) + mock_session.get = AsyncMock(return_value=mock_response) + + tmdb_client.session = mock_session + + result = await tmdb_client._make_request("tv/search", {"query": "test"}) + + assert result == {"data": "test"} + + @pytest.mark.asyncio + async def test_make_request_unauthorized(self, tmdb_client): + """Test 401 unauthorized error.""" + mock_session = AsyncMock() + mock_response = AsyncMock() + mock_response.status = 401 + mock_response.raise_for_status = MagicMock( + side_effect=ClientResponseError(None, None, status=401) + ) + mock_session.get = AsyncMock(return_value=mock_response) + + tmdb_client.session = mock_session + + with pytest.raises(TMDBAPIError, match="Invalid API key"): + await tmdb_client._make_request("tv/search", {}) + + @pytest.mark.asyncio + async def test_make_request_not_found(self, tmdb_client): + """Test 404 not found error.""" + mock_session = AsyncMock() + mock_response = AsyncMock() + mock_response.status = 404 + mock_response.raise_for_status = MagicMock( + side_effect=ClientResponseError(None, None, status=404) + ) + mock_session.get = AsyncMock(return_value=mock_response) + + tmdb_client.session = mock_session + + with pytest.raises(TMDBAPIError, match="not found"): + await tmdb_client._make_request("tv/99999", {}) + + @pytest.mark.asyncio + async def test_make_request_rate_limit(self, tmdb_client): + """Test 429 rate limit error.""" + mock_session = AsyncMock() + mock_response = AsyncMock() + mock_response.status = 429 + mock_response.raise_for_status = MagicMock( + side_effect=ClientResponseError(None, None, status=429) + ) + mock_session.get = AsyncMock(return_value=mock_response) + + tmdb_client.session = mock_session + + with pytest.raises(TMDBAPIError, match="rate limit"): + await tmdb_client._make_request("tv/search", {}) + + +class TestTMDBClientDownloadImage: + """Test download_image method.""" + + @pytest.mark.asyncio + async def test_download_image_success(self, tmdb_client, tmp_path): + """Test successful image download.""" + image_data = b"fake_image_data" + mock_session = AsyncMock() + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.read = AsyncMock(return_value=image_data) + mock_session.get = AsyncMock(return_value=mock_response) + + tmdb_client.session = mock_session + + output_path = tmp_path / "test.jpg" + await tmdb_client.download_image("https://test.com/image.jpg", output_path) + + assert output_path.exists() + assert output_path.read_bytes() == image_data + + @pytest.mark.asyncio + async def test_download_image_failure(self, tmdb_client, tmp_path): + """Test image download failure.""" + mock_session = AsyncMock() + mock_response = AsyncMock() + mock_response.status = 404 + mock_response.raise_for_status = MagicMock( + side_effect=ClientResponseError(None, None, status=404) + ) + mock_session.get = AsyncMock(return_value=mock_response) + + tmdb_client.session = mock_session + + output_path = tmp_path / "test.jpg" + + with pytest.raises(TMDBAPIError): + await tmdb_client.download_image("https://test.com/missing.jpg", output_path)