diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 543a6d9..8ae8ac5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -74,7 +74,7 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and `NfoRepairService.repair_series()`. 13 required tags are checked. - **`perform_nfo_repair_scan()` - (`src/server/services/initialization_service.py`)**: New async function + (`src/server/services/folder_scan_service.py`)**: New async function that iterates every series directory, checks whether `tvshow.nfo` is missing required tags using `nfo_needs_repair()`, and queues the series for background reload via `asyncio.create_task`. Skips gracefully when `tmdb_api_key` or diff --git a/docs/NFO_GUIDE.md b/docs/NFO_GUIDE.md index 18f522f..e04661c 100644 --- a/docs/NFO_GUIDE.md +++ b/docs/NFO_GUIDE.md @@ -815,8 +815,7 @@ This calls `NFOService.update_tvshow_nfo()` directly and overwrites the existing | File | Purpose | | ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | | `src/core/services/nfo_repair_service.py` | `REQUIRED_TAGS`, `parse_nfo_tags`, `find_missing_tags`, `nfo_needs_repair`, `NfoRepairService` | -| `src/server/services/initialization_service.py` | `perform_nfo_repair_scan` — invoked from `FolderScanService` | -| `src/server/services/folder_scan_service.py` | Calls `perform_nfo_repair_scan` during the scheduled daily folder scan | +| `src/server/services/folder_scan_service.py` | `perform_nfo_repair_scan` — invoked during the scheduled daily folder scan | --- diff --git a/src/server/services/folder_scan_service.py b/src/server/services/folder_scan_service.py index 78d709e..4268d54 100644 --- a/src/server/services/folder_scan_service.py +++ b/src/server/services/folder_scan_service.py @@ -13,8 +13,8 @@ from typing import Optional import structlog from lxml import etree +from src.config.settings import settings as _settings from src.core.utils.image_downloader import ImageDownloader -from src.server.services.initialization_service import perform_nfo_repair_scan logger = structlog.get_logger(__name__) @@ -24,6 +24,101 @@ _TMDB_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3) # Semaphore to limit concurrent poster image downloads to 3. _POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3) +# Semaphore to limit concurrent NFO repair TMDB operations to 3. +_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. + + Called from ``FolderScanService.run_folder_scan()`` during the scheduled + daily folder scan (not on every startup). Checks each subfolder of + ``settings.anime_directory`` for a ``tvshow.nfo`` 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. + + Args: + background_loader: Unused. Kept to avoid breaking call-sites. + """ + 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 + if not _settings.anime_directory: + logger.warning("NFO repair scan skipped — anime directory not configured") + return + anime_dir = Path(_settings.anime_directory) + if not anime_dir.is_dir(): + logger.warning("NFO repair scan skipped — anime directory not found: %s", anime_dir) + return + + queued = 0 + total = 0 + for series_dir in sorted(anime_dir.iterdir()): + if not series_dir.is_dir(): + continue + nfo_path = series_dir / "tvshow.nfo" + if not nfo_path.exists(): + continue + total += 1 + series_name = series_dir.name + if nfo_needs_repair(nfo_path): + queued += 1 + # Each task creates its own NFOService so connectors are isolated. + asyncio.create_task( + _repair_one_series(series_dir, series_name), + name=f"nfo_repair:{series_name}", + ) + + logger.info( + "NFO repair scan complete: %d of %d series queued for repair", + queued, + total, + ) + class FolderScanServiceError(Exception): """Service-level exception for folder-scan operations.""" diff --git a/src/server/services/initialization_service.py b/src/server/services/initialization_service.py index 0a75781..ee97988 100644 --- a/src/server/services/initialization_service.py +++ b/src/server/services/initialization_service.py @@ -377,101 +377,6 @@ 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. - - Called from ``FolderScanService.run_folder_scan()`` during the scheduled - daily folder scan (not on every startup). Checks each subfolder of - ``settings.anime_directory`` for a ``tvshow.nfo`` 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. - - Args: - background_loader: Unused. Kept to avoid breaking call-sites. - """ - 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 - if not settings.anime_directory: - logger.warning("NFO repair scan skipped — anime directory not configured") - return - anime_dir = Path(settings.anime_directory) - if not anime_dir.is_dir(): - logger.warning("NFO repair scan skipped — anime directory not found: %s", anime_dir) - return - - queued = 0 - total = 0 - for series_dir in sorted(anime_dir.iterdir()): - if not series_dir.is_dir(): - continue - nfo_path = series_dir / "tvshow.nfo" - if not nfo_path.exists(): - continue - total += 1 - series_name = series_dir.name - if nfo_needs_repair(nfo_path): - queued += 1 - # Each task creates its own NFOService so connectors are isolated. - asyncio.create_task( - _repair_one_series(series_dir, series_name), - name=f"nfo_repair:{series_name}", - ) - - logger.info( - "NFO repair scan complete: %d of %d series queued for repair", - queued, - total, - ) - - async def _check_media_scan_status() -> bool: """Check if initial media scan has been completed. diff --git a/tests/integration/test_nfo_repair_startup.py b/tests/integration/test_nfo_repair_startup.py index 08f970b..52de91f 100644 --- a/tests/integration/test_nfo_repair_startup.py +++ b/tests/integration/test_nfo_repair_startup.py @@ -67,7 +67,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader: @pytest.mark.asyncio async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path): """Series whose tvshow.nfo is missing required tags are scheduled via asyncio.create_task.""" - from src.server.services.initialization_service import perform_nfo_repair_scan + from src.server.services.folder_scan_service import perform_nfo_repair_scan series_dir = tmp_path / "IncompleteAnime" series_dir.mkdir() @@ -83,7 +83,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader: mock_repair_service.repair_series = AsyncMock(return_value=True) with patch( - "src.server.services.initialization_service.settings", mock_settings + "src.server.services.folder_scan_service._settings", mock_settings ), patch( "src.core.services.nfo_repair_service.nfo_needs_repair", return_value=True, @@ -103,7 +103,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader: @pytest.mark.asyncio async def test_complete_nfo_series_not_scheduled(self, tmp_path): """Series whose tvshow.nfo has all required tags are not scheduled for repair.""" - from src.server.services.initialization_service import perform_nfo_repair_scan + from src.server.services.folder_scan_service import perform_nfo_repair_scan series_dir = tmp_path / "CompleteAnime" series_dir.mkdir() @@ -116,7 +116,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader: mock_settings.anime_directory = str(tmp_path) with patch( - "src.server.services.initialization_service.settings", mock_settings + "src.server.services.folder_scan_service._settings", mock_settings ), patch( "src.core.services.nfo_repair_service.nfo_needs_repair", return_value=False, diff --git a/tests/unit/test_folder_scan_service.py b/tests/unit/test_folder_scan_service.py index a22ce4d..e691404 100644 --- a/tests/unit/test_folder_scan_service.py +++ b/tests/unit/test_folder_scan_service.py @@ -20,6 +20,7 @@ from src.server.services.folder_scan_service import ( _TMDB_SEMAPHORE, FolderScanService, FolderScanServiceError, + perform_nfo_repair_scan, ) # --------------------------------------------------------------------------- diff --git a/tests/unit/test_initialization_service.py b/tests/unit/test_initialization_service.py index 6968800..a30689d 100644 --- a/tests/unit/test_initialization_service.py +++ b/tests/unit/test_initialization_service.py @@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest +from src.server.services.folder_scan_service import perform_nfo_repair_scan from src.server.services.initialization_service import ( _check_initial_scan_status, _check_media_scan_status, @@ -27,7 +28,6 @@ from src.server.services.initialization_service import ( _validate_anime_directory, perform_initial_setup, perform_media_scan_if_needed, - perform_nfo_repair_scan, perform_nfo_scan_if_needed, ) @@ -771,7 +771,7 @@ class TestPerformNfoRepairScan: mock_settings.anime_directory = str(tmp_path) with patch( - "src.server.services.initialization_service.settings", mock_settings + "src.server.services.folder_scan_service._settings", mock_settings ): await perform_nfo_repair_scan() @@ -785,7 +785,7 @@ class TestPerformNfoRepairScan: mock_settings.anime_directory = "" with patch( - "src.server.services.initialization_service.settings", mock_settings + "src.server.services.folder_scan_service._settings", mock_settings ): await perform_nfo_repair_scan() @@ -805,7 +805,7 @@ class TestPerformNfoRepairScan: mock_repair_service.repair_series = AsyncMock(return_value=True) with patch( - "src.server.services.initialization_service.settings", mock_settings + "src.server.services.folder_scan_service._settings", mock_settings ), patch( "src.core.services.nfo_repair_service.nfo_needs_repair", return_value=True, @@ -835,7 +835,7 @@ class TestPerformNfoRepairScan: mock_settings.anime_directory = str(tmp_path) with patch( - "src.server.services.initialization_service.settings", mock_settings + "src.server.services.folder_scan_service._settings", mock_settings ), patch( "src.core.services.nfo_repair_service.nfo_needs_repair", return_value=False, @@ -865,7 +865,7 @@ class TestPerformNfoRepairScan: mock_repair_service.repair_series = AsyncMock(return_value=True) with patch( - "src.server.services.initialization_service.settings", mock_settings + "src.server.services.folder_scan_service._settings", mock_settings ), patch( "src.core.services.nfo_repair_service.nfo_needs_repair", return_value=True,