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")
|
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):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -498,23 +498,54 @@ 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.
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
@@ -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,21 +530,62 @@ 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
|
||||||
|
|
||||||
# Stub returns False since NFO service was removed
|
with patch("src.server.services.image_loading_service.ImageLoadingService") as mock_service_class:
|
||||||
assert result is False
|
mock_service = MagicMock()
|
||||||
assert task.progress["nfo"] is False
|
mock_service.load_series_images = AsyncMock(return_value={
|
||||||
assert task.progress["logo"] is False
|
"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
|
assert task.progress["images"] is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_load_nfo_uses_existing(self, background_loader_service):
|
async def test_load_nfo_and_images_service_error(self, background_loader_service):
|
||||||
"""Test using existing NFO file - NFO service removed, stub returns False."""
|
"""Test graceful handling when ImageLoadingService throws exception."""
|
||||||
mock_db = AsyncMock()
|
mock_db = AsyncMock()
|
||||||
mock_series = MagicMock()
|
|
||||||
mock_series.has_nfo = True
|
|
||||||
|
|
||||||
task = SeriesLoadingTask(
|
task = SeriesLoadingTask(
|
||||||
key="test",
|
key="test",
|
||||||
@@ -550,29 +593,15 @@ class TestLoadNfoAndImages:
|
|||||||
name="Test Series"
|
name="Test Series"
|
||||||
)
|
)
|
||||||
|
|
||||||
# NFO service removed, _load_nfo_and_images is now a stub that returns False
|
with patch("src.server.nfo.tmdb_client.get_tmdb_client") as mock_get_tmdb:
|
||||||
result = await background_loader_service._load_nfo_and_images(task, mock_db)
|
mock_get_tmdb.side_effect = Exception("TMDB API error")
|
||||||
|
|
||||||
# Stub returns False since NFO service was removed
|
result = await background_loader_service._load_nfo_and_images(task, mock_db)
|
||||||
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 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
|
||||||
|
|
||||||
|
|
||||||
class TestScanMissingEpisodes:
|
class TestScanMissingEpisodes:
|
||||||
@@ -813,12 +842,7 @@ 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",
|
||||||
@@ -826,10 +850,13 @@ class TestErrorHandling:
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user