diff --git a/src/server/models/anime.py b/src/server/models/anime.py index 11f92bb..5b45324 100644 --- a/src/server/models/anime.py +++ b/src/server/models/anime.py @@ -133,6 +133,29 @@ class SearchResult(BaseModel): ) ) title: str = Field(..., description="Series title") + folder: Optional[str] = Field( + None, + description=( + "Series folder name on disk (metadata only) " + "(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only." + ) + ) + snippet: Optional[str] = Field( + None, + description="Search result snippet or description" + ) + score: Optional[float] = Field( + None, + description="Search relevance score (0.0 to 1.0)" + ) + + @field_validator('key', mode='before') + @classmethod + def normalize_key(cls, v: str) -> str: + """Normalize key to lowercase.""" + if isinstance(v, str): + return v.lower().strip() + return v class AnimeDetailsResponse(BaseModel): diff --git a/src/server/models/config.py b/src/server/models/config.py index f921880..5b450d8 100644 --- a/src/server/models/config.py +++ b/src/server/models/config.py @@ -45,6 +45,11 @@ class SchedulerConfig(BaseModel): "completes. Checks each series folder for tvshow.nfo and " "creates or fills missing properties.", ) + image_scan_after_rescan: bool = Field( + default=True, + description="Download series images (poster.jpg, fanart.jpg, logo.png) " + "from TMDB after a scheduled rescan completes.", + ) # Legacy alias fields — read via Pydantic alias auto_download: Optional[bool] = Field(default=None, alias="auto_download") diff --git a/src/server/services/background_loader_service.py b/src/server/services/background_loader_service.py index 11acd30..6713794 100644 --- a/src/server/services/background_loader_service.py +++ b/src/server/services/background_loader_service.py @@ -497,24 +497,55 @@ class BackgroundLoaderService: async def _load_nfo_and_images(self, task: SeriesLoadingTask, db: Any) -> bool: """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. - + + Downloads poster.jpg, fanart.jpg, and logo.png from TMDB + using the ImageLoadingService. + Args: task: The loading task db: Database session - + Returns: - bool: Always False since NFO service removed + bool: True if any images were loaded, False otherwise """ task.status = LoadingStatus.LOADING_NFO - await self._broadcast_status(task, "NFO loading disabled...") - - task.progress["nfo"] = False - task.progress["logo"] = False - task.progress["images"] = False - return False + await self._broadcast_status(task, "Loading images...") + + try: + from src.server.nfo.tmdb_client import get_tmdb_client + from src.server.services.image_loading_service import ( + init_image_loading_service, + ) + + tmdb_client = get_tmdb_client() + image_service = init_image_loading_service(tmdb_client) + + result = await image_service.load_series_images( + key=task.key, + folder=task.folder, + anime_directory=self.series_app.directory_to_search, + db=db, + ) + + task.progress["nfo"] = True # NFO was already created earlier in the flow + task.progress["logo"] = result.get("logo", False) + task.progress["images"] = result.get("poster", False) and result.get("fanart", False) + + logger.info( + "Images loaded for series %s: poster=%s fanart=%s logo=%s", + task.key, + result.get("poster", False), + result.get("fanart", False), + result.get("logo", False), + ) + return True + + except Exception as e: + logger.warning("Failed to load images for series %s: %s", task.key, e) + task.progress["nfo"] = True + 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/image_loading_service.py b/src/server/services/image_loading_service.py new file mode 100644 index 0000000..7e4c516 --- /dev/null +++ b/src/server/services/image_loading_service.py @@ -0,0 +1,387 @@ +"""Image loading service for downloading series artwork from TMDB. + +This service downloads poster.jpg, fanart.jpg, and logo.png images +for anime series using TMDB as the image source. + +Integrated with: +- BackgroundLoaderService: triggered when adding new anime +- SchedulerService: triggered during scheduled rescan +""" +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import structlog +from sqlalchemy.ext.asyncio import AsyncSession + +from src.server.database.service import AnimeSeriesService +from src.server.nfo.tmdb_client import TMDBClient +from src.server.utils.image_downloader import ImageDownloader +from src.server.utils.media import FANART_FILENAME, LOGO_FILENAME, POSTER_FILENAME + +logger = structlog.get_logger(__name__) + + +class ImageLoadingServiceError(Exception): + """Exception raised for image loading failures.""" + + +class ImageLoadingService: + """Service for loading series images from TMDB. + + Downloads poster.jpg, fanart.jpg, and logo.png for anime series + using TMDB as the image source. Images are saved to the series + folder alongside tvshow.nfo. + + Attributes: + tmdb_client: TMDB API client for fetching image URLs + image_downloader: Downloader for saving images to disk + """ + + # Batch size for scheduler bulk processing + BATCH_SIZE = 10 + + def __init__(self, tmdb_client: TMDBClient): + """Initialize the image loading service. + + Args: + tmdb_client: TMDB API client for fetching image metadata + """ + self._tmdb_client = tmdb_client + + async def load_series_images( + self, + key: str, + folder: str, + anime_directory: str, + db: AsyncSession, + ) -> Dict[str, bool]: + """Load all images for a single series. + + Downloads poster.jpg, fanart.jpg, and logo.png from TMDB + if they don't already exist in the series folder. + + Args: + key: Series unique identifier (provider key) + folder: Series folder name (metadata, for path construction) + anime_directory: Base anime directory path + db: Database session + + Returns: + Dict with download status for each image type: + { + "poster": bool, # True if poster.jpg exists/downloads succeeded + "fanart": bool, # True if fanart.jpg exists/downloads succeeded + "logo": bool # True if logo.png exists/downloads succeeded + } + """ + series_dir = Path(anime_directory) / folder + + if not series_dir.exists(): + logger.warning( + "Series directory not found, cannot load images", + key=key, + folder=folder, + path=str(series_dir), + ) + return {"poster": False, "fanart": False, "logo": False} + + # Get series from database to retrieve TMDB ID + series = await AnimeSeriesService.get_by_key(db, key) + if not series: + logger.warning("Series not found in database", key=key) + return {"poster": False, "fanart": False, "logo": False} + + if not series.tmdb_id: + logger.warning( + "Series has no TMDB ID, cannot load images", + key=key, + name=series.name, + ) + return {"poster": False, "fanart": False, "logo": False} + + try: + # Fetch image metadata from TMDB + images_data = await self._tmdb_client.get_tv_show_images(series.tmdb_id) + + poster_url, logo_url, fanart_url = self._select_best_images(images_data) + + # Download images + results = await self._download_images( + series_dir, poster_url, logo_url, fanart_url + ) + + # Update database flags + await self._update_series_flags(db, series, results) + + return results + + except Exception as e: + logger.exception("Failed to load images for series: %s", key, exc_info=e) + return {"poster": False, "fanart": False, "logo": False} + + async def load_series_images_batch( + self, + series_list: List[Dict[str, Any]], + anime_directory: str, + db: AsyncSession, + ) -> Dict[str, int]: + """Load images for multiple series in batches. + + Used by scheduler to process many series efficiently. + + Args: + series_list: List of dicts with 'key' and 'folder' for each series + anime_directory: Base anime directory path + db: Database session + + Returns: + Dict with counts: + { + "total": int, # Total series processed + "success": int, # Series with all images loaded + "partial": int, # Series with some images loaded + "failed": int, # Series with no images loaded + "poster": int, # Count of poster.jpg downloads + "fanart": int, # Count of fanart.jpg downloads + "logo": int, # Count of logo.png downloads + } + """ + stats = { + "total": len(series_list), + "success": 0, + "partial": 0, + "failed": 0, + "poster": 0, + "fanart": 0, + "logo": 0, + } + + # Process in batches to respect TMDB rate limits + for i in range(0, len(series_list), self.BATCH_SIZE): + batch = series_list[i : i + self.BATCH_SIZE] + + tasks = [ + self.load_series_images( + key=series["key"], + folder=series["folder"], + anime_directory=anime_directory, + db=db, + ) + for series in batch + ] + + results = await asyncio.gather(*tasks, return_exceptions=True) + + for series, result in zip(batch, results): + if isinstance(result, Exception): + logger.warning( + "Image loading failed for series: %s", + series["key"], + exc_info=result, + ) + stats["failed"] += 1 + continue + + if result["poster"] and result["fanart"] and result["logo"]: + stats["success"] += 1 + elif result["poster"] or result["fanart"] or result["logo"]: + stats["partial"] += 1 + else: + stats["failed"] += 1 + + if result["poster"]: + stats["poster"] += 1 + if result["fanart"]: + stats["fanart"] += 1 + if result["logo"]: + stats["logo"] += 1 + + # Small delay between batches to avoid overwhelming TMDB + if i + self.BATCH_SIZE < len(series_list): + await asyncio.sleep(0.5) + + logger.info( + "Batch image loading completed", + total=stats["total"], + success=stats["success"], + partial=stats["partial"], + failed=stats["failed"], + ) + + return stats + + def _select_best_images( + self, images_data: Dict[str, Any] + ) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """Select the best available images from TMDB data. + + Args: + images_data: TMDB images response with 'posters', 'backdrops', 'logos' + + Returns: + Tuple of (poster_url, logo_url, fanart_url) - URLs or None if not available + """ + poster_url = None + logo_url = None + fanart_url = None + + # Select poster: prefer English, otherwise take first available + posters = images_data.get("posters", []) + for poster in posters: + if poster.get("iso_639_1") == "en" or poster.get("iso_639_1") is None: + poster_url = self._tmdb_client.get_image_url(poster["file_path"]) + break + if not poster_url and posters: + poster_url = self._tmdb_client.get_image_url(posters[0]["file_path"]) + + # Select logo/clearlogo: prefer English with transparent background + logos = images_data.get("logos", []) + for logo in logos: + if logo.get("iso_639_1") == "en": + logo_url = self._tmdb_client.get_image_url(logo["file_path"]) + break + if not logo_url and logos: + logo_url = self._tmdb_client.get_image_url(logos[0]["file_path"]) + + # Select fanart/backdrop: prefer English + backdrops = images_data.get("backdrops", []) + for backdrop in backdrops: + if backdrop.get("iso_639_1") == "en": + fanart_url = self._tmdb_client.get_image_url(backdrop["file_path"]) + break + if not fanart_url and backdrops: + fanart_url = self._tmdb_client.get_image_url(backdrops[0]["file_path"]) + + return poster_url, logo_url, fanart_url + + async def _download_images( + self, + series_dir: Path, + poster_url: Optional[str], + logo_url: Optional[str], + fanart_url: Optional[str], + ) -> Dict[str, bool]: + """Download images to series directory. + + Args: + series_dir: Path to series folder + poster_url: URL for poster.jpg + logo_url: URL for logo.png + fanart_url: URL for fanart.jpg + + Returns: + Dict with download status for each image + """ + results = {"poster": False, "fanart": False, "logo": False} + + async with ImageDownloader() as downloader: + tasks = [] + + if poster_url: + tasks.append( + self._download_and_track( + downloader, poster_url, series_dir / POSTER_FILENAME, "poster", results + ) + ) + if logo_url: + tasks.append( + self._download_and_track( + downloader, logo_url, series_dir / LOGO_FILENAME, "logo", results + ) + ) + if fanart_url: + tasks.append( + self._download_and_track( + downloader, fanart_url, series_dir / FANART_FILENAME, "fanart", results + ) + ) + + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + return results + + async def _download_and_track( + self, + downloader: ImageDownloader, + url: str, + local_path: Path, + key: str, + results: Dict[str, bool], + ) -> None: + """Download single image and track result. + + Args: + downloader: ImageDownloader instance + url: Image URL + local_path: Local destination path + key: Result dict key ('poster', 'logo', 'fanart') + results: Dict to update with result + """ + try: + success = await downloader.download_image( + url, local_path, skip_existing=True, validate=True + ) + results[key] = success + except Exception as e: + logger.warning("Failed to download %s: %s", key, e) + results[key] = False + + async def _update_series_flags( + self, + db: AsyncSession, + series: Any, + results: Dict[str, bool], + ) -> None: + """Update database flags after image loading. + + Args: + db: Database session + series: AnimeSeries instance + results: Dict with download status for each image + """ + try: + series.images_loaded = results["poster"] and results["fanart"] + series.logo_loaded = results["logo"] + await db.commit() + except Exception as e: + logger.warning("Failed to update series image flags: %s", e) + await db.rollback() + + +# Singleton instance +_image_loading_service: Optional[ImageLoadingService] = None + + +def get_image_loading_service() -> ImageLoadingService: + """Get the image loading service singleton. + + Returns: + ImageLoadingService instance + + Raises: + RuntimeError: If service not initialized + """ + if _image_loading_service is None: + raise RuntimeError( + "ImageLoadingService not initialized. " + "Call init_image_loading_service() first." + ) + return _image_loading_service + + +def init_image_loading_service(tmdb_client: TMDBClient) -> ImageLoadingService: + """Initialize the image loading service singleton. + + Args: + tmdb_client: TMDB API client + + Returns: + ImageLoadingService instance + """ + global _image_loading_service + _image_loading_service = ImageLoadingService(tmdb_client=tmdb_client) + return _image_loading_service \ No newline at end of file diff --git a/src/server/services/scheduler/scheduler_service.py b/src/server/services/scheduler/scheduler_service.py index 0d03e87..e5208ae 100644 --- a/src/server/services/scheduler/scheduler_service.py +++ b/src/server/services/scheduler/scheduler_service.py @@ -11,11 +11,12 @@ from __future__ import annotations import logging from datetime import datetime, timedelta, timezone -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger +from src.config.settings import settings from src.server.models.config import SchedulerConfig from src.server.services.config_service import ConfigServiceError, get_config_service @@ -264,6 +265,9 @@ class SchedulerService: "nfo_scan_after_rescan": ( self._config.nfo_scan_after_rescan if self._config else True ), + "image_scan_after_rescan": ( + self._config.image_scan_after_rescan if self._config else True + ), "last_run": ( self._last_scan_time.isoformat() if self._last_scan_time @@ -409,6 +413,21 @@ class SchedulerService: logger.error("Auto-download failed: %s", exc, exc_info=True) await self._broadcast("auto_download_error", {"error": str(exc)}) + # 4. Image scan (if enabled) + if self._config and self._config.image_scan_after_rescan: + try: + image_result = await self._run_image_scan() + await self._broadcast("image_scan_completed", { + "total": image_result.get("total", 0), + "success": image_result.get("success", 0), + "poster": image_result.get("poster", 0), + "fanart": image_result.get("fanart", 0), + "logo": image_result.get("logo", 0), + }) + except Exception as exc: + logger.error("Image scan failed: %s", exc, exc_info=True) + await self._broadcast("image_scan_error", {"error": str(exc)}) + self._last_scan_time = datetime.now(timezone.utc) duration = (self._last_scan_time - scan_start).total_seconds() @@ -462,6 +481,61 @@ class SchedulerService: ) return result + async def _run_image_scan(self) -> Dict[str, Any]: + """Download missing images for all series from TMDB.""" + from src.server.database.connection import get_db_session + from src.server.nfo.tmdb_client import get_tmdb_client + from src.server.services.image_loading_service import init_image_loading_service + from src.server.utils.dependencies import get_anime_service + + anime_service = get_anime_service() + + try: + series_list_data = await anime_service.list_series_with_filters() + except Exception as exc: + logger.warning("Failed to get series list for image scan: %s", exc) + return {"total": 0, "success": 0, "partial": 0, "failed": 0} + + if not series_list_data: + logger.info("No series found for image scan") + return {"total": 0, "success": 0, "partial": 0, "failed": 0} + + # Build list of series to process + series_to_process = [] + for series_data in series_list_data: + key = series_data.get("key") + folder = series_data.get("folder") + if not key or not folder: + continue + series_to_process.append({"key": key, "folder": folder}) + + if not series_to_process: + logger.info("No series to process for image scan") + return {"total": 0, "success": 0, "partial": 0, "failed": 0} + + logger.info("Starting image scan for %d series...", len(series_to_process)) + + # Initialize TMDB client and image loading service + tmdb_client = get_tmdb_client() + image_service = init_image_loading_service(tmdb_client) + + anime_dir = settings.anime_directory + async with get_db_session() as db: + result = await image_service.load_series_images_batch( + series_list=series_to_process, + anime_directory=anime_dir, + db=db, + ) + + logger.info( + "Image scan completed: total=%d success=%d partial=%d failed=%d", + result.get("total", 0), + result.get("success", 0), + result.get("partial", 0), + result.get("failed", 0), + ) + return result + async def _run_auto_download(self) -> int: """Queue and start downloads for all series with missing episodes.""" from src.server.models.download import EpisodeIdentifier diff --git a/tests/unit/test_background_loader_service.py b/tests/unit/test_background_loader_service.py index 17b0c60..71dcc50 100644 --- a/tests/unit/test_background_loader_service.py +++ b/tests/unit/test_background_loader_service.py @@ -512,14 +512,16 @@ class TestScanSeriesEpisodes: class TestLoadNfoAndImages: - """Tests for loading NFO files and images.""" - + """Tests for loading NFO files and images via ImageLoadingService. + + These tests verify the behavior of _load_nfo_and_images by mocking + the external dependencies at the correct import locations. + """ + @pytest.mark.asyncio - async def test_load_nfo_creates_new_nfo(self, background_loader_service, mock_websocket_service): - """Test creating new NFO file - NFO service removed, stub returns False.""" + async def test_load_nfo_and_images_success(self, background_loader_service): + """Test successful image loading via ImageLoadingService.""" mock_db = AsyncMock() - mock_series = MagicMock() - mock_series.has_nfo = False task = SeriesLoadingTask( key="test", @@ -528,51 +530,78 @@ class TestLoadNfoAndImages: year=2020 ) - # 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) + # Mock get_tmdb_client and init_image_loading_service at source + with patch("src.server.nfo.tmdb_client.get_tmdb_client") as mock_get_tmdb: + mock_tmdb = MagicMock() + mock_get_tmdb.return_value = mock_tmdb + + with patch("src.server.services.image_loading_service.ImageLoadingService") as mock_service_class: + mock_service = MagicMock() + mock_service.load_series_images = AsyncMock(return_value={ + "poster": True, + "fanart": True, + "logo": True, + }) + mock_service_class.return_value = mock_service + + 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 + + @pytest.mark.asyncio + async def test_load_nfo_and_images_partial(self, background_loader_service): + """Test partial image loading (some images fail).""" + mock_db = AsyncMock() + + task = SeriesLoadingTask( + key="test", + folder="test_folder", + name="Test Series" + ) + + with patch("src.server.nfo.tmdb_client.get_tmdb_client") as mock_get_tmdb: + mock_tmdb = MagicMock() + mock_get_tmdb.return_value = mock_tmdb + + with patch("src.server.services.image_loading_service.ImageLoadingService") as mock_service_class: + mock_service = MagicMock() + mock_service.load_series_images = AsyncMock(return_value={ + "poster": True, + "fanart": False, + "logo": True, + }) + mock_service_class.return_value = mock_service + + 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 False + + @pytest.mark.asyncio + async def test_load_nfo_and_images_service_error(self, background_loader_service): + """Test graceful handling when ImageLoadingService throws exception.""" + mock_db = AsyncMock() + + task = SeriesLoadingTask( + key="test", + folder="test_folder", + name="Test Series" + ) + + with patch("src.server.nfo.tmdb_client.get_tmdb_client") as mock_get_tmdb: + mock_get_tmdb.side_effect = Exception("TMDB API error") + + 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 False + assert task.progress["nfo"] is True 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 - NFO service removed, stub returns False.""" - mock_db = AsyncMock() - mock_series = MagicMock() - mock_series.has_nfo = True - - task = SeriesLoadingTask( - key="test", - folder="test_folder", - name="Test Series" - ) - - # 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 - - @pytest.mark.asyncio - async def test_load_nfo_without_nfo_service(self, background_loader_service): - """Test graceful handling when NFO service not available.""" - background_loader_service.series_app.nfo_service = None - - mock_db = AsyncMock() - task = SeriesLoadingTask( - key="test", - folder="test_folder", - name="Test Series" - ) - - result = await background_loader_service._load_nfo_and_images(task, mock_db) - - assert result is False - assert task.progress["nfo"] is False - assert task.progress["logo"] is False class TestScanMissingEpisodes: @@ -813,23 +842,21 @@ class TestErrorHandling: @pytest.mark.asyncio async def test_load_nfo_error_handling(self, background_loader_service): - """Test error handling during NFO creation.""" - background_loader_service.series_app.nfo_service.has_nfo = MagicMock(return_value=False) - background_loader_service.series_app.nfo_service.create_tvshow_nfo = AsyncMock( - side_effect=Exception("API error") - ) - + """Test error handling when ImageLoadingService fails.""" mock_db = AsyncMock() task = SeriesLoadingTask( key="test", folder="test_folder", name="Test Series" ) - - result = await background_loader_service._load_nfo_and_images(task, mock_db) - + + with patch("src.server.nfo.tmdb_client.get_tmdb_client") as mock_get_tmdb: + mock_get_tmdb.side_effect = Exception("TMDB API error") + + result = await background_loader_service._load_nfo_and_images(task, mock_db) + assert result is False - assert task.progress["nfo"] is False + assert task.progress["nfo"] is True # Optimistic - NFO created elsewhere assert task.progress["logo"] is False assert task.progress["images"] is False