New archi: domain driven development
Working but need to check out code
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
"""Movies domain - Business logic for movie management."""
|
||||
from .entities import Movie
|
||||
from .value_objects import MovieTitle, ReleaseYear, Quality
|
||||
from .exceptions import MovieNotFound, InvalidMovieData
|
||||
from .services import MovieService
|
||||
|
||||
__all__ = [
|
||||
"Movie",
|
||||
"MovieTitle",
|
||||
"ReleaseYear",
|
||||
"Quality",
|
||||
"MovieNotFound",
|
||||
"InvalidMovieData",
|
||||
"MovieService",
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Movie domain entities."""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ..shared.value_objects import ImdbId, FilePath, FileSize
|
||||
from .value_objects import MovieTitle, ReleaseYear, Quality
|
||||
|
||||
|
||||
@dataclass
|
||||
class Movie:
|
||||
"""
|
||||
Movie entity representing a movie in the media library.
|
||||
|
||||
This is the main aggregate root for the movies domain.
|
||||
"""
|
||||
imdb_id: ImdbId
|
||||
title: MovieTitle
|
||||
release_year: Optional[ReleaseYear] = None
|
||||
quality: Quality = Quality.UNKNOWN
|
||||
file_path: Optional[FilePath] = None
|
||||
file_size: Optional[FileSize] = None
|
||||
tmdb_id: Optional[int] = None
|
||||
overview: Optional[str] = None
|
||||
poster_path: Optional[str] = None
|
||||
vote_average: Optional[float] = None
|
||||
added_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate movie 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 MovieTitle is actually a MovieTitle instance
|
||||
if not isinstance(self.title, MovieTitle):
|
||||
if isinstance(self.title, str):
|
||||
object.__setattr__(self, 'title', MovieTitle(self.title))
|
||||
else:
|
||||
raise ValueError(f"title must be MovieTitle or str, got {type(self.title)}")
|
||||
|
||||
def has_file(self) -> bool:
|
||||
"""Check if the movie has an associated file."""
|
||||
return self.file_path is not None and self.file_path.exists()
|
||||
|
||||
def is_downloaded(self) -> bool:
|
||||
"""Check if the movie is downloaded (has a file)."""
|
||||
return self.has_file()
|
||||
|
||||
def get_folder_name(self) -> str:
|
||||
"""
|
||||
Get the folder name for this movie.
|
||||
|
||||
Format: "Title (Year)"
|
||||
Example: "Inception (2010)"
|
||||
"""
|
||||
if self.release_year:
|
||||
return f"{self.title.value} ({self.release_year.value})"
|
||||
return self.title.value
|
||||
|
||||
def get_filename(self) -> str:
|
||||
"""
|
||||
Get the suggested filename for this movie.
|
||||
|
||||
Format: "Title.Year.Quality.ext"
|
||||
Example: "Inception.2010.1080p.mkv"
|
||||
"""
|
||||
parts = [self.title.normalized()]
|
||||
|
||||
if self.release_year:
|
||||
parts.append(str(self.release_year.value))
|
||||
|
||||
if self.quality != Quality.UNKNOWN:
|
||||
parts.append(self.quality.value)
|
||||
|
||||
# Extension will be added based on actual file
|
||||
return ".".join(parts)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.title.value} ({self.release_year.value if self.release_year else 'Unknown'})"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Movie(imdb_id={self.imdb_id}, title='{self.title.value}')"
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Movie domain exceptions."""
|
||||
from ..shared.exceptions import DomainException, NotFoundError
|
||||
|
||||
|
||||
class MovieNotFound(NotFoundError):
|
||||
"""Raised when a movie is not found."""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidMovieData(DomainException):
|
||||
"""Raised when movie data is invalid."""
|
||||
pass
|
||||
|
||||
|
||||
class MovieAlreadyExists(DomainException):
|
||||
"""Raised when trying to add a movie that already exists."""
|
||||
pass
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Movie repository interfaces (abstract)."""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional
|
||||
|
||||
from ..shared.value_objects import ImdbId
|
||||
from .entities import Movie
|
||||
|
||||
|
||||
class MovieRepository(ABC):
|
||||
"""
|
||||
Abstract repository for movie persistence.
|
||||
|
||||
This defines the interface that infrastructure implementations must follow.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def save(self, movie: Movie) -> None:
|
||||
"""
|
||||
Save a movie to the repository.
|
||||
|
||||
Args:
|
||||
movie: Movie entity to save
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_by_imdb_id(self, imdb_id: ImdbId) -> Optional[Movie]:
|
||||
"""
|
||||
Find a movie by its IMDb ID.
|
||||
|
||||
Args:
|
||||
imdb_id: IMDb ID to search for
|
||||
|
||||
Returns:
|
||||
Movie if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_all(self) -> List[Movie]:
|
||||
"""
|
||||
Get all movies in the repository.
|
||||
|
||||
Returns:
|
||||
List of all movies
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, imdb_id: ImdbId) -> bool:
|
||||
"""
|
||||
Delete a movie from the repository.
|
||||
|
||||
Args:
|
||||
imdb_id: IMDb ID of the movie to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def exists(self, imdb_id: ImdbId) -> bool:
|
||||
"""
|
||||
Check if a movie exists in the repository.
|
||||
|
||||
Args:
|
||||
imdb_id: IMDb ID to check
|
||||
|
||||
Returns:
|
||||
True if exists, False otherwise
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Movie domain services - Business logic."""
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
import re
|
||||
|
||||
from ..shared.value_objects import ImdbId, FilePath
|
||||
from .entities import Movie
|
||||
from .value_objects import Quality
|
||||
from .repositories import MovieRepository
|
||||
from .exceptions import MovieNotFound, MovieAlreadyExists
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MovieService:
|
||||
"""
|
||||
Domain service for movie-related business logic.
|
||||
|
||||
This service contains business rules that don't naturally fit
|
||||
within a single entity.
|
||||
"""
|
||||
|
||||
def __init__(self, repository: MovieRepository):
|
||||
"""
|
||||
Initialize movie service.
|
||||
|
||||
Args:
|
||||
repository: Movie repository for persistence
|
||||
"""
|
||||
self.repository = repository
|
||||
|
||||
def add_movie(self, movie: Movie) -> None:
|
||||
"""
|
||||
Add a new movie to the library.
|
||||
|
||||
Args:
|
||||
movie: Movie entity to add
|
||||
|
||||
Raises:
|
||||
MovieAlreadyExists: If movie with same IMDb ID already exists
|
||||
"""
|
||||
if self.repository.exists(movie.imdb_id):
|
||||
raise MovieAlreadyExists(f"Movie with IMDb ID {movie.imdb_id} already exists")
|
||||
|
||||
self.repository.save(movie)
|
||||
logger.info(f"Added movie: {movie.title.value} ({movie.imdb_id})")
|
||||
|
||||
def get_movie(self, imdb_id: ImdbId) -> Movie:
|
||||
"""
|
||||
Get a movie by IMDb ID.
|
||||
|
||||
Args:
|
||||
imdb_id: IMDb ID of the movie
|
||||
|
||||
Returns:
|
||||
Movie entity
|
||||
|
||||
Raises:
|
||||
MovieNotFound: If movie not found
|
||||
"""
|
||||
movie = self.repository.find_by_imdb_id(imdb_id)
|
||||
if not movie:
|
||||
raise MovieNotFound(f"Movie with IMDb ID {imdb_id} not found")
|
||||
return movie
|
||||
|
||||
def get_all_movies(self) -> List[Movie]:
|
||||
"""
|
||||
Get all movies in the library.
|
||||
|
||||
Returns:
|
||||
List of all movies
|
||||
"""
|
||||
return self.repository.find_all()
|
||||
|
||||
def update_movie(self, movie: Movie) -> None:
|
||||
"""
|
||||
Update an existing movie.
|
||||
|
||||
Args:
|
||||
movie: Movie entity with updated data
|
||||
|
||||
Raises:
|
||||
MovieNotFound: If movie doesn't exist
|
||||
"""
|
||||
if not self.repository.exists(movie.imdb_id):
|
||||
raise MovieNotFound(f"Movie with IMDb ID {movie.imdb_id} not found")
|
||||
|
||||
self.repository.save(movie)
|
||||
logger.info(f"Updated movie: {movie.title.value} ({movie.imdb_id})")
|
||||
|
||||
def remove_movie(self, imdb_id: ImdbId) -> None:
|
||||
"""
|
||||
Remove a movie from the library.
|
||||
|
||||
Args:
|
||||
imdb_id: IMDb ID of the movie to remove
|
||||
|
||||
Raises:
|
||||
MovieNotFound: If movie not found
|
||||
"""
|
||||
if not self.repository.delete(imdb_id):
|
||||
raise MovieNotFound(f"Movie with IMDb ID {imdb_id} not found")
|
||||
|
||||
logger.info(f"Removed movie with IMDb ID: {imdb_id}")
|
||||
|
||||
def detect_quality_from_filename(self, filename: str) -> Quality:
|
||||
"""
|
||||
Detect video quality from filename.
|
||||
|
||||
Args:
|
||||
filename: Filename to analyze
|
||||
|
||||
Returns:
|
||||
Detected quality or UNKNOWN
|
||||
"""
|
||||
filename_lower = filename.lower()
|
||||
|
||||
# Check for quality indicators
|
||||
if '2160p' in filename_lower or '4k' in filename_lower:
|
||||
return Quality.UHD_4K
|
||||
elif '1080p' in filename_lower:
|
||||
return Quality.FULL_HD
|
||||
elif '720p' in filename_lower:
|
||||
return Quality.HD
|
||||
elif '480p' in filename_lower:
|
||||
return Quality.SD
|
||||
|
||||
return Quality.UNKNOWN
|
||||
|
||||
def extract_year_from_filename(self, filename: str) -> Optional[int]:
|
||||
"""
|
||||
Extract release year from filename.
|
||||
|
||||
Args:
|
||||
filename: Filename to analyze
|
||||
|
||||
Returns:
|
||||
Year if found, None otherwise
|
||||
"""
|
||||
# Look for 4-digit year in parentheses or standalone
|
||||
# Examples: "Movie (2010)", "Movie.2010.1080p"
|
||||
patterns = [
|
||||
r'\((\d{4})\)', # (2010)
|
||||
r'\.(\d{4})\.', # .2010.
|
||||
r'\s(\d{4})\s', # 2010
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, filename)
|
||||
if match:
|
||||
year = int(match.group(1))
|
||||
# Validate year is reasonable
|
||||
if 1888 <= year <= 2100:
|
||||
return year
|
||||
|
||||
return None
|
||||
|
||||
def validate_movie_file(self, file_path: FilePath) -> bool:
|
||||
"""
|
||||
Validate that a file is a valid movie file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
True if valid movie file, False otherwise
|
||||
"""
|
||||
if not file_path.exists():
|
||||
logger.warning(f"File does not exist: {file_path}")
|
||||
return False
|
||||
|
||||
if not file_path.is_file():
|
||||
logger.warning(f"Path is not a file: {file_path}")
|
||||
return False
|
||||
|
||||
# Check file extension
|
||||
valid_extensions = {'.mkv', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm'}
|
||||
if file_path.value.suffix.lower() not in valid_extensions:
|
||||
logger.warning(f"Invalid file extension: {file_path.value.suffix}")
|
||||
return False
|
||||
|
||||
# Check file size (should be at least 100 MB for a movie)
|
||||
min_size = 100 * 1024 * 1024 # 100 MB
|
||||
if file_path.value.stat().st_size < min_size:
|
||||
logger.warning(f"File too small to be a movie: {file_path.value.stat().st_size} bytes")
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Movie domain value objects."""
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from ..shared.exceptions import ValidationError
|
||||
|
||||
|
||||
class Quality(Enum):
|
||||
"""Video quality levels."""
|
||||
SD = "480p"
|
||||
HD = "720p"
|
||||
FULL_HD = "1080p"
|
||||
UHD_4K = "2160p"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, quality_str: str) -> "Quality":
|
||||
"""
|
||||
Parse quality from string.
|
||||
|
||||
Args:
|
||||
quality_str: Quality string (e.g., "1080p", "720p")
|
||||
|
||||
Returns:
|
||||
Quality enum value
|
||||
"""
|
||||
quality_map = {
|
||||
"480p": cls.SD,
|
||||
"720p": cls.HD,
|
||||
"1080p": cls.FULL_HD,
|
||||
"2160p": cls.UHD_4K,
|
||||
}
|
||||
return quality_map.get(quality_str, cls.UNKNOWN)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MovieTitle:
|
||||
"""
|
||||
Value object representing a movie title.
|
||||
|
||||
Ensures the title is valid and normalized.
|
||||
"""
|
||||
value: str
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate movie title."""
|
||||
if not self.value:
|
||||
raise ValidationError("Movie title cannot be empty")
|
||||
|
||||
if not isinstance(self.value, str):
|
||||
raise ValidationError(f"Movie title must be a string, got {type(self.value)}")
|
||||
|
||||
if len(self.value) > 500:
|
||||
raise ValidationError(f"Movie title too long: {len(self.value)} characters (max 500)")
|
||||
|
||||
def normalized(self) -> str:
|
||||
"""
|
||||
Return normalized title for file system usage.
|
||||
|
||||
Removes special characters and replaces spaces with dots.
|
||||
"""
|
||||
import re
|
||||
# Remove special characters except spaces, dots, and hyphens
|
||||
cleaned = re.sub(r'[^\w\s\.\-]', '', self.value)
|
||||
# Replace spaces with dots
|
||||
normalized = cleaned.replace(' ', '.')
|
||||
return normalized
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"MovieTitle('{self.value}')"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseYear:
|
||||
"""
|
||||
Value object representing a movie release year.
|
||||
|
||||
Validates that the year is reasonable.
|
||||
"""
|
||||
value: int
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate release year."""
|
||||
if not isinstance(self.value, int):
|
||||
raise ValidationError(f"Release year must be an integer, got {type(self.value)}")
|
||||
|
||||
# Movies started around 1888, and we shouldn't have movies from the future
|
||||
if self.value < 1888 or self.value > 2100:
|
||||
raise ValidationError(f"Invalid release year: {self.value}")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ReleaseYear({self.value})"
|
||||
Reference in New Issue
Block a user