structlog fails with BrokenPipeError when stdout is redirected (e.g., background processes, Docker logs). Replace all structlog.get_logger() calls with logging.getLogger() and convert keyword-style log calls to %-format strings. Also removes stale Docs/tasks.md (2028 lines) and updates Robot Framework tests to match current API behavior.
714 lines
24 KiB
Python
714 lines
24 KiB
Python
"""NFO scan service for validating and creating tvshow.nfo files.
|
|
|
|
This module provides a service layer for scanning the anime library,
|
|
checking whether each series has a valid tvshow.nfo file, creating
|
|
missing files, and filling in missing properties from TMDB metadata.
|
|
|
|
All series are identified by 'key' (provider-assigned, URL-safe
|
|
identifier). 'folder' is used as metadata only for filesystem paths.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
import structlog
|
|
|
|
from src.config.settings import settings
|
|
from src.server.nfo.nfo_generator import generate_tvshow_nfo
|
|
from src.server.nfo.nfo_mapper import tmdb_to_nfo_model
|
|
from src.server.nfo.nfo_models import TVShowNFO
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
class NfoScanServiceError(Exception):
|
|
"""Service-level exception for NFO scan operations."""
|
|
|
|
|
|
class NfoScanProgress:
|
|
"""Tracks the current state of an NFO scan operation.
|
|
|
|
Attributes:
|
|
scan_id: Unique identifier for this scan
|
|
status: Current status (started, in_progress, completed, failed, cancelled)
|
|
total: Total number of series to scan
|
|
current: Number of series processed
|
|
percentage: Completion percentage
|
|
message: Human-readable progress message
|
|
key: Current series key being processed (metadata only)
|
|
folder: Current series folder being processed (metadata only)
|
|
created: Number of NFO files created
|
|
updated: Number of NFO files updated
|
|
errors: List of error messages encountered
|
|
started_at: When the scan started
|
|
updated_at: When progress was last updated
|
|
"""
|
|
|
|
def __init__(self, scan_id: str):
|
|
self.scan_id = scan_id
|
|
self.status = "started"
|
|
self.total = 0
|
|
self.current = 0
|
|
self.percentage = 0.0
|
|
self.message = "Initializing NFO scan..."
|
|
self.key: Optional[str] = None
|
|
self.folder: Optional[str] = None
|
|
self.started_at = datetime.now(timezone.utc)
|
|
self.updated_at = datetime.now(timezone.utc)
|
|
self.created = 0
|
|
self.updated = 0
|
|
self.errors: List[str] = []
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
result = {
|
|
"scan_id": self.scan_id,
|
|
"status": self.status,
|
|
"total": self.total,
|
|
"current": self.current,
|
|
"percentage": round(self.percentage, 2),
|
|
"message": self.message,
|
|
"started_at": self.started_at.isoformat(),
|
|
"updated_at": self.updated_at.isoformat(),
|
|
"created": self.created,
|
|
"updated": self.updated,
|
|
"errors": self.errors,
|
|
}
|
|
if self.key is not None:
|
|
result["key"] = self.key
|
|
if self.folder is not None:
|
|
result["folder"] = self.folder
|
|
return result
|
|
|
|
|
|
class NfoScanService:
|
|
"""Manages NFO validation and creation for anime series.
|
|
|
|
Scans the anime library directory, checks each series folder for
|
|
a tvshow.nfo file, creates missing files, and fills in missing
|
|
or empty properties from TMDB metadata.
|
|
|
|
Uses 'key' as the primary series identifier and 'folder' as
|
|
metadata only for filesystem operations.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._current_scan: Optional[NfoScanProgress] = None
|
|
self._is_scanning = False
|
|
self._lock = asyncio.Lock()
|
|
|
|
# Event handlers for scan events
|
|
self._scan_event_handlers: List[Callable[[Dict[str, Any]], None]] = []
|
|
|
|
logger.info("NfoScanService initialized")
|
|
|
|
def subscribe_to_scan_events(
|
|
self,
|
|
handler: Callable[[Dict[str, Any]], None],
|
|
) -> None:
|
|
"""Subscribe to NFO scan events."""
|
|
self._scan_event_handlers.append(handler)
|
|
|
|
def unsubscribe_from_scan_events(
|
|
self,
|
|
handler: Callable[[Dict[str, Any]], None],
|
|
) -> None:
|
|
"""Unsubscribe from NFO scan events."""
|
|
try:
|
|
self._scan_event_handlers.remove(handler)
|
|
except ValueError:
|
|
logger.warning("Handler not found for unsubscribe")
|
|
|
|
async def _emit_scan_event(self, event_data: Dict[str, Any]) -> None:
|
|
"""Emit scan event to all subscribers."""
|
|
for handler in self._scan_event_handlers:
|
|
try:
|
|
if asyncio.iscoroutinefunction(handler):
|
|
await handler(event_data)
|
|
else:
|
|
handler(event_data)
|
|
except Exception as e:
|
|
logger.error("NFO scan event handler error error=%s", str(e))
|
|
|
|
@property
|
|
def is_scanning(self) -> bool:
|
|
return self._is_scanning
|
|
|
|
@property
|
|
def current_scan(self) -> Optional[NfoScanProgress]:
|
|
return self._current_scan
|
|
|
|
async def scan_all(
|
|
self,
|
|
anime_service: Any, # AnimeService instance
|
|
) -> Dict[str, Any]:
|
|
"""Run NFO validation and creation across all series.
|
|
|
|
Args:
|
|
anime_service: AnimeService instance for accessing series data.
|
|
|
|
Returns:
|
|
Summary dict with keys: total, created, updated, errors_count,
|
|
scan_id, and duration_seconds.
|
|
|
|
Raises:
|
|
NfoScanServiceError: If a scan is already in progress.
|
|
"""
|
|
async with self._lock:
|
|
if self._is_scanning:
|
|
raise NfoScanServiceError("An NFO scan is already in progress")
|
|
self._is_scanning = True
|
|
|
|
scan_id = f"nfo_scan_{id(self)}"
|
|
scan_progress = NfoScanProgress(scan_id)
|
|
self._current_scan = scan_progress
|
|
|
|
logger.info("Starting NFO scan")
|
|
|
|
# Emit scan started
|
|
await self._emit_scan_event({
|
|
"type": "nfo_scan_started",
|
|
"scan_id": scan_id,
|
|
"message": "NFO scan started",
|
|
})
|
|
|
|
# Get all series from AnimeService
|
|
try:
|
|
series_list = await anime_service.list_series_with_filters()
|
|
except Exception as exc:
|
|
logger.error("Failed to get series list: %s", exc)
|
|
async with self._lock:
|
|
self._is_scanning = False
|
|
raise NfoScanServiceError(f"Failed to get series list: {exc}") from exc
|
|
|
|
if not series_list:
|
|
logger.info("No series found — NFO scan complete")
|
|
scan_progress.status = "completed"
|
|
scan_progress.message = "No series found"
|
|
scan_progress.percentage = 100.0
|
|
scan_progress.updated_at = datetime.now(timezone.utc)
|
|
|
|
async with self._lock:
|
|
self._is_scanning = False
|
|
|
|
await self._emit_scan_event({
|
|
"type": "nfo_scan_completed",
|
|
"scan_id": scan_id,
|
|
"success": True,
|
|
"message": "No series found",
|
|
"data": scan_progress.to_dict(),
|
|
})
|
|
return {
|
|
"total": 0,
|
|
"created": 0,
|
|
"updated": 0,
|
|
"errors_count": 0,
|
|
"scan_id": scan_id,
|
|
"duration_seconds": 0.0,
|
|
}
|
|
|
|
scan_progress.total = len(series_list)
|
|
scan_progress.status = "in_progress"
|
|
scan_progress.message = f"Scanning {scan_progress.total} series..."
|
|
scan_progress.updated_at = datetime.now(timezone.utc)
|
|
|
|
start_time = datetime.now(timezone.utc)
|
|
errors: List[str] = []
|
|
|
|
for idx, series in enumerate(series_list):
|
|
key = series.get("key", "")
|
|
folder = series.get("folder", "")
|
|
name = series.get("name", "")
|
|
|
|
scan_progress.key = key
|
|
scan_progress.folder = folder
|
|
scan_progress.message = f"Scanning: {name}"
|
|
scan_progress.updated_at = datetime.now(timezone.utc)
|
|
|
|
await self._emit_scan_event({
|
|
"type": "nfo_scan_progress",
|
|
"data": scan_progress.to_dict(),
|
|
})
|
|
|
|
try:
|
|
result = await self._scan_series(key, folder, series)
|
|
if result == "created":
|
|
scan_progress.created += 1
|
|
elif result == "updated":
|
|
scan_progress.updated += 1
|
|
except Exception as exc:
|
|
error_msg = f"NFO scan failed for {key}: {exc}"
|
|
logger.warning(error_msg)
|
|
errors.append(error_msg)
|
|
scan_progress.errors.append(error_msg)
|
|
|
|
scan_progress.current = idx + 1
|
|
scan_progress.percentage = round(
|
|
(scan_progress.current / scan_progress.total) * 100, 2
|
|
)
|
|
scan_progress.updated_at = datetime.now(timezone.utc)
|
|
|
|
end_time = datetime.now(timezone.utc)
|
|
duration = (end_time - start_time).total_seconds()
|
|
scan_progress.status = "completed"
|
|
scan_progress.message = (
|
|
f"NFO scan completed: {scan_progress.created} created, "
|
|
f"{scan_progress.updated} updated, {len(errors)} errors"
|
|
)
|
|
scan_progress.percentage = 100.0
|
|
scan_progress.updated_at = end_time
|
|
|
|
async with self._lock:
|
|
self._is_scanning = False
|
|
|
|
logger.info(
|
|
"NFO scan completed: total=%d created=%d updated=%d errors=%d duration=%.2fs",
|
|
scan_progress.total,
|
|
scan_progress.created,
|
|
scan_progress.updated,
|
|
len(errors),
|
|
duration,
|
|
)
|
|
|
|
await self._emit_scan_event({
|
|
"type": "nfo_scan_completed",
|
|
"scan_id": scan_id,
|
|
"success": True,
|
|
"message": scan_progress.message,
|
|
"data": scan_progress.to_dict(),
|
|
"statistics": {
|
|
"total": scan_progress.total,
|
|
"created": scan_progress.created,
|
|
"updated": scan_progress.updated,
|
|
"errors_count": len(errors),
|
|
},
|
|
})
|
|
|
|
return {
|
|
"total": scan_progress.total,
|
|
"created": scan_progress.created,
|
|
"updated": scan_progress.updated,
|
|
"errors_count": len(errors),
|
|
"scan_id": scan_id,
|
|
"duration_seconds": round(duration, 2),
|
|
}
|
|
|
|
async def _scan_series(
|
|
self,
|
|
key: str,
|
|
folder: str,
|
|
series_data: Dict[str, Any],
|
|
) -> Optional[str]:
|
|
"""Scan and update NFO for a single series.
|
|
|
|
Args:
|
|
key: Series key (primary identifier)
|
|
folder: Series folder name (metadata for filesystem path)
|
|
series_data: Series data dict from anime_service
|
|
|
|
Returns:
|
|
"created" if new NFO was created, "updated" if existing was
|
|
modified, None if no change needed or error occurred.
|
|
"""
|
|
if not folder:
|
|
logger.debug("Skipping series with no folder: key=%s", key)
|
|
return None
|
|
|
|
anime_dir = getattr(settings, "anime_directory", None)
|
|
if not anime_dir:
|
|
logger.warning("anime_directory not configured — skipping NFO scan")
|
|
return None
|
|
|
|
series_path = os.path.join(anime_dir, folder)
|
|
nfo_path = os.path.join(series_path, "tvshow.nfo")
|
|
|
|
nfo_exists = os.path.isfile(nfo_path)
|
|
|
|
# If tmdb_id is missing, try to look it up by series name
|
|
if not series_data.get("tmdb_id"):
|
|
logger.debug("No tmdb_id for %s — attempting TMDB lookup", key)
|
|
name = series_data.get("name", "")
|
|
found_tmdb_id = await self._lookup_tmdb_id_by_name(name)
|
|
if found_tmdb_id:
|
|
series_data["tmdb_id"] = found_tmdb_id
|
|
await self._save_tmdb_id(key, found_tmdb_id)
|
|
logger.info("Found and saved tmdb_id %s for %s", found_tmdb_id, key)
|
|
else:
|
|
logger.warning(
|
|
"Could not resolve tmdb_id for %s (%s)",
|
|
key,
|
|
name,
|
|
)
|
|
|
|
if not nfo_exists:
|
|
# Create new NFO
|
|
logger.info("Creating NFO for series: %s (%s)", key, folder)
|
|
await self._create_nfo(key, folder, series_data, nfo_path)
|
|
await self._update_series_nfo_flag(key, has_nfo=True, nfo_path=nfo_path)
|
|
return "created"
|
|
|
|
# NFO exists — check if it needs updating
|
|
updated = await self._update_nfo_if_needed(key, folder, series_data, nfo_path)
|
|
if updated:
|
|
await self._update_series_nfo_flag(key, has_nfo=True, nfo_path=nfo_path)
|
|
return "updated"
|
|
|
|
return None
|
|
|
|
async def _create_nfo(
|
|
self,
|
|
key: str,
|
|
folder: str,
|
|
series_data: Dict[str, Any],
|
|
nfo_path: str,
|
|
) -> None:
|
|
"""Create a new tvshow.nfo file from TMDB metadata.
|
|
|
|
Args:
|
|
key: Series key
|
|
folder: Series folder name
|
|
series_data: Series data from anime_service
|
|
nfo_path: Full path to the NFO file to create
|
|
"""
|
|
tmdb_id = series_data.get("tmdb_id")
|
|
|
|
if not tmdb_id:
|
|
logger.warning(
|
|
"Cannot create NFO for %s: no tmdb_id available",
|
|
key,
|
|
)
|
|
return
|
|
|
|
try:
|
|
tmdb_data = await self._fetch_tmdb_data(tmdb_id)
|
|
except Exception as exc:
|
|
logger.warning("Failed to fetch TMDB data for %s: %s", key, exc)
|
|
return
|
|
|
|
if not tmdb_data:
|
|
logger.warning("No TMDB data for %s", key)
|
|
return
|
|
|
|
nfo_model = tmdb_to_nfo_model(
|
|
tmdb_data,
|
|
content_ratings=None,
|
|
get_image_url=self._make_tmdb_image_url(tmdb_id),
|
|
image_size="original",
|
|
)
|
|
|
|
xml_content = generate_tvshow_nfo(nfo_model)
|
|
|
|
# Ensure directory exists
|
|
os.makedirs(os.path.dirname(nfo_path), exist_ok=True)
|
|
|
|
with open(nfo_path, "w", encoding="utf-8") as f:
|
|
f.write(xml_content)
|
|
|
|
logger.info("Created tvshow.nfo for %s at %s", key, nfo_path)
|
|
|
|
await self._emit_scan_event({
|
|
"type": "nfo_created",
|
|
"key": key,
|
|
"folder": folder,
|
|
"path": nfo_path,
|
|
})
|
|
|
|
async def _update_nfo_if_needed(
|
|
self,
|
|
key: str,
|
|
folder: str,
|
|
series_data: Dict[str, Any],
|
|
nfo_path: str,
|
|
) -> bool:
|
|
"""Load existing NFO, check for missing fields, fill and rewrite.
|
|
|
|
Args:
|
|
key: Series key
|
|
folder: Series folder name
|
|
series_data: Series data from anime_service
|
|
nfo_path: Full path to the existing NFO file
|
|
|
|
Returns:
|
|
True if NFO was updated, False if no changes were needed.
|
|
"""
|
|
try:
|
|
from lxml import etree
|
|
except ImportError:
|
|
logger.warning("lxml not available — cannot update existing NFO files")
|
|
return False
|
|
|
|
try:
|
|
tree = etree.parse(nfo_path)
|
|
root = tree.getroot()
|
|
except Exception as exc:
|
|
logger.warning("Failed to parse existing NFO for %s: %s — will regenerate", key, exc)
|
|
# Corrupt or unreadable NFO — regenerate from TMDB
|
|
return await self._regenerate_nfo(key, folder, series_data, nfo_path)
|
|
|
|
# Check for missing or empty critical fields
|
|
critical_fields = ["title", "plot", "premiered", "tmdbid"]
|
|
missing_fields: List[str] = []
|
|
|
|
for field in critical_fields:
|
|
elem = root.find(field)
|
|
if elem is None or not elem.text or elem.text.strip() == "":
|
|
missing_fields.append(field)
|
|
|
|
if not missing_fields:
|
|
logger.debug("NFO for %s is complete — no update needed", key)
|
|
return False
|
|
|
|
logger.info(
|
|
"NFO for %s is missing fields %s — attempting to fill from TMDB",
|
|
key,
|
|
missing_fields,
|
|
)
|
|
|
|
# Try to fill missing fields from TMDB
|
|
tmdb_id = series_data.get("tmdb_id")
|
|
if not tmdb_id:
|
|
logger.warning("Cannot update NFO for %s: no tmdb_id", key)
|
|
return False
|
|
|
|
try:
|
|
tmdb_data = await self._fetch_tmdb_data(tmdb_id)
|
|
except Exception as exc:
|
|
logger.warning("Failed to fetch TMDB data for %s: %s", key, exc)
|
|
return False
|
|
|
|
if not tmdb_data:
|
|
return False
|
|
|
|
nfo_model = tmdb_to_nfo_model(
|
|
tmdb_data,
|
|
content_ratings=None,
|
|
get_image_url=self._make_tmdb_image_url(tmdb_id),
|
|
image_size="original",
|
|
)
|
|
|
|
# Serialize updated model to XML and write
|
|
xml_content = generate_tvshow_nfo(nfo_model)
|
|
|
|
with open(nfo_path, "w", encoding="utf-8") as f:
|
|
f.write(xml_content)
|
|
|
|
logger.info("Updated NFO for %s (filled %d fields)", key, len(missing_fields))
|
|
|
|
await self._emit_scan_event({
|
|
"type": "nfo_updated",
|
|
"key": key,
|
|
"folder": folder,
|
|
"path": nfo_path,
|
|
"missing_fields": missing_fields,
|
|
})
|
|
|
|
return True
|
|
|
|
async def _regenerate_nfo(
|
|
self,
|
|
key: str,
|
|
folder: str,
|
|
series_data: Dict[str, Any],
|
|
nfo_path: str,
|
|
) -> bool:
|
|
"""Regenerate NFO from scratch when existing file is corrupt."""
|
|
tmdb_id = series_data.get("tmdb_id")
|
|
if not tmdb_id:
|
|
return False
|
|
|
|
try:
|
|
tmdb_data = await self._fetch_tmdb_data(tmdb_id)
|
|
except Exception as exc:
|
|
logger.warning("Failed to fetch TMDB data for %s during regeneration: %s", key, exc)
|
|
return False
|
|
|
|
if not tmdb_data:
|
|
return False
|
|
|
|
nfo_model = tmdb_to_nfo_model(
|
|
tmdb_data,
|
|
content_ratings=None,
|
|
get_image_url=self._make_tmdb_image_url(tmdb_id),
|
|
image_size="original",
|
|
)
|
|
|
|
xml_content = generate_tvshow_nfo(nfo_model)
|
|
|
|
with open(nfo_path, "w", encoding="utf-8") as f:
|
|
f.write(xml_content)
|
|
|
|
logger.info("Regenerated NFO for %s", key)
|
|
return True
|
|
|
|
async def _save_tmdb_id(self, key: str, tmdb_id: int) -> None:
|
|
"""Save tmdb_id to the database for a series.
|
|
|
|
Args:
|
|
key: Series key (primary identifier)
|
|
tmdb_id: TMDB series ID to save
|
|
"""
|
|
try:
|
|
from src.server.database.connection import get_db_session
|
|
from src.server.database.service import AnimeSeriesService
|
|
|
|
async with get_db_session() as db:
|
|
series = await AnimeSeriesService.get_by_key(db, key)
|
|
if series:
|
|
series.tmdb_id = tmdb_id
|
|
await db.flush()
|
|
logger.debug("Saved tmdb_id %s for series: %s", tmdb_id, key)
|
|
else:
|
|
logger.warning("Series not found for tmdb_id save: %s", key)
|
|
except Exception as exc:
|
|
logger.warning("Failed to save tmdb_id for %s: %s", key, exc)
|
|
|
|
async def _lookup_tmdb_id_by_name(self, name: str) -> Optional[int]:
|
|
"""Look up a TMDB series ID by series name.
|
|
|
|
Args:
|
|
name: Series name to search for
|
|
|
|
Returns:
|
|
TMDB series ID or None if not found.
|
|
"""
|
|
if not name:
|
|
return None
|
|
|
|
try:
|
|
from src.server.nfo.tmdb_client import get_tmdb_client
|
|
|
|
async with get_tmdb_client() as client:
|
|
results = await client.search_tv_show(name)
|
|
if results and results.get("results"):
|
|
first_result = results["results"][0]
|
|
return first_result.get("id")
|
|
return None
|
|
except Exception as exc:
|
|
logger.warning("TMDB lookup failed for %s: %s", name, exc)
|
|
return None
|
|
|
|
async def _fetch_tmdb_data(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
|
"""Fetch series metadata from TMDB API.
|
|
|
|
Args:
|
|
tmdb_id: TMDB series ID
|
|
|
|
Returns:
|
|
TMDB response dict or None on failure.
|
|
"""
|
|
try:
|
|
from src.server.nfo.tmdb_client import get_tmdb_client
|
|
|
|
async with get_tmdb_client() as client:
|
|
data = await client.get_tv_show_details(tmdb_id)
|
|
return data
|
|
except Exception as exc:
|
|
logger.warning("TMDB fetch failed for TMDB ID %s: %s", tmdb_id, exc)
|
|
return None
|
|
|
|
def _make_tmdb_image_url(self, tmdb_id: int) -> Callable[[str, str], str]:
|
|
"""Create a get_image_url closure bound to a TMDB account."""
|
|
from src.server.nfo.tmdb_client import get_tmdb_image_base_url
|
|
|
|
base = get_tmdb_image_base_url(tmdb_id)
|
|
|
|
def get_image_url(path: str, size: str = "original") -> str:
|
|
if not path:
|
|
return ""
|
|
return f"{base}{size}{path}"
|
|
|
|
return get_image_url
|
|
|
|
async def _update_series_nfo_flag(
|
|
self,
|
|
key: str,
|
|
has_nfo: bool,
|
|
nfo_path: str,
|
|
) -> None:
|
|
"""Update the has_nfo flag and nfo_path in the database.
|
|
|
|
Args:
|
|
key: Series key (primary identifier)
|
|
has_nfo: Whether the series now has an NFO file
|
|
nfo_path: Path to the NFO file
|
|
"""
|
|
try:
|
|
from src.server.database.connection import get_db_session
|
|
from src.server.database.service import AnimeSeriesService
|
|
|
|
async with get_db_session() as db:
|
|
series = await AnimeSeriesService.get_by_key(db, key)
|
|
if series:
|
|
now = datetime.now(timezone.utc)
|
|
series.has_nfo = has_nfo
|
|
series.nfo_path = nfo_path
|
|
if series.nfo_created_at is None:
|
|
series.nfo_created_at = now
|
|
series.nfo_updated_at = now
|
|
await db.flush()
|
|
logger.debug("Updated NFO flag for series: %s", key)
|
|
except Exception as exc:
|
|
logger.warning("Failed to update NFO flag for %s: %s", key, exc)
|
|
|
|
async def cancel_scan(self) -> bool:
|
|
"""Cancel the current NFO scan if one is in progress.
|
|
|
|
Returns:
|
|
True if scan was cancelled, False if no scan in progress.
|
|
"""
|
|
async with self._lock:
|
|
if not self._is_scanning:
|
|
return False
|
|
|
|
self._is_scanning = False
|
|
|
|
if self._current_scan:
|
|
self._current_scan.status = "cancelled"
|
|
self._current_scan.message = "NFO scan cancelled by user"
|
|
self._current_scan.updated_at = datetime.now(timezone.utc)
|
|
|
|
if self._current_scan:
|
|
await self._emit_scan_event({
|
|
"type": "nfo_scan_cancelled",
|
|
"scan_id": self._current_scan.scan_id,
|
|
"message": "NFO scan cancelled by user",
|
|
})
|
|
|
|
logger.info("NFO scan cancelled")
|
|
return True
|
|
|
|
async def get_scan_status(self) -> Dict[str, Any]:
|
|
"""Get the current NFO scan status.
|
|
|
|
Returns:
|
|
Dict with is_scanning and current_scan data.
|
|
"""
|
|
return {
|
|
"is_scanning": self._is_scanning,
|
|
"current_scan": (
|
|
self._current_scan.to_dict() if self._current_scan else None
|
|
),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module-level singleton
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_nfo_scan_service: Optional[NfoScanService] = None
|
|
|
|
|
|
def get_nfo_scan_service() -> NfoScanService:
|
|
"""Return the singleton NfoScanService instance."""
|
|
global _nfo_scan_service
|
|
if _nfo_scan_service is None:
|
|
_nfo_scan_service = NfoScanService()
|
|
return _nfo_scan_service
|
|
|
|
|
|
def reset_nfo_scan_service() -> None:
|
|
"""Reset the singleton NfoScanService instance (for testing)."""
|
|
global _nfo_scan_service
|
|
_nfo_scan_service = None |