fix: always repair NFO via update_tvshow_nfo so plot is written

This commit is contained in:
2026-02-22 16:55:54 +01:00
parent 759cd09ded
commit c186e0d4f7
3 changed files with 59 additions and 54 deletions

View File

@@ -1,4 +1,5 @@
"""Centralized initialization service for application startup and setup.""" """Centralized initialization service for application startup and setup."""
import asyncio
from pathlib import Path from pathlib import Path
from typing import Callable, Optional 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). Runs on every application startup (not guarded by a run-once DB flag).
Checks each subfolder of ``settings.anime_directory`` for a ``tvshow.nfo`` Checks each subfolder of ``settings.anime_directory`` for a ``tvshow.nfo``
and queues a repair via *background_loader* when required tags are absent and calls ``NfoRepairService.repair_series`` for every file with absent or
or empty, or runs repairs inline when no loader is provided. 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: Args:
background_loader: Optional BackgroundLoaderService. When provided, background_loader: Unused. Kept to avoid breaking call-sites.
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_factory import NFOServiceFactory
from src.core.services.nfo_repair_service import NfoRepairService, nfo_needs_repair 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 series_name = series_dir.name
if nfo_needs_repair(nfo_path): if nfo_needs_repair(nfo_path):
queued += 1 queued += 1
if background_loader is not None: # Always repair via update_tvshow_nfo so that missing fields such
await background_loader.add_series_loading_task( # as `plot` are fetched from TMDB. Fire as an asyncio task to
key=series_name, # avoid blocking the startup loop.
folder=series_name, asyncio.create_task(
name=series_name, repair_service.repair_series(series_dir, series_name),
) name=f"nfo_repair:{series_name}",
else: )
await repair_service.repair_series(series_dir, series_name)
logger.info( logger.info(
"NFO repair scan complete: %d of %d series queued for repair", "NFO repair scan complete: %d of %d series queued for repair",

View File

@@ -48,11 +48,10 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
"""Integration test: incomplete NFO series are queued via background_loader.""" """Integration test: incomplete NFO series are queued via background_loader."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_incomplete_nfo_series_queued(self, tmp_path): async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path):
"""Series whose tvshow.nfo is missing required tags get queued.""" """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.initialization_service import perform_nfo_repair_scan
# Create a series directory with a minimal (incomplete) NFO
series_dir = tmp_path / "IncompleteAnime" series_dir = tmp_path / "IncompleteAnime"
series_dir.mkdir() series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text( (series_dir / "tvshow.nfo").write_text(
@@ -63,8 +62,8 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
mock_settings.tmdb_api_key = "test-key" mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(tmp_path) mock_settings.anime_directory = str(tmp_path)
mock_loader = AsyncMock() mock_repair_service = AsyncMock()
mock_loader.add_series_loading_task = AsyncMock() mock_repair_service.repair_series = AsyncMock(return_value=True)
with patch( with patch(
"src.server.services.initialization_service.settings", mock_settings "src.server.services.initialization_service.settings", mock_settings
@@ -73,19 +72,20 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
return_value=True, return_value=True,
), patch( ), patch(
"src.core.services.nfo_factory.NFOServiceFactory" "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() 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( mock_create_task.assert_called_once()
key="IncompleteAnime",
folder="IncompleteAnime",
name="IncompleteAnime",
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_complete_nfo_series_not_queued(self, tmp_path): async def test_complete_nfo_series_not_scheduled(self, tmp_path):
"""Series whose tvshow.nfo has all required tags are not queued.""" """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.initialization_service import perform_nfo_repair_scan
series_dir = tmp_path / "CompleteAnime" series_dir = tmp_path / "CompleteAnime"
@@ -98,9 +98,6 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
mock_settings.tmdb_api_key = "test-key" mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(tmp_path) mock_settings.anime_directory = str(tmp_path)
mock_loader = AsyncMock()
mock_loader.add_series_loading_task = AsyncMock()
with patch( with patch(
"src.server.services.initialization_service.settings", mock_settings "src.server.services.initialization_service.settings", mock_settings
), patch( ), patch(
@@ -108,8 +105,10 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
return_value=False, return_value=False,
), patch( ), patch(
"src.core.services.nfo_factory.NFOServiceFactory" "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() 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()

View File

@@ -790,9 +790,8 @@ class TestPerformNfoRepairScan:
await perform_nfo_repair_scan() await perform_nfo_repair_scan()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_queues_deficient_series_via_background_loader(self, tmp_path): async def test_queues_deficient_series_as_asyncio_task(self, tmp_path):
"""Series with incomplete NFO should be queued via background_loader.""" """Series with incomplete NFO should be scheduled via asyncio.create_task."""
# Create a fake series directory with a tvshow.nfo file
series_dir = tmp_path / "MyAnime" series_dir = tmp_path / "MyAnime"
series_dir.mkdir() series_dir.mkdir()
nfo_file = series_dir / "tvshow.nfo" nfo_file = series_dir / "tvshow.nfo"
@@ -802,8 +801,8 @@ class TestPerformNfoRepairScan:
mock_settings.tmdb_api_key = "test-key" mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(tmp_path) mock_settings.anime_directory = str(tmp_path)
mock_background_loader = AsyncMock() mock_repair_service = AsyncMock()
mock_background_loader.add_series_loading_task = AsyncMock() mock_repair_service.repair_series = AsyncMock(return_value=True)
with patch( with patch(
"src.server.services.initialization_service.settings", mock_settings "src.server.services.initialization_service.settings", mock_settings
@@ -812,17 +811,20 @@ class TestPerformNfoRepairScan:
return_value=True, return_value=True,
), patch( ), patch(
"src.core.services.nfo_factory.NFOServiceFactory" "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() 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( mock_create_task.assert_called_once()
key="MyAnime", folder="MyAnime", name="MyAnime"
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_complete_series(self, tmp_path): 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 = tmp_path / "CompleteAnime"
series_dir.mkdir() series_dir.mkdir()
nfo_file = series_dir / "tvshow.nfo" nfo_file = series_dir / "tvshow.nfo"
@@ -832,9 +834,6 @@ class TestPerformNfoRepairScan:
mock_settings.tmdb_api_key = "test-key" mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(tmp_path) mock_settings.anime_directory = str(tmp_path)
mock_background_loader = AsyncMock()
mock_background_loader.add_series_loading_task = AsyncMock()
with patch( with patch(
"src.server.services.initialization_service.settings", mock_settings "src.server.services.initialization_service.settings", mock_settings
), patch( ), patch(
@@ -842,15 +841,17 @@ class TestPerformNfoRepairScan:
return_value=False, return_value=False,
), patch( ), patch(
"src.core.services.nfo_factory.NFOServiceFactory" "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() 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 @pytest.mark.asyncio
async def test_repairs_directly_without_background_loader(self, tmp_path): async def test_repairs_via_asyncio_task_without_background_loader(self, tmp_path):
"""When no background_loader provided, repair_series is called directly.""" """When no background_loader provided, repair is still scheduled via asyncio.create_task."""
series_dir = tmp_path / "NeedsRepair" series_dir = tmp_path / "NeedsRepair"
series_dir.mkdir() series_dir.mkdir()
nfo_file = series_dir / "tvshow.nfo" nfo_file = series_dir / "tvshow.nfo"
@@ -873,8 +874,10 @@ class TestPerformNfoRepairScan:
) as mock_factory_cls, patch( ) as mock_factory_cls, patch(
"src.core.services.nfo_repair_service.NfoRepairService", "src.core.services.nfo_repair_service.NfoRepairService",
return_value=mock_repair_service, return_value=mock_repair_service,
): ), patch(
"asyncio.create_task"
) as mock_create_task:
mock_factory_cls.return_value.create.return_value = MagicMock() mock_factory_cls.return_value.create.return_value = MagicMock()
await perform_nfo_repair_scan(background_loader=None) await perform_nfo_repair_scan(background_loader=None)
mock_repair_service.repair_series.assert_called_once() mock_create_task.assert_called_once()