From d71feb64ddb3b535eee2ed7b921d384d1fd52d6e Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 22 Feb 2026 11:16:25 +0100 Subject: [PATCH] feat: add perform_nfo_repair_scan startup hook --- docs/CHANGELOG.md | 7 + src/server/services/initialization_service.py | 63 ++++++++- tests/unit/test_initialization_service.py | 121 ++++++++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bf9a506..d9b1b74 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -55,6 +55,13 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle that detects incomplete `tvshow.nfo` files and triggers TMDB re-fetch. Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and `NfoRepairService.repair_series()`. 13 required tags are checked. +- **`perform_nfo_repair_scan()` startup hook + (`src/server/services/initialization_service.py`)**: New async function + called during application startup. Iterates every series directory, checks + whether `tvshow.nfo` is missing required tags using `nfo_needs_repair()`, and + either queues the series for background reload (when a `background_loader` is + provided) or calls `NfoRepairService.repair_series()` directly. Skips + gracefully when `tmdb_api_key` or `anime_directory` is not configured. ### Changed diff --git a/src/server/services/initialization_service.py b/src/server/services/initialization_service.py index cd16362..deaf532 100644 --- a/src/server/services/initialization_service.py +++ b/src/server/services/initialization_service.py @@ -1,5 +1,6 @@ """Centralized initialization service for application startup and setup.""" -from typing import Callable +from pathlib import Path +from typing import Callable, Optional import structlog @@ -375,6 +376,66 @@ async def perform_nfo_scan_if_needed(progress_service=None): ) +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 queues a repair via *background_loader* when required tags are absent + or empty, or runs repairs inline when no loader is provided. + + Args: + background_loader: Optional BackgroundLoaderService. When provided, + deficient series are queued non-blocking. When None, repairs + execute inline (useful in tests). + """ + from src.core.services.nfo_factory import NFOServiceFactory + from src.core.services.nfo_repair_service import NfoRepairService, 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 + 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()): + 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 + if background_loader is not None: + await background_loader.add_series_loading_task( + key=series_name, + folder=series_name, + name=series_name, + ) + else: + await repair_service.repair_series(series_dir, 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/unit/test_initialization_service.py b/tests/unit/test_initialization_service.py index 3e4f359..6d82adc 100644 --- a/tests/unit/test_initialization_service.py +++ b/tests/unit/test_initialization_service.py @@ -28,6 +28,7 @@ from src.server.services.initialization_service import ( perform_initial_setup, perform_media_scan_if_needed, perform_nfo_scan_if_needed, + perform_nfo_repair_scan, ) @@ -757,3 +758,123 @@ class TestInitializationIntegration: result2 = await perform_initial_setup() assert result2 is False + + +class TestPerformNfoRepairScan: + """Tests for the perform_nfo_repair_scan startup hook.""" + + @pytest.mark.asyncio + async def test_skips_without_tmdb_api_key(self, tmp_path): + """Should return immediately when no TMDB API key is configured.""" + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "" + mock_settings.anime_directory = str(tmp_path) + + with patch( + "src.server.services.initialization_service.settings", mock_settings + ): + await perform_nfo_repair_scan() + + # No exception means guard worked — nothing was iterated + + @pytest.mark.asyncio + async def test_skips_without_anime_directory(self, tmp_path): + """Should return immediately when no anime directory is configured.""" + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "some-key" + mock_settings.anime_directory = "" + + with patch( + "src.server.services.initialization_service.settings", mock_settings + ): + await perform_nfo_repair_scan() + + @pytest.mark.asyncio + async def test_queues_deficient_series_via_background_loader(self, tmp_path): + """Series with incomplete NFO should be queued via background_loader.""" + # Create a fake series directory with a tvshow.nfo file + series_dir = tmp_path / "MyAnime" + series_dir.mkdir() + nfo_file = series_dir / "tvshow.nfo" + nfo_file.write_text("MyAnime") + + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "test-key" + mock_settings.anime_directory = str(tmp_path) + + mock_background_loader = AsyncMock() + mock_background_loader.add_series_loading_task = AsyncMock() + + with patch( + "src.server.services.initialization_service.settings", mock_settings + ), patch( + "src.core.services.nfo_repair_service.nfo_needs_repair", + return_value=True, + ), patch( + "src.core.services.nfo_factory.NFOServiceFactory" + ) as mock_factory_cls: + mock_factory_cls.return_value.create.return_value = MagicMock() + await perform_nfo_repair_scan(background_loader=mock_background_loader) + + mock_background_loader.add_series_loading_task.assert_called_once_with( + key="MyAnime", folder="MyAnime", name="MyAnime" + ) + + @pytest.mark.asyncio + async def test_skips_complete_series(self, tmp_path): + """Series with complete NFO should not be queued or repaired.""" + series_dir = tmp_path / "CompleteAnime" + series_dir.mkdir() + nfo_file = series_dir / "tvshow.nfo" + nfo_file.write_text("CompleteAnime") + + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "test-key" + mock_settings.anime_directory = str(tmp_path) + + mock_background_loader = AsyncMock() + mock_background_loader.add_series_loading_task = AsyncMock() + + with patch( + "src.server.services.initialization_service.settings", mock_settings + ), patch( + "src.core.services.nfo_repair_service.nfo_needs_repair", + return_value=False, + ), patch( + "src.core.services.nfo_factory.NFOServiceFactory" + ) as mock_factory_cls: + mock_factory_cls.return_value.create.return_value = MagicMock() + await perform_nfo_repair_scan(background_loader=mock_background_loader) + + mock_background_loader.add_series_loading_task.assert_not_called() + + @pytest.mark.asyncio + async def test_repairs_directly_without_background_loader(self, tmp_path): + """When no background_loader provided, repair_series is called directly.""" + series_dir = tmp_path / "NeedsRepair" + series_dir.mkdir() + nfo_file = series_dir / "tvshow.nfo" + nfo_file.write_text("NeedsRepair") + + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "test-key" + mock_settings.anime_directory = str(tmp_path) + + mock_repair_service = AsyncMock() + mock_repair_service.repair_series = AsyncMock(return_value=True) + + with patch( + "src.server.services.initialization_service.settings", mock_settings + ), patch( + "src.core.services.nfo_repair_service.nfo_needs_repair", + return_value=True, + ), patch( + "src.core.services.nfo_factory.NFOServiceFactory" + ) as mock_factory_cls, patch( + "src.core.services.nfo_repair_service.NfoRepairService", + return_value=mock_repair_service, + ): + mock_factory_cls.return_value.create.return_value = MagicMock() + await perform_nfo_repair_scan(background_loader=None) + + mock_repair_service.repair_series.assert_called_once()