diff --git a/backend/src/indexer/models.py b/backend/src/indexer/models.py index 1efe340..4c26ce7 100644 --- a/backend/src/indexer/models.py +++ b/backend/src/indexer/models.py @@ -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 diff --git a/backend/src/main.py b/backend/src/main.py index 440a34b..c3bf9d1 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -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: diff --git a/backend/src/torrent/models.py b/backend/src/torrent/models.py index 3f9c912..b60d7e6 100644 --- a/backend/src/torrent/models.py +++ b/backend/src/torrent/models.py @@ -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 diff --git a/backend/src/tv/models.py b/backend/src/tv/models.py index a734932..d49dc60 100644 --- a/backend/src/tv/models.py +++ b/backend/src/tv/models.py @@ -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] diff --git a/backend/src/tv/repository.py b/backend/src/tv/repository.py index 49dcfc5..539a8a0 100644 --- a/backend/src/tv/repository.py +++ b/backend/src/tv/repository.py @@ -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] \ No newline at end of file + 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)) diff --git a/backend/src/tv/router.py b/backend/src/tv/router.py index f17a087..fa925ec 100644 --- a/backend/src/tv/router.py +++ b/backend/src/tv/router.py @@ -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 # -------------------------------- diff --git a/backend/src/tv/schemas.py b/backend/src/tv/schemas.py index 420ec61..5a53669 100644 --- a/backend/src/tv/schemas.py +++ b/backend/src/tv/schemas.py @@ -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) diff --git a/backend/src/tv/service.py b/backend/src/tv/service.py index 005683b..65fe561 100644 --- a/backend/src/tv/service.py +++ b/backend/src/tv/service.py @@ -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]