add movie module

This commit is contained in:
maxDorninger
2025-06-23 20:59:13 +02:00
parent 9de91a65b7
commit 0e108b6756
10 changed files with 1419 additions and 10 deletions

View File

@@ -1,7 +1,7 @@
from pydantic import BaseModel
class MetaDataProviderShowSearchResult(BaseModel):
class MetaDataProviderSearchResult(BaseModel):
poster_path: str | None
overview: str | None
name: str

View File

@@ -0,0 +1,3 @@
import logging
log = logging.getLogger(__name__)

View File

@@ -0,0 +1,51 @@
from typing import Annotated
from fastapi import Depends, Path
from media_manager.database import DbSessionDependency
from media_manager.movies.repository import MovieRepository
from media_manager.movies.schemas import Movie, MovieId
from media_manager.movies.service import MovieService
from media_manager.exceptions import NotFoundError
from fastapi import HTTPException
from media_manager.indexer.dependencies import indexer_service_dep
from media_manager.torrent.dependencies import torrent_service_dep
def get_movie_repository(db_session: DbSessionDependency) -> MovieRepository:
return MovieRepository(db_session)
movie_repository_dep = Annotated[MovieRepository, Depends(get_movie_repository)]
def get_movie_service(
movie_repository: movie_repository_dep,
torrent_service: torrent_service_dep,
indexer_service: indexer_service_dep,
) -> MovieService:
return MovieService(
movie_repository=movie_repository,
torrent_service=torrent_service,
indexer_service=indexer_service,
)
movie_service_dep = Annotated[MovieService, Depends(get_movie_service)]
def get_movie_by_id(
movie_service: movie_service_dep,
movie_id: MovieId = Path(..., description="The ID of the movie"),
) -> Movie:
try:
movie = movie_service.get_movie_by_id(movie_id)
except NotFoundError:
raise HTTPException(
status_code=404,
detail=f"Movie with ID {movie_id} not found.",
)
return movie
movie_dep = Annotated[Movie, Depends(get_movie_by_id)]

View File

