feat: enhance season request management with CRUD operations and update schemas

This commit is contained in:
maxDorninger
2025-05-23 21:53:39 +02:00
parent 7d13fa1260
commit 123073f6b7
8 changed files with 162 additions and 125 deletions

View File

@@ -2,7 +2,7 @@ from sqlalchemy import String, Integer
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
from backend.src.database import Base
from indexer.schemas import IndexerQueryResultId
from torrent.schemas import Quality

View File

@@ -54,6 +54,7 @@ from auth.users import bearer_auth_backend, fastapi_users, cookie_auth_backend
from config import BasicConfig
from auth.users import oauth_client
import auth.db # registering user table for sqlalchemy
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -73,7 +74,7 @@ else:
log.info("Development Mode not activated!")
database.init_db()
log.info("Database initialized")
app = FastAPI(root_path="/api/v1")
if basic_config.DEVELOPMENT:

View File

@@ -2,7 +2,7 @@ from uuid import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
from backend.src.database import Base
from torrent.schemas import Quality, TorrentStatus

View File

@@ -3,7 +3,7 @@ from uuid import UUID
from sqlalchemy import ForeignKey, PrimaryKeyConstraint, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
from backend.src.database import Base
from torrent.models import Quality
@@ -69,8 +69,9 @@ class SeasonFile(Base):
class SeasonRequest(Base):
__tablename__ = "season_request"
__table_args__ = (
PrimaryKeyConstraint("season_id", "wanted_quality"),
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]

View File

