feat: add perform_nfo_repair_scan startup hook
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user