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()