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:
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
387
src/server/services/image_loading_service.py
Normal file
387
src/server/services/image_loading_service.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user