@@ -42,7 +42,7 @@ class MovieFile(Base):
class MovieRequest(Base):
__tablename__ = "movie_request"
__table_args__ = (UniqueConstraint("season_id", "wanted_quality"),)
__table_args__ = (UniqueConstraint("movie_id", "wanted_quality"),)
id: Mapped[UUID] = mapped_column(primary_key=True)
movie_id: Mapped[UUID] = mapped_column(
ForeignKey(column="movie.id", ondelete="CASCADE"),

View File

@@ -0,0 +1,467 @@
from sqlalchemy import select, delete
from sqlalchemy.exc import (
IntegrityError,
SQLAlchemyError,
)
from sqlalchemy.orm import Session, joinedload
import logging
from media_manager.exceptions import NotFoundError
from media_manager.movies.models import Movie, MovieRequest, MovieFile
from media_manager.movies.schemas import (
Movie as MovieSchema,
MovieId,
MovieRequest as MovieRequestSchema,
MovieRequestId,
MovieFile as MovieFileSchema,
RichMovieRequest as RichMovieRequestSchema,
)
from media_manager.torrent.models import Torrent
from media_manager.torrent.schemas import TorrentId, Torrent as TorrentSchema
log = logging.getLogger(__name__)
class MovieRepository:
"""
Repository for managing movies in the database.
Provides methods to retrieve, save, and delete movies.
"""
def __init__(self, db: Session):
self.db = db
def get_movie_by_id(self, movie_id: MovieId) -> MovieSchema:
"""
Retrieve a movie by its ID.
:param movie_id: The ID of the movie to retrieve.
:return: A Movie object if found.
:raises NotFoundError: If the movie with the given ID is not found.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug(f"Attempting to retrieve movie with id: {movie_id}")
try:
stmt = select(Movie).where(Movie.id == movie_id)
result = self.db.execute(stmt).unique().scalar_one_or_none()
if not result:
log.warning(f"Movie with id {movie_id} not found.")
raise NotFoundError(f"Movie with id {movie_id} not found.")
log.info(f"Successfully retrieved movie with id: {movie_id}")
return MovieSchema.model_validate(result)
except SQLAlchemyError as e:
log.error(f"Database error while retrieving movie {movie_id}: {e}")
raise
def get_movie_by_external_id(
self, external_id: int, metadata_provider: str
) -> MovieSchema:
"""
Retrieve a movie by its external ID.
:param external_id: The ID of the movie to retrieve.
:param metadata_provider: The metadata provider associated with the ID.
:return: A Movie object if found.
:raises NotFoundError: If the movie with the given external ID and provider is not found.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug(
f"Attempting to retrieve movie with external_id: {external_id} and provider: {metadata_provider}"
)
try:
stmt = (
select(Movie)
.where(Movie.external_id == external_id)
.where(Movie.metadata_provider == metadata_provider)
)
result = self.db.execute(stmt).unique().scalar_one_or_none()
if not result:
log.warning(
f"Movie with external_id {external_id} and provider {metadata_provider} not found."
)
raise NotFoundError(
f"Movie with external_id {external_id} and provider {metadata_provider} not found."
)
log.info(
f"Successfully retrieved movie with external_id: {external_id} and provider: {metadata_provider}"
)
return MovieSchema.model_validate(result)
except SQLAlchemyError as e:
log.error(
f"Database error while retrieving movie by external_id {external_id}: {e}"
)
raise
def get_movies(self) -> list[MovieSchema]:
"""
Retrieve all movies from the database.
:return: A list of Movie objects.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug("Attempting to retrieve all movies.")
try:
stmt = select(Movie)
results = self.db.execute(stmt).scalars().unique().all()
log.info(f"Successfully retrieved {len(results)} movies.")
return [MovieSchema.model_validate(movie) for movie in results]
except SQLAlchemyError as e:
log.error(f"Database error while retrieving all movies: {e}")
raise
def save_movie(self, movie: MovieSchema) -> MovieSchema:
"""
Save a new movie or update an existing one in the database.
:param movie: The Movie object to save.
:return: The saved Movie object.
:raises ValueError: If a movie with the same primary key already exists (on insert).
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug(f"Attempting to save movie: {movie.name} (ID: {movie.id})")
db_movie = self.db.get(Movie, movie.id) if movie.id else None
if db_movie: # Update existing movie
log.debug(f"Updating existing movie with ID: {movie.id}")
db_movie.external_id = movie.external_id
db_movie.metadata_provider = movie.metadata_provider
db_movie.name = movie.name
db_movie.overview = movie.overview
db_movie.year = movie.year
else: # Insert new movie
log.debug(f"Creating new movie: {movie.name}")
db_movie = Movie(**movie.model_dump())
self.db.add(db_movie)
try:
self.db.commit()
self.db.refresh(db_movie)
log.info(f"Successfully saved movie: {db_movie.name} (ID: {db_movie.id})")
return MovieSchema.model_validate(db_movie)
except IntegrityError as e:
self.db.rollback()
log.error(f"Integrity error while saving movie {movie.name}: {e}")
raise ValueError(
f"Movie with this primary key or unique constraint violation: {e.orig}"
)
except SQLAlchemyError as e:
self.db.rollback()
log.error(f"Database error while saving movie {movie.name}: {e}")
raise
def delete_movie(self, movie_id: MovieId) -> None:
"""
Delete a movie by its ID.
:param movie_id: The ID of the movie to delete.
:raises NotFoundError: If the movie with the given ID is not found.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug(f"Attempting to delete movie with id: {movie_id}")
try:
movie = self.db.get(Movie, movie_id)
if not movie:
log.warning(f"Movie with id {movie_id} not found for deletion.")
raise NotFoundError(f"Movie with id {movie_id} not found.")
self.db.delete(movie)
self.db.commit()
log.info(f"Successfully deleted movie with id: {movie_id}")
except SQLAlchemyError as e:
self.db.rollback()
log.error(f"Database error while deleting movie {movie_id}: {e}")
raise
def add_movie_request(
self, movie_request: MovieRequestSchema
) -> MovieRequestSchema:
"""
Adds a Movie to the MovieRequest table, which marks it as requested.
:param movie_request: The MovieRequest object to add.
:return: The added MovieRequest object.
:raises IntegrityError: If a similar request already exists or violates constraints.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug(f"Adding movie request: {movie_request.model_dump_json()}")
db_model = MovieRequest(**movie_request.model_dump())
try:
self.db.add(db_model)
self.db.commit()
self.db.refresh(db_model)
log.info(f"Successfully added movie request with id: {db_model.id}")
return MovieRequestSchema.model_validate(db_model)
except IntegrityError as e:
self.db.rollback()
log.error(f"Integrity error while adding movie request: {e}")
raise
except SQLAlchemyError as e:
self.db.rollback()
log.error(f"Database error while adding movie request: {e}")
raise
def delete_movie_request(self, movie_request_id: MovieRequestId) -> None:
"""
Removes a MovieRequest by its ID.
:param movie_request_id: The ID of the movie request to delete.
:raises NotFoundError: If the movie request is not found.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug(f"Attempting to delete movie request with id: {movie_request_id}")
try:
stmt = delete(MovieRequest).where(MovieRequest.id == movie_request_id)
result = self.db.execute(stmt)
if result.rowcount == 0:
log.warning(
f"Movie request with id {movie_request_id} not found during delete execution (rowcount 0)."
)
self.db.commit()
log.info(f"Successfully deleted movie request with id: {movie_request_id}")
except SQLAlchemyError as e:
self.db.rollback()
log.error(
f"Database error while deleting movie request {movie_request_id}: {e}"
)
raise
def get_movie_requests(self) -> list[RichMovieRequestSchema]:
"""
Retrieve all movie requests.
:return: A list of RichMovieRequest objects.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug("Attempting to retrieve all movie requests.")
try:
stmt = select(MovieRequest).options(
joinedload(MovieRequest.requested_by),
joinedload(MovieRequest.authorized_by),
joinedload(MovieRequest.movie),
)
results = self.db.execute(stmt).scalars().unique().all()
log.info(f"Successfully retrieved {len(results)} movie requests.")
return [RichMovieRequestSchema.model_validate(x) for x in results]
except SQLAlchemyError as e:
log.error(f"Database error while retrieving movie requests: {e}")
raise
def add_movie_file(self, movie_file: MovieFileSchema) -> MovieFileSchema:
"""
Adds a movie file record to the database.
:param movie_file: The MovieFile object to add.
:return: The added MovieFile object.
:raises IntegrityError: If the record violates constraints.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug(f"Adding movie file: {movie_file.model_dump_json()}")
db_model = MovieFile(**movie_file.model_dump())
try:
self.db.add(db_model)
self.db.commit()
self.db.refresh(db_model)
log.info(
f"Successfully added movie file. Torrent ID: {db_model.torrent_id}, Path: {db_model.file_path_suffix}"
)
return MovieFileSchema.model_validate(db_model)
except IntegrityError as e:
self.db.rollback()
log.error(f"Integrity error while adding movie file: {e}")
raise
except SQLAlchemyError as e:
self.db.rollback()
log.error(f"Database error while adding movie file: {e}")
raise
def remove_movie_files_by_torrent_id(self, torrent_id: TorrentId) -> int:
"""
Removes movie file records associated with a given torrent ID.
:param torrent_id: The ID of the torrent whose movie files are to be removed.
:return: The number of movie files removed.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug(f"Attempting to remove movie files for torrent_id: {torrent_id}")
try:
stmt = delete(MovieFile).where(MovieFile.torrent_id == torrent_id)
result = self.db.execute(stmt)
self.db.commit()
deleted_count = result.rowcount
log.info(
f"Successfully removed {deleted_count} movie files for torrent_id: {torrent_id}"
)
return deleted_count
except SQLAlchemyError as e:
self.db.rollback()
log.error(
f"Database error removing movie files for torrent_id {torrent_id}: {e}"
)
raise
def get_movie_files_by_movie_id(self, movie_id: MovieId) -> list[MovieFileSchema]:
"""
Retrieve all movie files for a given movie ID.
:param movie_id: The ID of the movie.
:return: A list of MovieFile objects.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug(f"Attempting to retrieve movie files for movie_id: {movie_id}")
try:
stmt = select(MovieFile).where(MovieFile.movie_id == movie_id)
results = self.db.execute(stmt).scalars().all()
log.info(
f"Successfully retrieved {len(results)} movie files for movie_id: {movie_id}"
)
return [MovieFileSchema.model_validate(sf) for sf in results]
except SQLAlchemyError as e:
log.error(
f"Database error retrieving movie files for movie_id {movie_id}: {e}"
)
raise
def get_torrents_by_movie_id(self, movie_id: MovieId) -> list[TorrentSchema]:
"""
Retrieve all torrents associated with a given movie ID.
:param movie_id: The ID of the movie.
:return: A list of Torrent objects.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug(f"Attempting to retrieve torrents for movie_id: {movie_id}")
try:
stmt = (
select(Torrent)
.distinct()
.join(MovieFile, MovieFile.torrent_id == Torrent.id)
.where(MovieFile.movie_id == movie_id)
)
results = self.db.execute(stmt).scalars().unique().all()
log.info(
f"Successfully retrieved {len(results)} torrents for movie_id: {movie_id}"
)
return [TorrentSchema.model_validate(torrent) for torrent in results]
except SQLAlchemyError as e:
log.error(
f"Database error retrieving torrents for movie_id {movie_id}: {e}"
)
raise
def get_all_movies_with_torrents(self) -> list[MovieSchema]:
"""
Retrieve all movies that are associated with a torrent, ordered alphabetically by movie name.
:return: A list of Movie objects.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug("Attempting to retrieve all movies with torrents.")
try:
stmt = (
select(Movie)
.distinct()
.join(MovieFile, Movie.id == MovieFile.movie_id)
.join(Torrent, MovieFile.torrent_id == Torrent.id)
.order_by(Movie.name)
)
results = self.db.execute(stmt).scalars().unique().all()
log.info(f"Successfully retrieved {len(results)} movies with torrents.")
return [MovieSchema.model_validate(movie) for movie in results]
except SQLAlchemyError as e:
log.error(f"Database error retrieving all movies with torrents: {e}")
raise
def get_movie_request(self, movie_request_id: MovieRequestId) -> MovieRequestSchema:
"""
Retrieve a movie request by its ID.
:param movie_request_id: The ID of the movie request.
:return: A MovieRequest object.
:raises NotFoundError: If the movie request is not found.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug(f"Attempting to retrieve movie request with id: {movie_request_id}")
try:
request = self.db.get(MovieRequest, movie_request_id)
if not request:
log.warning(f"Movie request with id {movie_request_id} not found.")
raise NotFoundError(
f"Movie request with id {movie_request_id} not found."
)
log.info(
f"Successfully retrieved movie request with id: {movie_request_id}"
)
return MovieRequestSchema.model_validate(request)
except SQLAlchemyError as e:
log.error(
f"Database error retrieving movie request {movie_request_id}: {e}"
)
raise
def get_movie_by_torrent_id(self, torrent_id: TorrentId) -> MovieSchema:
"""
Retrieve a movie by a torrent ID.
:param torrent_id: The ID of the torrent to retrieve the movie for.
:return: A Movie object.
:raises NotFoundError: If the movie for the given torrent ID is not found.
:raises SQLAlchemyError: If a database error occurs.
"""
log.debug(f"Attempting to retrieve movie by torrent_id: {torrent_id}")
try:
stmt = (
select(Movie)
.join(MovieFile, Movie.id == MovieFile.movie_id)
.where(MovieFile.torrent_id == torrent_id)
)
result = self.db.execute(stmt).unique().scalar_one_or_none()
if not result:
log.warning(f"Movie for torrent_id {torrent_id} not found.")
raise NotFoundError(f"Movie for torrent_id {torrent_id} not found.")
log.info(f"Successfully retrieved movie for torrent_id: {torrent_id}")
return MovieSchema.model_validate(result)
except SQLAlchemyError as e:
log.error(
f"Database error retrieving movie by torrent_id {torrent_id}: {e}"
)
raise
def update_movie_attributes(
self,
movie_id: MovieId,
name: str | None = None,
overview: str | None = None,
year: int | None = None,
) -> MovieSchema:
"""
Update attributes of an existing movie.
:param movie_id: The ID of the movie to update.
:param name: The new name for the movie.
:param overview: The new overview for the movie.
:param year: The new year for the movie.
:return: The updated MovieSchema object.
"""
log.debug(f"Attempting to update attributes for movie ID: {movie_id}")
db_movie = self.db.get(Movie, movie_id)
if not db_movie:
log.warning(f"Movie with id {movie_id} not found for attribute update.")
raise NotFoundError(f"Movie with id {movie_id} not found.")
updated = False
if name is not None and db_movie.name != name:
db_movie.name = name
updated = True
if overview is not None and db_movie.overview != overview:
db_movie.overview = overview
updated = True
if year is not None and db_movie.year != year:
db_movie.year = year
updated = True
if updated:
self.db.commit()
self.db.refresh(db_movie)
log.info(f"Successfully updated attributes for movie ID: {movie_id}")
else:
log.info(f"No attribute changes needed for movie ID: {movie_id}")
return MovieSchema.model_validate(db_movie)

View File

@@ -0,0 +1,222 @@
from typing import Annotated
from fastapi import APIRouter, Depends, status
from fastapi.responses import JSONResponse
from media_manager.auth.schemas import UserRead
from media_manager.auth.users import current_active_user, current_superuser
from media_manager.indexer.schemas import PublicIndexerQueryResult, IndexerQueryResultId
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
from media_manager.torrent.schemas import Torrent
from media_manager.movies import log
from media_manager.exceptions import MediaAlreadyExists
from media_manager.movies.schemas import (
Movie,
MovieRequest,
MovieId,
RichMovieTorrent,
PublicMovie,
PublicMovieFile,
CreateMovieRequest,
MovieRequestId,
UpdateMovieRequest,
RichMovieRequest,
)
from media_manager.movies.dependencies import (
movie_dep,
movie_service_dep,
)
from media_manager.metadataProvider.dependencies import metadata_provider_dep
router = APIRouter()
# --------------------------------
# CREATE AND DELETE MOVIES
# --------------------------------
@router.post(
"/movies",
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(current_active_user)],
responses={
status.HTTP_201_CREATED: {
"model": Movie,
"description": "Successfully created movie",
},
status.HTTP_409_CONFLICT: {"model": str, "description": "Movie already exists"},
},
)
def add_a_movie(
movie_service: movie_service_dep,
metadata_provider: metadata_provider_dep,
movie_id: int,
):
try:
movie = movie_service.add_movie(
external_id=movie_id,
metadata_provider=metadata_provider,
)
except MediaAlreadyExists as e:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT, content={"message": str(e)}
)
return movie
# --------------------------------
# GET MOVIES
# --------------------------------
@router.get(
"/movies",
dependencies=[Depends(current_active_user)],
response_model=list[PublicMovie],
)
def get_all_movies(movie_service: movie_service_dep):
return movie_service.get_all_movies()
@router.get(
"/movies/search",
dependencies=[Depends(current_active_user)],
response_model=list[MetaDataProviderSearchResult],
)
def search_for_movie(
query: str,
movie_service: movie_service_dep,
metadata_provider: metadata_provider_dep,
):
return movie_service.search_for_movie(
query=query, metadata_provider=metadata_provider
)
@router.get(
"/movies/popular",
dependencies=[Depends(current_active_user)],
response_model=list[MetaDataProviderSearchResult],
)
def get_popular_movies(
movie_service: movie_service_dep,
metadata_provider: metadata_provider_dep,
):
return movie_service.get_popular_movies(metadata_provider=metadata_provider)
@router.get(
"/movies/torrents",
dependencies=[Depends(current_active_user)],
response_model=list[RichMovieTorrent],
)
def get_all_movies_with_torrents(movie_service: movie_service_dep):
return movie_service.get_all_movies_with_torrents()
@router.get(
"/movies/{movie_id}",
dependencies=[Depends(current_active_user)],
response_model=PublicMovie,
)
def get_movie_by_id(movie_service: movie_service_dep, movie_id: MovieId):
return movie_service.get_public_movie_by_id(movie_id=movie_id)
@router.get(
"/movies/{movie_id}/files",
dependencies=[Depends(current_active_user)],
response_model=list[PublicMovieFile],
)
def get_movie_files_by_movie_id(movie_service: movie_service_dep, movie_id: MovieId):
return movie_service.get_public_movie_files_by_movie_id(movie_id=movie_id)
# --------------------------------
# MOVIE REQUESTS
# --------------------------------
@router.post(
"/movies/requests",
status_code=status.HTTP_201_CREATED,
response_model=MovieRequest,
)
def create_movie_request(
movie_service: movie_service_dep,
movie_request: CreateMovieRequest,
user: UserRead = Depends(current_active_user),
):
return movie_service.add_movie_request(movie_request=movie_request)
@router.get(
"/movies/requests",
dependencies=[Depends(current_superuser)],
response_model=list[RichMovieRequest],
)
def get_all_movie_requests(movie_service: movie_service_dep):
return movie_service.get_all_movie_requests()
@router.put(
"/movies/requests/{movie_request_id}",
dependencies=[Depends(current_superuser)],
response_model=MovieRequest,
)
def update_movie_request(
movie_service: movie_service_dep,
movie_request_id: MovieRequestId,
update_movie_request: UpdateMovieRequest,
):
movie_request = movie_service.get_movie_request_by_id(
movie_request_id=movie_request_id
)
movie_request.min_quality = update_movie_request.min_quality
movie_request.wanted_quality = update_movie_request.wanted_quality
return movie_service.update_movie_request(movie_request=movie_request)
@router.delete(
"/movies/requests/{movie_request_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(current_superuser)],
)
def delete_movie_request(
movie_service: movie_service_dep, movie_request_id: MovieRequestId
):
movie_service.delete_movie_request(movie_request_id=movie_request_id)
return JSONResponse(status_code=status.HTTP_204_NO_CONTENT)
# --------------------------------
# TORRENTS
# --------------------------------
@router.get(
"/movies/{movie_id}/torrents",
dependencies=[Depends(current_active_user)],
response_model=list[PublicIndexerQueryResult],
)
def get_all_available_torrents_for_a_movie(
movie_service: movie_service_dep, movie_id: MovieId
):
return movie_service.get_all_available_torrents_for_a_movie(movie_id=movie_id)
@router.post(
"/movies/{movie_id}/torrents",
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(current_active_user)],
response_model=Torrent,
)
def download_torrent_for_movie(
movie_service: movie_service_dep,
movie_id: MovieId,
indexer_result_id: IndexerQueryResultId,
):
return movie_service.download_torrent(
public_indexer_result_id=indexer_result_id, movie_id=movie_id
)

