feat(setup): detect filesystem properties during initial scan

SetupService.run() now checks each anime folder for tvshow.nfo,
logo.png, and poster/fanart images instead of using hardcoded
defaults. Provider key resolution via search is unchanged.
This commit is contained in:
2026-06-05 20:05:04 +02:00
parent 5c2be3f7c4
commit 18d10b44b5
3 changed files with 262 additions and 4 deletions

View File

@@ -70,6 +70,10 @@ class AnimeSeriesService:
logo_loaded: bool = False,
images_loaded: bool = False,
loading_started_at: datetime | None = None,
has_nfo: bool = False,
nfo_path: str | None = None,
nfo_created_at: datetime | None = None,
nfo_updated_at: datetime | None = None,
) -> AnimeSeries:
"""Create a new anime series.
@@ -85,6 +89,10 @@ class AnimeSeriesService:
logo_loaded: Whether logo is loaded (default: False)
images_loaded: Whether images are loaded (default: False)
loading_started_at: When loading started (optional)
has_nfo: Whether tvshow.nfo exists (default: False)
nfo_path: Path to tvshow.nfo file (optional)
nfo_created_at: When NFO file was created (optional)
nfo_updated_at: When NFO file was last updated (optional)
Returns:
Created AnimeSeries instance
@@ -103,6 +111,10 @@ class AnimeSeriesService:
logo_loaded=logo_loaded,
images_loaded=images_loaded,
loading_started_at=loading_started_at,
has_nfo=has_nfo,
nfo_path=nfo_path,
nfo_created_at=nfo_created_at,
nfo_updated_at=nfo_updated_at,
)
db.add(series)
await db.flush()

View File

@@ -11,6 +11,8 @@ via _check_initial_scan_status, not by this service itself.
"""
import os
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
@@ -24,6 +26,17 @@ from src.server.utils.dependencies import get_series_app
logger = structlog.get_logger(__name__)
@dataclass
class SeriesProperties:
"""Filesystem-derived properties for an AnimeSeries."""
has_nfo: bool = False
nfo_path: Optional[str] = None
nfo_created_at: Optional[datetime] = None
nfo_updated_at: Optional[datetime] = None
logo_loaded: bool = False
images_loaded: bool = False
class SetupService:
"""Service for setup operations during application initialization."""
@@ -92,6 +105,77 @@ class SetupService:
return ""
@staticmethod
def _check_nfo_file(folder_path: Path) -> tuple[bool, Optional[str], Optional[datetime], Optional[datetime]]:
"""Check if tvshow.nfo exists and return its metadata.
Args:
folder_path: Path to the series folder
Returns:
Tuple of (has_nfo, nfo_path, nfo_created_at, nfo_updated_at)
"""
nfo_path = folder_path / "tvshow.nfo"
if nfo_path.is_file():
stat = nfo_path.stat()
created = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc)
updated = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
return True, str(nfo_path), created, updated
return False, None, None, None
@staticmethod
def _check_logo_file(folder_path: Path) -> bool:
"""Check if logo.png exists.
Args:
folder_path: Path to the series folder
Returns:
True if logo.png exists, False otherwise
"""
return (folder_path / "logo.png").is_file()
@staticmethod
def _check_image_files(folder_path: Path) -> bool:
"""Check if any image files (poster, fanart) exist.
Args:
folder_path: Path to the series folder
Returns:
True if any poster.jpg/jpeg/png or fanart.jpg/jpeg/png exists
"""
image_extensions = {'.jpg', '.jpeg', '.png'}
for child in folder_path.iterdir():
if child.is_file():
name_lower = child.name.lower()
if name_lower.startswith(('poster', 'fanart')) and child.suffix.lower() in image_extensions:
return True
return False
@classmethod
def _get_series_properties(cls, folder_path: Path) -> SeriesProperties:
"""Get all filesystem-derived properties for a series folder.
Args:
folder_path: Path to the series folder
Returns:
SeriesProperties with all detected values
"""
has_nfo, nfo_path, nfo_created_at, nfo_updated_at = cls._check_nfo_file(folder_path)
logo_loaded = cls._check_logo_file(folder_path)
images_loaded = cls._check_image_files(folder_path)
return SeriesProperties(
has_nfo=has_nfo,
nfo_path=nfo_path,
nfo_created_at=nfo_created_at,
nfo_updated_at=nfo_updated_at,
logo_loaded=logo_loaded,
images_loaded=images_loaded,
)
@classmethod
async def run(cls) -> int:
"""Run the setup service.
@@ -152,8 +236,11 @@ class SetupService:
# Resolve key via provider search
resolved_key = await cls._resolve_key_via_search(title)
# Check filesystem properties
props = cls._get_series_properties(folder)
# Create AnimeSeries record
await AnimeSeriesService.create(
series = await AnimeSeriesService.create(
db=db,
key=resolved_key,
name=title,
@@ -162,8 +249,12 @@ class SetupService:
year=year,
loading_status="completed",
episodes_loaded=True,
logo_loaded=False,
images_loaded=False,
logo_loaded=props.logo_loaded,
images_loaded=props.images_loaded,
has_nfo=props.has_nfo,
nfo_path=props.nfo_path,
nfo_created_at=props.nfo_created_at,
nfo_updated_at=props.nfo_updated_at,
)
created_count += 1
@@ -172,7 +263,10 @@ class SetupService:
folder=folder_name,
title=title,
year=year,
key=resolved_key or "(unresolved)"
key=resolved_key or "(unresolved)",
has_nfo=props.has_nfo,
logo_loaded=props.logo_loaded,
images_loaded=props.images_loaded,
)
logger.info(

View File

@@ -390,3 +390,155 @@ class TestSetupServiceRun:
assert result == 1
mock_create.assert_called_once()
class TestCheckNfoFile:
"""Test _check_nfo_file method."""
def test_returns_true_when_tvshow_nfo_exists(self, tmp_path):
"""tvshow.nfo exists → returns (True, path, created, updated)."""
folder = tmp_path / "Series"
folder.mkdir()
nfo_file = folder / "tvshow.nfo"
nfo_file.touch()
has_nfo, nfo_path, created, updated = SetupService._check_nfo_file(folder)
assert has_nfo is True
assert nfo_path == str(nfo_file)
assert created is not None
assert updated is not None
def test_returns_false_when_no_nfo(self, tmp_path):
"""No tvshow.nfo → returns (False, None, None, None)."""
folder = tmp_path / "Series"
folder.mkdir()
has_nfo, nfo_path, created, updated = SetupService._check_nfo_file(folder)
assert has_nfo is False
assert nfo_path is None
assert created is None
assert updated is None
def test_returns_false_when_nfo_is_directory(self, tmp_path):
"""tvshow.nfo exists but is a directory → returns False."""
folder = tmp_path / "Series"
folder.mkdir()
(folder / "tvshow.nfo").mkdir()
has_nfo, nfo_path, created, updated = SetupService._check_nfo_file(folder)
assert has_nfo is False
class TestCheckLogoFile:
"""Test _check_logo_file method."""
def test_returns_true_when_logo_png_exists(self, tmp_path):
"""logo.png exists → returns True."""
folder = tmp_path / "Series"
folder.mkdir()
(folder / "logo.png").touch()
assert SetupService._check_logo_file(folder) is True
def test_returns_false_when_no_logo(self, tmp_path):
"""No logo.png → returns False."""
folder = tmp_path / "Series"
folder.mkdir()
assert SetupService._check_logo_file(folder) is False
def test_returns_false_for_other_files(self, tmp_path):
"""Files like logo.jpg or logo.gif → returns False."""
folder = tmp_path / "Series"
folder.mkdir()
(folder / "logo.jpg").touch()
(folder / "logo.gif").touch()
assert SetupService._check_logo_file(folder) is False
class TestCheckImageFiles:
"""Test _check_image_files method."""
def test_returns_true_for_poster_jpg(self, tmp_path):
"""poster.jpg exists → returns True."""
folder = tmp_path / "Series"
folder.mkdir()
(folder / "poster.jpg").touch()
assert SetupService._check_image_files(folder) is True
def test_returns_true_for_poster_jpeg(self, tmp_path):
"""poster.jpeg exists → returns True."""
folder = tmp_path / "Series"
folder.mkdir()
(folder / "poster.jpeg").touch()
assert SetupService._check_image_files(folder) is True
def test_returns_true_for_poster_png(self, tmp_path):
"""poster.png exists → returns True."""
folder = tmp_path / "Series"
folder.mkdir()
(folder / "poster.png").touch()
assert SetupService._check_image_files(folder) is True
def test_returns_true_for_fanart_jpg(self, tmp_path):
"""fanart.jpg exists → returns True."""
folder = tmp_path / "Series"
folder.mkdir()
(folder / "fanart.jpg").touch()
assert SetupService._check_image_files(folder) is True
def test_returns_false_when_no_images(self, tmp_path):
"""No poster or fanart images → returns False."""
folder = tmp_path / "Series"
folder.mkdir()
(folder / "episode_01.mp4").touch()
assert SetupService._check_image_files(folder) is False
def test_returns_false_for_unrelated_files(self, tmp_path):
"""Files not matching poster/fanart pattern → returns False."""
folder = tmp_path / "Series"
folder.mkdir()
(folder / "banner.png").touch()
(folder / "thumbnail.jpg").touch()
assert SetupService._check_image_files(folder) is False
class TestGetSeriesProperties:
"""Test _get_series_properties method."""
def test_returns_all_properties_from_filesystem(self, tmp_path):
"""Folder with tvshow.nfo, logo.png, poster.jpg → returns correct props."""
folder = tmp_path / "Series"
folder.mkdir()
(folder / "tvshow.nfo").touch()
(folder / "logo.png").touch()
(folder / "poster.jpg").touch()
props = SetupService._get_series_properties(folder)
assert props.has_nfo is True
assert props.nfo_path is not None
assert props.logo_loaded is True
assert props.images_loaded is True
def test_returns_defaults_when_no_files(self, tmp_path):
"""Empty folder → returns all False/None."""
folder = tmp_path / "Series"
folder.mkdir()
props = SetupService._get_series_properties(folder)
assert props.has_nfo is False
assert props.nfo_path is None
assert props.logo_loaded is False
assert props.images_loaded is False