mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-04-17 15:13:24 +02:00
add movie module
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MetaDataProviderShowSearchResult(BaseModel):
|
||||
class MetaDataProviderSearchResult(BaseModel):
|
||||
poster_path: str | None
|
||||
overview: str | None
|
||||
name: str
|
||||
|
||||
3
media_manager/movies/__init__.py
Normal file
3
media_manager/movies/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
51
media_manager/movies/dependencies.py
Normal file
51
media_manager/movies/dependencies.py
Normal 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)]
|
||||
@@ -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"),
|
||||
|
||||
467
media_manager/movies/repository.py
Normal file
467
media_manager/movies/repository.py
Normal 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)
|
||||
222
media_manager/movies/router.py
Normal file
222
media_manager/movies/router.py
Normal 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
|
||||
)
|
||||
@@ -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]
|
||||
|
||||
638
media_manager/movies/service.py
Normal file
638
media_manager/movies/service.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user