From c186e0d4f75318adc2023360594a0ba8d3ec5558 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 22 Feb 2026 16:55:54 +0100 Subject: [PATCH] fix: always repair NFO via update_tvshow_nfo so plot is written --- src/server/services/initialization_service.py | 29 ++++++------ tests/integration/test_nfo_repair_startup.py | 39 ++++++++-------- tests/unit/test_initialization_service.py | 45 ++++++++++--------- 3 files changed, 59 insertions(+), 54 deletions(-) diff --git a/src/server/services/initialization_service.py b/src/server/services/initialization_service.py index deaf532..bf04be5 100644 --- a/src/server/services/initialization_service.py +++ b/src/server/services/initialization_service.py @@ -1,4 +1,5 @@ """Centralized initialization service for application startup and setup.""" +import asyncio from pathlib import Path from typing import Callable, Optional @@ -381,13 +382,16 @@ async def perform_nfo_repair_scan(background_loader=None) -> None: 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. + 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. + + 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. Args: - background_loader: Optional BackgroundLoaderService. When provided, - deficient series are queued non-blocking. When None, repairs - execute inline (useful in tests). + 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 @@ -420,14 +424,13 @@ async def perform_nfo_repair_scan(background_loader=None) -> None: 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) + # 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. + asyncio.create_task( + repair_service.repair_series(series_dir, series_name), + name=f"nfo_repair:{series_name}", + ) logger.info( "NFO repair scan complete: %d of %d series queued for repair", diff --git a/tests/integration/test_nfo_repair_startup.py b/tests/integration/test_nfo_repair_startup.py index 9483349..f9f0046 100644 --- a/tests/integration/test_nfo_repair_startup.py +++ b/tests/integration/test_nfo_repair_startup.py @@ -48,11 +48,10 @@ 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.""" + 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 - # Create a series directory with a minimal (incomplete) NFO series_dir = tmp_path / "IncompleteAnime" series_dir.mkdir() (series_dir / "tvshow.nfo").write_text( @@ -63,8 +62,8 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader: mock_settings.tmdb_api_key = "test-key" mock_settings.anime_directory = str(tmp_path) - mock_loader = AsyncMock() - mock_loader.add_series_loading_task = AsyncMock() + mock_repair_service = AsyncMock() + mock_repair_service.repair_series = AsyncMock(return_value=True) with patch( "src.server.services.initialization_service.settings", mock_settings @@ -73,19 +72,20 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader: return_value=True, ), patch( "src.core.services.nfo_factory.NFOServiceFactory" - ) as mock_factory: + ) as mock_factory, patch( + "src.core.services.nfo_repair_service.NfoRepairService", + return_value=mock_repair_service, + ), patch( + "asyncio.create_task" + ) as mock_create_task: mock_factory.return_value.create.return_value = MagicMock() - await perform_nfo_repair_scan(background_loader=mock_loader) + await perform_nfo_repair_scan(background_loader=AsyncMock()) - mock_loader.add_series_loading_task.assert_called_once_with( - key="IncompleteAnime", - folder="IncompleteAnime", - name="IncompleteAnime", - ) + mock_create_task.assert_called_once() @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.""" + 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 series_dir = tmp_path / "CompleteAnime" @@ -98,9 +98,6 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader: 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( @@ -108,8 +105,10 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader: return_value=False, ), patch( "src.core.services.nfo_factory.NFOServiceFactory" - ) as mock_factory: + ) as mock_factory, patch( + "asyncio.create_task" + ) as mock_create_task: mock_factory.return_value.create.return_value = MagicMock() - await perform_nfo_repair_scan(background_loader=mock_loader) + await perform_nfo_repair_scan(background_loader=AsyncMock()) - mock_loader.add_series_loading_task.assert_not_called() + mock_create_task.assert_not_called() diff --git a/tests/unit/test_initialization_service.py b/tests/unit/test_initialization_service.py index 2c516b0..6968800 100644 --- a/tests/unit/test_initialization_service.py +++ b/tests/unit/test_initialization_service.py @@ -790,9 +790,8 @@ class TestPerformNfoRepairScan: 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 + async def test_queues_deficient_series_as_asyncio_task(self, tmp_path): + """Series with incomplete NFO should be scheduled via asyncio.create_task.""" series_dir = tmp_path / "MyAnime" series_dir.mkdir() nfo_file = series_dir / "tvshow.nfo" @@ -802,8 +801,8 @@ class TestPerformNfoRepairScan: 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() + mock_repair_service = AsyncMock() + mock_repair_service.repair_series = AsyncMock(return_value=True) with patch( "src.server.services.initialization_service.settings", mock_settings @@ -812,17 +811,20 @@ class TestPerformNfoRepairScan: return_value=True, ), patch( "src.core.services.nfo_factory.NFOServiceFactory" - ) as mock_factory_cls: + ) as mock_factory_cls, patch( + "src.core.services.nfo_repair_service.NfoRepairService", + return_value=mock_repair_service, + ), patch( + "asyncio.create_task" + ) as mock_create_task: mock_factory_cls.return_value.create.return_value = MagicMock() - await perform_nfo_repair_scan(background_loader=mock_background_loader) + await perform_nfo_repair_scan(background_loader=AsyncMock()) - mock_background_loader.add_series_loading_task.assert_called_once_with( - key="MyAnime", folder="MyAnime", name="MyAnime" - ) + mock_create_task.assert_called_once() @pytest.mark.asyncio async def test_skips_complete_series(self, tmp_path): - """Series with complete NFO should not be queued or repaired.""" + """Series with complete NFO should not be scheduled for repair.""" series_dir = tmp_path / "CompleteAnime" series_dir.mkdir() nfo_file = series_dir / "tvshow.nfo" @@ -832,9 +834,6 @@ class TestPerformNfoRepairScan: 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( @@ -842,15 +841,17 @@ class TestPerformNfoRepairScan: return_value=False, ), patch( "src.core.services.nfo_factory.NFOServiceFactory" - ) as mock_factory_cls: + ) as mock_factory_cls, patch( + "asyncio.create_task" + ) as mock_create_task: mock_factory_cls.return_value.create.return_value = MagicMock() - await perform_nfo_repair_scan(background_loader=mock_background_loader) + await perform_nfo_repair_scan(background_loader=AsyncMock()) - mock_background_loader.add_series_loading_task.assert_not_called() + mock_create_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.""" + async def test_repairs_via_asyncio_task_without_background_loader(self, tmp_path): + """When no background_loader provided, repair is still scheduled via asyncio.create_task.""" series_dir = tmp_path / "NeedsRepair" series_dir.mkdir() nfo_file = series_dir / "tvshow.nfo" @@ -873,8 +874,10 @@ class TestPerformNfoRepairScan: ) as mock_factory_cls, patch( "src.core.services.nfo_repair_service.NfoRepairService", return_value=mock_repair_service, - ): + ), patch( + "asyncio.create_task" + ) as mock_create_task: 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() + mock_create_task.assert_called_once()