From 1712dfd7768e456567e68c398f76168f63c420ed Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 22 Feb 2026 17:06:21 +0100 Subject: [PATCH] fix: isolate NFO repair sessions to prevent connector-closed errors --- src/server/services/initialization_service.py | 69 ++++++++++++++----- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/src/server/services/initialization_service.py b/src/server/services/initialization_service.py index bf04be5..8aa136e 100644 --- a/src/server/services/initialization_service.py +++ b/src/server/services/initialization_service.py @@ -377,24 +377,65 @@ async def perform_nfo_scan_if_needed(progress_service=None): ) +_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3) + + +async def _repair_one_series(series_dir: Path, series_name: str) -> None: + """Repair a single series NFO in isolation. + + Creates a fresh :class:`NFOService` and :class:`NfoRepairService` per + invocation so that each repair owns its own ``aiohttp`` session/connector + and concurrent tasks cannot interfere with each other. + + A module-level semaphore (``_NFO_REPAIR_SEMAPHORE``) limits the number of + simultaneous TMDB requests to avoid rate-limiting. + + Any exception is caught and logged so the asyncio task never silently + drops an unhandled error. + + Args: + series_dir: Absolute path to the series folder. + series_name: Human-readable series name for log messages. + """ + from src.core.services.nfo_factory import NFOServiceFactory + from src.core.services.nfo_repair_service import NfoRepairService + + async with _NFO_REPAIR_SEMAPHORE: + try: + factory = NFOServiceFactory() + nfo_service = factory.create() + repair_service = NfoRepairService(nfo_service) + await repair_service.repair_series(series_dir, series_name) + except Exception as exc: # pylint: disable=broad-except + logger.error( + "NFO repair failed for %s: %s", + series_name, + exc, + ) + + async def perform_nfo_repair_scan(background_loader=None) -> None: """Scan all series folders and repair incomplete tvshow.nfo files. Runs on every application startup (not guarded by a run-once DB flag). Checks each subfolder of ``settings.anime_directory`` for a ``tvshow.nfo`` - and calls ``NfoRepairService.repair_series`` for every file with absent or - empty required tags. Repairs are fired as independent asyncio tasks so - they do not block the startup sequence. + and calls ``_repair_one_series`` for every file with absent or empty + required tags. + + Each repair task creates its own isolated :class:`NFOService` / + :class:`TMDBClient` so concurrent tasks never share an ``aiohttp`` + session — this prevents "Connector is closed" errors when many repairs + run in parallel. A semaphore caps TMDB concurrency at 3 to stay within + rate limits. The ``background_loader`` parameter is accepted for backwards-compatibility - but is no longer used — repairs always go through ``update_tvshow_nfo`` so - that the TMDB overview (plot) and all other required tags are written. + but is no longer used. Args: background_loader: Unused. Kept to avoid breaking call-sites. """ - from src.core.services.nfo_factory import NFOServiceFactory - from src.core.services.nfo_repair_service import NfoRepairService, nfo_needs_repair + from src.core.services.nfo_repair_service import nfo_needs_repair + if not settings.tmdb_api_key: logger.warning("NFO repair scan skipped — TMDB API key not configured") return @@ -405,13 +446,7 @@ async def perform_nfo_repair_scan(background_loader=None) -> None: if not anime_dir.is_dir(): logger.warning("NFO repair scan skipped — anime directory not found: %s", anime_dir) return - try: - factory = NFOServiceFactory() - nfo_service = factory.create() - except ValueError as exc: - logger.warning("NFO repair scan skipped — cannot create NFOService: %s", exc) - return - repair_service = NfoRepairService(nfo_service) + queued = 0 total = 0 for series_dir in sorted(anime_dir.iterdir()): @@ -424,11 +459,9 @@ async def perform_nfo_repair_scan(background_loader=None) -> None: series_name = series_dir.name if nfo_needs_repair(nfo_path): queued += 1 - # Always repair via update_tvshow_nfo so that missing fields such - # as `plot` are fetched from TMDB. Fire as an asyncio task to - # avoid blocking the startup loop. + # Each task creates its own NFOService so connectors are isolated. asyncio.create_task( - repair_service.repair_series(series_dir, series_name), + _repair_one_series(series_dir, series_name), name=f"nfo_repair:{series_name}", )