diff --git a/alembic/env.py b/alembic/env.py index b09aad9..20de4f5 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -106,7 +106,13 @@ def run_migrations_online() -> None: """ - def include_object(_object, name, type_, _reflected, _compare_to): + def include_object( + _object: object | None, + name: str | None, + type_: str | None, + _reflected: bool | None, + _compare_to: object | None, + ) -> bool: if type_ == "table" and name == "apscheduler_jobs": return False return True diff --git a/media_manager/auth/config.py b/media_manager/auth/config.py index a664c2b..6ad4c87 100644 --- a/media_manager/auth/config.py +++ b/media_manager/auth/config.py @@ -20,7 +20,3 @@ class AuthConfig(BaseSettings): admin_emails: list[str] = [] email_password_resets: bool = False openid_connect: OpenIdConfig = OpenIdConfig() - - @property - def jwt_signing_key(self): - return self._jwt_signing_key diff --git a/media_manager/auth/db.py b/media_manager/auth/db.py index 145e8e4..12e961e 100644 --- a/media_manager/auth/db.py +++ b/media_manager/auth/db.py @@ -39,5 +39,7 @@ async def get_async_session() -> AsyncGenerator[AsyncSession, None]: yield session -async def get_user_db(session: AsyncSession = Depends(get_async_session)): +async def get_user_db( + session: AsyncSession = Depends(get_async_session), +) -> AsyncGenerator[SQLAlchemyUserDatabase, None]: yield SQLAlchemyUserDatabase(session, User, OAuthAccount) diff --git a/media_manager/auth/router.py b/media_manager/auth/router.py index 2e1e410..80db648 100644 --- a/media_manager/auth/router.py +++ b/media_manager/auth/router.py @@ -19,7 +19,7 @@ users_router = APIRouter() auth_metadata_router = APIRouter() -def get_openid_router(): +def get_openid_router() -> APIRouter: if openid_client: return get_oauth_router( oauth_client=openid_client, diff --git a/media_manager/auth/users.py b/media_manager/auth/users.py index e5549fb..9081ecc 100644 --- a/media_manager/auth/users.py +++ b/media_manager/auth/users.py @@ -1,7 +1,7 @@ import contextlib import logging import uuid -from typing import Any, Optional, override +from typing import Any, AsyncGenerator, Optional, override from fastapi import Depends, Request from fastapi.responses import RedirectResponse, Response @@ -59,7 +59,9 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): await self.update(user=user, user_update=updated_user) @override - async def on_after_register(self, user: User, request: Optional[Request] = None): + async def on_after_register( + self, user: User, request: Optional[Request] = None + ) -> None: log.info(f"User {user.id} has registered.") if user.email in config.admin_emails: updated_user = UserUpdate(is_superuser=True, is_verified=True) @@ -68,7 +70,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): @override async def on_after_forgot_password( self, user: User, token: str, request: Optional[Request] = None - ): + ) -> None: link = f"{MediaManagerConfig().misc.frontend_url}web/login/reset-password?token={token}" log.info(f"User {user.id} has forgot their password. Reset Link: {link}") @@ -83,7 +85,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):

Hi {user.email},

- if you forgot your password, reset you password here.
+ if you forgot your password, reset you password here.
If you did not request a password reset, you can ignore this email.



@@ -99,23 +101,27 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): @override async def on_after_reset_password( self, user: User, request: Optional[Request] = None - ): + ) -> None: log.info(f"User {user.id} has reset their password.") @override async def on_after_request_verify( self, user: User, token: str, request: Optional[Request] = None - ): + ) -> None: log.info( f"Verification requested for user {user.id}. Verification token: {token}" ) @override - async def on_after_verify(self, user: User, request: Optional[Request] = None): + async def on_after_verify( + self, user: User, request: Optional[Request] = None + ) -> None: log.info(f"User {user.id} has been verified") -async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)): +async def get_user_manager( + user_db: SQLAlchemyUserDatabase = Depends(get_user_db), +) -> AsyncGenerator[UserManager, None]: yield UserManager(user_db) @@ -124,7 +130,7 @@ get_user_db_context = contextlib.asynccontextmanager(get_user_db) get_user_manager_context = contextlib.asynccontextmanager(get_user_manager) -async def create_default_admin_user(): +async def create_default_admin_user() -> None: """Create a default admin user if no users exist in the database""" try: async with get_async_session_context() as session: @@ -177,10 +183,6 @@ async def create_default_admin_user(): ) -async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)): - yield UserManager(user_db) - - def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]: return JWTStrategy(secret=SECRET, lifetime_seconds=LIFETIME) diff --git a/media_manager/config.py b/media_manager/config.py index 5c86acf..782cb2a 100644 --- a/media_manager/config.py +++ b/media_manager/config.py @@ -41,7 +41,7 @@ class BasicConfig(BaseSettings): movie_directory: Path = Path(__file__).parent.parent / "data" / "movies" torrent_directory: Path = Path(__file__).parent.parent / "data" / "torrents" - frontend_url: AnyHttpUrl = "http://localhost:8000" + frontend_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000") cors_urls: list[str] = [] development: bool = False diff --git a/media_manager/database/__init__.py b/media_manager/database/__init__.py index e69ea7e..e34a73e 100644 --- a/media_manager/database/__init__.py +++ b/media_manager/database/__init__.py @@ -9,6 +9,8 @@ from sqlalchemy.engine import Engine from sqlalchemy.engine.url import URL from sqlalchemy.orm import Session, declarative_base, sessionmaker +from media_manager.database.config import DbConfig + log = logging.getLogger(__name__) Base = declarative_base() @@ -35,7 +37,7 @@ def build_db_url( def init_engine( - db_config: Any | None = None, + db_config: DbConfig | None = None, url: str | URL | None = None, ) -> Engine: """ diff --git a/media_manager/exceptions.py b/media_manager/exceptions.py index c1ee8b7..d6000c3 100644 --- a/media_manager/exceptions.py +++ b/media_manager/exceptions.py @@ -7,7 +7,7 @@ from sqlalchemy.exc import IntegrityError class MediaManagerError(Exception): """Base exception for MediaManager errors.""" - def __init__(self, message: str = "An error occurred."): + def __init__(self, message: str = "An error occurred.") -> None: super().__init__(message) self.message = message @@ -17,76 +17,76 @@ class MediaAlreadyExistsError(MediaManagerError): def __init__( self, message: str = "Entity with this ID or other identifier already exists" - ): + ) -> None: super().__init__(message) class NotFoundError(MediaManagerError): """Raised when an entity is not found (HTTP 404).""" - def __init__(self, message: str = "The requested entity was not found."): + def __init__(self, message: str = "The requested entity was not found.") -> None: super().__init__(message) class InvalidConfigError(MediaManagerError): """Raised when the server is improperly configured (HTTP 500).""" - def __init__(self, message: str = "The server is improperly configured."): + def __init__(self, message: str = "The server is improperly configured.") -> None: super().__init__(message) class BadRequestError(MediaManagerError): """Raised for invalid client requests (HTTP 400).""" - def __init__(self, message: str = "Bad request."): + def __init__(self, message: str = "Bad request.") -> None: super().__init__(message) class UnauthorizedError(MediaManagerError): """Raised for authentication failures (HTTP 401).""" - def __init__(self, message: str = "Unauthorized."): + def __init__(self, message: str = "Unauthorized.") -> None: super().__init__(message) class ForbiddenError(MediaManagerError): """Raised for forbidden actions (HTTP 403).""" - def __init__(self, message: str = "Forbidden."): + def __init__(self, message: str = "Forbidden.") -> None: super().__init__(message) class ConflictError(MediaManagerError): """Raised for resource conflicts (HTTP 409).""" - def __init__(self, message: str = "Conflict."): + def __init__(self, message: str = "Conflict.") -> None: super().__init__(message) class UnprocessableEntityError(MediaManagerError): """Raised for validation errors (HTTP 422).""" - def __init__(self, message: str = "Unprocessable entity."): + def __init__(self, message: str = "Unprocessable entity.") -> None: super().__init__(message) # Exception handlers async def media_already_exists_exception_handler( - _request: Request, exc: MediaAlreadyExistsError + _request: Request, _exc: Exception ) -> JSONResponse: - return JSONResponse(status_code=409, content={"detail": exc.message}) + return JSONResponse(status_code=409, content={"detail": str(_exc)}) async def not_found_error_exception_handler( - _request: Request, exc: NotFoundError + _request: Request, _exc: Exception ) -> JSONResponse: - return JSONResponse(status_code=404, content={"detail": exc.message}) + return JSONResponse(status_code=404, content={"detail": str(_exc)}) async def invalid_config_error_exception_handler( - _request: Request, exc: InvalidConfigError + _request: Request, _exc: Exception ) -> JSONResponse: - return JSONResponse(status_code=500, content={"detail": exc.message}) + return JSONResponse(status_code=500, content={"detail": str(_exc)}) async def bad_request_error_handler( @@ -107,8 +107,8 @@ async def forbidden_error_handler( return JSONResponse(status_code=403, content={"detail": exc.message}) -async def conflict_error_handler(_request: Request, exc: ConflictError) -> JSONResponse: - return JSONResponse(status_code=409, content={"detail": exc.message}) +async def conflict_error_handler(_request: Request, _exc: Exception) -> JSONResponse: + return JSONResponse(status_code=409, content={"detail": str(_exc)}) async def unprocessable_entity_error_handler( @@ -128,7 +128,7 @@ async def sqlalchemy_integrity_error_handler( ) -def register_exception_handlers(app: FastAPI): +def register_exception_handlers(app: FastAPI) -> None: app.add_exception_handler(NotFoundError, not_found_error_exception_handler) app.add_exception_handler( MediaAlreadyExistsError, media_already_exists_exception_handler diff --git a/media_manager/filesystem_checks.py b/media_manager/filesystem_checks.py index d9573d1..5ae2797 100644 --- a/media_manager/filesystem_checks.py +++ b/media_manager/filesystem_checks.py @@ -1,8 +1,11 @@ import shutil +from logging import Logger from pathlib import Path +from media_manager.config import MediaManagerConfig -def run_filesystem_checks(config, log): + +def run_filesystem_checks(config: MediaManagerConfig, log: Logger) -> None: log.info("Creating directories if they don't exist...") config.misc.tv_directory.mkdir(parents=True, exist_ok=True) config.misc.movie_directory.mkdir(parents=True, exist_ok=True) diff --git a/media_manager/indexer/dependencies.py b/media_manager/indexer/dependencies.py index b1ae1c1..a531984 100644 --- a/media_manager/indexer/dependencies.py +++ b/media_manager/indexer/dependencies.py @@ -5,7 +5,6 @@ from fastapi import Depends from media_manager.database import DbSessionDependency from media_manager.indexer.repository import IndexerRepository from media_manager.indexer.service import IndexerService -from media_manager.tv.service import TvService def get_indexer_repository(db_session: DbSessionDependency) -> IndexerRepository: @@ -21,4 +20,4 @@ def get_indexer_service( return IndexerService(indexer_repository) -indexer_service_dep = Annotated[TvService, Depends(get_indexer_service)] +indexer_service_dep = Annotated[IndexerService, Depends(get_indexer_service)] diff --git a/media_manager/indexer/indexers/generic.py b/media_manager/indexer/indexers/generic.py index d70e700..f4abe1b 100644 --- a/media_manager/indexer/indexers/generic.py +++ b/media_manager/indexer/indexers/generic.py @@ -8,7 +8,7 @@ from media_manager.tv.schemas import Show class GenericIndexer(ABC): name: str - def __init__(self, name: str): + def __init__(self, name: str) -> None: self.name = name @abstractmethod diff --git a/media_manager/indexer/indexers/jackett.py b/media_manager/indexer/indexers/jackett.py index 2b10f67..ebb8eb8 100644 --- a/media_manager/indexer/indexers/jackett.py +++ b/media_manager/indexer/indexers/jackett.py @@ -1,4 +1,5 @@ import concurrent +import concurrent.futures import logging from concurrent.futures.thread import ThreadPoolExecutor @@ -15,7 +16,7 @@ log = logging.getLogger(__name__) class Jackett(GenericIndexer, TorznabMixin): - def __init__(self): + def __init__(self) -> None: """ A subclass of GenericIndexer for interacting with the Jacket API. @@ -73,7 +74,9 @@ class Jackett(GenericIndexer, TorznabMixin): def search_season( self, query: str, show: Show, season_number: int ) -> list[IndexerQueryResult]: - pass + log.debug(f"Searching for season {season_number} of show {show.title}") + return self.search(query=query, is_tv=True) def search_movie(self, query: str, movie: Movie) -> list[IndexerQueryResult]: - pass + log.debug(f"Searching for movie {movie.title}") + return self.search(query=query, is_tv=False) diff --git a/media_manager/indexer/indexers/prowlarr.py b/media_manager/indexer/indexers/prowlarr.py index 38b48ff..1ef4603 100644 --- a/media_manager/indexer/indexers/prowlarr.py +++ b/media_manager/indexer/indexers/prowlarr.py @@ -1,7 +1,7 @@ import logging from dataclasses import dataclass -from requests import Session +from requests import Response, Session from media_manager.config import MediaManagerConfig from media_manager.indexer.indexers.generic import GenericIndexer @@ -31,14 +31,14 @@ class IndexerInfo: class Prowlarr(GenericIndexer, TorznabMixin): - def __init__(self): + def __init__(self) -> None: """ A subclass of GenericIndexer for interacting with the Prowlarr API. """ super().__init__(name="prowlarr") self.config = MediaManagerConfig().indexers.prowlarr - def _call_prowlarr_api(self, path: str, parameters: dict | None = None): + def _call_prowlarr_api(self, path: str, parameters: dict | None = None) -> Response: url = f"{self.config.url}/api/v1{path}" headers = {"X-Api-Key": self.config.api_key} with Session() as session: diff --git a/media_manager/indexer/indexers/torznab_mixin.py b/media_manager/indexer/indexers/torznab_mixin.py index a43eebc..a83a7ec 100644 --- a/media_manager/indexer/indexers/torznab_mixin.py +++ b/media_manager/indexer/indexers/torznab_mixin.py @@ -61,12 +61,19 @@ class TorznabMixin: if upload_volume_factor == 2: flags.append("doubleupload") + if not item.find("size") or item.find("size").text is None: + log.warning( + f"Torznab item {item.find('title').text} has no size, skipping." + ) + continue + size = int(item.find("size").text or "0") + result = IndexerQueryResult( - title=item.find("title").text, + title=item.find("title").text or "unknown", download_url=str(item.find("enclosure").attrib["url"]), seeders=seeders, flags=flags, - size=int(item.find("size").text), + size=size, usenet=is_usenet, age=age, indexer=indexer_name, diff --git a/media_manager/indexer/repository.py b/media_manager/indexer/repository.py index 343a6d5..c1b1a56 100644 --- a/media_manager/indexer/repository.py +++ b/media_manager/indexer/repository.py @@ -14,7 +14,7 @@ log = logging.getLogger(__name__) class IndexerRepository: - def __init__(self, db: Session): + def __init__(self, db: Session) -> None: self.db = db def get_result(self, result_id: IndexerQueryResultId) -> IndexerQueryResultSchema: diff --git a/media_manager/indexer/schemas.py b/media_manager/indexer/schemas.py index 0d28c55..29a87c9 100644 --- a/media_manager/indexer/schemas.py +++ b/media_manager/indexer/schemas.py @@ -13,7 +13,7 @@ IndexerQueryResultId = typing.NewType("IndexerQueryResultId", UUID) class IndexerQueryResult(BaseModel): model_config = ConfigDict(from_attributes=True) - id: IndexerQueryResultId = pydantic.Field(default_factory=uuid4) + id: IndexerQueryResultId = pydantic.Field(default_factory=lambda: IndexerQueryResultId(uuid4())) title: str download_url: str = pydantic.Field( exclude=True, @@ -62,7 +62,7 @@ class IndexerQueryResult(BaseModel): result = [] return result - def __gt__(self, other) -> bool: + def __gt__(self, other: "IndexerQueryResult") -> bool: if self.quality.value != other.quality.value: return self.quality.value < other.quality.value if self.score != other.score: @@ -76,7 +76,7 @@ class IndexerQueryResult(BaseModel): return self.size < other.size - def __lt__(self, other) -> bool: + def __lt__(self, other: "IndexerQueryResult") -> bool: if self.quality.value != other.quality.value: return self.quality.value > other.quality.value if self.score != other.score: diff --git a/media_manager/indexer/service.py b/media_manager/indexer/service.py index 5100851..e9e724d 100644 --- a/media_manager/indexer/service.py +++ b/media_manager/indexer/service.py @@ -14,7 +14,7 @@ log = logging.getLogger(__name__) class IndexerService: - def __init__(self, indexer_repository: IndexerRepository): + def __init__(self, indexer_repository: IndexerRepository) -> None: config = MediaManagerConfig() self.repository = indexer_repository self.indexers: list[GenericIndexer] = [] @@ -55,7 +55,7 @@ class IndexerService: return results - def search_movie(self, movie: Movie): + def search_movie(self, movie: Movie) -> list[IndexerQueryResult]: query = f"{movie.name} {movie.year}" query = remove_special_chars_and_parentheses(query) @@ -75,7 +75,7 @@ class IndexerService: return results - def search_season(self, show: Show, season_number: int): + def search_season(self, show: Show, season_number: int) -> list[IndexerQueryResult]: query = f"{show.name} {show.year} S{season_number:02d}" query = remove_special_chars_and_parentheses(query) diff --git a/media_manager/indexer/utils.py b/media_manager/indexer/utils.py index a4337aa..dcca6b2 100644 --- a/media_manager/indexer/utils.py +++ b/media_manager/indexer/utils.py @@ -14,7 +14,7 @@ log = logging.getLogger(__name__) def evaluate_indexer_query_result( query_result: IndexerQueryResult, ruleset: ScoringRuleSet -) -> (IndexerQueryResult, bool): +) -> tuple[IndexerQueryResult, bool]: title_rules = MediaManagerConfig().indexers.title_scoring_rules indexer_flag_rules = MediaManagerConfig().indexers.indexer_flag_scoring_rules for rule_name in ruleset.rule_names: diff --git a/media_manager/logging.py b/media_manager/logging.py index 2d63833..9fae9a5 100644 --- a/media_manager/logging.py +++ b/media_manager/logging.py @@ -11,7 +11,7 @@ from pythonjsonlogger.json import JsonFormatter class ISOJsonFormatter(JsonFormatter): @override - def formatTime(self, record, datefmt=None): + def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str: dt = datetime.fromtimestamp(record.created, tz=timezone.utc) return dt.isoformat(timespec="milliseconds").replace("+00:00", "Z") @@ -62,7 +62,7 @@ LOGGING_CONFIG = { } -def setup_logging(): +def setup_logging() -> None: dictConfig(LOGGING_CONFIG) logging.basicConfig( level=LOG_LEVEL, diff --git a/media_manager/main.py b/media_manager/main.py index 8586e42..772f8c2 100644 --- a/media_manager/main.py +++ b/media_manager/main.py @@ -2,12 +2,12 @@ import logging import os import uvicorn -from fastapi import APIRouter, FastAPI +from fastapi import APIRouter, FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from psycopg.errors import UniqueViolation from sqlalchemy.exc import IntegrityError -from starlette.responses import FileResponse, RedirectResponse, Response +from starlette.responses import FileResponse, RedirectResponse from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware import media_manager.movies.router as movies_router @@ -143,23 +143,23 @@ else: @app.get("/") -async def root(): +async def root() -> RedirectResponse: return RedirectResponse(url="/web/") @app.get("/dashboard") -async def dashboard(): +async def dashboard() -> RedirectResponse: return RedirectResponse(url="/web/") @app.get("/login") -async def login(): +async def login() -> RedirectResponse: return RedirectResponse(url="/web/") # this will serve the custom 404 page for frontend routes, so SvelteKit can handle routing @app.exception_handler(404) -async def not_found_handler(request, _exc): +async def not_found_handler(request: Request, _exc: Exception) -> Response: if not DISABLE_FRONTEND_MOUNT and any( base_path in ["/web", "/dashboard", "/login"] for base_path in request.url.path ): diff --git a/media_manager/metadataProvider/abstract_metadata_provider.py b/media_manager/metadataProvider/abstract_metadata_provider.py index 301f06f..28935e5 100644 --- a/media_manager/metadataProvider/abstract_metadata_provider.py +++ b/media_manager/metadataProvider/abstract_metadata_provider.py @@ -19,13 +19,13 @@ class AbstractMetadataProvider(ABC): @abstractmethod def get_show_metadata( - self, show_id: int | None = None, language: str | None = None + self, show_id: int, language: str | None = None ) -> Show: raise NotImplementedError() @abstractmethod def get_movie_metadata( - self, movie_id: int | None = None, language: str | None = None + self, movie_id: int, language: str | None = None ) -> Movie: raise NotImplementedError() diff --git a/media_manager/metadataProvider/tmdb.py b/media_manager/metadataProvider/tmdb.py index 0fe3477..1acb30f 100644 --- a/media_manager/metadataProvider/tmdb.py +++ b/media_manager/metadataProvider/tmdb.py @@ -21,7 +21,7 @@ log = logging.getLogger(__name__) class TmdbMetadataProvider(AbstractMetadataProvider): name = "tmdb" - def __init__(self): + def __init__(self) -> None: config = MediaManagerConfig().metadata.tmdb self.url = config.tmdb_relay_url self.primary_languages = config.primary_languages @@ -244,12 +244,12 @@ class TmdbMetadataProvider(AbstractMetadataProvider): @override def get_show_metadata( - self, show_id: int | None = None, language: str | None = None + self, show_id: int, language: str | None = None ) -> Show: """ - :param id: the external id of the show - :type id: int + :param show_id: the external id of the show + :type show_id: int :param language: optional language code (ISO 639-1) to fetch metadata in :type language: str | None :return: returns a Show object @@ -374,13 +374,13 @@ class TmdbMetadataProvider(AbstractMetadataProvider): @override def get_movie_metadata( - self, movie_id: int | None = None, language: str | None = None + self, movie_id: int, language: str | None = None ) -> Movie: """ Get movie metadata with language-aware fetching. - :param id: the external id of the movie - :type id: int + :param movie_id: the external id of the movie + :type movie_id: int :param language: optional language code (ISO 639-1) to fetch metadata in :type language: str | None :return: returns a Movie object diff --git a/media_manager/metadataProvider/tvdb.py b/media_manager/metadataProvider/tvdb.py index a3d100b..616ae74 100644 --- a/media_manager/metadataProvider/tvdb.py +++ b/media_manager/metadataProvider/tvdb.py @@ -18,7 +18,7 @@ log = logging.getLogger(__name__) class TvdbMetadataProvider(AbstractMetadataProvider): name = "tvdb" - def __init__(self): + def __init__(self) -> None: config = MediaManagerConfig().metadata.tvdb self.url = config.tvdb_relay_url @@ -64,11 +64,11 @@ class TvdbMetadataProvider(AbstractMetadataProvider): @override def get_show_metadata( - self, show_id: int | None = None, language: str | None = None + self, show_id: int, language: str | None = None ) -> Show: """ - :param id: the external id of the show + :param show_id: The external id of the show :param language: does nothing, TVDB does not support multiple languages """ series = self.__get_show(show_id) @@ -230,9 +230,14 @@ class TvdbMetadataProvider(AbstractMetadataProvider): except KeyError: year = None + if result.get("image"): + poster_path = "https://artworks.thetvdb.com" + str(result.get("image")) + else: + poster_path = None + formatted_results.append( MetaDataProviderSearchResult( - poster_path="https://artworks.thetvdb.com" + result.get("image") + poster_path= poster_path if result.get("image") else None, overview=result.get("overview"), @@ -265,7 +270,7 @@ class TvdbMetadataProvider(AbstractMetadataProvider): @override def get_movie_metadata( - self, movie_id: int | None = None, language: str | None = None + self, movie_id: int, language: str | None = None ) -> Movie: """ @@ -273,7 +278,7 @@ class TvdbMetadataProvider(AbstractMetadataProvider): :param language: does nothing, TVDB does not support multiple languages :return: returns a Movie object """ - movie = self.__get_movie(movie_id) + movie = self.__get_movie(movie_id=movie_id) # get imdb id from remote ids imdb_id = None diff --git a/media_manager/movies/repository.py b/media_manager/movies/repository.py index f78e0cf..06b8058 100644 --- a/media_manager/movies/repository.py +++ b/media_manager/movies/repository.py @@ -40,7 +40,7 @@ class MovieRepository: Provides methods to retrieve, save, and delete movies. """ - def __init__(self, db: Session): + def __init__(self, db: Session) -> None: self.db = db def get_movie_by_id(self, movie_id: MovieId) -> MovieSchema: diff --git a/media_manager/movies/router.py b/media_manager/movies/router.py index 1ffe82e..4c4ad35 100644 --- a/media_manager/movies/router.py +++ b/media_manager/movies/router.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from media_manager.auth.schemas import UserRead from media_manager.auth.users import current_active_user, current_superuser from media_manager.config import LibraryItem, MediaManagerConfig -from media_manager.exceptions import ConflictError +from media_manager.exceptions import ConflictError, NotFoundError from media_manager.indexer.schemas import ( IndexerQueryResult, IndexerQueryResultId, @@ -97,7 +97,7 @@ def get_all_importable_movies( ) def import_detected_movie( movie_service: movie_service_dep, movie: movie_dep, directory: str -): +) -> None: """ Import a detected movie from the specified directory into the library. """ @@ -145,7 +145,7 @@ def add_a_movie( metadata_provider: metadata_provider_dep, movie_id: int, language: str | None = None, -): +) -> Movie: """ Add a new movie to the library. """ @@ -159,6 +159,8 @@ def add_a_movie( movie = movie_service.get_movie_by_external_id( external_id=movie_id, metadata_provider=metadata_provider.name ) + if not movie: + raise NotFoundError from ConflictError return movie @@ -217,7 +219,7 @@ def create_movie_request( log.info( f"User {user.email} is creating a movie request for {movie_request.movie_id}" ) - movie_request = MovieRequest.model_validate(movie_request) + movie_request: MovieRequest = MovieRequest.model_validate(movie_request) movie_request.requested_by = user if user.is_superuser: movie_request.authorized = True @@ -254,7 +256,7 @@ def authorize_request( movie_request_id: MovieRequestId, user: Annotated[UserRead, Depends(current_superuser)], authorized_status: bool = False, -): +) -> MovieRequest: """ Authorize or de-authorize a movie request. """ @@ -276,7 +278,7 @@ def authorize_request( ) def delete_movie_request( movie_service: movie_service_dep, movie_request_id: MovieRequestId -): +) -> None: """ Delete a movie request. """ @@ -309,7 +311,7 @@ def delete_a_movie( movie: movie_dep, delete_files_on_disk: bool = False, delete_torrents: bool = False, -): +) -> None: """ Delete a movie from the library. """ diff --git a/media_manager/movies/schemas.py b/media_manager/movies/schemas.py index d176be7..f63ee57 100644 --- a/media_manager/movies/schemas.py +++ b/media_manager/movies/schemas.py @@ -15,7 +15,7 @@ MovieRequestId = typing.NewType("MovieRequestId", UUID) class Movie(BaseModel): model_config = ConfigDict(from_attributes=True) - id: MovieId = Field(default_factory=uuid.uuid4) + id: MovieId = Field(default_factory=lambda: MovieId(uuid.uuid4())) name: str overview: str year: int | None @@ -59,7 +59,7 @@ class CreateMovieRequest(MovieRequestBase): class MovieRequest(MovieRequestBase): model_config = ConfigDict(from_attributes=True) - id: MovieRequestId = Field(default_factory=uuid.uuid4) + id: MovieRequestId = Field(default_factory=lambda: MovieRequestId(uuid.uuid4())) movie_id: MovieId diff --git a/media_manager/movies/service.py b/media_manager/movies/service.py index a309ed8..3dd12b6 100644 --- a/media_manager/movies/service.py +++ b/media_manager/movies/service.py @@ -32,6 +32,7 @@ from media_manager.movies.schemas import ( RichMovieRequest, RichMovieTorrent, ) +from media_manager.notification.repository import NotificationRepository from media_manager.notification.service import NotificationService from media_manager.schemas import MediaImportSuggestion from media_manager.torrent.repository import TorrentRepository @@ -58,8 +59,8 @@ class MovieService: movie_repository: MovieRepository, torrent_service: TorrentService, indexer_service: IndexerService, - notification_service: NotificationService = None, - ): + notification_service: NotificationService, + ) -> None: self.movie_repository = movie_repository self.torrent_service = torrent_service self.indexer_service = indexer_service @@ -70,7 +71,7 @@ class MovieService: external_id: int, metadata_provider: AbstractMetadataProvider, language: str | None = None, - ) -> Movie | None: + ) -> Movie: """ Add a new movie to the database. @@ -82,7 +83,7 @@ class MovieService: movie_id=external_id, language=language ) if not movie_with_metadata: - return None + raise NotFoundError saved_movie = self.movie_repository.save_movie(movie=movie_with_metadata) metadata_provider.download_movie_poster_image(movie=saved_movie) @@ -99,7 +100,7 @@ class MovieService: def get_movie_request_by_id( self, movie_request_id: MovieRequestId - ) -> MovieRequest | None: + ) -> MovieRequest: """ Get a movie request by its ID. @@ -781,14 +782,16 @@ def auto_download_all_approved_movie_requests() -> None: Auto download all approved movie requests. This is a standalone function as it creates its own DB session. """ - db: Session = SessionLocal() + db: Session = SessionLocal() if SessionLocal else next(get_session()) movie_repository = MovieRepository(db=db) torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db)) indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db)) + notification_service = NotificationService(notification_repository=NotificationRepository(db=db)) movie_service = MovieService( movie_repository=movie_repository, torrent_service=torrent_service, indexer_service=indexer_service, + notification_service=notification_service ) log.info("Auto downloading all approved movie requests") @@ -818,10 +821,12 @@ def import_all_movie_torrents() -> None: movie_repository = MovieRepository(db=db) torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db)) indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db)) + notification_service = NotificationService(notification_repository=NotificationRepository(db=db)) movie_service = MovieService( movie_repository=movie_repository, torrent_service=torrent_service, indexer_service=indexer_service, + notification_service=notification_service, ) log.info("Importing all torrents") torrents = torrent_service.get_all_torrents() @@ -855,6 +860,7 @@ def update_all_movies_metadata() -> None: movie_repository=movie_repository, torrent_service=TorrentService(torrent_repository=TorrentRepository(db=db)), indexer_service=IndexerService(indexer_repository=IndexerRepository(db=db)), + notification_service=NotificationService(notification_repository=NotificationRepository(db=db)) ) log.info("Updating metadata for all movies") diff --git a/media_manager/notification/manager.py b/media_manager/notification/manager.py index a1e95ef..2e7558c 100644 --- a/media_manager/notification/manager.py +++ b/media_manager/notification/manager.py @@ -31,7 +31,7 @@ class NotificationManager: Manages and orchestrates notifications across all configured service providers. """ - def __init__(self): + def __init__(self) -> None: self.config = MediaManagerConfig().notifications self.providers: List[AbstractNotificationServiceProvider] = [] self._initialize_providers() diff --git a/media_manager/notification/repository.py b/media_manager/notification/repository.py index a080176..943f953 100644 --- a/media_manager/notification/repository.py +++ b/media_manager/notification/repository.py @@ -20,7 +20,7 @@ log = logging.getLogger(__name__) class NotificationRepository: - def __init__(self, db: Session): + def __init__(self, db: Session) -> None: self.db = db def get_notification(self, nid: NotificationId) -> NotificationSchema: @@ -60,7 +60,7 @@ class NotificationRepository: log.error(f"Database error while retrieving notifications: {e}") raise - def save_notification(self, notification: NotificationSchema): + def save_notification(self, notification: NotificationSchema) -> None: try: self.db.add( Notification( diff --git a/media_manager/notification/router.py b/media_manager/notification/router.py index 227723e..61a18ed 100644 --- a/media_manager/notification/router.py +++ b/media_manager/notification/router.py @@ -69,7 +69,7 @@ def get_notification( ) def mark_notification_as_read( notification_id: NotificationId, notification_service: notification_service_dep -): +) -> None: """ Mark a notification as read. """ @@ -86,7 +86,7 @@ def mark_notification_as_read( ) def mark_notification_as_unread( notification_id: NotificationId, notification_service: notification_service_dep -): +) -> None: """ Mark a notification as unread. """ @@ -103,7 +103,7 @@ def mark_notification_as_unread( ) def delete_notification( notification_id: NotificationId, notification_service: notification_service_dep -): +) -> None: """ Delete a notification. """ diff --git a/media_manager/notification/schemas.py b/media_manager/notification/schemas.py index 1e3bf8a..ecdcf81 100644 --- a/media_manager/notification/schemas.py +++ b/media_manager/notification/schemas.py @@ -12,7 +12,7 @@ class Notification(BaseModel): model_config = ConfigDict(from_attributes=True) id: NotificationId = Field( - default_factory=uuid.uuid4, description="Unique identifier for the notification" + default_factory=lambda: NotificationId(uuid.uuid4()), description="Unique identifier for the notification" ) read: bool = Field(False, description="Whether the notification has been read") message: str = Field(description="The content of the notification") diff --git a/media_manager/notification/service.py b/media_manager/notification/service.py index a97515e..c7f7a09 100644 --- a/media_manager/notification/service.py +++ b/media_manager/notification/service.py @@ -7,7 +7,7 @@ class NotificationService: def __init__( self, notification_repository: NotificationRepository, - ): + ) -> None: self.notification_repository = notification_repository self.notification_manager = notification_manager diff --git a/media_manager/notification/service_providers/email.py b/media_manager/notification/service_providers/email.py index 9339252..a369ef0 100644 --- a/media_manager/notification/service_providers/email.py +++ b/media_manager/notification/service_providers/email.py @@ -7,7 +7,7 @@ from media_manager.notification.service_providers.abstract_notification_service_ class EmailNotificationServiceProvider(AbstractNotificationServiceProvider): - def __init__(self): + def __init__(self) -> None: self.config = MediaManagerConfig().notifications.email_notifications def send_notification(self, message: MessageNotification) -> bool: diff --git a/media_manager/notification/service_providers/gotify.py b/media_manager/notification/service_providers/gotify.py index 13610c7..ce57e2b 100644 --- a/media_manager/notification/service_providers/gotify.py +++ b/media_manager/notification/service_providers/gotify.py @@ -12,7 +12,7 @@ class GotifyNotificationServiceProvider(AbstractNotificationServiceProvider): Gotify Notification Service Provider """ - def __init__(self): + def __init__(self) -> None: self.config = MediaManagerConfig().notifications.gotify def send_notification(self, message: MessageNotification) -> bool: diff --git a/media_manager/notification/service_providers/ntfy.py b/media_manager/notification/service_providers/ntfy.py index a5e8439..304ccc0 100644 --- a/media_manager/notification/service_providers/ntfy.py +++ b/media_manager/notification/service_providers/ntfy.py @@ -12,7 +12,7 @@ class NtfyNotificationServiceProvider(AbstractNotificationServiceProvider): Ntfy Notification Service Provider """ - def __init__(self): + def __init__(self) -> None: self.config = MediaManagerConfig().notifications.ntfy def send_notification(self, message: MessageNotification) -> bool: diff --git a/media_manager/notification/service_providers/pushover.py b/media_manager/notification/service_providers/pushover.py index acee023..b66ad13 100644 --- a/media_manager/notification/service_providers/pushover.py +++ b/media_manager/notification/service_providers/pushover.py @@ -8,7 +8,7 @@ from media_manager.notification.service_providers.abstract_notification_service_ class PushoverNotificationServiceProvider(AbstractNotificationServiceProvider): - def __init__(self): + def __init__(self) -> None: self.config = MediaManagerConfig().notifications.pushover def send_notification(self, message: MessageNotification) -> bool: diff --git a/media_manager/scheduler.py b/media_manager/scheduler.py index 5d2e152..9c9618d 100644 --- a/media_manager/scheduler.py +++ b/media_manager/scheduler.py @@ -3,6 +3,7 @@ from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger import media_manager.database +from media_manager.config import MediaManagerConfig from media_manager.movies.service import ( auto_download_all_approved_movie_requests, import_all_movie_torrents, @@ -15,7 +16,7 @@ from media_manager.tv.service import ( ) -def setup_scheduler(config): +def setup_scheduler(config: MediaManagerConfig) -> BackgroundScheduler: from media_manager.database import init_engine init_engine(config.database) diff --git a/media_manager/torrent/download_clients/abstract_download_client.py b/media_manager/torrent/download_clients/abstract_download_client.py index eecba94..d8df5da 100644 --- a/media_manager/torrent/download_clients/abstract_download_client.py +++ b/media_manager/torrent/download_clients/abstract_download_client.py @@ -16,11 +16,11 @@ class AbstractDownloadClient(ABC): pass @abstractmethod - def download_torrent(self, torrent: IndexerQueryResult) -> Torrent: + def download_torrent(self, indexer_result: IndexerQueryResult) -> Torrent: """ Add a torrent to the download client and return the torrent object. - :param torrent: The indexer query result of the torrent file to download. + :param indexer_result: The indexer query result of the torrent file to download. :return: The torrent object with calculated hash and initial status. """ diff --git a/media_manager/torrent/download_clients/qbittorrent.py b/media_manager/torrent/download_clients/qbittorrent.py index 538c3c5..d20a078 100644 --- a/media_manager/torrent/download_clients/qbittorrent.py +++ b/media_manager/torrent/download_clients/qbittorrent.py @@ -43,7 +43,7 @@ class QbittorrentDownloadClient(AbstractDownloadClient): ERROR_STATE = ("missingFiles", "error", "checkingResumeData") UNKNOWN_STATE = ("unknown",) - def __init__(self): + def __init__(self) -> None: self.config = MediaManagerConfig().torrents.qbittorrent self.api_client = qbittorrentapi.Client( host=self.config.host, diff --git a/media_manager/torrent/download_clients/sabnzbd.py b/media_manager/torrent/download_clients/sabnzbd.py index af83c6f..38247e8 100644 --- a/media_manager/torrent/download_clients/sabnzbd.py +++ b/media_manager/torrent/download_clients/sabnzbd.py @@ -27,7 +27,7 @@ class SabnzbdDownloadClient(AbstractDownloadClient): ERROR_STATE = ("Failed",) UNKNOWN_STATE = ("Unknown",) - def __init__(self): + def __init__(self) -> None: self.config = MediaManagerConfig().torrents.sabnzbd self.client = sabnzbd_api.SabnzbdClient( host=self.config.host, diff --git a/media_manager/torrent/download_clients/transmission.py b/media_manager/torrent/download_clients/transmission.py index 509c2e0..eaba85c 100644 --- a/media_manager/torrent/download_clients/transmission.py +++ b/media_manager/torrent/download_clients/transmission.py @@ -30,7 +30,7 @@ class TransmissionDownloadClient(AbstractDownloadClient): } ) - def __init__(self): + def __init__(self) -> None: self.config = MediaManagerConfig().torrents.transmission try: self._client = transmission_rpc.Client( diff --git a/media_manager/torrent/manager.py b/media_manager/torrent/manager.py index e76dad2..65b12a4 100644 --- a/media_manager/torrent/manager.py +++ b/media_manager/torrent/manager.py @@ -30,7 +30,7 @@ class DownloadManager: Only one torrent client and one usenet client are active at a time. """ - def __init__(self): + def __init__(self) -> None: self._torrent_client: AbstractDownloadClient | None = None self._usenet_client: AbstractDownloadClient | None = None self.config = MediaManagerConfig().torrents diff --git a/media_manager/torrent/repository.py b/media_manager/torrent/repository.py index aba9fc0..382e0df 100644 --- a/media_manager/torrent/repository.py +++ b/media_manager/torrent/repository.py @@ -18,7 +18,7 @@ from media_manager.tv.schemas import Show as ShowSchema class TorrentRepository: - def __init__(self, db: DbSessionDependency): + def __init__(self, db: DbSessionDependency) -> None: self.db = db def get_seasons_files_of_torrent( @@ -62,7 +62,7 @@ class TorrentRepository: def delete_torrent( self, torrent_id: TorrentId, delete_associated_media_files: bool = False - ): + ) -> None: if delete_associated_media_files: movie_files_stmt = delete(MovieFile).where( MovieFile.torrent_id == torrent_id @@ -76,7 +76,7 @@ class TorrentRepository: self.db.delete(self.db.get(Torrent, torrent_id)) - def get_movie_of_torrent(self, torrent_id: TorrentId): + def get_movie_of_torrent(self, torrent_id: TorrentId) -> MovieSchema | None: stmt = ( select(Movie) .join(MovieFile, Movie.id == MovieFile.movie_id) @@ -87,7 +87,7 @@ class TorrentRepository: return None return MovieSchema.model_validate(result) - def get_movie_files_of_torrent(self, torrent_id: TorrentId): + def get_movie_files_of_torrent(self, torrent_id: TorrentId) -> list[MovieFileSchema]: stmt = select(MovieFile).where(MovieFile.torrent_id == torrent_id) result = self.db.execute(stmt).scalars().all() return [MovieFileSchema.model_validate(movie_file) for movie_file in result] diff --git a/media_manager/torrent/router.py b/media_manager/torrent/router.py index abb03c3..a412d2b 100644 --- a/media_manager/torrent/router.py +++ b/media_manager/torrent/router.py @@ -36,7 +36,7 @@ def delete_torrent( service: torrent_service_dep, torrent: torrent_dep, delete_files: bool = False, -): +) -> None: if delete_files: try: service.cancel_download(torrent=torrent, delete_files=False) @@ -54,7 +54,7 @@ def delete_torrent( def retry_torrent_download( service: torrent_service_dep, torrent: torrent_dep, -): +) -> None: service.pause_download(torrent=torrent) service.resume_download(torrent=torrent) diff --git a/media_manager/torrent/schemas.py b/media_manager/torrent/schemas.py index a481602..1ae4650 100644 --- a/media_manager/torrent/schemas.py +++ b/media_manager/torrent/schemas.py @@ -33,7 +33,7 @@ class TorrentStatus(Enum): class Torrent(BaseModel): model_config = ConfigDict(from_attributes=True) - id: TorrentId = Field(default_factory=uuid.uuid4) + id: TorrentId = Field(default_factory=lambda: TorrentId(uuid.uuid4())) status: TorrentStatus title: str quality: Quality diff --git a/media_manager/torrent/service.py b/media_manager/torrent/service.py index f27eaf9..c5bcaaf 100644 --- a/media_manager/torrent/service.py +++ b/media_manager/torrent/service.py @@ -1,7 +1,7 @@ import logging from media_manager.indexer.schemas import IndexerQueryResult -from media_manager.movies.schemas import Movie +from media_manager.movies.schemas import Movie, MovieFile from media_manager.torrent.manager import DownloadManager from media_manager.torrent.repository import TorrentRepository from media_manager.torrent.schemas import Torrent, TorrentId @@ -14,8 +14,8 @@ class TorrentService: def __init__( self, torrent_repository: TorrentRepository, - download_manager: DownloadManager = None, - ): + download_manager: DownloadManager | None = None, + ) -> None: self.torrent_repository = torrent_repository self.download_manager = download_manager or DownloadManager() @@ -101,7 +101,7 @@ class TorrentService: self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id) ) - def delete_torrent(self, torrent_id: TorrentId): + def delete_torrent(self, torrent_id: TorrentId) -> None: log.info(f"Deleting torrent with ID: {torrent_id}") t = self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id) delete_media_files = not t.imported @@ -109,5 +109,5 @@ class TorrentService: torrent_id=torrent_id, delete_associated_media_files=delete_media_files ) - def get_movie_files_of_torrent(self, torrent: Torrent): + def get_movie_files_of_torrent(self, torrent: Torrent) -> list[MovieFile]: return self.torrent_repository.get_movie_files_of_torrent(torrent_id=torrent.id) diff --git a/media_manager/torrent/utils.py b/media_manager/torrent/utils.py index 5ef54d5..48223f1 100644 --- a/media_manager/torrent/utils.py +++ b/media_manager/torrent/utils.py @@ -34,7 +34,7 @@ def list_files_recursively(path: Path = Path()) -> list[Path]: return valid_files -def extract_archives(files): +def extract_archives(files: list) -> None: archive_types = { "application/zip", "application/x-zip-compressedapplication/x-compressed", @@ -61,11 +61,11 @@ def extract_archives(files): log.error(f"Failed to extract archive {file}. Error: {e}") -def get_torrent_filepath(torrent: Torrent): +def get_torrent_filepath(torrent: Torrent) -> Path: return MediaManagerConfig().misc.torrent_directory / torrent.title -def import_file(target_file: Path, source_file: Path): +def import_file(target_file: Path, source_file: Path) -> None: if target_file.exists(): target_file.unlink() @@ -87,11 +87,15 @@ def get_files_for_import( Extracts all files from the torrent download directory, including extracting archives. Returns a tuple containing: seperated video files, subtitle files, and all files found in the torrent directory. """ - search_directory = directory if directory else get_torrent_filepath(torrent=torrent) if torrent: log.info(f"Importing torrent {torrent}") - else: + search_directory = get_torrent_filepath(torrent=torrent) + elif directory: log.info(f"Importing files from directory {directory}") + search_directory = directory + else: + msg = "Either torrent or directory must be provided." + raise ValueError(msg) all_files: list[Path] = list_files_recursively(path=search_directory) log.debug(f"Found {len(all_files)} files downloaded by the torrent") diff --git a/media_manager/tv/repository.py b/media_manager/tv/repository.py index c0529b3..e89ae57 100644 --- a/media_manager/tv/repository.py +++ b/media_manager/tv/repository.py @@ -44,7 +44,7 @@ class TvRepository: Provides methods to retrieve, save, and delete shows and seasons. """ - def __init__(self, db: Session): + def __init__(self, db: Session) -> None: self.db = db def get_show_by_id(self, show_id: ShowId) -> ShowSchema: @@ -341,10 +341,10 @@ class TvRepository: results = self.db.execute(stmt).scalars().unique().all() return [ RichSeasonRequestSchema( - id=x.id, + id=SeasonRequestId(x.id), min_quality=x.min_quality, wanted_quality=x.wanted_quality, - season_id=x.season_id, + season_id=SeasonId(x.season_id), show=x.season.show, season=x.season, requested_by=x.requested_by, diff --git a/media_manager/tv/router.py b/media_manager/tv/router.py index 914cf16..8f33f75 100644 --- a/media_manager/tv/router.py +++ b/media_manager/tv/router.py @@ -7,7 +7,7 @@ from media_manager.auth.db import User from media_manager.auth.schemas import UserRead from media_manager.auth.users import current_active_user, current_superuser from media_manager.config import LibraryItem, MediaManagerConfig -from media_manager.exceptions import MediaAlreadyExistsError +from media_manager.exceptions import MediaAlreadyExistsError, NotFoundError from media_manager.indexer.schemas import ( IndexerQueryResult, IndexerQueryResultId, @@ -94,7 +94,7 @@ def get_all_importable_shows( dependencies=[Depends(current_superuser)], status_code=status.HTTP_204_NO_CONTENT, ) -def import_detected_show(tv_service: tv_service_dep, tv_show: show_dep, directory: str): +def import_detected_show(tv_service: tv_service_dep, tv_show: show_dep, directory: str) -> None: """ Import a detected show from the specified directory into the library. """ @@ -140,12 +140,12 @@ def add_a_show( metadata_provider: metadata_provider_dep, show_id: int, language: str | None = None, -): +) -> Show: """ Add a new show to the library. """ try: - show = tv_service.add_show( + show = tv_service.add_show( external_id=show_id, metadata_provider=metadata_provider, language=language, @@ -154,6 +154,8 @@ def add_a_show( show = tv_service.get_show_by_external_id( show_id, metadata_provider=metadata_provider.name ) + if not show: + raise NotFoundError from MediaAlreadyExistsError return show @@ -205,7 +207,7 @@ def delete_a_show( show: show_dep, delete_files_on_disk: bool = False, delete_torrents: bool = False, -): +) -> None: """ Delete a show from the library. """ @@ -296,7 +298,7 @@ def request_a_season( user: Annotated[User, Depends(current_active_user)], season_request: CreateSeasonRequest, tv_service: tv_service_dep, -): +) -> None: """ Create a new season request. """ @@ -314,7 +316,7 @@ def update_request( tv_service: tv_service_dep, user: Annotated[User, Depends(current_active_user)], season_request: UpdateSeasonRequest, -): +) -> None: """ Update an existing season request. """ @@ -336,13 +338,15 @@ def authorize_request( user: Annotated[User, Depends(current_superuser)], season_request_id: SeasonRequestId, authorized_status: bool = False, -): +) -> None: """ Authorize or de-authorize a season request. """ season_request = tv_service.get_season_request_by_id( season_request_id=season_request_id ) + if not season_request: + raise NotFoundError season_request.authorized_by = UserRead.model_validate(user) season_request.authorized = authorized_status if not authorized_status: @@ -359,7 +363,7 @@ def delete_season_request( tv_service: tv_service_dep, user: Annotated[User, Depends(current_active_user)], request_id: SeasonRequestId, -): +) -> None: """ Delete a season request. """ @@ -367,11 +371,11 @@ def delete_season_request( if user.is_superuser or request.requested_by.id == user.id: tv_service.delete_season_request(season_request_id=request_id) log.info(f"User {user.id} deleted season request {request_id}.") - return None + return log.warning( f"User {user.id} tried to delete season request {request_id} but is not authorized." ) - return HTTPException( + raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to delete this request", ) diff --git a/media_manager/tv/schemas.py b/media_manager/tv/schemas.py index 1e9b3be..d72125a 100644 --- a/media_manager/tv/schemas.py +++ b/media_manager/tv/schemas.py @@ -20,7 +20,7 @@ SeasonRequestId = typing.NewType("SeasonRequestId", UUID) class Episode(BaseModel): model_config = ConfigDict(from_attributes=True) - id: EpisodeId = Field(default_factory=uuid.uuid4) + id: EpisodeId = Field(default_factory=lambda: EpisodeId(uuid.uuid4())) number: EpisodeNumber external_id: int title: str @@ -29,7 +29,7 @@ class Episode(BaseModel): class Season(BaseModel): model_config = ConfigDict(from_attributes=True) - id: SeasonId = Field(default_factory=uuid.uuid4) + id: SeasonId = Field(default_factory=lambda: SeasonId(uuid.uuid4())) number: SeasonNumber name: str @@ -43,7 +43,7 @@ class Season(BaseModel): class Show(BaseModel): model_config = ConfigDict(from_attributes=True) - id: ShowId = Field(default_factory=uuid.uuid4) + id: ShowId = Field(default_factory=lambda: ShowId(uuid.uuid4())) name: str overview: str @@ -85,7 +85,7 @@ class UpdateSeasonRequest(SeasonRequestBase): class SeasonRequest(SeasonRequestBase): model_config = ConfigDict(from_attributes=True) - id: SeasonRequestId = Field(default_factory=uuid.uuid4) + id: SeasonRequestId = Field(default_factory=lambda: SeasonRequestId(uuid.uuid4())) season_id: SeasonId requested_by: UserRead | None = None diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index 6f7978f..9ccc350 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -19,6 +19,7 @@ from media_manager.metadataProvider.abstract_metadata_provider import ( from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult from media_manager.metadataProvider.tmdb import TmdbMetadataProvider from media_manager.metadataProvider.tvdb import TvdbMetadataProvider +from media_manager.notification.repository import NotificationRepository from media_manager.notification.service import NotificationService from media_manager.schemas import MediaImportSuggestion from media_manager.torrent.repository import TorrentRepository @@ -66,8 +67,8 @@ class TvService: tv_repository: TvRepository, torrent_service: TorrentService, indexer_service: IndexerService, - notification_service: NotificationService = None, - ): + notification_service: NotificationService, + ) -> None: self.tv_repository = tv_repository self.torrent_service = torrent_service self.indexer_service = indexer_service @@ -78,7 +79,7 @@ class TvService: external_id: int, metadata_provider: AbstractMetadataProvider, language: str | None = None, - ) -> Show | None: + ) -> Show: """ Add a new show to the database. @@ -572,7 +573,7 @@ class TvService: self.delete_season_request(season_request.id) return True - def get_root_show_directory(self, show: Show): + def get_root_show_directory(self, show: Show) -> Path: misc_config = MediaManagerConfig().misc show_directory_name = f"{remove_special_characters(show.name)} ({show.year}) [{show.metadata_provider}id-{show.external_id}]" log.debug( @@ -966,10 +967,12 @@ def auto_download_all_approved_season_requests() -> None: tv_repository = TvRepository(db=db) torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db)) indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db)) + notification_service = NotificationService(notification_repository=NotificationRepository(db=db)) tv_service = TvService( tv_repository=tv_repository, torrent_service=torrent_service, indexer_service=indexer_service, + notification_service=notification_service ) log.info("Auto downloading all approved season requests") @@ -1001,10 +1004,12 @@ def import_all_show_torrents() -> None: tv_repository = TvRepository(db=db) torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db)) indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db)) + notification_service = NotificationService(notification_repository=NotificationRepository(db=db)) tv_service = TvService( tv_repository=tv_repository, torrent_service=torrent_service, indexer_service=indexer_service, + notification_service=notification_service ) log.info("Importing all torrents") torrents = torrent_service.get_all_torrents() @@ -1037,6 +1042,7 @@ def update_all_non_ended_shows_metadata() -> None: tv_repository=tv_repository, torrent_service=TorrentService(torrent_repository=TorrentRepository(db=db)), indexer_service=IndexerService(indexer_repository=IndexerRepository(db=db)), + notification_service=NotificationService(notification_repository=NotificationRepository(db=db)) ) log.info("Updating metadata for all non-ended shows") diff --git a/metadata_relay/app/tmdb.py b/metadata_relay/app/tmdb.py index 45660f5..39a9744 100644 --- a/metadata_relay/app/tmdb.py +++ b/metadata_relay/app/tmdb.py @@ -16,39 +16,39 @@ else: tmdbsimple.API_KEY = tmdb_api_key @router.get("/tv/trending") - async def get_tmdb_trending_tv(language: str = "en"): + async def get_tmdb_trending_tv(language: str = "en") -> dict: return Trending(media_type="tv").info(language=language) @router.get("/tv/search") - async def search_tmdb_tv(query: str, page: int = 1, language: str = "en"): + async def search_tmdb_tv(query: str, page: int = 1, language: str = "en") -> dict: return Search().tv(page=page, query=query, language=language) @router.get("/tv/shows/{show_id}") - async def get_tmdb_show(show_id: int, language: str = "en"): + async def get_tmdb_show(show_id: int, language: str = "en") -> dict: return TV(show_id).info(language=language) @router.get("/tv/shows/{show_id}/external_ids") - async def get_tmdb_show_external_ids(show_id: int): + async def get_tmdb_show_external_ids(show_id: int) -> dict: return TV(show_id).external_ids() @router.get("/tv/shows/{show_id}/{season_number}") - async def get_tmdb_season(season_number: int, show_id: int, language: str = "en"): + async def get_tmdb_season(season_number: int, show_id: int, language: str = "en") -> dict: return TV_Seasons(season_number=season_number, tv_id=show_id).info( language=language ) @router.get("/movies/trending") - async def get_tmdb_trending_movies(language: str = "en"): + async def get_tmdb_trending_movies(language: str = "en") -> dict: return Trending(media_type="movie").info(language=language) @router.get("/movies/search") - async def search_tmdb_movies(query: str, page: int = 1, language: str = "en"): + async def search_tmdb_movies(query: str, page: int = 1, language: str = "en") -> dict: return Search().movie(page=page, query=query, language=language) @router.get("/movies/{movie_id}") - async def get_tmdb_movie(movie_id: int, language: str = "en"): + async def get_tmdb_movie(movie_id: int, language: str = "en") -> dict: return Movies(movie_id).info(language=language) @router.get("/movies/{movie_id}/external_ids") - async def get_tmdb_movie_external_ids(movie_id: int): + async def get_tmdb_movie_external_ids(movie_id: int) -> dict: return Movies(movie_id).external_ids() diff --git a/metadata_relay/app/tvdb.py b/metadata_relay/app/tvdb.py index 4f62b82..4c56f1a 100644 --- a/metadata_relay/app/tvdb.py +++ b/metadata_relay/app/tvdb.py @@ -16,29 +16,29 @@ else: tvdb_client = tvdb_v4_official.TVDB(tvdb_api_key) @router.get("/tv/trending") - async def get_tvdb_trending_tv(): + async def get_tvdb_trending_tv() -> list: return tvdb_client.get_all_series() @router.get("/tv/search") - async def search_tvdb_tv(query: str): + async def search_tvdb_tv(query: str) -> list: return tvdb_client.search(query) @router.get("/tv/shows/{show_id}") - async def get_tvdb_show(show_id: int): + async def get_tvdb_show(show_id: int) -> dict: return tvdb_client.get_series_extended(show_id) @router.get("/tv/seasons/{season_id}") - async def get_tvdb_season(season_id: int): + async def get_tvdb_season(season_id: int) -> dict: return tvdb_client.get_season_extended(season_id) @router.get("/movies/trending") - async def get_tvdb_trending_movies(): + async def get_tvdb_trending_movies() -> list: return tvdb_client.get_all_movies() @router.get("/movies/search") - async def search_tvdb_movies(query: str): + async def search_tvdb_movies(query: str) -> list: return tvdb_client.search(query) @router.get("/movies/{movie_id}") - async def get_tvdb_movie(movie_id: int): + async def get_tvdb_movie(movie_id: int) -> dict: return tvdb_client.get_movie_extended(movie_id) diff --git a/metadata_relay/main.py b/metadata_relay/main.py index 5cc0616..1903e97 100644 --- a/metadata_relay/main.py +++ b/metadata_relay/main.py @@ -5,7 +5,7 @@ from app.tvdb import router as tvdb_router from fastapi import FastAPI from starlette_exporter import PrometheusMiddleware, handle_metrics -app = FastAPI(root_path=os.getenv("BASE_PATH")) +app = FastAPI(root_path=os.getenv("BASE_PATH", "")) app.add_middleware(PrometheusMiddleware) app.add_route("/metrics", handle_metrics) @@ -15,5 +15,5 @@ app.include_router(tvdb_router) @app.get("/") -async def root(): +async def root() -> dict: return {"message": "Hello World"} diff --git a/pyproject.toml b/pyproject.toml index 308dd50..33038ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,10 @@ dependencies = [ ] [dependency-groups] -dev = ["ruff"] +dev = [ + "ruff", + "ty>=0.0.9", +] [tool.setuptools.packages.find] include = ["media_manager*"] diff --git a/ruff.toml b/ruff.toml index 848c14f..45c97e9 100644 --- a/ruff.toml +++ b/ruff.toml @@ -5,9 +5,9 @@ line-ending = "lf" quote-style = "double" [lint] -# to be enabled: ANN, BLE, C90, CPY, D, DOC, DTZ, FBT, G, PL, RSE, SLF, SIM, TC, TRY, UP +# to be enabled: BLE, C90, CPY, D, DOC, DTZ, FBT, G, PL, RSE, SLF, SIM, TC, TRY, UP extend-select = [ - "A", "ARG", "ASYNC", + "A", "ARG", "ASYNC", "ANN", "B", "C4", "COM", "DTZ", diff --git a/uv.lock b/uv.lock index f5714bc..51c539d 100644 --- a/uv.lock +++ b/uv.lock @@ -886,6 +886,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "ruff" }, + { name = "ty" }, ] [package.metadata] @@ -922,7 +923,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "ruff" }] +dev = [ + { name = "ruff" }, + { name = "ty", specifier = ">=0.0.9" }, +] [[package]] name = "mypy-extensions" @@ -1673,6 +1677,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/d2/3f3e03fe96c23701fa24890dcd393034f4d37fb1e4649f573b1a6f3cf994/tvdb_v4_official-1.1.0-py3-none-any.whl", hash = "sha256:1d66f87f7d3d36feb4923b37aefd5a048dd208096bc640d1898acb1956fc0ba1", size = 3801, upload-time = "2022-09-22T19:11:49.819Z" }, ] +[[package]] +name = "ty" +version = "0.0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/7b/4f677c622d58563c593c32081f8a8572afd90e43dc15b0dedd27b4305038/ty-0.0.9.tar.gz", hash = "sha256:83f980c46df17586953ab3060542915827b43c4748a59eea04190c59162957fe", size = 4858642, upload-time = "2026-01-05T12:24:56.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/3f/c1ee119738b401a8081ff84341781122296b66982e5982e6f162d946a1ff/ty-0.0.9-py3-none-linux_armv6l.whl", hash = "sha256:dd270d4dd6ebeb0abb37aee96cbf9618610723677f500fec1ba58f35bfa8337d", size = 9763596, upload-time = "2026-01-05T12:24:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/63/41/6b0669ef4cd806d4bd5c30263e6b732a362278abac1bc3a363a316cde896/ty-0.0.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:debfb2ba418b00e86ffd5403cb666b3f04e16853f070439517dd1eaaeeff9255", size = 9591514, upload-time = "2026-01-05T12:24:26.891Z" }, + { url = "https://files.pythonhosted.org/packages/02/a1/874aa756aee5118e690340a771fb9ded0d0c2168c0b7cc7d9561c2a750b0/ty-0.0.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:107c76ebb05a13cdb669172956421f7ffd289ad98f36d42a44a465588d434d58", size = 9097773, upload-time = "2026-01-05T12:24:14.442Z" }, + { url = "https://files.pythonhosted.org/packages/32/62/cb9a460cf03baab77b3361d13106b93b40c98e274d07c55f333ce3c716f6/ty-0.0.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6868ca5c87ca0caa1b3cb84603c767356242b0659b88307eda69b2fb0bfa416b", size = 9581824, upload-time = "2026-01-05T12:24:35.074Z" }, + { url = "https://files.pythonhosted.org/packages/5a/97/633ecb348c75c954f09f8913669de8c440b13b43ea7d214503f3f1c4bb60/ty-0.0.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14a4aa0eb5c1d3591c2adbdda4e44429a6bb5d2e298a704398bb2a7ccdafdfe", size = 9591050, upload-time = "2026-01-05T12:24:08.804Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/4b0c6a7a8a234e2113f88c80cc7aaa9af5868de7a693859f3c49da981934/ty-0.0.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01bd4466504cefa36b465c6608e9af4504415fa67f6affc01c7d6ce36663c7f4", size = 10018262, upload-time = "2026-01-05T12:24:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/cb/97/076d72a028f6b31e0b87287aa27c5b71a2f9927ee525260ea9f2f56828b8/ty-0.0.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:76c8253d1b30bc2c3eaa1b1411a1c34423decde0f4de0277aa6a5ceacfea93d9", size = 10911642, upload-time = "2026-01-05T12:24:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/705d6a5ed07ea36b1f23592c3f0dbc8fc7649267bfbb3bf06464cdc9a98a/ty-0.0.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8992fa4a9c6a5434eae4159fdd4842ec8726259bfd860e143ab95d078de6f8e3", size = 10632468, upload-time = "2026-01-05T12:24:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/44/78/4339a254537488d62bf392a936b3ec047702c0cc33d6ce3a5d613f275cd0/ty-0.0.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c79d503d151acb4a145a3d98702d07cb641c47292f63e5ffa0151e4020a5d33", size = 10273422, upload-time = "2026-01-05T12:24:45.8Z" }, + { url = "https://files.pythonhosted.org/packages/90/40/e7f386e87c9abd3670dcee8311674d7e551baa23b2e4754e2405976e6c92/ty-0.0.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a7ebf89ed276b564baa1f0dd9cd708e7b5aa89f19ce1b2f7d7132075abf93e", size = 10120289, upload-time = "2026-01-05T12:24:17.424Z" }, + { url = "https://files.pythonhosted.org/packages/f7/46/1027442596e725c50d0d1ab5179e9fa78a398ab412994b3006d0ee0899c7/ty-0.0.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ae3866e50109d2400a886bb11d9ef607f23afc020b226af773615cf82ae61141", size = 9566657, upload-time = "2026-01-05T12:24:51.048Z" }, + { url = "https://files.pythonhosted.org/packages/56/be/df921cf1967226aa01690152002b370a7135c6cced81e86c12b86552cdc4/ty-0.0.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:185244a5eacfcd8f5e2d85b95e4276316772f1e586520a6cb24aa072ec1bac26", size = 9610334, upload-time = "2026-01-05T12:24:20.334Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e8/f085268860232cc92ebe95415e5c8640f7f1797ac3a49ddd137c6222924d/ty-0.0.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f834ff27d940edb24b2e86bbb3fb45ab9e07cf59ca8c5ac615095b2542786408", size = 9726701, upload-time = "2026-01-05T12:24:29.785Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/9394210c66041cd221442e38f68a596945103d9446ece505889ffa9b3da9/ty-0.0.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:773f4b3ba046de952d7c1ad3a2c09b24f3ed4bc8342ae3cbff62ebc14aa6d48c", size = 10227082, upload-time = "2026-01-05T12:24:40.132Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9f/75951eb573b473d35dd9570546fc1319f7ca2d5b5c50a5825ba6ea6cb33a/ty-0.0.9-py3-none-win32.whl", hash = "sha256:1f20f67e373038ff20f36d5449e787c0430a072b92d5933c5b6e6fc79d3de4c8", size = 9176458, upload-time = "2026-01-05T12:24:32.559Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/b1cdf71ac874e72678161e25e2326a7d30bc3489cd3699561355a168e54f/ty-0.0.9-py3-none-win_amd64.whl", hash = "sha256:2c415f3bbb730f8de2e6e0b3c42eb3a91f1b5fbbcaaead2e113056c3b361c53c", size = 10040479, upload-time = "2026-01-05T12:24:42.697Z" }, + { url = "https://files.pythonhosted.org/packages/b5/8f/abc75c4bb774b12698629f02d0d12501b0a7dff9c31dc3bd6b6c6467e90a/ty-0.0.9-py3-none-win_arm64.whl", hash = "sha256:48e339d794542afeed710ea4f846ead865cc38cecc335a9c781804d02eaa2722", size = 9543127, upload-time = "2026-01-05T12:24:11.731Z" }, +] + [[package]] name = "typer" version = "0.21.0"