Updated folder structure (for Docker)
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
"""TV Shows domain - Business logic for TV show management."""
|
||||
|
||||
from .entities import Episode, Season, TVShow
|
||||
from .exceptions import InvalidEpisode, SeasonNotFound, TVShowNotFound
|
||||
from .services import TVShowService
|
||||
from .value_objects import EpisodeNumber, SeasonNumber, ShowStatus
|
||||
|
||||
__all__ = [
|
||||
"TVShow",
|
||||
"Season",
|
||||
"Episode",
|
||||
"ShowStatus",
|
||||
"SeasonNumber",
|
||||
"EpisodeNumber",
|
||||
"TVShowNotFound",
|
||||
"InvalidEpisode",
|
||||
"SeasonNotFound",
|
||||
"TVShowService",
|
||||
]
|
||||
@@ -0,0 +1,204 @@
|
||||
"""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})"
|
||||
@@ -0,0 +1,33 @@
|
||||
"""TV Show domain exceptions."""
|
||||
|
||||
from ..shared.exceptions import DomainException, NotFoundError
|
||||
|
||||
|
||||
class TVShowNotFound(NotFoundError):
|
||||
"""Raised when a TV show is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SeasonNotFound(NotFoundError):
|
||||
"""Raised when a season is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class EpisodeNotFound(NotFoundError):
|
||||
"""Raised when an episode is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidEpisode(DomainException):
|
||||
"""Raised when episode data is invalid."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TVShowAlreadyExists(DomainException):
|
||||
"""Raised when trying to add a TV show that already exists."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,126 @@
|
||||
"""TV Show repository interfaces (abstract)."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..shared.value_objects import ImdbId
|
||||
from .entities import Episode, Season, TVShow
|
||||
from .value_objects import EpisodeNumber, SeasonNumber
|
||||
|
||||
|
||||
class TVShowRepository(ABC):
|
||||
"""
|
||||
Abstract repository for TV show persistence.
|
||||
|
||||
This defines the interface that infrastructure implementations must follow.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def save(self, show: TVShow) -> None:
|
||||
"""
|
||||
Save a TV show to the repository.
|
||||
|
||||
Args:
|
||||
show: TVShow entity to save
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_by_imdb_id(self, imdb_id: ImdbId) -> TVShow | None:
|
||||
"""
|
||||
Find a TV show by its IMDb ID.
|
||||
|
||||
Args:
|
||||
imdb_id: IMDb ID to search for
|
||||
|
||||
Returns:
|
||||
TVShow if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_all(self) -> list[TVShow]:
|
||||
"""
|
||||
Get all TV shows in the repository.
|
||||
|
||||
Returns:
|
||||
List of all TV shows
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, imdb_id: ImdbId) -> bool:
|
||||
"""
|
||||
Delete a TV show from the repository.
|
||||
|
||||
Args:
|
||||
imdb_id: IMDb ID of the show to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def exists(self, imdb_id: ImdbId) -> bool:
|
||||
"""
|
||||
Check if a TV show exists in the repository.
|
||||
|
||||
Args:
|
||||
imdb_id: IMDb ID to check
|
||||
|
||||
Returns:
|
||||
True if exists, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SeasonRepository(ABC):
|
||||
"""Abstract repository for season persistence."""
|
||||
|
||||
@abstractmethod
|
||||
def save(self, season: Season) -> None:
|
||||
"""Save a season."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_by_show_and_number(
|
||||
self, show_imdb_id: ImdbId, season_number: SeasonNumber
|
||||
) -> Season | None:
|
||||
"""Find a season by show and season number."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_all_by_show(self, show_imdb_id: ImdbId) -> list[Season]:
|
||||
"""Get all seasons for a show."""
|
||||
pass
|
||||
|
||||
|
||||
class EpisodeRepository(ABC):
|
||||
"""Abstract repository for episode persistence."""
|
||||
|
||||
@abstractmethod
|
||||
def save(self, episode: Episode) -> None:
|
||||
"""Save an episode."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_by_show_season_episode(
|
||||
self,
|
||||
show_imdb_id: ImdbId,
|
||||
season_number: SeasonNumber,
|
||||
episode_number: EpisodeNumber,
|
||||
) -> Episode | None:
|
||||
"""Find an episode by show, season, and episode number."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_all_by_season(
|
||||
self, show_imdb_id: ImdbId, season_number: SeasonNumber
|
||||
) -> list[Episode]:
|
||||
"""Get all episodes for a season."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_all_by_show(self, show_imdb_id: ImdbId) -> list[Episode]:
|
||||
"""Get all episodes for a show."""
|
||||
pass
|
||||
@@ -0,0 +1,234 @@
|
||||
"""TV Show domain services - Business logic."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from ..shared.value_objects import ImdbId
|
||||
from .entities import TVShow
|
||||
from .exceptions import (
|
||||
TVShowAlreadyExists,
|
||||
TVShowNotFound,
|
||||
)
|
||||
from .repositories import EpisodeRepository, SeasonRepository, TVShowRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TVShowService:
|
||||
"""
|
||||
Domain service for TV show-related business logic.
|
||||
|
||||
This service contains business rules that don't naturally fit
|
||||
within a single entity.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
show_repository: TVShowRepository,
|
||||
season_repository: SeasonRepository | None = None,
|
||||
episode_repository: EpisodeRepository | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize TV show service.
|
||||
|
||||
Args:
|
||||
show_repository: TV show repository for persistence
|
||||
season_repository: Optional season repository
|
||||
episode_repository: Optional episode repository
|
||||
"""
|
||||
self.show_repository = show_repository
|
||||
self.season_repository = season_repository
|
||||
self.episode_repository = episode_repository
|
||||
|
||||
def track_show(self, show: TVShow) -> None:
|
||||
"""
|
||||
Start tracking a TV show.
|
||||
|
||||
Args:
|
||||
show: TVShow entity to track
|
||||
|
||||
Raises:
|
||||
TVShowAlreadyExists: If show is already being tracked
|
||||
"""
|
||||
if self.show_repository.exists(show.imdb_id):
|
||||
raise TVShowAlreadyExists(
|
||||
f"TV show with IMDb ID {show.imdb_id} is already tracked"
|
||||
)
|
||||
|
||||
self.show_repository.save(show)
|
||||
logger.info(f"Started tracking TV show: {show.title} ({show.imdb_id})")
|
||||
|
||||
def get_show(self, imdb_id: ImdbId) -> TVShow:
|
||||
"""
|
||||
Get a TV show by IMDb ID.
|
||||
|
||||
Args:
|
||||
imdb_id: IMDb ID of the show
|
||||
|
||||
Returns:
|
||||
TVShow entity
|
||||
|
||||
Raises:
|
||||
TVShowNotFound: If show not found
|
||||
"""
|
||||
show = self.show_repository.find_by_imdb_id(imdb_id)
|
||||
if not show:
|
||||
raise TVShowNotFound(f"TV show with IMDb ID {imdb_id} not found")
|
||||
return show
|
||||
|
||||
def get_all_shows(self) -> list[TVShow]:
|
||||
"""
|
||||
Get all tracked TV shows.
|
||||
|
||||
Returns:
|
||||
List of all TV shows
|
||||
"""
|
||||
return self.show_repository.find_all()
|
||||
|
||||
def get_ongoing_shows(self) -> list[TVShow]:
|
||||
"""
|
||||
Get all ongoing TV shows.
|
||||
|
||||
Returns:
|
||||
List of ongoing TV shows
|
||||
"""
|
||||
all_shows = self.show_repository.find_all()
|
||||
return [show for show in all_shows if show.is_ongoing()]
|
||||
|
||||
def get_ended_shows(self) -> list[TVShow]:
|
||||
"""
|
||||
Get all ended TV shows.
|
||||
|
||||
Returns:
|
||||
List of ended TV shows
|
||||
"""
|
||||
all_shows = self.show_repository.find_all()
|
||||
return [show for show in all_shows if show.is_ended()]
|
||||
|
||||
def update_show(self, show: TVShow) -> None:
|
||||
"""
|
||||
Update an existing TV show.
|
||||
|
||||
Args:
|
||||
show: TVShow entity with updated data
|
||||
|
||||
Raises:
|
||||
TVShowNotFound: If show doesn't exist
|
||||
"""
|
||||
if not self.show_repository.exists(show.imdb_id):
|
||||
raise TVShowNotFound(f"TV show with IMDb ID {show.imdb_id} not found")
|
||||
|
||||
self.show_repository.save(show)
|
||||
logger.info(f"Updated TV show: {show.title} ({show.imdb_id})")
|
||||
|
||||
def untrack_show(self, imdb_id: ImdbId) -> None:
|
||||
"""
|
||||
Stop tracking a TV show.
|
||||
|
||||
Args:
|
||||
imdb_id: IMDb ID of the show to untrack
|
||||
|
||||
Raises:
|
||||
TVShowNotFound: If show not found
|
||||
"""
|
||||
if not self.show_repository.delete(imdb_id):
|
||||
raise TVShowNotFound(f"TV show with IMDb ID {imdb_id} not found")
|
||||
|
||||
logger.info(f"Stopped tracking TV show with IMDb ID: {imdb_id}")
|
||||
|
||||
def parse_episode_from_filename(self, filename: str) -> tuple[int, int] | None:
|
||||
"""
|
||||
Parse season and episode numbers from filename.
|
||||
|
||||
Supports formats:
|
||||
- S01E05
|
||||
- 1x05
|
||||
- Season 1 Episode 5
|
||||
|
||||
Args:
|
||||
filename: Filename to parse
|
||||
|
||||
Returns:
|
||||
Tuple of (season, episode) if found, None otherwise
|
||||
"""
|
||||
filename_lower = filename.lower()
|
||||
|
||||
# Pattern 1: S01E05
|
||||
pattern1 = r"s(\d{1,2})e(\d{1,2})"
|
||||
match = re.search(pattern1, filename_lower)
|
||||
if match:
|
||||
return (int(match.group(1)), int(match.group(2)))
|
||||
|
||||
# Pattern 2: 1x05
|
||||
pattern2 = r"(\d{1,2})x(\d{1,2})"
|
||||
match = re.search(pattern2, filename_lower)
|
||||
if match:
|
||||
return (int(match.group(1)), int(match.group(2)))
|
||||
|
||||
# Pattern 3: Season 1 Episode 5
|
||||
pattern3 = r"season\s*(\d{1,2})\s*episode\s*(\d{1,2})"
|
||||
match = re.search(pattern3, filename_lower)
|
||||
if match:
|
||||
return (int(match.group(1)), int(match.group(2)))
|
||||
|
||||
return None
|
||||
|
||||
def validate_episode_file(self, filename: str) -> bool:
|
||||
"""
|
||||
Validate that a file is a valid episode file.
|
||||
|
||||
Args:
|
||||
filename: Filename to validate
|
||||
|
||||
Returns:
|
||||
True if valid episode file, False otherwise
|
||||
"""
|
||||
# Check file extension
|
||||
valid_extensions = {".mkv", ".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm"}
|
||||
extension = filename[filename.rfind(".") :].lower() if "." in filename else ""
|
||||
|
||||
if extension not in valid_extensions:
|
||||
logger.warning(f"Invalid file extension: {extension}")
|
||||
return False
|
||||
|
||||
# Check if we can parse episode info
|
||||
episode_info = self.parse_episode_from_filename(filename)
|
||||
if not episode_info:
|
||||
logger.warning(f"Could not parse episode info from filename: {filename}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def find_next_episode(
|
||||
self, show: TVShow, last_season: int, last_episode: int
|
||||
) -> tuple[int, int] | None:
|
||||
"""
|
||||
Find the next episode to download for a show.
|
||||
|
||||
Args:
|
||||
show: TVShow entity
|
||||
last_season: Last downloaded season number
|
||||
last_episode: Last downloaded episode number
|
||||
|
||||
Returns:
|
||||
Tuple of (season, episode) for next episode, or None if show is complete
|
||||
"""
|
||||
# If show has ended and we've watched all seasons, no next episode
|
||||
if show.is_ended() and last_season >= show.seasons_count:
|
||||
return None
|
||||
|
||||
# Simple logic: next episode in same season, or first episode of next season
|
||||
# This could be enhanced with actual episode counts per season
|
||||
next_episode = last_episode + 1
|
||||
next_season = last_season
|
||||
|
||||
# Assume max 50 episodes per season (could be improved with actual data)
|
||||
if next_episode > 50:
|
||||
next_season += 1
|
||||
next_episode = 1
|
||||
|
||||
# Don't go beyond known seasons
|
||||
if next_season > show.seasons_count:
|
||||
return None
|
||||
|
||||
return (next_season, next_episode)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""TV Show domain value objects."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from ..shared.exceptions import ValidationError
|
||||
|
||||
|
||||
class ShowStatus(Enum):
|
||||
"""Status of a TV show - whether it's still airing or has ended."""
|
||||
|
||||
ONGOING = "ongoing"
|
||||
ENDED = "ended"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, status_str: str) -> "ShowStatus":
|
||||
"""
|
||||
Parse status from string.
|
||||
|
||||
Args:
|
||||
status_str: Status string (e.g., "ongoing", "ended")
|
||||
|
||||
Returns:
|
||||
ShowStatus enum value
|
||||
"""
|
||||
status_map = {
|
||||
"ongoing": cls.ONGOING,
|
||||
"ended": cls.ENDED,
|
||||
}
|
||||
return status_map.get(status_str.lower(), cls.UNKNOWN)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SeasonNumber:
|
||||
"""
|
||||
Value object representing a season number.
|
||||
|
||||
Validates that the season number is valid (>= 0).
|
||||
Season 0 is used for specials.
|
||||
"""
|
||||
|
||||
value: int
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate season number."""
|
||||
if not isinstance(self.value, int):
|
||||
raise ValidationError(
|
||||
f"Season number must be an integer, got {type(self.value)}"
|
||||
)
|
||||
|
||||
if self.value < 0:
|
||||
raise ValidationError(f"Season number cannot be negative: {self.value}")
|
||||
|
||||
# Reasonable upper limit
|
||||
if self.value > 100:
|
||||
raise ValidationError(f"Season number too high: {self.value}")
|
||||
|
||||
def is_special(self) -> bool:
|
||||
"""Check if this is the specials season (season 0)."""
|
||||
return self.value == 0
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"SeasonNumber({self.value})"
|
||||
|
||||
def __int__(self) -> int:
|
||||
return self.value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EpisodeNumber:
|
||||
"""
|
||||
Value object representing an episode number.
|
||||
|
||||
Validates that the episode number is valid (>= 1).
|
||||
"""
|
||||
|
||||
value: int
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate episode number."""
|
||||
if not isinstance(self.value, int):
|
||||
raise ValidationError(
|
||||
f"Episode number must be an integer, got {type(self.value)}"
|
||||
)
|
||||
|
||||
if self.value < 1:
|
||||
raise ValidationError(f"Episode number must be >= 1, got {self.value}")
|
||||
|
||||
# Reasonable upper limit
|
||||
if self.value > 1000:
|
||||
raise ValidationError(f"Episode number too high: {self.value}")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"EpisodeNumber({self.value})"
|
||||
|
||||
def __int__(self) -> int:
|
||||
return self.value
|
||||
Reference in New Issue
Block a user