feat!: migrate to OpenAI native tool calls and fix circular deps (#fuck-gemini)

- Fix circular dependencies in agent/tools
- Migrate from custom JSON to OpenAI tool calls format
- Add async streaming (step_stream, complete_stream)
- Simplify prompt system and remove token counting
- Add 5 new API endpoints (/health, /v1/models, /api/memory/*)
- Add 3 new tools (get_torrent_by_index, add_torrent_by_index, set_language)
- Fix all 500 tests and add coverage config (80% threshold)
- Add comprehensive docs (README, pytest guide)

BREAKING: LLM interface changed, memory injection via get_memory()
This commit is contained in:
2025-12-06 19:11:05 +01:00
parent 2c8cdd3ab1
commit 9ca31e45e0
92 changed files with 7897 additions and 1786 deletions
+3 -2
View File
@@ -1,8 +1,9 @@
"""Movies domain - Business logic for movie management."""
from .entities import Movie
from .value_objects import MovieTitle, ReleaseYear, Quality
from .exceptions import MovieNotFound, InvalidMovieData
from .exceptions import InvalidMovieData, MovieNotFound
from .services import MovieService
from .value_objects import MovieTitle, Quality, ReleaseYear
__all__ = [
"Movie",
+30 -28
View File
@@ -1,86 +1,88 @@
"""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
from ..shared.value_objects import FilePath, FileSize, ImdbId
from .value_objects import MovieTitle, Quality, ReleaseYear
@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
release_year: ReleaseYear | None = 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
file_path: FilePath | None = None
file_size: FileSize | None = None
tmdb_id: int | None = 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))
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)}")
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))
object.__setattr__(self, "title", MovieTitle(self.title))
else:
raise ValueError(f"title must be MovieTitle or str, got {type(self.title)}")
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}')"
+4
View File
@@ -1,17 +1,21 @@
"""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
+17 -17
View File
@@ -1,6 +1,6 @@
"""Movie repository interfaces (abstract)."""
from abc import ABC, abstractmethod
from typing import List, Optional
from ..shared.value_objects import ImdbId
from .entities import Movie
@@ -9,64 +9,64 @@ 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]:
def find_by_imdb_id(self, imdb_id: ImdbId) -> Movie | None:
"""
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]:
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
"""
+58 -54
View File
@@ -1,13 +1,13 @@
"""Movie domain services - Business logic."""
import logging
from typing import Optional, List
import re
from ..shared.value_objects import ImdbId, FilePath
from ..shared.value_objects import FilePath, ImdbId
from .entities import Movie
from .value_objects import Quality
from .exceptions import MovieAlreadyExists, MovieNotFound
from .repositories import MovieRepository
from .exceptions import MovieNotFound, MovieAlreadyExists
from .value_objects import Quality
logger = logging.getLogger(__name__)
@@ -15,46 +15,48 @@ 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")
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
"""
@@ -62,89 +64,89 @@ class MovieService:
if not movie:
raise MovieNotFound(f"Movie with IMDb ID {imdb_id} not found")
return movie
def get_all_movies(self) -> List[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:
if "2160p" in filename_lower or "4k" in filename_lower:
return Quality.UHD_4K
elif '1080p' in filename_lower:
elif "1080p" in filename_lower:
return Quality.FULL_HD
elif '720p' in filename_lower:
elif "720p" in filename_lower:
return Quality.HD
elif '480p' in filename_lower:
elif "480p" in filename_lower:
return Quality.SD
return Quality.UNKNOWN
def extract_year_from_filename(self, filename: str) -> Optional[int]:
def extract_year_from_filename(self, filename: str) -> int | None:
"""
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
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:
@@ -152,37 +154,39 @@ class MovieService:
# 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'}
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")
logger.warning(
f"File too small to be a movie: {file_path.value.stat().st_size} bytes"
)
return False
return True
+32 -22
View File
@@ -1,27 +1,28 @@
"""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
"""
@@ -38,38 +39,44 @@ class Quality(Enum):
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)}")
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)")
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)
cleaned = re.sub(r"[^\w\s\.\-]", "", self.value)
# Replace spaces with dots
normalized = cleaned.replace(' ', '.')
normalized = cleaned.replace(" ", ".")
return normalized
def __str__(self) -> str:
return self.value
def __repr__(self) -> str:
return f"MovieTitle('{self.value}')"
@@ -78,22 +85,25 @@ class MovieTitle:
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)}")
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})"