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"