diff --git a/backend/src/auth/config.py b/backend/src/auth/config.py index 6f71732..6fae062 100644 --- a/backend/src/auth/config.py +++ b/backend/src/auth/config.py @@ -5,17 +5,18 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class AuthConfig(BaseSettings): # to get a signing key run: # openssl rand -hex 32 - model_config = SettingsConfigDict(env_prefix='AUTH_') + model_config = SettingsConfigDict(env_prefix="AUTH_") token_secret: str session_lifetime: int = 60 * 60 * 24 admin_email: str | list[str] + @property def jwt_signing_key(self): return self._jwt_signing_key class OAuth2Config(BaseSettings): - model_config = SettingsConfigDict(env_prefix='OAUTH_') + model_config = SettingsConfigDict(env_prefix="OAUTH_") client_id: str client_secret: str authorize_endpoint: str diff --git a/backend/src/auth/db.py b/backend/src/auth/db.py index 017891b..c0a1e01 100644 --- a/backend/src/auth/db.py +++ b/backend/src/auth/db.py @@ -1,7 +1,11 @@ from collections.abc import AsyncGenerator from fastapi import Depends -from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase, SQLAlchemyBaseOAuthAccountTableUUID +from fastapi_users.db import ( + SQLAlchemyBaseUserTableUUID, + SQLAlchemyUserDatabase, + SQLAlchemyBaseOAuthAccountTableUUID, +) from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import Mapped, relationship diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py index 5ff33b6..1d7dc27 100644 --- a/backend/src/auth/router.py +++ b/backend/src/auth/router.py @@ -16,7 +16,11 @@ if oauth_enabled: oauth_config = OAuth2Config() -@users_router.get("/users/all", status_code=status.HTTP_200_OK, dependencies=[Depends(current_superuser)]) +@users_router.get( + "/users/all", + status_code=status.HTTP_200_OK, + dependencies=[Depends(current_superuser)], +) def get_all_users(db: DbSessionDependency) -> list[UserRead]: stmt = select(User) result = db.execute(stmt).scalars().unique() diff --git a/backend/src/auth/users.py b/backend/src/auth/users.py index 2a4496e..2222401 100644 --- a/backend/src/auth/users.py +++ b/backend/src/auth/users.py @@ -8,7 +8,8 @@ from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models from fastapi_users.authentication import ( AuthenticationBackend, BearerTransport, - CookieTransport, JWTStrategy, + CookieTransport, + JWTStrategy, ) from fastapi_users.db import SQLAlchemyUserDatabase from httpx_oauth.oauth2 import OAuth2 @@ -34,15 +35,17 @@ class GenericOAuth2(OAuth2): userinfo_endpoint = self.user_info_endpoint async with httpx.AsyncClient() as client: resp = await client.get( - userinfo_endpoint, - headers={"Authorization": f"Bearer {token}"} + userinfo_endpoint, headers={"Authorization": f"Bearer {token}"} ) resp.raise_for_status() data = resp.json() return data["sub"], data["email"] -if os.getenv("OAUTH_ENABLED") is not None and os.getenv("OAUTH_ENABLED").upper() == "TRUE": +if ( + os.getenv("OAUTH_ENABLED") is not None + and os.getenv("OAUTH_ENABLED").upper() == "TRUE" +): oauth2_config = OAuth2Config() oauth_client = GenericOAuth2( client_id=oauth2_config.client_id, @@ -72,7 +75,9 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): ): print(f"User {user.id} has forgot their password. Reset token: {token}") - async def on_after_reset_password(self, user: User, request: Optional[Request] = None): + async def on_after_reset_password( + self, user: User, request: Optional[Request] = None + ): print(f"User {user.id} has reset their password.") async def on_after_request_verify( @@ -80,9 +85,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): ): print(f"Verification requested for user {user.id}. Verification token: {token}") - async def on_after_verify( - self, user: User, request: Optional[Request] = None - ): + async def on_after_verify(self, user: User, request: Optional[Request] = None): print(f"User {user.id} has been verified") @@ -98,7 +101,10 @@ def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]: # thus the user would be stuck on the OAuth Providers "redirecting" page class RedirectingCookieTransport(CookieTransport): async def get_login_response(self, token: str) -> Response: - response = RedirectResponse(str(BasicConfig().FRONTEND_URL) + "dashboard", status_code=status.HTTP_302_FOUND) + response = RedirectResponse( + str(BasicConfig().FRONTEND_URL) + "dashboard", + status_code=status.HTTP_302_FOUND, + ) return self._set_login_cookie(response, token) @@ -122,7 +128,11 @@ oauth_cookie_auth_backend = AuthenticationBackend( get_strategy=get_jwt_strategy, ) -fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [bearer_auth_backend, cookie_auth_backend]) +fastapi_users = FastAPIUsers[User, uuid.UUID]( + get_user_manager, [bearer_auth_backend, cookie_auth_backend] +) current_active_user = fastapi_users.current_user(active=True, verified=True) -current_superuser = fastapi_users.current_user(active=True, verified=True, superuser=True) +current_superuser = fastapi_users.current_user( + active=True, verified=True, superuser=True +) diff --git a/backend/src/database/__init__.py b/backend/src/database/__init__.py index 101e6ed..4ce4fdd 100644 --- a/backend/src/database/__init__.py +++ b/backend/src/database/__init__.py @@ -12,8 +12,19 @@ from backend.src.database.config import DbConfig log = logging.getLogger(__name__) config = DbConfig() -db_url = "postgresql+psycopg" + "://" + config.USER + ":" + config.PASSWORD + "@" + config.HOST + ":" + str( - config.PORT) + "/" + config.DBNAME +db_url = ( + "postgresql+psycopg" + + "://" + + config.USER + + ":" + + config.PASSWORD + + "@" + + config.HOST + + ":" + + str(config.PORT) + + "/" + + config.DBNAME +) engine = create_engine(db_url, echo=False) log.debug("initializing sqlalchemy declarative base") @@ -21,7 +32,6 @@ Base = declarative_base() SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - def init_db() -> None: log.debug("initializing database with following tables") for table in Base.metadata.tables: @@ -41,7 +51,7 @@ def get_session() -> Generator[Session, Any, None]: db.close() -db_session: ContextVar[Session] = ContextVar('db_session') +db_session: ContextVar[Session] = ContextVar("db_session") DbSessionDependency = Annotated[Session, Depends(get_session)] diff --git a/backend/src/database/config.py b/backend/src/database/config.py index 7ae6805..5a9cb04 100644 --- a/backend/src/database/config.py +++ b/backend/src/database/config.py @@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class DbConfig(BaseSettings): - model_config = SettingsConfigDict(env_prefix='DB_') + model_config = SettingsConfigDict(env_prefix="DB_") HOST: str = "localhost" PORT: int = 5432 USER: str = "MediaManager" diff --git a/backend/src/indexer/indexers/generic.py b/backend/src/indexer/indexers/generic.py index 589a755..6b7ee19 100644 --- a/backend/src/indexer/indexers/generic.py +++ b/backend/src/indexer/indexers/generic.py @@ -8,7 +8,7 @@ class GenericIndexer(object): if name: self.name = name else: - raise ValueError('indexer name must not be None') + raise ValueError("indexer name must not be None") def get_search_results(self, query: str) -> list[IndexerQueryResult]: """ diff --git a/backend/src/indexer/indexers/prowlarr.py b/backend/src/indexer/indexers/prowlarr.py index d848132..6ffd298 100644 --- a/backend/src/indexer/indexers/prowlarr.py +++ b/backend/src/indexer/indexers/prowlarr.py @@ -17,7 +17,7 @@ class Prowlarr(GenericIndexer): :param api_key: The API key for authenticating requests to Prowlarr. :param kwargs: Additional keyword arguments to pass to the superclass constructor. """ - super().__init__(name='prowlarr') + super().__init__(name="prowlarr") config = ProwlarrConfig() self.api_key = config.api_key self.url = config.url @@ -25,31 +25,29 @@ class Prowlarr(GenericIndexer): def get_search_results(self, query: str) -> list[IndexerQueryResult]: log.debug("Searching for " + query) - url = self.url + '/api/v1/search' - headers = { - 'accept': 'application/json', - 'X-Api-Key': self.api_key - } + url = self.url + "/api/v1/search" + headers = {"accept": "application/json", "X-Api-Key": self.api_key} params = { - 'query': query, + "query": query, } response = requests.get(url, headers=headers, params=params) if response.status_code == 200: result_list: list[IndexerQueryResult] = [] for result in response.json(): - if result['protocol'] == 'torrent': + if result["protocol"] == "torrent": log.debug("torrent result: " + result.__str__()) result_list.append( IndexerQueryResult( - download_url=result['downloadUrl'], - title=result['sortTitle'], - seeders=result['seeders'], - flags=result['indexerFlags'], - size=result['size'], ) + download_url=result["downloadUrl"], + title=result["sortTitle"], + seeders=result["seeders"], + flags=result["indexerFlags"], + size=result["size"], + ) ) return result_list else: - log.error(f'Prowlarr Error: {response.status_code}') + log.error(f"Prowlarr Error: {response.status_code}") return [] diff --git a/backend/src/indexer/models.py b/backend/src/indexer/models.py index d2f7b22..643b349 100644 --- a/backend/src/indexer/models.py +++ b/backend/src/indexer/models.py @@ -8,7 +8,7 @@ from backend.src.torrent.schemas import Quality class IndexerQueryResult(Base): - __tablename__ = 'indexer_query_result' + __tablename__ = "indexer_query_result" id: Mapped[IndexerQueryResultId] = mapped_column(primary_key=True) title: Mapped[str] download_url: Mapped[str] diff --git a/backend/src/indexer/repository.py b/backend/src/indexer/repository.py index c377fb1..8c7214e 100644 --- a/backend/src/indexer/repository.py +++ b/backend/src/indexer/repository.py @@ -1,13 +1,22 @@ from sqlalchemy.orm import Session from indexer.models import IndexerQueryResult -from indexer.schemas import IndexerQueryResultId, IndexerQueryResult as IndexerQueryResultSchema +from indexer.schemas import ( + IndexerQueryResultId, + IndexerQueryResult as IndexerQueryResultSchema, +) -def get_result(result_id: IndexerQueryResultId, db: Session) -> IndexerQueryResultSchema: - return IndexerQueryResultSchema.model_validate(db.get(IndexerQueryResult, result_id)) +def get_result( + result_id: IndexerQueryResultId, db: Session +) -> IndexerQueryResultSchema: + return IndexerQueryResultSchema.model_validate( + db.get(IndexerQueryResult, result_id) + ) -def save_result(result: IndexerQueryResultSchema, db: Session) -> IndexerQueryResultSchema: +def save_result( + result: IndexerQueryResultSchema, db: Session +) -> IndexerQueryResultSchema: db.add(IndexerQueryResult(**result.model_dump())) return result diff --git a/backend/src/indexer/schemas.py b/backend/src/indexer/schemas.py index bedc19b..6d2a07f 100644 --- a/backend/src/indexer/schemas.py +++ b/backend/src/indexer/schemas.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, computed_field, ConfigDict from backend.src.torrent.models import Quality -IndexerQueryResultId = typing.NewType('IndexerQueryResultId', UUID) +IndexerQueryResultId = typing.NewType("IndexerQueryResultId", UUID) # TODO: use something like strategy pattern to make sorting more user customizable @@ -24,10 +24,10 @@ class IndexerQueryResult(BaseModel): @computed_field(return_type=Quality) @property def quality(self) -> Quality: - high_quality_pattern = r'\b(4k|4K)\b' - medium_quality_pattern = r'\b(1080p|1080P)\b' - low_quality_pattern = r'\b(720p|720P)\b' - very_low_quality_pattern = r'\b(480p|480P|360p|360P)\b' + high_quality_pattern = r"\b(4k|4K)\b" + medium_quality_pattern = r"\b(1080p|1080P)\b" + low_quality_pattern = r"\b(720p|720P)\b" + very_low_quality_pattern = r"\b(480p|480P|360p|360P)\b" if re.search(high_quality_pattern, self.title): return Quality.high diff --git a/backend/src/indexer/service.py b/backend/src/indexer/service.py index 1192450..804789d 100644 --- a/backend/src/indexer/service.py +++ b/backend/src/indexer/service.py @@ -19,5 +19,7 @@ def search(query: str, db: Session) -> list[IndexerQueryResult]: return results -def get_indexer_query_result(result_id: IndexerQueryResultId, db: Session) -> IndexerQueryResult: +def get_indexer_query_result( + result_id: IndexerQueryResultId, db: Session +) -> IndexerQueryResult: return indexer.repository.get_result(result_id=result_id, db=db) diff --git a/backend/src/main.py b/backend/src/main.py index dbac72d..7ba6540 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -14,8 +14,7 @@ LOGGING_CONFIG = { }, "json": { "()": JsonFormatter, - } - + }, }, "handlers": { "console": { @@ -29,7 +28,7 @@ LOGGING_CONFIG = { "filename": "./log.txt", "maxBytes": 10485760, "backupCount": 5, - } + }, }, "loggers": { "uvicorn": {"handlers": ["console", "file"], "level": "DEBUG"}, @@ -39,16 +38,16 @@ LOGGING_CONFIG = { } dictConfig(LOGGING_CONFIG) -logging.basicConfig(level=logging.DEBUG, - format="%(asctime)s - %(levelname)s - %(name)s - %(funcName)s(): %(message)s", - stream=sys.stdout, - ) +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(levelname)s - %(name)s - %(funcName)s(): %(message)s", + stream=sys.stdout, +) log = logging.getLogger(__name__) from backend.src.database import init_db import tv.router import torrent.router -import auth.db init_db() log.info("Database initialized") @@ -62,9 +61,15 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from auth.schemas import UserCreate, UserRead, UserUpdate -from auth.users import bearer_auth_backend, fastapi_users, cookie_auth_backend, oauth_cookie_auth_backend +from auth.users import ( + bearer_auth_backend, + fastapi_users, + cookie_auth_backend, + oauth_cookie_auth_backend, +) from auth.router import users_router as custom_users_router from auth.router import auth_metadata_router + basic_config = BasicConfig() if basic_config.DEVELOPMENT: basic_config.torrent_directory.mkdir(parents=True, exist_ok=True) @@ -94,12 +99,12 @@ if basic_config.DEVELOPMENT: app.include_router( fastapi_users.get_auth_router(bearer_auth_backend), prefix="/auth/jwt", - tags=["auth"] + tags=["auth"], ) app.include_router( fastapi_users.get_auth_router(cookie_auth_backend), prefix="/auth/cookie", - tags=["auth"] + tags=["auth"], ) app.include_router( fastapi_users.get_register_router(UserRead, UserCreate), @@ -117,15 +122,9 @@ app.include_router( tags=["auth"], ) # All users route router -app.include_router( - custom_users_router, - tags=["users"] -) +app.include_router(custom_users_router, tags=["users"]) # OAuth Metadata Router -app.include_router( - auth_metadata_router, - tags=["oauth"] -) +app.include_router(auth_metadata_router, tags=["oauth"]) # User Router app.include_router( fastapi_users.get_users_router(UserRead, UserUpdate), @@ -135,29 +134,26 @@ app.include_router( # OAuth2 Routers if oauth_client is not None: app.include_router( - fastapi_users.get_oauth_router(oauth_client, - oauth_cookie_auth_backend, - auth.users.SECRET, - associate_by_email=True, - is_verified_by_default=True, - ), + fastapi_users.get_oauth_router( + oauth_client, + oauth_cookie_auth_backend, + auth.users.SECRET, + associate_by_email=True, + is_verified_by_default=True, + ), prefix=f"/auth/cookie/{oauth_client.name}", tags=["oauth"], ) -app.include_router( - tv.router.router, - prefix="/tv", - tags=["tv"] -) -app.include_router( - torrent.router.router, - prefix="/torrent", - tags=["torrent"] -) +app.include_router(tv.router.router, prefix="/tv", tags=["tv"]) +app.include_router(torrent.router.router, prefix="/torrent", tags=["torrent"]) # static file routers -app.mount("/static/image", StaticFiles(directory=basic_config.image_directory), name="static-images") +app.mount( + "/static/image", + StaticFiles(directory=basic_config.image_directory), + name="static-images", +) 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 f437712..6c880b2 100644 --- a/backend/src/metadataProvider/__init__.py +++ b/backend/src/metadataProvider/__init__.py @@ -18,9 +18,10 @@ def get_show_metadata(id: int = None, provider: str = "tmdb") -> Show: @cached(search_show_cache) -def search_show(query: str | None = None, provider: str = "tmdb") -> list[MetaDataProviderShowSearchResult]: +def search_show( + query: str | None = None, provider: str = "tmdb" +) -> list[MetaDataProviderShowSearchResult]: """ If no query is provided, it will return the most popular shows. """ return metadata_providers[provider].search_show(query) - diff --git a/backend/src/metadataProvider/abstractMetaDataProvider.py b/backend/src/metadataProvider/abstractMetaDataProvider.py index 0800958..5b081d7 100644 --- a/backend/src/metadataProvider/abstractMetaDataProvider.py +++ b/backend/src/metadataProvider/abstractMetaDataProvider.py @@ -10,6 +10,7 @@ log = logging.getLogger(__name__) class AbstractMetadataProvider(ABC): storage_path = config.BasicConfig().image_directory + @property @abstractmethod def name(self) -> str: diff --git a/backend/src/metadataProvider/tmdb.py b/backend/src/metadataProvider/tmdb.py index 1a3c96c..dbf11ce 100644 --- a/backend/src/metadataProvider/tmdb.py +++ b/backend/src/metadataProvider/tmdb.py @@ -7,7 +7,10 @@ from pydantic_settings import BaseSettings from tmdbsimple import TV, TV_Seasons import metadataProvider.utils -from metadataProvider.abstractMetaDataProvider import AbstractMetadataProvider, register_metadata_provider +from metadataProvider.abstractMetaDataProvider import ( + AbstractMetadataProvider, + register_metadata_provider, +) from metadataProvider.schemas import MetaDataProviderShowSearchResult from tv.schemas import Episode, Season, Show, SeasonNumber, EpisodeNumber @@ -35,7 +38,9 @@ class TmdbMetadataProvider(AbstractMetadataProvider): season_list = [] # inserting all the metadata into the objects for season in show_metadata["seasons"]: - season_metadata = TV_Seasons(tv_id=show_metadata["id"], season_number=season["season_number"]).info() + season_metadata = TV_Seasons( + tv_id=show_metadata["id"], season_number=season["season_number"] + ).info() episode_list = [] for episode in season_metadata["episodes"]: @@ -43,7 +48,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider): Episode( external_id=int(episode["id"]), title=episode["name"], - number=EpisodeNumber(episode["episode_number"]) + number=EpisodeNumber(episode["episode_number"]), ) ) @@ -54,11 +59,12 @@ class TmdbMetadataProvider(AbstractMetadataProvider): overview=season_metadata["overview"], number=SeasonNumber(season_metadata["season_number"]), episodes=episode_list, - ) ) - year = metadataProvider.utils.get_year_from_first_air_date(show_metadata["first_air_date"]) + year = metadataProvider.utils.get_year_from_first_air_date( + show_metadata["first_air_date"] + ) show = Show( external_id=id, @@ -72,9 +78,12 @@ class TmdbMetadataProvider(AbstractMetadataProvider): # downloading the poster # all pictures from TMDB should already be jpeg, so no need to convert if show_metadata["poster_path"] is not None: - poster_url = "https://image.tmdb.org/t/p/original" + show_metadata["poster_path"] - if metadataProvider.utils.download_poster_image(storage_path=self.storage_path, poster_url=poster_url, - show=show): + poster_url = ( + "https://image.tmdb.org/t/p/original" + show_metadata["poster_path"] + ) + if metadataProvider.utils.download_poster_image( + storage_path=self.storage_path, poster_url=poster_url, show=show + ): log.info("Successfully downloaded poster image for show " + show.name) else: log.warning(f"download for image of show {show.name} failed") @@ -83,7 +92,9 @@ class TmdbMetadataProvider(AbstractMetadataProvider): return show - def search_show(self, query: str | None = None, max_pages: int = 5) -> list[MetaDataProviderShowSearchResult]: + def search_show( + self, query: str | None = None, max_pages: int = 5 + ) -> list[MetaDataProviderShowSearchResult]: """ Search for shows using TMDB API. If no query is provided, it will return the most popular shows. @@ -91,7 +102,9 @@ class TmdbMetadataProvider(AbstractMetadataProvider): if query is None: result_factory = lambda page: tmdbsimple.Trending(media_type="tv").info() else: - result_factory = lambda page: tmdbsimple.Search().tv(page=page, query=query, include_adult=True) + result_factory = lambda page: tmdbsimple.Search().tv( + page=page, query=query, include_adult=True + ) results = [] for i in range(1, max_pages + 1): @@ -106,7 +119,9 @@ class TmdbMetadataProvider(AbstractMetadataProvider): for result in results: try: if result["poster_path"] is not None: - poster_url = "https://image.tmdb.org/t/p/original" + result["poster_path"] + poster_url = ( + "https://image.tmdb.org/t/p/original" + result["poster_path"] + ) else: poster_url = None formatted_results.append( @@ -115,7 +130,9 @@ class TmdbMetadataProvider(AbstractMetadataProvider): overview=result["overview"], name=result["name"], external_id=result["id"], - year=metadataProvider.utils.get_year_from_first_air_date(result["first_air_date"]), + year=metadataProvider.utils.get_year_from_first_air_date( + result["first_air_date"] + ), metadata_provider=self.name, added=False, vote_average=result["vote_average"], @@ -131,4 +148,6 @@ class TmdbMetadataProvider(AbstractMetadataProvider): if config.TMDB_API_KEY is not None: log.info("Registering TMDB as metadata provider") - register_metadata_provider(metadata_provider=TmdbMetadataProvider(config.TMDB_API_KEY)) + register_metadata_provider( + metadata_provider=TmdbMetadataProvider(config.TMDB_API_KEY) + ) diff --git a/backend/src/metadataProvider/tvdb.py b/backend/src/metadataProvider/tvdb.py index 05feb34..746fbe9 100644 --- a/backend/src/metadataProvider/tvdb.py +++ b/backend/src/metadataProvider/tvdb.py @@ -11,7 +11,10 @@ from pydantic_settings import BaseSettings from tmdbsimple import TV, TV_Seasons import metadataProvider.utils -from metadataProvider.abstractMetaDataProvider import AbstractMetadataProvider, register_metadata_provider +from metadataProvider.abstractMetaDataProvider import ( + AbstractMetadataProvider, + register_metadata_provider, +) from metadataProvider.schemas import MetaDataProviderShowSearchResult from tv.schemas import Episode, Season, Show, SeasonNumber, EpisodeNumber @@ -43,27 +46,48 @@ class TvdbMetadataProvider(AbstractMetadataProvider): seasons = [] for season in series["seasons"]: s = self.tvdb_client.get_season_extended(season["id"]) - episodes = [Episode(number=episode['number'], external_id=episode['id'], title=episode['name']) for episode - in s["episodes"]] - seasons.append(Season(number=s['number'], name="TVDB doesn't provide Season Names", - overview="TVDB doesn't provide Season Overviews", external_id=s['id'], - episodes=episodes)) + episodes = [ + Episode( + number=episode["number"], + external_id=episode["id"], + title=episode["name"], + ) + for episode in s["episodes"] + ] + seasons.append( + Season( + number=s["number"], + name="TVDB doesn't provide Season Names", + overview="TVDB doesn't provide Season Overviews", + external_id=s["id"], + episodes=episodes, + ) + ) try: - year = series['year'] + year = series["year"] except KeyError: year = None - show = Show(name=series['name'], overview=series['overview'], year=year, - external_id=series['id'], metadata_provider=self.name, seasons=seasons) + show = Show( + name=series["name"], + overview=series["overview"], + year=year, + external_id=series["id"], + metadata_provider=self.name, + seasons=seasons, + ) if series["image"] is not None: - metadataProvider.utils.download_poster_image(storage_path=self.storage_path, - poster_url=series['image'], show=show) + metadataProvider.utils.download_poster_image( + storage_path=self.storage_path, poster_url=series["image"], show=show + ) else: log.warning(f"image for show {show.name} could not be downloaded") return show - def search_show(self, query: str | None = None) -> list[MetaDataProviderShowSearchResult]: + def search_show( + self, query: str | None = None + ) -> list[MetaDataProviderShowSearchResult]: if query is None: results = self.tvdb_client.get_all_series() else: @@ -71,9 +95,9 @@ class TvdbMetadataProvider(AbstractMetadataProvider): formatted_results = [] for result in results: try: - if result['type'] == 'series': + if result["type"] == "series": try: - year = result['year'] + year = result["year"] except KeyError: year = None @@ -86,7 +110,7 @@ class TvdbMetadataProvider(AbstractMetadataProvider): year=year, metadata_provider=self.name, added=False, - vote_average=None + vote_average=None, ) ) except Exception as e: @@ -96,7 +120,9 @@ class TvdbMetadataProvider(AbstractMetadataProvider): if config.TVDB_API_KEY is not None: log.info("Registering TVDB as metadata provider") - register_metadata_provider(metadata_provider=TvdbMetadataProvider(config.TVDB_API_KEY)) + register_metadata_provider( + metadata_provider=TvdbMetadataProvider(config.TVDB_API_KEY) + ) if __name__ == "__main__": tvdb = TvdbMetadataProvider(config.TVDB_API_KEY) diff --git a/backend/src/metadataProvider/utils.py b/backend/src/metadataProvider/utils.py index e031307..3cf8615 100644 --- a/backend/src/metadataProvider/utils.py +++ b/backend/src/metadataProvider/utils.py @@ -3,10 +3,9 @@ import mimetypes import requests - def get_year_from_first_air_date(first_air_date: str | None) -> int | None: if first_air_date: - return int(first_air_date.split('-')[0]) + return int(first_air_date.split("-")[0]) else: return None @@ -16,7 +15,7 @@ def download_poster_image(storage_path=None, poster_url=None, show=None) -> bool content_type = res.headers["content-type"] file_extension = mimetypes.guess_extension(content_type) if res.status_code == 200: - with open(storage_path.joinpath(str(show.id) + file_extension), 'wb') as f: + with open(storage_path.joinpath(str(show.id) + file_extension), "wb") as f: f.write(res.content) return True else: diff --git a/backend/src/torrent/repository.py b/backend/src/torrent/repository.py index 45cdbaa..7a447df 100644 --- a/backend/src/torrent/repository.py +++ b/backend/src/torrent/repository.py @@ -7,7 +7,9 @@ from tv.models import SeasonFile, Show, Season from tv.schemas import SeasonFile as SeasonFileSchema, Show as ShowSchema -def get_seasons_files_of_torrent(db: Session, torrent_id: TorrentId) -> list[SeasonFileSchema]: +def get_seasons_files_of_torrent( + db: Session, torrent_id: TorrentId +) -> list[SeasonFileSchema]: stmt = select(SeasonFile).where(SeasonFile.torrent_id == torrent_id) result = db.execute(stmt).scalars().all() return [SeasonFileSchema.model_validate(season_file) for season_file in result] @@ -15,10 +17,10 @@ def get_seasons_files_of_torrent(db: Session, torrent_id: TorrentId) -> list[Sea def get_show_of_torrent(db: Session, torrent_id: TorrentId) -> ShowSchema: stmt = ( - select(Show). - join(SeasonFile.season). - join(Season.show). - where(SeasonFile.torrent_id == torrent_id) + select(Show) + .join(SeasonFile.season) + .join(Season.show) + .where(SeasonFile.torrent_id == torrent_id) ) result = db.execute(stmt).unique().scalar_one_or_none() return ShowSchema.model_validate(result) diff --git a/backend/src/torrent/router.py b/backend/src/torrent/router.py index aa7d504..adcf6d7 100644 --- a/backend/src/torrent/router.py +++ b/backend/src/torrent/router.py @@ -14,24 +14,40 @@ def get_torrent(service: TorrentServiceDependency, torrent_id: TorrentId): return service.get_torrent_by_id(id=torrent_id) -@router.get("/", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)], - response_model=list[Torrent]) +@router.get( + "/", + status_code=status.HTTP_200_OK, + dependencies=[Depends(current_active_user)], + response_model=list[Torrent], +) def get_all_torrents(service: TorrentServiceDependency): return service.get_all_torrents() -@router.post("/{torrent_id}", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)], - response_model=Torrent) +@router.post( + "/{torrent_id}", + status_code=status.HTTP_200_OK, + dependencies=[Depends(current_active_user)], + response_model=Torrent, +) def import_torrent(service: TorrentServiceDependency, torrent_id: TorrentId): return service.import_torrent(service.get_torrent_by_id(id=torrent_id)) -@router.post("/", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)], - response_model=list[Torrent]) +@router.post( + "/", + status_code=status.HTTP_200_OK, + dependencies=[Depends(current_active_user)], + response_model=list[Torrent], +) def import_all_torrents(service: TorrentServiceDependency): return service.import_all_torrents() -@router.delete("/{torrent_id}", status_code=status.HTTP_200_OK, dependencies=[Depends(current_superuser)]) +@router.delete( + "/{torrent_id}", + status_code=status.HTTP_200_OK, + dependencies=[Depends(current_superuser)], +) def delete_torrent(service: TorrentServiceDependency, torrent_id: TorrentId): service.delete_torrent(torrent_id=torrent_id) diff --git a/backend/src/torrent/service.py b/backend/src/torrent/service.py index 696bfc1..3db4765 100644 --- a/backend/src/torrent/service.py +++ b/backend/src/torrent/service.py @@ -17,16 +17,25 @@ import tv.repository import tv.service from config import BasicConfig from indexer import IndexerQueryResult -from torrent.repository import get_seasons_files_of_torrent, get_show_of_torrent, save_torrent +from torrent.repository import ( + get_seasons_files_of_torrent, + get_show_of_torrent, + save_torrent, +) from torrent.schemas import Torrent, TorrentStatus, TorrentId -from torrent.utils import list_files_recursively, get_torrent_filepath, import_file, extract_archives +from torrent.utils import ( + list_files_recursively, + get_torrent_filepath, + import_file, + extract_archives, +) from tv.schemas import SeasonFile, Show log = logging.getLogger(__name__) class TorrentServiceConfig(BaseSettings): - model_config = SettingsConfigDict(env_prefix='QBITTORRENT_') + model_config = SettingsConfigDict(env_prefix="QBITTORRENT_") host: str = "localhost" port: int = 8080 username: str = "admin" @@ -34,9 +43,25 @@ class TorrentServiceConfig(BaseSettings): class TorrentService: - DOWNLOADING_STATE = ("allocating", "downloading", "metaDL", "pausedDL", "queuedDL", "stalledDL", "checkingDL", - "forcedDL", "moving") - FINISHED_STATE = ("uploading", "pausedUP", "queuedUP", "stalledUP", "checkingUP", "forcedUP") + DOWNLOADING_STATE = ( + "allocating", + "downloading", + "metaDL", + "pausedDL", + "queuedDL", + "stalledDL", + "checkingDL", + "forcedDL", + "moving", + ) + FINISHED_STATE = ( + "uploading", + "pausedUP", + "queuedUP", + "stalledUP", + "checkingUP", + "forcedUP", + ) ERROR_STATE = ("missingFiles", "error", "checkingResumeData") UNKNOWN_STATE = ("unknown",) api_client = qbittorrentapi.Client(**TorrentServiceConfig().model_dump()) @@ -54,35 +79,42 @@ class TorrentService: def download(self, indexer_result: IndexerQueryResult) -> Torrent: log.info(f"Attempting to download torrent: {indexer_result.title}") - torrent = Torrent(status=TorrentStatus.unknown, - title=indexer_result.title, - quality=indexer_result.quality, - imported=False, - hash="") + torrent = Torrent( + status=TorrentStatus.unknown, + title=indexer_result.title, + quality=indexer_result.quality, + imported=False, + hash="", + ) url = indexer_result.download_url torrent_filepath = BasicConfig().torrent_directory / f"{torrent.title}.torrent" - with open(torrent_filepath, 'wb') as file: + with open(torrent_filepath, "wb") as file: content = requests.get(url).content file.write(content) - with open(torrent_filepath, 'rb') as file: + with open(torrent_filepath, "rb") as file: content = file.read() try: decoded_content = bencoder.decode(content) except Exception as e: log.error(f"Failed to decode torrent file: {e}") raise e - torrent.hash = hashlib.sha1(bencoder.encode(decoded_content[b'info'])).hexdigest() - answer = self.api_client.torrents_add(category="MediaManager", torrent_files=content, - save_path=torrent.title) + torrent.hash = hashlib.sha1( + bencoder.encode(decoded_content[b"info"]) + ).hexdigest() + answer = self.api_client.torrents_add( + category="MediaManager", torrent_files=content, save_path=torrent.title + ) if answer == "Ok.": log.info(f"Successfully added torrent: {torrent.title}") return self.get_torrent_status(torrent=torrent) else: log.error(f"Failed to download torrent. API response: {answer}") - raise RuntimeError(f"Failed to download torrent, API-Answer isn't 'Ok.'; API Answer: {answer}") + raise RuntimeError( + f"Failed to download torrent, API-Answer isn't 'Ok.'; API Answer: {answer}" + ) def get_torrent_status(self, torrent: Torrent) -> Torrent: log.info(f"Fetching status for torrent: {torrent.title}") @@ -161,14 +193,25 @@ class TorrentService: subtitle_files.append(file) log.debug(f"File is a subtitle, it will be imported: {file}") else: - log.debug(f"File is neither a video nor a subtitle, will not be imported: {file}") - log.info(f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files)) + log.debug( + f"File is neither a video nor a subtitle, will not be imported: {file}" + ) + log.info( + f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files) + ) # Fetch show and season information show: Show = get_show_of_torrent(db=self.db, torrent_id=torrent.id) - show_file_path = BasicConfig().tv_directory / f"{show.name} ({show.year}) [{show.metadata_provider}id-{show.external_id}]" - season_files: list[SeasonFile] = get_seasons_files_of_torrent(db=self.db, torrent_id=torrent.id) - log.info(f"Found {len(season_files)} season files associated with torrent {torrent.title}") + show_file_path = ( + BasicConfig().tv_directory + / f"{show.name} ({show.year}) [{show.metadata_provider}id-{show.external_id}]" + ) + season_files: list[SeasonFile] = get_seasons_files_of_torrent( + db=self.db, torrent_id=torrent.id + ) + log.info( + f"Found {len(season_files)} season files associated with torrent {torrent.title}" + ) # creating directories and hard linking files for season_file in season_files: @@ -181,52 +224,82 @@ class TorrentService: log.warning(f"Path already exists: {season_path}") for episode in season.episodes: - episode_file_name = f"{show.name} S{season.number:02d}E{episode.number:02d}" + episode_file_name = ( + f"{show.name} S{season.number:02d}E{episode.number:02d}" + ) if season_file.file_path_suffix != "": episode_file_name += f" - {season_file.file_path_suffix}" - pattern = r'.*[.]S0?' + str(season.number) + r'E0?' + str(episode.number) + r"[.].*" + pattern = ( + r".*[.]S0?" + + str(season.number) + + r"E0?" + + str(episode.number) + + r"[.].*" + ) subtitle_pattern = pattern + r"[.]([A-Za-z]{2})[.]srt" target_file_name = season_path / episode_file_name # import subtitles for subtitle_file in subtitle_files: - log.debug(f"Searching for pattern {subtitle_pattern} in subtitle file: {subtitle_file.name}") + log.debug( + f"Searching for pattern {subtitle_pattern} in subtitle file: {subtitle_file.name}" + ) regex_result = re.search(subtitle_pattern, subtitle_file.name) if regex_result: language_code = regex_result.group(1) log.debug( - f"Found matching pattern: {subtitle_pattern} in subtitle file: {subtitle_file.name}," + - f" extracted language code: {language_code}") - target_subtitle_file = target_file_name.with_suffix(f".{language_code}.srt") - import_file(target_file=target_subtitle_file, source_file=subtitle_file) + f"Found matching pattern: {subtitle_pattern} in subtitle file: {subtitle_file.name}," + + f" extracted language code: {language_code}" + ) + target_subtitle_file = target_file_name.with_suffix( + f".{language_code}.srt" + ) + import_file( + target_file=target_subtitle_file, source_file=subtitle_file + ) else: - log.debug(f"Didn't find any pattern {subtitle_pattern} in subtitle file: {subtitle_file.name}") + log.debug( + f"Didn't find any pattern {subtitle_pattern} in subtitle file: {subtitle_file.name}" + ) # import episode videos for file in video_files: - log.debug(f"Searching for pattern {pattern} in video file: {file.name}") + log.debug( + f"Searching for pattern {pattern} in video file: {file.name}" + ) if re.search(pattern, file.name): - log.debug(f"Found matching pattern: {pattern} in file {file.name}") + log.debug( + f"Found matching pattern: {pattern} in file {file.name}" + ) target_video_file = target_file_name.with_suffix(file.suffix) import_file(target_file=target_video_file, source_file=file) break else: - log.warning(f"S{season.number}E{episode.number} in Torrent {torrent.title}'s files not found.") + log.warning( + f"S{season.number}E{episode.number} in Torrent {torrent.title}'s files not found." + ) torrent.imported = True return self.get_torrent_status(torrent=torrent) def get_all_torrents(self) -> list[Torrent]: - return [self.get_torrent_status(x) for x in torrent.repository.get_all_torrents(db=self.db)] + return [ + self.get_torrent_status(x) + for x in torrent.repository.get_all_torrents(db=self.db) + ] def get_torrent_by_id(self, id: TorrentId) -> Torrent: - return self.get_torrent_status(torrent.repository.get_torrent_by_id(torrent_id=id, db=self.db)) + return self.get_torrent_status( + torrent.repository.get_torrent_by_id(torrent_id=id, db=self.db) + ) def delete_torrent(self, torrent_id: TorrentId): t = torrent.repository.get_torrent_by_id(torrent_id=torrent_id, db=self.db) if not t.imported: - tv.repository.remove_season_files_by_torrent_id(db=self.db, torrent_id=torrent_id) + tv.repository.remove_season_files_by_torrent_id( + db=self.db, torrent_id=torrent_id + ) torrent.repository.delete_torrent(db=self.db, torrent_id=t.id) @repeat_every(seconds=3600) diff --git a/backend/src/torrent/utils.py b/backend/src/torrent/utils.py index f69e3e9..1780c1e 100644 --- a/backend/src/torrent/utils.py +++ b/backend/src/torrent/utils.py @@ -9,6 +9,7 @@ from torrent.schemas import Torrent log = logging.getLogger(__name__) + def list_files_recursively(path: Path = Path(".")) -> list[Path]: files = list(path.glob("**/*")) log.debug(f"Found {len(files)} entries via glob") @@ -28,10 +29,13 @@ def extract_archives(files): for file in files: file_type = mimetypes.guess_type(file) log.debug(f"File: {file}, Size: {file.stat().st_size} bytes, Type: {file_type}") - if file_type[0] == 'application/x-compressed': - log.debug(f"File {file} is a compressed file, extracting it into directory {file.parent}") + if file_type[0] == "application/x-compressed": + log.debug( + f"File {file} is a compressed file, extracting it into directory {file.parent}" + ) patoolib.extract_archive(str(file), outdir=str(file.parent)) + def get_torrent_filepath(torrent: Torrent): return BasicConfig().torrent_directory / torrent.title diff --git a/backend/src/tv/exceptions.py b/backend/src/tv/exceptions.py index b3faf4f..03974b6 100644 --- a/backend/src/tv/exceptions.py +++ b/backend/src/tv/exceptions.py @@ -1,6 +1,6 @@ class MediaAlreadyExists(ValueError): - '''Raised when a show already exists''' + """Raised when a show already exists""" class MediaDoesNotExist(ValueError): - '''Raised when a does not show exist''' + """Raised when a does not show exist""" diff --git a/backend/src/tv/models.py b/backend/src/tv/models.py index bb542a0..90e74b1 100644 --- a/backend/src/tv/models.py +++ b/backend/src/tv/models.py @@ -19,7 +19,9 @@ class Show(Base): overview: Mapped[str] year: Mapped[int | None] - seasons: Mapped[list["Season"]] = relationship(back_populates="show", cascade="all, delete") + seasons: Mapped[list["Season"]] = relationship( + back_populates="show", cascade="all, delete" + ) class Season(Base): @@ -27,26 +29,34 @@ class Season(Base): __table_args__ = (UniqueConstraint("show_id", "number"),) id: Mapped[UUID] = mapped_column(primary_key=True) - show_id: Mapped[UUID] = mapped_column(ForeignKey(column="show.id", ondelete="CASCADE"), ) + 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") - episodes: Mapped[list["Episode"]] = relationship(back_populates="season", cascade="all, delete") + episodes: Mapped[list["Episode"]] = relationship( + back_populates="season", cascade="all, delete" + ) - season_files = relationship("SeasonFile", back_populates="season", cascade="all, delete") - season_requests = relationship("SeasonRequest", back_populates="season", cascade="all, delete") + season_files = relationship( + "SeasonFile", back_populates="season", cascade="all, delete" + ) + season_requests = relationship( + "SeasonRequest", back_populates="season", cascade="all, delete" + ) class Episode(Base): __tablename__ = "episode" - __table_args__ = ( - UniqueConstraint("season_id", "number"), - ) + __table_args__ = (UniqueConstraint("season_id", "number"),) id: Mapped[UUID] = mapped_column(primary_key=True) - season_id: Mapped[UUID] = mapped_column(ForeignKey("season.id", ondelete="CASCADE"), ) + season_id: Mapped[UUID] = mapped_column( + ForeignKey("season.id", ondelete="CASCADE"), + ) number: Mapped[int] external_id: Mapped[int] title: Mapped[str] @@ -56,30 +66,41 @@ class Episode(Base): class SeasonFile(Base): __tablename__ = "season_file" - __table_args__ = ( - PrimaryKeyConstraint("season_id", "file_path_suffix"), + __table_args__ = (PrimaryKeyConstraint("season_id", "file_path_suffix"),) + 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"), ) - 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_suffix: Mapped[str] quality: Mapped[Quality] torrent = relationship("Torrent", back_populates="season_files", uselist=False) season = relationship("Season", back_populates="season_files", uselist=False) + class SeasonRequest(Base): __tablename__ = "season_request" - __table_args__ = ( - UniqueConstraint("season_id", "wanted_quality"), - ) + __table_args__ = (UniqueConstraint("season_id", "wanted_quality"),) id: Mapped[UUID] = mapped_column(primary_key=True) - season_id: Mapped[UUID] = mapped_column(ForeignKey(column="season.id", ondelete="CASCADE"), ) + season_id: Mapped[UUID] = mapped_column( + ForeignKey(column="season.id", ondelete="CASCADE"), + ) wanted_quality: Mapped[Quality] min_quality: Mapped[Quality] - requested_by_id: Mapped[UUID | None] = mapped_column(ForeignKey(column="user.id", ondelete="SET NULL"), ) + requested_by_id: Mapped[UUID | None] = mapped_column( + ForeignKey(column="user.id", ondelete="SET NULL"), + ) authorized: Mapped[bool] = mapped_column(default=False) - authorized_by_id: Mapped[UUID | None] = mapped_column(ForeignKey(column="user.id", ondelete="SET NULL"), ) + authorized_by_id: Mapped[UUID | None] = mapped_column( + ForeignKey(column="user.id", ondelete="SET NULL"), + ) - requested_by: Mapped["User|None"] = relationship(foreign_keys=[requested_by_id], uselist=False) - authorized_by: Mapped["User|None"] = relationship(foreign_keys=[authorized_by_id], uselist=False) + requested_by: Mapped["User|None"] = relationship( + foreign_keys=[requested_by_id], uselist=False + ) + authorized_by: Mapped["User|None"] = relationship( + foreign_keys=[authorized_by_id], uselist=False + ) season = relationship("Season", back_populates="season_requests", uselist=False) diff --git a/backend/src/tv/repository.py b/backend/src/tv/repository.py index f518f7d..a78738f 100644 --- a/backend/src/tv/repository.py +++ b/backend/src/tv/repository.py @@ -6,9 +6,17 @@ from torrent.models import Torrent from torrent.schemas import TorrentId, Torrent as TorrentSchema from tv import log 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, SeasonRequestId, \ - RichSeasonRequest as RichSeasonRequestSchema +from tv.schemas import ( + Season as SeasonSchema, + SeasonId, + Show as ShowSchema, + ShowId, + SeasonRequest as SeasonRequestSchema, + SeasonFile as SeasonFileSchema, + SeasonNumber, + SeasonRequestId, + RichSeasonRequest as RichSeasonRequestSchema, +) def get_show(show_id: ShowId, db: Session) -> ShowSchema | None: @@ -19,7 +27,11 @@ 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: @@ -28,7 +40,9 @@ 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: +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. @@ -38,8 +52,11 @@ 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: @@ -71,12 +88,35 @@ 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: @@ -122,9 +162,13 @@ def add_season_request(season_request: SeasonRequestSchema, db: Session) -> None season_id=season_request.season_id, wanted_quality=season_request.wanted_quality, min_quality=season_request.min_quality, - requested_by_id=season_request.requested_by.id if season_request.requested_by else None, + requested_by_id=season_request.requested_by.id + if season_request.requested_by + else None, authorized=season_request.authorized, - authorized_by_id=season_request.authorized_by.id if season_request.authorized_by else None + authorized_by_id=season_request.authorized_by.id + if season_request.authorized_by + else None, ) db.add(db_model) db.commit() @@ -140,23 +184,40 @@ def delete_season_request(season_request_id: SeasonRequestId, db: Session) -> No db.commit() -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))) +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)) + ) result = db.execute(stmt).unique().scalar_one_or_none() return SeasonSchema.model_validate(result) def get_season_requests(db: Session) -> list[RichSeasonRequestSchema]: - stmt = select(SeasonRequest).options(joinedload(SeasonRequest.requested_by), - joinedload(SeasonRequest.authorized_by), - joinedload(SeasonRequest.season).joinedload(Season.show)) + stmt = select(SeasonRequest).options( + joinedload(SeasonRequest.requested_by), + joinedload(SeasonRequest.authorized_by), + joinedload(SeasonRequest.season).joinedload(Season.show), + ) result = db.execute(stmt).scalars().unique().all() - return [RichSeasonRequestSchema(min_quality=x.min_quality, - wanted_quality=x.wanted_quality, show=x.season.show, season=x.season, - requested_by=x.requested_by, authorized_by=x.authorized_by, authorized=x.authorized, - id=x.id, season_id=x.season.id) - for x in result] + return [ + RichSeasonRequestSchema( + min_quality=x.min_quality, + wanted_quality=x.wanted_quality, + show=x.season.show, + season=x.season, + requested_by=x.requested_by, + authorized_by=x.authorized_by, + authorized=x.authorized, + id=x.id, + season_id=x.season.id, + ) + for x in result + ] def add_season_file(db: Session, season_file: SeasonFileSchema) -> SeasonFileSchema: @@ -166,22 +227,24 @@ 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] @@ -193,13 +256,15 @@ 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() @@ -207,16 +272,20 @@ def get_all_shows_with_torrents(db: Session) -> list[ShowSchema]: 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] -def get_season_request(db: Session, season_request_id: SeasonRequestId) -> SeasonRequestSchema: +def get_season_request( + db: Session, season_request_id: SeasonRequestId +) -> SeasonRequestSchema: return SeasonRequestSchema.model_validate(db.get(SeasonRequest, season_request_id)) - diff --git a/backend/src/tv/router.py b/backend/src/tv/router.py index 91188b9..ce4ab17 100644 --- a/backend/src/tv/router.py +++ b/backend/src/tv/router.py @@ -1,4 +1,3 @@ -import logging from typing import Annotated from fastapi import APIRouter, Depends, status @@ -15,8 +14,19 @@ from metadataProvider.schemas import MetaDataProviderShowSearchResult from torrent.schemas import Torrent from tv import log from tv.exceptions import MediaAlreadyExists -from tv.schemas import Show, SeasonRequest, ShowId, RichShowTorrent, PublicShow, PublicSeasonFile, SeasonNumber, \ - CreateSeasonRequest, SeasonRequestId, UpdateSeasonRequest, RichSeasonRequest +from tv.schemas import ( + Show, + SeasonRequest, + ShowId, + RichShowTorrent, + PublicShow, + PublicSeasonFile, + SeasonNumber, + CreateSeasonRequest, + SeasonRequestId, + UpdateSeasonRequest, + RichSeasonRequest, +) router = APIRouter() @@ -25,18 +35,38 @@ router = APIRouter() # CREATE AND DELETE SHOWS # -------------------------------- -@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"}, }) + +@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_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, ) + 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 JSONResponse( + status_code=status.HTTP_409_CONFLICT, content={"message": str(e)} + ) return show -@router.delete("/shows/{show_id}", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)]) +@router.delete( + "/shows/{show_id}", + status_code=status.HTTP_200_OK, + dependencies=[Depends(current_active_user)], +) def delete_a_show(db: DbSessionDependency, show_id: ShowId): db.delete(db.get(Show, show_id)) db.commit() @@ -46,15 +76,26 @@ def delete_a_show(db: DbSessionDependency, show_id: ShowId): # GET SHOW INFORMATION # -------------------------------- -@router.get("/shows", dependencies=[Depends(current_active_user)], response_model=list[Show]) -def get_all_shows(db: DbSessionDependency, external_id: int = None, metadata_provider: str = "tmdb"): + +@router.get( + "/shows", dependencies=[Depends(current_active_user)], response_model=list[Show] +) +def get_all_shows( + db: DbSessionDependency, external_id: int = None, metadata_provider: str = "tmdb" +): if external_id is not None: - return tv.service.get_show_by_external_id(db=db, external_id=external_id, metadata_provider=metadata_provider) + return tv.service.get_show_by_external_id( + db=db, external_id=external_id, metadata_provider=metadata_provider + ) else: return tv.service.get_all_shows(db=db) -@router.get("/shows/torrents", dependencies=[Depends(current_active_user)], response_model=list[RichShowTorrent]) +@router.get( + "/shows/torrents", + dependencies=[Depends(current_active_user)], + response_model=list[RichShowTorrent], +) def get_shows_with_torrents(db: DbSessionDependency): """ get all shows that are associated with torrents @@ -64,30 +105,51 @@ def get_shows_with_torrents(db: DbSessionDependency): return result -@router.get("/shows/{show_id}", dependencies=[Depends(current_active_user)], response_model=PublicShow) +@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) -@router.get("/shows/{show_id}/torrents", dependencies=[Depends(current_active_user)], response_model=RichShowTorrent) +@router.get( + "/shows/{show_id}/torrents", + dependencies=[Depends(current_active_user)], + response_model=RichShowTorrent, +) 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)) + 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}/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 + ) # -------------------------------- # MANAGE REQUESTS # -------------------------------- + @router.post("/seasons/requests", status_code=status.HTTP_204_NO_CONTENT) -def request_a_season(db: DbSessionDependency, user: Annotated[User, Depends(current_active_user)], - season_request: CreateSeasonRequest): +def request_a_season( + db: DbSessionDependency, + user: Annotated[User, Depends(current_active_user)], + season_request: CreateSeasonRequest, +): """ adds request flag to a season """ @@ -100,33 +162,54 @@ def request_a_season(db: DbSessionDependency, user: Annotated[User, Depends(curr return -@router.get("/seasons/requests", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)], - response_model=list[RichSeasonRequest]) +@router.get( + "/seasons/requests", + status_code=status.HTTP_200_OK, + dependencies=[Depends(current_active_user)], + response_model=list[RichSeasonRequest], +) def get_season_requests(db: DbSessionDependency) -> list[RichSeasonRequest]: return tv.service.get_all_season_requests(db=db) -@router.delete("/seasons/requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT, ) -def delete_season_request(db: DbSessionDependency, user: Annotated[User, Depends(current_active_user)], - request_id: SeasonRequestId): +@router.delete( + "/seasons/requests/{request_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_season_request( + db: DbSessionDependency, + user: Annotated[User, Depends(current_active_user)], + request_id: SeasonRequestId, +): request = tv.service.get_season_request_by_id(db=db, season_request_id=request_id) if user.is_superuser or request.requested_by.id == user.id: tv.service.delete_season_request(db=db, season_request_id=request_id) log.info(f"User {user.id} deleted season request {request_id}.") else: - log.warning(f"User {user.id} tried to delete season request {request_id} but is not authorized.") - return JSONResponse(status_code=status.HTTP_403_FORBIDDEN, - content={"message": "Not authorized to delete this request."}) + log.warning( + f"User {user.id} tried to delete season request {request_id} but is not authorized." + ) + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"message": "Not authorized to delete this request."}, + ) - -@router.patch("/seasons/requests/{season_request_id}", status_code=status.HTTP_204_NO_CONTENT) -def authorize_request(db: DbSessionDependency, user: Annotated[User, Depends(current_superuser)], - season_request_id: SeasonRequestId, authorized_status: bool = False): +@router.patch( + "/seasons/requests/{season_request_id}", status_code=status.HTTP_204_NO_CONTENT +) +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: SeasonRequest = tv.repository.get_season_request( + db=db, season_request_id=season_request_id + ) season_request.authorized_by = UserRead.model_validate(user) season_request.authorized = authorized_status if not authorized_status: @@ -136,48 +219,92 @@ def authorize_request(db: DbSessionDependency, user: Annotated[User, Depends(cur @router.put("/seasons/requests", status_code=status.HTTP_204_NO_CONTENT) -def update_request(db: DbSessionDependency, user: Annotated[User, Depends(current_active_user)], - season_request: UpdateSeasonRequest): +def update_request( + db: DbSessionDependency, + user: Annotated[User, Depends(current_active_user)], + season_request: UpdateSeasonRequest, +): season_request: SeasonRequest = SeasonRequest.model_validate(season_request) - request = tv.service.get_season_request_by_id(db=db, season_request_id=season_request.id) + request = tv.service.get_season_request_by_id( + db=db, season_request_id=season_request.id + ) if request.requested_by.id == user.id or user.is_superuser: season_request.requested_by = UserRead.model_validate(user) tv.service.update_season_request(db=db, season_request=season_request) return + # -------------------------------- # MANAGE TORRENTS # -------------------------------- + # 1 is the default for season_number because it returns multi season torrents -@router.get("/torrents", status_code=status.HTTP_200_OK, dependencies=[Depends(current_superuser)], - response_model=list[PublicIndexerQueryResult]) -def get_torrents_for_a_season(db: DbSessionDependency, show_id: ShowId, season_number: int = 1, - search_query_override: str = None): - return tv.service.get_all_available_torrents_for_a_season(db=db, season_number=season_number, show_id=show_id, - search_query_override=search_query_override) +@router.get( + "/torrents", + status_code=status.HTTP_200_OK, + dependencies=[Depends(current_superuser)], + response_model=list[PublicIndexerQueryResult], +) +def get_torrents_for_a_season( + db: DbSessionDependency, + show_id: ShowId, + season_number: int = 1, + search_query_override: str = None, +): + return tv.service.get_all_available_torrents_for_a_season( + db=db, + season_number=season_number, + show_id=show_id, + search_query_override=search_query_override, + ) # download a torrent -@router.post("/torrents", status_code=status.HTTP_200_OK, response_model=Torrent, - dependencies=[Depends(current_superuser)]) -def download_a_torrent(db: DbSessionDependency, public_indexer_result_id: IndexerQueryResultId, show_id: ShowId, - override_file_path_suffix: str = ""): - 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) +@router.post( + "/torrents", + status_code=status.HTTP_200_OK, + response_model=Torrent, + dependencies=[Depends(current_superuser)], +) +def download_a_torrent( + db: DbSessionDependency, + public_indexer_result_id: IndexerQueryResultId, + show_id: ShowId, + override_file_path_suffix: str = "", +): + 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 # -------------------------------- -@router.get("/search", dependencies=[Depends(current_active_user)], - response_model=list[MetaDataProviderShowSearchResult]) -def search_metadata_providers_for_a_show(db: DbSessionDependency, query: str, metadata_provider: str = "tmdb"): - return tv.service.search_for_show(query=query, metadata_provider=metadata_provider, db=db) + +@router.get( + "/search", + dependencies=[Depends(current_active_user)], + response_model=list[MetaDataProviderShowSearchResult], +) +def search_metadata_providers_for_a_show( + db: DbSessionDependency, query: str, metadata_provider: str = "tmdb" +): + return tv.service.search_for_show( + query=query, metadata_provider=metadata_provider, db=db + ) -@router.get("/recommended", dependencies=[Depends(current_active_user)], - response_model=list[MetaDataProviderShowSearchResult]) -def search_metadata_providers_for_a_show(db: DbSessionDependency, metadata_provider: str = "tmdb"): +@router.get( + "/recommended", + dependencies=[Depends(current_active_user)], + response_model=list[MetaDataProviderShowSearchResult], +) +def search_metadata_providers_for_a_show( + db: DbSessionDependency, metadata_provider: str = "tmdb" +): return tv.service.get_popular_shows(metadata_provider=metadata_provider, db=db) diff --git a/backend/src/tv/schemas.py b/backend/src/tv/schemas.py index 1f6ced8..6436187 100644 --- a/backend/src/tv/schemas.py +++ b/backend/src/tv/schemas.py @@ -17,6 +17,7 @@ SeasonNumber = typing.NewType("SeasonNumber", int) EpisodeNumber = typing.NewType("EpisodeNumber", int) SeasonRequestId = typing.NewType("SeasonRequestId", UUID) + class Episode(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/tv/service.py b/backend/src/tv/service.py index e10f740..52ba60a 100644 --- a/backend/src/tv/service.py +++ b/backend/src/tv/service.py @@ -14,15 +14,35 @@ from torrent.service import TorrentService 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, SeasonRequestId, RichSeasonRequest +from tv.schemas import ( + Show, + ShowId, + SeasonRequest, + SeasonFile, + SeasonId, + Season, + RichShowTorrent, + RichSeasonTorrent, + PublicSeason, + PublicShow, + PublicSeasonFile, + SeasonNumber, + SeasonRequestId, + RichSeasonRequest, +) 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) + 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 @@ -31,7 +51,9 @@ def add_season_request(db: Session, season_request: SeasonRequest) -> None: tv.repository.add_season_request(db=db, season_request=season_request) -def get_season_request_by_id(db: Session, season_request_id: SeasonRequestId) -> SeasonRequest | None: +def get_season_request_by_id( + db: Session, season_request_id: SeasonRequestId +) -> SeasonRequest | None: return tv.repository.get_season_request(db=db, season_request_id=season_request_id) @@ -44,7 +66,9 @@ def delete_season_request(db: Session, season_request_id: SeasonRequestId) -> No tv.repository.delete_season_request(db=db, season_request_id=season_request_id) -def get_public_season_files_by_season_id(db: Session, season_id: SeasonId) -> list[PublicSeasonFile]: +def get_public_season_files_by_season_id( + db: Session, season_id: SeasonId +) -> list[PublicSeasonFile]: season_files = get_season_files_by_season_id(db=db, season_id=season_id) public_season_files = [PublicSeasonFile.model_validate(x) for x in season_files] result = [] @@ -55,18 +79,25 @@ def get_public_season_files_by_season_id(db: Session, season_id: SeasonId) -> li return result -def get_public_season_files_by_season_number(db: Session, season_number: SeasonNumber, show_id: ShowId) -> list[ - PublicSeasonFile]: - season = tv.repository.get_season_by_number(db=db, season_number=season_number, show_id=show_id) +def get_public_season_files_by_season_number( + db: Session, season_number: SeasonNumber, show_id: ShowId +) -> list[PublicSeasonFile]: + season = tv.repository.get_season_by_number( + db=db, season_number=season_number, show_id=show_id + ) return get_public_season_files_by_season_id(db=db, season_id=season.id) -def check_if_show_exists(db: Session, - external_id: int = None, - metadata_provider: str = None, - show_id: ShowId = None) -> bool: +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): + if tv.repository.get_show_by_external_id( + external_id=external_id, metadata_provider=metadata_provider, db=db + ): return True else: return False @@ -76,22 +107,30 @@ def check_if_show_exists(db: Session, else: return False else: - raise ValueError("External ID and metadata provider or Show ID must be provided") + 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, - search_query_override: str = None) -> list[ - IndexerQueryResult]: - log.debug(f"getting all available torrents for season {season_number} for show {show_id}") +def get_all_available_torrents_for_a_season( + db: Session, season_number: int, show_id: ShowId, search_query_override: str = None +) -> list[IndexerQueryResult]: + log.debug( + f"getting all available torrents for season {season_number} for show {show_id}" + ) show = tv.repository.get_show(show_id=show_id, db=db) if search_query_override: search_query = search_query_override else: # TODO: add more Search query strings and combine all the results, like "season 3", "s03", "s3" search_query = show.name + " s" + str(season_number).zfill(2) - torrents: list[IndexerQueryResult] = indexer.service.search(query=search_query, db=db) + torrents: list[IndexerQueryResult] = indexer.service.search( + query=search_query, db=db + ) if search_query_override: - log.debug(f"Found with search query override {torrents.__len__()} torrents: {torrents}") + log.debug( + f"Found with search query override {torrents.__len__()} torrents: {torrents}" + ) return torrents result: list[IndexerQueryResult] = [] for torrent in torrents: @@ -105,19 +144,27 @@ def get_all_shows(db: Session) -> list[Show]: return tv.repository.get_shows(db=db) -def search_for_show(query: str, metadata_provider: str, db: Session) -> list[MetaDataProviderShowSearchResult]: +def search_for_show( + query: str, metadata_provider: str, db: Session +) -> list[MetaDataProviderShowSearchResult]: results = metadataProvider.search_show(query, metadata_provider) for result in results: - if check_if_show_exists(db=db, external_id=result.external_id, metadata_provider=metadata_provider): + if check_if_show_exists( + db=db, external_id=result.external_id, metadata_provider=metadata_provider + ): result.added = True return results def get_popular_shows(metadata_provider: str, db: Session): - results: list[MetaDataProviderShowSearchResult] = metadataProvider.search_show(provider=metadata_provider) + results: list[MetaDataProviderShowSearchResult] = metadataProvider.search_show( + provider=metadata_provider + ) for result in results: - if check_if_show_exists(db=db, external_id=result.external_id, metadata_provider=metadata_provider): + if check_if_show_exists( + db=db, external_id=result.external_id, metadata_provider=metadata_provider + ): results.pop(results.index(result)) return results @@ -149,15 +196,21 @@ def season_file_exists_on_file(db: Session, season_file: SeasonFile) -> bool: if season_file.torrent_id is None: return True else: - torrent_file = torrent.repository.get_torrent_by_id(db=db, torrent_id=season_file.torrent_id) + torrent_file = torrent.repository.get_torrent_by_id( + db=db, torrent_id=season_file.torrent_id + ) if torrent_file.imported: return True return False -def get_show_by_external_id(db: Session, external_id: int, metadata_provider: str) -> Show | None: - return tv.repository.get_show_by_external_id(external_id=external_id, metadata_provider=metadata_provider, db=db) +def get_show_by_external_id( + db: Session, external_id: int, metadata_provider: str +) -> Show | None: + return tv.repository.get_show_by_external_id( + external_id=external_id, metadata_provider=metadata_provider, db=db + ) def get_season(db: Session, season_id: SeasonId) -> Season: @@ -172,17 +225,28 @@ def get_torrents_for_show(db: Session, show: Show) -> RichShowTorrent: show_torrents = tv.repository.get_torrents_by_show_id(db=db, show_id=show.id) rich_season_torrents = [] for show_torrent in show_torrents: - seasons = tv.repository.get_seasons_by_torrent_id(db=db, torrent_id=show_torrent.id) + seasons = tv.repository.get_seasons_by_torrent_id( + db=db, torrent_id=show_torrent.id + ) season_files = get_seasons_files_of_torrent(db=db, torrent_id=show_torrent.id) file_path_suffix = season_files[0].file_path_suffix - season_torrent = RichSeasonTorrent(torrent_id=show_torrent.id, torrent_title=show_torrent.title, - status=show_torrent.status, quality=show_torrent.quality, - imported=show_torrent.imported, seasons=seasons, - file_path_suffix=file_path_suffix - ) + season_torrent = RichSeasonTorrent( + torrent_id=show_torrent.id, + torrent_title=show_torrent.title, + status=show_torrent.status, + quality=show_torrent.quality, + imported=show_torrent.imported, + seasons=seasons, + file_path_suffix=file_path_suffix, + ) rich_season_torrents.append(season_torrent) - return RichShowTorrent(show_id=show.id, name=show.name, year=show.year, - metadata_provider=show.metadata_provider, torrents=rich_season_torrents) + return RichShowTorrent( + show_id=show.id, + name=show.name, + year=show.year, + metadata_provider=show.metadata_provider, + torrents=rich_season_torrents, + ) def get_all_shows_with_torrents(db: Session) -> list[RichShowTorrent]: @@ -190,14 +254,26 @@ def get_all_shows_with_torrents(db: Session) -> list[RichShowTorrent]: return [get_torrents_for_show(show=show, db=db) for show in shows] -def download_torrent(db: Session, public_indexer_result_id: IndexerQueryResultId, show_id: ShowId, - override_show_file_path_suffix: str = "") -> Torrent: - indexer_result = indexer.service.get_indexer_query_result(db=db, result_id=public_indexer_result_id) +def download_torrent( + db: Session, + public_indexer_result_id: IndexerQueryResultId, + show_id: ShowId, + override_show_file_path_suffix: str = "", +) -> Torrent: + indexer_result = indexer.service.get_indexer_query_result( + db=db, result_id=public_indexer_result_id + ) show_torrent = TorrentService(db=db).download(indexer_result=indexer_result) for season_number in indexer_result.season: - season = tv.repository.get_season_by_number(db=db, season_number=season_number, show_id=show_id) - season_file = SeasonFile(season_id=season.id, quality=indexer_result.quality, torrent_id=show_torrent.id, - file_path_suffix=override_show_file_path_suffix) + season = tv.repository.get_season_by_number( + db=db, season_number=season_number, show_id=show_id + ) + season_file = SeasonFile( + season_id=season.id, + quality=indexer_result.quality, + torrent_id=show_torrent.id, + file_path_suffix=override_show_file_path_suffix, + ) add_season_file(db=db, season_file=season_file) return show_torrent