feat: add perform_nfo_repair_scan startup hook

This commit is contained in:
2026-02-22 11:16:25 +01:00
parent 3e5ad8a4a6
commit d71feb64dd
3 changed files with 190 additions and 1 deletions

View File

@@ -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. that detects incomplete `tvshow.nfo` files and triggers TMDB re-fetch.
Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and
`NfoRepairService.repair_series()`. 13 required tags are checked. `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 ### Changed

View File

@@ -1,5 +1,6 @@
"""Centralized initialization service for application startup and setup.""" """Centralized initialization service for application startup and setup."""
from typing import Callable from pathlib import Path
from typing import Callable, Optional
import structlog 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: async def _check_media_scan_status() -> bool:
"""Check if initial media scan has been completed. """Check if initial media scan has been completed.

View File

@@ -28,6 +28,7 @@ from src.server.services.initialization_service import (
perform_initial_setup, perform_initial_setup,
perform_media_scan_if_needed, perform_media_scan_if_needed,
perform_nfo_scan_if_needed, perform_nfo_scan_if_needed,
perform_nfo_repair_scan,
) )
@@ -757,3 +758,123 @@ class TestInitializationIntegration:
result2 = await perform_initial_setup() result2 = await perform_initial_setup()
assert result2 is False 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("<tvshow><title>MyAnime</title></tvshow>")
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("<tvshow><title>CompleteAnime</title></tvshow>")
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("<tvshow><title>NeedsRepair</title></tvshow>")
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()