@@ -1,12 +1,16 @@
from sqlalchemy import select, delete
import pprint
from sqlalchemy import select, delete, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
import database
from torrent.models import Torrent
from torrent.schemas import TorrentId, Torrent as TorrentSchema
from tv.models import Season, Show, Episode, SeasonRequest, SeasonFile
from tv.schemas import Season as SeasonSchema, SeasonId, Show as ShowSchema, ShowId, \
SeasonRequest as SeasonRequestSchema, SeasonFile as SeasonFileSchema, SeasonNumber
SeasonRequest as SeasonRequestSchema, SeasonFile as SeasonFileSchema, SeasonNumber, SeasonRequestId, \
RichSeasonRequest as RichSeasonRequestSchema
def get_show(show_id: ShowId, db: Session) -> ShowSchema | None:
@@ -17,13 +21,7 @@ def get_show(show_id: ShowId, db: Session) -> ShowSchema | None:
:param db: The database session.
:return: A ShowSchema object if found, otherwise None.
"""
stmt = (
select(Show)
.where(Show.id == show_id)
.options(
joinedload(Show.seasons).joinedload(Season.episodes)
)
)
stmt = (select(Show).where(Show.id == show_id).options(joinedload(Show.seasons).joinedload(Season.episodes)))
result = db.execute(stmt).unique().scalar_one_or_none()
if not result:
@@ -31,6 +29,7 @@ def get_show(show_id: ShowId, db: Session) -> ShowSchema | None:
return ShowSchema.model_validate(result)
def get_show_by_external_id(external_id: int, db: Session, metadata_provider: str) -> ShowSchema | None:
"""
Retrieve a show by its external ID, including nested seasons and episodes.
@@ -41,21 +40,14 @@ def get_show_by_external_id(external_id: int, db: Session, metadata_provider: st
:return: A ShowSchema object if found, otherwise None.
"""
stmt = (
select(Show)
.where(Show.external_id == external_id)
.where(Show.metadata_provider == metadata_provider)
.options(
joinedload(Show.seasons).joinedload(Season.episodes)
)
)
select(Show).where(Show.external_id == external_id).where(Show.metadata_provider == metadata_provider).options(
joinedload(Show.seasons).joinedload(Season.episodes)))
result = db.execute(stmt).unique().scalar_one_or_none()
if not result:
return None
return ShowSchema(
**result.__dict__
)
return ShowSchema(**result.__dict__)
def get_shows(db: Session) -> list[ShowSchema]:
@@ -81,33 +73,12 @@ def save_show(show: ShowSchema, db: Session) -> ShowSchema:
:return: The saved ShowSchema object.
:raises ValueError: If a show with the same primary key already exists.
"""
db_show = Show(
id=show.id,
external_id=show.external_id,
metadata_provider=show.metadata_provider,
name=show.name,
overview=show.overview,
year=show.year,
seasons=[
Season(
id=season.id,
show_id=show.id,
number=season.number,
external_id=season.external_id,
name=season.name,
overview=season.overview,
episodes=[
Episode(
id=episode.id,
season_id=season.id,
number=episode.number,
external_id=episode.external_id,
title=episode.title
) for episode in season.episodes
]
) for season in show.seasons
]
)
db_show = Show(id=show.id, external_id=show.external_id, metadata_provider=show.metadata_provider, name=show.name,
overview=show.overview, year=show.year, seasons=[
Season(id=season.id, show_id=show.id, number=season.number, external_id=season.external_id,
name=season.name, overview=season.overview, episodes=[
Episode(id=episode.id, season_id=season.id, number=episode.number, external_id=episode.external_id,
title=episode.title) for episode in season.episodes]) for season in show.seasons])
db.add(db_show)
try:
@@ -161,24 +132,17 @@ def remove_season_from_requested_list(season_request: SeasonRequestSchema, db: S
def get_season_by_number(db: Session, season_number: int, show_id: ShowId) -> SeasonSchema:
stmt = (
select(Season).
where(Season.show_id == show_id).
where(Season.number == season_number).
options(
joinedload(Season.episodes),
joinedload(Season.show)
)
)
stmt = (select(Season).where(Season.show_id == show_id).where(Season.number == season_number).options(
joinedload(Season.episodes), joinedload(Season.show)))
result = db.execute(stmt).unique().scalar_one_or_none()
return SeasonSchema.model_validate(result)
def get_season_requests(db: Session) -> list[SeasonRequestSchema]:
stmt = select(SeasonRequest)
def get_season_requests(db: Session) -> list[RichSeasonRequestSchema]:
stmt = select(SeasonRequest).join(Season, Season.id == SeasonRequest.season_id).join(Show,
Season.show_id == Show.id)
result = db.execute(stmt).scalars().all()
return [SeasonRequestSchema.model_validate(season) for season in result]
return [RichSeasonRequestSchema.model_validate(season) for season in result]
def add_season_file(db: Session, season_file: SeasonFileSchema) -> SeasonFileSchema:
db.add(SeasonFile(**season_file.model_dump()))
@@ -187,31 +151,26 @@ def add_season_file(db: Session, season_file: SeasonFileSchema) -> SeasonFileSch
def remove_season_files_by_torrent_id(db: Session, torrent_id: TorrentId):
stmt = (
delete(SeasonFile).
where(SeasonFile.torrent_id == torrent_id)
)
stmt = (delete(SeasonFile).where(SeasonFile.torrent_id == torrent_id))
db.execute(stmt)
def get_season_files_by_season_id(db: Session, season_id: SeasonId):
stmt = (
select(SeasonFile).
where(SeasonFile.season_id == season_id)
)
stmt = (select(SeasonFile).where(SeasonFile.season_id == season_id))
result = db.execute(stmt).scalars().all()
return [SeasonFileSchema.model_validate(season_file) for season_file in result]
def get_torrents_by_show_id(db: Session, show_id: ShowId) -> list[TorrentSchema]:
stmt = (
select(Torrent)
.distinct()
.join(SeasonFile, SeasonFile.torrent_id == Torrent.id)
.join(Season, Season.id == SeasonFile.season_id)
.where(Season.show_id == show_id)
)
stmt = (select(Torrent)
.distinct()
.join(SeasonFile, SeasonFile.torrent_id == Torrent.id)
.join(Season, Season.id == SeasonFile.season_id)
.where(Season.show_id == show_id))
result = db.execute(stmt).scalars().unique().all()
return [TorrentSchema.model_validate(torrent) for torrent in result]
def get_all_shows_with_torrents(db: Session) -> list[ShowSchema]:
"""
Retrieve all shows that are associated with a torrent alphabetically from the database.
@@ -219,29 +178,40 @@ def get_all_shows_with_torrents(db: Session) -> list[ShowSchema]:
:param db: The database session.
:return: A list of ShowSchema objects.
"""
stmt = (
select(Show)
.distinct()
.join(Season, Show.id == Season.show_id)
.join(SeasonFile, Season.id == SeasonFile.season_id)
.join(Torrent, SeasonFile.torrent_id == Torrent.id)
.options(joinedload(Show.seasons).joinedload(Season.episodes))
.order_by(Show.name)
)
stmt = (select(Show)
.distinct()
.join(Season, Show.id == Season.show_id)
.join(SeasonFile, Season.id == SeasonFile.season_id)
.join(Torrent, SeasonFile.torrent_id == Torrent.id)
.options(joinedload(Show.seasons).joinedload(Season.episodes))
.order_by(Show.name))
results = db.execute(stmt).scalars().unique().all()
return [ShowSchema.model_validate(show) for show in results]
def get_seasons_by_torrent_id(db: Session, torrent_id: TorrentId) -> list[SeasonNumber]:
stmt = (
select(Season.number)
.distinct()
.join(SeasonFile, SeasonFile.torrent_id == Torrent.id)
.join(Season, Season.id == SeasonFile.season_id)
.where(Torrent.id == torrent_id)
.select_from(Torrent)
)
stmt = (select(Season.number)
.distinct()
.join(SeasonFile, SeasonFile.torrent_id == Torrent.id)
.join(Season, Season.id == SeasonFile.season_id).where(
Torrent.id == torrent_id).select_from(Torrent))
result = db.execute(stmt).scalars().unique().all()
return [SeasonNumber(x) for x in result]
return [SeasonNumber(x) for x in result]
def get_season_request(db: Session, season_request_id: SeasonRequestId) -> SeasonRequestSchema:
return SeasonRequestSchema.model_validate(db.get(SeasonRequest, season_request_id))
def update_season_request(db: Session, season_request: SeasonRequestSchema) -> None:
db.delete(db.get(SeasonRequest, season_request.id))
db.add(SeasonRequest(**season_request.model_dump()))
db.commit()
if __name__ == "__main__":
session = database.sessionmaker(autocommit=False, autoflush=False, bind=database.engine)()
pprint.pprint(get_season_requests(db=session))

View File

@@ -1,10 +1,9 @@
import logging
import pprint
from typing import Annotated
from fastapi import APIRouter, Depends, status
from fastapi.responses import JSONResponse
import metadataProvider
import tv.repository
import tv.service
from auth.users import current_active_user, current_superuser
from database import DbSessionDependency
@@ -12,8 +11,9 @@ from indexer.schemas import PublicIndexerQueryResult, IndexerQueryResultId
from metadataProvider.schemas import MetaDataProviderShowSearchResult
from torrent.schemas import Torrent
from tv.exceptions import MediaAlreadyExists
from tv.schemas import Show, SeasonRequest, ShowId, RichShowTorrent, PublicShow, SeasonId, SeasonFile, PublicSeasonFile, \
SeasonNumber
from tv.schemas import Show, SeasonRequest, ShowId, RichShowTorrent, PublicShow, PublicSeasonFile, SeasonNumber, \
CreateSeasonRequest, SeasonRequestId, UpdateSeasonRequest, RichSeasonRequest
from auth.db import User
router = APIRouter()
@@ -23,10 +23,8 @@ router = APIRouter()
# --------------------------------
@router.post("/shows", status_code=status.HTTP_201_CREATED, dependencies=[Depends(current_active_user)],
responses={
status.HTTP_201_CREATED: {"model": Show, "description": "Successfully created show"},
status.HTTP_409_CONFLICT: {"model": str, "description": "Show already exists"},
})
responses={status.HTTP_201_CREATED: {"model": Show, "description": "Successfully created show"},
status.HTTP_409_CONFLICT: {"model": str, "description": "Show already exists"}, })
def add_a_show(db: DbSessionDependency, show_id: int, metadata_provider: str = "tmdb"):
try:
show = tv.service.add_show(db=db, external_id=show_id, metadata_provider=metadata_provider, )
@@ -52,6 +50,7 @@ def get_all_shows(db: DbSessionDependency, external_id: int = None, metadata_pro
else:
return tv.service.get_all_shows(db=db)
@router.get("/shows/torrents", dependencies=[Depends(current_active_user)], response_model=list[RichShowTorrent])
def get_shows_with_torrents(db: DbSessionDependency):
"""
@@ -61,6 +60,7 @@ def get_shows_with_torrents(db: DbSessionDependency):
result = tv.service.get_all_shows_with_torrents(db=db)
return result
@router.get("/shows/{show_id}", dependencies=[Depends(current_active_user)], response_model=PublicShow)
def get_a_show(db: DbSessionDependency, show_id: ShowId):
return tv.service.get_public_show_by_id(db=db, show_id=show_id)
@@ -71,21 +71,34 @@ def get_a_shows_torrents(db: DbSessionDependency, show_id: ShowId):
return tv.service.get_torrents_for_show(db=db, show=tv.service.get_show_by_id(db=db, show_id=show_id))
# TODO: replace by route with season_id rather than show_id and season_number
@router.get("/shows/{show_id}/{season_number}/files", status_code=status.HTTP_200_OK,
dependencies=[Depends(current_active_user)])
def get_season_files(db: DbSessionDependency, season_number: SeasonNumber, show_id: ShowId) -> list[PublicSeasonFile]:
return tv.service.get_public_season_files_by_season_number(db=db, season_number=season_number, show_id=show_id)
@router.get("/shows/{show_id}/{season_number}/requests", status_code=status.HTTP_200_OK,
dependencies=[Depends(current_active_user)], response_model=list[RichSeasonRequest])
def get_season_files(db: DbSessionDependency, season_number: SeasonNumber, show_id: ShowId) -> list[RichSeasonRequest]:
return None
# --------------------------------
# MANAGE REQUESTS
# --------------------------------
@router.post("/seasons/requests", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)])
def request_a_season(db: DbSessionDependency, season_request: SeasonRequest):
@router.post("/seasons/requests", status_code=status.HTTP_200_OK, response_model=RichSeasonRequest)
def request_a_season(db: DbSessionDependency, user: Annotated[User, Depends(current_active_user)],
season_request: CreateSeasonRequest):
"""
adds request flag to a season
"""
tv.service.request_season(db=db, season_request=season_request)
request: SeasonRequest = SeasonRequest.model_validate(season_request)
request.requested_by = user.id
tv.service.request_season(db=db, season_request=request)
return request
@router.get("/seasons/requests", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)])
@@ -93,20 +106,34 @@ def get_requested_seasons(db: DbSessionDependency) -> list[SeasonRequest]:
return tv.service.get_all_requested_seasons(db=db)
@router.delete("/seasons/requests", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)])
def unrequest_season(db: DbSessionDependency, request: SeasonRequest):
@router.patch("/seasons/requests/{season_request_id}", status_code=status.HTTP_200_OK, response_model=SeasonRequest)
def authorize_request(db: DbSessionDependency, user: Annotated[User, Depends(current_superuser)],
season_request_id: SeasonRequestId, authorized_status: bool = False):
"""
updates the request flag to true
"""
season_request: SeasonRequest = tv.repository.get_season_request(db=db, season_request_id=season_request_id)
season_request.authorized_by = user.id
season_request.authorized = authorized_status
tv.service.update_season_request(db=db, season_request=season_request)
return season_request
@router.put("/seasons/requests/{season_request_id}", status_code=status.HTTP_200_OK, response_model=SeasonRequest)
def update_request(db: DbSessionDependency, user: Annotated[User, Depends(current_superuser)],
season_request: UpdateSeasonRequest):
season_request: SeasonRequest = SeasonRequest.model_validate(season_request)
season_request.requested_by = user.id
tv.service.update_season_request(db=db, season_request=season_request)
return season_request
@router.delete("/seasons/requests/{season_request_id}", status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(current_active_user)])
def delete_season_request(db: DbSessionDependency, request: SeasonRequest):
tv.service.unrequest_season(db=db, season_request=request)
# --------------------------------
# MANAGE SEASON FILES
# --------------------------------
# --------------------------------
# MANAGE TORRENTS
# --------------------------------
@@ -128,6 +155,7 @@ def download_a_torrent(db: DbSessionDependency, public_indexer_result_id: Indexe
return tv.service.download_torrent(db=db, public_indexer_result_id=public_indexer_result_id, show_id=show_id,
override_show_file_path_suffix=override_file_path_suffix)
# --------------------------------
# SEARCH SHOWS ON METADATA PROVIDERS
# --------------------------------

View File

@@ -3,6 +3,7 @@ import uuid
from uuid import UUID
from pydantic import BaseModel, Field, ConfigDict
from tvdb_v4_official import Request
from torrent.models import Quality
from torrent.schemas import TorrentId, TorrentStatus
@@ -13,7 +14,7 @@ EpisodeId = typing.NewType("EpisodeId", UUID)
SeasonNumber = typing.NewType("SeasonNumber", int)
EpisodeNumber = typing.NewType("EpisodeNumber", int)
SeasonRequestId = typing.NewType("SeasonRequestId", UUID)
class Episode(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -53,14 +54,36 @@ class Show(BaseModel):
seasons: list[Season]
class SeasonRequest(BaseModel):
model_config = ConfigDict(from_attributes=True)
class SeasonRequestBase(BaseModel):
season_id: SeasonId
min_quality: Quality
wanted_quality: Quality
class CreateSeasonRequest(SeasonRequestBase):
pass
class UpdateSeasonRequest(SeasonRequestBase):
id: SeasonRequestId
class SeasonRequest(SeasonRequestBase):
model_config = ConfigDict(from_attributes=True)
id: SeasonRequestId = Field(default_factory=uuid.uuid4)
requested_by: UUID | None = None
authorized: bool = False
authorized_by: UUID | None = None
class RichSeasonRequest(SeasonRequest):
show_id: ShowId
show_name: str
show_year: int | None
season_number: SeasonNumber
class SeasonFile(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -73,6 +96,7 @@ class SeasonFile(BaseModel):
class PublicSeasonFile(SeasonFile):
downloaded: bool = False
class RichSeasonTorrent(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -85,6 +109,7 @@ class RichSeasonTorrent(BaseModel):
file_path_suffix: str
seasons: list[SeasonNumber]
class RichShowTorrent(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,3 +1,4 @@
from pydantic.v1 import UUID4
from sqlalchemy.orm import Session
import indexer.service
@@ -14,7 +15,7 @@ from tv import log
from tv.exceptions import MediaAlreadyExists
from tv.repository import add_season_file, get_season_files_by_season_id
from tv.schemas import Show, ShowId, SeasonRequest, SeasonFile, SeasonId, Season, RichShowTorrent, RichSeasonTorrent, \
PublicSeason, PublicShow, PublicSeasonFile, SeasonNumber
PublicSeason, PublicShow, PublicSeasonFile, SeasonNumber, SeasonRequestId
def add_show(db: Session, external_id: int, metadata_provider: str) -> Show | None:
@@ -30,6 +31,9 @@ def request_season(db: Session, season_request: SeasonRequest) -> None:
tv.repository.add_season_to_requested_list(db=db, season_request=season_request)
def update_season_request(db: Session, season_request: SeasonRequest) -> None:
tv.repository.update_season_request(db=db, season_request=season_request)
def unrequest_season(db: Session, season_request: SeasonRequest) -> None:
tv.repository.remove_season_from_requested_list(db=db, season_request=season_request)
@@ -181,3 +185,11 @@ def download_torrent(db: Session, public_indexer_result_id: IndexerQueryResultId
file_path_suffix=override_show_file_path_suffix)
add_season_file(db=db, season_file=season_file)
return show_torrent
def get_season_requests_by_season_id(db: Session, season_id: SeasonId) -> list[SeasonRequest]:
return [x for x in tv.repository.get_season_requests(db=db) if x.season_id == season_id]
def get_season_requests_by_show_id(db: Session, show_id: ShowId) -> list[SeasonRequest]:
return [x for x in tv.repository.get_season_requests(db=db) if x.show_id == show_id]