View File

@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, ConfigDict, model_validator
from media_manager.auth.schemas import UserRead
from media_manager.torrent.models import Quality
from media_manager.torrent.schemas import TorrentId, TorrentStatus
from media_manager.torrent.schemas import TorrentId, TorrentStatus, Torrent
MovieId = typing.NewType("MovieId", UUID)
MovieRequestId = typing.NewType("MovieRequestId", UUID)
@@ -24,6 +24,23 @@ class Movie(BaseModel):
metadata_provider: str
class MovieFile(BaseModel):
model_config = ConfigDict(from_attributes=True)
movie_id: MovieId
file_path_suffix: str
quality: Quality
torrent_id: TorrentId | None = None
class PublicMovieFile(MovieFile):
downloaded: bool = False
class PublicMovie(Movie):
downloaded: bool = False
class MovieRequestBase(BaseModel):
min_quality: Quality
wanted_quality: Quality
@@ -53,3 +70,15 @@ class MovieRequest(BaseModel):
requested_by: UserRead | None = None
authorized: bool = False
authorized_by: UserRead | None = None
class RichMovieRequest(MovieRequest):
movie: Movie
class RichMovieTorrent(BaseModel):
movie_id: MovieId
name: str
year: int | None
metadata_provider: str
torrents: list[Torrent]

