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)