"""TV Show domain entities.""" import re from dataclasses import dataclass, field from datetime import datetime from ..shared.value_objects import FilePath, FileSize, ImdbId from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus @dataclass class TVShow: """ TV Show entity representing a TV show in the media library. This is the main aggregate root for the TV shows domain. Migrated from agent/models/tv_show.py """ imdb_id: ImdbId title: str seasons_count: int status: ShowStatus tmdb_id: int | None = None first_air_date: str | None = None added_at: datetime = field(default_factory=datetime.now) def __post_init__(self): """Validate TV show entity.""" # Ensure ImdbId is actually an ImdbId instance if not isinstance(self.imdb_id, ImdbId): if isinstance(self.imdb_id, str): object.__setattr__(self, "imdb_id", ImdbId(self.imdb_id)) else: raise ValueError( f"imdb_id must be ImdbId or str, got {type(self.imdb_id)}" ) # Ensure ShowStatus is actually a ShowStatus instance if not isinstance(self.status, ShowStatus): if isinstance(self.status, str): object.__setattr__(self, "status", ShowStatus.from_string(self.status)) else: raise ValueError( f"status must be ShowStatus or str, got {type(self.status)}" ) # Validate seasons_count if not isinstance(self.seasons_count, int) or self.seasons_count < 0: raise ValueError( f"seasons_count must be a non-negative integer, got {self.seasons_count}" ) def is_ongoing(self) -> bool: """Check if the show is still ongoing.""" return self.status == ShowStatus.ONGOING def is_ended(self) -> bool: """Check if the show has ended.""" return self.status == ShowStatus.ENDED def get_folder_name(self) -> str: """ Get the folder name for this TV show. Format: "Title" Example: "Breaking.Bad" """ # Remove special characters and replace spaces with dots cleaned = re.sub(r"[^\w\s\.\-]", "", self.title) return cleaned.replace(" ", ".") def __str__(self) -> str: return f"{self.title} ({self.status.value}, {self.seasons_count} seasons)" def __repr__(self) -> str: return f"TVShow(imdb_id={self.imdb_id}, title='{self.title}')" @dataclass class Season: """ Season entity representing a season of a TV show. """ show_imdb_id: ImdbId season_number: SeasonNumber episode_count: int name: str | None = None overview: str | None = None air_date: str | None = None poster_path: str | None = None def __post_init__(self): """Validate season entity.""" # Ensure ImdbId is actually an ImdbId instance if not isinstance(self.show_imdb_id, ImdbId): if isinstance(self.show_imdb_id, str): object.__setattr__(self, "show_imdb_id", ImdbId(self.show_imdb_id)) # Ensure SeasonNumber is actually a SeasonNumber instance if not isinstance(self.season_number, SeasonNumber): if isinstance(self.season_number, int): object.__setattr__( self, "season_number", SeasonNumber(self.season_number) ) # Validate episode_count if not isinstance(self.episode_count, int) or self.episode_count < 0: raise ValueError( f"episode_count must be a non-negative integer, got {self.episode_count}" ) def is_special(self) -> bool: """Check if this is the specials season.""" return self.season_number.is_special() def get_folder_name(self) -> str: """ Get the folder name for this season. Format: "Season 01" or "Specials" for season 0 """ if self.is_special(): return "Specials" return f"Season {self.season_number.value:02d}" def __str__(self) -> str: if self.name: return f"Season {self.season_number.value}: {self.name}" return f"Season {self.season_number.value}" def __repr__(self) -> str: return f"Season(show={self.show_imdb_id}, number={self.season_number.value})" @dataclass class Episode: """ Episode entity representing an episode of a TV show. """ show_imdb_id: ImdbId season_number: SeasonNumber episode_number: EpisodeNumber title: str file_path: FilePath | None = None file_size: FileSize | None = None overview: str | None = None air_date: str | None = None still_path: str | None = None vote_average: float | None = None runtime: int | None = None # in minutes def __post_init__(self): """Validate episode entity.""" # Ensure ImdbId is actually an ImdbId instance if not isinstance(self.show_imdb_id, ImdbId): if isinstance(self.show_imdb_id, str): object.__setattr__(self, "show_imdb_id", ImdbId(self.show_imdb_id)) # Ensure SeasonNumber is actually a SeasonNumber instance if not isinstance(self.season_number, SeasonNumber): if isinstance(self.season_number, int): object.__setattr__( self, "season_number", SeasonNumber(self.season_number) ) # Ensure EpisodeNumber is actually an EpisodeNumber instance if not isinstance(self.episode_number, EpisodeNumber): if isinstance(self.episode_number, int): object.__setattr__( self, "episode_number", EpisodeNumber(self.episode_number) ) def has_file(self) -> bool: """Check if the episode has an associated file.""" return self.file_path is not None and self.file_path.exists() def is_downloaded(self) -> bool: """Check if the episode is downloaded.""" return self.has_file() def get_filename(self) -> str: """ Get the suggested filename for this episode. Format: "S01E01 - Episode Title.ext" Example: "S01E05 - Pilot.mkv" """ season_str = f"S{self.season_number.value:02d}" episode_str = f"E{self.episode_number.value:02d}" # Clean title for filename clean_title = re.sub(r"[^\w\s\-]", "", self.title) clean_title = clean_title.replace(" ", ".") return f"{season_str}{episode_str}.{clean_title}" def __str__(self) -> str: return f"S{self.season_number.value:02d}E{self.episode_number.value:02d} - {self.title}" def __repr__(self) -> str: return f"Episode(show={self.show_imdb_id}, S{self.season_number.value:02d}E{self.episode_number.value:02d})"