remove everything related to requests (#455)

This PR removes the requests feature. The functionality will be replaced
either by Seerr or by reimplementing it in a better way.
This commit is contained in:
Maximilian Dorninger
2026-02-22 19:46:47 +01:00
committed by GitHub
parent c2645000e5
commit a643c9426d
25 changed files with 633 additions and 2627 deletions

View File

@@ -3,7 +3,6 @@ from uuid import UUID
from sqlalchemy import ForeignKey, PrimaryKeyConstraint, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from media_manager.auth.db import User
from media_manager.database import Base
from media_manager.torrent.models import Quality
@@ -22,10 +21,6 @@ class Movie(Base):
original_language: Mapped[str | None] = mapped_column(default=None)
imdb_id: Mapped[str | None] = mapped_column(default=None)
movie_requests: Mapped[list["MovieRequest"]] = relationship(
"MovieRequest", back_populates="movie", cascade="all, delete-orphan"
)
class MovieFile(Base):
__tablename__ = "movie_file"
@@ -42,31 +37,3 @@ class MovieFile(Base):
)
torrent = relationship("Torrent", back_populates="movie_files", uselist=False)
class MovieRequest(Base):
__tablename__ = "movie_request"
__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"),
)
wanted_quality: Mapped[Quality]
min_quality: Mapped[Quality]
authorized: Mapped[bool] = mapped_column(default=False)
requested_by_id: Mapped[UUID | None] = mapped_column(
ForeignKey(column="user.id", ondelete="SET NULL"),
)
authorized_by_id: Mapped[UUID | None] = mapped_column(
ForeignKey(column="user.id", ondelete="SET NULL"),
)
requested_by: Mapped["User|None"] = relationship(
foreign_keys=[requested_by_id], uselist=False
)
authorized_by: Mapped["User|None"] = relationship(
foreign_keys=[authorized_by_id], uselist=False
)
movie = relationship("Movie", back_populates="movie_requests", uselist=False)

View File

@@ -5,10 +5,10 @@ from sqlalchemy.exc import (
IntegrityError,
SQLAlchemyError,
)
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.orm import Session
from media_manager.exceptions import ConflictError, NotFoundError
from media_manager.movies.models import Movie, MovieFile, MovieRequest
from media_manager.movies.models import Movie, MovieFile
from media_manager.movies.schemas import (
Movie as MovieSchema,
)
@@ -17,17 +17,10 @@ from media_manager.movies.schemas import (
)
from media_manager.movies.schemas import (
MovieId,
MovieRequestId,
)
from media_manager.movies.schemas import (
MovieRequest as MovieRequestSchema,
)
from media_manager.movies.schemas import (
MovieTorrent as MovieTorrentSchema,
)
from media_manager.movies.schemas import (
RichMovieRequest as RichMovieRequestSchema,
)
from media_manager.torrent.models import Torrent
from media_manager.torrent.schemas import TorrentId
@@ -173,46 +166,6 @@ class MovieRepository:
log.exception(f"Database error while deleting movie {movie_id}")
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(
id=movie_request.id,
movie_id=movie_request.movie_id,
requested_by_id=movie_request.requested_by.id
if movie_request.requested_by
else None,
authorized_by_id=movie_request.authorized_by.id
if movie_request.authorized_by
else None,
wanted_quality=movie_request.wanted_quality,
min_quality=movie_request.min_quality,
authorized=movie_request.authorized,
)
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:
self.db.rollback()
log.exception("Integrity error while adding movie request")
raise
except SQLAlchemyError:
self.db.rollback()
log.exception("Database error while adding movie request")
raise
def set_movie_library(self, movie_id: MovieId, library: str) -> None:
"""
Sets the library for a movie.
@@ -234,49 +187,6 @@ class MovieRepository:
log.exception(f"Database error setting library for movie {movie_id}")
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.
"""
try:
stmt = delete(MovieRequest).where(MovieRequest.id == movie_request_id)
result = self.db.execute(stmt)
if result.rowcount == 0:
self.db.rollback()
msg = f"movie request with id {movie_request_id} not found."
raise NotFoundError(msg)
self.db.commit()
# Successfully deleted movie request with id: {movie_request_id}
except SQLAlchemyError:
self.db.rollback()
log.exception(
f"Database error while deleting movie request {movie_request_id}"
)
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.
"""
try:
stmt = select(MovieRequest).options(
joinedload(MovieRequest.requested_by),
joinedload(MovieRequest.authorized_by),
joinedload(MovieRequest.movie),
)
results = self.db.execute(stmt).scalars().unique().all()
return [RichMovieRequestSchema.model_validate(x) for x in results]
except SQLAlchemyError:
log.exception("Database error while retrieving movie requests")
raise
def add_movie_file(self, movie_file: MovieFileSchema) -> MovieFileSchema:
"""
Adds a movie file record to the database.
@@ -396,25 +306,6 @@ class MovieRepository:
log.exception("Database error retrieving all movies with torrents")
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.
"""
try:
request = self.db.get(MovieRequest, movie_request_id)
if not request:
msg = f"Movie request with id {movie_request_id} not found."
raise NotFoundError(msg)
return MovieRequestSchema.model_validate(request)
except SQLAlchemyError:
log.exception(f"Database error retrieving movie request {movie_request_id}")
raise
def get_movie_by_torrent_id(self, torrent_id: TorrentId) -> MovieSchema:
"""
Retrieve a movie by a torrent ID.

