diff --git a/backend/src/auth/db.py b/backend/src/auth/db.py index d8e6580..1c333b2 100644 --- a/backend/src/auth/db.py +++ b/backend/src/auth/db.py @@ -3,16 +3,11 @@ from collections.abc import AsyncGenerator from fastapi import Depends from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from sqlalchemy.orm import DeclarativeBase import database -class Base(DeclarativeBase): - pass - - -class User(SQLAlchemyBaseUserTableUUID, Base): +class User(SQLAlchemyBaseUserTableUUID, database.Base): pass @@ -20,11 +15,6 @@ engine = create_async_engine(database.db_url, echo=False) async_session_maker = async_sessionmaker(engine, expire_on_commit=False) -async def create_db_and_tables(): - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - async def get_async_session() -> AsyncGenerator[AsyncSession, None]: async with async_session_maker() as session: yield session diff --git a/backend/src/database/__init__.py b/backend/src/database/__init__.py index 7ad61e9..388fb00 100644 --- a/backend/src/database/__init__.py +++ b/backend/src/database/__init__.py @@ -1,8 +1,10 @@ import logging +from contextvars import ContextVar from typing import Annotated, Any, Generator from fastapi import Depends -from sqlmodel import SQLModel, Session, create_engine +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, declarative_base, sessionmaker from database.config import DbConfig @@ -13,15 +15,28 @@ db_url = "postgresql+psycopg" + "://" + config.USER + ":" + config.PASSWORD + "@ config.PORT) + "/" + config.DBNAME engine = create_engine(db_url, echo=False) +Base = declarative_base() +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + def init_db() -> None: - SQLModel.metadata.create_all(engine) + Base.metadata.create_all(engine) def get_session() -> Generator[Session, Any, None]: - with Session(engine) as session: - yield session + db = SessionLocal() + try: + yield db + db.commit() + except Exception as e: + db.rollback() + log.critical(f"error occurred: {e}") + finally: + db.close() + + +db_session: ContextVar[Session] = ContextVar('db_session') DbSessionDependency = Annotated[Session, Depends(get_session)] diff --git a/backend/src/database/tv.py b/backend/src/database/tv.py deleted file mode 100644 index 7d13e22..0000000 --- a/backend/src/database/tv.py +++ /dev/null @@ -1,54 +0,0 @@ -import uuid -from uuid import UUID - -from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, String, UniqueConstraint -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship - - -class Base(DeclarativeBase): - pass - - -class Show(Base): - __tablename__ = "show" - __table_args__ = (UniqueConstraint("external_id", "metadata_provider", "version"),) - - id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - external_id: Mapped[int] = mapped_column(Integer, nullable=False) - metadata_provider: Mapped[str] = mapped_column(String, nullable=False) - name: Mapped[str] = mapped_column(String, nullable=False) - overview: Mapped[str] = mapped_column(String, nullable=False) - year: Mapped[int | None] = mapped_column(Integer, nullable=True) - version: Mapped[str] = mapped_column(String, default="") - - seasons: Mapped[list["Season"]] = relationship(back_populates="show", cascade="all, delete") - - -class Season(Base): - __tablename__ = "season" - __table_args__ = (UniqueConstraint("show_id", "number"),) - - id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - show_id: Mapped[UUID] = mapped_column(ForeignKey("show.id", ondelete="CASCADE"), nullable=False) - number: Mapped[int] = mapped_column(Integer, nullable=False) - external_id: Mapped[int] = mapped_column(Integer, nullable=False) - name: Mapped[str] = mapped_column(String, nullable=False) - overview: Mapped[str] = mapped_column(String, nullable=False) - - show: Mapped[Show] = relationship(back_populates="seasons") - episodes: Mapped[list["Episode"]] = relationship(back_populates="season", cascade="all, delete") - - -class Episode(Base): - __tablename__ = "episode" - __table_args__ = ( - ForeignKeyConstraint(["show_id", "season_number"], ["season.show_id", "season.number"], ondelete="CASCADE"), - ) - - show_id: Mapped[UUID] = mapped_column(ForeignKey("show.id"), primary_key=True) - season_number: Mapped[int] = mapped_column(Integer, primary_key=True) - number: Mapped[int] = mapped_column(Integer, primary_key=True) - external_id: Mapped[int] = mapped_column(Integer, nullable=False) - title: Mapped[str] = mapped_column(String, nullable=False) - - season: Mapped[Season] = relationship(back_populates="episodes") diff --git a/backend/src/database/users.py b/backend/src/database/users.py deleted file mode 100644 index b28b04f..0000000 --- a/backend/src/database/users.py +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/backend/src/indexer/__init__.py b/backend/src/indexer/__init__.py index d69d89e..20d84ae 100644 --- a/backend/src/indexer/__init__.py +++ b/backend/src/indexer/__init__.py @@ -1,26 +1,11 @@ import logging -from database.tv import Season from indexer.config import ProwlarrConfig from indexer.generic import GenericIndexer, IndexerQueryResult from indexer.prowlarr import Prowlarr log = logging.getLogger(__name__) - -def search(query: str | Season) -> list[IndexerQueryResult]: - results = [] - - if isinstance(query, Season): - query = query.show.name + " s" + query.number.__str__() - log.debug(f"Searching for Season {query}") - - for indexer in indexers: - results.extend(indexer.get_search_results(query)) - - return results - - indexers: list[GenericIndexer] = [] if ProwlarrConfig().enabled: diff --git a/backend/src/indexer/generic.py b/backend/src/indexer/generic.py index 086fd93..b817c45 100644 --- a/backend/src/indexer/generic.py +++ b/backend/src/indexer/generic.py @@ -1,57 +1,4 @@ -import re -from uuid import UUID, uuid4 - -import pydantic -from pydantic import BaseModel, computed_field - -from database.torrents import QualityMixin, Torrent - - -# TODO: use something like strategy pattern to make sorting more user customizable -class IndexerQueryResult(BaseModel, QualityMixin): - id: UUID = pydantic.Field(default_factory=uuid4) - title: str - _download_url: str - seeders: int - flags: set[str] - - @computed_field - @property - def season(self) -> set[int]: - pattern = r"\b[sS](\d+)\b" - matches = re.findall(pattern, self.title, re.IGNORECASE) - if matches.__len__() == 2: - result = set() - for i in range(int(matches[0]), int(matches[1]) + 1): - result.add(i) - elif matches.__len__() == 1: - result = {int(matches[0])} - else: - result = {} - return result - - def __gt__(self, other) -> bool: - if self.quality.value != other.quality.value: - return self.quality.value > other.quality.value - return self.seeders < other.seeders - - def __lt__(self, other) -> bool: - if self.quality.value != other.quality.value: - return self.quality.value < other.quality.value - return self.seeders > other.seeders - - def download(self) -> Torrent: - """ - downloads a torrent file and returns the filepath - """ - import requests - url = self.download_url - torrent_filepath = self.title + ".torrent" - with open(torrent_filepath, 'wb') as out_file: - content = requests.get(url).content - out_file.write(content) - - return Torrent(torrent_title=self.title) +from indexer.schemas import IndexerQueryResult class GenericIndexer(object): diff --git a/backend/src/indexer/models.py b/backend/src/indexer/models.py new file mode 100644 index 0000000..761fae9 --- /dev/null +++ b/backend/src/indexer/models.py @@ -0,0 +1,16 @@ +from sqlalchemy.orm import Mapped, mapped_column + +from database import Base +from indexer.schemas import IndexerQueryResultId +from torrent.schemas import Quality + + +class IndexerQueryResult(Base): + __tablename__ = 'indexer_query_result' + id: Mapped[IndexerQueryResultId] = mapped_column(primary_key=True) + title: Mapped[str] + download_url: Mapped[str] + seeders: Mapped[int] + flags: Mapped[set[str]] + quality: Mapped[Quality | None] + season: Mapped[set[int]] diff --git a/backend/src/indexer/prowlarr.py b/backend/src/indexer/prowlarr.py index badd885..8c76a7b 100644 --- a/backend/src/indexer/prowlarr.py +++ b/backend/src/indexer/prowlarr.py @@ -40,7 +40,7 @@ class Prowlarr(GenericIndexer): log.debug("torrent result: " + result.__str__()) result_list.append( IndexerQueryResult( - _download_url=result['downloadUrl'], + download_url=result['downloadUrl'], title=result['sortTitle'], seeders=result['seeders'], flags=result['indexerFlags'], diff --git a/backend/src/indexer/repository.py b/backend/src/indexer/repository.py new file mode 100644 index 0000000..3c9afdd --- /dev/null +++ b/backend/src/indexer/repository.py @@ -0,0 +1,13 @@ +from sqlalchemy.orm import Session + +from indexer.models import IndexerQueryResult +from indexer.schemas import IndexerQueryResultId, IndexerQueryResult as IndexerQueryResultSchema + + +def get_result(result_id: IndexerQueryResultId, db: Session) -> IndexerQueryResultSchema: + return IndexerQueryResultSchema(**db.get(IndexerQueryResult, result_id).__dict__) + + +def save_result(result: IndexerQueryResultSchema, db: Session) -> IndexerQueryResultSchema: + db.add(IndexerQueryResult(**result.__dict__)) + return result diff --git a/backend/src/indexer/schemas.py b/backend/src/indexer/schemas.py new file mode 100644 index 0000000..c83e794 --- /dev/null +++ b/backend/src/indexer/schemas.py @@ -0,0 +1,58 @@ +import re +import typing +from uuid import UUID, uuid4 + +import pydantic +from pydantic import BaseModel, computed_field + +from torrent.models import Quality, Torrent + +IndexerQueryResultId = typing.NewType('IndexerQueryResultId', UUID) + + +# TODO: use something like strategy pattern to make sorting more user customizable +class IndexerQueryResult(BaseModel): + id: IndexerQueryResultId = pydantic.Field(default_factory=uuid4) + title: str + download_url: str + seeders: int + flags: set[str] + quality: Quality | None + + @computed_field + @property + def season(self) -> set[int]: + pattern = r"\b[sS](\d+)\b" + matches = re.findall(pattern, self.title, re.IGNORECASE) + if matches.__len__() == 2: + result = set() + for i in range(int(matches[0]), int(matches[1]) + 1): + result.add(i) + elif matches.__len__() == 1: + result = {int(matches[0])} + else: + result = {} + return result + + def __gt__(self, other) -> bool: + if self.quality.value != other.quality.value: + return self.quality.value > other.quality.value + return self.seeders < other.seeders + + def __lt__(self, other) -> bool: + if self.quality.value != other.quality.value: + return self.quality.value < other.quality.value + return self.seeders > other.seeders + + def download(self) -> Torrent: + """ + downloads a torrent file and returns the filepath + """ + import requests + url = self.download_url + torrent_filepath = self.title + ".torrent" + with open(torrent_filepath, 'wb') as out_file: + content = requests.get(url).content + out_file.write(content) + + return Torrent(status=None, title=self.title, quality=self.quality, id=self.id) diff --git a/backend/src/indexer/service.py b/backend/src/indexer/service.py new file mode 100644 index 0000000..00057b9 --- /dev/null +++ b/backend/src/indexer/service.py @@ -0,0 +1,21 @@ +from sqlalchemy.orm import Session + +from indexer import IndexerQueryResult, log, indexers +from indexer.repository import save_result +from indexer.schemas import IndexerQueryResultId + + +def search(query: str, db: Session) -> list[IndexerQueryResult]: + results = [] + + log.debug(f"Searching for Torrent: {query}") + + for indexer in indexers: + results.extend(indexer.get_search_results(query)) + for result in results: + save_result(result=result, db=db) + return results + + +def get_indexer_query_result(result_id: IndexerQueryResultId, db: Session) -> IndexerQueryResult: + return get_indexer_query_result(result_id=result_id, db=db) diff --git a/backend/src/main.py b/backend/src/main.py index a68a96c..47fc35b 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -1,10 +1,8 @@ import logging import sys -from contextlib import asynccontextmanager from logging.config import dictConfig import database -from auth.db import create_db_and_tables from auth.schemas import UserCreate, UserRead, UserUpdate from auth.users import bearer_auth_backend, fastapi_users @@ -45,16 +43,9 @@ LOGGING_CONFIG = { # Apply logging config dictConfig(LOGGING_CONFIG) - -@asynccontextmanager -async def lifespan(app: FastAPI): - # Not needed if you setup a migration system like Alembic - await create_db_and_tables() - yield - - database.init_db() -app = FastAPI(root_path="/api/v1", lifespan=lifespan) + +app = FastAPI(root_path="/api/v1") app.include_router( fastapi_users.get_auth_router(bearer_auth_backend), prefix="/auth/jwt", @@ -88,7 +79,9 @@ app.include_router( app.include_router( tv.router.router, - tags=["tv"]) + prefix="/tv", + tags=["tv"] +) if __name__ == "__main__": uvicorn.run(app, host="127.0.0.1", port=5049, log_config=LOGGING_CONFIG) diff --git a/backend/src/metadataProvider/__init__.py b/backend/src/metadataProvider/__init__.py index 2d1e254..1fcbbbe 100644 --- a/backend/src/metadataProvider/__init__.py +++ b/backend/src/metadataProvider/__init__.py @@ -1,8 +1,8 @@ import logging import metadataProvider.tmdb -from database.tv import Show from metadataProvider.abstractMetaDataProvider import metadata_providers +from tv.schemas import Show log = logging.getLogger(__name__) diff --git a/backend/src/metadataProvider/abstractMetaDataProvider.py b/backend/src/metadataProvider/abstractMetaDataProvider.py index 862d89e..6cbc40b 100644 --- a/backend/src/metadataProvider/abstractMetaDataProvider.py +++ b/backend/src/metadataProvider/abstractMetaDataProvider.py @@ -2,7 +2,7 @@ import logging from abc import ABC, abstractmethod import config -from database.tv import Show +from tv.schemas import Show log = logging.getLogger(__name__) diff --git a/backend/src/metadataProvider/tmdb.py b/backend/src/metadataProvider/tmdb.py index c15e245..2a3e23d 100644 --- a/backend/src/metadataProvider/tmdb.py +++ b/backend/src/metadataProvider/tmdb.py @@ -6,8 +6,8 @@ import tmdbsimple from pydantic_settings import BaseSettings from tmdbsimple import TV, TV_Seasons -from database.tv import Episode, Season, Show from metadataProvider.abstractMetaDataProvider import AbstractMetadataProvider, register_metadata_provider +from tv.schemas import Episode, Season, Show class TmdbConfig(BaseSettings): @@ -51,7 +51,8 @@ class TmdbMetadataProvider(AbstractMetadataProvider): name=season_metadata["name"], overview=season_metadata["overview"], number=int(season_metadata["season_number"]), - episodes=episode_list + episodes=episode_list, + ) ) @@ -76,7 +77,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider): content_type = res.headers["content-type"] file_extension = mimetypes.guess_extension(content_type) if res.status_code == 200: - with open(f"{self.storage_path}/images/{show.id}{file_extension}", 'wb') as f: + with open(f"{self.storage_path}/{show.id}{file_extension}", 'wb') as f: f.write(res.content) log.info(f"image for show {show.name} successfully downloaded") diff --git a/backend/src/torrent/__init__.py b/backend/src/torrent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/database/torrents.py b/backend/src/torrent/models.py similarity index 51% rename from backend/src/database/torrents.py rename to backend/src/torrent/models.py index 9edba9f..fa239e1 100644 --- a/backend/src/database/torrents.py +++ b/backend/src/torrent/models.py @@ -1,19 +1,12 @@ import re -from enum import Enum from typing import Literal -from uuid import UUID, uuid4 +from uuid import UUID from pydantic import computed_field -from sqlalchemy import Column, String -from sqlmodel import Field, SQLModel +from sqlalchemy.orm import Mapped, mapped_column - -class Quality(Enum): - high = 1 - medium = 2 - low = 3 - very_low = 4 - unknown = 5 +from database import Base +from torrent.schemas import Quality class QualityMixin: @@ -38,17 +31,11 @@ class QualityMixin: else: return Quality.unknown -class TorrentMixin: - torrent_id: UUID | None = Field(default=None, foreign_key="torrent.id") +class Torrent(Base): + __tablename__ = "torrent" -class Torrent(SQLModel, QualityMixin, table=True): - id: UUID = Field(default_factory=uuid4, primary_key=True) - torrent_status: Literal["downloading", "finished", "error"] | None = Field(default=None, - sa_column=Column(String)) - torrent_title: str = Field(default=None) - - @property - @computed_field - def torrent_filepath(self) -> str: - return f"{self.id}.torrent" \ No newline at end of file + id: Mapped[UUID] = mapped_column(primary_key=True) + status: Mapped[Literal["downloading", "finished", "error"] | None] + title: Mapped[str] + quality: Mapped[Quality | None] diff --git a/backend/src/torrent/schemas.py b/backend/src/torrent/schemas.py new file mode 100644 index 0000000..79b74c2 --- /dev/null +++ b/backend/src/torrent/schemas.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class Quality(Enum): + high = 1 + medium = 2 + low = 3 + very_low = 4 + unknown = 5 diff --git a/backend/src/tv/exceptions.py b/backend/src/tv/exceptions.py new file mode 100644 index 0000000..b3faf4f --- /dev/null +++ b/backend/src/tv/exceptions.py @@ -0,0 +1,6 @@ +class MediaAlreadyExists(ValueError): + '''Raised when a show already exists''' + + +class MediaDoesNotExist(ValueError): + '''Raised when a does not show exist''' diff --git a/backend/src/tv/models.py b/backend/src/tv/models.py index 1b22941..4b8bdd4 100644 --- a/backend/src/tv/models.py +++ b/backend/src/tv/models.py @@ -1,25 +1,22 @@ -import uuid from uuid import UUID -from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, String, UniqueConstraint -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from sqlalchemy import ForeignKey, PrimaryKeyConstraint, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship - -class Base(DeclarativeBase): - pass +from database import Base +from torrent.models import Quality class Show(Base): __tablename__ = "show" - __table_args__ = (UniqueConstraint("external_id", "metadata_provider", "version"),) + __table_args__ = (UniqueConstraint("external_id", "metadata_provider"),) - id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - external_id: Mapped[int] = mapped_column(Integer, nullable=False) - metadata_provider: Mapped[str] = mapped_column(String, nullable=False) - name: Mapped[str] = mapped_column(String, nullable=False) - overview: Mapped[str] = mapped_column(String, nullable=False) - year: Mapped[int | None] = mapped_column(Integer, nullable=True) - version: Mapped[str] = mapped_column(String, default="") + id: Mapped[UUID] = mapped_column(primary_key=True) + external_id: Mapped[int] + metadata_provider: Mapped[str] + name: Mapped[str] + overview: Mapped[str] + year: Mapped[int | None] seasons: Mapped[list["Season"]] = relationship(back_populates="show", cascade="all, delete") @@ -28,30 +25,47 @@ class Season(Base): __tablename__ = "season" __table_args__ = (UniqueConstraint("show_id", "number"),) - id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - show_id: Mapped[UUID] = mapped_column(ForeignKey(column="show.id", ondelete="CASCADE"), nullable=False) - number: Mapped[int] = mapped_column(Integer, nullable=False) - external_id: Mapped[int] = mapped_column(Integer, nullable=False) - name: Mapped[str] = mapped_column(String, nullable=False) - overview: Mapped[str] = mapped_column(String, nullable=False) - torrent_id: Mapped[UUID] = mapped_column(ForeignKey(column="torrent.id"), nullable=False) + id: Mapped[UUID] = mapped_column(primary_key=True) + show_id: Mapped[UUID] = mapped_column(ForeignKey(column="show.id", ondelete="CASCADE"), ) + number: Mapped[int] + external_id: Mapped[int] + name: Mapped[str] + overview: Mapped[str] - show: Mapped[Show] = relationship(back_populates="seasons") + show: Mapped["Show"] = relationship(back_populates="seasons") episodes: Mapped[list["Episode"]] = relationship(back_populates="season", cascade="all, delete") class Episode(Base): __tablename__ = "episode" __table_args__ = ( - ForeignKeyConstraint(columns=["show_id", "season_number"], - refcolumns=["season.show_id", "season.number"], - ondelete="CASCADE"), + UniqueConstraint("season_id", "number"), ) + id: Mapped[UUID] = mapped_column(primary_key=True) + season_id: Mapped[UUID] = mapped_column(ForeignKey("season.id", ondelete="CASCADE"), ) + number: Mapped[int] + external_id: Mapped[int] + title: Mapped[str] - show_id: Mapped[UUID] = mapped_column(ForeignKey("show.id"), primary_key=True) - season_number: Mapped[int] = mapped_column(Integer, primary_key=True) - number: Mapped[int] = mapped_column(Integer, primary_key=True) - external_id: Mapped[int] = mapped_column(Integer, nullable=False) - title: Mapped[str] = mapped_column(String, nullable=False) + season: Mapped["Season"] = relationship(back_populates="episodes") - season: Mapped[Season] = relationship(back_populates="episodes") + +class SeasonFile(Base): + __tablename__ = "season_file" + __table_args__ = ( + PrimaryKeyConstraint("season_id", "file_path"), + ) + season_id: Mapped[UUID] = mapped_column(ForeignKey(column="season.id", ondelete="CASCADE"), ) + torrent_id: Mapped[UUID | None] = mapped_column(ForeignKey(column="torrent.id", ondelete="SET NULL"), ) + file_path: Mapped[str] + quality: Mapped[Quality] + + +class SeasonRequest(Base): + __tablename__ = "season_torrent_relation" + __table_args__ = ( + PrimaryKeyConstraint("season_id", "wanted_quality"), + ) + 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 new file mode 100644 index 0000000..b5cebf9 --- /dev/null +++ b/backend/src/tv/repository.py @@ -0,0 +1,196 @@ +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, joinedload + +import database +from tv.models import Season, Show, Episode, SeasonRequest +from tv.schemas import Episode as EpisodeSchema, Season as SeasonSchema, SeasonId, Show as ShowSchema, ShowId, \ + SeasonRequest as SeasonRequestSchema + + +def get_show(show_id: ShowId, db: Session) -> ShowSchema | None: + """ + Retrieve a show by its ID, including seasons and episodes. + + :param show_id: The ID of the show to retrieve. + :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) # Load relationships + ) + ) + + result = db.execute(stmt).unique().scalar_one_or_none() + if not result: + return None + + return ShowSchema( + **result.__dict__ + ) + + +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. + + :param external_id: The ID of the show to retrieve. + :param metadata_provider: The metadata provider associated with the ID. + :param db: The database session. + :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) # Load relationships + ) + ) + + result = db.execute(stmt).unique().scalar_one_or_none() + if not result: + return None + + return ShowSchema( + **result.__dict__ + ) + + +def get_shows(db: Session) -> list[ShowSchema]: + """ + Retrieve all shows from the database, including nested seasons and episodes. + + :param db: The database session. + :return: A list of ShowSchema objects. + """ + stmt = select(Show) + + results = db.execute(stmt).scalars().all() + + return [ + ShowSchema( + **show.__dict__, + seasons=[ + SeasonSchema( + **season.__dict__, + episodes=[EpisodeSchema(**episode.__dict__) for episode in season.episodes] + ) + for season in show.seasons + ] + ) + for show in results + ] + + +def save_show(show: ShowSchema, db: Session) -> ShowSchema: + """ + Save a new show to the database, including its seasons and episodes. + + :param show: The ShowSchema object to save. + :param db: The database session. + :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, # Correctly linking to the parent show + number=season.number, + external_id=season.external_id, + name=season.name, + overview=season.overview, + episodes=[ + Episode( + id=episode.id, + season_id=season.id, # Correctly linking to the parent season + number=episode.number, + external_id=episode.external_id, + title=episode.title + ) for episode in season.episodes # Convert episodes properly + ] + ) for season in show.seasons # Convert seasons properly + ] + ) + + db.add(db_show) + try: + db.commit() + db.refresh(db_show) + return ShowSchema( + **db_show.__dict__, + seasons=[ + SeasonSchema( + **season.__dict__, + episodes=[EpisodeSchema(**episode.__dict__) for episode in season.episodes] + ) for season in db_show.seasons + ] + ) + except IntegrityError: + db.rollback() + raise ValueError("Show with this primary key already exists.") + + +def delete_show(show_id: ShowId, db: Session) -> None: + """ + Delete a show by its ID. + + :param show_id: The ID of the show to delete. + :param db: The database session. + :return: The deleted ShowSchema object if found, otherwise None. + """ + show = db.get(Show, show_id) + db.delete(show) + db.commit() + + +def get_season(season_id: SeasonId, db: Session) -> SeasonSchema: + """ + + :param season_id: The ID of the season to get. + :param db: The database session. + :return: a Season object. + """ + return SeasonSchema(**db.get(Season(), season_id).__dict__) + + +def add_season_to_requested_list(season_request: SeasonRequestSchema, db: Session) -> None: + """ + Adds a Season to the SeasonRequest table, which marks it as requested. + + """ + db.add(SeasonRequest(**season_request.__dict__)) + db.commit() + + +def remove_season_from_requested_list(season_request: SeasonRequestSchema, db: Session) -> None: + """ + Removes a Season from the SeasonRequest table, which removes it from the 'requested' list. + + """ + db.delete(SeasonRequest(**season_request.__dict__)) + db.commit() + + +def get_season_requests(db: Session) -> list[SeasonRequestSchema]: + stmt = select(SeasonRequest) + result = db.execute(stmt).scalars().all() + return [SeasonRequestSchema(**season.__dict__) for season in result] + + +if __name__ == "__main__": + database.init_db() + session = database.SessionLocal() + print(get_show(show_id=ShowId('2fc11032-d614-4651-877d-c8b11aa63aef'), db=session)) + print(get_shows(db=session)) + print(delete_show(show_id=ShowId('2fc11032-d614-4651-877d-c8b11aa63aef'), db=session)) diff --git a/backend/src/tv/router.py b/backend/src/tv/router.py index d684568..21db283 100644 --- a/backend/src/tv/router.py +++ b/backend/src/tv/router.py @@ -1,197 +1,51 @@ -import json -import pprint -from uuid import UUID - -import psycopg.errors from fastapi import APIRouter, Depends, status from fastapi.responses import JSONResponse -from pydantic import BaseModel -from sqlmodel import select -import dowloadClients -import indexer import metadataProvider +import tv.service from auth.users import current_active_user from database import DbSessionDependency -from database.torrents import Torrent -from database.tv import Season, Show -from indexer import IndexerQueryResult -from tv import log +from tv.exceptions import MediaAlreadyExists +from tv.schemas import Show, SeasonRequest, ShowId -router = APIRouter( - prefix="/tv", -) +router = APIRouter() -class ShowDetails(BaseModel): - show: Show - seasons: list[Season] +# -------------------------------- +# CREATE AND DELETE SHOWS +# -------------------------------- - -@router.post("/show", status_code=status.HTTP_201_CREATED, dependencies=[Depends(current_active_user)], +@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"}, }) -def add_show(db: DbSessionDependency, show_id: int, metadata_provider: str = "tmdb", version: str = ""): - res = db.exec(select(Show). - where(Show.external_id == show_id). - where(Show.metadata_provider == metadata_provider). - where(Show.version == version)).first() - - if res is not None: - return JSONResponse(status_code=status.HTTP_409_CONFLICT, content={"message": "Show already exists"}) - - show = metadataProvider.get_show_metadata(id=show_id, provider=metadata_provider) - show.version = version - log.info("Adding show: " + json.dumps(show.model_dump(), default=str)) - db.add(show) +def add_show(db: DbSessionDependency, show_id: int, metadata_provider: str = "tmdb"): try: - db.commit() - db.refresh(show) - except psycopg.errors.UniqueViolation as e: - log.debug(e) - log.info("Show already exists " + show.__str__()) - return JSONResponse(status_code=status.HTTP_409_CONFLICT, content={"message": "Show already exists"}) - + show = tv.service.add_show(db=db, external_id=show_id, metadata_provider=metadata_provider, ) + except MediaAlreadyExists as e: + return JSONResponse(status_code=status.HTTP_409_CONFLICT, content={"message": str(e)}) return show -@router.delete("/{show_id}", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)]) -def delete_show(db: DbSessionDependency, show_id: UUID): +@router.delete("/shows/{show_id}", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)]) +def delete_show(db: DbSessionDependency, show_id: ShowId): db.delete(db.get(Show, show_id)) db.commit() -@router.patch("/{show_id}/{season_id}", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)], - response_model=Season) -def add_season(db: DbSessionDependency, season_id: UUID): - """ - adds requested flag to a season - """ - season = db.get(Season, season_id) - season.requested = True - db.add(season) - db.commit() - db.refresh(season) +# -------------------------------- +# GET SHOW INFORMATION +# -------------------------------- - return season - - -@router.delete("/{show_id}/{season_id}", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)], - response_model=Show) -def delete_season(db: DbSessionDependency, show_id: UUID, season: int): - """ - removes requested flag from a season - """ - season = db.get(Season, (show_id, season)) - season.requested = False - db.add(season) - db.commit() - db.refresh(season) - return season - - -@router.get("/{show_id}/{season_id}/torrent", status_code=status.HTTP_200_OK, dependencies=[Depends( - current_active_user)], - response_model=list[IndexerQueryResult]) -def get_season_torrents(db: DbSessionDependency, show_id: UUID, season_id: UUID): - season = db.get(Season, season_id) - - if season is None: - return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"message": "Season not found"}) - - torrents: list[IndexerQueryResult] = indexer.search(season) - result = [] - for torrent in torrents: - if season.number in torrent.season: - result.append(torrent) - - db.commit() - if len(result) == 0: - return result - result.sort() - - log.info(f"Found {torrents.__len__()} torrents for show {season.show.name} season {season.number}, of which " - f"{result.__len__()} torrents fit the query") - log.debug(f"unfiltered torrents: \n{pprint.pformat(torrents)}\nfiltered torrents: \n{pprint.pformat(result)}") - return result - - -@router.post("/{show_id}/torrent", status_code=status.HTTP_200_OK, dependencies=[Depends( - current_active_user)], response_model=list[Season]) -def download_seasons_torrent(db: DbSessionDependency, show_id: UUID, torrent_id: UUID): - """ - downloads torrents for a show season, links the torrent for all seasons the torrent contains - - """ - torrent = db.get(Torrent, torrent_id) - - if torrent is None: - return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"message": "Torrent not found"}) - - seasons = [] - for season_number in torrent.season: - seasons.append( - db.exec(select(Season) - .where(Season.show_id == show_id) - .where(Season.number == season_number) - ).first() - ) - - torrent = torrent.download() - - dowloadClients.client.download(Torrent) - - for season in seasons: - season.requested = True - season.torrent_id = torrent.id - - return seasons - - -@router.post("/{show_id}/{season_id}/torrent", status_code=status.HTTP_200_OK, dependencies=[Depends( - current_active_user)], response_model=list[Season]) -def delete_seasons_torrent(db: DbSessionDependency, show_id: UUID, season_id: UUID, torrent_id: UUID): - """ - downloads torrents for a season, links the torrent only to the specified season - this means that multiple torrents can contain a season but you can choose from one which the content should be - imported - - """ - torrent = db.get(Torrent, torrent_id) - - if torrent is None: - return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"message": "Torrent not found"}) - - seasons = [] - for season_number in torrent.season: - seasons.append( - db.exec(select(Season) - .where(Season.show_id == show_id) - .where(Season.number == season_number) - ).first() - ) - - torrent = torrent.download() - - dowloadClients.client.download(Torrent) - - for season in seasons: - season.requested = True - season.torrent_id = torrent.id - - return seasons - - -@router.get("/", dependencies=[Depends(current_active_user)], response_model=list[Show]) +@router.get("/shows", dependencies=[Depends(current_active_user)], response_model=list[Show]) def get_shows(db: DbSessionDependency): """""" - return db.exec(select(Show)).unique().fetchall() + return tv.service.get_all_shows(db=db) -@router.get("/{show_id}", dependencies=[Depends(current_active_user)], response_model=ShowDetails) -def get_show(db: DbSessionDependency, show_id: UUID): +@router.get("/shows/{show_id}", dependencies=[Depends(current_active_user)], response_model=Show) +def get_show(db: DbSessionDependency, show_id: ShowId): """ :param show_id: @@ -199,15 +53,60 @@ def get_show(db: DbSessionDependency, show_id: UUID): :return: :rtype: """ - shows = db.execute(select(Show, Season).where(Show.id == show_id).join(Season).order_by(Season.number)).fetchall() - seasons = [] - for show in shows: - seasons.append(show[1]) - shows = db.execute(select(Show, Season).where(Show.id == show_id).join(Season).order_by(Season.number)) + return tv.service.get_show_by_id(db=db, show_id=show_id) - return ShowDetails(show=shows.first()[0], seasons=seasons) +# -------------------------------- +# CREATE AND DELETE SHOW REQUESTS +# -------------------------------- + +@router.post("/season", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)]) +def add_season(db: DbSessionDependency, season_request: SeasonRequest): + """ + adds request flag to a season + """ + tv.service.request_season(db=db, season_request=season_request) + + +@router.delete("/season", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)], + response_model=Show) +def delete_season(db: DbSessionDependency, season_request: SeasonRequest): + """ + removes request for a season + """ + tv.service.unrequest_season(db=db, season_request=season_request) + + +# -------------------------------- +# MANAGE REQUESTS +# -------------------------------- + +@router.get("/season/request", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)]) +def get_requested_seasons(db: DbSessionDependency) -> list[SeasonRequest]: + return tv.service.get_all_requested_seasons(db=db) + + +@router.delete("/season/request", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)]) +def unrequest_season(db: DbSessionDependency, request: SeasonRequest): + tv.service.unrequest_season(db=db, season_request=request) + + +# -------------------------------- +# MANAGE TORRENTS +# -------------------------------- + +# 1 is the default for season_number because it returns multi season torrents +@router.get("/torrents/{show_id}", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)]) +def get_torrents(db: DbSessionDependency, show_id: ShowId, season_number: int = 1): + return tv.service.get_all_available_torrents_for_a_season(db=db, season_number=season_number, show_id=show_id) + + +# download a torrent +@router.post("/torrents/") +# -------------------------------- +# SEARCH SHOWS ON METADATA PROVIDERS +# -------------------------------- @router.get("/search", dependencies=[Depends(current_active_user)]) def search_show(query: str, metadata_provider: str = "tmdb"): diff --git a/backend/src/tv/schemas.py b/backend/src/tv/schemas.py new file mode 100644 index 0000000..13de1ca --- /dev/null +++ b/backend/src/tv/schemas.py @@ -0,0 +1,67 @@ +import typing +import uuid +from uuid import UUID + +from pydantic import BaseModel, Field, ConfigDict + +from torrent.models import Quality + +ShowId = typing.NewType("ShowId", UUID) +SeasonId = typing.NewType("SeasonId", UUID) +EpisodeId = typing.NewType("EpisodeId", UUID) + +SeasonNumber = typing.NewType("SeasonNumber", int) +EpisodeNumber = typing.NewType("EpisodeNumber", int) + + +class Episode(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: EpisodeId = Field(default_factory=uuid.uuid4) + number: EpisodeNumber + external_id: int + title: str + + +class Season(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: SeasonId = Field(default_factory=uuid.uuid4) + number: SeasonNumber + + name: str + overview: str + + external_id: int + + episodes: list[Episode] + + +class Show(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: ShowId = Field(default_factory=uuid.uuid4) + + name: str + overview: str + year: int + + external_id: int + metadata_provider: str + + seasons: list[Season] + + +class SeasonRequest(BaseModel): + model_config = ConfigDict(from_attributes=True) + season_id: SeasonId + min_quality: Quality + wanted_quality: Quality + + +class SeasonFile(BaseModel): + model_config = ConfigDict(from_attributes=True) + season_id: SeasonId + quality: Quality + torrent_id: UUID + file_path: str diff --git a/backend/src/tv/service.py b/backend/src/tv/service.py new file mode 100644 index 0000000..137dc62 --- /dev/null +++ b/backend/src/tv/service.py @@ -0,0 +1,88 @@ +from sqlalchemy.orm import Session + +import database +import indexer +# import indexer +import metadataProvider +import tv.repository +from indexer import IndexerQueryResult +from tv.exceptions import MediaAlreadyExists +from tv.repository import get_show_by_external_id +from tv.schemas import Show, ShowId, SeasonRequest + + +def add_show(db: Session, external_id: int, metadata_provider: str) -> Show | None: + if check_if_show_exists(db=db, external_id=external_id, metadata_provider=metadata_provider): + raise MediaAlreadyExists(f"Show with external ID {external_id} and" + + f" metadata provider {metadata_provider} already exists") + show_with_metadata = metadataProvider.get_show_metadata(id=external_id, provider=metadata_provider) + saved_show = tv.repository.save_show(db=db, show=show_with_metadata) + return saved_show + + +def request_season(db: Session, season_request: SeasonRequest) -> None: + tv.repository.add_season_to_requested_list(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) + + +def check_if_show_exists(db: Session, + external_id: int = None, + metadata_provider: str = None, + show_id: ShowId = None) -> bool: + if external_id and metadata_provider: + if tv.repository.get_show_by_external_id(external_id=external_id, metadata_provider=metadata_provider, db=db): + return True + else: + return False + elif show_id: + if tv.repository.get_show(show_id=show_id, db=db): + return True + else: + return False + else: + raise ValueError("External ID and metadata provider or Show ID must be provided") + + +def get_all_available_torrents_for_a_season(db: Session, season_number: int, show_id: ShowId) -> list[ + IndexerQueryResult]: + show = tv.repository.get_show(show_id=show_id, db=db) + torrents: list[IndexerQueryResult] = indexer.search(show.name + " S" + str(season_number)) + result = [] + for torrent in torrents: + if season.number in torrent.season: + result.append(torrent) + return result + + +def get_all_shows(db: Session) -> list[Show]: + return tv.repository.get_shows(db=db) + + +def get_show_by_id(db: Session, show_id: ShowId) -> Show | None: + return tv.repository.get_show(show_id=show_id, db=db) + + +def get_all_requested_seasons(db: Session) -> list[SeasonRequest]: + return tv.repository.get_season_requests(db=db) + + +if __name__ == "__main__": + session = database.SessionLocal() + + try: + show = add_show(db=session, external_id=1418, metadata_provider="tmdb") + except MediaAlreadyExists as e: + print(e) + show = get_show_by_external_id(db=session, external_id=1418, metadata_provider="tmdb") + + print(show) + print(show.name) + for season in show.seasons: + print(season) + # print(season.number) + for episode in season.episodes: + print(episode) + # print(episode.title) diff --git a/docker-compose.yml b/docker-compose.yml index 0f6a5b9..a67aa3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,7 @@ services: db: image: postgres:latest restart: unless-stopped + container_name: postgres volumes: - .\backend\res\postgres:/var/lib/postgresql/data environment: @@ -21,12 +22,6 @@ services: - .\backend\res\prowlarr:/config ports: - "9696:9696" - ollama: - image: ollama/ollama - volumes: - - .\backend\res\ollama:/root/.ollama - ports: - - "11434:11434" qbittorrent: image: lscr.io/linuxserver/qbittorrent:latest container_name: qbittorrent