feat: wire NFO repair scan into app startup lifespan

This commit is contained in:
2026-02-22 11:17:45 +01:00
parent d71feb64dd
commit adea1e2ede
3 changed files with 123 additions and 0 deletions

View File

@@ -62,6 +62,10 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
either queues the series for background reload (when a `background_loader` is either queues the series for background reload (when a `background_loader` is
provided) or calls `NfoRepairService.repair_series()` directly. Skips provided) or calls `NfoRepairService.repair_series()` directly. Skips
gracefully when `tmdb_api_key` or `anime_directory` is not configured. gracefully when `tmdb_api_key` or `anime_directory` is not configured.
- **NFO repair wired into startup lifespan (`src/server/fastapi_app.py`)**:
`perform_nfo_repair_scan(background_loader)` is called at the end of the
FastAPI lifespan startup, after `perform_media_scan_if_needed`, ensuring
every existing series NFO is checked and repaired on each server start.
### Changed ### Changed

View File

@@ -220,6 +220,7 @@ async def lifespan(_application: FastAPI):
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,
) )
try: try:
@@ -273,6 +274,10 @@ async def lifespan(_application: FastAPI):
# Run media scan only on first run # Run media scan only on first run
await perform_media_scan_if_needed(background_loader) await perform_media_scan_if_needed(background_loader)
# Scan every series NFO on startup and repair any that are
# missing required tags by queuing them for background reload
await perform_nfo_repair_scan(background_loader)
else: else:
logger.info( logger.info(
"Download service initialization skipped - " "Download service initialization skipped - "

View File

@@ -0,0 +1,114 @@
"""Integration tests verifying perform_nfo_repair_scan is wired into app startup.
These tests confirm that:
1. The lifespan calls perform_nfo_repair_scan after perform_media_scan_if_needed.
2. Series with incomplete NFO files are queued via the background_loader.
"""
from unittest.mock import AsyncMock, MagicMock, patch, call
import pytest
class TestNfoRepairScanCalledOnStartup:
"""Verify perform_nfo_repair_scan is invoked during the FastAPI lifespan."""
def test_perform_nfo_repair_scan_imported_in_lifespan(self):
"""fastapi_app.py lifespan imports perform_nfo_repair_scan."""
import importlib
import src.server.fastapi_app as app_module
source = importlib.util.find_spec("src.server.fastapi_app").origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
assert "perform_nfo_repair_scan" in content, (
"perform_nfo_repair_scan must be imported and called in fastapi_app.py"
)
def test_perform_nfo_repair_scan_called_after_media_scan(self):
"""perform_nfo_repair_scan must appear after perform_media_scan_if_needed."""
import importlib
source = importlib.util.find_spec("src.server.fastapi_app").origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
media_scan_pos = content.find("perform_media_scan_if_needed(background_loader)")
repair_scan_pos = content.find("perform_nfo_repair_scan(background_loader)")
assert media_scan_pos != -1, "perform_media_scan_if_needed call not found"
assert repair_scan_pos != -1, "perform_nfo_repair_scan call not found"
assert repair_scan_pos > media_scan_pos, (
"perform_nfo_repair_scan must be called AFTER perform_media_scan_if_needed"
)
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."""
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(
"<tvshow><title>IncompleteAnime</title></tvshow>"
)
mock_settings = MagicMock()
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(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=True,
), patch(
"src.core.services.nfo_factory.NFOServiceFactory"
) as mock_factory:
mock_factory.return_value.create.return_value = MagicMock()
await perform_nfo_repair_scan(background_loader=mock_loader)
mock_loader.add_series_loading_task.assert_called_once_with(
key="IncompleteAnime",
folder="IncompleteAnime",
name="IncompleteAnime",
)
@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."""
from src.server.services.initialization_service import perform_nfo_repair_scan
series_dir = tmp_path / "CompleteAnime"
series_dir.mkdir()
(series_dir / "tvshow.nfo").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_loader = AsyncMock()
mock_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:
mock_factory.return_value.create.return_value = MagicMock()
await perform_nfo_repair_scan(background_loader=mock_loader)
mock_loader.add_series_loading_task.assert_not_called()