View File

@@ -0,0 +1,638 @@
import re
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from media_manager.exceptions import InvalidConfigError
from media_manager.indexer.repository import IndexerRepository
from media_manager.database import SessionLocal
from media_manager.indexer.schemas import IndexerQueryResult
from media_manager.indexer.schemas import IndexerQueryResultId
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
from media_manager.torrent.schemas import Torrent, TorrentStatus, Quality
from media_manager.torrent.service import TorrentService
from media_manager.movies import log
from media_manager.movies.schemas import (
Movie,
MovieId,
MovieRequest,
MovieFile,
RichMovieTorrent,
PublicMovie,
PublicMovieFile,
MovieRequestId,
RichMovieRequest,
)
from media_manager.torrent.schemas import QualityStrings
from media_manager.movies.repository import MovieRepository
from media_manager.exceptions import NotFoundError
import pprint
from pathlib import Path
from media_manager.config import BasicConfig
from media_manager.torrent.repository import TorrentRepository
from media_manager.torrent.utils import import_file, import_torrent
from media_manager.indexer.service import IndexerService
from media_manager.metadataProvider.abstractMetaDataProvider import (
AbstractMetadataProvider,
)
from media_manager.metadataProvider.tmdb import TmdbMetadataProvider
from media_manager.metadataProvider.tvdb import TvdbMetadataProvider
from media_manager.movies.schemas import Movie, MovieRequest, MovieRequestId
class MovieService:
def __init__(
self,
movie_repository: MovieRepository,
torrent_service: TorrentService,
indexer_service: IndexerService,
):
self.movie_repository = movie_repository
self.torrent_service = torrent_service
self.indexer_service = indexer_service
def add_movie(
self, external_id: int, metadata_provider: AbstractMetadataProvider
) -> Movie | None:
"""
Add a new movie to the database.
:param external_id: The ID of the movie in the metadata provider's system.
:param metadata_provider: The name of the metadata provider.
"""
movie_with_metadata = metadata_provider.get_movie_metadata(id=external_id)
saved_movie = self.movie_repository.save_movie(movie=movie_with_metadata)
metadata_provider.download_movie_poster_image(movie=saved_movie)
return saved_movie
def add_movie_request(self, movie_request: MovieRequest) -> MovieRequest:
"""
Add a new movie request.
:param movie_request: The movie request to add.
:return: The added movie request.
"""
return self.movie_repository.add_movie_request(movie_request=movie_request)
def get_movie_request_by_id(
self, movie_request_id: MovieRequestId
) -> MovieRequest | None:
"""
Get a movie request by its ID.
:param movie_request_id: The ID of the movie request.
:return: The movie request or None if not found.
"""
return self.movie_repository.get_movie_request(
movie_request_id=movie_request_id
)
def update_movie_request(self, movie_request: MovieRequest) -> MovieRequest:
"""
Update an existing movie request.
:param movie_request: The movie request to update.
:return: The updated movie request.
"""
self.movie_repository.delete_movie_request(movie_request_id=movie_request.id)
return self.movie_repository.add_movie_request(movie_request=movie_request)
def delete_movie_request(self, movie_request_id: MovieRequestId) -> None:
"""
Delete a movie request by its ID.
:param movie_request_id: The ID of the movie request to delete.
"""
self.movie_repository.delete_movie_request(movie_request_id=movie_request_id)
def get_public_movie_files_by_movie_id(
self, movie_id: MovieId
) -> list[PublicMovieFile]:
"""
Get all public movie files for a given movie ID.
:param movie_id: The ID of the movie.
:return: A list of public movie files.
"""
movie_files = self.movie_repository.get_movie_files_by_movie_id(
movie_id=movie_id
)
public_movie_files = [PublicMovieFile.model_validate(x) for x in movie_files]
result = []
for movie_file in public_movie_files:
if self.movie_file_exists_on_file(movie_file=movie_file):
movie_file.downloaded = True
result.append(movie_file)
return result
def check_if_movie_exists(
self,
external_id: int = None,
metadata_provider: str = None,
movie_id: MovieId = None,
) -> bool:
"""
Check if a movie exists in the database.
:param external_id: The external ID of the movie.
:param metadata_provider: The metadata provider.
:param movie_id: The ID of the movie.
:return: True if the movie exists, False otherwise.
:raises ValueError: If neither external ID and metadata provider nor movie ID are provided.
"""
if external_id and metadata_provider:
try:
self.movie_repository.get_movie_by_external_id(
external_id=external_id, metadata_provider=metadata_provider
)
return True
except NotFoundError:
return False
elif movie_id:
try:
self.movie_repository.get_movie_by_id(movie_id=movie_id)
return True
except NotFoundError:
return False
else:
raise ValueError(
"External ID and metadata provider or Movie ID must be provided"
)
def get_all_available_torrents_for_a_movie(
self, movie_id: MovieId, search_query_override: str = None
) -> list[IndexerQueryResult]:
"""
Get all available torrents for a given movie.
:param movie_id: The ID of the movie.
:param search_query_override: Optional override for the search query.
:return: A list of indexer query results.
"""
log.debug(f"getting all available torrents for movie {movie_id}")
movie = self.movie_repository.get_movie_by_id(movie_id=movie_id)
if search_query_override:
search_query = search_query_override
else:
search_query = f"{movie.name} {movie.year}"
torrents: list[IndexerQueryResult] = self.indexer_service.search(
query=search_query
)
if search_query_override:
log.debug(
f"Found with search query override {torrents.__len__()} torrents: {torrents}"
)
return torrents
result: list[IndexerQueryResult] = []
for torrent in torrents:
if (
movie.name.lower() in torrent.title.lower()
and str(movie.year) in torrent.title
):
result.append(torrent)
result.sort()
return result
def get_all_movies(self) -> list[Movie]:
"""
Get all movies.
:return: A list of all movies.
"""
return self.movie_repository.get_movies()
def search_for_movie(
self, query: str, metadata_provider: AbstractMetadataProvider
) -> list[MetaDataProviderSearchResult]:
"""
Search for movies using a given query.
:param query: The search query.
:param metadata_provider: The metadata provider to search.
:return: A list of metadata provider movie search results.
"""
results = metadata_provider.search_movie(query)
for result in results:
if self.check_if_movie_exists(
external_id=result.external_id, metadata_provider=metadata_provider.name
):
result.added = True
return results
def get_popular_movies(
self, metadata_provider: AbstractMetadataProvider
) -> list[MetaDataProviderSearchResult]:
"""
Get popular movies from a given metadata provider.
:param metadata_provider: The metadata provider to use.
:return: A list of metadata provider movie search results.
"""
results: list[MetaDataProviderSearchResult] = metadata_provider.search_movie()
filtered_results = []
for result in results:
if not self.check_if_movie_exists(
external_id=result.external_id, metadata_provider=metadata_provider.name
):
filtered_results.append(result)
return filtered_results
def get_public_movie_by_id(self, movie_id: MovieId) -> PublicMovie:
"""
Get a public movie by its ID.
:param movie_id: The ID of the movie.
:return: A public movie.
"""
movie = self.movie_repository.get_movie_by_id(movie_id=movie_id)
public_movie = PublicMovie.model_validate(movie)
public_movie.downloaded = self.is_movie_downloaded(movie_id=movie.id)
return public_movie
def get_movie_by_id(self, movie_id: MovieId) -> Movie:
"""
Get a movie by its ID.
:param movie_id: The ID of the movie.
:return: The movie.
"""
return self.movie_repository.get_movie_by_id(movie_id=movie_id)
def is_movie_downloaded(self, movie_id: MovieId) -> bool:
"""
Check if a movie is downloaded.
:param movie_id: The ID of the movie.
:return: True if the movie is downloaded, False otherwise.
"""
movie_files = self.movie_repository.get_movie_files_by_movie_id(
movie_id=movie_id
)
for movie_file in movie_files:
if self.movie_file_exists_on_file(movie_file=movie_file):
return True
return False
def movie_file_exists_on_file(self, movie_file: MovieFile) -> bool:
"""
Check if a movie file exists on the filesystem.
:param movie_file: The movie file to check.
:return: True if the file exists, False otherwise.
"""
if movie_file.torrent_id is None:
return True
else:
torrent_file = self.torrent_service.get_torrent_by_id(
torrent_id=movie_file.torrent_id
)
if torrent_file.imported:
return True
return False
def get_movie_by_external_id(
self, external_id: int, metadata_provider: str
) -> Movie | None:
"""
Get a movie by its external ID and metadata provider.
:param external_id: The external ID of the movie.
:param metadata_provider: The metadata provider.
:return: The movie or None if not found.
"""
return self.movie_repository.get_movie_by_external_id(
external_id=external_id, metadata_provider=metadata_provider
)
def get_all_movie_requests(self) -> list[RichMovieRequest]:
"""
Get all movie requests.
:return: A list of rich movie requests.
"""
return self.movie_repository.get_movie_requests()
def get_torrents_for_movie(self, movie: Movie) -> RichMovieTorrent:
"""
Get torrents for a given movie.
:param movie: The movie.
:return: A rich movie torrent.
"""
movie_torrents = self.movie_repository.get_torrents_by_movie_id(
movie_id=movie.id
)
return RichMovieTorrent(
movie_id=movie.id,
name=movie.name,
year=movie.year,
metadata_provider=movie.metadata_provider,
torrents=movie_torrents,
)
def get_all_movies_with_torrents(self) -> list[RichMovieTorrent]:
"""
Get all movies with torrents.
:return: A list of rich movie torrents.
"""
movies = self.movie_repository.get_all_movies_with_torrents()
return [self.get_torrents_for_movie(movie=movie) for movie in movies]
def download_torrent(
self,
public_indexer_result_id: IndexerQueryResultId,
movie_id: MovieId,
override_movie_file_path_suffix: str = "",
) -> Torrent:
"""
Download a torrent for a given indexer result and movie.
:param public_indexer_result_id: The ID of the indexer result.
:param movie_id: The ID of the movie.
:param override_movie_file_path_suffix: Optional override for the file path suffix.
:return: The downloaded torrent.
"""
indexer_result = self.indexer_service.get_result(
result_id=public_indexer_result_id
)
movie_torrent = self.torrent_service.download(indexer_result=indexer_result)
movie_file = MovieFile(
movie_id=movie_id,
quality=indexer_result.quality,
torrent_id=movie_torrent.id,
file_path_suffix=override_movie_file_path_suffix,
)
self.movie_repository.add_movie_file(movie_file=movie_file)
return movie_torrent
def download_approved_movie_request(
self, movie_request: MovieRequest, movie: Movie
) -> bool:
"""
Download an approved movie request.
:param movie_request: The movie request to download.
:param movie: The Movie object.
:return: True if the download was successful, False otherwise.
:raises ValueError: If the movie request is not authorized.
"""
if not movie_request.authorized:
log.error(
f"Movie request {movie_request.id} is not authorized for download"
)
raise ValueError(
f"Movie request {movie_request.id} is not authorized for download"
)
log.info(f"Downloading approved movie request {movie_request.id}")
torrents = self.get_all_available_torrents_for_a_movie(movie_id=movie.id)
available_torrents: list[IndexerQueryResult] = []
for torrent in torrents:
if (
(torrent.quality.value < movie_request.wanted_quality.value)
or (torrent.quality.value > movie_request.min_quality.value)
or (torrent.seeders < 3)
):
log.info(
f"Skipping torrent {torrent.title} with quality {torrent.quality} for movie {movie.id}, because it does not match the requested quality {movie_request.wanted_quality}"
)
else:
available_torrents.append(torrent)
log.info(
f"Taking torrent {torrent.title} with quality {torrent.quality} for movie {movie.id} into consideration"
)
if len(available_torrents) == 0:
log.warning(
f"No torrents matching criteria were found (wanted quality: {movie_request.wanted_quality}, min_quality: {movie_request.min_quality} for movie {movie.id})"
)
return False
available_torrents.sort()
torrent = self.torrent_service.download(indexer_result=available_torrents[0])
movie_file = MovieFile(
movie_id=movie.id,
quality=torrent.quality,
torrent_id=torrent.id,
file_path_suffix=QualityStrings[torrent.quality.name].value.upper(),
)
try:
self.movie_repository.add_movie_file(movie_file=movie_file)
except IntegrityError:
log.warning(
f"Movie file for movie {movie.id} and quality {torrent.quality} already exists, skipping."
)
self.delete_movie_request(movie_request.id)
return True
def import_torrent_files(self, torrent: Torrent, movie: Movie) -> None:
"""
Organizes files from a torrent into the movie directory structure.
:param torrent: The Torrent object
:param movie: The Movie object
"""
video_files, subtitle_files = import_torrent(torrent=torrent)
log.info(
f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files)
)
movie_file_path = (
BasicConfig().movie_directory
/ f"{movie.name} ({movie.year}) [{movie.metadata_provider}id-{movie.external_id}]"
)
movie_files = self.torrent_service.get_movie_files_of_torrent(torrent=torrent)
log.info(
f"Found {len(movie_files)} movie files associated with torrent {torrent.title}"
)
for movie_file in movie_files:
try:
movie_file_path.mkdir(parents=True, exist_ok=True)
except Exception as e:
log.warning(f"Could not create path {movie_file_path}: {e}")
movie_file_name = f"{movie.name} ({movie.year})"
if movie_file.file_path_suffix != "":
movie_file_name += f" - {movie_file.file_path_suffix}"
# import movie video
if video_files:
target_video_file = (
movie_file_path / f"{movie_file_name}{video_files[0].suffix}"
)
import_file(target_file=target_video_file, source_file=video_files[0])
# import subtitles
for subtitle_file in subtitle_files:
language_code_match = re.search(
r"\.([a-z]{2})\.srt$", subtitle_file.name, re.IGNORECASE
)
language_code = (
language_code_match.group(1) if language_code_match else "en"
)
target_subtitle_file = (
movie_file_path / f"{movie_file_name}.{language_code}.srt"
)
import_file(target_file=target_subtitle_file, source_file=subtitle_file)
log.info(f"Finished organizing files for torrent {torrent.title}")
def update_movie_metadata(
self, db_movie: Movie, metadata_provider: AbstractMetadataProvider
) -> Movie | None:
"""
Updates the metadata of a movie.
:param metadata_provider: The metadata provider object to fetch fresh data from.
:param db_movie: The Movie to update
:return: The updated Movie object, or None if the movie is not found or an error occurs.
"""
log.debug(f"Found movie: {db_movie.name} for metadata update.")
fresh_movie_data = metadata_provider.get_movie_metadata(id=db_movie.external_id)
if not fresh_movie_data:
log.warning(
f"Could not fetch fresh metadata for movie {db_movie.name} (External ID: {db_movie.external_id}) from {db_movie.metadata_provider}."
)
return db_movie
log.debug(f"Fetched fresh metadata for movie: {fresh_movie_data.name}")
self.movie_repository.update_movie_attributes(
movie_id=db_movie.id,
name=fresh_movie_data.name,
overview=fresh_movie_data.overview,
year=fresh_movie_data.year,
)
updated_movie = self.movie_repository.get_movie_by_id(movie_id=db_movie.id)
log.info(f"Successfully updated metadata for movie ID: {db_movie.id}")
metadata_provider.download_movie_poster_image()
return updated_movie
def auto_download_all_approved_movie_requests() -> None:
"""
Auto download all approved movie requests.
This is a standalone function as it creates its own DB session.
"""
db: Session = SessionLocal()
movie_repository = MovieRepository(db=db)
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
movie_service = MovieService(
movie_repository=movie_repository,
torrent_service=torrent_service,
indexer_service=indexer_service,
)
log.info("Auto downloading all approved movie requests")
movie_requests = movie_repository.get_movie_requests()
log.info(f"Found {len(movie_requests)} movie requests to process")
log.debug(f"Movie requests: {[x.model_dump() for x in movie_requests]}")
count = 0
for movie_request in movie_requests:
if movie_request.authorized:
log.info(f"Processing movie request {movie_request.id} for download")
movie = movie_repository.get_movie_by_id(movie_id=movie_request.movie_id)
if movie_service.download_approved_movie_request(
movie_request=movie_request, movie=movie
):
count += 1
else:
log.warning(
f"Failed to download movie request {movie_request.id} for movie {movie.name}"
)
log.info(f"Auto downloaded {count} approved movie requests")
db.commit()
db.close()
def import_all_torrents() -> None:
db: Session = SessionLocal()
movie_repository = MovieRepository(db=db)
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
movie_service = MovieService(
movie_repository=movie_repository,
torrent_service=torrent_service,
indexer_service=indexer_service,
)
log.info("Importing all torrents")
torrents = torrent_service.get_all_torrents()
log.info("Found %d torrents to import", len(torrents))
imported_torrents = []
for t in torrents:
if not t.imported and t.status == TorrentStatus.finished:
movie = torrent_service.get_movie_of_torrent(torrent=t)
if movie is None:
log.warning(
f"torrent {t.title} is not a movie torrent, skipping import."
)
continue
imported_torrents.append(
movie_service.import_torrent_files(torrent=t, movie=movie)
)
log.info("Finished importing all torrents")
db.commit()
db.close()
def update_all_movies_metadata() -> None:
"""
Updates the metadata of all movies.
"""
db: Session = SessionLocal()
movie_repository = MovieRepository(db=db)
movie_service = MovieService(
movie_repository=movie_repository,
torrent_service=TorrentService(torrent_repository=TorrentRepository(db=db)),
indexer_service=IndexerService(indexer_repository=IndexerRepository(db=db)),
)
log.info("Updating metadata for all movies")
movies = movie_repository.get_movies()
log.info(f"Found {len(movies)} movies to update")
for movie in movies:
try:
if movie.metadata_provider == "tmdb":
metadata_provider = TmdbMetadataProvider()
elif movie.metadata_provider == "tvdb":
metadata_provider = TvdbMetadataProvider()
else:
log.error(
f"Unsupported metadata provider {movie.metadata_provider} for movie {movie.name}, skipping update."
)
continue
except InvalidConfigError as e:
log.error(
f"Error initializing metadata provider {movie.metadata_provider} for movie {movie.name}: {str(e)}"
)
continue
updated_movie = movie_service.update_movie_metadata(
db_movie=movie, metadata_provider=metadata_provider
)
if updated_movie:
log.info(f"Successfully updated metadata for movie: {updated_movie.name}")
else:
log.warning(f"Failed to update metadata for movie: {movie.name}")
db.commit()
db.close()

