feat: Integrate NFO service with series management

- Created SeriesManagerService to orchestrate SerieList and NFOService
- Follows clean architecture (core entities stay independent)
- Supports auto-create and update-on-scan based on configuration
- Created CLI tool (src/cli/nfo_cli.py) for NFO management
- Commands: 'scan' (create/update NFOs) and 'status' (check NFO coverage)
- Batch processing with rate limiting to respect TMDB API limits
- Comprehensive error handling and logging

Usage:
  python -m src.cli.nfo_cli scan     # Create missing NFOs
  python -m src.cli.nfo_cli status   # Check NFO statistics
This commit is contained in:
2026-01-11 21:02:28 +01:00
parent 2f00c3feac
commit 36e663c556
2 changed files with 408 additions and 0 deletions

View File

@@ -0,0 +1,214 @@
"""Service for managing series with NFO metadata support.
This service layer component orchestrates SerieList (core entity) with
NFOService to provide automatic NFO creation and updates during series scans.
This follows clean architecture principles by keeping the core entities
independent of external services like TMDB API.
"""
import asyncio
import logging
from pathlib import Path
from typing import Optional
from src.config.settings import settings
from src.core.entities.SerieList import SerieList
from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBAPIError
logger = logging.getLogger(__name__)
class SeriesManagerService:
"""Service for managing series with optional NFO metadata support.
This service wraps SerieList and adds NFO creation/update capabilities
based on configuration settings. It maintains clean separation between
core entities and external services.
Attributes:
serie_list: SerieList instance for series management
nfo_service: Optional NFOService for metadata management
auto_create_nfo: Whether to auto-create NFO files
update_on_scan: Whether to update existing NFO files
"""
def __init__(
self,
anime_directory: str,
tmdb_api_key: Optional[str] = None,
auto_create_nfo: bool = False,
update_on_scan: bool = False,
download_poster: bool = True,
download_logo: bool = True,
download_fanart: bool = True,
image_size: str = "original"
):
"""Initialize series manager service.
Args:
anime_directory: Base directory for anime series
tmdb_api_key: TMDB API key (optional, required for NFO features)
auto_create_nfo: Automatically create NFO files when scanning
update_on_scan: Update existing NFO files when scanning
download_poster: Download poster.jpg
download_logo: Download logo.png
download_fanart: Download fanart.jpg
image_size: Image size to download
"""
self.anime_directory = anime_directory
self.serie_list = SerieList(anime_directory)
# NFO configuration
self.auto_create_nfo = auto_create_nfo
self.update_on_scan = update_on_scan
self.download_poster = download_poster
self.download_logo = download_logo
self.download_fanart = download_fanart
# Initialize NFO service if API key provided and NFO features enabled
self.nfo_service: Optional[NFOService] = None
if tmdb_api_key and (auto_create_nfo or update_on_scan):
self.nfo_service = NFOService(
tmdb_api_key=tmdb_api_key,
anime_directory=anime_directory,
image_size=image_size,
auto_create=auto_create_nfo
)
logger.info("NFO service initialized (auto_create=%s, update=%s)",
auto_create_nfo, update_on_scan)
elif auto_create_nfo or update_on_scan:
logger.warning(
"NFO features requested but TMDB_API_KEY not provided. "
"NFO creation/updates will be skipped."
)
@classmethod
def from_settings(cls) -> "SeriesManagerService":
"""Create SeriesManagerService from application settings.
Returns:
Configured SeriesManagerService instance
"""
return cls(
anime_directory=settings.anime_directory,
tmdb_api_key=settings.tmdb_api_key,
auto_create_nfo=settings.nfo_auto_create,
update_on_scan=settings.nfo_update_on_scan,
download_poster=settings.nfo_download_poster,
download_logo=settings.nfo_download_logo,
download_fanart=settings.nfo_download_fanart,
image_size=settings.nfo_image_size
)
async def process_nfo_for_series(self, serie_folder: str, serie_name: str, year: Optional[int] = None):
"""Process NFO file for a series (create or update).
Args:
serie_folder: Series folder name
serie_name: Series display name
year: Release year (helps with TMDB matching)
"""
if not self.nfo_service:
return
try:
folder_path = Path(self.anime_directory) / serie_folder
nfo_exists = await self.nfo_service.check_nfo_exists(serie_folder)
if not nfo_exists and self.auto_create_nfo:
logger.info(f"Creating NFO for '{serie_name}' ({serie_folder})")
await self.nfo_service.create_tvshow_nfo(
serie_name=serie_name,
serie_folder=serie_folder,
year=year,
download_poster=self.download_poster,
download_logo=self.download_logo,
download_fanart=self.download_fanart
)
logger.info(f"Successfully created NFO for '{serie_name}'")
elif nfo_exists and self.update_on_scan:
logger.info(f"Updating NFO for '{serie_name}' ({serie_folder})")
try:
await self.nfo_service.update_tvshow_nfo(
serie_folder=serie_folder,
download_media=True
)
logger.info(f"Successfully updated NFO for '{serie_name}'")
except NotImplementedError:
logger.warning(
f"NFO update not yet implemented for '{serie_name}'. "
"Delete tvshow.nfo to recreate."
)
except TMDBAPIError as e:
logger.error(f"TMDB API error processing '{serie_name}': {e}")
except Exception as e:
logger.error(
f"Unexpected error processing NFO for '{serie_name}': {e}",
exc_info=True
)
async def scan_and_process_nfo(self):
"""Scan all series and process NFO files based on configuration.
This method:
1. Uses SerieList to scan series folders
2. For each series without NFO (if auto_create=True), creates one
3. For each series with NFO (if update_on_scan=True), updates it
4. Runs operations concurrently for better performance
"""
if not self.nfo_service:
logger.info("NFO service not enabled, skipping NFO processing")
return
# Get all series from SerieList
all_series = self.serie_list.get_all()
if not all_series:
logger.info("No series found to process")
return
logger.info(f"Processing NFO for {len(all_series)} series...")
# Create tasks for concurrent processing
tasks = []
for serie in all_series:
# Extract year from first air date if available
year = None
if hasattr(serie, 'year') and serie.year:
year = serie.year
task = self.process_nfo_for_series(
serie_folder=serie.folder,
serie_name=serie.name,
year=year
)
tasks.append(task)
# Process in batches to avoid overwhelming TMDB API
batch_size = 5
for i in range(0, len(tasks), batch_size):
batch = tasks[i:i + batch_size]
await asyncio.gather(*batch, return_exceptions=True)
# Small delay between batches to respect rate limits
if i + batch_size < len(tasks):
await asyncio.sleep(2)
logger.info("NFO processing complete")
def get_serie_list(self) -> SerieList:
"""Get the underlying SerieList instance.
Returns:
SerieList instance
"""
return self.serie_list
async def close(self):
"""Clean up resources."""
if self.nfo_service:
await self.nfo_service.close()