From 18d10b44b54ef11ab167762c110075d8593a7987 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 5 Jun 2026 20:05:04 +0200 Subject: [PATCH] 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. --- src/server/database/service.py | 12 +++ src/server/services/setup_service.py | 102 +++++++++++++++++- tests/unit/test_setup_service.py | 152 +++++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 4 deletions(-) diff --git a/src/server/database/service.py b/src/server/database/service.py index afc49e6..31989c1 100644 --- a/src/server/database/service.py +++ b/src/server/database/service.py @@ -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() diff --git a/src/server/services/setup_service.py b/src/server/services/setup_service.py index 0c09f30..bbd1a4c 100644 --- a/src/server/services/setup_service.py +++ b/src/server/services/setup_service.py @@ -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( diff --git a/tests/unit/test_setup_service.py b/tests/unit/test_setup_service.py index b83f09e..40850fe 100644 --- a/tests/unit/test_setup_service.py +++ b/tests/unit/test_setup_service.py @@ -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