From adea1e2ede133710f6c8b343a66ce4b5444a58f4 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 22 Feb 2026 11:17:45 +0100 Subject: [PATCH] feat: wire NFO repair scan into app startup lifespan --- docs/CHANGELOG.md | 4 + src/server/fastapi_app.py | 5 + tests/integration/test_nfo_repair_startup.py | 114 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 tests/integration/test_nfo_repair_startup.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d9b1b74..70df01a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -62,6 +62,10 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle 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. +- **NFO repair wired into startup lifespan (`src/server/fastapi_app.py`)**: + `perform_nfo_repair_scan(background_loader)` is called at the end of the + FastAPI lifespan startup, after `perform_media_scan_if_needed`, ensuring + every existing series NFO is checked and repaired on each server start. ### Changed diff --git a/src/server/fastapi_app.py b/src/server/fastapi_app.py index 2422051..411fc85 100644 --- a/src/server/fastapi_app.py +++ b/src/server/fastapi_app.py @@ -220,6 +220,7 @@ async def lifespan(_application: FastAPI): perform_initial_setup, perform_media_scan_if_needed, perform_nfo_scan_if_needed, + perform_nfo_repair_scan, ) try: @@ -273,6 +274,10 @@ async def lifespan(_application: FastAPI): # Run media scan only on first run await perform_media_scan_if_needed(background_loader) + + # Scan every series NFO on startup and repair any that are + # missing required tags by queuing them for background reload + await perform_nfo_repair_scan(background_loader) else: logger.info( "Download service initialization skipped - " diff --git a/tests/integration/test_nfo_repair_startup.py b/tests/integration/test_nfo_repair_startup.py new file mode 100644 index 0000000..c7c0f0b --- /dev/null +++ b/tests/integration/test_nfo_repair_startup.py @@ -0,0 +1,114 @@ +"""Integration tests verifying perform_nfo_repair_scan is wired into app startup. + +These tests confirm that: +1. The lifespan calls perform_nfo_repair_scan after perform_media_scan_if_needed. +2. Series with incomplete NFO files are queued via the background_loader. +""" +from unittest.mock import AsyncMock, MagicMock, patch, call + +import pytest + + +class TestNfoRepairScanCalledOnStartup: + """Verify perform_nfo_repair_scan is invoked during the FastAPI lifespan.""" + + def test_perform_nfo_repair_scan_imported_in_lifespan(self): + """fastapi_app.py lifespan imports perform_nfo_repair_scan.""" + import importlib + import src.server.fastapi_app as app_module + + source = importlib.util.find_spec("src.server.fastapi_app").origin + with open(source, "r", encoding="utf-8") as fh: + content = fh.read() + + assert "perform_nfo_repair_scan" in content, ( + "perform_nfo_repair_scan must be imported and called in fastapi_app.py" + ) + + def test_perform_nfo_repair_scan_called_after_media_scan(self): + """perform_nfo_repair_scan must appear after perform_media_scan_if_needed.""" + import importlib + + source = importlib.util.find_spec("src.server.fastapi_app").origin + with open(source, "r", encoding="utf-8") as fh: + content = fh.read() + + media_scan_pos = content.find("perform_media_scan_if_needed(background_loader)") + repair_scan_pos = content.find("perform_nfo_repair_scan(background_loader)") + + assert media_scan_pos != -1, "perform_media_scan_if_needed call not found" + assert repair_scan_pos != -1, "perform_nfo_repair_scan call not found" + assert repair_scan_pos > media_scan_pos, ( + "perform_nfo_repair_scan must be called AFTER perform_media_scan_if_needed" + ) + + +class TestNfoRepairScanIntegrationWithBackgroundLoader: + """Integration test: incomplete NFO series are queued via background_loader.""" + + @pytest.mark.asyncio + async def test_incomplete_nfo_series_queued(self, tmp_path): + """Series whose tvshow.nfo is missing required tags get queued.""" + from src.server.services.initialization_service import perform_nfo_repair_scan + + # Create a series directory with a minimal (incomplete) NFO + series_dir = tmp_path / "IncompleteAnime" + series_dir.mkdir() + (series_dir / "tvshow.nfo").write_text( + "IncompleteAnime" + ) + + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "test-key" + mock_settings.anime_directory = str(tmp_path) + + mock_loader = AsyncMock() + mock_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: + mock_factory.return_value.create.return_value = MagicMock() + await perform_nfo_repair_scan(background_loader=mock_loader) + + mock_loader.add_series_loading_task.assert_called_once_with( + key="IncompleteAnime", + folder="IncompleteAnime", + name="IncompleteAnime", + ) + + @pytest.mark.asyncio + async def test_complete_nfo_series_not_queued(self, tmp_path): + """Series whose tvshow.nfo has all required tags are not queued.""" + from src.server.services.initialization_service import perform_nfo_repair_scan + + series_dir = tmp_path / "CompleteAnime" + series_dir.mkdir() + (series_dir / "tvshow.nfo").write_text( + "CompleteAnime" + ) + + mock_settings = MagicMock() + mock_settings.tmdb_api_key = "test-key" + mock_settings.anime_directory = str(tmp_path) + + mock_loader = AsyncMock() + mock_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: + mock_factory.return_value.create.return_value = MagicMock() + await perform_nfo_repair_scan(background_loader=mock_loader) + + mock_loader.add_series_loading_task.assert_not_called()