- series.py: use regex to remove all trailing (YYYY) before appending year - nfo_service.py: _extract_year_from_name strips all trailing year suffixes - nfo_repair_service.py: add _read_tmdb_id() helper to extract TMDB ID from NFO
415 lines
14 KiB
Python
415 lines
14 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
import warnings
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from src.server.utils.filesystem import sanitize_folder_name
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Serie:
|
|
"""
|
|
Represents an anime series with metadata and episode information.
|
|
|
|
The `key` property is the unique identifier for the series
|
|
(provider-assigned, URL-safe).
|
|
The `folder` property is the filesystem folder name
|
|
(metadata only, not used for lookups).
|
|
|
|
Args:
|
|
key: Unique series identifier from provider
|
|
(e.g., "attack-on-titan"). Cannot be empty.
|
|
name: Display name of the series
|
|
site: Provider site URL
|
|
folder: Filesystem folder name (metadata only,
|
|
e.g., "Attack on Titan (2013)")
|
|
episodeDict: Dictionary mapping season numbers to
|
|
lists of episode numbers
|
|
year: Release year of the series (optional)
|
|
|
|
Raises:
|
|
ValueError: If key is None or empty string
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
key: str,
|
|
name: str,
|
|
site: str,
|
|
folder: str,
|
|
episodeDict: dict[int, list[int]],
|
|
year: int | None = None,
|
|
nfo_path: Optional[str] = None
|
|
):
|
|
if not key or not key.strip():
|
|
raise ValueError("Serie key cannot be None or empty")
|
|
|
|
self._key = key.strip()
|
|
self._name = name
|
|
self._site = site
|
|
self._folder = folder
|
|
self._episodeDict = episodeDict
|
|
self._year = year
|
|
self._nfo_path = nfo_path
|
|
|
|
def __str__(self):
|
|
"""String representation of Serie object"""
|
|
year_str = f", year={self.year}" if self.year else ""
|
|
return (
|
|
f"Serie(key='{self.key}', name='{self.name}', "
|
|
f"site='{self.site}', folder='{self.folder}', "
|
|
f"episodeDict={self.episodeDict}{year_str})"
|
|
)
|
|
|
|
def __repr__(self):
|
|
"""Concise developer representation of Serie object."""
|
|
season_count = len(self.episodeDict)
|
|
episode_count = sum(len(eps) for eps in self.episodeDict.values())
|
|
year_str = f", year={self.year}" if self.year else ""
|
|
return (
|
|
f"Serie(key={self.key!r}, name={self.name!r}"
|
|
f"{year_str}, seasons={season_count}, episodes={episode_count})"
|
|
)
|
|
|
|
@property
|
|
def key(self) -> str:
|
|
"""
|
|
Unique series identifier (primary identifier for all lookups).
|
|
|
|
This is the provider-assigned, URL-safe identifier used
|
|
throughout the application for series identification,
|
|
lookups, and operations.
|
|
|
|
Returns:
|
|
str: The unique series key
|
|
"""
|
|
return self._key
|
|
|
|
@key.setter
|
|
def key(self, value: str):
|
|
"""
|
|
Set the unique series identifier.
|
|
|
|
Args:
|
|
value: New key value
|
|
|
|
Raises:
|
|
ValueError: If value is None or empty string
|
|
"""
|
|
if not value or not value.strip():
|
|
raise ValueError("Serie key cannot be None or empty")
|
|
self._key = value.strip()
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self._name
|
|
|
|
@name.setter
|
|
def name(self, value: str):
|
|
self._name = value
|
|
|
|
@property
|
|
def site(self) -> str:
|
|
return self._site
|
|
|
|
@site.setter
|
|
def site(self, value: str):
|
|
self._site = value
|
|
|
|
@property
|
|
def folder(self) -> str:
|
|
"""
|
|
Filesystem folder name (metadata only, not used for lookups).
|
|
|
|
This property contains the local directory name where the series
|
|
files are stored. It should NOT be used as an identifier for
|
|
series lookups - use `key` instead.
|
|
|
|
Returns:
|
|
str: The filesystem folder name
|
|
"""
|
|
return self._folder
|
|
|
|
@folder.setter
|
|
def folder(self, value: str):
|
|
"""
|
|
Set the filesystem folder name.
|
|
|
|
Args:
|
|
value: Folder name for the series
|
|
"""
|
|
self._folder = value
|
|
|
|
@property
|
|
def episodeDict(self) -> dict[int, list[int]]:
|
|
return self._episodeDict
|
|
|
|
@episodeDict.setter
|
|
def episodeDict(self, value: dict[int, list[int]]):
|
|
self._episodeDict = value
|
|
|
|
@property
|
|
def year(self) -> int | None:
|
|
"""
|
|
Release year of the series.
|
|
|
|
Returns:
|
|
int or None: The year the series was released, or None if unknown
|
|
"""
|
|
return self._year
|
|
|
|
@year.setter
|
|
def year(self, value: int | None):
|
|
"""Set the release year of the series."""
|
|
self._year = value
|
|
|
|
@property
|
|
def nfo_path(self) -> Optional[str]:
|
|
"""
|
|
Path to the tvshow.nfo metadata file.
|
|
|
|
Returns:
|
|
str or None: Path to the NFO file, or None if not set
|
|
"""
|
|
return self._nfo_path
|
|
|
|
@nfo_path.setter
|
|
def nfo_path(self, value: Optional[str]):
|
|
"""Set the path to the NFO file."""
|
|
self._nfo_path = value
|
|
|
|
def has_nfo(self, base_directory: Optional[str] = None) -> bool:
|
|
"""
|
|
Check if tvshow.nfo file exists for this series.
|
|
|
|
Args:
|
|
base_directory: Base anime directory path. If provided, checks
|
|
relative to base_directory/folder/tvshow.nfo. If not provided,
|
|
uses nfo_path directly.
|
|
|
|
Returns:
|
|
bool: True if tvshow.nfo exists, False otherwise
|
|
"""
|
|
if base_directory:
|
|
nfo_file = Path(base_directory) / self.folder / "tvshow.nfo"
|
|
elif self._nfo_path:
|
|
nfo_file = Path(self._nfo_path)
|
|
else:
|
|
return False
|
|
|
|
return nfo_file.exists() and nfo_file.is_file()
|
|
|
|
def has_poster(self, base_directory: Optional[str] = None) -> bool:
|
|
"""
|
|
Check if poster.jpg file exists for this series.
|
|
|
|
Args:
|
|
base_directory: Base anime directory path. If provided, checks
|
|
relative to base_directory/folder/poster.jpg.
|
|
|
|
Returns:
|
|
bool: True if poster.jpg exists, False otherwise
|
|
"""
|
|
if not base_directory:
|
|
return False
|
|
|
|
poster_file = Path(base_directory) / self.folder / "poster.jpg"
|
|
return poster_file.exists() and poster_file.is_file()
|
|
|
|
def has_logo(self, base_directory: Optional[str] = None) -> bool:
|
|
"""
|
|
Check if logo.png file exists for this series.
|
|
|
|
Args:
|
|
base_directory: Base anime directory path. If provided, checks
|
|
relative to base_directory/folder/logo.png.
|
|
|
|
Returns:
|
|
bool: True if logo.png exists, False otherwise
|
|
"""
|
|
if not base_directory:
|
|
return False
|
|
|
|
logo_file = Path(base_directory) / self.folder / "logo.png"
|
|
return logo_file.exists() and logo_file.is_file()
|
|
|
|
def has_fanart(self, base_directory: Optional[str] = None) -> bool:
|
|
"""
|
|
Check if fanart.jpg file exists for this series.
|
|
|
|
Args:
|
|
base_directory: Base anime directory path. If provided, checks
|
|
relative to base_directory/folder/fanart.jpg.
|
|
|
|
Returns:
|
|
bool: True if fanart.jpg exists, False otherwise
|
|
"""
|
|
if not base_directory:
|
|
return False
|
|
|
|
fanart_file = Path(base_directory) / self.folder / "fanart.jpg"
|
|
return fanart_file.exists() and fanart_file.is_file()
|
|
|
|
@property
|
|
def name_with_year(self) -> str:
|
|
"""
|
|
Get the series name with year appended if available.
|
|
|
|
Returns a name in the format "Name (Year)" if year is available,
|
|
otherwise returns just the name. This should be used for creating
|
|
filesystem folders to distinguish series with the same name.
|
|
|
|
Returns:
|
|
str: Name with year in format "Name (Year)", or just name if no year
|
|
|
|
Example:
|
|
>>> serie = Serie("dororo", "Dororo", ..., year=2025)
|
|
>>> serie.name_with_year
|
|
'Dororo (2025)'
|
|
"""
|
|
if self._year:
|
|
import re
|
|
year_suffix = f" ({self._year})"
|
|
# Strip ALL trailing year suffixes before appending to prevent duplication
|
|
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', self._name).strip()
|
|
return f"{clean_name}{year_suffix}"
|
|
return self._name
|
|
|
|
@property
|
|
def sanitized_folder(self) -> str:
|
|
"""
|
|
Get a filesystem-safe folder name derived from the display name with year.
|
|
|
|
This property returns a sanitized version of the series name with year
|
|
(if available) suitable for use as a filesystem folder name. It removes/
|
|
replaces characters that are invalid for filesystems while preserving
|
|
Unicode characters.
|
|
|
|
Use this property when creating folders for the series on disk.
|
|
The `folder` property stores the actual folder name used.
|
|
|
|
Returns:
|
|
str: Filesystem-safe folder name based on display name with year
|
|
|
|
Example:
|
|
>>> serie = Serie("attack-on-titan", "Attack on Titan: Final", ..., year=2025)
|
|
>>> serie.sanitized_folder
|
|
'Attack on Titan Final (2025)'
|
|
"""
|
|
# Use name_with_year if available, fall back to folder, then key
|
|
name_to_sanitize = self.name_with_year or self._folder or self._key
|
|
try:
|
|
return sanitize_folder_name(name_to_sanitize)
|
|
except ValueError:
|
|
# Fallback to key if name cannot be sanitized
|
|
return sanitize_folder_name(self._key)
|
|
|
|
def ensure_folder_with_year(self) -> str:
|
|
"""Ensure folder name includes year if available.
|
|
|
|
If the serie has a year and the current folder name doesn't include it,
|
|
updates the folder name to include the year in format "Name (Year)".
|
|
|
|
This method should be called before creating folders or NFO files to
|
|
ensure consistent naming across the application.
|
|
|
|
Returns:
|
|
str: The folder name (updated if needed)
|
|
|
|
Example:
|
|
>>> serie = Serie("perfect-blue", "Perfect Blue", ..., folder="Perfect Blue", year=1997)
|
|
>>> serie.ensure_folder_with_year()
|
|
'Perfect Blue (1997)'
|
|
>>> serie.folder # folder property is updated
|
|
'Perfect Blue (1997)'
|
|
"""
|
|
if self._year:
|
|
# Check if folder already has year format
|
|
year_pattern = f"({self._year})"
|
|
if year_pattern not in self._folder:
|
|
# Update folder to include year
|
|
self._folder = self.sanitized_folder
|
|
logger.info(
|
|
f"Updated folder name for '{self._key}' to include year: {self._folder}"
|
|
)
|
|
return self._folder
|
|
|
|
def to_dict(self):
|
|
"""Convert Serie object to dictionary for JSON serialization."""
|
|
return {
|
|
"key": self.key,
|
|
"name": self.name,
|
|
"site": self.site,
|
|
"folder": self.folder,
|
|
"episodeDict": {
|
|
str(k): list(v) for k, v in self.episodeDict.items()
|
|
},
|
|
"year": self.year,
|
|
"nfo_path": self.nfo_path
|
|
}
|
|
|
|
@staticmethod
|
|
def from_dict(data: dict):
|
|
"""Create a Serie object from dictionary."""
|
|
# Convert keys to int
|
|
episode_dict = {
|
|
int(k): v for k, v in data["episodeDict"].items()
|
|
}
|
|
return Serie(
|
|
data["key"],
|
|
data["name"],
|
|
data["site"],
|
|
data["folder"],
|
|
episode_dict,
|
|
data.get("year"), # Optional year field for backward compatibility
|
|
data.get("nfo_path") # Optional nfo_path field
|
|
)
|
|
|
|
def save_to_file(self, filename: str):
|
|
"""Save Serie object to JSON file.
|
|
|
|
.. deprecated::
|
|
File-based storage is deprecated. Use database storage via
|
|
`AnimeSeriesService.create()` instead. This method will be
|
|
removed in v3.0.0.
|
|
|
|
Args:
|
|
filename: Path to save the JSON file
|
|
"""
|
|
warnings.warn(
|
|
"save_to_file() is deprecated and will be removed in v3.0.0. "
|
|
"Use database storage via AnimeSeriesService.create() instead.",
|
|
DeprecationWarning,
|
|
stacklevel=2
|
|
)
|
|
with open(filename, "w", encoding="utf-8") as file:
|
|
json.dump(self.to_dict(), file, indent=4)
|
|
|
|
@classmethod
|
|
def load_from_file(cls, filename: str) -> "Serie":
|
|
"""Load Serie object from JSON file.
|
|
|
|
.. deprecated::
|
|
File-based storage is deprecated. Use database storage via
|
|
`AnimeSeriesService.get_by_key()` instead. This method will be
|
|
removed in v3.0.0.
|
|
|
|
Args:
|
|
filename: Path to load the JSON file from
|
|
|
|
Returns:
|
|
Serie: The loaded Serie object
|
|
"""
|
|
warnings.warn(
|
|
"load_from_file() is deprecated and will be removed in v3.0.0. "
|
|
"Use database storage via AnimeSeriesService instead.",
|
|
DeprecationWarning,
|
|
stacklevel=2
|
|
)
|
|
with open(filename, "r", encoding="utf-8") as file:
|
|
data = json.load(file)
|
|
return cls.from_dict(data)
|