small fix

This commit is contained in:
maxDorninger
2025-04-15 15:51:00 +02:00
parent 3a80f874f9
commit edfeedc608
25 changed files with 635 additions and 392 deletions

View File

@@ -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

View File

@@ -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)]

View File

@@ -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")

View File

@@ -1,3 +0,0 @@

View File

@@ -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:

View File

@@ -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):

View File

@@ -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]]

View File

@@ -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'],

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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__)

View File

@@ -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__)

View File

@@ -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")

View File

View File

@@ -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"
id: Mapped[UUID] = mapped_column(primary_key=True)
status: Mapped[Literal["downloading", "finished", "error"] | None]
title: Mapped[str]
quality: Mapped[Quality | None]

View File

@@ -0,0 +1,9 @@
from enum import Enum
class Quality(Enum):
high = 1
medium = 2
low = 3
very_low = 4
unknown = 5

View File

@@ -0,0 +1,6 @@
class MediaAlreadyExists(ValueError):
'''Raised when a show already exists'''
class MediaDoesNotExist(ValueError):
'''Raised when a does not show exist'''

View File

@@ -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]

View File

@@ -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))

View File

@@ -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"):

67
backend/src/tv/schemas.py Normal file
View File

@@ -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

88
backend/src/tv/service.py Normal file
View File

@@ -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)