View File

@@ -1,9 +1,7 @@
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from media_manager.auth.schemas import UserRead
from media_manager.auth.users import current_active_user, current_superuser
from media_manager.config import LibraryItem, MediaManagerConfig
from media_manager.exceptions import ConflictError, NotFoundError
@@ -13,20 +11,14 @@ from media_manager.indexer.schemas import (
)
from media_manager.metadataProvider.dependencies import metadata_provider_dep
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
from media_manager.movies import log
from media_manager.movies.dependencies import (
movie_dep,
movie_service_dep,
)
from media_manager.movies.schemas import (
CreateMovieRequest,
Movie,
MovieRequest,
MovieRequestBase,
MovieRequestId,
PublicMovie,
PublicMovieFile,
RichMovieRequest,
RichMovieTorrent,
)
from media_manager.schemas import MediaImportSuggestion
@@ -188,103 +180,6 @@ def get_available_libraries() -> list[LibraryItem]:
return MediaManagerConfig().misc.movie_libraries
# -----------------------------------------------------------------------------
# MOVIE REQUESTS
# -----------------------------------------------------------------------------
@router.get(
"/requests",
dependencies=[Depends(current_active_user)],
)
def get_all_movie_requests(movie_service: movie_service_dep) -> list[RichMovieRequest]:
"""
Get all movie requests.
"""
return movie_service.get_all_movie_requests()
@router.post(
"/requests",
status_code=status.HTTP_201_CREATED,
)
def create_movie_request(
movie_service: movie_service_dep,
movie_request: CreateMovieRequest,
user: Annotated[UserRead, Depends(current_active_user)],
) -> MovieRequest:
"""
Create a new movie request.
"""
log.info(
f"User {user.email} is creating a movie request for {movie_request.movie_id}"
)
movie_request: MovieRequest = MovieRequest.model_validate(movie_request)
movie_request.requested_by = user
if user.is_superuser:
movie_request.authorized = True
movie_request.authorized_by = user
return movie_service.add_movie_request(movie_request=movie_request)
@router.put(
"/requests/{movie_request_id}",
)
def update_movie_request(
movie_service: movie_service_dep,
movie_request_id: MovieRequestId,
update_movie_request: MovieRequestBase,
user: Annotated[UserRead, Depends(current_active_user)],
) -> MovieRequest:
"""
Update an existing movie request.
"""
movie_request = movie_service.get_movie_request_by_id(
movie_request_id=movie_request_id
)
if movie_request.requested_by.id != user.id or user.is_superuser:
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.patch("/requests/{movie_request_id}", status_code=status.HTTP_204_NO_CONTENT)
def authorize_request(
movie_service: movie_service_dep,
movie_request_id: MovieRequestId,
user: Annotated[UserRead, Depends(current_superuser)],
authorized_status: bool = False,
) -> None:
"""
Authorize or de-authorize a movie request.
"""
movie_request = movie_service.get_movie_request_by_id(
movie_request_id=movie_request_id
)
movie_request.authorized = authorized_status
if authorized_status:
movie_request.authorized_by = user
else:
movie_request.authorized_by = None
movie_service.update_movie_request(movie_request=movie_request)
@router.delete(
"/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
) -> None:
"""
Delete a movie request.
"""
movie_service.delete_movie_request(movie_request_id=movie_request_id)
# -----------------------------------------------------------------------------
# MOVIES - SINGLE RESOURCE
# -----------------------------------------------------------------------------

