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

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