refactor: move NFO repair from initialization_service to folder_scan_service
Moves perform_nfo_repair_scan and its helpers (_repair_one_series, _NFO_REPAIR_SEMAPHORE) into folder_scan_service.py so NFO repair runs during the scheduled folder scan instead of on startup. - Removes NFO repair code from initialization_service.py - Updates all test imports and patch targets - Updates docs/NFO_GUIDE.md and docs/CHANGELOG.md references All 174 related tests pass.
This commit is contained in:
@@ -74,7 +74,7 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
|
|||||||
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()`
|
- **`perform_nfo_repair_scan()`
|
||||||
(`src/server/services/initialization_service.py`)**: New async function
|
(`src/server/services/folder_scan_service.py`)**: New async function
|
||||||
that iterates every series directory, checks whether `tvshow.nfo` is missing
|
that iterates every series directory, checks whether `tvshow.nfo` is missing
|
||||||
required tags using `nfo_needs_repair()`, and queues the series for background
|
required tags using `nfo_needs_repair()`, and queues the series for background
|
||||||
reload via `asyncio.create_task`. Skips gracefully when `tmdb_api_key` or
|
reload via `asyncio.create_task`. Skips gracefully when `tmdb_api_key` or
|
||||||
|
|||||||
@@ -815,8 +815,7 @@ This calls `NFOService.update_tvshow_nfo()` directly and overwrites the existing
|
|||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
| `src/core/services/nfo_repair_service.py` | `REQUIRED_TAGS`, `parse_nfo_tags`, `find_missing_tags`, `nfo_needs_repair`, `NfoRepairService` |
|
| `src/core/services/nfo_repair_service.py` | `REQUIRED_TAGS`, `parse_nfo_tags`, `find_missing_tags`, `nfo_needs_repair`, `NfoRepairService` |
|
||||||
| `src/server/services/initialization_service.py` | `perform_nfo_repair_scan` — invoked from `FolderScanService` |
|
| `src/server/services/folder_scan_service.py` | `perform_nfo_repair_scan` — invoked during the scheduled daily folder scan |
|
||||||
| `src/server/services/folder_scan_service.py` | Calls `perform_nfo_repair_scan` during the scheduled daily folder scan |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ from typing import Optional
|
|||||||
import structlog
|
import structlog
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
from src.config.settings import settings as _settings
|
||||||
from src.core.utils.image_downloader import ImageDownloader
|
from src.core.utils.image_downloader import ImageDownloader
|
||||||
from src.server.services.initialization_service import perform_nfo_repair_scan
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@@ -24,6 +24,101 @@ _TMDB_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
|||||||
# Semaphore to limit concurrent poster image downloads to 3.
|
# Semaphore to limit concurrent poster image downloads to 3.
|
||||||
_POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
_POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
||||||
|
|
||||||
|
# Semaphore to limit concurrent NFO repair TMDB operations to 3.
|
||||||
|
_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
||||||
|
|
||||||
|
|
||||||
|
async def _repair_one_series(series_dir: Path, series_name: str) -> None:
|
||||||
|
"""Repair a single series NFO in isolation.
|
||||||
|
|
||||||
|
Creates a fresh :class:`NFOService` and :class:`NfoRepairService` per
|
||||||
|
invocation so that each repair owns its own ``aiohttp`` session/connector
|
||||||
|
and concurrent tasks cannot interfere with each other.
|
||||||
|
|
||||||
|
A module-level semaphore (``_NFO_REPAIR_SEMAPHORE``) limits the number of
|
||||||
|
simultaneous TMDB requests to avoid rate-limiting.
|
||||||
|
|
||||||
|
Any exception is caught and logged so the asyncio task never silently
|
||||||
|
drops an unhandled error.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
series_dir: Absolute path to the series folder.
|
||||||
|
series_name: Human-readable series name for log messages.
|
||||||
|
"""
|
||||||
|
from src.core.services.nfo_factory import NFOServiceFactory
|
||||||
|
from src.core.services.nfo_repair_service import NfoRepairService
|
||||||
|
|
||||||
|
async with _NFO_REPAIR_SEMAPHORE:
|
||||||
|
try:
|
||||||
|
factory = NFOServiceFactory()
|
||||||
|
nfo_service = factory.create()
|
||||||
|
repair_service = NfoRepairService(nfo_service)
|
||||||
|
await repair_service.repair_series(series_dir, series_name)
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
logger.error(
|
||||||
|
"NFO repair failed for %s: %s",
|
||||||
|
series_name,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def perform_nfo_repair_scan(background_loader=None) -> None:
|
||||||
|
"""Scan all series folders and repair incomplete tvshow.nfo files.
|
||||||
|
|
||||||
|
Called from ``FolderScanService.run_folder_scan()`` during the scheduled
|
||||||
|
daily folder scan (not on every startup). Checks each subfolder of
|
||||||
|
``settings.anime_directory`` for a ``tvshow.nfo`` and calls
|
||||||
|
``_repair_one_series`` for every file with absent or empty required tags.
|
||||||
|
|
||||||
|
Each repair task creates its own isolated :class:`NFOService` /
|
||||||
|
:class:`TMDBClient` so concurrent tasks never share an ``aiohttp``
|
||||||
|
session — this prevents "Connector is closed" errors when many repairs
|
||||||
|
run in parallel. A semaphore caps TMDB concurrency at 3 to stay within
|
||||||
|
rate limits.
|
||||||
|
|
||||||
|
The ``background_loader`` parameter is accepted for backwards-compatibility
|
||||||
|
but is no longer used.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
background_loader: Unused. Kept to avoid breaking call-sites.
|
||||||
|
"""
|
||||||
|
from src.core.services.nfo_repair_service import 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
|
||||||
|
|
||||||
|
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
|
||||||
|
# Each task creates its own NFOService so connectors are isolated.
|
||||||
|
asyncio.create_task(
|
||||||
|
_repair_one_series(series_dir, series_name),
|
||||||
|
name=f"nfo_repair:{series_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"NFO repair scan complete: %d of %d series queued for repair",
|
||||||
|
queued,
|
||||||
|
total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FolderScanServiceError(Exception):
|
class FolderScanServiceError(Exception):
|
||||||
"""Service-level exception for folder-scan operations."""
|
"""Service-level exception for folder-scan operations."""
|
||||||
|
|||||||
@@ -377,101 +377,6 @@ async def perform_nfo_scan_if_needed(progress_service=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
|
||||||
|
|
||||||
|
|
||||||
async def _repair_one_series(series_dir: Path, series_name: str) -> None:
|
|
||||||
"""Repair a single series NFO in isolation.
|
|
||||||
|
|
||||||
Creates a fresh :class:`NFOService` and :class:`NfoRepairService` per
|
|
||||||
invocation so that each repair owns its own ``aiohttp`` session/connector
|
|
||||||
and concurrent tasks cannot interfere with each other.
|
|
||||||
|
|
||||||
A module-level semaphore (``_NFO_REPAIR_SEMAPHORE``) limits the number of
|
|
||||||
simultaneous TMDB requests to avoid rate-limiting.
|
|
||||||
|
|
||||||
Any exception is caught and logged so the asyncio task never silently
|
|
||||||
drops an unhandled error.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
series_dir: Absolute path to the series folder.
|
|
||||||
series_name: Human-readable series name for log messages.
|
|
||||||
"""
|
|
||||||
from src.core.services.nfo_factory import NFOServiceFactory
|
|
||||||
from src.core.services.nfo_repair_service import NfoRepairService
|
|
||||||
|
|
||||||
async with _NFO_REPAIR_SEMAPHORE:
|
|
||||||
try:
|
|
||||||
factory = NFOServiceFactory()
|
|
||||||
nfo_service = factory.create()
|
|
||||||
repair_service = NfoRepairService(nfo_service)
|
|
||||||
await repair_service.repair_series(series_dir, series_name)
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
|
||||||
logger.error(
|
|
||||||
"NFO repair failed for %s: %s",
|
|
||||||
series_name,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def perform_nfo_repair_scan(background_loader=None) -> None:
|
|
||||||
"""Scan all series folders and repair incomplete tvshow.nfo files.
|
|
||||||
|
|
||||||
Called from ``FolderScanService.run_folder_scan()`` during the scheduled
|
|
||||||
daily folder scan (not on every startup). Checks each subfolder of
|
|
||||||
``settings.anime_directory`` for a ``tvshow.nfo`` and calls
|
|
||||||
``_repair_one_series`` for every file with absent or empty required tags.
|
|
||||||
|
|
||||||
Each repair task creates its own isolated :class:`NFOService` /
|
|
||||||
:class:`TMDBClient` so concurrent tasks never share an ``aiohttp``
|
|
||||||
session — this prevents "Connector is closed" errors when many repairs
|
|
||||||
run in parallel. A semaphore caps TMDB concurrency at 3 to stay within
|
|
||||||
rate limits.
|
|
||||||
|
|
||||||
The ``background_loader`` parameter is accepted for backwards-compatibility
|
|
||||||
but is no longer used.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
background_loader: Unused. Kept to avoid breaking call-sites.
|
|
||||||
"""
|
|
||||||
from src.core.services.nfo_repair_service import 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
|
|
||||||
|
|
||||||
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
|
|
||||||
# Each task creates its own NFOService so connectors are isolated.
|
|
||||||
asyncio.create_task(
|
|
||||||
_repair_one_series(series_dir, series_name),
|
|
||||||
name=f"nfo_repair:{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.
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path):
|
async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path):
|
||||||
"""Series whose tvshow.nfo is missing required tags are scheduled via asyncio.create_task."""
|
"""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.folder_scan_service import perform_nfo_repair_scan
|
||||||
|
|
||||||
series_dir = tmp_path / "IncompleteAnime"
|
series_dir = tmp_path / "IncompleteAnime"
|
||||||
series_dir.mkdir()
|
series_dir.mkdir()
|
||||||
@@ -83,7 +83,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
|||||||
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.initialization_service.settings", mock_settings
|
"src.server.services.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
@@ -103,7 +103,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_complete_nfo_series_not_scheduled(self, tmp_path):
|
async def test_complete_nfo_series_not_scheduled(self, tmp_path):
|
||||||
"""Series whose tvshow.nfo has all required tags are not scheduled for repair."""
|
"""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.folder_scan_service import perform_nfo_repair_scan
|
||||||
|
|
||||||
series_dir = tmp_path / "CompleteAnime"
|
series_dir = tmp_path / "CompleteAnime"
|
||||||
series_dir.mkdir()
|
series_dir.mkdir()
|
||||||
@@ -116,7 +116,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
|||||||
mock_settings.anime_directory = str(tmp_path)
|
mock_settings.anime_directory = str(tmp_path)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.initialization_service.settings", mock_settings
|
"src.server.services.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from src.server.services.folder_scan_service import (
|
|||||||
_TMDB_SEMAPHORE,
|
_TMDB_SEMAPHORE,
|
||||||
FolderScanService,
|
FolderScanService,
|
||||||
FolderScanServiceError,
|
FolderScanServiceError,
|
||||||
|
perform_nfo_repair_scan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from src.server.services.folder_scan_service import perform_nfo_repair_scan
|
||||||
from src.server.services.initialization_service import (
|
from src.server.services.initialization_service import (
|
||||||
_check_initial_scan_status,
|
_check_initial_scan_status,
|
||||||
_check_media_scan_status,
|
_check_media_scan_status,
|
||||||
@@ -27,7 +28,6 @@ from src.server.services.initialization_service import (
|
|||||||
_validate_anime_directory,
|
_validate_anime_directory,
|
||||||
perform_initial_setup,
|
perform_initial_setup,
|
||||||
perform_media_scan_if_needed,
|
perform_media_scan_if_needed,
|
||||||
perform_nfo_repair_scan,
|
|
||||||
perform_nfo_scan_if_needed,
|
perform_nfo_scan_if_needed,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -771,7 +771,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_settings.anime_directory = str(tmp_path)
|
mock_settings.anime_directory = str(tmp_path)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.initialization_service.settings", mock_settings
|
"src.server.services.folder_scan_service._settings", mock_settings
|
||||||
):
|
):
|
||||||
await perform_nfo_repair_scan()
|
await perform_nfo_repair_scan()
|
||||||
|
|
||||||
@@ -785,7 +785,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_settings.anime_directory = ""
|
mock_settings.anime_directory = ""
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.initialization_service.settings", mock_settings
|
"src.server.services.folder_scan_service._settings", mock_settings
|
||||||
):
|
):
|
||||||
await perform_nfo_repair_scan()
|
await perform_nfo_repair_scan()
|
||||||
|
|
||||||
@@ -805,7 +805,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.initialization_service.settings", mock_settings
|
"src.server.services.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
@@ -835,7 +835,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_settings.anime_directory = str(tmp_path)
|
mock_settings.anime_directory = str(tmp_path)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.initialization_service.settings", mock_settings
|
"src.server.services.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
@@ -865,7 +865,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.initialization_service.settings", mock_settings
|
"src.server.services.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
|
|||||||
Reference in New Issue
Block a user