fix: task 1.5 poster check + fix stuck tests

- Fix structlog format string in folder_scan_service (%(key)d -> kwargs)
- Add nfo_download_poster setting check before poster download
- Create missing NFO fixture files (tvshow.nfo.bad/good) for repair tests
- Fix test_context_used_in_logging to check all call args not format string
- Fix test_system_settings_integration isolation via reset_all_scans
This commit is contained in:
2026-05-13 08:07:16 +02:00
parent eb2fc3c5ab
commit eb0e6e8ccb
5 changed files with 230 additions and 5 deletions

View File

@@ -11,7 +11,9 @@ from pathlib import Path
from typing import Optional
import structlog
from lxml import etree
from src.core.utils.image_downloader import ImageDownloader
from src.server.services.initialization_service import perform_nfo_repair_scan
logger = structlog.get_logger(__name__)
@@ -19,6 +21,9 @@ logger = structlog.get_logger(__name__)
# Module-level semaphore to limit concurrent TMDB operations to 3.
_TMDB_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
# Semaphore to limit concurrent poster image downloads to 3.
_POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
class FolderScanServiceError(Exception):
"""Service-level exception for folder-scan operations."""
@@ -49,11 +54,203 @@ class FolderScanService:
await perform_nfo_repair_scan(background_loader=None)
logger.info("NFO repair scan queued; repairs will continue in background")
# Sub-tasks 1.41.5 will fill in the actual work here.
# 1.4 — Validate and rename series folders after NFO repair.
logger.info("Starting folder rename validation")
from src.server.services.folder_rename_service import (
validate_and_rename_series_folders,
)
rename_stats = await validate_and_rename_series_folders()
logger.info(
"Folder rename validation complete",
scanned=rename_stats["scanned"],
renamed=rename_stats["renamed"],
skipped=rename_stats["skipped"],
errors=rename_stats["errors"],
)
# 1.5 — Check and download missing poster.jpg files.
logger.info("Starting poster check")
poster_stats = await self.check_and_download_missing_posters()
logger.info(
"Poster check complete",
scanned=poster_stats["scanned"],
downloaded=poster_stats["downloaded"],
skipped=poster_stats["skipped"],
errors=poster_stats["errors"],
)
logger.info("Folder scan completed")
except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Folder scan failed", error=str(exc), exc_info=True)
# ------------------------------------------------------------------
# Poster check helpers
# ------------------------------------------------------------------
async def check_and_download_missing_posters(self) -> dict[str, int]:
"""Iterate over series folders and download missing poster.jpg files.
For each folder containing a ``tvshow.nfo``:
1. Check if ``poster.jpg`` exists and is at least
:attr:`ImageDownloader.min_file_size` bytes.
2. If missing or too small, parse ``tvshow.nfo`` for a ``<thumb>``
URL (preferring ``aspect="poster"``).
3. Download the image via :class:`ImageDownloader` under a
semaphore that limits concurrency to 3.
Returns:
Dictionary with counts:
- ``"scanned"``: total folders scanned
- ``"downloaded"``: posters successfully downloaded
- ``"skipped"``: folders skipped (no NFO, no thumb URL,
or poster already valid)
- ``"errors"``: folders that caused a download error
"""
from src.config.settings import settings # noqa: PLC0415
stats = {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
if not settings.anime_directory:
logger.warning("Poster check skipped — anime directory not configured")
return stats
anime_dir = Path(settings.anime_directory)
if not anime_dir.is_dir():
logger.warning(
"Poster check skipped — anime directory not found: %s", anime_dir
)
return stats
# Gather all series directories that contain a tvshow.nfo
series_dirs = [
d for d in anime_dir.iterdir()
if d.is_dir() and (d / "tvshow.nfo").exists()
]
if not series_dirs:
logger.debug("No series folders found for poster check")
return stats
# Process each series folder concurrently with semaphore
tasks = [
self._check_and_download_poster(series_dir, stats)
for series_dir in series_dirs
]
await asyncio.gather(*tasks, return_exceptions=True)
return stats
async def _check_and_download_poster(
self, series_dir: Path, stats: dict[str, int]
) -> None:
"""Check and download poster for a single series folder.
Args:
series_dir: Path to the series folder.
stats: Mutable stats dictionary to update.
"""
stats["scanned"] += 1
poster_path = series_dir / "poster.jpg"
# Check if poster already exists and is large enough
if poster_path.exists():
try:
# Default min_file_size from ImageDownloader is 1024 bytes (1 KB)
if poster_path.stat().st_size >= 1024:
logger.debug(
"Poster already valid for '%s'", series_dir.name
)
stats["skipped"] += 1
return
except OSError:
pass # Fall through to re-download
# Parse NFO for thumb URL
nfo_path = series_dir / "tvshow.nfo"
poster_url = self._extract_poster_url_from_nfo(nfo_path)
if not poster_url:
logger.info(
"No poster URL found in NFO for '%s', skipping",
series_dir.name,
)
stats["skipped"] += 1
return
# Respect the nfo_download_poster setting
from src.config.settings import settings as app_settings # noqa: PLC0415
if not app_settings.nfo_download_poster:
logger.debug(
"Poster download disabled by nfo_download_poster setting for '%s'",
series_dir.name,
)
stats["skipped"] += 1
return
# Download poster with semaphore
async with _POSTER_DOWNLOAD_SEMAPHORE:
try:
async with ImageDownloader() as downloader:
success = await downloader.download_poster(
poster_url, series_dir, skip_existing=False
)
if success:
logger.info(
"Downloaded poster for '%s'", series_dir.name
)
stats["downloaded"] += 1
else:
logger.warning(
"Failed to download poster for '%s'", series_dir.name
)
stats["errors"] += 1
except Exception as exc: # pylint: disable=broad-except
logger.error(
"Error downloading poster for '%s': %s",
series_dir.name,
exc,
)
stats["errors"] += 1
@staticmethod
def _extract_poster_url_from_nfo(nfo_path: Path) -> Optional[str]:
"""Parse tvshow.nfo and extract the poster thumb URL.
Prefers ``<thumb aspect="poster">``; falls back to the first
``<thumb>`` element if no aspect attribute is present.
Args:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
The poster URL string, or ``None`` if not found.
"""
if not nfo_path.exists():
return None
try:
tree = etree.parse(str(nfo_path))
root = tree.getroot()
# Prefer thumb with aspect="poster"
for thumb in root.findall(".//thumb"):
if thumb.get("aspect") == "poster" and thumb.text:
return thumb.text.strip()
# Fallback to first thumb with text
for thumb in root.findall(".//thumb"):
if thumb.text:
return thumb.text.strip()
return None
except etree.XMLSyntaxError:
logger.warning("Malformed XML in %s", nfo_path)
return None
except Exception: # pylint: disable=broad-except
return None
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------

View File

@@ -263,8 +263,9 @@ class TestWithErrorRecoveryDecorator:
fail_once()
# Should have logged a warning with context
mock_logger.warning.assert_called()
logged_msg = mock_logger.warning.call_args[0][0]
assert "my_context" in logged_msg
# context is a %s arg, so check all positional call args
logged_args = mock_logger.warning.call_args[0]
assert any("my_context" in str(arg) for arg in logged_args)
def test_retryable_error_is_retried(self):
"""RetryableError (standard Exception subclass) is retried."""

View File

@@ -11,7 +11,11 @@ async def test_system_settings_integration():
# Initialize database
await init_db()
# Test get_or_create (should create on first call)
# Reset all flags to a known-clean state before the test
async with get_db_session() as db:
await SystemSettingsService.reset_all_scans(db)
# Test get_or_create (should return record with all flags False after reset)
async with get_db_session() as db:
settings = await SystemSettingsService.get_or_create(db)
assert settings is not None

4
tvshow.nfo.bad Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Attack on Titan</title>
</tvshow>

19
tvshow.nfo.good Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Attack on Titan</title>
<originaltitle>進撃の巨人</originaltitle>
<year>2013</year>
<plot>After his hometown is destroyed and his mother is killed, young Eren Yeager vows to cleanse the earth of the giant humanoid Titans that have brought humanity to the brink of extinction.</plot>
<runtime>24</runtime>
<premiered>2013-04-07</premiered>
<status>Ended</status>
<imdbid>tt2560140</imdbid>
<genre>Action</genre>
<studio>Wit Studio</studio>
<country>Japan</country>
<actor>
<name>Yuki Kaji</name>
<role>Eren Yeager</role>
</actor>
<watched>false</watched>
</tvshow>