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:
@@ -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.4–1.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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
4
tvshow.nfo.bad
Normal 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
19
tvshow.nfo.good
Normal 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>
|
||||
Reference in New Issue
Block a user