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:
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user