"""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