New archi: domain driven development

Working but need to check out code
This commit is contained in:
2025-12-01 07:10:03 +01:00
parent 2b815502f6
commit 2c8cdd3ab1
73 changed files with 4084 additions and 853 deletions
+18
View File
@@ -0,0 +1,18 @@
"""TV Shows domain - Business logic for TV show management."""
from .entities import TVShow, Season, Episode
from .value_objects import ShowStatus, SeasonNumber, EpisodeNumber
from .exceptions import TVShowNotFound, InvalidEpisode, SeasonNotFound
from .services import TVShowService
__all__ = [
"TVShow",
"Season",
"Episode",
"ShowStatus",
"SeasonNumber",
"EpisodeNumber",
"TVShowNotFound",
"InvalidEpisode",
"SeasonNotFound",
"TVShowService",
]
+191
View File
@@ -0,0 +1,191 @@
"""TV Show domain entities."""
from dataclasses import dataclass, field
from typing import Optional, List
from datetime import datetime
from ..shared.value_objects import ImdbId, FilePath, FileSize
from .value_objects import ShowStatus, SeasonNumber, EpisodeNumber
@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: Optional[int] = None
overview: Optional[str] = None
poster_path: Optional[str] = None
first_air_date: Optional[str] = None
vote_average: Optional[float] = 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"
"""
import re
# 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: Optional[str] = None
overview: Optional[str] = None
air_date: Optional[str] = None
poster_path: Optional[str] = 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: Optional[FilePath] = None
file_size: Optional[FileSize] = None
overview: Optional[str] = None
air_date: Optional[str] = None
still_path: Optional[str] = None
vote_average: Optional[float] = None
runtime: Optional[int] = 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
import re
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})"
+27
View File
@@ -0,0 +1,27 @@
"""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
+130
View File
@@ -0,0 +1,130 @@
"""TV Show repository interfaces (abstract)."""
from abc import ABC, abstractmethod
from typing import List, Optional
from ..shared.value_objects import ImdbId
from .entities import TVShow, Season, Episode
from .value_objects import SeasonNumber, EpisodeNumber
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) -> Optional[TVShow]:
"""
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
) -> Optional[Season]:
"""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
) -> Optional[Episode]:
"""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
+228
View File
@@ -0,0 +1,228 @@
"""TV Show domain services - Business logic."""
import logging
from typing import Optional, List
import re
from ..shared.value_objects import ImdbId
from .entities import TVShow, Season, Episode
from .value_objects import SeasonNumber, EpisodeNumber
from .repositories import TVShowRepository, SeasonRepository, EpisodeRepository
from .exceptions import TVShowNotFound, TVShowAlreadyExists, SeasonNotFound, EpisodeNotFound
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: Optional[SeasonRepository] = None,
episode_repository: Optional[EpisodeRepository] = 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) -> Optional[tuple[int, int]]:
"""
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) -> Optional[tuple[int, int]]:
"""
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)
+96
View File
@@ -0,0 +1,96 @@
"""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