View File

@@ -7,7 +7,6 @@ from media_manager.tv.models import SeasonFile, Show, Season
from media_manager.tv.schemas import SeasonFile as SeasonFileSchema, Show as ShowSchema
from media_manager.exceptions import NotFoundError
class TorrentRepository:
def __init__(self, db: DbSessionDependency):
self.db = db

View File

@@ -7,7 +7,7 @@ from media_manager.exceptions import NotFoundError
from media_manager.tv.schemas import Show, ShowId, SeasonId
from media_manager.tv.service import TvService
from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId
from media_manager.metadataProvider.schemas import MetaDataProviderShowSearchResult
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
@pytest.fixture
@@ -544,7 +544,7 @@ def test_get_all_available_torrents_for_a_season_no_results(
def test_search_for_show_no_existing(tv_service, mock_torrent_service):
query = "Test Show"
mock_metadata_provider = MagicMock()
search_result_item = MetaDataProviderShowSearchResult(
search_result_item = MetaDataProviderSearchResult(
external_id=123,
name="Test Show",
year=2022,
@@ -568,7 +568,7 @@ def test_search_for_show_no_existing(tv_service, mock_torrent_service):
def test_search_for_show_with_existing(tv_service, mock_torrent_service):
query = "Test Show"
mock_metadata_provider = MagicMock()
search_result_item = MetaDataProviderShowSearchResult(
search_result_item = MetaDataProviderSearchResult(
external_id=123,
name="Test Show",
year=2022,
@@ -600,7 +600,7 @@ def test_search_for_show_empty_results(tv_service, mock_torrent_service):
def test_get_popular_shows_none_added(tv_service, mock_torrent_service):
mock_metadata_provider = MagicMock()
popular_show1 = MetaDataProviderShowSearchResult(
popular_show1 = MetaDataProviderSearchResult(
external_id=123,
name="Popular Show 1",
year=2022,
@@ -609,7 +609,7 @@ def test_get_popular_shows_none_added(tv_service, mock_torrent_service):
added=False,
poster_path=None,
)
popular_show2 = MetaDataProviderShowSearchResult(
popular_show2 = MetaDataProviderSearchResult(
external_id=456,
name="Popular Show 2",
year=2023,
@@ -629,7 +629,7 @@ def test_get_popular_shows_none_added(tv_service, mock_torrent_service):
def test_get_popular_shows_all_added(tv_service, mock_torrent_service):
mock_metadata_provider = MagicMock()
popular_show1 = MetaDataProviderShowSearchResult(
popular_show1 = MetaDataProviderSearchResult(
external_id=123,
name="Popular Show 1",
year=2022,