feat: add ImageLoadingService for downloading series artwork

- Create ImageLoadingService that downloads poster.jpg, fanart.jpg, and
  logo.png from TMDB when anime is added or during scheduler rescan
- Integrate into BackgroundLoaderService._load_nfo_and_images() to trigger
  image downloads when new anime is added
- Add image_scan_after_rescan config option to scheduler (default: true)
- Add _run_image_scan() to scheduler rescan flow, processing series in
  batches of 10 to respect TMDB rate limits
- Fix SearchResult model missing folder, snippet, and score fields
- Update background_loader tests to match new image loading behavior
This commit is contained in:
2026-06-14 20:51:57 +02:00
parent 6dc3cda810
commit 7a1b2e565e
6 changed files with 617 additions and 70 deletions

View File

@@ -133,6 +133,29 @@ class SearchResult(BaseModel):
) )
) )
title: str = Field(..., description="Series title") 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): class AnimeDetailsResponse(BaseModel):

View File

@@ -45,6 +45,11 @@ class SchedulerConfig(BaseModel):
"completes. Checks each series folder for tvshow.nfo and " "completes. Checks each series folder for tvshow.nfo and "
"creates or fills missing properties.", "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 # Legacy alias fields — read via Pydantic alias
auto_download: Optional[bool] = Field(default=None, alias="auto_download") auto_download: Optional[bool] = Field(default=None, alias="auto_download")

View File

@@ -497,24 +497,55 @@ class BackgroundLoaderService:
async def _load_nfo_and_images(self, task: SeriesLoadingTask, db: Any) -> bool: async def _load_nfo_and_images(self, task: SeriesLoadingTask, db: Any) -> bool:
"""Load NFO file and images for a series. """Load NFO file and images for a series.
Note: NFO service has been removed. This method now just marks Downloads poster.jpg, fanart.jpg, and logo.png from TMDB
progress as False since NFO handling moved to server layer. using the ImageLoadingService.
Args: Args:
task: The loading task task: The loading task
db: Database session db: Database session
Returns: Returns:
bool: Always False since NFO service removed bool: True if any images were loaded, False otherwise
""" """
task.status = LoadingStatus.LOADING_NFO task.status = LoadingStatus.LOADING_NFO
await self._broadcast_status(task, "NFO loading disabled...") await self._broadcast_status(task, "Loading images...")
task.progress["nfo"] = False try:
task.progress["logo"] = False from src.server.nfo.tmdb_client import get_tmdb_client
task.progress["images"] = False from src.server.services.image_loading_service import (
return False 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: async def _scan_missing_episodes(self, task: SeriesLoadingTask, db: Any) -> None:
"""Scan for missing episodes after NFO creation. """Scan for missing episodes after NFO creation.

View File

@@ -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

View File

@@ -11,11 +11,12 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, timedelta, timezone 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.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from src.config.settings import settings
from src.server.models.config import SchedulerConfig from src.server.models.config import SchedulerConfig
from src.server.services.config_service import ConfigServiceError, get_config_service from src.server.services.config_service import ConfigServiceError, get_config_service
@@ -264,6 +265,9 @@ class SchedulerService:
"nfo_scan_after_rescan": ( "nfo_scan_after_rescan": (
self._config.nfo_scan_after_rescan if self._config else True 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": ( "last_run": (
self._last_scan_time.isoformat() self._last_scan_time.isoformat()
if self._last_scan_time if self._last_scan_time
@@ -409,6 +413,21 @@ class SchedulerService:
logger.error("Auto-download failed: %s", exc, exc_info=True) logger.error("Auto-download failed: %s", exc, exc_info=True)
await self._broadcast("auto_download_error", {"error": str(exc)}) 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) self._last_scan_time = datetime.now(timezone.utc)
duration = (self._last_scan_time - scan_start).total_seconds() duration = (self._last_scan_time - scan_start).total_seconds()
@@ -462,6 +481,61 @@ class SchedulerService:
) )
return result 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: async def _run_auto_download(self) -> int:
"""Queue and start downloads for all series with missing episodes.""" """Queue and start downloads for all series with missing episodes."""
from src.server.models.download import EpisodeIdentifier from src.server.models.download import EpisodeIdentifier

View File

@@ -512,14 +512,16 @@ class TestScanSeriesEpisodes:
class TestLoadNfoAndImages: 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 @pytest.mark.asyncio
async def test_load_nfo_creates_new_nfo(self, background_loader_service, mock_websocket_service): async def test_load_nfo_and_images_success(self, background_loader_service):
"""Test creating new NFO file - NFO service removed, stub returns False.""" """Test successful image loading via ImageLoadingService."""
mock_db = AsyncMock() mock_db = AsyncMock()
mock_series = MagicMock()
mock_series.has_nfo = False
task = SeriesLoadingTask( task = SeriesLoadingTask(
key="test", key="test",
@@ -528,51 +530,78 @@ class TestLoadNfoAndImages:
year=2020 year=2020
) )
# NFO service removed, _load_nfo_and_images is now a stub that returns False # Mock get_tmdb_client and init_image_loading_service at source
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_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 result is False
assert task.progress["nfo"] is False assert task.progress["nfo"] is True
assert task.progress["logo"] is False assert task.progress["logo"] is False
assert task.progress["images"] 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: class TestScanMissingEpisodes:
@@ -813,23 +842,21 @@ class TestErrorHandling:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_load_nfo_error_handling(self, background_loader_service): async def test_load_nfo_error_handling(self, background_loader_service):
"""Test error handling during NFO creation.""" """Test error handling when ImageLoadingService fails."""
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")
)
mock_db = AsyncMock() mock_db = AsyncMock()
task = SeriesLoadingTask( task = SeriesLoadingTask(
key="test", key="test",
folder="test_folder", folder="test_folder",
name="Test Series" 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 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["logo"] is False
assert task.progress["images"] is False assert task.progress["images"] is False