View File

@@ -2,14 +2,12 @@ import typing
import uuid
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field
from media_manager.auth.schemas import UserRead
from media_manager.torrent.models import Quality
from media_manager.torrent.schemas import TorrentId, TorrentStatus
MovieId = typing.NewType("MovieId", UUID)
MovieRequestId = typing.NewType("MovieRequestId", UUID)
class Movie(BaseModel):
@@ -40,38 +38,6 @@ class PublicMovieFile(MovieFile):
imported: bool = False
class MovieRequestBase(BaseModel):
min_quality: Quality
wanted_quality: Quality
@model_validator(mode="after")
def ensure_wanted_quality_is_eq_or_gt_min_quality(self) -> "MovieRequestBase":
if self.min_quality.value < self.wanted_quality.value:
msg = "wanted_quality must be equal to or lower than minimum_quality."
raise ValueError(msg)
return self
class CreateMovieRequest(MovieRequestBase):
movie_id: MovieId
class MovieRequest(MovieRequestBase):
model_config = ConfigDict(from_attributes=True)
id: MovieRequestId = Field(default_factory=lambda: MovieRequestId(uuid.uuid4()))
movie_id: MovieId
requested_by: UserRead | None = None
authorized: bool = False
authorized_by: UserRead | None = None
class RichMovieRequest(MovieRequest):
movie: Movie
class MovieTorrent(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@@ -4,10 +4,9 @@ from pathlib import Path
from typing import overload
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from media_manager.config import MediaManagerConfig
from media_manager.database import SessionLocal, get_session
from media_manager.database import get_session
from media_manager.exceptions import InvalidConfigError, NotFoundError, RenameError
from media_manager.indexer.repository import IndexerRepository
from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId
@@ -25,11 +24,8 @@ from media_manager.movies.schemas import (
Movie,
MovieFile,
MovieId,
MovieRequest,
MovieRequestId,
PublicMovie,
PublicMovieFile,
RichMovieRequest,
RichMovieTorrent,
)
from media_manager.notification.repository import NotificationRepository
@@ -38,7 +34,6 @@ from media_manager.schemas import MediaImportSuggestion
from media_manager.torrent.repository import TorrentRepository
from media_manager.torrent.schemas import (
Quality,
QualityStrings,
Torrent,
TorrentStatus,
)
@@ -89,44 +84,6 @@ class MovieService:
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:
"""
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 delete_movie(
self,
movie: Movie,
@@ -391,14 +348,6 @@ class MovieService:
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 set_movie_library(self, movie: Movie, library: str) -> None:
self.movie_repository.set_movie_library(movie_id=movie.id, library=library)
@@ -471,65 +420,6 @@ class MovieService:
self.torrent_service.resume_download(torrent=movie_torrent)
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:
msg = "Movie request is not authorized"
raise ValueError(msg)
log.info(f"Downloading approved movie request {movie_request.id}")
torrents = self.get_all_available_torrents_for_movie(movie=movie)
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.debug(
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.debug(
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 found for movie request {movie_request.id} with quality between {QualityStrings[movie_request.min_quality.name]} and {QualityStrings[movie_request.wanted_quality.name]}"
)
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.name} and torrent {torrent.title} already exists"
)
self.delete_movie_request(movie_request.id)
return True
def get_movie_root_path(self, movie: Movie) -> Path:
misc_config = MediaManagerConfig().misc
movie_file_path = (
@@ -774,47 +664,6 @@ class MovieService:
return importable_movies
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() if SessionLocal else next(get_session())
movie_repository = MovieRepository(db=db)
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
notification_service = NotificationService(
notification_repository=NotificationRepository(db=db)
)
movie_service = MovieService(
movie_repository=movie_repository,
torrent_service=torrent_service,
indexer_service=indexer_service,
notification_service=notification_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")
count = 0
for movie_request in movie_requests:
if movie_request.authorized:
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.info(
f"Could not 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_movie_torrents() -> None:
with next(get_session()) as db:
movie_repository = MovieRepository(db=db)

View File

@@ -5,12 +5,10 @@ from apscheduler.triggers.cron import CronTrigger
import media_manager.database
from media_manager.config import MediaManagerConfig
from media_manager.movies.service import (
auto_download_all_approved_movie_requests,
import_all_movie_torrents,
update_all_movies_metadata,
)
from media_manager.tv.service import (
auto_download_all_approved_season_requests,
import_all_show_torrents,
update_all_non_ended_shows_metadata,
)
@@ -23,7 +21,6 @@ def setup_scheduler(config: MediaManagerConfig) -> BackgroundScheduler:
jobstores = {"default": SQLAlchemyJobStore(engine=media_manager.database.engine)}
scheduler = BackgroundScheduler(jobstores=jobstores)
every_15_minutes_trigger = CronTrigger(minute="*/15", hour="*")
daily_trigger = CronTrigger(hour=0, minute=0, jitter=60 * 60 * 24 * 2)
weekly_trigger = CronTrigger(
day_of_week="mon", hour=0, minute=0, jitter=60 * 60 * 24 * 2
)
@@ -39,18 +36,6 @@ def setup_scheduler(config: MediaManagerConfig) -> BackgroundScheduler:
id="import_all_show_torrents",
replace_existing=True,
)
scheduler.add_job(
auto_download_all_approved_season_requests,
daily_trigger,
id="auto_download_all_approved_season_requests",
replace_existing=True,
)
scheduler.add_job(
auto_download_all_approved_movie_requests,
daily_trigger,
id="auto_download_all_approved_movie_requests",
replace_existing=True,
)
scheduler.add_job(
update_all_movies_metadata,
weekly_trigger,

View File

@@ -3,7 +3,6 @@ from uuid import UUID
from sqlalchemy import ForeignKey, PrimaryKeyConstraint, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from media_manager.auth.db import User
from media_manager.database import Base
from media_manager.torrent.models import Quality
@@ -48,10 +47,6 @@ class Season(Base):
back_populates="season", cascade="all, delete"
)
season_requests = relationship(
"SeasonRequest", back_populates="season", cascade="all, delete"
)
class Episode(Base):
__tablename__ = "episode"
@@ -85,29 +80,3 @@ class EpisodeFile(Base):
torrent = relationship("Torrent", back_populates="episode_files", uselist=False)
episode = relationship("Episode", back_populates="episode_files", uselist=False)
class SeasonRequest(Base):
__tablename__ = "season_request"
__table_args__ = (UniqueConstraint("season_id", "wanted_quality"),)
id: Mapped[UUID] = mapped_column(primary_key=True)
season_id: Mapped[UUID] = mapped_column(
ForeignKey(column="season.id", ondelete="CASCADE"),
)
wanted_quality: Mapped[Quality]
min_quality: Mapped[Quality]
requested_by_id: Mapped[UUID | None] = mapped_column(
ForeignKey(column="user.id", ondelete="SET NULL"),
)
authorized: Mapped[bool] = mapped_column(default=False)
authorized_by_id: Mapped[UUID | None] = mapped_column(
ForeignKey(column="user.id", ondelete="SET NULL"),
)
requested_by: Mapped["User|None"] = relationship(
foreign_keys=[requested_by_id], uselist=False
)
authorized_by: Mapped["User|None"] = relationship(
foreign_keys=[authorized_by_id], uselist=False
)
season = relationship("Season", back_populates="season_requests", uselist=False)

View File

@@ -7,7 +7,7 @@ from media_manager.torrent.models import Torrent
from media_manager.torrent.schemas import Torrent as TorrentSchema
from media_manager.torrent.schemas import TorrentId
from media_manager.tv import log
from media_manager.tv.models import Episode, EpisodeFile, Season, SeasonRequest, Show
from media_manager.tv.models import Episode, EpisodeFile, Season, Show
from media_manager.tv.schemas import Episode as EpisodeSchema
from media_manager.tv.schemas import EpisodeFile as EpisodeFileSchema
from media_manager.tv.schemas import (
@@ -15,12 +15,9 @@ from media_manager.tv.schemas import (
EpisodeNumber,
SeasonId,
SeasonNumber,
SeasonRequestId,
ShowId,
)
from media_manager.tv.schemas import RichSeasonRequest as RichSeasonRequestSchema
from media_manager.tv.schemas import Season as SeasonSchema
from media_manager.tv.schemas import SeasonRequest as SeasonRequestSchema
from media_manager.tv.schemas import Show as ShowSchema
@@ -256,67 +253,6 @@ class TvRepository:
)
raise
def add_season_request(
self, season_request: SeasonRequestSchema
) -> SeasonRequestSchema:
"""
Adds a Season to the SeasonRequest table, which marks it as requested.
:param season_request: The SeasonRequest object to add.
:return: The added SeasonRequest object.
:raises IntegrityError: If a similar request already exists or violates constraints.
:raises SQLAlchemyError: If a database error occurs.
"""
db_model = SeasonRequest(
id=season_request.id,
season_id=season_request.season_id,
wanted_quality=season_request.wanted_quality,
min_quality=season_request.min_quality,
requested_by_id=season_request.requested_by.id
if season_request.requested_by
else None,
authorized=season_request.authorized,
authorized_by_id=season_request.authorized_by.id
if season_request.authorized_by
else None,
)
try:
self.db.add(db_model)
self.db.commit()
self.db.refresh(db_model)
return SeasonRequestSchema.model_validate(db_model)
except IntegrityError:
self.db.rollback()
log.exception("Integrity error while adding season request")
raise
except SQLAlchemyError:
self.db.rollback()
log.exception("Database error while adding season request")
raise
def delete_season_request(self, season_request_id: SeasonRequestId) -> None:
"""
Removes a SeasonRequest by its ID.
:param season_request_id: The ID of the season request to delete.
:raises NotFoundError: If the season request is not found.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
stmt = delete(SeasonRequest).where(SeasonRequest.id == season_request_id)
result = self.db.execute(stmt)
if result.rowcount == 0:
self.db.rollback()
msg = f"SeasonRequest with id {season_request_id} not found."
raise NotFoundError(msg)
self.db.commit()
except SQLAlchemyError:
self.db.rollback()
log.exception(
f"Database error while deleting season request {season_request_id}"
)
raise
def get_season_by_number(self, season_number: int, show_id: ShowId) -> SeasonSchema:
"""
Retrieve a season by its number and show ID.
@@ -345,38 +281,6 @@ class TvRepository:
)
raise
def get_season_requests(self) -> list[RichSeasonRequestSchema]:
"""
Retrieve all season requests.
:return: A list of RichSeasonRequest objects.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
stmt = select(SeasonRequest).options(
joinedload(SeasonRequest.requested_by),
joinedload(SeasonRequest.authorized_by),
joinedload(SeasonRequest.season).joinedload(Season.show),
)
results = self.db.execute(stmt).scalars().unique().all()
return [
RichSeasonRequestSchema(
id=SeasonRequestId(x.id),
min_quality=x.min_quality,
wanted_quality=x.wanted_quality,
season_id=SeasonId(x.season_id),
show=x.season.show,
season=x.season,
requested_by=x.requested_by,
authorized_by=x.authorized_by,
authorized=x.authorized,
)
for x in results
]
except SQLAlchemyError:
log.exception("Database error while retrieving season requests")
raise
def add_episode_file(self, episode_file: EpisodeFileSchema) -> EpisodeFileSchema:
"""
Adds an episode file record to the database.
@@ -581,30 +485,6 @@ class TvRepository:
)
raise
def get_season_request(
self, season_request_id: SeasonRequestId
) -> SeasonRequestSchema:
"""
Retrieve a season request by its ID.
:param season_request_id: The ID of the season request.
:return: A SeasonRequest object.
:raises NotFoundError: If the season request is not found.
:raises SQLAlchemyError: If a database error occurs.
"""
try:
request = self.db.get(SeasonRequest, season_request_id)
if not request:
log.warning(f"Season request with id {season_request_id} not found.")
msg = f"Season request with id {season_request_id} not found."
raise NotFoundError(msg)
return SeasonRequestSchema.model_validate(request)
except SQLAlchemyError:
log.exception(
f"Database error retrieving season request {season_request_id}"
)
raise
def get_show_by_season_id(self, season_id: SeasonId) -> ShowSchema:
"""
Retrieve a show by one of its season's ID.

View File

@@ -1,10 +1,7 @@
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from media_manager.auth.db import User
from media_manager.auth.schemas import UserRead
from media_manager.auth.users import current_active_user, current_superuser
from media_manager.config import LibraryItem, MediaManagerConfig
from media_manager.exceptions import MediaAlreadyExistsError, NotFoundError
@@ -17,24 +14,18 @@ from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
from media_manager.schemas import MediaImportSuggestion
from media_manager.torrent.schemas import Torrent
from media_manager.torrent.utils import get_importable_media_directories
from media_manager.tv import log
from media_manager.tv.dependencies import (
season_dep,
show_dep,
tv_service_dep,
)
from media_manager.tv.schemas import (
CreateSeasonRequest,
PublicEpisodeFile,
PublicShow,
RichSeasonRequest,
RichShowTorrent,
Season,
SeasonRequest,
SeasonRequestId,
Show,
ShowId,
UpdateSeasonRequest,
)
router = APIRouter()
@@ -278,110 +269,6 @@ def get_a_shows_torrents(show: show_dep, tv_service: tv_service_dep) -> RichShow
return tv_service.get_torrents_for_show(show=show)
# -----------------------------------------------------------------------------
# SEASONS - REQUESTS
# -----------------------------------------------------------------------------
@router.get(
"/seasons/requests",
status_code=status.HTTP_200_OK,
dependencies=[Depends(current_active_user)],
)
def get_season_requests(tv_service: tv_service_dep) -> list[RichSeasonRequest]:
"""
Get all season requests.
"""
return tv_service.get_all_season_requests()
@router.post("/seasons/requests", status_code=status.HTTP_204_NO_CONTENT)
def request_a_season(
user: Annotated[User, Depends(current_active_user)],
season_request: CreateSeasonRequest,
tv_service: tv_service_dep,
) -> None:
"""
Create a new season request.
"""
request: SeasonRequest = SeasonRequest.model_validate(season_request)
request.requested_by = UserRead.model_validate(user)
if user.is_superuser:
request.authorized = True
request.authorized_by = UserRead.model_validate(user)
tv_service.add_season_request(request)
return
@router.put("/seasons/requests", status_code=status.HTTP_204_NO_CONTENT)
def update_request(
tv_service: tv_service_dep,
user: Annotated[User, Depends(current_active_user)],
season_request: UpdateSeasonRequest,
) -> None:
"""
Update an existing season request.
"""
updated_season_request: SeasonRequest = SeasonRequest.model_validate(season_request)
request = tv_service.get_season_request_by_id(
season_request_id=updated_season_request.id
)
if request.requested_by.id == user.id or user.is_superuser:
updated_season_request.requested_by = UserRead.model_validate(user)
tv_service.update_season_request(season_request=updated_season_request)
return
@router.patch(
"/seasons/requests/{season_request_id}", status_code=status.HTTP_204_NO_CONTENT
)
def authorize_request(
tv_service: tv_service_dep,
user: Annotated[User, Depends(current_superuser)],
season_request_id: SeasonRequestId,
authorized_status: bool = False,
) -> None:
"""
Authorize or de-authorize a season request.
"""
season_request = tv_service.get_season_request_by_id(
season_request_id=season_request_id
)
if not season_request:
raise NotFoundError
season_request.authorized_by = UserRead.model_validate(user)
season_request.authorized = authorized_status
if not authorized_status:
season_request.authorized_by = None
tv_service.update_season_request(season_request=season_request)
@router.delete(
"/seasons/requests/{request_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_season_request(
tv_service: tv_service_dep,
user: Annotated[User, Depends(current_active_user)],
request_id: SeasonRequestId,
) -> None:
"""
Delete a season request.
"""
request = tv_service.get_season_request_by_id(season_request_id=request_id)
if user.is_superuser or request.requested_by.id == user.id:
tv_service.delete_season_request(season_request_id=request_id)
log.info(f"User {user.id} deleted season request {request_id}.")
return
log.warning(
f"User {user.id} tried to delete season request {request_id} but is not authorized."
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to delete this request",
)
# -----------------------------------------------------------------------------
# SEASONS
# -----------------------------------------------------------------------------

View File

@@ -2,9 +2,8 @@ import typing
import uuid
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field
from media_manager.auth.schemas import UserRead
from media_manager.torrent.models import Quality
from media_manager.torrent.schemas import TorrentId, TorrentStatus
@@ -14,7 +13,6 @@ EpisodeId = typing.NewType("EpisodeId", UUID)
SeasonNumber = typing.NewType("SeasonNumber", int)
EpisodeNumber = typing.NewType("EpisodeNumber", int)
SeasonRequestId = typing.NewType("SeasonRequestId", UUID)
class Episode(BaseModel):
@@ -63,42 +61,6 @@ class Show(BaseModel):
seasons: list[Season]
class SeasonRequestBase(BaseModel):
min_quality: Quality
wanted_quality: Quality
@model_validator(mode="after")
def ensure_wanted_quality_is_eq_or_gt_min_quality(self) -> "SeasonRequestBase":
if self.min_quality.value < self.wanted_quality.value:
msg = "wanted_quality must be equal to or lower than minimum_quality."
raise ValueError(msg)
return self
class CreateSeasonRequest(SeasonRequestBase):
season_id: SeasonId
class UpdateSeasonRequest(SeasonRequestBase):
id: SeasonRequestId
class SeasonRequest(SeasonRequestBase):
model_config = ConfigDict(from_attributes=True)
id: SeasonRequestId = Field(default_factory=lambda: SeasonRequestId(uuid.uuid4()))
season_id: SeasonId
requested_by: UserRead | None = None
authorized: bool = False
authorized_by: UserRead | None = None
class RichSeasonRequest(SeasonRequest):
show: Show
season: Season
class EpisodeFile(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@@ -25,7 +25,6 @@ from media_manager.schemas import MediaImportSuggestion
from media_manager.torrent.repository import TorrentRepository
from media_manager.torrent.schemas import (
Quality,
QualityStrings,
Torrent,
TorrentStatus,
)
@@ -48,17 +47,13 @@ from media_manager.tv.schemas import (
PublicEpisodeFile,
PublicSeason,
PublicShow,
RichSeasonRequest,
RichSeasonTorrent,
RichShowTorrent,
Season,
SeasonId,
SeasonRequest,
SeasonRequestId,
Show,
ShowId,
)
from media_manager.tv.schemas import Episode as EpisodeSchema
class TvService:
@@ -94,28 +89,6 @@ class TvService:
metadata_provider.download_show_poster_image(show=saved_show)
return saved_show
def add_season_request(self, season_request: SeasonRequest) -> SeasonRequest:
"""
Add a new season request.
:param season_request: The season request to add.
:return: The added season request.
"""
return self.tv_repository.add_season_request(season_request=season_request)
def get_season_request_by_id(
self, season_request_id: SeasonRequestId
) -> SeasonRequest | None:
"""
Get a season request by its ID.
:param season_request_id: The ID of the season request.
:return: The season request or None if not found.
"""
return self.tv_repository.get_season_request(
season_request_id=season_request_id
)
def get_total_downloaded_episoded_count(self) -> int:
"""
Get total number of downloaded episodes.
@@ -123,27 +96,9 @@ class TvService:
return self.tv_repository.get_total_downloaded_episodes_count()
def update_season_request(self, season_request: SeasonRequest) -> SeasonRequest:
"""
Update an existing season request.
:param season_request: The season request to update.
:return: The updated season request.
"""
self.tv_repository.delete_season_request(season_request_id=season_request.id)
return self.tv_repository.add_season_request(season_request=season_request)
def set_show_library(self, show: Show, library: str) -> None:
self.tv_repository.set_show_library(show_id=show.id, library=library)
def delete_season_request(self, season_request_id: SeasonRequestId) -> None:
"""
Delete a season request by its ID.
:param season_request_id: The ID of the season request to delete.
"""
self.tv_repository.delete_season_request(season_request_id=season_request_id)
def delete_show(
self,
show: Show,
@@ -498,14 +453,6 @@ class TvService:
"""
return self.tv_repository.get_season_by_episode(episode_id=episode_id)
def get_all_season_requests(self) -> list[RichSeasonRequest]:
"""
Get all season requests.
:return: A list of rich season requests.
"""
return self.tv_repository.get_season_requests()
def get_torrents_for_show(self, show: Show) -> RichShowTorrent:
"""
Get torrents for a given show.
@@ -632,72 +579,6 @@ class TvService:
return show_torrent
def download_approved_season_request(
self, season_request: SeasonRequest, show: Show
) -> bool:
"""
Download an approved season request.
:param season_request: The season request to download.
:param show: The Show object.
:return: True if the download was successful, False otherwise.
:raises ValueError: If the season request is not authorized.
"""
if not season_request.authorized:
msg = f"Season request {season_request.id} is not authorized for download"
raise ValueError(msg)
log.info(f"Downloading approved season request {season_request.id}")
season = self.get_season(season_id=season_request.season_id)
torrents = self.get_all_available_torrents_for_a_season(
season_number=season.number, show_id=show.id
)
available_torrents: list[IndexerQueryResult] = []
for torrent in torrents:
if (
(torrent.quality.value < season_request.wanted_quality.value)
or (torrent.quality.value > season_request.min_quality.value)
or (torrent.seeders < 3)
):
log.info(
f"Skipping torrent {torrent.title} with quality {torrent.quality} for season {season.id}, because it does not match the requested quality {season_request.wanted_quality}"
)
elif torrent.season != [season.number]:
log.info(
f"Skipping torrent {torrent.title} with quality {torrent.quality} for season {season.id}, because it contains to many/wrong seasons {torrent.season} (wanted: {season.number})"
)
else:
available_torrents.append(torrent)
log.info(
f"Taking torrent {torrent.title} with quality {torrent.quality} for season {season.id} into consideration"
)
if len(available_torrents) == 0:
log.warning(
f"No torrents matching criteria were found (wanted quality: {season_request.wanted_quality}, min_quality: {season_request.min_quality} for season {season.id})"
)
return False
available_torrents.sort()
torrent = self.torrent_service.download(indexer_result=available_torrents[0])
season_file = SeasonFile( # noqa: F821
season_id=season.id,
quality=torrent.quality,
torrent_id=torrent.id,
file_path_suffix=QualityStrings[torrent.quality.name].value.upper(),
)
try:
self.tv_repository.add_season_file(season_file=season_file)
except IntegrityError:
log.warning(
f"Season file for season {season.id} and quality {torrent.quality} already exists, skipping."
)
self.delete_season_request(season_request.id)
return True
def get_root_show_directory(self, show: Show) -> Path:
misc_config = MediaManagerConfig().misc
show_directory_name = f"{remove_special_characters(show.name)} ({show.year}) [{show.metadata_provider}id-{show.external_id}]"
@@ -1056,7 +937,7 @@ class TvService:
log.debug(
f"Adding new episode {fresh_episode_data.number} to season {existing_season.number}"
)
episode_schema = EpisodeSchema(
episode_schema = Episode(
id=EpisodeId(fresh_episode_data.id),
number=fresh_episode_data.number,
external_id=fresh_episode_data.external_id,
@@ -1072,7 +953,7 @@ class TvService:
f"Adding new season {fresh_season_data.number} to show {db_show.name}"
)
episodes_for_schema = [
EpisodeSchema(
Episode(
id=EpisodeId(ep_data.id),
number=ep_data.number,
external_id=ep_data.external_id,
@@ -1190,49 +1071,6 @@ class TvService:
return import_suggestions
def auto_download_all_approved_season_requests() -> None:
"""
Auto download all approved season requests.
This is a standalone function as it creates its own DB session.
"""
with next(get_session()) as db:
tv_repository = TvRepository(db=db)
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
notification_service = NotificationService(
notification_repository=NotificationRepository(db=db)
)
tv_service = TvService(
tv_repository=tv_repository,
torrent_service=torrent_service,
indexer_service=indexer_service,
notification_service=notification_service,
)
log.info("Auto downloading all approved season requests")
season_requests = tv_repository.get_season_requests()
log.info(f"Found {len(season_requests)} season requests to process")
count = 0
for season_request in season_requests:
if season_request.authorized:
log.info(f"Processing season request {season_request.id} for download")
show = tv_repository.get_show_by_season_id(
season_id=season_request.season_id
)
if tv_service.download_approved_season_request(
season_request=season_request, show=show
):
count += 1
else:
log.warning(
f"Failed to download season request {season_request.id} for show {show.name}"
)
log.info(f"Auto downloaded {count} approved season requests")
db.commit()
def import_all_show_torrents() -> None:
with next(get_session()) as db:
tv_repository = TvRepository(db=db)
@@ -1310,30 +1148,8 @@ def update_all_non_ended_shows_metadata() -> None:
db_show=show, metadata_provider=metadata_provider
)
# Automatically add season requests for new seasons
existing_seasons = [x.id for x in show.seasons]
new_seasons = [
x for x in updated_show.seasons if x.id not in existing_seasons
]
if show.continuous_download:
for new_season in new_seasons:
log.info(
f"Automatically adding season request for new season {new_season.number} of show {updated_show.name}"
)
tv_service.add_season_request(
SeasonRequest(
min_quality=Quality.sd,
wanted_quality=Quality.uhd,
season_id=new_season.id,
authorized=True,
)
)
if updated_show:
log.debug(
f"Added new seasons: {len(new_seasons)} to show: {updated_show.name}"
)
log.debug("Updated show metadata", extra={"show": updated_show.name})
else:
log.warning(f"Failed to update metadata for show: {show.name}")
db.commit()