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, logo_loaded: bool = False,
images_loaded: bool = False, images_loaded: bool = False,
loading_started_at: datetime | None = None, 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: ) -> AnimeSeries:
"""Create a new anime series. """Create a new anime series.
@@ -85,6 +89,10 @@ class AnimeSeriesService:
logo_loaded: Whether logo is loaded (default: False) logo_loaded: Whether logo is loaded (default: False)
images_loaded: Whether images are loaded (default: False) images_loaded: Whether images are loaded (default: False)
loading_started_at: When loading started (optional) 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: Returns:
Created AnimeSeries instance Created AnimeSeries instance
@@ -103,6 +111,10 @@ class AnimeSeriesService:
logo_loaded=logo_loaded, logo_loaded=logo_loaded,
images_loaded=images_loaded, images_loaded=images_loaded,
loading_started_at=loading_started_at, 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) db.add(series)
await db.flush() await db.flush()

View File

@@ -11,6 +11,8 @@ via _check_initial_scan_status, not by this service itself.
""" """
import os import os
import re import re
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -24,6 +26,17 @@ from src.server.utils.dependencies import get_series_app
logger = structlog.get_logger(__name__) 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: class SetupService:
"""Service for setup operations during application initialization.""" """Service for setup operations during application initialization."""
@@ -92,6 +105,77 @@ class SetupService:
return "" 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 @classmethod
async def run(cls) -> int: async def run(cls) -> int:
"""Run the setup service. """Run the setup service.
@@ -152,8 +236,11 @@ class SetupService:
# Resolve key via provider search # Resolve key via provider search
resolved_key = await cls._resolve_key_via_search(title) resolved_key = await cls._resolve_key_via_search(title)
# Check filesystem properties
props = cls._get_series_properties(folder)
# Create AnimeSeries record # Create AnimeSeries record
await AnimeSeriesService.create( series = await AnimeSeriesService.create(
db=db, db=db,
key=resolved_key, key=resolved_key,
name=title, name=title,
@@ -162,8 +249,12 @@ class SetupService:
year=year, year=year,
loading_status="completed", loading_status="completed",
episodes_loaded=True, episodes_loaded=True,
logo_loaded=False, logo_loaded=props.logo_loaded,
images_loaded=False, 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 created_count += 1
@@ -172,7 +263,10 @@ class SetupService:
folder=folder_name, folder=folder_name,
title=title, title=title,
year=year, 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( logger.info(

View File

@@ -390,3 +390,155 @@ class TestSetupServiceRun:
assert result == 1 assert result == 1
mock_create.assert_called_once() 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