diff --git a/alembic/env.py b/alembic/env.py index 20de4f5e..c3ceb863 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -27,10 +27,12 @@ # target_metadata = mymodel.Base.metadata from media_manager.auth.db import OAuthAccount, User # noqa: E402 +from media_manager.books.models import Author, Book, BookFile, BookRequest # noqa: E402 from media_manager.config import MediaManagerConfig # noqa: E402 from media_manager.database import Base # noqa: E402 from media_manager.indexer.models import IndexerQueryResult # noqa: E402 from media_manager.movies.models import Movie, MovieFile, MovieRequest # noqa: E402 +from media_manager.music.models import Album, AlbumFile, AlbumRequest, Artist, Track # noqa: E402 from media_manager.notification.models import Notification # noqa: E402 from media_manager.torrent.models import Torrent # noqa: E402 from media_manager.tv.models import ( # noqa: E402 @@ -46,6 +48,14 @@ # this is to keep pycharm from complaining about/optimizing unused imports # noinspection PyStatementEffect __all__ = [ + "Album", + "AlbumFile", + "AlbumRequest", + "Artist", + "Author", + "Book", + "BookFile", + "BookRequest", "Episode", "IndexerQueryResult", "Movie", @@ -58,6 +68,7 @@ "SeasonRequest", "Show", "Torrent", + "Track", "User", ] diff --git a/alembic/versions/122dd19d0818_add_book_tables.py b/alembic/versions/122dd19d0818_add_book_tables.py new file mode 100644 index 00000000..4881a9d0 --- /dev/null +++ b/alembic/versions/122dd19d0818_add_book_tables.py @@ -0,0 +1,82 @@ +"""add book tables + +Revision ID: 122dd19d0818 +Revises: 6679fc11aa8f +Create Date: 2026-02-08 14:17:34.994940 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +import fastapi_users_db_sqlalchemy.generics + + +# revision identifiers, used by Alembic. +revision: str = "122dd19d0818" +down_revision: Union[str, None] = "6679fc11aa8f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Reference the existing quality enum - use postgresql.ENUM to avoid recreation +quality_enum = postgresql.ENUM("uhd", "fullhd", "hd", "sd", "unknown", name="quality", create_type=False) + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table("book_author", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("external_id", sa.String(), nullable=False), + sa.Column("metadata_provider", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("overview", sa.String(), nullable=False), + sa.Column("library", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("external_id", "metadata_provider") + ) + op.create_table("book", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("author_id", sa.Uuid(), nullable=False), + sa.Column("external_id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("year", sa.Integer(), nullable=True), + sa.Column("format", sa.String(), nullable=False), + sa.Column("isbn", sa.String(), nullable=True), + sa.Column("publisher", sa.String(), nullable=True), + sa.Column("page_count", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["author_id"], ["book_author.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("author_id", "external_id") + ) + op.create_table("book_file", + sa.Column("book_id", sa.Uuid(), nullable=False), + sa.Column("torrent_id", sa.Uuid(), nullable=True), + sa.Column("file_path_suffix", sa.String(), nullable=False), + sa.Column("quality", quality_enum, nullable=False), + sa.ForeignKeyConstraint(["book_id"], ["book.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["torrent_id"], ["torrent.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("book_id", "file_path_suffix") + ) + op.create_table("book_request", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("book_id", sa.Uuid(), nullable=False), + sa.Column("wanted_quality", quality_enum, nullable=False), + sa.Column("min_quality", quality_enum, nullable=False), + sa.Column("requested_by_id", fastapi_users_db_sqlalchemy.generics.GUID(), nullable=True), + sa.Column("authorized", sa.Boolean(), nullable=False), + sa.Column("authorized_by_id", fastapi_users_db_sqlalchemy.generics.GUID(), nullable=True), + sa.ForeignKeyConstraint(["authorized_by_id"], ["user.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["book_id"], ["book.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["requested_by_id"], ["user.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("book_id", "wanted_quality") + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table("book_request") + op.drop_table("book_file") + op.drop_table("book") + op.drop_table("book_author") diff --git a/alembic/versions/6679fc11aa8f_add_music_tables.py b/alembic/versions/6679fc11aa8f_add_music_tables.py new file mode 100644 index 00000000..1c36cf5b --- /dev/null +++ b/alembic/versions/6679fc11aa8f_add_music_tables.py @@ -0,0 +1,97 @@ +"""add music tables + +Revision ID: 6679fc11aa8f +Revises: 2c61f662ca9e +Create Date: 2026-02-07 22:01:54.813204 + +""" +from typing import Sequence, Union + +from alembic import op +import fastapi_users_db_sqlalchemy.generics +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# Reference the existing quality enum type (created in initial migration) +quality_enum = postgresql.ENUM('uhd', 'fullhd', 'hd', 'sd', 'unknown', name='quality', create_type=False) + + +# revision identifiers, used by Alembic. +revision: str = '6679fc11aa8f' +down_revision: Union[str, None] = '2c61f662ca9e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('artist', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('external_id', sa.String(), nullable=False), + sa.Column('metadata_provider', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('overview', sa.String(), nullable=False), + sa.Column('library', sa.String(), nullable=False), + sa.Column('country', sa.String(), nullable=True), + sa.Column('disambiguation', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('external_id', 'metadata_provider') + ) + op.create_table('album', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('artist_id', sa.Uuid(), nullable=False), + sa.Column('external_id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('year', sa.Integer(), nullable=True), + sa.Column('album_type', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('artist_id', 'external_id') + ) + op.create_table('album_file', + sa.Column('album_id', sa.Uuid(), nullable=False), + sa.Column('torrent_id', sa.Uuid(), nullable=True), + sa.Column('file_path_suffix', sa.String(), nullable=False), + sa.Column('quality', quality_enum, nullable=False), + sa.ForeignKeyConstraint(['album_id'], ['album.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['torrent_id'], ['torrent.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('album_id', 'file_path_suffix') + ) + op.create_table('album_request', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('album_id', sa.Uuid(), nullable=False), + sa.Column('wanted_quality', quality_enum, nullable=False), + sa.Column('min_quality', quality_enum, nullable=False), + sa.Column('requested_by_id', fastapi_users_db_sqlalchemy.generics.GUID(), nullable=True), + sa.Column('authorized', sa.Boolean(), nullable=False), + sa.Column('authorized_by_id', fastapi_users_db_sqlalchemy.generics.GUID(), nullable=True), + sa.ForeignKeyConstraint(['album_id'], ['album.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['authorized_by_id'], ['user.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['requested_by_id'], ['user.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('album_id', 'wanted_quality') + ) + op.create_table('track', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('album_id', sa.Uuid(), nullable=False), + sa.Column('number', sa.Integer(), nullable=False), + sa.Column('external_id', sa.String(), nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('duration_ms', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['album_id'], ['album.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('album_id', 'number') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('track') + op.drop_table('album_request') + op.drop_table('album_file') + op.drop_table('album') + op.drop_table('artist') + # ### end Alembic commands ### diff --git a/config.example.toml b/config.example.toml index 75041a4d..87360b8e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -13,6 +13,8 @@ cors_urls = ["http://localhost:8000"] # note the lack of a trailing slash image_directory = "/data/images" tv_directory = "/data/tv" movie_directory = "/data/movies" +music_directory = "/data/music" +books_directory = "/data/books" torrent_directory = "/data/torrents" # this is where MediaManager will search for the downloaded torrents and usenet files # you probaly don't need to change this @@ -29,6 +31,14 @@ path = "/data/tv/live-action" # Change this to match your actual TV shows locat name = "Documentary" path = "/data/movies/documentary" # Change this to match your actual movies location +[[misc.music_libraries]] +name = "FLAC" +path = "/data/music/flac" # Change this to match your actual music location + +[[misc.books_libraries]] +name = "Ebooks" +path = "/data/books/ebooks" # Change this to match your actual books location + [database] host = "db" port = 5432 @@ -169,4 +179,10 @@ primary_languages = [""] default_language = "en" [metadata.tvdb] -tvdb_relay_url = "https://metadata-relay.dorninger.co/tvdb" \ No newline at end of file +tvdb_relay_url = "https://metadata-relay.dorninger.co/tvdb" + +[metadata.musicbrainz] +enabled = true + +[metadata.openlibrary] +enabled = true \ No newline at end of file diff --git a/media_manager/books/__init__.py b/media_manager/books/__init__.py new file mode 100644 index 00000000..3988bf12 --- /dev/null +++ b/media_manager/books/__init__.py @@ -0,0 +1,3 @@ +import logging + +log = logging.getLogger(__name__) diff --git a/media_manager/books/dependencies.py b/media_manager/books/dependencies.py new file mode 100644 index 00000000..50fd85c5 --- /dev/null +++ b/media_manager/books/dependencies.py @@ -0,0 +1,70 @@ +from typing import Annotated + +from fastapi import Depends, HTTPException, Path + +from media_manager.books.repository import BookRepository +from media_manager.books.schemas import Author, AuthorId, Book, BookId +from media_manager.books.service import BookService +from media_manager.database import DbSessionDependency +from media_manager.exceptions import NotFoundError +from media_manager.indexer.dependencies import indexer_service_dep +from media_manager.notification.dependencies import notification_service_dep +from media_manager.torrent.dependencies import torrent_service_dep + + +def get_book_repository(db_session: DbSessionDependency) -> BookRepository: + return BookRepository(db_session) + + +book_repository_dep = Annotated[BookRepository, Depends(get_book_repository)] + + +def get_book_service( + book_repository: book_repository_dep, + torrent_service: torrent_service_dep, + indexer_service: indexer_service_dep, + notification_service: notification_service_dep, +) -> BookService: + return BookService( + book_repository=book_repository, + torrent_service=torrent_service, + indexer_service=indexer_service, + notification_service=notification_service, + ) + + +book_service_dep = Annotated[BookService, Depends(get_book_service)] + + +def get_author_by_id( + book_service: book_service_dep, + author_id: AuthorId = Path(..., description="The ID of the author"), +) -> Author: + try: + author = book_service.get_author_by_id(author_id) + except NotFoundError: + raise HTTPException( + status_code=404, + detail=f"Author with ID {author_id} not found.", + ) from None + return author + + +author_dep = Annotated[Author, Depends(get_author_by_id)] + + +def get_book_by_id( + book_service: book_service_dep, + book_id: BookId = Path(..., description="The ID of the book"), +) -> Book: + try: + book = book_service.book_repository.get_book(book_id=book_id) + except NotFoundError: + raise HTTPException( + status_code=404, + detail=f"Book with ID {book_id} not found.", + ) from None + return book + + +book_dep = Annotated[Book, Depends(get_book_by_id)] diff --git a/media_manager/books/models.py b/media_manager/books/models.py new file mode 100644 index 00000000..110e98ee --- /dev/null +++ b/media_manager/books/models.py @@ -0,0 +1,94 @@ +from uuid import UUID + +from sqlalchemy import ForeignKey, PrimaryKeyConstraint, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from media_manager.auth.db import User +from media_manager.database import Base +from media_manager.torrent.models import Quality + + +class Author(Base): + __tablename__ = "book_author" + __table_args__ = (UniqueConstraint("external_id", "metadata_provider"),) + + id: Mapped[UUID] = mapped_column(primary_key=True) + external_id: Mapped[str] + metadata_provider: Mapped[str] + name: Mapped[str] + overview: Mapped[str] + library: Mapped[str] = mapped_column(default="") + + books: Mapped[list["Book"]] = relationship( + back_populates="author", cascade="all, delete" + ) + + +class Book(Base): + __tablename__ = "book" + __table_args__ = (UniqueConstraint("author_id", "external_id"),) + + id: Mapped[UUID] = mapped_column(primary_key=True) + author_id: Mapped[UUID] = mapped_column( + ForeignKey(column="book_author.id", ondelete="CASCADE"), + ) + external_id: Mapped[str] + name: Mapped[str] + year: Mapped[int | None] + format: Mapped[str] = mapped_column(default="ebook") + isbn: Mapped[str | None] = mapped_column(default=None) + publisher: Mapped[str | None] = mapped_column(default=None) + page_count: Mapped[int | None] = mapped_column(default=None) + + author: Mapped["Author"] = relationship(back_populates="books") + + book_files = relationship( + "BookFile", back_populates="book", cascade="all, delete" + ) + book_requests = relationship( + "BookRequest", back_populates="book", cascade="all, delete" + ) + + +class BookFile(Base): + __tablename__ = "book_file" + __table_args__ = (PrimaryKeyConstraint("book_id", "file_path_suffix"),) + + book_id: Mapped[UUID] = mapped_column( + ForeignKey(column="book.id", ondelete="CASCADE"), + ) + torrent_id: Mapped[UUID | None] = mapped_column( + ForeignKey(column="torrent.id", ondelete="SET NULL"), + ) + file_path_suffix: Mapped[str] + quality: Mapped[Quality] + + torrent = relationship("Torrent", back_populates="book_files", uselist=False) + book = relationship("Book", back_populates="book_files", uselist=False) + + +class BookRequest(Base): + __tablename__ = "book_request" + __table_args__ = (UniqueConstraint("book_id", "wanted_quality"),) + + id: Mapped[UUID] = mapped_column(primary_key=True) + book_id: Mapped[UUID] = mapped_column( + ForeignKey(column="book.id", ondelete="CASCADE"), + ) + wanted_quality: Mapped[Quality] + min_quality: Mapped[Quality] + requested_by_id: Mapped[UUID | None] = mapped_column( + ForeignKey(column="user.id", ondelete="SET NULL"), + ) + authorized: Mapped[bool] = mapped_column(default=False) + authorized_by_id: Mapped[UUID | None] = mapped_column( + ForeignKey(column="user.id", ondelete="SET NULL"), + ) + + requested_by: Mapped["User|None"] = relationship( + foreign_keys=[requested_by_id], uselist=False + ) + authorized_by: Mapped["User|None"] = relationship( + foreign_keys=[authorized_by_id], uselist=False + ) + book = relationship("Book", back_populates="book_requests", uselist=False) diff --git a/media_manager/books/repository.py b/media_manager/books/repository.py new file mode 100644 index 00000000..c98acaae --- /dev/null +++ b/media_manager/books/repository.py @@ -0,0 +1,407 @@ +import logging + +from sqlalchemy import delete, select +from sqlalchemy.exc import ( + IntegrityError, + SQLAlchemyError, +) +from sqlalchemy.orm import Session, joinedload + +from media_manager.books.models import Author, Book, BookFile, BookRequest +from media_manager.books.schemas import ( + Author as AuthorSchema, +) +from media_manager.books.schemas import ( + AuthorId, + BookId, + BookRequestId, +) +from media_manager.books.schemas import ( + BookFile as BookFileSchema, +) +from media_manager.books.schemas import ( + BookRequest as BookRequestSchema, +) +from media_manager.books.schemas import ( + BookTorrent as BookTorrentSchema, +) +from media_manager.books.schemas import ( + RichBookRequest as RichBookRequestSchema, +) +from media_manager.exceptions import ConflictError, NotFoundError +from media_manager.torrent.models import Torrent +from media_manager.torrent.schemas import TorrentId + +log = logging.getLogger(__name__) + + +class BookRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def get_author_by_id(self, author_id: AuthorId) -> AuthorSchema: + try: + stmt = ( + select(Author) + .options(joinedload(Author.books)) + .where(Author.id == author_id) + ) + result = self.db.execute(stmt).unique().scalar_one_or_none() + if not result: + msg = f"Author with id {author_id} not found." + raise NotFoundError(msg) + return AuthorSchema.model_validate(result) + except SQLAlchemyError: + self.db.rollback() + log.exception(f"Database error while retrieving author {author_id}") + raise + + def get_author_by_external_id( + self, external_id: str, metadata_provider: str + ) -> AuthorSchema: + try: + stmt = ( + select(Author) + .options(joinedload(Author.books)) + .where(Author.external_id == external_id) + .where(Author.metadata_provider == metadata_provider) + ) + result = self.db.execute(stmt).unique().scalar_one_or_none() + if not result: + msg = f"Author with external_id {external_id} and provider {metadata_provider} not found." + raise NotFoundError(msg) + return AuthorSchema.model_validate(result) + except SQLAlchemyError: + self.db.rollback() + log.exception( + f"Database error while retrieving author by external_id {external_id}" + ) + raise + + def get_authors(self) -> list[AuthorSchema]: + try: + stmt = select(Author).options(joinedload(Author.books)) + results = self.db.execute(stmt).scalars().unique().all() + return [AuthorSchema.model_validate(author) for author in results] + except SQLAlchemyError: + self.db.rollback() + log.exception("Database error while retrieving all authors") + raise + + def save_author(self, author: AuthorSchema) -> AuthorSchema: + log.debug(f"Attempting to save author: {author.name} (ID: {author.id})") + db_author = self.db.get(Author, author.id) if author.id else None + + if db_author: + log.debug(f"Updating existing author with ID: {author.id}") + db_author.external_id = author.external_id + db_author.metadata_provider = author.metadata_provider + db_author.name = author.name + db_author.overview = author.overview + else: + log.debug(f"Creating new author: {author.name}") + author_data = author.model_dump(exclude={"books"}) + db_author = Author(**author_data) + self.db.add(db_author) + + for book in author.books: + book_data = book.model_dump() + book_data["author_id"] = db_author.id + db_book = Book(**book_data) + self.db.add(db_book) + + try: + self.db.commit() + self.db.refresh(db_author) + log.info(f"Successfully saved author: {db_author.name} (ID: {db_author.id})") + return self.get_author_by_id(AuthorId(db_author.id)) + except IntegrityError as e: + self.db.rollback() + log.exception(f"Integrity error while saving author {author.name}") + msg = f"Author with this primary key or unique constraint violation: {e.orig}" + raise ConflictError(msg) from e + except SQLAlchemyError: + self.db.rollback() + log.exception(f"Database error while saving author {author.name}") + raise + + def delete_author(self, author_id: AuthorId) -> None: + log.debug(f"Attempting to delete author with id: {author_id}") + try: + author = self.db.get(Author, author_id) + if not author: + log.warning(f"Author with id {author_id} not found for deletion.") + msg = f"Author with id {author_id} not found." + raise NotFoundError(msg) + self.db.delete(author) + self.db.commit() + log.info(f"Successfully deleted author with id: {author_id}") + except SQLAlchemyError: + self.db.rollback() + log.exception(f"Database error while deleting author {author_id}") + raise + + def set_author_library(self, author_id: AuthorId, library: str) -> None: + try: + author = self.db.get(Author, author_id) + if not author: + msg = f"Author with id {author_id} not found." + raise NotFoundError(msg) + author.library = library + self.db.commit() + except SQLAlchemyError: + self.db.rollback() + log.exception(f"Database error setting library for author {author_id}") + raise + + def update_author_attributes( + self, + author_id: AuthorId, + name: str | None = None, + overview: str | None = None, + ) -> AuthorSchema: + db_author = self.db.get(Author, author_id) + if not db_author: + msg = f"Author with id {author_id} not found." + raise NotFoundError(msg) + + updated = False + if name is not None and db_author.name != name: + db_author.name = name + updated = True + if overview is not None and db_author.overview != overview: + db_author.overview = overview + updated = True + + if updated: + self.db.commit() + self.db.refresh(db_author) + return self.get_author_by_id(AuthorId(db_author.id)) + + def get_book(self, book_id: BookId) -> "Book": + from media_manager.books.schemas import Book as BookSchema + + try: + stmt = select(Book).where(Book.id == book_id) + result = self.db.execute(stmt).unique().scalar_one_or_none() + if not result: + msg = f"Book with id {book_id} not found." + raise NotFoundError(msg) + return BookSchema.model_validate(result) + except SQLAlchemyError: + self.db.rollback() + log.exception(f"Database error while retrieving book {book_id}") + raise + + # ------------------------------------------------------------------------- + # BOOK REQUESTS + # ------------------------------------------------------------------------- + + def add_book_request( + self, book_request: BookRequestSchema + ) -> BookRequestSchema: + db_model = BookRequest( + id=book_request.id, + book_id=book_request.book_id, + requested_by_id=book_request.requested_by.id + if book_request.requested_by + else None, + authorized_by_id=book_request.authorized_by.id + if book_request.authorized_by + else None, + wanted_quality=book_request.wanted_quality, + min_quality=book_request.min_quality, + authorized=book_request.authorized, + ) + try: + self.db.add(db_model) + self.db.commit() + self.db.refresh(db_model) + log.info(f"Successfully added book request with id: {db_model.id}") + return BookRequestSchema.model_validate(db_model) + except IntegrityError: + self.db.rollback() + log.exception("Integrity error while adding book request") + raise + except SQLAlchemyError: + self.db.rollback() + log.exception("Database error while adding book request") + raise + + def delete_book_request(self, book_request_id: BookRequestId) -> None: + try: + stmt = delete(BookRequest).where(BookRequest.id == book_request_id) + result = self.db.execute(stmt) + if result.rowcount == 0: + self.db.rollback() + msg = f"Book request with id {book_request_id} not found." + raise NotFoundError(msg) + self.db.commit() + except SQLAlchemyError: + self.db.rollback() + log.exception( + f"Database error while deleting book request {book_request_id}" + ) + raise + + def get_book_requests(self) -> list[RichBookRequestSchema]: + try: + stmt = select(BookRequest).options( + joinedload(BookRequest.requested_by), + joinedload(BookRequest.authorized_by), + joinedload(BookRequest.book).joinedload(Book.author), + ) + results = self.db.execute(stmt).scalars().unique().all() + rich_results = [] + for req in results: + book_schema = self.get_book(BookId(req.book_id)) + author_schema = self.get_author_by_id(AuthorId(req.book.author_id)) + rich_results.append( + RichBookRequestSchema( + id=req.id, + book_id=req.book_id, + wanted_quality=req.wanted_quality, + min_quality=req.min_quality, + requested_by=req.requested_by, + authorized=req.authorized, + authorized_by=req.authorized_by, + book=book_schema, + author=author_schema, + ) + ) + return rich_results + except SQLAlchemyError: + self.db.rollback() + log.exception("Database error while retrieving book requests") + raise + + def get_book_request(self, book_request_id: BookRequestId) -> BookRequestSchema: + try: + request = self.db.get(BookRequest, book_request_id) + if not request: + msg = f"Book request with id {book_request_id} not found." + raise NotFoundError(msg) + return BookRequestSchema.model_validate(request) + except SQLAlchemyError: + self.db.rollback() + log.exception(f"Database error retrieving book request {book_request_id}") + raise + + # ------------------------------------------------------------------------- + # BOOK FILES + # ------------------------------------------------------------------------- + + def add_book_file(self, book_file: BookFileSchema) -> BookFileSchema: + db_model = BookFile(**book_file.model_dump()) + try: + self.db.add(db_model) + self.db.commit() + self.db.refresh(db_model) + return BookFileSchema.model_validate(db_model) + except IntegrityError: + self.db.rollback() + log.exception("Integrity error while adding book file") + raise + except SQLAlchemyError: + self.db.rollback() + log.exception("Database error while adding book file") + raise + + def remove_book_files_by_torrent_id(self, torrent_id: TorrentId) -> int: + try: + stmt = delete(BookFile).where(BookFile.torrent_id == torrent_id) + result = self.db.execute(stmt) + self.db.commit() + except SQLAlchemyError: + self.db.rollback() + log.exception( + f"Database error removing book files for torrent_id {torrent_id}" + ) + raise + return result.rowcount + + def get_book_files_by_book_id(self, book_id: BookId) -> list[BookFileSchema]: + try: + stmt = select(BookFile).where(BookFile.book_id == book_id) + results = self.db.execute(stmt).scalars().all() + return [BookFileSchema.model_validate(bf) for bf in results] + except SQLAlchemyError: + self.db.rollback() + log.exception( + f"Database error retrieving book files for book_id {book_id}" + ) + raise + + # ------------------------------------------------------------------------- + # TORRENTS + # ------------------------------------------------------------------------- + + def get_torrents_by_author_id( + self, author_id: AuthorId + ) -> list[BookTorrentSchema]: + try: + stmt = ( + select(Torrent, BookFile.file_path_suffix) + .distinct() + .join(BookFile, BookFile.torrent_id == Torrent.id) + .join(Book, Book.id == BookFile.book_id) + .where(Book.author_id == author_id) + ) + results = self.db.execute(stmt).all() + formatted_results = [] + for torrent, file_path_suffix in results: + book_torrent = BookTorrentSchema( + torrent_id=torrent.id, + torrent_title=torrent.title, + status=torrent.status, + quality=torrent.quality, + imported=torrent.imported, + file_path_suffix=file_path_suffix, + usenet=torrent.usenet, + ) + formatted_results.append(book_torrent) + except SQLAlchemyError: + self.db.rollback() + log.exception( + f"Database error retrieving torrents for author_id {author_id}" + ) + raise + return formatted_results + + def get_all_authors_with_torrents(self) -> list[AuthorSchema]: + try: + stmt = ( + select(Author) + .distinct() + .join(Book, Author.id == Book.author_id) + .join(BookFile, Book.id == BookFile.book_id) + .join(Torrent, BookFile.torrent_id == Torrent.id) + .options(joinedload(Author.books)) + .order_by(Author.name) + ) + results = self.db.execute(stmt).scalars().unique().all() + return [AuthorSchema.model_validate(author) for author in results] + except SQLAlchemyError: + self.db.rollback() + log.exception("Database error retrieving all authors with torrents") + raise + + def get_author_by_torrent_id(self, torrent_id: TorrentId) -> AuthorSchema | None: + try: + stmt = ( + select(Author) + .join(Book, Author.id == Book.author_id) + .join(BookFile, Book.id == BookFile.book_id) + .where(BookFile.torrent_id == torrent_id) + .options(joinedload(Author.books)) + ) + result = self.db.execute(stmt).unique().scalar_one_or_none() + if not result: + return None + return AuthorSchema.model_validate(result) + except SQLAlchemyError: + self.db.rollback() + log.exception( + f"Database error retrieving author by torrent_id {torrent_id}" + ) + raise diff --git a/media_manager/books/router.py b/media_manager/books/router.py new file mode 100644 index 00000000..34eb83a6 --- /dev/null +++ b/media_manager/books/router.py @@ -0,0 +1,418 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, status + +from media_manager.auth.schemas import UserRead +from media_manager.auth.users import current_active_user, current_superuser +from media_manager.books import log +from media_manager.books.dependencies import ( + author_dep, + book_dep, + book_service_dep, +) +from media_manager.books.schemas import ( + Author, + AuthorId, + Book, + BookId, + BookRequest, + BookRequestBase, + BookRequestId, + CreateBookRequest, + PublicAuthor, + PublicBookFile, + RichAuthorTorrent, + RichBookRequest, +) +from media_manager.config import LibraryItem, MediaManagerConfig +from media_manager.exceptions import ConflictError, NotFoundError +from media_manager.indexer.schemas import ( + IndexerQueryResult, + IndexerQueryResultId, +) +from media_manager.metadataProvider.book_dependencies import ( + book_metadata_provider_dep, +) +from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult +from media_manager.torrent.schemas import Torrent + +router = APIRouter() + +# ----------------------------------------------------------------------------- +# METADATA & SEARCH +# ----------------------------------------------------------------------------- + + +@router.get( + "/search", + dependencies=[Depends(current_active_user)], +) +def search_for_author( + query: str, + book_service: book_service_dep, + metadata_provider: book_metadata_provider_dep, +) -> list[MetaDataProviderSearchResult]: + """ + Search for an author on the configured book metadata provider. + """ + return book_service.search_for_author( + query=query, metadata_provider=metadata_provider + ) + + +@router.get( + "/recommended", + dependencies=[Depends(current_active_user)], +) +def get_popular_authors( + book_service: book_service_dep, + metadata_provider: book_metadata_provider_dep, +) -> list[MetaDataProviderSearchResult]: + """ + Get a list of trending/popular authors from OpenLibrary. + """ + return book_service.get_popular_authors(metadata_provider=metadata_provider) + + +# ----------------------------------------------------------------------------- +# AUTHORS +# ----------------------------------------------------------------------------- + + +@router.get( + "/authors", + dependencies=[Depends(current_active_user)], +) +def get_all_authors(book_service: book_service_dep) -> list[Author]: + """ + Get all authors in the library. + """ + return book_service.get_all_authors() + + +@router.post( + "/authors", + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(current_active_user)], + responses={ + status.HTTP_201_CREATED: { + "model": Author, + "description": "Successfully created author", + } + }, +) +def add_an_author( + book_service: book_service_dep, + metadata_provider: book_metadata_provider_dep, + author_id: str, +) -> Author: + """ + Add a new author to the library. + """ + try: + author = book_service.add_author( + external_id=author_id, + metadata_provider=metadata_provider, + ) + except ConflictError: + author = book_service.get_author_by_external_id( + external_id=author_id, metadata_provider=metadata_provider.name + ) + if not author: + raise NotFoundError from ConflictError + return author + + +@router.get( + "/authors/torrents", + dependencies=[Depends(current_active_user)], +) +def get_all_authors_with_torrents( + book_service: book_service_dep, +) -> list[RichAuthorTorrent]: + """ + Get all authors that are associated with torrents. + """ + return book_service.get_all_authors_with_torrents() + + +@router.get( + "/authors/libraries", + dependencies=[Depends(current_active_user)], +) +def get_available_libraries() -> list[LibraryItem]: + """ + Get available book libraries from configuration. + """ + return MediaManagerConfig().misc.books_libraries + + +# ----------------------------------------------------------------------------- +# BOOK REQUESTS +# ----------------------------------------------------------------------------- + + +@router.get( + "/books/requests", + dependencies=[Depends(current_active_user)], +) +def get_all_book_requests( + book_service: book_service_dep, +) -> list[RichBookRequest]: + """ + Get all book requests. + """ + return book_service.get_all_book_requests() + + +@router.post( + "/books/requests", + status_code=status.HTTP_201_CREATED, +) +def create_book_request( + book_service: book_service_dep, + book_request: CreateBookRequest, + user: Annotated[UserRead, Depends(current_active_user)], +) -> BookRequest: + """ + Create a new book request. + """ + log.info( + f"User {user.email} is creating a book request for {book_request.book_id}" + ) + book_request: BookRequest = BookRequest.model_validate(book_request) + book_request.requested_by = user + if user.is_superuser: + book_request.authorized = True + book_request.authorized_by = user + + return book_service.add_book_request(book_request=book_request) + + +@router.put( + "/books/requests/{book_request_id}", +) +def update_book_request( + book_service: book_service_dep, + book_request_id: BookRequestId, + update_request: BookRequestBase, + user: Annotated[UserRead, Depends(current_active_user)], +) -> BookRequest: + """ + Update an existing book request. + """ + book_request = book_service.get_book_request_by_id( + book_request_id=book_request_id + ) + if book_request.requested_by.id != user.id or user.is_superuser: + book_request.min_quality = update_request.min_quality + book_request.wanted_quality = update_request.wanted_quality + + return book_service.update_book_request(book_request=book_request) + + +@router.patch( + "/books/requests/{book_request_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def authorize_book_request( + book_service: book_service_dep, + book_request_id: BookRequestId, + user: Annotated[UserRead, Depends(current_superuser)], + authorized_status: bool = False, +) -> None: + """ + Authorize or de-authorize a book request. + """ + book_request = book_service.get_book_request_by_id( + book_request_id=book_request_id + ) + book_request.authorized = authorized_status + if authorized_status: + book_request.authorized_by = user + else: + book_request.authorized_by = None + book_service.update_book_request(book_request=book_request) + + +@router.delete( + "/books/requests/{book_request_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(current_superuser)], +) +def delete_book_request( + book_service: book_service_dep, book_request_id: BookRequestId +) -> None: + """ + Delete a book request. + """ + book_service.delete_book_request(book_request_id=book_request_id) + + +# ----------------------------------------------------------------------------- +# AUTHORS - SINGLE RESOURCE +# ----------------------------------------------------------------------------- + + +@router.get( + "/authors/{author_id}", + dependencies=[Depends(current_active_user)], +) +def get_author_by_id( + book_service: book_service_dep, author: author_dep +) -> PublicAuthor: + """ + Get details for a specific author. + """ + return book_service.get_public_author_by_id(author=author) + + +@router.delete( + "/authors/{author_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(current_superuser)], +) +def delete_an_author( + book_service: book_service_dep, + author: author_dep, + delete_files_on_disk: bool = False, + delete_torrents: bool = False, +) -> None: + """ + Delete an author from the library. + """ + book_service.delete_author( + author=author, + delete_files_on_disk=delete_files_on_disk, + delete_torrents=delete_torrents, + ) + + +@router.post( + "/authors/{author_id}/metadata", + dependencies=[Depends(current_active_user)], +) +def update_author_metadata( + book_service: book_service_dep, + author: author_dep, + metadata_provider: book_metadata_provider_dep, +) -> PublicAuthor: + """ + Refresh metadata for an author from the metadata provider. + """ + book_service.update_author_metadata( + db_author=author, metadata_provider=metadata_provider + ) + updated_author = book_service.get_author_by_id(author_id=author.id) + return book_service.get_public_author_by_id(author=updated_author) + + +@router.post( + "/authors/{author_id}/library", + dependencies=[Depends(current_superuser)], + status_code=status.HTTP_204_NO_CONTENT, +) +def set_library( + author: author_dep, + book_service: book_service_dep, + library: str, +) -> None: + """ + Set the library path for an author. + """ + book_service.set_author_library(author=author, library=library) + + +@router.get( + "/authors/{author_id}/torrents", + dependencies=[Depends(current_active_user)], +) +def get_torrents_for_author( + book_service: book_service_dep, + author: author_dep, +) -> RichAuthorTorrent: + """ + Get torrents associated with an author. + """ + return book_service.get_torrents_for_author(author=author) + + +# ----------------------------------------------------------------------------- +# BOOKS - SINGLE RESOURCE +# ----------------------------------------------------------------------------- + + +@router.get( + "/books/{book_id}", + dependencies=[Depends(current_active_user)], +) +def get_book_by_id(book: book_dep) -> Book: + """ + Get details for a specific book. + """ + return Book.model_validate(book) + + +@router.get( + "/books/{book_id}/files", + dependencies=[Depends(current_active_user)], +) +def get_book_files( + book_service: book_service_dep, + book: book_dep, +) -> list[PublicBookFile]: + """ + Get files associated with a specific book. + """ + return book_service.get_public_book_files(book_id=book.id) + + +# ----------------------------------------------------------------------------- +# TORRENTS +# ----------------------------------------------------------------------------- + + +@router.get( + "/torrents", + status_code=status.HTTP_200_OK, + dependencies=[Depends(current_superuser)], +) +def search_torrents_for_book( + book_service: book_service_dep, + author_id: AuthorId, + book_name: str, + search_query_override: str | None = None, +) -> list[IndexerQueryResult]: + """ + Search for torrents for a specific book. + """ + author = book_service.get_author_by_id(author_id=author_id) + return book_service.get_all_available_torrents_for_book( + author=author, + book_name=book_name, + search_query_override=search_query_override, + ) + + +@router.post( + "/torrents", + status_code=status.HTTP_200_OK, + dependencies=[Depends(current_superuser)], +) +def download_a_torrent( + book_service: book_service_dep, + public_indexer_result_id: IndexerQueryResultId, + author_id: AuthorId, + book_id: BookId, + override_file_path_suffix: str = "", +) -> Torrent: + """ + Download a torrent for a specific book. + """ + author = book_service.get_author_by_id(author_id=author_id) + return book_service.download_torrent( + public_indexer_result_id=public_indexer_result_id, + author=author, + book_id=book_id, + override_file_path_suffix=override_file_path_suffix, + ) diff --git a/media_manager/books/schemas.py b/media_manager/books/schemas.py new file mode 100644 index 00000000..a6424649 --- /dev/null +++ b/media_manager/books/schemas.py @@ -0,0 +1,162 @@ +import enum +import typing +import uuid +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from media_manager.auth.schemas import UserRead +from media_manager.torrent.models import Quality +from media_manager.torrent.schemas import TorrentId, TorrentStatus + +AuthorId = typing.NewType("AuthorId", UUID) +BookId = typing.NewType("BookId", UUID) +BookRequestId = typing.NewType("BookRequestId", UUID) + + +class BookFormat(str, enum.Enum): + ebook = "ebook" + audiobook = "audiobook" + + +class Book(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: BookId = Field(default_factory=lambda: BookId(uuid.uuid4())) + external_id: str + name: str + year: int | None + format: BookFormat = BookFormat.ebook + isbn: str | None = None + publisher: str | None = None + page_count: int | None = None + + +class Author(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: AuthorId = Field(default_factory=lambda: AuthorId(uuid.uuid4())) + + name: str + overview: str + external_id: str + metadata_provider: str + + library: str = "Default" + + books: list[Book] + + +class BookRequestBase(BaseModel): + min_quality: Quality + wanted_quality: Quality + + @model_validator(mode="after") + def ensure_wanted_quality_is_eq_or_gt_min_quality(self) -> "BookRequestBase": + if self.min_quality.value < self.wanted_quality.value: + msg = "wanted_quality must be equal to or lower than minimum_quality." + raise ValueError(msg) + return self + + +class CreateBookRequest(BookRequestBase): + book_id: BookId + + +class UpdateBookRequest(BookRequestBase): + id: BookRequestId + + +class BookRequest(BookRequestBase): + model_config = ConfigDict(from_attributes=True) + + id: BookRequestId = Field( + default_factory=lambda: BookRequestId(uuid.uuid4()) + ) + + book_id: BookId + requested_by: UserRead | None = None + authorized: bool = False + authorized_by: UserRead | None = None + + +class RichBookRequest(BookRequest): + author: Author + book: Book + + +class BookFile(BaseModel): + model_config = ConfigDict(from_attributes=True) + + book_id: BookId + quality: Quality + torrent_id: TorrentId | None + file_path_suffix: str + + +class PublicBookFile(BookFile): + downloaded: bool = False + + +class BookTorrent(BaseModel): + model_config = ConfigDict(from_attributes=True) + + torrent_id: TorrentId + torrent_title: str + status: TorrentStatus + quality: Quality + imported: bool + file_path_suffix: str + usenet: bool + + +class PublicBook(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: BookId + external_id: str + name: str + year: int | None + format: BookFormat + isbn: str | None = None + publisher: str | None = None + page_count: int | None = None + + downloaded: bool = False + + +class PublicAuthor(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: AuthorId + + name: str + overview: str + external_id: str + metadata_provider: str + + library: str + + books: list[PublicBook] + + +class RichBookTorrent(BaseModel): + model_config = ConfigDict(from_attributes=True) + + torrent_id: TorrentId + torrent_title: str + status: TorrentStatus + quality: Quality + imported: bool + usenet: bool + + file_path_suffix: str + + +class RichAuthorTorrent(BaseModel): + model_config = ConfigDict(from_attributes=True) + + author_id: AuthorId + name: str + metadata_provider: str + torrents: list[RichBookTorrent] diff --git a/media_manager/books/service.py b/media_manager/books/service.py new file mode 100644 index 00000000..08fbc079 --- /dev/null +++ b/media_manager/books/service.py @@ -0,0 +1,562 @@ +import mimetypes +import shutil +from pathlib import Path + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from media_manager.books import log +from media_manager.books.repository import BookRepository +from media_manager.books.schemas import ( + Author, + AuthorId, + BookFile, + BookId, + BookRequest, + BookRequestId, + PublicAuthor, + PublicBookFile, + RichAuthorTorrent, + RichBookRequest, +) +from media_manager.config import MediaManagerConfig +from media_manager.database import SessionLocal, get_session +from media_manager.exceptions import NotFoundError +from media_manager.indexer.repository import IndexerRepository +from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId +from media_manager.indexer.service import IndexerService +from media_manager.metadataProvider.abstract_book_metadata_provider import ( + AbstractBookMetadataProvider, +) +from media_manager.metadataProvider.openlibrary import OpenLibraryMetadataProvider +from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult +from media_manager.notification.repository import NotificationRepository +from media_manager.notification.service import NotificationService +from media_manager.torrent.repository import TorrentRepository +from media_manager.torrent.schemas import ( + QualityStrings, + Torrent, + TorrentStatus, +) +from media_manager.torrent.service import TorrentService +from media_manager.torrent.utils import ( + import_file, + list_files_recursively, + remove_special_characters, +) + +BOOK_EXTENSIONS = { + ".epub", ".mobi", ".pdf", ".azw3", ".fb2", ".cbz", ".cbr", + ".m4b", ".mp3", ".m4a", ".ogg", ".flac", +} + + +class BookService: + def __init__( + self, + book_repository: BookRepository, + torrent_service: TorrentService, + indexer_service: IndexerService, + notification_service: NotificationService, + ) -> None: + self.book_repository = book_repository + self.torrent_service = torrent_service + self.indexer_service = indexer_service + self.notification_service = notification_service + + def add_author( + self, + external_id: str, + metadata_provider: AbstractBookMetadataProvider, + ) -> Author: + author_with_metadata = metadata_provider.get_author_metadata( + author_id=external_id + ) + if not author_with_metadata: + raise NotFoundError + + saved_author = self.book_repository.save_author(author=author_with_metadata) + metadata_provider.download_author_cover_image(author=saved_author) + return saved_author + + def add_book_request(self, book_request: BookRequest) -> BookRequest: + return self.book_repository.add_book_request(book_request=book_request) + + def get_book_request_by_id( + self, book_request_id: BookRequestId + ) -> BookRequest: + return self.book_repository.get_book_request( + book_request_id=book_request_id + ) + + def update_book_request(self, book_request: BookRequest) -> BookRequest: + self.book_repository.delete_book_request( + book_request_id=book_request.id + ) + return self.book_repository.add_book_request(book_request=book_request) + + def delete_book_request(self, book_request_id: BookRequestId) -> None: + self.book_repository.delete_book_request( + book_request_id=book_request_id + ) + + def delete_author( + self, + author: Author, + delete_files_on_disk: bool = False, + delete_torrents: bool = False, + ) -> None: + if delete_files_on_disk or delete_torrents: + if delete_files_on_disk: + author_dir = self.get_author_root_path(author=author) + if author_dir.exists() and author_dir.is_dir(): + try: + shutil.rmtree(author_dir) + log.info(f"Deleted author directory: {author_dir}") + except OSError: + log.exception(f"Deleting author directory: {author_dir}") + + if delete_torrents: + author_torrents = self.book_repository.get_torrents_by_author_id( + author_id=author.id + ) + for book_torrent in author_torrents: + torrent = self.torrent_service.get_torrent_by_id( + torrent_id=book_torrent.torrent_id + ) + try: + self.torrent_service.cancel_download( + torrent=torrent, delete_files=True + ) + log.info(f"Deleted torrent: {torrent.title}") + except Exception: + log.warning( + f"Failed to delete torrent {torrent.hash}", + exc_info=True, + ) + + self.book_repository.delete_author(author_id=author.id) + + def get_public_book_files( + self, book_id: BookId + ) -> list[PublicBookFile]: + book_files = self.book_repository.get_book_files_by_book_id( + book_id=book_id + ) + public_book_files = [PublicBookFile.model_validate(x) for x in book_files] + result = [] + for book_file in public_book_files: + book_file.downloaded = self.book_file_exists_on_disk( + book_file=book_file + ) + result.append(book_file) + return result + + def book_file_exists_on_disk(self, book_file: BookFile) -> bool: + if book_file.torrent_id is None: + return True + torrent_file = self.torrent_service.get_torrent_by_id( + torrent_id=book_file.torrent_id + ) + return torrent_file.imported + + def get_all_authors(self) -> list[Author]: + return self.book_repository.get_authors() + + def get_popular_authors( + self, metadata_provider: AbstractBookMetadataProvider + ) -> list[MetaDataProviderSearchResult]: + results = metadata_provider.search_author() + return [ + result + for result in results + if not self._check_if_author_exists( + external_id=str(result.external_id), + metadata_provider=metadata_provider.name, + ) + ] + + def _check_if_author_exists( + self, external_id: str, metadata_provider: str + ) -> bool: + try: + self.book_repository.get_author_by_external_id( + external_id=external_id, metadata_provider=metadata_provider + ) + return True + except NotFoundError: + return False + + def search_for_author( + self, query: str, metadata_provider: AbstractBookMetadataProvider + ) -> list[MetaDataProviderSearchResult]: + results = metadata_provider.search_author(query) + for result in results: + try: + author = self.book_repository.get_author_by_external_id( + external_id=str(result.external_id), + metadata_provider=metadata_provider.name, + ) + result.added = True + result.id = author.id + except NotFoundError: + pass + except Exception: + log.error( + f"Unable to find internal author ID for {result.external_id} on {metadata_provider.name}" + ) + return results + + def get_public_author_by_id(self, author: Author) -> PublicAuthor: + public_author = PublicAuthor.model_validate(author) + for book in public_author.books: + book_files = self.book_repository.get_book_files_by_book_id( + book_id=book.id + ) + book.downloaded = len(book_files) > 0 + return public_author + + def get_author_by_id(self, author_id: AuthorId) -> Author: + return self.book_repository.get_author_by_id(author_id=author_id) + + def get_author_by_external_id( + self, external_id: str, metadata_provider: str + ) -> Author | None: + return self.book_repository.get_author_by_external_id( + external_id=external_id, metadata_provider=metadata_provider + ) + + def get_all_book_requests(self) -> list[RichBookRequest]: + return self.book_repository.get_book_requests() + + def set_author_library(self, author: Author, library: str) -> None: + self.book_repository.set_author_library( + author_id=author.id, library=library + ) + + def get_torrents_for_author(self, author: Author) -> RichAuthorTorrent: + author_torrents = self.book_repository.get_torrents_by_author_id( + author_id=author.id + ) + return RichAuthorTorrent( + author_id=author.id, + name=author.name, + metadata_provider=author.metadata_provider, + torrents=author_torrents, + ) + + def get_all_authors_with_torrents(self) -> list[RichAuthorTorrent]: + authors = self.book_repository.get_all_authors_with_torrents() + return [self.get_torrents_for_author(author=author) for author in authors] + + def get_all_available_torrents_for_book( + self, + author: Author, + book_name: str, + search_query_override: str | None = None, + ) -> list[IndexerQueryResult]: + return self.indexer_service.search_book( + author=author, + book_name=book_name, + search_query_override=search_query_override, + ) + + def download_torrent( + self, + public_indexer_result_id: IndexerQueryResultId, + author: Author, + book_id: BookId, + override_file_path_suffix: str = "", + ) -> Torrent: + indexer_result = self.indexer_service.get_result( + result_id=public_indexer_result_id + ) + book_torrent = self.torrent_service.download(indexer_result=indexer_result) + self.torrent_service.pause_download(torrent=book_torrent) + book_file = BookFile( + book_id=book_id, + quality=indexer_result.quality, + torrent_id=book_torrent.id, + file_path_suffix=override_file_path_suffix, + ) + try: + self.book_repository.add_book_file(book_file=book_file) + except IntegrityError: + log.warning( + f"Book file for author {author.name} and torrent {book_torrent.title} already exists" + ) + self.torrent_service.cancel_download( + torrent=book_torrent, delete_files=True + ) + raise + else: + log.info( + f"Added book file for author {author.name} and torrent {book_torrent.title}" + ) + self.torrent_service.resume_download(torrent=book_torrent) + return book_torrent + + def download_approved_book_request( + self, book_request: BookRequest, author: Author, book_name: str + ) -> bool: + if not book_request.authorized: + msg = "Book request is not authorized" + raise ValueError(msg) + + log.info(f"Downloading approved book request {book_request.id}") + + torrents = self.get_all_available_torrents_for_book( + author=author, book_name=book_name + ) + available_torrents: list[IndexerQueryResult] = [] + + for torrent in torrents: + if torrent.seeders < 3: + log.debug( + f"Skipping torrent {torrent.title} for book request {book_request.id}, too few seeders" + ) + else: + available_torrents.append(torrent) + + if len(available_torrents) == 0: + log.warning( + f"No torrents found for book request {book_request.id}" + ) + return False + + available_torrents.sort() + + torrent = self.torrent_service.download(indexer_result=available_torrents[0]) + book_file = BookFile( + book_id=book_request.book_id, + quality=torrent.quality, + torrent_id=torrent.id, + file_path_suffix=QualityStrings[torrent.quality.name].value.upper(), + ) + try: + self.book_repository.add_book_file(book_file=book_file) + except IntegrityError: + log.warning( + f"Book file for torrent {torrent.title} already exists" + ) + self.delete_book_request(book_request.id) + return True + + def get_author_root_path(self, author: Author) -> Path: + misc_config = MediaManagerConfig().misc + author_dir_name = remove_special_characters(author.name) + author_file_path = misc_config.books_directory / author_dir_name + + if author.library != "Default": + for library in misc_config.books_libraries: + if library.name == author.library: + log.debug( + f"Using library {library.name} for author {author.name}" + ) + return Path(library.path) / author_dir_name + log.warning( + f"Library {author.library} not found in config, using default library" + ) + return author_file_path + + def import_book_files(self, torrent: Torrent, author: Author) -> None: + from media_manager.torrent.utils import get_torrent_filepath + + torrent_path = get_torrent_filepath(torrent=torrent) + all_files = list_files_recursively(path=torrent_path) + + book_files = [ + f + for f in all_files + if f.suffix.lower() in BOOK_EXTENSIONS + or (mimetypes.guess_type(str(f))[0] or "").startswith("application/epub") + ] + + if not book_files: + log.warning( + f"No book files found in torrent {torrent.title} for author {author.name}" + ) + return + + log.info( + f"Found {len(book_files)} book files for import from torrent {torrent.title}" + ) + + author_root = self.get_author_root_path(author=author) + book_dir = author_root / remove_special_characters(torrent.title) + + try: + book_dir.mkdir(parents=True, exist_ok=True) + except Exception: + log.exception(f"Failed to create directory {book_dir}") + return + + success = True + for book_file in book_files: + target_file = book_dir / book_file.name + try: + import_file(target_file=target_file, source_file=book_file) + except Exception: + log.exception(f"Failed to import book file {book_file}") + success = False + + if success: + torrent.imported = True + self.torrent_service.torrent_repository.save_torrent(torrent=torrent) + + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Book Downloaded", + message=f"Book for {author.name} has been successfully downloaded and imported.", + ) + else: + log.error( + f"Failed to import some files for torrent {torrent.title}." + ) + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Import Failed", + message=f"Failed to import some book files for {author.name}. Please check logs.", + ) + + def update_author_metadata( + self, + db_author: Author, + metadata_provider: AbstractBookMetadataProvider, + ) -> Author | None: + log.debug(f"Found author: {db_author.name} for metadata update.") + + fresh_author_data = metadata_provider.get_author_metadata( + author_id=db_author.external_id + ) + if not fresh_author_data: + log.warning( + f"Could not fetch fresh metadata for author: {db_author.name} (ID: {db_author.external_id})" + ) + return None + + self.book_repository.update_author_attributes( + author_id=db_author.id, + name=fresh_author_data.name, + overview=fresh_author_data.overview, + ) + + updated_author = self.book_repository.get_author_by_id( + author_id=db_author.id + ) + log.info(f"Successfully updated metadata for author ID: {db_author.id}") + metadata_provider.download_author_cover_image(author=updated_author) + return updated_author + + +def auto_download_all_approved_book_requests() -> None: + db: Session = SessionLocal() if SessionLocal else next(get_session()) + book_repository = BookRepository(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) + ) + book_service = BookService( + book_repository=book_repository, + torrent_service=torrent_service, + indexer_service=indexer_service, + notification_service=notification_service, + ) + + log.info("Auto downloading all approved book requests") + book_requests = book_repository.get_book_requests() + log.info(f"Found {len(book_requests)} book requests to process") + count = 0 + + for book_request in book_requests: + if book_request.authorized: + book = book_repository.get_book(book_id=book_request.book_id) + author = book_repository.get_author_by_id( + author_id=AuthorId(book_request.author.id) + ) + if book_service.download_approved_book_request( + book_request=book_request, + author=author, + book_name=book.name, + ): + count += 1 + else: + log.info( + f"Could not download book request {book_request.id}" + ) + + log.info(f"Auto downloaded {count} approved book requests") + db.commit() + db.close() + + +def import_all_book_torrents() -> None: + with next(get_session()) as db: + book_repository = BookRepository(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) + ) + book_service = BookService( + book_repository=book_repository, + torrent_service=torrent_service, + indexer_service=indexer_service, + notification_service=notification_service, + ) + log.info("Importing all book torrents") + torrents = torrent_service.get_all_torrents() + for t in torrents: + try: + if not t.imported and t.status == TorrentStatus.finished: + author = book_repository.get_author_by_torrent_id( + torrent_id=t.id + ) + if author is None: + continue + book_service.import_book_files(torrent=t, author=author) + except RuntimeError: + log.exception(f"Failed to import torrent {t.title}") + log.info("Finished importing all book torrents") + db.commit() + + +def update_all_book_authors_metadata() -> None: + with next(get_session()) as db: + book_repository = BookRepository(db=db) + book_service = BookService( + book_repository=book_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 book authors") + authors = book_repository.get_authors() + log.info(f"Found {len(authors)} book authors to update") + + for author in authors: + try: + if author.metadata_provider == "openlibrary": + metadata_provider = OpenLibraryMetadataProvider() + else: + log.error( + f"Unsupported metadata provider {author.metadata_provider} for author {author.name}, skipping update." + ) + continue + except Exception: + log.exception( + f"Error initializing metadata provider {author.metadata_provider} for author {author.name}", + ) + continue + book_service.update_author_metadata( + db_author=author, metadata_provider=metadata_provider + ) + db.commit() diff --git a/media_manager/config.py b/media_manager/config.py index 7e2170b7..b25f33e9 100644 --- a/media_manager/config.py +++ b/media_manager/config.py @@ -38,6 +38,8 @@ class BasicConfig(BaseSettings): image_directory: Path = Path(__file__).parent.parent / "data" / "images" tv_directory: Path = Path(__file__).parent.parent / "data" / "tv" movie_directory: Path = Path(__file__).parent.parent / "data" / "movies" + music_directory: Path = Path(__file__).parent.parent / "data" / "music" + books_directory: Path = Path(__file__).parent.parent / "data" / "books" torrent_directory: Path = Path(__file__).parent.parent / "data" / "torrents" frontend_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000") @@ -46,6 +48,8 @@ class BasicConfig(BaseSettings): tv_libraries: list[LibraryItem] = [] movie_libraries: list[LibraryItem] = [] + music_libraries: list[LibraryItem] = [] + books_libraries: list[LibraryItem] = [] class MediaManagerConfig(BaseSettings): diff --git a/media_manager/filesystem_checks.py b/media_manager/filesystem_checks.py index 12b41969..6580c3a7 100644 --- a/media_manager/filesystem_checks.py +++ b/media_manager/filesystem_checks.py @@ -9,6 +9,8 @@ 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) + config.misc.music_directory.mkdir(parents=True, exist_ok=True) + config.misc.books_directory.mkdir(parents=True, exist_ok=True) config.misc.torrent_directory.mkdir(parents=True, exist_ok=True) config.misc.image_directory.mkdir(parents=True, exist_ok=True) log.info("Conducting filesystem tests...") diff --git a/media_manager/indexer/indexers/generic.py b/media_manager/indexer/indexers/generic.py index f4abe1b2..856cac88 100644 --- a/media_manager/indexer/indexers/generic.py +++ b/media_manager/indexer/indexers/generic.py @@ -1,7 +1,9 @@ from abc import ABC, abstractmethod +from media_manager.books.schemas import Author from media_manager.indexer.schemas import IndexerQueryResult from media_manager.movies.schemas import Movie +from media_manager.music.schemas import Artist from media_manager.tv.schemas import Show @@ -46,3 +48,25 @@ def search_movie(self, query: str, movie: Movie) -> list[IndexerQueryResult]: :return: A list of IndexerQueryResult objects representing the search results. """ raise NotImplementedError() + + @abstractmethod + def search_music(self, query: str, artist: Artist) -> list[IndexerQueryResult]: + """ + Sends a search request to the Indexer for music and returns the results. + + :param query: A string representing the search query (typically "artist album"). + :param artist: The artist to search for. + :return: A list of IndexerQueryResult objects representing the search results. + """ + raise NotImplementedError() + + @abstractmethod + def search_book(self, query: str, author: Author) -> list[IndexerQueryResult]: + """ + Sends a search request to the Indexer for books and returns the results. + + :param query: A string representing the search query (typically "author book"). + :param author: The author to search for. + :return: A list of IndexerQueryResult objects representing the search results. + """ + raise NotImplementedError() diff --git a/media_manager/indexer/indexers/jackett.py b/media_manager/indexer/indexers/jackett.py index b6fc1257..cffec22e 100644 --- a/media_manager/indexer/indexers/jackett.py +++ b/media_manager/indexer/indexers/jackett.py @@ -7,11 +7,13 @@ import requests +from media_manager.books.schemas import Author from media_manager.config import MediaManagerConfig from media_manager.indexer.indexers.generic import GenericIndexer from media_manager.indexer.indexers.torznab_mixin import TorznabMixin from media_manager.indexer.schemas import IndexerQueryResult from media_manager.movies.schemas import Movie +from media_manager.music.schemas import Artist from media_manager.tv.schemas import Show log = logging.getLogger(__name__) @@ -31,6 +33,9 @@ class IndexerInfo: supports_movie_search_imdb: bool supports_movie_search_tvdb: bool + supports_music_search: bool + supports_book_search: bool + class Jackett(GenericIndexer, TorznabMixin): def __init__(self) -> None: @@ -90,8 +95,10 @@ def __get_search_capabilities( xml_tree = ET.fromstring(xml) # noqa: S314 # trusted source, since it is user controlled tv_search = xml_tree.find("./*/tv-search") movie_search = xml_tree.find("./*/movie-search") - log.debug(tv_search.attrib) - log.debug(movie_search.attrib) + music_search = xml_tree.find("./*/music-search") + book_search = xml_tree.find("./*/book-search") + log.debug(tv_search.attrib if tv_search is not None else "no tv-search") + log.debug(movie_search.attrib if movie_search is not None else "no movie-search") tv_search_capabilities = [] movie_search_capabilities = [] @@ -101,6 +108,12 @@ def __get_search_capabilities( movie_search_available = (movie_search is not None) and ( movie_search.attrib["available"] == "yes" ) + music_search_available = (music_search is not None) and ( + music_search.attrib.get("available") == "yes" + ) + book_search_available = (book_search is not None) and ( + book_search.attrib.get("available") == "yes" + ) if tv_search_available: tv_search_capabilities = tv_search.attrib["supportedParams"].split(",") @@ -121,6 +134,8 @@ def __get_search_capabilities( supports_movie_search_imdb="imdbid" in movie_search_capabilities, supports_movie_search_tmdb="tmdbid" in movie_search_capabilities, supports_movie_search_tvdb="tvdbid" in movie_search_capabilities, + supports_music_search=music_search_available, + supports_book_search=book_search_available, ) def __get_optimal_query_parameters( @@ -159,6 +174,19 @@ def __get_optimal_query_parameters( query_params["tmdbid"] = params["tmdbid"] else: query_params["q"] = params["q"] + if params["t"] == "music": + if not search_capabilities.supports_music_search: + msg = f"Indexer {indexer} does not support Music search" + raise RuntimeError(msg) + query_params["q"] = params["q"] + query_params["cat"] = "3000" + if params["t"] == "book": + if not search_capabilities.supports_book_search: + msg = f"Indexer {indexer} does not support Book search" + raise RuntimeError(msg) + query_params["q"] = params["q"] + if params["t"] == "search": + query_params["q"] = params["q"] return query_params def get_torrents_by_indexer( @@ -206,3 +234,28 @@ def search_movie(self, query: str, movie: Movie) -> list[IndexerQueryResult]: params["imdbid"] = movie.imdb_id params[movie.metadata_provider + "id"] = movie.external_id return self.__search_jackett(params=params) + + def search_music(self, query: str, artist: Artist) -> list[IndexerQueryResult]: + log.debug(f"Searching for music: {query}") + params = { + "t": "music", + "q": query, + } + return self.__search_jackett(params=params) + + def search_book(self, query: str, author: Author) -> list[IndexerQueryResult]: + log.debug(f"Searching for book: {query}") + # Search with t=book for book-capable indexers + book_params = { + "t": "book", + "q": query, + } + results = self.__search_jackett(params=book_params) + # Also do a generic text search to catch results from indexers + # that don't advertise book search capability + generic_params = { + "t": "search", + "q": query, + } + results.extend(self.__search_jackett(params=generic_params)) + return results diff --git a/media_manager/indexer/indexers/prowlarr.py b/media_manager/indexer/indexers/prowlarr.py index 1ef46036..50c66877 100644 --- a/media_manager/indexer/indexers/prowlarr.py +++ b/media_manager/indexer/indexers/prowlarr.py @@ -3,11 +3,13 @@ from requests import Response, Session +from media_manager.books.schemas import Author from media_manager.config import MediaManagerConfig from media_manager.indexer.indexers.generic import GenericIndexer from media_manager.indexer.indexers.torznab_mixin import TorznabMixin from media_manager.indexer.schemas import IndexerQueryResult from media_manager.movies.schemas import Movie +from media_manager.music.schemas import Artist from media_manager.tv.schemas import Show log = logging.getLogger(__name__) @@ -29,6 +31,9 @@ class IndexerInfo: supports_movie_search_imdb: bool supports_movie_search_tvdb: bool + supports_music_search: bool + supports_book_search: bool + class Prowlarr(GenericIndexer, TorznabMixin): def __init__(self) -> None: @@ -87,6 +92,9 @@ def _get_indexers(self) -> list[IndexerInfo]: supports_movie_search = True movie_search_params = indexer["capabilities"]["movieSearchParams"] + supports_music_search = indexer["capabilities"].get("musicSearchParams") is not None + supports_book_search = indexer["capabilities"].get("bookSearchParams") is not None + indexer_info = IndexerInfo( id=indexer["id"], name=indexer.get("name", "unknown"), @@ -99,6 +107,8 @@ def _get_indexers(self) -> list[IndexerInfo]: supports_movie_search_tmdb="tmdbId" in movie_search_params, supports_movie_search_imdb="imdbId" in movie_search_params, supports_movie_search_tvdb="tvdbId" in movie_search_params, + supports_music_search=supports_music_search, + supports_book_search=supports_book_search, ) indexer_info_list.append(indexer_info) return indexer_info_list @@ -109,6 +119,12 @@ def _get_tv_indexers(self) -> list[IndexerInfo]: def _get_movie_indexers(self) -> list[IndexerInfo]: return [x for x in self._get_indexers() if x.supports_movie_search] + def _get_music_indexers(self) -> list[IndexerInfo]: + return [x for x in self._get_indexers() if x.supports_music_search] + + def _get_book_indexers(self) -> list[IndexerInfo]: + return [x for x in self._get_indexers() if x.supports_book_search] + def search(self, query: str, is_tv: bool) -> list[IndexerQueryResult]: log.info(f"Searching for: {query}") params = { @@ -179,3 +195,60 @@ def search_movie(self, query: str, movie: Movie) -> list[IndexerQueryResult]: ) return raw_results + + def search_music(self, query: str, artist: Artist) -> list[IndexerQueryResult]: + indexers = self._get_music_indexers() + + raw_results = [] + + for indexer in indexers: + log.debug("Preparing music search for indexer: " + indexer.name) + + search_params = { + "cat": "3000", + "q": query, + "t": "music", + } + + raw_results.extend( + self._newznab_search(parameters=search_params, indexer=indexer) + ) + + return raw_results + + def search_book(self, query: str, author: Author) -> list[IndexerQueryResult]: + raw_results = [] + + # Search book-capable indexers with t=book + book_indexers = self._get_book_indexers() + for indexer in book_indexers: + log.debug("Preparing book search for indexer: " + indexer.name) + + search_params = { + "q": query, + "t": "book", + } + + raw_results.extend( + self._newznab_search(parameters=search_params, indexer=indexer) + ) + + # Also do a generic text search across all indexers to catch results + # from indexers that don't advertise book search capability + book_indexer_ids = {i.id for i in book_indexers} + all_indexers = self._get_indexers() + for indexer in all_indexers: + if indexer.id in book_indexer_ids: + continue + log.debug("Preparing generic book search for indexer: " + indexer.name) + + search_params = { + "q": query, + "t": "search", + } + + raw_results.extend( + self._newznab_search(parameters=search_params, indexer=indexer) + ) + + return raw_results diff --git a/media_manager/indexer/service.py b/media_manager/indexer/service.py index 7b1976bd..089901b1 100644 --- a/media_manager/indexer/service.py +++ b/media_manager/indexer/service.py @@ -6,7 +6,9 @@ from media_manager.indexer.indexers.prowlarr import Prowlarr from media_manager.indexer.repository import IndexerRepository from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId +from media_manager.books.schemas import Author from media_manager.movies.schemas import Movie +from media_manager.music.schemas import Artist from media_manager.torrent.utils import remove_special_chars_and_parentheses from media_manager.tv.schemas import Show @@ -96,3 +98,47 @@ def search_season(self, show: Show, season_number: int) -> list[IndexerQueryResu self.repository.save_result(result=result) return results + + def search_music( + self, artist: Artist, album_name: str, search_query_override: str | None = None + ) -> list[IndexerQueryResult]: + query = search_query_override or f"{artist.name} {album_name}" + query = remove_special_chars_and_parentheses(query) + + results = [] + for indexer in self.indexers: + try: + indexer_results = indexer.search_music(query=query, artist=artist) + if indexer_results: + results.extend(indexer_results) + except Exception: + log.exception( + f"Indexer {indexer.__class__.__name__} failed for music search '{query}'" + ) + + for result in results: + self.repository.save_result(result=result) + + return results + + def search_book( + self, author: Author, book_name: str, search_query_override: str | None = None + ) -> list[IndexerQueryResult]: + query = search_query_override or f"{author.name} {book_name}" + query = remove_special_chars_and_parentheses(query) + + results = [] + for indexer in self.indexers: + try: + indexer_results = indexer.search_book(query=query, author=author) + if indexer_results: + results.extend(indexer_results) + except Exception: + log.exception( + f"Indexer {indexer.__class__.__name__} failed for book search '{query}'" + ) + + for result in results: + self.repository.save_result(result=result) + + return results diff --git a/media_manager/main.py b/media_manager/main.py index ceed7c30..8e1d0338 100644 --- a/media_manager/main.py +++ b/media_manager/main.py @@ -11,7 +11,9 @@ from starlette.responses import FileResponse, RedirectResponse from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware +import media_manager.books.router as books_router import media_manager.movies.router as movies_router +import media_manager.music.router as music_router import media_manager.torrent.router as torrent_router import media_manager.tv.router as tv_router from media_manager.auth.router import ( @@ -116,6 +118,8 @@ async def hello_world() -> dict: api_app.include_router(tv_router.router, prefix="/tv", tags=["tv"]) api_app.include_router(torrent_router.router, prefix="/torrent", tags=["torrent"]) api_app.include_router(movies_router.router, prefix="/movies", tags=["movie"]) +api_app.include_router(music_router.router, prefix="/music", tags=["music"]) +api_app.include_router(books_router.router, prefix="/books", tags=["books"]) api_app.include_router( notification_router, prefix="/notification", tags=["notification"] ) diff --git a/media_manager/metadataProvider/abstract_book_metadata_provider.py b/media_manager/metadataProvider/abstract_book_metadata_provider.py new file mode 100644 index 00000000..8d59b324 --- /dev/null +++ b/media_manager/metadataProvider/abstract_book_metadata_provider.py @@ -0,0 +1,49 @@ +import logging +from abc import ABC, abstractmethod + +from media_manager.books.schemas import Author, Book +from media_manager.config import MediaManagerConfig +from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult + +log = logging.getLogger(__name__) + + +class AbstractBookMetadataProvider(ABC): + storage_path = MediaManagerConfig().misc.image_directory + + @property + @abstractmethod + def name(self) -> str: + pass + + @abstractmethod + def get_author_metadata(self, author_id: str) -> Author: + raise NotImplementedError() + + @abstractmethod + def search_author( + self, query: str | None = None + ) -> list[MetaDataProviderSearchResult]: + raise NotImplementedError() + + @abstractmethod + def search_book(self, query: str) -> list[MetaDataProviderSearchResult]: + raise NotImplementedError() + + @abstractmethod + def download_author_cover_image(self, author: Author) -> bool: + """ + Downloads the cover image for an author. + :param author: The author to download the cover image for. + :return: True if the image was downloaded successfully, False otherwise. + """ + raise NotImplementedError() + + @abstractmethod + def download_book_cover_image(self, book: Book) -> bool: + """ + Downloads the cover image for a book. + :param book: The book to download the cover image for. + :return: True if the image was downloaded successfully, False otherwise. + """ + raise NotImplementedError() diff --git a/media_manager/metadataProvider/abstract_music_metadata_provider.py b/media_manager/metadataProvider/abstract_music_metadata_provider.py new file mode 100644 index 00000000..53c0e8af --- /dev/null +++ b/media_manager/metadataProvider/abstract_music_metadata_provider.py @@ -0,0 +1,36 @@ +import logging +from abc import ABC, abstractmethod + +from media_manager.config import MediaManagerConfig +from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult +from media_manager.music.schemas import Artist + +log = logging.getLogger(__name__) + + +class AbstractMusicMetadataProvider(ABC): + storage_path = MediaManagerConfig().misc.image_directory + + @property + @abstractmethod + def name(self) -> str: + pass + + @abstractmethod + def get_artist_metadata(self, artist_id: str) -> Artist: + raise NotImplementedError() + + @abstractmethod + def search_artist( + self, query: str | None = None + ) -> list[MetaDataProviderSearchResult]: + raise NotImplementedError() + + @abstractmethod + def download_artist_cover_image(self, artist: Artist) -> bool: + """ + Downloads the cover image for an artist. + :param artist: The artist to download the cover image for. + :return: True if the image was downloaded successfully, False otherwise. + """ + raise NotImplementedError() diff --git a/media_manager/metadataProvider/book_dependencies.py b/media_manager/metadataProvider/book_dependencies.py new file mode 100644 index 00000000..531b82b3 --- /dev/null +++ b/media_manager/metadataProvider/book_dependencies.py @@ -0,0 +1,25 @@ +from typing import Annotated, Literal + +from fastapi import Depends +from fastapi.exceptions import HTTPException + +from media_manager.metadataProvider.abstract_book_metadata_provider import ( + AbstractBookMetadataProvider, +) +from media_manager.metadataProvider.openlibrary import OpenLibraryMetadataProvider + + +def get_book_metadata_provider( + metadata_provider: Literal["openlibrary"] = "openlibrary", +) -> AbstractBookMetadataProvider: + if metadata_provider == "openlibrary": + return OpenLibraryMetadataProvider() + raise HTTPException( + status_code=400, + detail=f"Invalid book metadata provider: {metadata_provider}. Supported providers are 'openlibrary'.", + ) + + +book_metadata_provider_dep = Annotated[ + AbstractBookMetadataProvider, Depends(get_book_metadata_provider) +] diff --git a/media_manager/metadataProvider/config.py b/media_manager/metadataProvider/config.py index 4f07b469..b198897d 100644 --- a/media_manager/metadataProvider/config.py +++ b/media_manager/metadataProvider/config.py @@ -11,6 +11,16 @@ class TvdbConfig(BaseSettings): tvdb_relay_url: str = "https://metadata-relay.dorninger.co/tvdb" +class MusicBrainzConfig(BaseSettings): + enabled: bool = True + + +class OpenLibraryConfig(BaseSettings): + enabled: bool = True + + class MetadataProviderConfig(BaseSettings): tvdb: TvdbConfig = TvdbConfig() tmdb: TmdbConfig = TmdbConfig() + musicbrainz: MusicBrainzConfig = MusicBrainzConfig() + openlibrary: OpenLibraryConfig = OpenLibraryConfig() diff --git a/media_manager/metadataProvider/music_dependencies.py b/media_manager/metadataProvider/music_dependencies.py new file mode 100644 index 00000000..be35fb49 --- /dev/null +++ b/media_manager/metadataProvider/music_dependencies.py @@ -0,0 +1,25 @@ +from typing import Annotated, Literal + +from fastapi import Depends +from fastapi.exceptions import HTTPException + +from media_manager.metadataProvider.abstract_music_metadata_provider import ( + AbstractMusicMetadataProvider, +) +from media_manager.metadataProvider.musicbrainz import MusicBrainzMetadataProvider + + +def get_music_metadata_provider( + metadata_provider: Literal["musicbrainz"] = "musicbrainz", +) -> AbstractMusicMetadataProvider: + if metadata_provider == "musicbrainz": + return MusicBrainzMetadataProvider() + raise HTTPException( + status_code=400, + detail=f"Invalid music metadata provider: {metadata_provider}. Supported providers are 'musicbrainz'.", + ) + + +music_metadata_provider_dep = Annotated[ + AbstractMusicMetadataProvider, Depends(get_music_metadata_provider) +] diff --git a/media_manager/metadataProvider/musicbrainz.py b/media_manager/metadataProvider/musicbrainz.py new file mode 100644 index 00000000..3ce3577a --- /dev/null +++ b/media_manager/metadataProvider/musicbrainz.py @@ -0,0 +1,336 @@ +import logging +import time +from typing import override + +import musicbrainzngs +import requests + +import media_manager.metadataProvider.utils +from media_manager.metadataProvider.abstract_music_metadata_provider import ( + AbstractMusicMetadataProvider, +) +from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult +from media_manager.music.schemas import Album, Artist, Track +from media_manager.notification.manager import notification_manager + +log = logging.getLogger(__name__) + +# MusicBrainz requires a user agent string +musicbrainzngs.set_useragent("MediaManager", "0.1.0", "https://github.com/maxdorninger/MediaManager") + +# Cache: artist_mbid -> (cover_art_url, timestamp). TTL = 1 week. +_cover_art_cache: dict[str, tuple[str | None, float]] = {} +_COVER_ART_CACHE_TTL = 604800 + +# Cache the full trending response so repeated dashboard loads are instant. +# TTL = 1 hour (trending data changes slowly). +_trending_cache: tuple[list[MetaDataProviderSearchResult], float] | None = None +_TRENDING_CACHE_TTL = 3600 + + +class MusicBrainzMetadataProvider(AbstractMusicMetadataProvider): + name = "musicbrainz" + + @staticmethod + def __get_artist_cover_art_url(artist_mbid: str) -> str | None: + """Get a cover art URL for an artist by finding their first album. + + Results are cached in memory for 1 week to avoid repeated + MusicBrainz API calls (rate-limited to 1 req/sec). + """ + cached = _cover_art_cache.get(artist_mbid) + if cached and (time.monotonic() - cached[1]) < _COVER_ART_CACHE_TTL: + return cached[0] + + url = None + try: + result = musicbrainzngs.browse_release_groups( + artist=artist_mbid, release_type=["album"], limit=1 + ) + release_groups = result.get("release-group-list", []) + if release_groups: + rg_id = release_groups[0]["id"] + url = f"https://coverartarchive.org/release-group/{rg_id}/front-250" + except musicbrainzngs.WebServiceError: + log.debug(f"Failed to get release group for artist {artist_mbid}") + + _cover_art_cache[artist_mbid] = (url, time.monotonic()) + return url + + def __get_trending_artists(self) -> list[MetaDataProviderSearchResult]: + """Fetch trending artists from the ListenBrainz sitewide statistics API. + + The full enriched result is cached for 1 hour so repeated + dashboard loads are instant after the first request. + """ + global _trending_cache + if _trending_cache and (time.monotonic() - _trending_cache[1]) < _TRENDING_CACHE_TTL: + # Re-enrich on cache hit: cover art lookups that were skipped + # (due to max_new_lookups) on the first call can now be filled in + # progressively — already-cached URLs are free hits. + self.__enrich_with_cover_art(_trending_cache[0]) + # Return deep copies so the service layer can mutate (e.g. set added=True) + # without affecting the cache + return [r.model_copy() for r in _trending_cache[0]] + + try: + response = requests.get( + url="https://api.listenbrainz.org/1/stats/sitewide/artists", + params={"count": 25, "range": "this_week"}, + timeout=15, + ) + response.raise_for_status() + data = response.json() + except requests.RequestException as e: + log.exception("ListenBrainz API error getting trending artists") + if notification_manager.is_configured(): + notification_manager.send_notification( + title="ListenBrainz API Error", + message=f"Failed to fetch trending artists from ListenBrainz. Error: {e}", + ) + return [] + + formatted_results = [] + for artist in data.get("payload", {}).get("artists", []): + try: + artist_mbid = artist.get("artist_mbid") + if not artist_mbid: + continue + + listen_count = artist.get("listen_count", 0) + formatted_results.append( + MetaDataProviderSearchResult( + poster_path=None, + overview=f"{listen_count:,} listens this week", + name=artist.get("artist_name", "Unknown Artist"), + external_id=artist_mbid, + year=None, + metadata_provider=self.name, + added=False, + vote_average=None, + original_language=None, + ) + ) + except Exception: + log.warning("Error processing ListenBrainz trending result", exc_info=True) + + self.__enrich_with_cover_art(formatted_results) + + _trending_cache = (formatted_results, time.monotonic()) + + return [r.model_copy() for r in formatted_results] + + def __enrich_with_cover_art( + self, results: list[MetaDataProviderSearchResult], max_new_lookups: int = 6 + ) -> None: + """Fetch cover art URLs for results. + + Cached lookups are free. New MusicBrainz lookups are rate-limited + to 1 req/sec, so we cap uncached lookups at `max_new_lookups` to + keep response times reasonable. + """ + new_lookups = 0 + for result in results: + mbid = str(result.external_id) + is_cached = mbid in _cover_art_cache and ( + time.monotonic() - _cover_art_cache[mbid][1] + ) < _COVER_ART_CACHE_TTL + if not is_cached and new_lookups >= max_new_lookups: + continue + if not is_cached: + new_lookups += 1 + url = self.__get_artist_cover_art_url(mbid) + if url: + result.poster_path = url + + @override + def search_artist( + self, query: str | None = None + ) -> list[MetaDataProviderSearchResult]: + if query is None: + return self.__get_trending_artists() + + try: + result = musicbrainzngs.search_artists(artist=query, limit=25) + except musicbrainzngs.WebServiceError as e: + log.exception(f"MusicBrainz API error searching artists with query '{query}'") + if notification_manager.is_configured(): + notification_manager.send_notification( + title="MusicBrainz API Error", + message=f"Failed to search artists with query '{query}'. Error: {e}", + ) + raise + + formatted_results = [] + for artist in result.get("artist-list", []): + try: + artist_id = artist["id"] + name = artist.get("name", "Unknown Artist") + disambiguation = artist.get("disambiguation", "") + country = artist.get("country", "") + + overview = disambiguation + if country: + overview = f"{country} - {disambiguation}" if disambiguation else country + + formatted_results.append( + MetaDataProviderSearchResult( + poster_path=None, + overview=overview or None, + name=name, + external_id=artist_id, + year=None, + metadata_provider=self.name, + added=False, + vote_average=int(artist.get("ext:score", 0)) / 10.0 + if artist.get("ext:score") + else None, + original_language=None, + ) + ) + except Exception: + log.warning("Error processing MusicBrainz search result", exc_info=True) + + self.__enrich_with_cover_art(formatted_results) + + return formatted_results + + @override + def get_artist_metadata(self, artist_id: str) -> Artist: + try: + mb_artist = musicbrainzngs.get_artist_by_id( + artist_id, includes=["release-groups"] + )["artist"] + except musicbrainzngs.WebServiceError as e: + log.exception(f"MusicBrainz API error getting artist metadata for ID {artist_id}") + if notification_manager.is_configured(): + notification_manager.send_notification( + title="MusicBrainz API Error", + message=f"Failed to fetch artist metadata for ID {artist_id}. Error: {e}", + ) + raise + + albums = [] + for rg in mb_artist.get("release-group-list", []): + rg_type = rg.get("type", "Other") + if rg_type.lower() not in {"album", "single", "ep", "compilation"}: + continue + + # Get year from first-release-date + year = media_manager.metadataProvider.utils.get_year_from_date( + rg.get("first-release-date") + ) + + tracks = self.__get_release_group_tracks(rg["id"]) + + albums.append( + Album( + external_id=rg["id"], + name=rg.get("title", "Unknown Album"), + year=year, + album_type=rg_type.lower(), + tracks=tracks, + ) + ) + + # Sort albums by year + albums.sort(key=lambda a: a.year or 9999) + + overview = mb_artist.get("disambiguation", "") + life_span = mb_artist.get("life-span", {}) + if life_span.get("begin"): + active_since = f"Active since {life_span['begin']}" + if life_span.get("ended", "false") == "true" and life_span.get("end"): + active_since += f" - {life_span['end']}" + overview = f"{active_since}. {overview}" if overview else active_since + + return Artist( + external_id=artist_id, + name=mb_artist.get("name", "Unknown Artist"), + overview=overview or "", + metadata_provider=self.name, + country=mb_artist.get("country"), + disambiguation=mb_artist.get("disambiguation"), + albums=albums, + ) + + def __get_release_group_tracks(self, release_group_id: str) -> list[Track]: + """Get tracks from a release group by finding the best release within it.""" + try: + result = musicbrainzngs.browse_releases( + release_group=release_group_id, + includes=["recordings"], + limit=1, + ) + except musicbrainzngs.WebServiceError: + log.warning( + f"Failed to get tracks for release group {release_group_id}", + exc_info=True, + ) + return [] + + releases = result.get("release-list", []) + if not releases: + return [] + + release = releases[0] + tracks = [] + for medium in release.get("medium-list", []): + for track_data in medium.get("track-list", []): + recording = track_data.get("recording", {}) + # Use a running counter to ensure unique numbers across multi-disc releases + tracks.append( + Track( + external_id=recording.get("id", track_data.get("id", "")), + title=recording.get("title", track_data.get("title", "Unknown Track")), + number=len(tracks) + 1, + duration_ms=int(recording["length"]) + if recording.get("length") + else None, + ) + ) + + return tracks + + @override + def download_artist_cover_image(self, artist: Artist) -> bool: + """Download cover art for the artist's first album from Cover Art Archive.""" + for album in artist.albums: + try: + image_data = musicbrainzngs.get_release_group_image_front( + album.external_id + ) + except musicbrainzngs.ResponseError: + continue + except musicbrainzngs.WebServiceError: + log.warning( + f"Failed to download cover art for album {album.name}", + exc_info=True, + ) + continue + + if image_data: + try: + image_file_path = self.storage_path / f"{artist.id}.jpg" + image_file_path.write_bytes(image_data) + + from PIL import Image + + original_image = Image.open(image_file_path) + original_image.save( + image_file_path.with_suffix(".avif"), quality=50 + ) + original_image.save( + image_file_path.with_suffix(".webp"), quality=50 + ) + log.info(f"Successfully downloaded cover image for artist {artist.name}") + return True + except Exception: + log.warning( + f"Failed to process cover image for artist {artist.name}", + exc_info=True, + ) + continue + + log.warning(f"No cover art found for artist {artist.name}") + return False diff --git a/media_manager/metadataProvider/openlibrary.py b/media_manager/metadataProvider/openlibrary.py new file mode 100644 index 00000000..9515e745 --- /dev/null +++ b/media_manager/metadataProvider/openlibrary.py @@ -0,0 +1,420 @@ +import logging +import time +from typing import override + +import requests + +from media_manager.books.schemas import Author, Book, BookFormat +from media_manager.metadataProvider.abstract_book_metadata_provider import ( + AbstractBookMetadataProvider, +) +from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult +from media_manager.notification.manager import notification_manager + +log = logging.getLogger(__name__) + +# Cache: author_olid -> (cover_art_url, timestamp). TTL = 1 week. +_cover_art_cache: dict[str, tuple[str | None, float]] = {} +_COVER_ART_CACHE_TTL = 604800 + +_REQUEST_DELAY = 0.1 # seconds between API calls to be respectful + + +class OpenLibraryMetadataProvider(AbstractBookMetadataProvider): + name = "openlibrary" + + @staticmethod + def __get_author_cover_art_url(author_olid: str) -> str | None: + """Get a cover art URL for an author from OpenLibrary. + + Results are cached in memory for 1 week. + """ + cached = _cover_art_cache.get(author_olid) + if cached and (time.monotonic() - cached[1]) < _COVER_ART_CACHE_TTL: + return cached[0] + + url = f"https://covers.openlibrary.org/a/olid/{author_olid}-L.jpg?default=false" + try: + resp = requests.head(url, timeout=5, allow_redirects=True) + if resp.status_code == 200: + _cover_art_cache[author_olid] = (url, time.monotonic()) + return url + except requests.RequestException: + log.debug(f"Failed to check cover art for author {author_olid}") + + _cover_art_cache[author_olid] = (None, time.monotonic()) + return None + + def __enrich_with_cover_art( + self, results: list[MetaDataProviderSearchResult], max_new_lookups: int = 6 + ) -> None: + """Fetch cover art URLs for results. + + Cached lookups are free. New lookups are capped at `max_new_lookups` + to keep response times reasonable. + """ + new_lookups = 0 + for result in results: + olid = str(result.external_id) + is_cached = olid in _cover_art_cache and ( + time.monotonic() - _cover_art_cache[olid][1] + ) < _COVER_ART_CACHE_TTL + if not is_cached and new_lookups >= max_new_lookups: + continue + if not is_cached: + new_lookups += 1 + url = self.__get_author_cover_art_url(olid) + if url: + result.poster_path = url + + @override + def search_author( + self, query: str | None = None + ) -> list[MetaDataProviderSearchResult]: + if query is None: + return self.__get_trending_authors() + + try: + response = requests.get( + url="https://openlibrary.org/search/authors.json", + params={"q": query, "limit": 25}, + timeout=15, + ) + response.raise_for_status() + data = response.json() + except requests.RequestException as e: + log.exception(f"OpenLibrary API error searching authors with query '{query}'") + if notification_manager.is_configured(): + notification_manager.send_notification( + title="OpenLibrary API Error", + message=f"Failed to search authors with query '{query}'. Error: {e}", + ) + raise + + formatted_results = [] + for author in data.get("docs", []): + try: + author_key = author.get("key", "") + name = author.get("name", "Unknown Author") + top_work = author.get("top_work", "") + work_count = author.get("work_count", 0) + birth_date = author.get("birth_date", "") + + overview_parts = [] + if top_work: + overview_parts.append(f"Top work: {top_work}") + if work_count: + overview_parts.append(f"{work_count} works") + if birth_date: + overview_parts.append(f"Born: {birth_date}") + overview = " | ".join(overview_parts) if overview_parts else None + + formatted_results.append( + MetaDataProviderSearchResult( + poster_path=None, + overview=overview, + name=name, + external_id=author_key, + year=None, + metadata_provider=self.name, + added=False, + vote_average=None, + original_language=None, + ) + ) + except Exception: + log.warning("Error processing OpenLibrary search result", exc_info=True) + + self.__enrich_with_cover_art(formatted_results) + + return formatted_results + + def __get_trending_authors(self) -> list[MetaDataProviderSearchResult]: + """Fetch trending/popular works from OpenLibrary and extract unique authors.""" + try: + response = requests.get( + url="https://openlibrary.org/trending/daily.json", + params={"limit": 25}, + timeout=15, + ) + response.raise_for_status() + data = response.json() + except requests.RequestException as e: + log.exception("OpenLibrary API error getting trending works") + if notification_manager.is_configured(): + notification_manager.send_notification( + title="OpenLibrary API Error", + message=f"Failed to fetch trending works from OpenLibrary. Error: {e}", + ) + return [] + + seen_authors: set[str] = set() + formatted_results = [] + for work in data.get("works", []): + try: + authors = work.get("authors", []) or work.get("author_name", []) + if not authors: + continue + + # Handle both formats + if isinstance(authors[0], dict): + author_key = authors[0].get("key", "").replace("/authors/", "") + author_name = authors[0].get("name", "Unknown Author") + else: + author_keys = work.get("author_key", []) + if not author_keys: + continue + author_key = author_keys[0] + author_name = authors[0] + + if not author_key or author_key in seen_authors: + continue + seen_authors.add(author_key) + + title = work.get("title", "") + overview = f"Trending: {title}" if title else None + + formatted_results.append( + MetaDataProviderSearchResult( + poster_path=None, + overview=overview, + name=author_name, + external_id=author_key, + year=None, + metadata_provider=self.name, + added=False, + vote_average=None, + original_language=None, + ) + ) + except Exception: + log.warning("Error processing OpenLibrary trending result", exc_info=True) + + self.__enrich_with_cover_art(formatted_results) + + return formatted_results + + @override + def search_book(self, query: str) -> list[MetaDataProviderSearchResult]: + try: + response = requests.get( + url="https://openlibrary.org/search.json", + params={"q": query, "limit": 25}, + timeout=15, + ) + response.raise_for_status() + data = response.json() + except requests.RequestException as e: + log.exception(f"OpenLibrary API error searching books with query '{query}'") + if notification_manager.is_configured(): + notification_manager.send_notification( + title="OpenLibrary API Error", + message=f"Failed to search books with query '{query}'. Error: {e}", + ) + raise + + formatted_results = [] + for doc in data.get("docs", []): + try: + work_key = doc.get("key", "").replace("/works/", "") + title = doc.get("title", "Unknown Book") + author_names = doc.get("author_name", []) + year = doc.get("first_publish_year") + + overview = ", ".join(author_names) if author_names else None + + cover_id = doc.get("cover_i") + poster_path = ( + f"https://covers.openlibrary.org/b/id/{cover_id}-M.jpg" + if cover_id + else None + ) + + formatted_results.append( + MetaDataProviderSearchResult( + poster_path=poster_path, + overview=overview, + name=title, + external_id=work_key, + year=year, + metadata_provider=self.name, + added=False, + vote_average=None, + original_language=None, + ) + ) + except Exception: + log.warning("Error processing OpenLibrary book search result", exc_info=True) + + return formatted_results + + @override + def get_author_metadata(self, author_id: str) -> Author: + try: + response = requests.get( + url=f"https://openlibrary.org/authors/{author_id}.json", + timeout=15, + ) + response.raise_for_status() + author_data = response.json() + except requests.RequestException as e: + log.exception(f"OpenLibrary API error getting author metadata for ID {author_id}") + if notification_manager.is_configured(): + notification_manager.send_notification( + title="OpenLibrary API Error", + message=f"Failed to fetch author metadata for ID {author_id}. Error: {e}", + ) + raise + + time.sleep(_REQUEST_DELAY) + + # Fetch author's works + books = [] + try: + works_response = requests.get( + url=f"https://openlibrary.org/authors/{author_id}/works.json", + params={"limit": 100}, + timeout=15, + ) + works_response.raise_for_status() + works_data = works_response.json() + + for work in works_data.get("entries", []): + work_key = work.get("key", "").replace("/works/", "") + title = work.get("title", "Unknown Work") + + # Try to get year from created date + year = None + if work.get("first_publish_date"): + try: + year = int(str(work["first_publish_date"])[:4]) + except (ValueError, IndexError): + pass + + # Determine format based on subjects + book_format = BookFormat.ebook + + books.append( + Book( + external_id=work_key, + name=title, + year=year, + format=book_format, + ) + ) + except requests.RequestException: + log.warning( + f"Failed to fetch works for author {author_id}", exc_info=True + ) + + # Sort books by year + books.sort(key=lambda b: b.year or 9999) + + # Build overview + bio = author_data.get("bio") + if isinstance(bio, dict): + overview = bio.get("value", "") + elif isinstance(bio, str): + overview = bio + else: + overview = "" + + birth_date = author_data.get("birth_date", "") + death_date = author_data.get("death_date", "") + if birth_date: + life_info = f"Born: {birth_date}" + if death_date: + life_info += f" - Died: {death_date}" + overview = f"{life_info}. {overview}" if overview else life_info + + return Author( + external_id=author_id, + name=author_data.get("name", "Unknown Author"), + overview=overview or "", + metadata_provider=self.name, + books=books, + ) + + @override + def download_author_cover_image(self, author: Author) -> bool: + """Download cover art for the author from OpenLibrary.""" + author_olid = author.external_id + url = f"https://covers.openlibrary.org/a/olid/{author_olid}-L.jpg?default=false" + + try: + resp = requests.get(url, timeout=15) + if resp.status_code != 200 or len(resp.content) < 1000: + # Try first book's cover instead + for book in author.books: + book_url = f"https://covers.openlibrary.org/b/olid/{book.external_id}-L.jpg?default=false" + try: + book_resp = requests.get(book_url, timeout=15) + if book_resp.status_code == 200 and len(book_resp.content) >= 1000: + resp = book_resp + break + except requests.RequestException: + continue + else: + log.warning(f"No cover art found for author {author.name}") + return False + except requests.RequestException: + log.warning(f"Failed to download cover art for author {author.name}", exc_info=True) + return False + + try: + image_file_path = self.storage_path / f"{author.id}.jpg" + image_file_path.write_bytes(resp.content) + + from PIL import Image + + original_image = Image.open(image_file_path) + original_image.save( + image_file_path.with_suffix(".avif"), quality=50 + ) + original_image.save( + image_file_path.with_suffix(".webp"), quality=50 + ) + log.info(f"Successfully downloaded cover image for author {author.name}") + return True + except Exception: + log.warning( + f"Failed to process cover image for author {author.name}", + exc_info=True, + ) + return False + + @override + def download_book_cover_image(self, book: Book) -> bool: + """Download cover art for a book from OpenLibrary.""" + url = f"https://covers.openlibrary.org/b/olid/{book.external_id}-L.jpg?default=false" + + try: + resp = requests.get(url, timeout=15) + if resp.status_code != 200 or len(resp.content) < 1000: + log.warning(f"No cover art found for book {book.name}") + return False + except requests.RequestException: + log.warning(f"Failed to download cover art for book {book.name}", exc_info=True) + return False + + try: + image_file_path = self.storage_path / f"{book.id}.jpg" + image_file_path.write_bytes(resp.content) + + from PIL import Image + + original_image = Image.open(image_file_path) + original_image.save( + image_file_path.with_suffix(".avif"), quality=50 + ) + original_image.save( + image_file_path.with_suffix(".webp"), quality=50 + ) + log.info(f"Successfully downloaded cover image for book {book.name}") + return True + except Exception: + log.warning( + f"Failed to process cover image for book {book.name}", + exc_info=True, + ) + return False diff --git a/media_manager/metadataProvider/schemas.py b/media_manager/metadataProvider/schemas.py index 98fff7d1..1a4f3cfe 100644 --- a/media_manager/metadataProvider/schemas.py +++ b/media_manager/metadataProvider/schemas.py @@ -1,3 +1,5 @@ +from uuid import UUID + from pydantic import BaseModel from media_manager.movies.schemas import MovieId @@ -8,10 +10,10 @@ class MetaDataProviderSearchResult(BaseModel): poster_path: str | None overview: str | None name: str - external_id: int + external_id: int | str year: int | None metadata_provider: str added: bool vote_average: float | None = None original_language: str | None = None - id: MovieId | ShowId | None = None # Internal ID if already added + id: UUID | None = None # Internal ID if already added diff --git a/media_manager/metadataProvider/utils.py b/media_manager/metadataProvider/utils.py index bff3cbd6..483ba1b0 100644 --- a/media_manager/metadataProvider/utils.py +++ b/media_manager/metadataProvider/utils.py @@ -15,7 +15,7 @@ def download_poster_image(storage_path: Path, poster_url: str, uuid: UUID) -> bo res = requests.get(poster_url, stream=True, timeout=60) if res.status_code == 200: - image_file_path = storage_path.joinpath(str(uuid)).with_suffix("jpg") + image_file_path = storage_path.joinpath(str(uuid)).with_suffix(".jpg") image_file_path.write_bytes(res.content) original_image = Image.open(image_file_path) diff --git a/media_manager/music/__init__.py b/media_manager/music/__init__.py new file mode 100644 index 00000000..3988bf12 --- /dev/null +++ b/media_manager/music/__init__.py @@ -0,0 +1,3 @@ +import logging + +log = logging.getLogger(__name__) diff --git a/media_manager/music/dependencies.py b/media_manager/music/dependencies.py new file mode 100644 index 00000000..f9cb2c41 --- /dev/null +++ b/media_manager/music/dependencies.py @@ -0,0 +1,70 @@ +from typing import Annotated + +from fastapi import Depends, HTTPException, Path + +from media_manager.database import DbSessionDependency +from media_manager.exceptions import NotFoundError +from media_manager.indexer.dependencies import indexer_service_dep +from media_manager.music.repository import MusicRepository +from media_manager.music.schemas import Album, AlbumId, Artist, ArtistId +from media_manager.music.service import MusicService +from media_manager.notification.dependencies import notification_service_dep +from media_manager.torrent.dependencies import torrent_service_dep + + +def get_music_repository(db_session: DbSessionDependency) -> MusicRepository: + return MusicRepository(db_session) + + +music_repository_dep = Annotated[MusicRepository, Depends(get_music_repository)] + + +def get_music_service( + music_repository: music_repository_dep, + torrent_service: torrent_service_dep, + indexer_service: indexer_service_dep, + notification_service: notification_service_dep, +) -> MusicService: + return MusicService( + music_repository=music_repository, + torrent_service=torrent_service, + indexer_service=indexer_service, + notification_service=notification_service, + ) + + +music_service_dep = Annotated[MusicService, Depends(get_music_service)] + + +def get_artist_by_id( + music_service: music_service_dep, + artist_id: ArtistId = Path(..., description="The ID of the artist"), +) -> Artist: + try: + artist = music_service.get_artist_by_id(artist_id) + except NotFoundError: + raise HTTPException( + status_code=404, + detail=f"Artist with ID {artist_id} not found.", + ) from None + return artist + + +artist_dep = Annotated[Artist, Depends(get_artist_by_id)] + + +def get_album_by_id( + music_service: music_service_dep, + album_id: AlbumId = Path(..., description="The ID of the album"), +) -> Album: + try: + album = music_service.music_repository.get_album(album_id=album_id) + except NotFoundError: + raise HTTPException( + status_code=404, + detail=f"Album with ID {album_id} not found.", + ) from None + return album + + +album_dep = Annotated[Album, Depends(get_album_by_id)] diff --git a/media_manager/music/models.py b/media_manager/music/models.py new file mode 100644 index 00000000..07ead280 --- /dev/null +++ b/media_manager/music/models.py @@ -0,0 +1,112 @@ +from uuid import UUID + +from sqlalchemy import ForeignKey, PrimaryKeyConstraint, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from media_manager.auth.db import User +from media_manager.database import Base +from media_manager.torrent.models import Quality + + +class Artist(Base): + __tablename__ = "artist" + __table_args__ = (UniqueConstraint("external_id", "metadata_provider"),) + + id: Mapped[UUID] = mapped_column(primary_key=True) + external_id: Mapped[str] + metadata_provider: Mapped[str] + name: Mapped[str] + overview: Mapped[str] + library: Mapped[str] = mapped_column(default="") + country: Mapped[str | None] = mapped_column(default=None) + disambiguation: Mapped[str | None] = mapped_column(default=None) + + albums: Mapped[list["Album"]] = relationship( + back_populates="artist", cascade="all, delete" + ) + + +class Album(Base): + __tablename__ = "album" + __table_args__ = (UniqueConstraint("artist_id", "external_id"),) + + id: Mapped[UUID] = mapped_column(primary_key=True) + artist_id: Mapped[UUID] = mapped_column( + ForeignKey(column="artist.id", ondelete="CASCADE"), + ) + external_id: Mapped[str] + name: Mapped[str] + year: Mapped[int | None] + album_type: Mapped[str] = mapped_column(default="album") + + artist: Mapped["Artist"] = relationship(back_populates="albums") + tracks: Mapped[list["Track"]] = relationship( + back_populates="album", cascade="all, delete" + ) + + album_files = relationship( + "AlbumFile", back_populates="album", cascade="all, delete" + ) + album_requests = relationship( + "AlbumRequest", back_populates="album", cascade="all, delete" + ) + + +class Track(Base): + __tablename__ = "track" + __table_args__ = (UniqueConstraint("album_id", "number"),) + + id: Mapped[UUID] = mapped_column(primary_key=True) + album_id: Mapped[UUID] = mapped_column( + ForeignKey("album.id", ondelete="CASCADE"), + ) + number: Mapped[int] + external_id: Mapped[str] + title: Mapped[str] + duration_ms: Mapped[int | None] = mapped_column(default=None) + + album: Mapped["Album"] = relationship(back_populates="tracks") + + +class AlbumFile(Base): + __tablename__ = "album_file" + __table_args__ = (PrimaryKeyConstraint("album_id", "file_path_suffix"),) + + album_id: Mapped[UUID] = mapped_column( + ForeignKey(column="album.id", ondelete="CASCADE"), + ) + torrent_id: Mapped[UUID | None] = mapped_column( + ForeignKey(column="torrent.id", ondelete="SET NULL"), + ) + file_path_suffix: Mapped[str] + quality: Mapped[Quality] + + torrent = relationship("Torrent", back_populates="album_files", uselist=False) + album = relationship("Album", back_populates="album_files", uselist=False) + + +class AlbumRequest(Base): + __tablename__ = "album_request" + __table_args__ = (UniqueConstraint("album_id", "wanted_quality"),) + + id: Mapped[UUID] = mapped_column(primary_key=True) + album_id: Mapped[UUID] = mapped_column( + ForeignKey(column="album.id", ondelete="CASCADE"), + ) + wanted_quality: Mapped[Quality] + min_quality: Mapped[Quality] + requested_by_id: Mapped[UUID | None] = mapped_column( + ForeignKey(column="user.id", ondelete="SET NULL"), + ) + authorized: Mapped[bool] = mapped_column(default=False) + authorized_by_id: Mapped[UUID | None] = mapped_column( + ForeignKey(column="user.id", ondelete="SET NULL"), + ) + + requested_by: Mapped["User|None"] = relationship( + foreign_keys=[requested_by_id], uselist=False + ) + authorized_by: Mapped["User|None"] = relationship( + foreign_keys=[authorized_by_id], uselist=False + ) + album = relationship("Album", back_populates="album_requests", uselist=False) diff --git a/media_manager/music/repository.py b/media_manager/music/repository.py new file mode 100644 index 00000000..2581ffb2 --- /dev/null +++ b/media_manager/music/repository.py @@ -0,0 +1,431 @@ +import logging + +from sqlalchemy import delete, select +from sqlalchemy.exc import ( + IntegrityError, + SQLAlchemyError, +) +from sqlalchemy.orm import Session, joinedload + +from media_manager.exceptions import ConflictError, NotFoundError +from media_manager.music.models import Album, AlbumFile, AlbumRequest, Artist +from media_manager.music.schemas import ( + AlbumFile as AlbumFileSchema, +) +from media_manager.music.schemas import ( + AlbumId, + AlbumRequestId, + AlbumTorrent as AlbumTorrentSchema, +) +from media_manager.music.schemas import ( + AlbumRequest as AlbumRequestSchema, +) +from media_manager.music.schemas import ( + Artist as ArtistSchema, +) +from media_manager.music.schemas import ( + ArtistId, +) +from media_manager.music.schemas import ( + RichAlbumRequest as RichAlbumRequestSchema, +) +from media_manager.torrent.models import Torrent +from media_manager.torrent.schemas import TorrentId + +log = logging.getLogger(__name__) + + +class MusicRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def get_artist_by_id(self, artist_id: ArtistId) -> ArtistSchema: + try: + stmt = ( + select(Artist) + .options(joinedload(Artist.albums).joinedload(Album.tracks)) + .where(Artist.id == artist_id) + ) + result = self.db.execute(stmt).unique().scalar_one_or_none() + if not result: + msg = f"Artist with id {artist_id} not found." + raise NotFoundError(msg) + return ArtistSchema.model_validate(result) + except SQLAlchemyError: + self.db.rollback() + log.exception(f"Database error while retrieving artist {artist_id}") + raise + + def get_artist_by_external_id( + self, external_id: str, metadata_provider: str + ) -> ArtistSchema: + try: + stmt = ( + select(Artist) + .options(joinedload(Artist.albums).joinedload(Album.tracks)) + .where(Artist.external_id == external_id) + .where(Artist.metadata_provider == metadata_provider) + ) + result = self.db.execute(stmt).unique().scalar_one_or_none() + if not result: + msg = f"Artist with external_id {external_id} and provider {metadata_provider} not found." + raise NotFoundError(msg) + return ArtistSchema.model_validate(result) + except SQLAlchemyError: + self.db.rollback() + log.exception( + f"Database error while retrieving artist by external_id {external_id}" + ) + raise + + def get_artists(self) -> list[ArtistSchema]: + try: + stmt = select(Artist).options( + joinedload(Artist.albums).joinedload(Album.tracks) + ) + results = self.db.execute(stmt).scalars().unique().all() + return [ArtistSchema.model_validate(artist) for artist in results] + except SQLAlchemyError: + self.db.rollback() + log.exception("Database error while retrieving all artists") + raise + + def save_artist(self, artist: ArtistSchema) -> ArtistSchema: + log.debug(f"Attempting to save artist: {artist.name} (ID: {artist.id})") + db_artist = self.db.get(Artist, artist.id) if artist.id else None + + if db_artist: + log.debug(f"Updating existing artist with ID: {artist.id}") + db_artist.external_id = artist.external_id + db_artist.metadata_provider = artist.metadata_provider + db_artist.name = artist.name + db_artist.overview = artist.overview + db_artist.country = artist.country + db_artist.disambiguation = artist.disambiguation + else: + log.debug(f"Creating new artist: {artist.name}") + artist_data = artist.model_dump(exclude={"albums"}) + db_artist = Artist(**artist_data) + self.db.add(db_artist) + + for album in artist.albums: + album_data = album.model_dump(exclude={"tracks"}) + album_data["artist_id"] = db_artist.id + db_album = Album(**album_data) + self.db.add(db_album) + + for track in album.tracks: + from media_manager.music.models import Track + + track_data = track.model_dump() + track_data["album_id"] = db_album.id + db_track = Track(**track_data) + self.db.add(db_track) + + try: + self.db.commit() + self.db.refresh(db_artist) + log.info(f"Successfully saved artist: {db_artist.name} (ID: {db_artist.id})") + return self.get_artist_by_id(ArtistId(db_artist.id)) + except IntegrityError as e: + self.db.rollback() + log.exception(f"Integrity error while saving artist {artist.name}") + msg = f"Artist with this primary key or unique constraint violation: {e.orig}" + raise ConflictError(msg) from e + except SQLAlchemyError: + self.db.rollback() + log.exception(f"Database error while saving artist {artist.name}") + raise + + def delete_artist(self, artist_id: ArtistId) -> None: + log.debug(f"Attempting to delete artist with id: {artist_id}") + try: + artist = self.db.get(Artist, artist_id) + if not artist: + log.warning(f"Artist with id {artist_id} not found for deletion.") + msg = f"Artist with id {artist_id} not found." + raise NotFoundError(msg) + self.db.delete(artist) + self.db.commit() + log.info(f"Successfully deleted artist with id: {artist_id}") + except SQLAlchemyError: + self.db.rollback() + log.exception(f"Database error while deleting artist {artist_id}") + raise + + def set_artist_library(self, artist_id: ArtistId, library: str) -> None: + try: + artist = self.db.get(Artist, artist_id) + if not artist: + msg = f"Artist with id {artist_id} not found." + raise NotFoundError(msg) + artist.library = library + self.db.commit() + except SQLAlchemyError: + self.db.rollback() + log.exception(f"Database error setting library for artist {artist_id}") + raise + + def update_artist_attributes( + self, + artist_id: ArtistId, + name: str | None = None, + overview: str | None = None, + country: str | None = None, + disambiguation: str | None = None, + ) -> ArtistSchema: + db_artist = self.db.get(Artist, artist_id) + if not db_artist: + msg = f"Artist with id {artist_id} not found." + raise NotFoundError(msg) + + updated = False + if name is not None and db_artist.name != name: + db_artist.name = name + updated = True + if overview is not None and db_artist.overview != overview: + db_artist.overview = overview + updated = True + if country is not None and db_artist.country != country: + db_artist.country = country + updated = True + if disambiguation is not None and db_artist.disambiguation != disambiguation: + db_artist.disambiguation = disambiguation + updated = True + + if updated: + self.db.commit() + self.db.refresh(db_artist) + return self.get_artist_by_id(ArtistId(db_artist.id)) + + def get_album(self, album_id: AlbumId) -> "Album": + from media_manager.music.schemas import Album as AlbumSchema + + try: + stmt = ( + select(Album) + .options(joinedload(Album.tracks)) + .where(Album.id == album_id) + ) + result = self.db.execute(stmt).unique().scalar_one_or_none() + if not result: + msg = f"Album with id {album_id} not found." + raise NotFoundError(msg) + return AlbumSchema.model_validate(result) + except SQLAlchemyError: + self.db.rollback() + log.exception(f"Database error while retrieving album {album_id}") + raise + + # ------------------------------------------------------------------------- + # ALBUM REQUESTS + # ------------------------------------------------------------------------- + + def add_album_request( + self, album_request: AlbumRequestSchema + ) -> AlbumRequestSchema: + db_model = AlbumRequest( + id=album_request.id, + album_id=album_request.album_id, + requested_by_id=album_request.requested_by.id + if album_request.requested_by + else None, + authorized_by_id=album_request.authorized_by.id + if album_request.authorized_by + else None, + wanted_quality=album_request.wanted_quality, + min_quality=album_request.min_quality, + authorized=album_request.authorized, + ) + try: + self.db.add(db_model) + self.db.commit() + self.db.refresh(db_model) + log.info(f"Successfully added album request with id: {db_model.id}") + return AlbumRequestSchema.model_validate(db_model) + except IntegrityError: + self.db.rollback() + log.exception("Integrity error while adding album request") + raise + except SQLAlchemyError: + self.db.rollback() + log.exception("Database error while adding album request") + raise + + def delete_album_request(self, album_request_id: AlbumRequestId) -> None: + try: + stmt = delete(AlbumRequest).where(AlbumRequest.id == album_request_id) + result = self.db.execute(stmt) + if result.rowcount == 0: + self.db.rollback() + msg = f"Album request with id {album_request_id} not found." + raise NotFoundError(msg) + self.db.commit() + except SQLAlchemyError: + self.db.rollback() + log.exception( + f"Database error while deleting album request {album_request_id}" + ) + raise + + def get_album_requests(self) -> list[RichAlbumRequestSchema]: + try: + stmt = select(AlbumRequest).options( + joinedload(AlbumRequest.requested_by), + joinedload(AlbumRequest.authorized_by), + joinedload(AlbumRequest.album).joinedload(Album.artist), + ) + results = self.db.execute(stmt).scalars().unique().all() + rich_results = [] + for req in results: + album_schema = self.get_album(AlbumId(req.album_id)) + artist_schema = self.get_artist_by_id(ArtistId(req.album.artist_id)) + rich_results.append( + RichAlbumRequestSchema( + id=req.id, + album_id=req.album_id, + wanted_quality=req.wanted_quality, + min_quality=req.min_quality, + requested_by=req.requested_by, + authorized=req.authorized, + authorized_by=req.authorized_by, + album=album_schema, + artist=artist_schema, + ) + ) + return rich_results + except SQLAlchemyError: + self.db.rollback() + log.exception("Database error while retrieving album requests") + raise + + def get_album_request(self, album_request_id: AlbumRequestId) -> AlbumRequestSchema: + try: + request = self.db.get(AlbumRequest, album_request_id) + if not request: + msg = f"Album request with id {album_request_id} not found." + raise NotFoundError(msg) + return AlbumRequestSchema.model_validate(request) + except SQLAlchemyError: + self.db.rollback() + log.exception(f"Database error retrieving album request {album_request_id}") + raise + + # ------------------------------------------------------------------------- + # ALBUM FILES + # ------------------------------------------------------------------------- + + def add_album_file(self, album_file: AlbumFileSchema) -> AlbumFileSchema: + db_model = AlbumFile(**album_file.model_dump()) + try: + self.db.add(db_model) + self.db.commit() + self.db.refresh(db_model) + return AlbumFileSchema.model_validate(db_model) + except IntegrityError: + self.db.rollback() + log.exception("Integrity error while adding album file") + raise + except SQLAlchemyError: + self.db.rollback() + log.exception("Database error while adding album file") + raise + + def remove_album_files_by_torrent_id(self, torrent_id: TorrentId) -> int: + try: + stmt = delete(AlbumFile).where(AlbumFile.torrent_id == torrent_id) + result = self.db.execute(stmt) + self.db.commit() + except SQLAlchemyError: + self.db.rollback() + log.exception( + f"Database error removing album files for torrent_id {torrent_id}" + ) + raise + return result.rowcount + + def get_album_files_by_album_id(self, album_id: AlbumId) -> list[AlbumFileSchema]: + try: + stmt = select(AlbumFile).where(AlbumFile.album_id == album_id) + results = self.db.execute(stmt).scalars().all() + return [AlbumFileSchema.model_validate(af) for af in results] + except SQLAlchemyError: + self.db.rollback() + log.exception( + f"Database error retrieving album files for album_id {album_id}" + ) + raise + + # ------------------------------------------------------------------------- + # TORRENTS + # ------------------------------------------------------------------------- + + def get_torrents_by_artist_id( + self, artist_id: ArtistId + ) -> list[AlbumTorrentSchema]: + try: + stmt = ( + select(Torrent, AlbumFile.file_path_suffix) + .distinct() + .join(AlbumFile, AlbumFile.torrent_id == Torrent.id) + .join(Album, Album.id == AlbumFile.album_id) + .where(Album.artist_id == artist_id) + ) + results = self.db.execute(stmt).all() + formatted_results = [] + for torrent, file_path_suffix in results: + album_torrent = AlbumTorrentSchema( + torrent_id=torrent.id, + torrent_title=torrent.title, + status=torrent.status, + quality=torrent.quality, + imported=torrent.imported, + file_path_suffix=file_path_suffix, + usenet=torrent.usenet, + ) + formatted_results.append(album_torrent) + except SQLAlchemyError: + self.db.rollback() + log.exception( + f"Database error retrieving torrents for artist_id {artist_id}" + ) + raise + return formatted_results + + def get_all_artists_with_torrents(self) -> list[ArtistSchema]: + try: + stmt = ( + select(Artist) + .distinct() + .join(Album, Artist.id == Album.artist_id) + .join(AlbumFile, Album.id == AlbumFile.album_id) + .join(Torrent, AlbumFile.torrent_id == Torrent.id) + .options(joinedload(Artist.albums).joinedload(Album.tracks)) + .order_by(Artist.name) + ) + results = self.db.execute(stmt).scalars().unique().all() + return [ArtistSchema.model_validate(artist) for artist in results] + except SQLAlchemyError: + self.db.rollback() + log.exception("Database error retrieving all artists with torrents") + raise + + def get_artist_by_torrent_id(self, torrent_id: TorrentId) -> ArtistSchema | None: + try: + stmt = ( + select(Artist) + .join(Album, Artist.id == Album.artist_id) + .join(AlbumFile, Album.id == AlbumFile.album_id) + .where(AlbumFile.torrent_id == torrent_id) + .options(joinedload(Artist.albums).joinedload(Album.tracks)) + ) + result = self.db.execute(stmt).unique().scalar_one_or_none() + if not result: + return None + return ArtistSchema.model_validate(result) + except SQLAlchemyError: + self.db.rollback() + log.exception( + f"Database error retrieving artist by torrent_id {torrent_id}" + ) + raise diff --git a/media_manager/music/router.py b/media_manager/music/router.py new file mode 100644 index 00000000..bea2177e --- /dev/null +++ b/media_manager/music/router.py @@ -0,0 +1,418 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, 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, NotFoundError +from media_manager.indexer.schemas import ( + IndexerQueryResult, + IndexerQueryResultId, +) +from media_manager.metadataProvider.music_dependencies import ( + music_metadata_provider_dep, +) +from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult +from media_manager.music import log +from media_manager.music.dependencies import ( + album_dep, + artist_dep, + music_service_dep, +) +from media_manager.music.schemas import ( + Album, + AlbumId, + AlbumRequest, + AlbumRequestBase, + AlbumRequestId, + Artist, + ArtistId, + CreateAlbumRequest, + PublicAlbumFile, + PublicArtist, + RichAlbumRequest, + RichArtistTorrent, +) +from media_manager.torrent.schemas import Torrent + +router = APIRouter() + +# ----------------------------------------------------------------------------- +# METADATA & SEARCH +# ----------------------------------------------------------------------------- + + +@router.get( + "/search", + dependencies=[Depends(current_active_user)], +) +def search_for_artist( + query: str, + music_service: music_service_dep, + metadata_provider: music_metadata_provider_dep, +) -> list[MetaDataProviderSearchResult]: + """ + Search for an artist on the configured music metadata provider. + """ + return music_service.search_for_artist( + query=query, metadata_provider=metadata_provider + ) + + +@router.get( + "/recommended", + dependencies=[Depends(current_active_user)], +) +def get_popular_artists( + music_service: music_service_dep, + metadata_provider: music_metadata_provider_dep, +) -> list[MetaDataProviderSearchResult]: + """ + Get a list of trending/popular artists from ListenBrainz. + """ + return music_service.get_popular_artists(metadata_provider=metadata_provider) + + +# ----------------------------------------------------------------------------- +# ARTISTS +# ----------------------------------------------------------------------------- + + +@router.get( + "/artists", + dependencies=[Depends(current_active_user)], +) +def get_all_artists(music_service: music_service_dep) -> list[Artist]: + """ + Get all artists in the library. + """ + return music_service.get_all_artists() + + +@router.post( + "/artists", + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(current_active_user)], + responses={ + status.HTTP_201_CREATED: { + "model": Artist, + "description": "Successfully created artist", + } + }, +) +def add_an_artist( + music_service: music_service_dep, + metadata_provider: music_metadata_provider_dep, + artist_id: str, +) -> Artist: + """ + Add a new artist to the library. + """ + try: + artist = music_service.add_artist( + external_id=artist_id, + metadata_provider=metadata_provider, + ) + except ConflictError: + artist = music_service.get_artist_by_external_id( + external_id=artist_id, metadata_provider=metadata_provider.name + ) + if not artist: + raise NotFoundError from ConflictError + return artist + + +@router.get( + "/artists/torrents", + dependencies=[Depends(current_active_user)], +) +def get_all_artists_with_torrents( + music_service: music_service_dep, +) -> list[RichArtistTorrent]: + """ + Get all artists that are associated with torrents. + """ + return music_service.get_all_artists_with_torrents() + + +@router.get( + "/artists/libraries", + dependencies=[Depends(current_active_user)], +) +def get_available_libraries() -> list[LibraryItem]: + """ + Get available music libraries from configuration. + """ + return MediaManagerConfig().misc.music_libraries + + +# ----------------------------------------------------------------------------- +# ALBUM REQUESTS +# ----------------------------------------------------------------------------- + + +@router.get( + "/albums/requests", + dependencies=[Depends(current_active_user)], +) +def get_all_album_requests( + music_service: music_service_dep, +) -> list[RichAlbumRequest]: + """ + Get all album requests. + """ + return music_service.get_all_album_requests() + + +@router.post( + "/albums/requests", + status_code=status.HTTP_201_CREATED, +) +def create_album_request( + music_service: music_service_dep, + album_request: CreateAlbumRequest, + user: Annotated[UserRead, Depends(current_active_user)], +) -> AlbumRequest: + """ + Create a new album request. + """ + log.info( + f"User {user.email} is creating an album request for {album_request.album_id}" + ) + album_request: AlbumRequest = AlbumRequest.model_validate(album_request) + album_request.requested_by = user + if user.is_superuser: + album_request.authorized = True + album_request.authorized_by = user + + return music_service.add_album_request(album_request=album_request) + + +@router.put( + "/albums/requests/{album_request_id}", +) +def update_album_request( + music_service: music_service_dep, + album_request_id: AlbumRequestId, + update_request: AlbumRequestBase, + user: Annotated[UserRead, Depends(current_active_user)], +) -> AlbumRequest: + """ + Update an existing album request. + """ + album_request = music_service.get_album_request_by_id( + album_request_id=album_request_id + ) + if album_request.requested_by.id != user.id or user.is_superuser: + album_request.min_quality = update_request.min_quality + album_request.wanted_quality = update_request.wanted_quality + + return music_service.update_album_request(album_request=album_request) + + +@router.patch( + "/albums/requests/{album_request_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def authorize_album_request( + music_service: music_service_dep, + album_request_id: AlbumRequestId, + user: Annotated[UserRead, Depends(current_superuser)], + authorized_status: bool = False, +) -> None: + """ + Authorize or de-authorize an album request. + """ + album_request = music_service.get_album_request_by_id( + album_request_id=album_request_id + ) + album_request.authorized = authorized_status + if authorized_status: + album_request.authorized_by = user + else: + album_request.authorized_by = None + music_service.update_album_request(album_request=album_request) + + +@router.delete( + "/albums/requests/{album_request_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(current_superuser)], +) +def delete_album_request( + music_service: music_service_dep, album_request_id: AlbumRequestId +) -> None: + """ + Delete an album request. + """ + music_service.delete_album_request(album_request_id=album_request_id) + + +# ----------------------------------------------------------------------------- +# ARTISTS - SINGLE RESOURCE +# ----------------------------------------------------------------------------- + + +@router.get( + "/artists/{artist_id}", + dependencies=[Depends(current_active_user)], +) +def get_artist_by_id( + music_service: music_service_dep, artist: artist_dep +) -> PublicArtist: + """ + Get details for a specific artist. + """ + return music_service.get_public_artist_by_id(artist=artist) + + +@router.delete( + "/artists/{artist_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(current_superuser)], +) +def delete_an_artist( + music_service: music_service_dep, + artist: artist_dep, + delete_files_on_disk: bool = False, + delete_torrents: bool = False, +) -> None: + """ + Delete an artist from the library. + """ + music_service.delete_artist( + artist=artist, + delete_files_on_disk=delete_files_on_disk, + delete_torrents=delete_torrents, + ) + + +@router.post( + "/artists/{artist_id}/metadata", + dependencies=[Depends(current_active_user)], +) +def update_artist_metadata( + music_service: music_service_dep, + artist: artist_dep, + metadata_provider: music_metadata_provider_dep, +) -> PublicArtist: + """ + Refresh metadata for an artist from the metadata provider. + """ + music_service.update_artist_metadata( + db_artist=artist, metadata_provider=metadata_provider + ) + updated_artist = music_service.get_artist_by_id(artist_id=artist.id) + return music_service.get_public_artist_by_id(artist=updated_artist) + + +@router.post( + "/artists/{artist_id}/library", + dependencies=[Depends(current_superuser)], + status_code=status.HTTP_204_NO_CONTENT, +) +def set_library( + artist: artist_dep, + music_service: music_service_dep, + library: str, +) -> None: + """ + Set the library path for an artist. + """ + music_service.set_artist_library(artist=artist, library=library) + + +@router.get( + "/artists/{artist_id}/torrents", + dependencies=[Depends(current_active_user)], +) +def get_torrents_for_artist( + music_service: music_service_dep, + artist: artist_dep, +) -> RichArtistTorrent: + """ + Get torrents associated with an artist. + """ + return music_service.get_torrents_for_artist(artist=artist) + + +# ----------------------------------------------------------------------------- +# ALBUMS - SINGLE RESOURCE +# ----------------------------------------------------------------------------- + + +@router.get( + "/albums/{album_id}", + dependencies=[Depends(current_active_user)], +) +def get_album_by_id(album: album_dep) -> Album: + """ + Get details for a specific album. + """ + return Album.model_validate(album) + + +@router.get( + "/albums/{album_id}/files", + dependencies=[Depends(current_active_user)], +) +def get_album_files( + music_service: music_service_dep, + album: album_dep, +) -> list[PublicAlbumFile]: + """ + Get files associated with a specific album. + """ + return music_service.get_public_album_files(album_id=album.id) + + +# ----------------------------------------------------------------------------- +# TORRENTS +# ----------------------------------------------------------------------------- + + +@router.get( + "/torrents", + status_code=status.HTTP_200_OK, + dependencies=[Depends(current_superuser)], +) +def search_torrents_for_album( + music_service: music_service_dep, + artist_id: ArtistId, + album_name: str, + search_query_override: str | None = None, +) -> list[IndexerQueryResult]: + """ + Search for torrents for a specific album. + """ + artist = music_service.get_artist_by_id(artist_id=artist_id) + return music_service.get_all_available_torrents_for_album( + artist=artist, + album_name=album_name, + search_query_override=search_query_override, + ) + + +@router.post( + "/torrents", + status_code=status.HTTP_200_OK, + dependencies=[Depends(current_superuser)], +) +def download_a_torrent( + music_service: music_service_dep, + public_indexer_result_id: IndexerQueryResultId, + artist_id: ArtistId, + album_id: AlbumId, + override_file_path_suffix: str = "", +) -> Torrent: + """ + Download a torrent for a specific album. + """ + artist = music_service.get_artist_by_id(artist_id=artist_id) + return music_service.download_torrent( + public_indexer_result_id=public_indexer_result_id, + artist=artist, + album_id=album_id, + override_file_path_suffix=override_file_path_suffix, + ) diff --git a/media_manager/music/schemas.py b/media_manager/music/schemas.py new file mode 100644 index 00000000..c865a995 --- /dev/null +++ b/media_manager/music/schemas.py @@ -0,0 +1,168 @@ +import typing +import uuid +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from media_manager.auth.schemas import UserRead +from media_manager.torrent.models import Quality +from media_manager.torrent.schemas import TorrentId, TorrentStatus + +ArtistId = typing.NewType("ArtistId", UUID) +AlbumId = typing.NewType("AlbumId", UUID) +TrackId = typing.NewType("TrackId", UUID) +AlbumRequestId = typing.NewType("AlbumRequestId", UUID) + + +class Track(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: TrackId = Field(default_factory=lambda: TrackId(uuid.uuid4())) + number: int + external_id: str + title: str + duration_ms: int | None = None + + +class Album(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: AlbumId = Field(default_factory=lambda: AlbumId(uuid.uuid4())) + external_id: str + name: str + year: int | None + album_type: str = "album" + + tracks: list[Track] + + +class Artist(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: ArtistId = Field(default_factory=lambda: ArtistId(uuid.uuid4())) + + name: str + overview: str + external_id: str + metadata_provider: str + + library: str = "Default" + country: str | None = None + disambiguation: str | None = None + + albums: list[Album] + + +class AlbumRequestBase(BaseModel): + min_quality: Quality + wanted_quality: Quality + + @model_validator(mode="after") + def ensure_wanted_quality_is_eq_or_gt_min_quality(self) -> "AlbumRequestBase": + if self.min_quality.value < self.wanted_quality.value: + msg = "wanted_quality must be equal to or lower than minimum_quality." + raise ValueError(msg) + return self + + +class CreateAlbumRequest(AlbumRequestBase): + album_id: AlbumId + + +class UpdateAlbumRequest(AlbumRequestBase): + id: AlbumRequestId + + +class AlbumRequest(AlbumRequestBase): + model_config = ConfigDict(from_attributes=True) + + id: AlbumRequestId = Field( + default_factory=lambda: AlbumRequestId(uuid.uuid4()) + ) + + album_id: AlbumId + requested_by: UserRead | None = None + authorized: bool = False + authorized_by: UserRead | None = None + + +class RichAlbumRequest(AlbumRequest): + artist: Artist + album: Album + + +class AlbumFile(BaseModel): + model_config = ConfigDict(from_attributes=True) + + album_id: AlbumId + quality: Quality + torrent_id: TorrentId | None + file_path_suffix: str + + +class PublicAlbumFile(AlbumFile): + downloaded: bool = False + + +class AlbumTorrent(BaseModel): + model_config = ConfigDict(from_attributes=True) + + torrent_id: TorrentId + torrent_title: str + status: TorrentStatus + quality: Quality + imported: bool + file_path_suffix: str + usenet: bool + + +class PublicAlbum(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: AlbumId + external_id: str + name: str + year: int | None + album_type: str + + downloaded: bool = False + tracks: list[Track] + + +class PublicArtist(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: ArtistId + + name: str + overview: str + external_id: str + metadata_provider: str + + library: str + country: str | None = None + disambiguation: str | None = None + + albums: list[PublicAlbum] + + +class RichAlbumTorrent(BaseModel): + model_config = ConfigDict(from_attributes=True) + + torrent_id: TorrentId + torrent_title: str + status: TorrentStatus + quality: Quality + imported: bool + usenet: bool + + file_path_suffix: str + + +class RichArtistTorrent(BaseModel): + model_config = ConfigDict(from_attributes=True) + + artist_id: ArtistId + name: str + metadata_provider: str + torrents: list[RichAlbumTorrent] diff --git a/media_manager/music/service.py b/media_manager/music/service.py new file mode 100644 index 00000000..76a96366 --- /dev/null +++ b/media_manager/music/service.py @@ -0,0 +1,563 @@ +import mimetypes +import shutil +from pathlib import Path + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from media_manager.config import MediaManagerConfig +from media_manager.database import SessionLocal, get_session +from media_manager.exceptions import NotFoundError +from media_manager.indexer.repository import IndexerRepository +from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId +from media_manager.indexer.service import IndexerService +from media_manager.metadataProvider.abstract_music_metadata_provider import ( + AbstractMusicMetadataProvider, +) +from media_manager.metadataProvider.musicbrainz import MusicBrainzMetadataProvider +from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult +from media_manager.music import log +from media_manager.music.repository import MusicRepository +from media_manager.music.schemas import ( + AlbumFile, + AlbumId, + AlbumRequest, + AlbumRequestId, + Artist, + ArtistId, + PublicAlbumFile, + PublicArtist, + RichAlbumRequest, + RichArtistTorrent, +) +from media_manager.notification.repository import NotificationRepository +from media_manager.notification.service import NotificationService +from media_manager.torrent.repository import TorrentRepository +from media_manager.torrent.schemas import ( + Quality, + QualityStrings, + Torrent, + TorrentStatus, +) +from media_manager.torrent.service import TorrentService +from media_manager.torrent.utils import ( + import_file, + list_files_recursively, + remove_special_characters, +) + +AUDIO_EXTENSIONS = {".flac", ".mp3", ".ogg", ".m4a", ".opus", ".wav", ".aac", ".wma", ".alac"} + + +class MusicService: + def __init__( + self, + music_repository: MusicRepository, + torrent_service: TorrentService, + indexer_service: IndexerService, + notification_service: NotificationService, + ) -> None: + self.music_repository = music_repository + self.torrent_service = torrent_service + self.indexer_service = indexer_service + self.notification_service = notification_service + + def add_artist( + self, + external_id: str, + metadata_provider: AbstractMusicMetadataProvider, + ) -> Artist: + artist_with_metadata = metadata_provider.get_artist_metadata( + artist_id=external_id + ) + if not artist_with_metadata: + raise NotFoundError + + saved_artist = self.music_repository.save_artist(artist=artist_with_metadata) + metadata_provider.download_artist_cover_image(artist=saved_artist) + return saved_artist + + def add_album_request(self, album_request: AlbumRequest) -> AlbumRequest: + return self.music_repository.add_album_request(album_request=album_request) + + def get_album_request_by_id( + self, album_request_id: AlbumRequestId + ) -> AlbumRequest: + return self.music_repository.get_album_request( + album_request_id=album_request_id + ) + + def update_album_request(self, album_request: AlbumRequest) -> AlbumRequest: + self.music_repository.delete_album_request( + album_request_id=album_request.id + ) + return self.music_repository.add_album_request(album_request=album_request) + + def delete_album_request(self, album_request_id: AlbumRequestId) -> None: + self.music_repository.delete_album_request( + album_request_id=album_request_id + ) + + def delete_artist( + self, + artist: Artist, + delete_files_on_disk: bool = False, + delete_torrents: bool = False, + ) -> None: + if delete_files_on_disk or delete_torrents: + if delete_files_on_disk: + artist_dir = self.get_artist_root_path(artist=artist) + if artist_dir.exists() and artist_dir.is_dir(): + try: + shutil.rmtree(artist_dir) + log.info(f"Deleted artist directory: {artist_dir}") + except OSError: + log.exception(f"Deleting artist directory: {artist_dir}") + + if delete_torrents: + artist_torrents = self.music_repository.get_torrents_by_artist_id( + artist_id=artist.id + ) + for album_torrent in artist_torrents: + torrent = self.torrent_service.get_torrent_by_id( + torrent_id=album_torrent.torrent_id + ) + try: + self.torrent_service.cancel_download( + torrent=torrent, delete_files=True + ) + log.info(f"Deleted torrent: {torrent.title}") + except Exception: + log.warning( + f"Failed to delete torrent {torrent.hash}", + exc_info=True, + ) + + self.music_repository.delete_artist(artist_id=artist.id) + + def get_public_album_files( + self, album_id: AlbumId + ) -> list[PublicAlbumFile]: + album_files = self.music_repository.get_album_files_by_album_id( + album_id=album_id + ) + public_album_files = [PublicAlbumFile.model_validate(x) for x in album_files] + result = [] + for album_file in public_album_files: + album_file.downloaded = self.album_file_exists_on_disk( + album_file=album_file + ) + result.append(album_file) + return result + + def album_file_exists_on_disk(self, album_file: AlbumFile) -> bool: + if album_file.torrent_id is None: + return True + torrent_file = self.torrent_service.get_torrent_by_id( + torrent_id=album_file.torrent_id + ) + return torrent_file.imported + + def get_all_artists(self) -> list[Artist]: + return self.music_repository.get_artists() + + def get_popular_artists( + self, metadata_provider: AbstractMusicMetadataProvider + ) -> list[MetaDataProviderSearchResult]: + results = metadata_provider.search_artist() + return [ + result + for result in results + if not self._check_if_artist_exists( + external_id=str(result.external_id), + metadata_provider=metadata_provider.name, + ) + ] + + def _check_if_artist_exists( + self, external_id: str, metadata_provider: str + ) -> bool: + try: + self.music_repository.get_artist_by_external_id( + external_id=external_id, metadata_provider=metadata_provider + ) + return True + except NotFoundError: + return False + + def search_for_artist( + self, query: str, metadata_provider: AbstractMusicMetadataProvider + ) -> list[MetaDataProviderSearchResult]: + results = metadata_provider.search_artist(query) + for result in results: + try: + artist = self.music_repository.get_artist_by_external_id( + external_id=str(result.external_id), + metadata_provider=metadata_provider.name, + ) + result.added = True + result.id = artist.id + except NotFoundError: + pass + except Exception: + log.error( + f"Unable to find internal artist ID for {result.external_id} on {metadata_provider.name}" + ) + return results + + def get_public_artist_by_id(self, artist: Artist) -> PublicArtist: + torrents = self.get_torrents_for_artist(artist=artist).torrents + public_artist = PublicArtist.model_validate(artist) + for album in public_artist.albums: + album_files = self.music_repository.get_album_files_by_album_id( + album_id=album.id + ) + album.downloaded = len(album_files) > 0 + return public_artist + + def get_artist_by_id(self, artist_id: ArtistId) -> Artist: + return self.music_repository.get_artist_by_id(artist_id=artist_id) + + def get_artist_by_external_id( + self, external_id: str, metadata_provider: str + ) -> Artist | None: + return self.music_repository.get_artist_by_external_id( + external_id=external_id, metadata_provider=metadata_provider + ) + + def get_all_album_requests(self) -> list[RichAlbumRequest]: + return self.music_repository.get_album_requests() + + def set_artist_library(self, artist: Artist, library: str) -> None: + self.music_repository.set_artist_library( + artist_id=artist.id, library=library + ) + + def get_torrents_for_artist(self, artist: Artist) -> RichArtistTorrent: + artist_torrents = self.music_repository.get_torrents_by_artist_id( + artist_id=artist.id + ) + return RichArtistTorrent( + artist_id=artist.id, + name=artist.name, + metadata_provider=artist.metadata_provider, + torrents=artist_torrents, + ) + + def get_all_artists_with_torrents(self) -> list[RichArtistTorrent]: + artists = self.music_repository.get_all_artists_with_torrents() + return [self.get_torrents_for_artist(artist=artist) for artist in artists] + + def get_all_available_torrents_for_album( + self, + artist: Artist, + album_name: str, + search_query_override: str | None = None, + ) -> list[IndexerQueryResult]: + return self.indexer_service.search_music( + artist=artist, + album_name=album_name, + search_query_override=search_query_override, + ) + + def download_torrent( + self, + public_indexer_result_id: IndexerQueryResultId, + artist: Artist, + album_id: AlbumId, + override_file_path_suffix: str = "", + ) -> Torrent: + indexer_result = self.indexer_service.get_result( + result_id=public_indexer_result_id + ) + album_torrent = self.torrent_service.download(indexer_result=indexer_result) + self.torrent_service.pause_download(torrent=album_torrent) + album_file = AlbumFile( + album_id=album_id, + quality=indexer_result.quality, + torrent_id=album_torrent.id, + file_path_suffix=override_file_path_suffix, + ) + try: + self.music_repository.add_album_file(album_file=album_file) + except IntegrityError: + log.warning( + f"Album file for artist {artist.name} and torrent {album_torrent.title} already exists" + ) + self.torrent_service.cancel_download( + torrent=album_torrent, delete_files=True + ) + raise + else: + log.info( + f"Added album file for artist {artist.name} and torrent {album_torrent.title}" + ) + self.torrent_service.resume_download(torrent=album_torrent) + return album_torrent + + def download_approved_album_request( + self, album_request: AlbumRequest, artist: Artist, album_name: str + ) -> bool: + if not album_request.authorized: + msg = "Album request is not authorized" + raise ValueError(msg) + + log.info(f"Downloading approved album request {album_request.id}") + + torrents = self.get_all_available_torrents_for_album( + artist=artist, album_name=album_name + ) + available_torrents: list[IndexerQueryResult] = [] + + for torrent in torrents: + if torrent.seeders < 3: + log.debug( + f"Skipping torrent {torrent.title} for album request {album_request.id}, too few seeders" + ) + else: + available_torrents.append(torrent) + + if len(available_torrents) == 0: + log.warning( + f"No torrents found for album request {album_request.id}" + ) + return False + + available_torrents.sort() + + torrent = self.torrent_service.download(indexer_result=available_torrents[0]) + album_file = AlbumFile( + album_id=album_request.album_id, + quality=torrent.quality, + torrent_id=torrent.id, + file_path_suffix=QualityStrings[torrent.quality.name].value.upper(), + ) + try: + self.music_repository.add_album_file(album_file=album_file) + except IntegrityError: + log.warning( + f"Album file for torrent {torrent.title} already exists" + ) + self.delete_album_request(album_request.id) + return True + + def get_artist_root_path(self, artist: Artist) -> Path: + misc_config = MediaManagerConfig().misc + artist_dir_name = remove_special_characters(artist.name) + artist_file_path = misc_config.music_directory / artist_dir_name + + if artist.library != "Default": + for library in misc_config.music_libraries: + if library.name == artist.library: + log.debug( + f"Using library {library.name} for artist {artist.name}" + ) + return Path(library.path) / artist_dir_name + log.warning( + f"Library {artist.library} not found in config, using default library" + ) + return artist_file_path + + def import_music_files(self, torrent: Torrent, artist: Artist) -> None: + from media_manager.torrent.utils import get_torrent_filepath + + torrent_path = get_torrent_filepath(torrent=torrent) + all_files = list_files_recursively(path=torrent_path) + + audio_files = [ + f + for f in all_files + if f.suffix.lower() in AUDIO_EXTENSIONS + or (mimetypes.guess_type(str(f))[0] or "").startswith("audio") + ] + + if not audio_files: + log.warning( + f"No audio files found in torrent {torrent.title} for artist {artist.name}" + ) + return + + log.info( + f"Found {len(audio_files)} audio files for import from torrent {torrent.title}" + ) + + artist_root = self.get_artist_root_path(artist=artist) + album_dir = artist_root / remove_special_characters(torrent.title) + + try: + album_dir.mkdir(parents=True, exist_ok=True) + except Exception: + log.exception(f"Failed to create directory {album_dir}") + return + + success = True + for audio_file in audio_files: + target_file = album_dir / audio_file.name + try: + import_file(target_file=target_file, source_file=audio_file) + except Exception: + log.exception(f"Failed to import audio file {audio_file}") + success = False + + if success: + torrent.imported = True + self.torrent_service.torrent_repository.save_torrent(torrent=torrent) + + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Music Downloaded", + message=f"Music for {artist.name} has been successfully downloaded and imported.", + ) + else: + log.error( + f"Failed to import some files for torrent {torrent.title}." + ) + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Import Failed", + message=f"Failed to import some music files for {artist.name}. Please check logs.", + ) + + def update_artist_metadata( + self, + db_artist: Artist, + metadata_provider: AbstractMusicMetadataProvider, + ) -> Artist | None: + log.debug(f"Found artist: {db_artist.name} for metadata update.") + + fresh_artist_data = metadata_provider.get_artist_metadata( + artist_id=db_artist.external_id + ) + if not fresh_artist_data: + log.warning( + f"Could not fetch fresh metadata for artist: {db_artist.name} (ID: {db_artist.external_id})" + ) + return None + + self.music_repository.update_artist_attributes( + artist_id=db_artist.id, + name=fresh_artist_data.name, + overview=fresh_artist_data.overview, + country=fresh_artist_data.country, + disambiguation=fresh_artist_data.disambiguation, + ) + + updated_artist = self.music_repository.get_artist_by_id( + artist_id=db_artist.id + ) + log.info(f"Successfully updated metadata for artist ID: {db_artist.id}") + metadata_provider.download_artist_cover_image(artist=updated_artist) + return updated_artist + + +def auto_download_all_approved_album_requests() -> None: + db: Session = SessionLocal() if SessionLocal else next(get_session()) + music_repository = MusicRepository(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) + ) + music_service = MusicService( + music_repository=music_repository, + torrent_service=torrent_service, + indexer_service=indexer_service, + notification_service=notification_service, + ) + + log.info("Auto downloading all approved album requests") + album_requests = music_repository.get_album_requests() + log.info(f"Found {len(album_requests)} album requests to process") + count = 0 + + for album_request in album_requests: + if album_request.authorized: + album = music_repository.get_album(album_id=album_request.album_id) + artist = music_repository.get_artist_by_id( + artist_id=ArtistId(album_request.artist.id) + ) + if music_service.download_approved_album_request( + album_request=album_request, + artist=artist, + album_name=album.name, + ): + count += 1 + else: + log.info( + f"Could not download album request {album_request.id}" + ) + + log.info(f"Auto downloaded {count} approved album requests") + db.commit() + db.close() + + +def import_all_music_torrents() -> None: + with next(get_session()) as db: + music_repository = MusicRepository(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) + ) + music_service = MusicService( + music_repository=music_repository, + torrent_service=torrent_service, + indexer_service=indexer_service, + notification_service=notification_service, + ) + log.info("Importing all music torrents") + torrents = torrent_service.get_all_torrents() + for t in torrents: + try: + if not t.imported and t.status == TorrentStatus.finished: + artist = music_repository.get_artist_by_torrent_id( + torrent_id=t.id + ) + if artist is None: + continue + music_service.import_music_files(torrent=t, artist=artist) + except RuntimeError: + log.exception(f"Failed to import torrent {t.title}") + log.info("Finished importing all music torrents") + db.commit() + + +def update_all_artists_metadata() -> None: + with next(get_session()) as db: + music_repository = MusicRepository(db=db) + music_service = MusicService( + music_repository=music_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 artists") + artists = music_repository.get_artists() + log.info(f"Found {len(artists)} artists to update") + + for artist in artists: + try: + if artist.metadata_provider == "musicbrainz": + metadata_provider = MusicBrainzMetadataProvider() + else: + log.error( + f"Unsupported metadata provider {artist.metadata_provider} for artist {artist.name}, skipping update." + ) + continue + except Exception: + log.exception( + f"Error initializing metadata provider {artist.metadata_provider} for artist {artist.name}", + ) + continue + music_service.update_artist_metadata( + db_artist=artist, metadata_provider=metadata_provider + ) + db.commit() diff --git a/media_manager/scheduler.py b/media_manager/scheduler.py index 9c9618d2..2cb498a7 100644 --- a/media_manager/scheduler.py +++ b/media_manager/scheduler.py @@ -3,12 +3,22 @@ from apscheduler.triggers.cron import CronTrigger import media_manager.database +from media_manager.books.service import ( + auto_download_all_approved_book_requests, + import_all_book_torrents, + update_all_book_authors_metadata, +) from media_manager.config import MediaManagerConfig from media_manager.movies.service import ( auto_download_all_approved_movie_requests, import_all_movie_torrents, update_all_movies_metadata, ) +from media_manager.music.service import ( + auto_download_all_approved_album_requests, + import_all_music_torrents, + update_all_artists_metadata, +) from media_manager.tv.service import ( auto_download_all_approved_season_requests, import_all_show_torrents, @@ -63,5 +73,41 @@ def setup_scheduler(config: MediaManagerConfig) -> BackgroundScheduler: id="update_all_non_ended_shows_metadata", replace_existing=True, ) + scheduler.add_job( + import_all_music_torrents, + every_15_minutes_trigger, + id="import_all_music_torrents", + replace_existing=True, + ) + scheduler.add_job( + auto_download_all_approved_album_requests, + daily_trigger, + id="auto_download_all_approved_album_requests", + replace_existing=True, + ) + scheduler.add_job( + update_all_artists_metadata, + weekly_trigger, + id="update_all_artists_metadata", + replace_existing=True, + ) + scheduler.add_job( + import_all_book_torrents, + every_15_minutes_trigger, + id="import_all_book_torrents", + replace_existing=True, + ) + scheduler.add_job( + auto_download_all_approved_book_requests, + daily_trigger, + id="auto_download_all_approved_book_requests", + replace_existing=True, + ) + scheduler.add_job( + update_all_book_authors_metadata, + weekly_trigger, + id="update_all_book_authors_metadata", + replace_existing=True, + ) scheduler.start() return scheduler diff --git a/media_manager/torrent/models.py b/media_manager/torrent/models.py index 296c879d..4925242d 100644 --- a/media_manager/torrent/models.py +++ b/media_manager/torrent/models.py @@ -18,3 +18,5 @@ class Torrent(Base): season_files = relationship("SeasonFile", back_populates="torrent") movie_files = relationship("MovieFile", back_populates="torrent") + album_files = relationship("AlbumFile", back_populates="torrent") + book_files = relationship("BookFile", back_populates="torrent") diff --git a/media_manager/torrent/repository.py b/media_manager/torrent/repository.py index 6e20ef42..93fbf2c9 100644 --- a/media_manager/torrent/repository.py +++ b/media_manager/torrent/repository.py @@ -1,5 +1,10 @@ from sqlalchemy import delete, select +from media_manager.books.models import Author as BookAuthorModel +from media_manager.books.models import Book as BookModel +from media_manager.books.models import BookFile +from media_manager.books.schemas import Author as BookAuthorSchema +from media_manager.books.schemas import BookFile as BookFileSchema from media_manager.database import DbSessionDependency from media_manager.exceptions import NotFoundError from media_manager.movies.models import Movie, MovieFile @@ -9,6 +14,11 @@ from media_manager.movies.schemas import ( MovieFile as MovieFileSchema, ) +from media_manager.music.models import Album as AlbumModel +from media_manager.music.models import AlbumFile +from media_manager.music.models import Artist as ArtistModel +from media_manager.music.schemas import AlbumFile as AlbumFileSchema +from media_manager.music.schemas import Artist as ArtistSchema from media_manager.torrent.models import Torrent from media_manager.torrent.schemas import Torrent as TorrentSchema from media_manager.torrent.schemas import TorrentId @@ -74,6 +84,16 @@ def delete_torrent( ) self.db.execute(season_files_stmt) + album_files_stmt = delete(AlbumFile).where( + AlbumFile.torrent_id == torrent_id + ) + self.db.execute(album_files_stmt) + + book_files_stmt = delete(BookFile).where( + BookFile.torrent_id == torrent_id + ) + self.db.execute(book_files_stmt) + self.db.delete(self.db.get(Torrent, torrent_id)) def get_movie_of_torrent(self, torrent_id: TorrentId) -> MovieSchema | None: @@ -93,3 +113,41 @@ def get_movie_files_of_torrent( 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] + + def get_artist_of_torrent(self, torrent_id: TorrentId) -> ArtistSchema | None: + stmt = ( + select(ArtistModel) + .join(AlbumModel, ArtistModel.id == AlbumModel.artist_id) + .join(AlbumFile, AlbumModel.id == AlbumFile.album_id) + .where(AlbumFile.torrent_id == torrent_id) + ) + result = self.db.execute(stmt).unique().scalar_one_or_none() + if result is None: + return None + return ArtistSchema.model_validate(result) + + def get_album_files_of_torrent( + self, torrent_id: TorrentId + ) -> list[AlbumFileSchema]: + stmt = select(AlbumFile).where(AlbumFile.torrent_id == torrent_id) + result = self.db.execute(stmt).scalars().all() + return [AlbumFileSchema.model_validate(album_file) for album_file in result] + + def get_book_author_of_torrent(self, torrent_id: TorrentId) -> BookAuthorSchema | None: + stmt = ( + select(BookAuthorModel) + .join(BookModel, BookAuthorModel.id == BookModel.author_id) + .join(BookFile, BookModel.id == BookFile.book_id) + .where(BookFile.torrent_id == torrent_id) + ) + result = self.db.execute(stmt).unique().scalar_one_or_none() + if result is None: + return None + return BookAuthorSchema.model_validate(result) + + def get_book_files_of_torrent( + self, torrent_id: TorrentId + ) -> list[BookFileSchema]: + stmt = select(BookFile).where(BookFile.torrent_id == torrent_id) + result = self.db.execute(stmt).scalars().all() + return [BookFileSchema.model_validate(book_file) for book_file in result] diff --git a/media_manager/torrent/service.py b/media_manager/torrent/service.py index d8c1beef..6d6501c8 100644 --- a/media_manager/torrent/service.py +++ b/media_manager/torrent/service.py @@ -1,7 +1,11 @@ import logging +from media_manager.books.schemas import Author as BookAuthorSchema +from media_manager.books.schemas import BookFile from media_manager.indexer.schemas import IndexerQueryResult from media_manager.movies.schemas import Movie, MovieFile +from media_manager.music.schemas import AlbumFile +from media_manager.music.schemas import Artist as ArtistSchema from media_manager.torrent.manager import DownloadManager from media_manager.torrent.repository import TorrentRepository from media_manager.torrent.schemas import Torrent, TorrentId @@ -111,3 +115,15 @@ def delete_torrent(self, torrent_id: TorrentId) -> None: def get_movie_files_of_torrent(self, torrent: Torrent) -> list[MovieFile]: return self.torrent_repository.get_movie_files_of_torrent(torrent_id=torrent.id) + + def get_artist_of_torrent(self, torrent: Torrent) -> ArtistSchema | None: + return self.torrent_repository.get_artist_of_torrent(torrent_id=torrent.id) + + def get_album_files_of_torrent(self, torrent: Torrent) -> list[AlbumFile]: + return self.torrent_repository.get_album_files_of_torrent(torrent_id=torrent.id) + + def get_book_author_of_torrent(self, torrent: Torrent) -> BookAuthorSchema | None: + return self.torrent_repository.get_book_author_of_torrent(torrent_id=torrent.id) + + def get_book_files_of_torrent(self, torrent: Torrent) -> list[BookFile]: + return self.torrent_repository.get_book_files_of_torrent(torrent_id=torrent.id) diff --git a/media_manager/torrent/utils.py b/media_manager/torrent/utils.py index c3e7b6cb..5a290218 100644 --- a/media_manager/torrent/utils.py +++ b/media_manager/torrent/utils.py @@ -125,6 +125,83 @@ def get_files_for_import( return video_files, subtitle_files, all_files +AUDIO_EXTENSIONS = {".flac", ".mp3", ".ogg", ".m4a", ".opus", ".wav", ".aac", ".wma", ".alac"} + +BOOK_EXTENSIONS = {".epub", ".mobi", ".pdf", ".azw3", ".fb2", ".cbz", ".cbr", ".m4b"} + + +def get_audio_files_for_import( + torrent: Torrent | None = None, directory: Path | None = None +) -> tuple[list[Path], list[Path]]: + """ + Extracts all files from the torrent download directory, including extracting archives. + Returns a tuple containing: audio files and all files found in the directory. + """ + if torrent: + log.info(f"Importing audio torrent {torrent}") + search_directory = get_torrent_filepath(torrent=torrent) + elif directory: + log.info(f"Importing audio 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") + extract_archives(all_files) + all_files = list_files_recursively(path=search_directory) + + audio_files: list[Path] = [] + for file in all_files: + if file.suffix.lower() in AUDIO_EXTENSIONS: + audio_files.append(file) + log.debug(f"File is audio, it will be imported: {file}") + else: + log.debug(f"File is not audio, will not be imported: {file}") + + log.info( + f"Found {len(all_files)} files ({len(audio_files)} audio files) for further processing." + ) + return audio_files, all_files + + +def get_book_files_for_import( + torrent: Torrent | None = None, directory: Path | None = None +) -> tuple[list[Path], list[Path]]: + """ + Extracts all files from the torrent download directory, including extracting archives. + Returns a tuple containing: book files and all files found in the directory. + """ + if torrent: + log.info(f"Importing book torrent {torrent}") + search_directory = get_torrent_filepath(torrent=torrent) + elif directory: + log.info(f"Importing book 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") + extract_archives(all_files) + all_files = list_files_recursively(path=search_directory) + + book_files: list[Path] = [] + for file in all_files: + if file.suffix.lower() in BOOK_EXTENSIONS: + book_files.append(file) + log.debug(f"File is a book, it will be imported: {file}") + else: + log.debug(f"File is not a book, will not be imported: {file}") + + log.info( + f"Found {len(all_files)} files ({len(book_files)} book files) for further processing." + ) + return book_files, all_files + + def get_torrent_hash(torrent: IndexerQueryResult) -> str: """ Helper method to get the torrent hash from the torrent object. @@ -221,6 +298,8 @@ def get_importable_media_directories(path: Path) -> list[Path]: libraries = [ *MediaManagerConfig().misc.movie_libraries, *MediaManagerConfig().misc.tv_libraries, + *MediaManagerConfig().misc.music_libraries, + *MediaManagerConfig().misc.books_libraries, ] library_paths = {Path(library.path).absolute() for library in libraries} diff --git a/pyproject.toml b/pyproject.toml index a6d1efd7..eab5aea0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "libtorrent>=2.0.11", "pathvalidate>=3.3.1", "asgi-correlation-id>=4.3.4", + "musicbrainzngs>=0.7.1", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 7d7c5951..a80be31b 100644 --- a/uv.lock +++ b/uv.lock @@ -878,6 +878,7 @@ dependencies = [ { name = "httpx-oauth" }, { name = "jsonschema" }, { name = "libtorrent" }, + { name = "musicbrainzngs" }, { name = "pathvalidate" }, { name = "patool" }, { name = "pillow" }, @@ -919,6 +920,7 @@ requires-dist = [ { name = "httpx-oauth", specifier = ">=0.16.1" }, { name = "jsonschema", specifier = ">=4.24.0" }, { name = "libtorrent", specifier = ">=2.0.11" }, + { name = "musicbrainzngs", specifier = ">=0.7.1" }, { name = "pathvalidate", specifier = ">=3.3.1" }, { name = "patool", specifier = ">=4.0.1" }, { name = "pillow", specifier = ">=11.3.0" }, @@ -945,6 +947,15 @@ dev = [ { name = "ty", specifier = ">=0.0.9" }, ] +[[package]] +name = "musicbrainzngs" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/67/3e74ae93d90ceeba72ed1a266dd3ca9abd625f315f0afd35f9b034acedd1/musicbrainzngs-0.7.1.tar.gz", hash = "sha256:ab1c0100fd0b305852e65f2ed4113c6de12e68afd55186987b8ed97e0f98e627", size = 117469, upload-time = "2020-01-11T17:38:47.581Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/fd/cef7b2580436910ccd2f8d3deec0f3c81743e15c0eb5b97dde3fbf33c0c8/musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10", size = 25289, upload-time = "2020-01-11T17:38:45.469Z" }, +] + [[package]] name = "mypy-extensions" version = "1.1.0" diff --git a/web/package-lock.json b/web/package-lock.json index be59f212..a096f498 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -134,9 +134,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -146,15 +146,14 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -164,15 +163,14 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -182,15 +180,14 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -200,15 +197,14 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -218,15 +214,14 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -236,15 +231,14 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -254,15 +248,14 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -272,15 +265,14 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -290,15 +282,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -308,15 +299,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -326,15 +316,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -344,15 +333,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -362,15 +350,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -380,15 +367,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -398,15 +384,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -416,15 +401,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -434,15 +418,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -452,15 +435,14 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -470,15 +452,14 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -488,15 +469,14 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -506,15 +486,14 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -524,15 +503,14 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -542,15 +520,14 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -560,15 +537,14 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -578,15 +554,14 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -596,7 +571,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2000,6 +1974,7 @@ "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -2039,6 +2014,7 @@ "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -2509,6 +2485,7 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -2755,6 +2732,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2995,6 +2973,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3107,6 +3086,7 @@ "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -3290,7 +3270,8 @@ "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-reactive-utils": { "version": "8.6.0", @@ -3330,50 +3311,6 @@ "node": ">=10.13.0" } }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, "node_modules/esbuild-runner": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/esbuild-runner/-/esbuild-runner-2.2.2.tgz", @@ -3429,6 +3366,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4877,6 +4815,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5017,6 +4956,7 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -5033,6 +4973,7 @@ "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" @@ -5192,6 +5133,7 @@ "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -5444,6 +5386,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.8.tgz", "integrity": "sha512-1Jh7FwVh/2Uxg0T7SeE1qFKMhwYH45b2v53bcZpW7qHa6O8iU1ByEj56PF0IQ6dU4HE5gRkic6h+vx+tclHeiw==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -5617,6 +5560,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "devalue": "^5.3.2", "memoize-weak": "^1.0.2", @@ -5823,7 +5767,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -5968,6 +5913,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6110,6 +6056,7 @@ "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -6792,6 +6739,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/lib/api/api.d.ts b/web/src/lib/api/api.d.ts index 4258bd34..8321a9e9 100644 --- a/web/src/lib/api/api.d.ts +++ b/web/src/lib/api/api.d.ts @@ -4,4306 +4,6535 @@ */ export interface paths { - '/api/v1/health': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Hello World */ - get: operations['hello_world_api_v1_health_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/auth/jwt/login': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Auth:Jwt.Login */ - post: operations['auth_jwt_login_api_v1_auth_jwt_login_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/auth/jwt/logout': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Auth:Jwt.Logout */ - post: operations['auth_jwt_logout_api_v1_auth_jwt_logout_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/auth/cookie/login': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Auth:Cookie.Login */ - post: operations['auth_cookie_login_api_v1_auth_cookie_login_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/auth/cookie/logout': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Auth:Cookie.Logout */ - post: operations['auth_cookie_logout_api_v1_auth_cookie_logout_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/auth/register': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Register:Register */ - post: operations['register_register_api_v1_auth_register_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/auth/forgot-password': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Reset:Forgot Password */ - post: operations['reset_forgot_password_api_v1_auth_forgot_password_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/auth/reset-password': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Reset:Reset Password */ - post: operations['reset_reset_password_api_v1_auth_reset_password_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/auth/request-verify-token': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Verify:Request-Token */ - post: operations['verify_request_token_api_v1_auth_request_verify_token_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/auth/verify': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Verify:Verify */ - post: operations['verify_verify_api_v1_auth_verify_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/users/all': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get All Users */ - get: operations['get_all_users_api_v1_users_all_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/users/me': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Users:Current User */ - get: operations['users_current_user_api_v1_users_me_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - /** Users:Patch Current User */ - patch: operations['users_patch_current_user_api_v1_users_me_patch']; - trace?: never; - }; - '/api/v1/users/{id}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Users:User */ - get: operations['users_user_api_v1_users__id__get']; - put?: never; - post?: never; - /** Users:Delete User */ - delete: operations['users_delete_user_api_v1_users__id__delete']; - options?: never; - head?: never; - /** Users:Patch User */ - patch: operations['users_patch_user_api_v1_users__id__patch']; - trace?: never; - }; - '/api/v1/auth/metadata': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Auth Metadata */ - get: operations['get_auth_metadata_api_v1_auth_metadata_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/auth/oauth/authorize': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Oauth:Oauth2.Cookie.Authorize */ - get: operations['oauth_oauth2_cookie_authorize_api_v1_auth_oauth_authorize_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/auth/oauth/callback': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Oauth:Oauth2.Cookie.Callback - * @description The response varies based on the authentication backend used. - */ - get: operations['oauth_oauth2_cookie_callback_api_v1_auth_oauth_callback_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/search': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Search Metadata Providers For A Show - * @description Search for a show on the configured metadata provider. - */ - get: operations['search_metadata_providers_for_a_show_api_v1_tv_search_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/recommended': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Recommended Shows - * @description Get a list of recommended/popular shows from the metadata provider. - */ - get: operations['get_recommended_shows_api_v1_tv_recommended_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/importable': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get All Importable Shows - * @description Get a list of unknown shows that were detected in the TV directory and are importable. - */ - get: operations['get_all_importable_shows_api_v1_tv_importable_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/importable/{show_id}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Import Detected Show - * @description Import a detected show from the specified directory into the library. - */ - post: operations['import_detected_show_api_v1_tv_importable__show_id__post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/shows': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get All Shows - * @description Get all shows in the library. - */ - get: operations['get_all_shows_api_v1_tv_shows_get']; - put?: never; - /** - * Add A Show - * @description Add a new show to the library. - */ - post: operations['add_a_show_api_v1_tv_shows_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/shows/torrents': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Shows With Torrents - * @description Get all shows that are associated with torrents. - */ - get: operations['get_shows_with_torrents_api_v1_tv_shows_torrents_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/shows/libraries': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Available Libraries - * @description Get available TV libraries from configuration. - */ - get: operations['get_available_libraries_api_v1_tv_shows_libraries_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/shows/{show_id}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get A Show - * @description Get details for a specific show. - */ - get: operations['get_a_show_api_v1_tv_shows__show_id__get']; - put?: never; - post?: never; - /** - * Delete A Show - * @description Delete a show from the library. - */ - delete: operations['delete_a_show_api_v1_tv_shows__show_id__delete']; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/shows/{show_id}/metadata': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Update Shows Metadata - * @description Update a show's metadata from the provider. - */ - post: operations['update_shows_metadata_api_v1_tv_shows__show_id__metadata_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/shows/{show_id}/continuousDownload': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Set Continuous Download - * @description Toggle whether future seasons of a show will be automatically downloaded. - */ - post: operations['set_continuous_download_api_v1_tv_shows__show_id__continuousDownload_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/shows/{show_id}/library': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Set Library - * @description Set the library path for a Show. - */ - post: operations['set_library_api_v1_tv_shows__show_id__library_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/shows/{show_id}/torrents': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get A Shows Torrents - * @description Get torrents associated with a specific show. - */ - get: operations['get_a_shows_torrents_api_v1_tv_shows__show_id__torrents_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/seasons/requests': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Season Requests - * @description Get all season requests. - */ - get: operations['get_season_requests_api_v1_tv_seasons_requests_get']; - /** - * Update Request - * @description Update an existing season request. - */ - put: operations['update_request_api_v1_tv_seasons_requests_put']; - /** - * Request A Season - * @description Create a new season request. - */ - post: operations['request_a_season_api_v1_tv_seasons_requests_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/seasons/requests/{season_request_id}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - /** - * Authorize Request - * @description Authorize or de-authorize a season request. - */ - patch: operations['authorize_request_api_v1_tv_seasons_requests__season_request_id__patch']; - trace?: never; - }; - '/api/v1/tv/seasons/requests/{request_id}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - /** - * Delete Season Request - * @description Delete a season request. - */ - delete: operations['delete_season_request_api_v1_tv_seasons_requests__request_id__delete']; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/seasons/{season_id}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Season - * @description Get details for a specific season. - */ - get: operations['get_season_api_v1_tv_seasons__season_id__get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/seasons/{season_id}/files': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Season Files - * @description Get files associated with a specific season. - */ - get: operations['get_season_files_api_v1_tv_seasons__season_id__files_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/torrents': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Torrents For A Season - * @description Search for torrents for a specific season of a show. - * Default season_number is 1 because it often returns multi-season torrents. - */ - get: operations['get_torrents_for_a_season_api_v1_tv_torrents_get']; - put?: never; - /** - * Download A Torrent - * @description Trigger a download for a specific torrent. - */ - post: operations['download_a_torrent_api_v1_tv_torrents_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/tv/episodes/count': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Total Count Of Downloaded Episodes - * @description Total number of episodes downloaded - */ - get: operations['get_total_count_of_downloaded_episodes_api_v1_tv_episodes_count_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/torrent': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get All Torrents */ - get: operations['get_all_torrents_api_v1_torrent_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/torrent/{torrent_id}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Torrent */ - get: operations['get_torrent_api_v1_torrent__torrent_id__get']; - put?: never; - post?: never; - /** Delete Torrent */ - delete: operations['delete_torrent_api_v1_torrent__torrent_id__delete']; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/torrent/{torrent_id}/retry': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Retry Torrent Download */ - post: operations['retry_torrent_download_api_v1_torrent__torrent_id__retry_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/torrent/{torrent_id}/status': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - /** Update Torrent Status */ - patch: operations['update_torrent_status_api_v1_torrent__torrent_id__status_patch']; - trace?: never; - }; - '/api/v1/movies/search': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Search For Movie - * @description Search for a movie on the configured metadata provider. - */ - get: operations['search_for_movie_api_v1_movies_search_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/movies/recommended': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Popular Movies - * @description Get a list of recommended/popular movies from the metadata provider. - */ - get: operations['get_popular_movies_api_v1_movies_recommended_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/movies/importable': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get All Importable Movies - * @description Get a list of unknown movies that were detected in the movie directory and are importable. - */ - get: operations['get_all_importable_movies_api_v1_movies_importable_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/movies/importable/{movie_id}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Import Detected Movie - * @description Import a detected movie from the specified directory into the library. - */ - post: operations['import_detected_movie_api_v1_movies_importable__movie_id__post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/movies': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get All Movies - * @description Get all movies in the library. - */ - get: operations['get_all_movies_api_v1_movies_get']; - put?: never; - /** - * Add A Movie - * @description Add a new movie to the library. - */ - post: operations['add_a_movie_api_v1_movies_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/movies/torrents': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get All Movies With Torrents - * @description Get all movies that are associated with torrents. - */ - get: operations['get_all_movies_with_torrents_api_v1_movies_torrents_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/movies/libraries': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Available Libraries - * @description Get available Movie libraries from configuration. - */ - get: operations['get_available_libraries_api_v1_movies_libraries_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/movies/requests': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get All Movie Requests - * @description Get all movie requests. - */ - get: operations['get_all_movie_requests_api_v1_movies_requests_get']; - put?: never; - /** - * Create Movie Request - * @description Create a new movie request. - */ - post: operations['create_movie_request_api_v1_movies_requests_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/movies/requests/{movie_request_id}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - /** - * Update Movie Request - * @description Update an existing movie request. - */ - put: operations['update_movie_request_api_v1_movies_requests__movie_request_id__put']; - post?: never; - /** - * Delete Movie Request - * @description Delete a movie request. - */ - delete: operations['delete_movie_request_api_v1_movies_requests__movie_request_id__delete']; - options?: never; - head?: never; - /** - * Authorize Request - * @description Authorize or de-authorize a movie request. - */ - patch: operations['authorize_request_api_v1_movies_requests__movie_request_id__patch']; - trace?: never; - }; - '/api/v1/movies/{movie_id}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Movie By Id - * @description Get details for a specific movie. - */ - get: operations['get_movie_by_id_api_v1_movies__movie_id__get']; - put?: never; - post?: never; - /** - * Delete A Movie - * @description Delete a movie from the library. - */ - delete: operations['delete_a_movie_api_v1_movies__movie_id__delete']; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/movies/{movie_id}/library': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Set Library - * @description Set the library path for a Movie. - */ - post: operations['set_library_api_v1_movies__movie_id__library_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/movies/{movie_id}/files': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Movie Files By Movie Id - * @description Get files associated with a specific movie. - */ - get: operations['get_movie_files_by_movie_id_api_v1_movies__movie_id__files_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/movies/{movie_id}/torrents': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Search For Torrents For Movie - * @description Search for torrents for a specific movie. - */ - get: operations['search_for_torrents_for_movie_api_v1_movies__movie_id__torrents_get']; - put?: never; - /** - * Download Torrent For Movie - * @description Trigger a download for a specific torrent for a movie. - */ - post: operations['download_torrent_for_movie_api_v1_movies__movie_id__torrents_post']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/notification': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get All Notifications - * @description Get all notifications. - */ - get: operations['get_all_notifications_api_v1_notification_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/notification/unread': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Unread Notifications - * @description Get all unread notifications. - */ - get: operations['get_unread_notifications_api_v1_notification_unread_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/notification/{notification_id}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Notification - * @description Get a specific notification by ID. - */ - get: operations['get_notification_api_v1_notification__notification_id__get']; - put?: never; - post?: never; - /** - * Delete Notification - * @description Delete a notification. - */ - delete: operations['delete_notification_api_v1_notification__notification_id__delete']; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/v1/notification/{notification_id}/read': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - /** - * Mark Notification As Read - * @description Mark a notification as read. - */ - patch: operations['mark_notification_as_read_api_v1_notification__notification_id__read_patch']; - trace?: never; - }; - '/api/v1/notification/{notification_id}/unread': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - /** - * Mark Notification As Unread - * @description Mark a notification as unread. - */ - patch: operations['mark_notification_as_unread_api_v1_notification__notification_id__unread_patch']; - trace?: never; - }; - '/': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Root */ - get: operations['root__get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/dashboard': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Dashboard */ - get: operations['dashboard_dashboard_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/login': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Login */ - get: operations['login_login_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + "/api/v1/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Hello World */ + get: operations["hello_world_api_v1_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/jwt/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Auth:Jwt.Login */ + post: operations["auth_jwt_login_api_v1_auth_jwt_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/jwt/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Auth:Jwt.Logout */ + post: operations["auth_jwt_logout_api_v1_auth_jwt_logout_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/cookie/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Auth:Cookie.Login */ + post: operations["auth_cookie_login_api_v1_auth_cookie_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/cookie/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Auth:Cookie.Logout */ + post: operations["auth_cookie_logout_api_v1_auth_cookie_logout_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Register:Register */ + post: operations["register_register_api_v1_auth_register_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/forgot-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reset:Forgot Password */ + post: operations["reset_forgot_password_api_v1_auth_forgot_password_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/reset-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reset:Reset Password */ + post: operations["reset_reset_password_api_v1_auth_reset_password_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/request-verify-token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Verify:Request-Token */ + post: operations["verify_request_token_api_v1_auth_request_verify_token_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Verify:Verify */ + post: operations["verify_verify_api_v1_auth_verify_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/users/all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get All Users */ + get: operations["get_all_users_api_v1_users_all_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/users/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Users:Current User */ + get: operations["users_current_user_api_v1_users_me_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Users:Patch Current User */ + patch: operations["users_patch_current_user_api_v1_users_me_patch"]; + trace?: never; + }; + "/api/v1/users/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Users:User */ + get: operations["users_user_api_v1_users__id__get"]; + put?: never; + post?: never; + /** Users:Delete User */ + delete: operations["users_delete_user_api_v1_users__id__delete"]; + options?: never; + head?: never; + /** Users:Patch User */ + patch: operations["users_patch_user_api_v1_users__id__patch"]; + trace?: never; + }; + "/api/v1/auth/metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Auth Metadata */ + get: operations["get_auth_metadata_api_v1_auth_metadata_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/oauth/authorize": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Oauth:Oauth2.Cookie.Authorize */ + get: operations["oauth_oauth2_cookie_authorize_api_v1_auth_oauth_authorize_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/oauth/callback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Oauth:Oauth2.Cookie.Callback + * @description The response varies based on the authentication backend used. + */ + get: operations["oauth_oauth2_cookie_callback_api_v1_auth_oauth_callback_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search Metadata Providers For A Show + * @description Search for a show on the configured metadata provider. + */ + get: operations["search_metadata_providers_for_a_show_api_v1_tv_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/recommended": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Recommended Shows + * @description Get a list of recommended/popular shows from the metadata provider. + */ + get: operations["get_recommended_shows_api_v1_tv_recommended_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/importable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Importable Shows + * @description Get a list of unknown shows that were detected in the TV directory and are importable. + */ + get: operations["get_all_importable_shows_api_v1_tv_importable_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/importable/{show_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Import Detected Show + * @description Import a detected show from the specified directory into the library. + */ + post: operations["import_detected_show_api_v1_tv_importable__show_id__post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/shows": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Shows + * @description Get all shows in the library. + */ + get: operations["get_all_shows_api_v1_tv_shows_get"]; + put?: never; + /** + * Add A Show + * @description Add a new show to the library. + */ + post: operations["add_a_show_api_v1_tv_shows_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/shows/torrents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Shows With Torrents + * @description Get all shows that are associated with torrents. + */ + get: operations["get_shows_with_torrents_api_v1_tv_shows_torrents_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/shows/libraries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Available Libraries + * @description Get available TV libraries from configuration. + */ + get: operations["get_available_libraries_api_v1_tv_shows_libraries_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/shows/{show_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get A Show + * @description Get details for a specific show. + */ + get: operations["get_a_show_api_v1_tv_shows__show_id__get"]; + put?: never; + post?: never; + /** + * Delete A Show + * @description Delete a show from the library. + */ + delete: operations["delete_a_show_api_v1_tv_shows__show_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/shows/{show_id}/metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Update Shows Metadata + * @description Update a show's metadata from the provider. + */ + post: operations["update_shows_metadata_api_v1_tv_shows__show_id__metadata_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/shows/{show_id}/continuousDownload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set Continuous Download + * @description Toggle whether future seasons of a show will be automatically downloaded. + */ + post: operations["set_continuous_download_api_v1_tv_shows__show_id__continuousDownload_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/shows/{show_id}/library": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set Library + * @description Set the library path for a Show. + */ + post: operations["set_library_api_v1_tv_shows__show_id__library_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/shows/{show_id}/torrents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get A Shows Torrents + * @description Get torrents associated with a specific show. + */ + get: operations["get_a_shows_torrents_api_v1_tv_shows__show_id__torrents_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/seasons/requests": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Season Requests + * @description Get all season requests. + */ + get: operations["get_season_requests_api_v1_tv_seasons_requests_get"]; + /** + * Update Request + * @description Update an existing season request. + */ + put: operations["update_request_api_v1_tv_seasons_requests_put"]; + /** + * Request A Season + * @description Create a new season request. + */ + post: operations["request_a_season_api_v1_tv_seasons_requests_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/seasons/requests/{season_request_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Authorize Request + * @description Authorize or de-authorize a season request. + */ + patch: operations["authorize_request_api_v1_tv_seasons_requests__season_request_id__patch"]; + trace?: never; + }; + "/api/v1/tv/seasons/requests/{request_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete Season Request + * @description Delete a season request. + */ + delete: operations["delete_season_request_api_v1_tv_seasons_requests__request_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/seasons/{season_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Season + * @description Get details for a specific season. + */ + get: operations["get_season_api_v1_tv_seasons__season_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/seasons/{season_id}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Season Files + * @description Get files associated with a specific season. + */ + get: operations["get_season_files_api_v1_tv_seasons__season_id__files_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/torrents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Torrents For A Season + * @description Search for torrents for a specific season of a show. + * Default season_number is 1 because it often returns multi-season torrents. + */ + get: operations["get_torrents_for_a_season_api_v1_tv_torrents_get"]; + put?: never; + /** + * Download A Torrent + * @description Trigger a download for a specific torrent. + */ + post: operations["download_a_torrent_api_v1_tv_torrents_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tv/episodes/count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Total Count Of Downloaded Episodes + * @description Total number of episodes downloaded + */ + get: operations["get_total_count_of_downloaded_episodes_api_v1_tv_episodes_count_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/torrent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get All Torrents */ + get: operations["get_all_torrents_api_v1_torrent_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/torrent/{torrent_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Torrent */ + get: operations["get_torrent_api_v1_torrent__torrent_id__get"]; + put?: never; + post?: never; + /** Delete Torrent */ + delete: operations["delete_torrent_api_v1_torrent__torrent_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/torrent/{torrent_id}/retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Retry Torrent Download */ + post: operations["retry_torrent_download_api_v1_torrent__torrent_id__retry_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/torrent/{torrent_id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update Torrent Status */ + patch: operations["update_torrent_status_api_v1_torrent__torrent_id__status_patch"]; + trace?: never; + }; + "/api/v1/movies/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search For Movie + * @description Search for a movie on the configured metadata provider. + */ + get: operations["search_for_movie_api_v1_movies_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/movies/recommended": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Popular Movies + * @description Get a list of recommended/popular movies from the metadata provider. + */ + get: operations["get_popular_movies_api_v1_movies_recommended_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/movies/importable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Importable Movies + * @description Get a list of unknown movies that were detected in the movie directory and are importable. + */ + get: operations["get_all_importable_movies_api_v1_movies_importable_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/movies/importable/{movie_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Import Detected Movie + * @description Import a detected movie from the specified directory into the library. + */ + post: operations["import_detected_movie_api_v1_movies_importable__movie_id__post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/movies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Movies + * @description Get all movies in the library. + */ + get: operations["get_all_movies_api_v1_movies_get"]; + put?: never; + /** + * Add A Movie + * @description Add a new movie to the library. + */ + post: operations["add_a_movie_api_v1_movies_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/movies/torrents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Movies With Torrents + * @description Get all movies that are associated with torrents. + */ + get: operations["get_all_movies_with_torrents_api_v1_movies_torrents_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/movies/libraries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Available Libraries + * @description Get available Movie libraries from configuration. + */ + get: operations["get_available_libraries_api_v1_movies_libraries_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/movies/requests": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Movie Requests + * @description Get all movie requests. + */ + get: operations["get_all_movie_requests_api_v1_movies_requests_get"]; + put?: never; + /** + * Create Movie Request + * @description Create a new movie request. + */ + post: operations["create_movie_request_api_v1_movies_requests_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/movies/requests/{movie_request_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update Movie Request + * @description Update an existing movie request. + */ + put: operations["update_movie_request_api_v1_movies_requests__movie_request_id__put"]; + post?: never; + /** + * Delete Movie Request + * @description Delete a movie request. + */ + delete: operations["delete_movie_request_api_v1_movies_requests__movie_request_id__delete"]; + options?: never; + head?: never; + /** + * Authorize Request + * @description Authorize or de-authorize a movie request. + */ + patch: operations["authorize_request_api_v1_movies_requests__movie_request_id__patch"]; + trace?: never; + }; + "/api/v1/movies/{movie_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Movie By Id + * @description Get details for a specific movie. + */ + get: operations["get_movie_by_id_api_v1_movies__movie_id__get"]; + put?: never; + post?: never; + /** + * Delete A Movie + * @description Delete a movie from the library. + */ + delete: operations["delete_a_movie_api_v1_movies__movie_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/movies/{movie_id}/library": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set Library + * @description Set the library path for a Movie. + */ + post: operations["set_library_api_v1_movies__movie_id__library_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/movies/{movie_id}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Movie Files By Movie Id + * @description Get files associated with a specific movie. + */ + get: operations["get_movie_files_by_movie_id_api_v1_movies__movie_id__files_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/movies/{movie_id}/torrents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search For Torrents For Movie + * @description Search for torrents for a specific movie. + */ + get: operations["search_for_torrents_for_movie_api_v1_movies__movie_id__torrents_get"]; + put?: never; + /** + * Download Torrent For Movie + * @description Trigger a download for a specific torrent for a movie. + */ + post: operations["download_torrent_for_movie_api_v1_movies__movie_id__torrents_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/music/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search For Artist + * @description Search for an artist on the configured music metadata provider. + */ + get: operations["search_for_artist_api_v1_music_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/music/recommended": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Popular Artists + * @description Get a list of trending/popular artists from ListenBrainz. + */ + get: operations["get_popular_artists_api_v1_music_recommended_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/music/artists": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Artists + * @description Get all artists in the library. + */ + get: operations["get_all_artists_api_v1_music_artists_get"]; + put?: never; + /** + * Add An Artist + * @description Add a new artist to the library. + */ + post: operations["add_an_artist_api_v1_music_artists_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/music/artists/torrents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Artists With Torrents + * @description Get all artists that are associated with torrents. + */ + get: operations["get_all_artists_with_torrents_api_v1_music_artists_torrents_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/music/artists/libraries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Available Libraries + * @description Get available music libraries from configuration. + */ + get: operations["get_available_libraries_api_v1_music_artists_libraries_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/music/albums/requests": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Album Requests + * @description Get all album requests. + */ + get: operations["get_all_album_requests_api_v1_music_albums_requests_get"]; + put?: never; + /** + * Create Album Request + * @description Create a new album request. + */ + post: operations["create_album_request_api_v1_music_albums_requests_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/music/albums/requests/{album_request_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update Album Request + * @description Update an existing album request. + */ + put: operations["update_album_request_api_v1_music_albums_requests__album_request_id__put"]; + post?: never; + /** + * Delete Album Request + * @description Delete an album request. + */ + delete: operations["delete_album_request_api_v1_music_albums_requests__album_request_id__delete"]; + options?: never; + head?: never; + /** + * Authorize Album Request + * @description Authorize or de-authorize an album request. + */ + patch: operations["authorize_album_request_api_v1_music_albums_requests__album_request_id__patch"]; + trace?: never; + }; + "/api/v1/music/artists/{artist_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Artist By Id + * @description Get details for a specific artist. + */ + get: operations["get_artist_by_id_api_v1_music_artists__artist_id__get"]; + put?: never; + post?: never; + /** + * Delete An Artist + * @description Delete an artist from the library. + */ + delete: operations["delete_an_artist_api_v1_music_artists__artist_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/music/artists/{artist_id}/metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Update Artist Metadata + * @description Refresh metadata for an artist from the metadata provider. + */ + post: operations["update_artist_metadata_api_v1_music_artists__artist_id__metadata_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/music/artists/{artist_id}/library": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set Library + * @description Set the library path for an artist. + */ + post: operations["set_library_api_v1_music_artists__artist_id__library_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/music/artists/{artist_id}/torrents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Torrents For Artist + * @description Get torrents associated with an artist. + */ + get: operations["get_torrents_for_artist_api_v1_music_artists__artist_id__torrents_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/music/albums/{album_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Album By Id + * @description Get details for a specific album. + */ + get: operations["get_album_by_id_api_v1_music_albums__album_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/music/albums/{album_id}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Album Files + * @description Get files associated with a specific album. + */ + get: operations["get_album_files_api_v1_music_albums__album_id__files_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/music/torrents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search Torrents For Album + * @description Search for torrents for a specific album. + */ + get: operations["search_torrents_for_album_api_v1_music_torrents_get"]; + put?: never; + /** + * Download A Torrent + * @description Download a torrent for a specific album. + */ + post: operations["download_a_torrent_api_v1_music_torrents_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/books/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search For Author + * @description Search for an author on the configured book metadata provider. + */ + get: operations["search_for_author_api_v1_books_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/books/recommended": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Popular Authors + * @description Get a list of trending/popular authors from OpenLibrary. + */ + get: operations["get_popular_authors_api_v1_books_recommended_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/books/authors": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Authors + * @description Get all authors in the library. + */ + get: operations["get_all_authors_api_v1_books_authors_get"]; + put?: never; + /** + * Add An Author + * @description Add a new author to the library. + */ + post: operations["add_an_author_api_v1_books_authors_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/books/authors/torrents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Authors With Torrents + * @description Get all authors that are associated with torrents. + */ + get: operations["get_all_authors_with_torrents_api_v1_books_authors_torrents_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/books/authors/libraries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Available Libraries + * @description Get available book libraries from configuration. + */ + get: operations["get_available_libraries_api_v1_books_authors_libraries_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/books/books/requests": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Book Requests + * @description Get all book requests. + */ + get: operations["get_all_book_requests_api_v1_books_books_requests_get"]; + put?: never; + /** + * Create Book Request + * @description Create a new book request. + */ + post: operations["create_book_request_api_v1_books_books_requests_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/books/books/requests/{book_request_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update Book Request + * @description Update an existing book request. + */ + put: operations["update_book_request_api_v1_books_books_requests__book_request_id__put"]; + post?: never; + /** + * Delete Book Request + * @description Delete a book request. + */ + delete: operations["delete_book_request_api_v1_books_books_requests__book_request_id__delete"]; + options?: never; + head?: never; + /** + * Authorize Book Request + * @description Authorize or de-authorize a book request. + */ + patch: operations["authorize_book_request_api_v1_books_books_requests__book_request_id__patch"]; + trace?: never; + }; + "/api/v1/books/authors/{author_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Author By Id + * @description Get details for a specific author. + */ + get: operations["get_author_by_id_api_v1_books_authors__author_id__get"]; + put?: never; + post?: never; + /** + * Delete An Author + * @description Delete an author from the library. + */ + delete: operations["delete_an_author_api_v1_books_authors__author_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/books/authors/{author_id}/metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Update Author Metadata + * @description Refresh metadata for an author from the metadata provider. + */ + post: operations["update_author_metadata_api_v1_books_authors__author_id__metadata_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/books/authors/{author_id}/library": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set Library + * @description Set the library path for an author. + */ + post: operations["set_library_api_v1_books_authors__author_id__library_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/books/authors/{author_id}/torrents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Torrents For Author + * @description Get torrents associated with an author. + */ + get: operations["get_torrents_for_author_api_v1_books_authors__author_id__torrents_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/books/books/{book_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Book By Id + * @description Get details for a specific book. + */ + get: operations["get_book_by_id_api_v1_books_books__book_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/books/books/{book_id}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Book Files + * @description Get files associated with a specific book. + */ + get: operations["get_book_files_api_v1_books_books__book_id__files_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/books/torrents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search Torrents For Book + * @description Search for torrents for a specific book. + */ + get: operations["search_torrents_for_book_api_v1_books_torrents_get"]; + put?: never; + /** + * Download A Torrent + * @description Download a torrent for a specific book. + */ + post: operations["download_a_torrent_api_v1_books_torrents_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/notification": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Notifications + * @description Get all notifications. + */ + get: operations["get_all_notifications_api_v1_notification_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/notification/unread": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Unread Notifications + * @description Get all unread notifications. + */ + get: operations["get_unread_notifications_api_v1_notification_unread_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/notification/{notification_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Notification + * @description Get a specific notification by ID. + */ + get: operations["get_notification_api_v1_notification__notification_id__get"]; + put?: never; + post?: never; + /** + * Delete Notification + * @description Delete a notification. + */ + delete: operations["delete_notification_api_v1_notification__notification_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/notification/{notification_id}/read": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Mark Notification As Read + * @description Mark a notification as read. + */ + patch: operations["mark_notification_as_read_api_v1_notification__notification_id__read_patch"]; + trace?: never; + }; + "/api/v1/notification/{notification_id}/unread": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Mark Notification As Unread + * @description Mark a notification as unread. + */ + patch: operations["mark_notification_as_unread_api_v1_notification__notification_id__unread_patch"]; + trace?: never; + }; + "/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Root */ + get: operations["root__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dashboard": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Dashboard */ + get: operations["dashboard_dashboard_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Login */ + get: operations["login_login_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { - schemas: { - /** AuthMetadata */ - AuthMetadata: { - /** Oauth Providers */ - oauth_providers: string[]; - }; - /** BearerResponse */ - BearerResponse: { - /** Access Token */ - access_token: string; - /** Token Type */ - token_type: string; - }; - /** Body_auth_cookie_login_api_v1_auth_cookie_login_post */ - Body_auth_cookie_login_api_v1_auth_cookie_login_post: { - /** Grant Type */ - grant_type?: string | null; - /** Username */ - username: string; - /** - * Password - * Format: password - */ - password: string; - /** - * Scope - * @default - */ - scope: string; - /** Client Id */ - client_id?: string | null; - /** - * Client Secret - * Format: password - */ - client_secret?: string | null; - }; - /** Body_auth_jwt_login_api_v1_auth_jwt_login_post */ - Body_auth_jwt_login_api_v1_auth_jwt_login_post: { - /** Grant Type */ - grant_type?: string | null; - /** Username */ - username: string; - /** - * Password - * Format: password - */ - password: string; - /** - * Scope - * @default - */ - scope: string; - /** Client Id */ - client_id?: string | null; - /** - * Client Secret - * Format: password - */ - client_secret?: string | null; - }; - /** Body_reset_forgot_password_api_v1_auth_forgot_password_post */ - Body_reset_forgot_password_api_v1_auth_forgot_password_post: { - /** - * Email - * Format: email - */ - email: string; - }; - /** Body_reset_reset_password_api_v1_auth_reset_password_post */ - Body_reset_reset_password_api_v1_auth_reset_password_post: { - /** Token */ - token: string; - /** Password */ - password: string; - }; - /** Body_verify_request_token_api_v1_auth_request_verify_token_post */ - Body_verify_request_token_api_v1_auth_request_verify_token_post: { - /** - * Email - * Format: email - */ - email: string; - }; - /** Body_verify_verify_api_v1_auth_verify_post */ - Body_verify_verify_api_v1_auth_verify_post: { - /** Token */ - token: string; - }; - /** CreateMovieRequest */ - CreateMovieRequest: { - min_quality: components['schemas']['Quality']; - wanted_quality: components['schemas']['Quality']; - /** - * Movie Id - * Format: uuid - */ - movie_id: string; - }; - /** CreateSeasonRequest */ - CreateSeasonRequest: { - min_quality: components['schemas']['Quality']; - wanted_quality: components['schemas']['Quality']; - /** - * Season Id - * Format: uuid - */ - season_id: string; - }; - /** Episode */ - Episode: { - /** - * Id - * Format: uuid - */ - id?: string; - /** Number */ - number: number; - /** External Id */ - external_id: number; - /** Title */ - title: string; - }; - /** ErrorModel */ - ErrorModel: { - /** Detail */ - detail: - | string - | { - [key: string]: string; - }; - }; - /** HTTPValidationError */ - HTTPValidationError: { - /** Detail */ - detail?: components['schemas']['ValidationError'][]; - }; - /** IndexerQueryResult */ - IndexerQueryResult: { - /** - * Id - * Format: uuid - */ - id?: string; - /** Title */ - title: string; - /** Seeders */ - seeders: number; - /** Flags */ - flags: string[]; - /** Size */ - size: number; - /** Usenet */ - usenet: boolean; - /** Age */ - age: number; - /** - * Score - * @default 0 - */ - score: number; - /** Indexer */ - indexer: string | null; - readonly quality: components['schemas']['Quality']; - /** Season */ - readonly season: number[]; - }; - /** LibraryItem */ - LibraryItem: { - /** Name */ - name: string; - /** Path */ - path: string; - }; - /** MediaImportSuggestion */ - MediaImportSuggestion: { - /** - * Directory - * Format: path - */ - directory: string; - /** Candidates */ - candidates: components['schemas']['MetaDataProviderSearchResult'][]; - }; - /** MetaDataProviderSearchResult */ - MetaDataProviderSearchResult: { - /** Poster Path */ - poster_path: string | null; - /** Overview */ - overview: string | null; - /** Name */ - name: string; - /** External Id */ - external_id: number; - /** Year */ - year: number | null; - /** Metadata Provider */ - metadata_provider: string; - /** Added */ - added: boolean; - /** Vote Average */ - vote_average?: number | null; - /** Original Language */ - original_language?: string | null; - /** Id */ - id?: string | null; - }; - /** Movie */ - Movie: { - /** - * Id - * Format: uuid - */ - id?: string; - /** Name */ - name: string; - /** Overview */ - overview: string; - /** Year */ - year: number | null; - /** External Id */ - external_id: number; - /** Metadata Provider */ - metadata_provider: string; - /** - * Library - * @default Default - */ - library: string; - /** Original Language */ - original_language?: string | null; - /** Imdb Id */ - imdb_id?: string | null; - }; - /** MovieRequest */ - MovieRequest: { - min_quality: components['schemas']['Quality']; - wanted_quality: components['schemas']['Quality']; - /** - * Id - * Format: uuid - */ - id?: string; - /** - * Movie Id - * Format: uuid - */ - movie_id: string; - requested_by?: components['schemas']['UserRead'] | null; - /** - * Authorized - * @default false - */ - authorized: boolean; - authorized_by?: components['schemas']['UserRead'] | null; - }; - /** MovieRequestBase */ - MovieRequestBase: { - min_quality: components['schemas']['Quality']; - wanted_quality: components['schemas']['Quality']; - }; - /** MovieTorrent */ - MovieTorrent: { - /** - * Torrent Id - * Format: uuid - */ - torrent_id: string; - /** Torrent Title */ - torrent_title: string; - status: components['schemas']['TorrentStatus']; - quality: components['schemas']['Quality']; - /** Imported */ - imported: boolean; - /** File Path Suffix */ - file_path_suffix: string; - /** Usenet */ - usenet: boolean; - }; - /** Notification */ - Notification: { - /** - * Id - * Format: uuid - * @description Unique identifier for the notification - */ - id?: string; - /** - * Read - * @description Whether the notification has been read - * @default false - */ - read: boolean; - /** - * Message - * @description The content of the notification - */ - message: string; - /** - * Timestamp - * Format: date-time - * @description The timestamp of the notification - */ - timestamp?: string; - }; - /** OAuth2AuthorizeResponse */ - OAuth2AuthorizeResponse: { - /** Authorization Url */ - authorization_url: string; - }; - /** PublicMovie */ - PublicMovie: { - /** - * Id - * Format: uuid - */ - id?: string; - /** Name */ - name: string; - /** Overview */ - overview: string; - /** Year */ - year: number | null; - /** External Id */ - external_id: number; - /** Metadata Provider */ - metadata_provider: string; - /** - * Library - * @default Default - */ - library: string; - /** Original Language */ - original_language?: string | null; - /** Imdb Id */ - imdb_id?: string | null; - /** - * Downloaded - * @default false - */ - downloaded: boolean; - /** - * Torrents - * @default [] - */ - torrents: components['schemas']['MovieTorrent'][]; - }; - /** PublicMovieFile */ - PublicMovieFile: { - /** - * Movie Id - * Format: uuid - */ - movie_id: string; - /** File Path Suffix */ - file_path_suffix: string; - quality: components['schemas']['Quality']; - /** Torrent Id */ - torrent_id?: string | null; - /** - * Imported - * @default false - */ - imported: boolean; - }; - /** PublicSeason */ - PublicSeason: { - /** - * Id - * Format: uuid - */ - id: string; - /** Number */ - number: number; - /** - * Downloaded - * @default false - */ - downloaded: boolean; - /** Name */ - name: string; - /** Overview */ - overview: string; - /** External Id */ - external_id: number; - /** Episodes */ - episodes: components['schemas']['Episode'][]; - }; - /** PublicSeasonFile */ - PublicSeasonFile: { - /** - * Season Id - * Format: uuid - */ - season_id: string; - quality: components['schemas']['Quality']; - /** Torrent Id */ - torrent_id: string | null; - /** File Path Suffix */ - file_path_suffix: string; - /** - * Downloaded - * @default false - */ - downloaded: boolean; - }; - /** PublicShow */ - PublicShow: { - /** - * Id - * Format: uuid - */ - id: string; - /** Name */ - name: string; - /** Overview */ - overview: string; - /** Year */ - year: number | null; - /** External Id */ - external_id: number; - /** Metadata Provider */ - metadata_provider: string; - /** - * Ended - * @default false - */ - ended: boolean; - /** - * Continuous Download - * @default false - */ - continuous_download: boolean; - /** Library */ - library: string; - /** Seasons */ - seasons: components['schemas']['PublicSeason'][]; - }; - /** - * Quality - * @enum {integer} - */ - Quality: 1 | 2 | 3 | 4 | 5; - /** RichMovieRequest */ - RichMovieRequest: { - min_quality: components['schemas']['Quality']; - wanted_quality: components['schemas']['Quality']; - /** - * Id - * Format: uuid - */ - id?: string; - /** - * Movie Id - * Format: uuid - */ - movie_id: string; - requested_by?: components['schemas']['UserRead'] | null; - /** - * Authorized - * @default false - */ - authorized: boolean; - authorized_by?: components['schemas']['UserRead'] | null; - movie: components['schemas']['Movie']; - }; - /** RichMovieTorrent */ - RichMovieTorrent: { - /** - * Movie Id - * Format: uuid - */ - movie_id: string; - /** Name */ - name: string; - /** Year */ - year: number | null; - /** Metadata Provider */ - metadata_provider: string; - /** Torrents */ - torrents: components['schemas']['MovieTorrent'][]; - }; - /** RichSeasonRequest */ - RichSeasonRequest: { - min_quality: components['schemas']['Quality']; - wanted_quality: components['schemas']['Quality']; - /** - * Id - * Format: uuid - */ - id?: string; - /** - * Season Id - * Format: uuid - */ - season_id: string; - requested_by?: components['schemas']['UserRead'] | null; - /** - * Authorized - * @default false - */ - authorized: boolean; - authorized_by?: components['schemas']['UserRead'] | null; - show: components['schemas']['Show']; - season: components['schemas']['Season']; - }; - /** RichSeasonTorrent */ - RichSeasonTorrent: { - /** - * Torrent Id - * Format: uuid - */ - torrent_id: string; - /** Torrent Title */ - torrent_title: string; - status: components['schemas']['TorrentStatus']; - quality: components['schemas']['Quality']; - /** Imported */ - imported: boolean; - /** Usenet */ - usenet: boolean; - /** File Path Suffix */ - file_path_suffix: string; - /** Seasons */ - seasons: number[]; - }; - /** RichShowTorrent */ - RichShowTorrent: { - /** - * Show Id - * Format: uuid - */ - show_id: string; - /** Name */ - name: string; - /** Year */ - year: number | null; - /** Metadata Provider */ - metadata_provider: string; - /** Torrents */ - torrents: components['schemas']['RichSeasonTorrent'][]; - }; - /** Season */ - Season: { - /** - * Id - * Format: uuid - */ - id?: string; - /** Number */ - number: number; - /** Name */ - name: string; - /** Overview */ - overview: string; - /** External Id */ - external_id: number; - /** Episodes */ - episodes: components['schemas']['Episode'][]; - }; - /** Show */ - Show: { - /** - * Id - * Format: uuid - */ - id?: string; - /** Name */ - name: string; - /** Overview */ - overview: string; - /** Year */ - year: number | null; - /** - * Ended - * @default false - */ - ended: boolean; - /** External Id */ - external_id: number; - /** Metadata Provider */ - metadata_provider: string; - /** - * Continuous Download - * @default false - */ - continuous_download: boolean; - /** - * Library - * @default Default - */ - library: string; - /** Original Language */ - original_language?: string | null; - /** Imdb Id */ - imdb_id?: string | null; - /** Seasons */ - seasons: components['schemas']['Season'][]; - }; - /** Torrent */ - Torrent: { - /** - * Id - * Format: uuid - */ - id?: string; - status: components['schemas']['TorrentStatus']; - /** Title */ - title: string; - quality: components['schemas']['Quality']; - /** Imported */ - imported: boolean; - /** Hash */ - hash: string; - /** - * Usenet - * @default false - */ - usenet: boolean; - }; - /** - * TorrentStatus - * @enum {integer} - */ - TorrentStatus: 1 | 2 | 3 | 4; - /** UpdateSeasonRequest */ - UpdateSeasonRequest: { - min_quality: components['schemas']['Quality']; - wanted_quality: components['schemas']['Quality']; - /** - * Id - * Format: uuid - */ - id: string; - }; - /** UserCreate */ - UserCreate: { - /** - * Email - * Format: email - */ - email: string; - /** Password */ - password: string; - /** - * Is Active - * @default true - */ - is_active: boolean | null; - /** - * Is Superuser - * @default false - */ - is_superuser: boolean | null; - /** - * Is Verified - * @default false - */ - is_verified: boolean | null; - }; - /** UserRead */ - UserRead: { - /** - * Id - * Format: uuid - */ - id: string; - /** - * Email - * Format: email - */ - email: string; - /** - * Is Active - * @default true - */ - is_active: boolean; - /** - * Is Superuser - * @default false - */ - is_superuser: boolean; - /** - * Is Verified - * @default false - */ - is_verified: boolean; - }; - /** UserUpdate */ - UserUpdate: { - /** Password */ - password?: string | null; - /** Email */ - email?: string | null; - /** Is Active */ - is_active?: boolean | null; - /** Is Superuser */ - is_superuser?: boolean | null; - /** Is Verified */ - is_verified?: boolean | null; - }; - /** ValidationError */ - ValidationError: { - /** Location */ - loc: (string | number)[]; - /** Message */ - msg: string; - /** Error Type */ - type: string; - }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; + schemas: { + /** Album */ + Album: { + /** + * Id + * Format: uuid + */ + id?: string; + /** External Id */ + external_id: string; + /** Name */ + name: string; + /** Year */ + year: number | null; + /** + * Album Type + * @default album + */ + album_type: string; + /** Tracks */ + tracks: components["schemas"]["Track"][]; + }; + /** AlbumRequest */ + AlbumRequest: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + /** + * Id + * Format: uuid + */ + id?: string; + /** + * Album Id + * Format: uuid + */ + album_id: string; + requested_by?: components["schemas"]["UserRead"] | null; + /** + * Authorized + * @default false + */ + authorized: boolean; + authorized_by?: components["schemas"]["UserRead"] | null; + }; + /** AlbumRequestBase */ + AlbumRequestBase: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + }; + /** Artist */ + Artist: { + /** + * Id + * Format: uuid + */ + id?: string; + /** Name */ + name: string; + /** Overview */ + overview: string; + /** External Id */ + external_id: string; + /** Metadata Provider */ + metadata_provider: string; + /** + * Library + * @default Default + */ + library: string; + /** Country */ + country?: string | null; + /** Disambiguation */ + disambiguation?: string | null; + /** Albums */ + albums: components["schemas"]["Album"][]; + }; + /** AuthMetadata */ + AuthMetadata: { + /** Oauth Providers */ + oauth_providers: string[]; + }; + /** Author */ + Author: { + /** + * Id + * Format: uuid + */ + id?: string; + /** Name */ + name: string; + /** Overview */ + overview: string; + /** External Id */ + external_id: string; + /** Metadata Provider */ + metadata_provider: string; + /** + * Library + * @default Default + */ + library: string; + /** Books */ + books: components["schemas"]["Book"][]; + }; + /** BearerResponse */ + BearerResponse: { + /** Access Token */ + access_token: string; + /** Token Type */ + token_type: string; + }; + /** Body_auth_cookie_login_api_v1_auth_cookie_login_post */ + Body_auth_cookie_login_api_v1_auth_cookie_login_post: { + /** Grant Type */ + grant_type?: string | null; + /** Username */ + username: string; + /** + * Password + * Format: password + */ + password: string; + /** + * Scope + * @default + */ + scope: string; + /** Client Id */ + client_id?: string | null; + /** + * Client Secret + * Format: password + */ + client_secret?: string | null; + }; + /** Body_auth_jwt_login_api_v1_auth_jwt_login_post */ + Body_auth_jwt_login_api_v1_auth_jwt_login_post: { + /** Grant Type */ + grant_type?: string | null; + /** Username */ + username: string; + /** + * Password + * Format: password + */ + password: string; + /** + * Scope + * @default + */ + scope: string; + /** Client Id */ + client_id?: string | null; + /** + * Client Secret + * Format: password + */ + client_secret?: string | null; + }; + /** Body_reset_forgot_password_api_v1_auth_forgot_password_post */ + Body_reset_forgot_password_api_v1_auth_forgot_password_post: { + /** + * Email + * Format: email + */ + email: string; + }; + /** Body_reset_reset_password_api_v1_auth_reset_password_post */ + Body_reset_reset_password_api_v1_auth_reset_password_post: { + /** Token */ + token: string; + /** Password */ + password: string; + }; + /** Body_verify_request_token_api_v1_auth_request_verify_token_post */ + Body_verify_request_token_api_v1_auth_request_verify_token_post: { + /** + * Email + * Format: email + */ + email: string; + }; + /** Body_verify_verify_api_v1_auth_verify_post */ + Body_verify_verify_api_v1_auth_verify_post: { + /** Token */ + token: string; + }; + /** Book */ + Book: { + /** + * Id + * Format: uuid + */ + id?: string; + /** External Id */ + external_id: string; + /** Name */ + name: string; + /** Year */ + year: number | null; + /** @default ebook */ + format: components["schemas"]["BookFormat"]; + /** Isbn */ + isbn?: string | null; + /** Publisher */ + publisher?: string | null; + /** Page Count */ + page_count?: number | null; + }; + /** + * BookFormat + * @enum {string} + */ + BookFormat: "ebook" | "audiobook"; + /** BookRequest */ + BookRequest: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + /** + * Id + * Format: uuid + */ + id?: string; + /** + * Book Id + * Format: uuid + */ + book_id: string; + requested_by?: components["schemas"]["UserRead"] | null; + /** + * Authorized + * @default false + */ + authorized: boolean; + authorized_by?: components["schemas"]["UserRead"] | null; + }; + /** BookRequestBase */ + BookRequestBase: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + }; + /** CreateAlbumRequest */ + CreateAlbumRequest: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + /** + * Album Id + * Format: uuid + */ + album_id: string; + }; + /** CreateBookRequest */ + CreateBookRequest: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + /** + * Book Id + * Format: uuid + */ + book_id: string; + }; + /** CreateMovieRequest */ + CreateMovieRequest: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + /** + * Movie Id + * Format: uuid + */ + movie_id: string; + }; + /** CreateSeasonRequest */ + CreateSeasonRequest: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + /** + * Season Id + * Format: uuid + */ + season_id: string; + }; + /** Episode */ + Episode: { + /** + * Id + * Format: uuid + */ + id?: string; + /** Number */ + number: number; + /** External Id */ + external_id: number; + /** Title */ + title: string; + }; + /** ErrorModel */ + ErrorModel: { + /** Detail */ + detail: string | { + [key: string]: string; + }; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** IndexerQueryResult */ + IndexerQueryResult: { + /** + * Id + * Format: uuid + */ + id?: string; + /** Title */ + title: string; + /** Seeders */ + seeders: number; + /** Flags */ + flags: string[]; + /** Size */ + size: number; + /** Usenet */ + usenet: boolean; + /** Age */ + age: number; + /** + * Score + * @default 0 + */ + score: number; + /** Indexer */ + indexer: string | null; + readonly quality: components["schemas"]["Quality"]; + /** Season */ + readonly season: number[]; + }; + /** LibraryItem */ + LibraryItem: { + /** Name */ + name: string; + /** Path */ + path: string; + }; + /** MediaImportSuggestion */ + MediaImportSuggestion: { + /** + * Directory + * Format: path + */ + directory: string; + /** Candidates */ + candidates: components["schemas"]["MetaDataProviderSearchResult"][]; + }; + /** MetaDataProviderSearchResult */ + MetaDataProviderSearchResult: { + /** Poster Path */ + poster_path: string | null; + /** Overview */ + overview: string | null; + /** Name */ + name: string; + /** External Id */ + external_id: number | string; + /** Year */ + year: number | null; + /** Metadata Provider */ + metadata_provider: string; + /** Added */ + added: boolean; + /** Vote Average */ + vote_average?: number | null; + /** Original Language */ + original_language?: string | null; + /** Id */ + id?: string | null; + }; + /** Movie */ + Movie: { + /** + * Id + * Format: uuid + */ + id?: string; + /** Name */ + name: string; + /** Overview */ + overview: string; + /** Year */ + year: number | null; + /** External Id */ + external_id: number; + /** Metadata Provider */ + metadata_provider: string; + /** + * Library + * @default Default + */ + library: string; + /** Original Language */ + original_language?: string | null; + /** Imdb Id */ + imdb_id?: string | null; + }; + /** MovieRequest */ + MovieRequest: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + /** + * Id + * Format: uuid + */ + id?: string; + /** + * Movie Id + * Format: uuid + */ + movie_id: string; + requested_by?: components["schemas"]["UserRead"] | null; + /** + * Authorized + * @default false + */ + authorized: boolean; + authorized_by?: components["schemas"]["UserRead"] | null; + }; + /** MovieRequestBase */ + MovieRequestBase: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + }; + /** MovieTorrent */ + MovieTorrent: { + /** + * Torrent Id + * Format: uuid + */ + torrent_id: string; + /** Torrent Title */ + torrent_title: string; + status: components["schemas"]["TorrentStatus"]; + quality: components["schemas"]["Quality"]; + /** Imported */ + imported: boolean; + /** File Path Suffix */ + file_path_suffix: string; + /** Usenet */ + usenet: boolean; + }; + /** Notification */ + Notification: { + /** + * Id + * Format: uuid + * @description Unique identifier for the notification + */ + id?: string; + /** + * Read + * @description Whether the notification has been read + * @default false + */ + read: boolean; + /** + * Message + * @description The content of the notification + */ + message: string; + /** + * Timestamp + * Format: date-time + * @description The timestamp of the notification + */ + timestamp?: string; + }; + /** OAuth2AuthorizeResponse */ + OAuth2AuthorizeResponse: { + /** Authorization Url */ + authorization_url: string; + }; + /** PublicAlbum */ + PublicAlbum: { + /** + * Id + * Format: uuid + */ + id: string; + /** External Id */ + external_id: string; + /** Name */ + name: string; + /** Year */ + year: number | null; + /** Album Type */ + album_type: string; + /** + * Downloaded + * @default false + */ + downloaded: boolean; + /** Tracks */ + tracks: components["schemas"]["Track"][]; + }; + /** PublicAlbumFile */ + PublicAlbumFile: { + /** + * Album Id + * Format: uuid + */ + album_id: string; + quality: components["schemas"]["Quality"]; + /** Torrent Id */ + torrent_id: string | null; + /** File Path Suffix */ + file_path_suffix: string; + /** + * Downloaded + * @default false + */ + downloaded: boolean; + }; + /** PublicArtist */ + PublicArtist: { + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + /** Overview */ + overview: string; + /** External Id */ + external_id: string; + /** Metadata Provider */ + metadata_provider: string; + /** Library */ + library: string; + /** Country */ + country?: string | null; + /** Disambiguation */ + disambiguation?: string | null; + /** Albums */ + albums: components["schemas"]["PublicAlbum"][]; + }; + /** PublicAuthor */ + PublicAuthor: { + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + /** Overview */ + overview: string; + /** External Id */ + external_id: string; + /** Metadata Provider */ + metadata_provider: string; + /** Library */ + library: string; + /** Books */ + books: components["schemas"]["PublicBook"][]; + }; + /** PublicBook */ + PublicBook: { + /** + * Id + * Format: uuid + */ + id: string; + /** External Id */ + external_id: string; + /** Name */ + name: string; + /** Year */ + year: number | null; + format: components["schemas"]["BookFormat"]; + /** Isbn */ + isbn?: string | null; + /** Publisher */ + publisher?: string | null; + /** Page Count */ + page_count?: number | null; + /** + * Downloaded + * @default false + */ + downloaded: boolean; + }; + /** PublicBookFile */ + PublicBookFile: { + /** + * Book Id + * Format: uuid + */ + book_id: string; + quality: components["schemas"]["Quality"]; + /** Torrent Id */ + torrent_id: string | null; + /** File Path Suffix */ + file_path_suffix: string; + /** + * Downloaded + * @default false + */ + downloaded: boolean; + }; + /** PublicMovie */ + PublicMovie: { + /** + * Id + * Format: uuid + */ + id?: string; + /** Name */ + name: string; + /** Overview */ + overview: string; + /** Year */ + year: number | null; + /** External Id */ + external_id: number; + /** Metadata Provider */ + metadata_provider: string; + /** + * Library + * @default Default + */ + library: string; + /** Original Language */ + original_language?: string | null; + /** Imdb Id */ + imdb_id?: string | null; + /** + * Downloaded + * @default false + */ + downloaded: boolean; + /** + * Torrents + * @default [] + */ + torrents: components["schemas"]["MovieTorrent"][]; + }; + /** PublicMovieFile */ + PublicMovieFile: { + /** + * Movie Id + * Format: uuid + */ + movie_id: string; + /** File Path Suffix */ + file_path_suffix: string; + quality: components["schemas"]["Quality"]; + /** Torrent Id */ + torrent_id?: string | null; + /** + * Imported + * @default false + */ + imported: boolean; + }; + /** PublicSeason */ + PublicSeason: { + /** + * Id + * Format: uuid + */ + id: string; + /** Number */ + number: number; + /** + * Downloaded + * @default false + */ + downloaded: boolean; + /** Name */ + name: string; + /** Overview */ + overview: string; + /** External Id */ + external_id: number; + /** Episodes */ + episodes: components["schemas"]["Episode"][]; + }; + /** PublicSeasonFile */ + PublicSeasonFile: { + /** + * Season Id + * Format: uuid + */ + season_id: string; + quality: components["schemas"]["Quality"]; + /** Torrent Id */ + torrent_id: string | null; + /** File Path Suffix */ + file_path_suffix: string; + /** + * Downloaded + * @default false + */ + downloaded: boolean; + }; + /** PublicShow */ + PublicShow: { + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + /** Overview */ + overview: string; + /** Year */ + year: number | null; + /** External Id */ + external_id: number; + /** Metadata Provider */ + metadata_provider: string; + /** + * Ended + * @default false + */ + ended: boolean; + /** + * Continuous Download + * @default false + */ + continuous_download: boolean; + /** Library */ + library: string; + /** Seasons */ + seasons: components["schemas"]["PublicSeason"][]; + }; + /** + * Quality + * @enum {integer} + */ + Quality: 1 | 2 | 3 | 4 | 5; + /** RichAlbumRequest */ + RichAlbumRequest: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + /** + * Id + * Format: uuid + */ + id?: string; + /** + * Album Id + * Format: uuid + */ + album_id: string; + requested_by?: components["schemas"]["UserRead"] | null; + /** + * Authorized + * @default false + */ + authorized: boolean; + authorized_by?: components["schemas"]["UserRead"] | null; + artist: components["schemas"]["Artist"]; + album: components["schemas"]["Album"]; + }; + /** RichAlbumTorrent */ + RichAlbumTorrent: { + /** + * Torrent Id + * Format: uuid + */ + torrent_id: string; + /** Torrent Title */ + torrent_title: string; + status: components["schemas"]["TorrentStatus"]; + quality: components["schemas"]["Quality"]; + /** Imported */ + imported: boolean; + /** Usenet */ + usenet: boolean; + /** File Path Suffix */ + file_path_suffix: string; + }; + /** RichArtistTorrent */ + RichArtistTorrent: { + /** + * Artist Id + * Format: uuid + */ + artist_id: string; + /** Name */ + name: string; + /** Metadata Provider */ + metadata_provider: string; + /** Torrents */ + torrents: components["schemas"]["RichAlbumTorrent"][]; + }; + /** RichAuthorTorrent */ + RichAuthorTorrent: { + /** + * Author Id + * Format: uuid + */ + author_id: string; + /** Name */ + name: string; + /** Metadata Provider */ + metadata_provider: string; + /** Torrents */ + torrents: components["schemas"]["RichBookTorrent"][]; + }; + /** RichBookRequest */ + RichBookRequest: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + /** + * Id + * Format: uuid + */ + id?: string; + /** + * Book Id + * Format: uuid + */ + book_id: string; + requested_by?: components["schemas"]["UserRead"] | null; + /** + * Authorized + * @default false + */ + authorized: boolean; + authorized_by?: components["schemas"]["UserRead"] | null; + author: components["schemas"]["Author"]; + book: components["schemas"]["Book"]; + }; + /** RichBookTorrent */ + RichBookTorrent: { + /** + * Torrent Id + * Format: uuid + */ + torrent_id: string; + /** Torrent Title */ + torrent_title: string; + status: components["schemas"]["TorrentStatus"]; + quality: components["schemas"]["Quality"]; + /** Imported */ + imported: boolean; + /** Usenet */ + usenet: boolean; + /** File Path Suffix */ + file_path_suffix: string; + }; + /** RichMovieRequest */ + RichMovieRequest: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + /** + * Id + * Format: uuid + */ + id?: string; + /** + * Movie Id + * Format: uuid + */ + movie_id: string; + requested_by?: components["schemas"]["UserRead"] | null; + /** + * Authorized + * @default false + */ + authorized: boolean; + authorized_by?: components["schemas"]["UserRead"] | null; + movie: components["schemas"]["Movie"]; + }; + /** RichMovieTorrent */ + RichMovieTorrent: { + /** + * Movie Id + * Format: uuid + */ + movie_id: string; + /** Name */ + name: string; + /** Year */ + year: number | null; + /** Metadata Provider */ + metadata_provider: string; + /** Torrents */ + torrents: components["schemas"]["MovieTorrent"][]; + }; + /** RichSeasonRequest */ + RichSeasonRequest: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + /** + * Id + * Format: uuid + */ + id?: string; + /** + * Season Id + * Format: uuid + */ + season_id: string; + requested_by?: components["schemas"]["UserRead"] | null; + /** + * Authorized + * @default false + */ + authorized: boolean; + authorized_by?: components["schemas"]["UserRead"] | null; + show: components["schemas"]["Show"]; + season: components["schemas"]["Season"]; + }; + /** RichSeasonTorrent */ + RichSeasonTorrent: { + /** + * Torrent Id + * Format: uuid + */ + torrent_id: string; + /** Torrent Title */ + torrent_title: string; + status: components["schemas"]["TorrentStatus"]; + quality: components["schemas"]["Quality"]; + /** Imported */ + imported: boolean; + /** Usenet */ + usenet: boolean; + /** File Path Suffix */ + file_path_suffix: string; + /** Seasons */ + seasons: number[]; + }; + /** RichShowTorrent */ + RichShowTorrent: { + /** + * Show Id + * Format: uuid + */ + show_id: string; + /** Name */ + name: string; + /** Year */ + year: number | null; + /** Metadata Provider */ + metadata_provider: string; + /** Torrents */ + torrents: components["schemas"]["RichSeasonTorrent"][]; + }; + /** Season */ + Season: { + /** + * Id + * Format: uuid + */ + id?: string; + /** Number */ + number: number; + /** Name */ + name: string; + /** Overview */ + overview: string; + /** External Id */ + external_id: number; + /** Episodes */ + episodes: components["schemas"]["Episode"][]; + }; + /** Show */ + Show: { + /** + * Id + * Format: uuid + */ + id?: string; + /** Name */ + name: string; + /** Overview */ + overview: string; + /** Year */ + year: number | null; + /** + * Ended + * @default false + */ + ended: boolean; + /** External Id */ + external_id: number; + /** Metadata Provider */ + metadata_provider: string; + /** + * Continuous Download + * @default false + */ + continuous_download: boolean; + /** + * Library + * @default Default + */ + library: string; + /** Original Language */ + original_language?: string | null; + /** Imdb Id */ + imdb_id?: string | null; + /** Seasons */ + seasons: components["schemas"]["Season"][]; + }; + /** Torrent */ + Torrent: { + /** + * Id + * Format: uuid + */ + id?: string; + status: components["schemas"]["TorrentStatus"]; + /** Title */ + title: string; + quality: components["schemas"]["Quality"]; + /** Imported */ + imported: boolean; + /** Hash */ + hash: string; + /** + * Usenet + * @default false + */ + usenet: boolean; + }; + /** + * TorrentStatus + * @enum {integer} + */ + TorrentStatus: 1 | 2 | 3 | 4; + /** Track */ + Track: { + /** + * Id + * Format: uuid + */ + id?: string; + /** Number */ + number: number; + /** External Id */ + external_id: string; + /** Title */ + title: string; + /** Duration Ms */ + duration_ms?: number | null; + }; + /** UpdateSeasonRequest */ + UpdateSeasonRequest: { + min_quality: components["schemas"]["Quality"]; + wanted_quality: components["schemas"]["Quality"]; + /** + * Id + * Format: uuid + */ + id: string; + }; + /** UserCreate */ + UserCreate: { + /** + * Email + * Format: email + */ + email: string; + /** Password */ + password: string; + /** + * Is Active + * @default true + */ + is_active: boolean | null; + /** + * Is Superuser + * @default false + */ + is_superuser: boolean | null; + /** + * Is Verified + * @default false + */ + is_verified: boolean | null; + }; + /** UserRead */ + UserRead: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Email + * Format: email + */ + email: string; + /** + * Is Active + * @default true + */ + is_active: boolean; + /** + * Is Superuser + * @default false + */ + is_superuser: boolean; + /** + * Is Verified + * @default false + */ + is_verified: boolean; + }; + /** UserUpdate */ + UserUpdate: { + /** Password */ + password?: string | null; + /** Email */ + email?: string | null; + /** Is Active */ + is_active?: boolean | null; + /** Is Superuser */ + is_superuser?: boolean | null; + /** Is Verified */ + is_verified?: boolean | null; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; } export type $defs = Record; export interface operations { - hello_world_api_v1_health_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': { - [key: string]: unknown; - }; - }; - }; - }; - }; - auth_jwt_login_api_v1_auth_jwt_login_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/x-www-form-urlencoded': components['schemas']['Body_auth_jwt_login_api_v1_auth_jwt_login_post']; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiOTIyMWZmYzktNjQwZi00MzcyLTg2ZDMtY2U2NDJjYmE1NjAzIiwiYXVkIjoiZmFzdGFwaS11c2VyczphdXRoIiwiZXhwIjoxNTcxNTA0MTkzfQ.M10bjOe45I5Ncu_uXvOmVV8QxnL-nZfcH96U90JaocI", - * "token_type": "bearer" - * } - */ - 'application/json': components['schemas']['BearerResponse']; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ErrorModel']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - auth_jwt_logout_api_v1_auth_jwt_logout_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': unknown; - }; - }; - /** @description Missing token or inactive user. */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - auth_cookie_login_api_v1_auth_cookie_login_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/x-www-form-urlencoded': components['schemas']['Body_auth_cookie_login_api_v1_auth_cookie_login_post']; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': unknown; - }; - }; - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ErrorModel']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - auth_cookie_logout_api_v1_auth_cookie_logout_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': unknown; - }; - }; - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Missing token or inactive user. */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - register_register_api_v1_auth_register_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['UserCreate']; - }; - }; - responses: { - /** @description Successful Response */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['UserRead']; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ErrorModel']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - reset_forgot_password_api_v1_auth_forgot_password_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['Body_reset_forgot_password_api_v1_auth_forgot_password_post']; - }; - }; - responses: { - /** @description Successful Response */ - 202: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - reset_reset_password_api_v1_auth_reset_password_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['Body_reset_reset_password_api_v1_auth_reset_password_post']; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': unknown; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ErrorModel']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - verify_request_token_api_v1_auth_request_verify_token_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['Body_verify_request_token_api_v1_auth_request_verify_token_post']; - }; - }; - responses: { - /** @description Successful Response */ - 202: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - verify_verify_api_v1_auth_verify_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['Body_verify_verify_api_v1_auth_verify_post']; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['UserRead']; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ErrorModel']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_all_users_api_v1_users_all_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['UserRead'][]; - }; - }; - }; - }; - users_current_user_api_v1_users_me_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['UserRead']; - }; - }; - /** @description Missing token or inactive user. */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - users_patch_current_user_api_v1_users_me_patch: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['UserUpdate']; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['UserRead']; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ErrorModel']; - }; - }; - /** @description Missing token or inactive user. */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - users_user_api_v1_users__id__get: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['UserRead']; - }; - }; - /** @description Missing token or inactive user. */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not a superuser. */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description The user does not exist. */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - users_delete_user_api_v1_users__id__delete: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Missing token or inactive user. */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not a superuser. */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description The user does not exist. */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - users_patch_user_api_v1_users__id__patch: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['UserUpdate']; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['UserRead']; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ErrorModel']; - }; - }; - /** @description Missing token or inactive user. */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not a superuser. */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description The user does not exist. */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_auth_metadata_api_v1_auth_metadata_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['AuthMetadata']; - }; - }; - }; - }; - oauth_oauth2_cookie_authorize_api_v1_auth_oauth_authorize_get: { - parameters: { - query?: { - scopes?: string[]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['OAuth2AuthorizeResponse']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - oauth_oauth2_cookie_callback_api_v1_auth_oauth_callback_get: { - parameters: { - query?: { - code?: string | null; - code_verifier?: string | null; - state?: string | null; - error?: string | null; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': unknown; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ErrorModel']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - search_metadata_providers_for_a_show_api_v1_tv_search_get: { - parameters: { - query: { - query: string; - metadata_provider?: 'tmdb' | 'tvdb'; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['MetaDataProviderSearchResult'][]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_recommended_shows_api_v1_tv_recommended_get: { - parameters: { - query?: { - metadata_provider?: 'tmdb' | 'tvdb'; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['MetaDataProviderSearchResult'][]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_all_importable_shows_api_v1_tv_importable_get: { - parameters: { - query?: { - metadata_provider?: 'tmdb' | 'tvdb'; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['MediaImportSuggestion'][]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - import_detected_show_api_v1_tv_importable__show_id__post: { - parameters: { - query: { - directory: string; - }; - header?: never; - path: { - /** @description The ID of the show */ - show_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_all_shows_api_v1_tv_shows_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Show'][]; - }; - }; - }; - }; - add_a_show_api_v1_tv_shows_post: { - parameters: { - query: { - show_id: number; - language?: string | null; - metadata_provider?: 'tmdb' | 'tvdb'; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully created show */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Show']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_shows_with_torrents_api_v1_tv_shows_torrents_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['RichShowTorrent'][]; - }; - }; - }; - }; - get_available_libraries_api_v1_tv_shows_libraries_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['LibraryItem'][]; - }; - }; - }; - }; - get_a_show_api_v1_tv_shows__show_id__get: { - parameters: { - query?: never; - header?: never; - path: { - /** @description The ID of the show */ - show_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['PublicShow']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - delete_a_show_api_v1_tv_shows__show_id__delete: { - parameters: { - query?: { - delete_files_on_disk?: boolean; - delete_torrents?: boolean; - }; - header?: never; - path: { - /** @description The ID of the show */ - show_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - update_shows_metadata_api_v1_tv_shows__show_id__metadata_post: { - parameters: { - query?: { - metadata_provider?: 'tmdb' | 'tvdb'; - }; - header?: never; - path: { - /** @description The ID of the show */ - show_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['PublicShow']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - set_continuous_download_api_v1_tv_shows__show_id__continuousDownload_post: { - parameters: { - query: { - continuous_download: boolean; - }; - header?: never; - path: { - /** @description The ID of the show */ - show_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['PublicShow']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - set_library_api_v1_tv_shows__show_id__library_post: { - parameters: { - query: { - library: string; - }; - header?: never; - path: { - /** @description The ID of the show */ - show_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_a_shows_torrents_api_v1_tv_shows__show_id__torrents_get: { - parameters: { - query?: never; - header?: never; - path: { - /** @description The ID of the show */ - show_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['RichShowTorrent']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_season_requests_api_v1_tv_seasons_requests_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['RichSeasonRequest'][]; - }; - }; - }; - }; - update_request_api_v1_tv_seasons_requests_put: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['UpdateSeasonRequest']; - }; - }; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - request_a_season_api_v1_tv_seasons_requests_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['CreateSeasonRequest']; - }; - }; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - authorize_request_api_v1_tv_seasons_requests__season_request_id__patch: { - parameters: { - query?: { - authorized_status?: boolean; - }; - header?: never; - path: { - season_request_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - delete_season_request_api_v1_tv_seasons_requests__request_id__delete: { - parameters: { - query?: never; - header?: never; - path: { - request_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_season_api_v1_tv_seasons__season_id__get: { - parameters: { - query?: never; - header?: never; - path: { - /** @description The ID of the season */ - season_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Season']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_season_files_api_v1_tv_seasons__season_id__files_get: { - parameters: { - query?: never; - header?: never; - path: { - /** @description The ID of the season */ - season_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['PublicSeasonFile'][]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_torrents_for_a_season_api_v1_tv_torrents_get: { - parameters: { - query: { - show_id: string; - season_number?: number; - search_query_override?: string | null; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['IndexerQueryResult'][]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - download_a_torrent_api_v1_tv_torrents_post: { - parameters: { - query: { - public_indexer_result_id: string; - show_id: string; - override_file_path_suffix?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Torrent']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_total_count_of_downloaded_episodes_api_v1_tv_episodes_count_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': number; - }; - }; - }; - }; - get_all_torrents_api_v1_torrent_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Torrent'][]; - }; - }; - }; - }; - get_torrent_api_v1_torrent__torrent_id__get: { - parameters: { - query?: never; - header?: never; - path: { - torrent_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Torrent']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - delete_torrent_api_v1_torrent__torrent_id__delete: { - parameters: { - query?: { - delete_files?: boolean; - }; - header?: never; - path: { - torrent_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - retry_torrent_download_api_v1_torrent__torrent_id__retry_post: { - parameters: { - query?: never; - header?: never; - path: { - torrent_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - update_torrent_status_api_v1_torrent__torrent_id__status_patch: { - parameters: { - query?: { - state?: components['schemas']['TorrentStatus'] | null; - imported?: boolean | null; - }; - header?: never; - path: { - torrent_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Torrent']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - search_for_movie_api_v1_movies_search_get: { - parameters: { - query: { - query: string; - metadata_provider?: 'tmdb' | 'tvdb'; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['MetaDataProviderSearchResult'][]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_popular_movies_api_v1_movies_recommended_get: { - parameters: { - query?: { - metadata_provider?: 'tmdb' | 'tvdb'; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['MetaDataProviderSearchResult'][]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_all_importable_movies_api_v1_movies_importable_get: { - parameters: { - query?: { - metadata_provider?: 'tmdb' | 'tvdb'; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['MediaImportSuggestion'][]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - import_detected_movie_api_v1_movies_importable__movie_id__post: { - parameters: { - query: { - directory: string; - }; - header?: never; - path: { - /** @description The ID of the movie */ - movie_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_all_movies_api_v1_movies_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Movie'][]; - }; - }; - }; - }; - add_a_movie_api_v1_movies_post: { - parameters: { - query: { - movie_id: number; - language?: string | null; - metadata_provider?: 'tmdb' | 'tvdb'; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully created movie */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Movie']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_all_movies_with_torrents_api_v1_movies_torrents_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['RichMovieTorrent'][]; - }; - }; - }; - }; - get_available_libraries_api_v1_movies_libraries_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['LibraryItem'][]; - }; - }; - }; - }; - get_all_movie_requests_api_v1_movies_requests_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['RichMovieRequest'][]; - }; - }; - }; - }; - create_movie_request_api_v1_movies_requests_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['CreateMovieRequest']; - }; - }; - responses: { - /** @description Successful Response */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['MovieRequest']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - update_movie_request_api_v1_movies_requests__movie_request_id__put: { - parameters: { - query?: never; - header?: never; - path: { - movie_request_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['MovieRequestBase']; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['MovieRequest']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - delete_movie_request_api_v1_movies_requests__movie_request_id__delete: { - parameters: { - query?: never; - header?: never; - path: { - movie_request_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - authorize_request_api_v1_movies_requests__movie_request_id__patch: { - parameters: { - query?: { - authorized_status?: boolean; - }; - header?: never; - path: { - movie_request_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_movie_by_id_api_v1_movies__movie_id__get: { - parameters: { - query?: never; - header?: never; - path: { - /** @description The ID of the movie */ - movie_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['PublicMovie']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - delete_a_movie_api_v1_movies__movie_id__delete: { - parameters: { - query?: { - delete_files_on_disk?: boolean; - delete_torrents?: boolean; - }; - header?: never; - path: { - /** @description The ID of the movie */ - movie_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - set_library_api_v1_movies__movie_id__library_post: { - parameters: { - query: { - library: string; - }; - header?: never; - path: { - /** @description The ID of the movie */ - movie_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_movie_files_by_movie_id_api_v1_movies__movie_id__files_get: { - parameters: { - query?: never; - header?: never; - path: { - /** @description The ID of the movie */ - movie_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['PublicMovieFile'][]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - search_for_torrents_for_movie_api_v1_movies__movie_id__torrents_get: { - parameters: { - query?: { - search_query_override?: string | null; - }; - header?: never; - path: { - /** @description The ID of the movie */ - movie_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['IndexerQueryResult'][]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - download_torrent_for_movie_api_v1_movies__movie_id__torrents_post: { - parameters: { - query: { - public_indexer_result_id: string; - override_file_path_suffix?: string; - }; - header?: never; - path: { - /** @description The ID of the movie */ - movie_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Torrent']; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - get_all_notifications_api_v1_notification_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Notification'][]; - }; - }; - }; - }; - get_unread_notifications_api_v1_notification_unread_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Notification'][]; - }; - }; - }; - }; - get_notification_api_v1_notification__notification_id__get: { - parameters: { - query?: never; - header?: never; - path: { - notification_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Notification']; - }; - }; - /** @description Notification not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - delete_notification_api_v1_notification__notification_id__delete: { - parameters: { - query?: never; - header?: never; - path: { - notification_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Notification not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - mark_notification_as_read_api_v1_notification__notification_id__read_patch: { - parameters: { - query?: never; - header?: never; - path: { - notification_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Notification not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - mark_notification_as_unread_api_v1_notification__notification_id__unread_patch: { - parameters: { - query?: never; - header?: never; - path: { - notification_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Notification not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['HTTPValidationError']; - }; - }; - }; - }; - root__get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': unknown; - }; - }; - }; - }; - dashboard_dashboard_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': unknown; - }; - }; - }; - }; - login_login_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': unknown; - }; - }; - }; - }; + hello_world_api_v1_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + }; + }; + auth_jwt_login_api_v1_auth_jwt_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/x-www-form-urlencoded": components["schemas"]["Body_auth_jwt_login_api_v1_auth_jwt_login_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiOTIyMWZmYzktNjQwZi00MzcyLTg2ZDMtY2U2NDJjYmE1NjAzIiwiYXVkIjoiZmFzdGFwaS11c2VyczphdXRoIiwiZXhwIjoxNTcxNTA0MTkzfQ.M10bjOe45I5Ncu_uXvOmVV8QxnL-nZfcH96U90JaocI", + * "token_type": "bearer" + * } + */ + "application/json": components["schemas"]["BearerResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + auth_jwt_logout_api_v1_auth_jwt_logout_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Missing token or inactive user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + auth_cookie_login_api_v1_auth_cookie_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/x-www-form-urlencoded": components["schemas"]["Body_auth_cookie_login_api_v1_auth_cookie_login_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + auth_cookie_logout_api_v1_auth_cookie_logout_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing token or inactive user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + register_register_api_v1_auth_register_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + reset_forgot_password_api_v1_auth_forgot_password_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_reset_forgot_password_api_v1_auth_forgot_password_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + reset_reset_password_api_v1_auth_reset_password_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_reset_reset_password_api_v1_auth_reset_password_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + verify_request_token_api_v1_auth_request_verify_token_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_verify_request_token_api_v1_auth_request_verify_token_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + verify_verify_api_v1_auth_verify_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_verify_verify_api_v1_auth_verify_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_all_users_api_v1_users_all_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"][]; + }; + }; + }; + }; + users_current_user_api_v1_users_me_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Missing token or inactive user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + users_patch_current_user_api_v1_users_me_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Missing token or inactive user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_user_api_v1_users__id__get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Missing token or inactive user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not a superuser. */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The user does not exist. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_delete_user_api_v1_users__id__delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing token or inactive user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not a superuser. */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The user does not exist. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_patch_user_api_v1_users__id__patch: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Missing token or inactive user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not a superuser. */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The user does not exist. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_auth_metadata_api_v1_auth_metadata_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthMetadata"]; + }; + }; + }; + }; + oauth_oauth2_cookie_authorize_api_v1_auth_oauth_authorize_get: { + parameters: { + query?: { + scopes?: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OAuth2AuthorizeResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + oauth_oauth2_cookie_callback_api_v1_auth_oauth_callback_get: { + parameters: { + query?: { + code?: string | null; + code_verifier?: string | null; + state?: string | null; + error?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search_metadata_providers_for_a_show_api_v1_tv_search_get: { + parameters: { + query: { + query: string; + metadata_provider?: "tmdb" | "tvdb"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MetaDataProviderSearchResult"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_recommended_shows_api_v1_tv_recommended_get: { + parameters: { + query?: { + metadata_provider?: "tmdb" | "tvdb"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MetaDataProviderSearchResult"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_all_importable_shows_api_v1_tv_importable_get: { + parameters: { + query?: { + metadata_provider?: "tmdb" | "tvdb"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MediaImportSuggestion"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + import_detected_show_api_v1_tv_importable__show_id__post: { + parameters: { + query: { + directory: string; + }; + header?: never; + path: { + /** @description The ID of the show */ + show_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_all_shows_api_v1_tv_shows_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Show"][]; + }; + }; + }; + }; + add_a_show_api_v1_tv_shows_post: { + parameters: { + query: { + show_id: number; + language?: string | null; + metadata_provider?: "tmdb" | "tvdb"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully created show */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Show"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_shows_with_torrents_api_v1_tv_shows_torrents_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RichShowTorrent"][]; + }; + }; + }; + }; + get_available_libraries_api_v1_tv_shows_libraries_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LibraryItem"][]; + }; + }; + }; + }; + get_a_show_api_v1_tv_shows__show_id__get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the show */ + show_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicShow"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_a_show_api_v1_tv_shows__show_id__delete: { + parameters: { + query?: { + delete_files_on_disk?: boolean; + delete_torrents?: boolean; + }; + header?: never; + path: { + /** @description The ID of the show */ + show_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_shows_metadata_api_v1_tv_shows__show_id__metadata_post: { + parameters: { + query?: { + metadata_provider?: "tmdb" | "tvdb"; + }; + header?: never; + path: { + /** @description The ID of the show */ + show_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicShow"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + set_continuous_download_api_v1_tv_shows__show_id__continuousDownload_post: { + parameters: { + query: { + continuous_download: boolean; + }; + header?: never; + path: { + /** @description The ID of the show */ + show_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicShow"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + set_library_api_v1_tv_shows__show_id__library_post: { + parameters: { + query: { + library: string; + }; + header?: never; + path: { + /** @description The ID of the show */ + show_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_a_shows_torrents_api_v1_tv_shows__show_id__torrents_get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the show */ + show_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RichShowTorrent"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_season_requests_api_v1_tv_seasons_requests_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RichSeasonRequest"][]; + }; + }; + }; + }; + update_request_api_v1_tv_seasons_requests_put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSeasonRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + request_a_season_api_v1_tv_seasons_requests_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSeasonRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + authorize_request_api_v1_tv_seasons_requests__season_request_id__patch: { + parameters: { + query?: { + authorized_status?: boolean; + }; + header?: never; + path: { + season_request_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_season_request_api_v1_tv_seasons_requests__request_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + request_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_season_api_v1_tv_seasons__season_id__get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the season */ + season_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Season"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_season_files_api_v1_tv_seasons__season_id__files_get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the season */ + season_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicSeasonFile"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_torrents_for_a_season_api_v1_tv_torrents_get: { + parameters: { + query: { + show_id: string; + season_number?: number; + search_query_override?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IndexerQueryResult"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + download_a_torrent_api_v1_tv_torrents_post: { + parameters: { + query: { + public_indexer_result_id: string; + show_id: string; + override_file_path_suffix?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Torrent"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_total_count_of_downloaded_episodes_api_v1_tv_episodes_count_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": number; + }; + }; + }; + }; + get_all_torrents_api_v1_torrent_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Torrent"][]; + }; + }; + }; + }; + get_torrent_api_v1_torrent__torrent_id__get: { + parameters: { + query?: never; + header?: never; + path: { + torrent_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Torrent"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_torrent_api_v1_torrent__torrent_id__delete: { + parameters: { + query?: { + delete_files?: boolean; + }; + header?: never; + path: { + torrent_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + retry_torrent_download_api_v1_torrent__torrent_id__retry_post: { + parameters: { + query?: never; + header?: never; + path: { + torrent_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_torrent_status_api_v1_torrent__torrent_id__status_patch: { + parameters: { + query?: { + state?: components["schemas"]["TorrentStatus"] | null; + imported?: boolean | null; + }; + header?: never; + path: { + torrent_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Torrent"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search_for_movie_api_v1_movies_search_get: { + parameters: { + query: { + query: string; + metadata_provider?: "tmdb" | "tvdb"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MetaDataProviderSearchResult"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_popular_movies_api_v1_movies_recommended_get: { + parameters: { + query?: { + metadata_provider?: "tmdb" | "tvdb"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MetaDataProviderSearchResult"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_all_importable_movies_api_v1_movies_importable_get: { + parameters: { + query?: { + metadata_provider?: "tmdb" | "tvdb"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MediaImportSuggestion"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + import_detected_movie_api_v1_movies_importable__movie_id__post: { + parameters: { + query: { + directory: string; + }; + header?: never; + path: { + /** @description The ID of the movie */ + movie_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_all_movies_api_v1_movies_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Movie"][]; + }; + }; + }; + }; + add_a_movie_api_v1_movies_post: { + parameters: { + query: { + movie_id: number; + language?: string | null; + metadata_provider?: "tmdb" | "tvdb"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully created movie */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Movie"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_all_movies_with_torrents_api_v1_movies_torrents_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RichMovieTorrent"][]; + }; + }; + }; + }; + get_available_libraries_api_v1_movies_libraries_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LibraryItem"][]; + }; + }; + }; + }; + get_all_movie_requests_api_v1_movies_requests_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RichMovieRequest"][]; + }; + }; + }; + }; + create_movie_request_api_v1_movies_requests_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateMovieRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MovieRequest"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_movie_request_api_v1_movies_requests__movie_request_id__put: { + parameters: { + query?: never; + header?: never; + path: { + movie_request_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MovieRequestBase"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MovieRequest"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_movie_request_api_v1_movies_requests__movie_request_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + movie_request_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + authorize_request_api_v1_movies_requests__movie_request_id__patch: { + parameters: { + query?: { + authorized_status?: boolean; + }; + header?: never; + path: { + movie_request_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_movie_by_id_api_v1_movies__movie_id__get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the movie */ + movie_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicMovie"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_a_movie_api_v1_movies__movie_id__delete: { + parameters: { + query?: { + delete_files_on_disk?: boolean; + delete_torrents?: boolean; + }; + header?: never; + path: { + /** @description The ID of the movie */ + movie_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + set_library_api_v1_movies__movie_id__library_post: { + parameters: { + query: { + library: string; + }; + header?: never; + path: { + /** @description The ID of the movie */ + movie_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_movie_files_by_movie_id_api_v1_movies__movie_id__files_get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the movie */ + movie_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicMovieFile"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search_for_torrents_for_movie_api_v1_movies__movie_id__torrents_get: { + parameters: { + query?: { + search_query_override?: string | null; + }; + header?: never; + path: { + /** @description The ID of the movie */ + movie_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IndexerQueryResult"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + download_torrent_for_movie_api_v1_movies__movie_id__torrents_post: { + parameters: { + query: { + public_indexer_result_id: string; + override_file_path_suffix?: string; + }; + header?: never; + path: { + /** @description The ID of the movie */ + movie_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Torrent"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search_for_artist_api_v1_music_search_get: { + parameters: { + query: { + query: string; + metadata_provider?: "musicbrainz"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MetaDataProviderSearchResult"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_popular_artists_api_v1_music_recommended_get: { + parameters: { + query?: { + metadata_provider?: "musicbrainz"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MetaDataProviderSearchResult"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_all_artists_api_v1_music_artists_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Artist"][]; + }; + }; + }; + }; + add_an_artist_api_v1_music_artists_post: { + parameters: { + query: { + artist_id: string; + metadata_provider?: "musicbrainz"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully created artist */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Artist"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_all_artists_with_torrents_api_v1_music_artists_torrents_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RichArtistTorrent"][]; + }; + }; + }; + }; + get_available_libraries_api_v1_music_artists_libraries_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LibraryItem"][]; + }; + }; + }; + }; + get_all_album_requests_api_v1_music_albums_requests_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RichAlbumRequest"][]; + }; + }; + }; + }; + create_album_request_api_v1_music_albums_requests_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateAlbumRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AlbumRequest"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_album_request_api_v1_music_albums_requests__album_request_id__put: { + parameters: { + query?: never; + header?: never; + path: { + album_request_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AlbumRequestBase"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AlbumRequest"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_album_request_api_v1_music_albums_requests__album_request_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + album_request_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + authorize_album_request_api_v1_music_albums_requests__album_request_id__patch: { + parameters: { + query?: { + authorized_status?: boolean; + }; + header?: never; + path: { + album_request_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_artist_by_id_api_v1_music_artists__artist_id__get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the artist */ + artist_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicArtist"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_an_artist_api_v1_music_artists__artist_id__delete: { + parameters: { + query?: { + delete_files_on_disk?: boolean; + delete_torrents?: boolean; + }; + header?: never; + path: { + /** @description The ID of the artist */ + artist_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_artist_metadata_api_v1_music_artists__artist_id__metadata_post: { + parameters: { + query?: { + metadata_provider?: "musicbrainz"; + }; + header?: never; + path: { + /** @description The ID of the artist */ + artist_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicArtist"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + set_library_api_v1_music_artists__artist_id__library_post: { + parameters: { + query: { + library: string; + }; + header?: never; + path: { + /** @description The ID of the artist */ + artist_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_torrents_for_artist_api_v1_music_artists__artist_id__torrents_get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the artist */ + artist_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RichArtistTorrent"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_album_by_id_api_v1_music_albums__album_id__get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the album */ + album_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Album"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_album_files_api_v1_music_albums__album_id__files_get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the album */ + album_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicAlbumFile"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search_torrents_for_album_api_v1_music_torrents_get: { + parameters: { + query: { + artist_id: string; + album_name: string; + search_query_override?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IndexerQueryResult"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + download_a_torrent_api_v1_music_torrents_post: { + parameters: { + query: { + public_indexer_result_id: string; + artist_id: string; + album_id: string; + override_file_path_suffix?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Torrent"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search_for_author_api_v1_books_search_get: { + parameters: { + query: { + query: string; + metadata_provider?: "openlibrary"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MetaDataProviderSearchResult"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_popular_authors_api_v1_books_recommended_get: { + parameters: { + query?: { + metadata_provider?: "openlibrary"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MetaDataProviderSearchResult"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_all_authors_api_v1_books_authors_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Author"][]; + }; + }; + }; + }; + add_an_author_api_v1_books_authors_post: { + parameters: { + query: { + author_id: string; + metadata_provider?: "openlibrary"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully created author */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Author"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_all_authors_with_torrents_api_v1_books_authors_torrents_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RichAuthorTorrent"][]; + }; + }; + }; + }; + get_available_libraries_api_v1_books_authors_libraries_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LibraryItem"][]; + }; + }; + }; + }; + get_all_book_requests_api_v1_books_books_requests_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RichBookRequest"][]; + }; + }; + }; + }; + create_book_request_api_v1_books_books_requests_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateBookRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BookRequest"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_book_request_api_v1_books_books_requests__book_request_id__put: { + parameters: { + query?: never; + header?: never; + path: { + book_request_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BookRequestBase"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BookRequest"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_book_request_api_v1_books_books_requests__book_request_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + book_request_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + authorize_book_request_api_v1_books_books_requests__book_request_id__patch: { + parameters: { + query?: { + authorized_status?: boolean; + }; + header?: never; + path: { + book_request_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_author_by_id_api_v1_books_authors__author_id__get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the author */ + author_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicAuthor"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_an_author_api_v1_books_authors__author_id__delete: { + parameters: { + query?: { + delete_files_on_disk?: boolean; + delete_torrents?: boolean; + }; + header?: never; + path: { + /** @description The ID of the author */ + author_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_author_metadata_api_v1_books_authors__author_id__metadata_post: { + parameters: { + query?: { + metadata_provider?: "openlibrary"; + }; + header?: never; + path: { + /** @description The ID of the author */ + author_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicAuthor"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + set_library_api_v1_books_authors__author_id__library_post: { + parameters: { + query: { + library: string; + }; + header?: never; + path: { + /** @description The ID of the author */ + author_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_torrents_for_author_api_v1_books_authors__author_id__torrents_get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the author */ + author_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RichAuthorTorrent"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_book_by_id_api_v1_books_books__book_id__get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the book */ + book_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Book"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_book_files_api_v1_books_books__book_id__files_get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the book */ + book_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicBookFile"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search_torrents_for_book_api_v1_books_torrents_get: { + parameters: { + query: { + author_id: string; + book_name: string; + search_query_override?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IndexerQueryResult"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + download_a_torrent_api_v1_books_torrents_post: { + parameters: { + query: { + public_indexer_result_id: string; + author_id: string; + book_id: string; + override_file_path_suffix?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Torrent"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_all_notifications_api_v1_notification_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Notification"][]; + }; + }; + }; + }; + get_unread_notifications_api_v1_notification_unread_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Notification"][]; + }; + }; + }; + }; + get_notification_api_v1_notification__notification_id__get: { + parameters: { + query?: never; + header?: never; + path: { + notification_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Notification"]; + }; + }; + /** @description Notification not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_notification_api_v1_notification__notification_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + notification_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Notification not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + mark_notification_as_read_api_v1_notification__notification_id__read_patch: { + parameters: { + query?: never; + header?: never; + path: { + notification_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Notification not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + mark_notification_as_unread_api_v1_notification__notification_id__unread_patch: { + parameters: { + query?: never; + header?: never; + path: { + notification_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Notification not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + root__get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + dashboard_dashboard_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + login_login_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; } diff --git a/web/src/lib/components/add-media-card.svelte b/web/src/lib/components/add-media-card.svelte index 5985b318..c0c37827 100644 --- a/web/src/lib/components/add-media-card.svelte +++ b/web/src/lib/components/add-media-card.svelte @@ -11,13 +11,16 @@ let errorMessage = $state(null); let { result, - isShow = true - }: { result: components['schemas']['MetaDataProviderSearchResult']; isShow: boolean } = $props(); + mediaType = 'tv' + }: { + result: components['schemas']['MetaDataProviderSearchResult']; + mediaType: 'tv' | 'movie' | 'music' | 'books'; + } = $props(); async function addMedia() { loading = true; let data; - if (isShow) { + if (mediaType === 'tv') { const response = await client.POST('/api/v1/tv/shows', { params: { query: { @@ -28,7 +31,7 @@ } }); data = response.data; - } else { + } else if (mediaType === 'movie') { const response = await client.POST('/api/v1/movies', { params: { query: { @@ -39,19 +42,59 @@ } }); data = response.data; + } else if (mediaType === 'books') { + const response = await client.POST('/api/v1/books/authors', { + params: { + query: { + author_id: String(result.external_id) + } + } + }); + data = response.data; + } else { + const response = await client.POST('/api/v1/music/artists', { + params: { + query: { + artist_id: String(result.external_id) + } + } + }); + data = response.data; } - if (isShow) { + if (mediaType === 'tv') { await goto(resolve('/dashboard/tv/[showId]', { showId: data?.id ?? '' }), { invalidateAll: true }); - } else { + } else if (mediaType === 'movie') { await goto(resolve('/dashboard/movies/[movieId]', { movieId: data?.id ?? '' }), { invalidateAll: true }); + } else if (mediaType === 'books') { + await goto(resolve('/dashboard/books/[authorId]', { authorId: data?.id ?? '' }), { + invalidateAll: true + }); + } else { + await goto(resolve('/dashboard/music/[artistId]', { artistId: data?.id ?? '' }), { + invalidateAll: true + }); } loading = false; } + + const labels = { + tv: { add: 'Add Show', exists: 'Show already exists' }, + movie: { add: 'Add Movie', exists: 'Movie already exists' }, + music: { add: 'Add Artist', exists: 'Artist already exists' }, + books: { add: 'Add Author', exists: 'Author already exists' } + }; + + const detailPaths = { + tv: { path: '/dashboard/tv/[showId]', param: 'showId' }, + movie: { path: '/dashboard/movies/[movieId]', param: 'movieId' }, + music: { path: '/dashboard/music/[artistId]', param: 'artistId' }, + books: { path: '/dashboard/books/[authorId]', param: 'authorId' } + }; @@ -84,12 +127,11 @@ {:else} {/if} diff --git a/web/src/lib/components/delete-media-dialog.svelte b/web/src/lib/components/delete-media-dialog.svelte index 837d881d..1891b916 100644 --- a/web/src/lib/components/delete-media-dialog.svelte +++ b/web/src/lib/components/delete-media-dialog.svelte @@ -12,10 +12,18 @@ let { media, - isShow + isShow, + isMusic = false, + isBooks = false }: { - media: components['schemas']['PublicMovie'] | components['schemas']['PublicShow']; + media: + | components['schemas']['PublicMovie'] + | components['schemas']['PublicShow'] + | components['schemas']['PublicArtist'] + | components['schemas']['PublicAuthor']; isShow: boolean; + isMusic?: boolean; + isBooks?: boolean; } = $props(); let deleteDialogOpen = $state(false); let deleteFilesOnDisk = $state(false); @@ -56,18 +64,57 @@ await goto(resolve('/dashboard/tv', {}), { invalidateAll: true }); } } + + async function delete_artist() { + const { error } = await client.DELETE('/api/v1/music/artists/{artist_id}', { + params: { + path: { artist_id: media.id! }, + query: { delete_files_on_disk: deleteFilesOnDisk, delete_torrents: deleteTorrents } + } + }); + if (error) { + toast.error('Failed to delete artist: ' + error.detail); + } else { + toast.success('Artist deleted successfully.'); + deleteDialogOpen = false; + await goto(resolve('/dashboard/music', {}), { invalidateAll: true }); + } + } + + async function delete_author() { + const { error } = await client.DELETE('/api/v1/books/authors/{author_id}', { + params: { + path: { author_id: media.id! }, + query: { delete_files_on_disk: deleteFilesOnDisk, delete_torrents: deleteTorrents } + } + }); + if (error) { + toast.error('Failed to delete author: ' + error.detail); + } else { + toast.success('Author deleted successfully.'); + deleteDialogOpen = false; + await goto(resolve('/dashboard/books', {}), { invalidateAll: true }); + } + } + + function getMediaName() { + if ('name' in media && 'year' in media) { + return getFullyQualifiedMediaName(media); + } + return media.name; + } - Delete {isShow ? ' Show' : ' Movie'} + Delete {isBooks ? ' Author' : isMusic ? ' Artist' : isShow ? ' Show' : ' Movie'} - Delete - {getFullyQualifiedMediaName(media)}? + Delete - {getMediaName()}? This action cannot be undone. This will permanently delete - {getFullyQualifiedMediaName(media)}. + {getMediaName()}.
@@ -94,7 +141,11 @@ Cancel { - if (isShow) { + if (isBooks) { + delete_author(); + } else if (isMusic) { + delete_artist(); + } else if (isShow) { delete_show(); } else delete_movie(); }} diff --git a/web/src/lib/components/download-dialogs/download-album-dialog.svelte b/web/src/lib/components/download-dialogs/download-album-dialog.svelte new file mode 100644 index 00000000..adf9e680 --- /dev/null +++ b/web/src/lib/components/download-dialogs/download-album-dialog.svelte @@ -0,0 +1,175 @@ + + + + + {#snippet basicModeContent()} + + {/snippet} + + {#if torrentsError} +
An error occurred: {torrentsError}
+ {/if} + + {#snippet rowSnippet(torrent)} + {torrent.title} + {(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB + {torrent.seeders} + {torrent.score} + {torrent.indexer ?? 'Unknown'} + + {#each torrent.flags as flag (flag)} + {flag} + {/each} + + + + + {/snippet} + +
+ + + + + Set File Path Suffix + + Optional suffix to differentiate between versions of the same album (e.g. "FLAC", "320K"). + + +
+ + +
+
+ + +
+
+
diff --git a/web/src/lib/components/download-dialogs/download-book-dialog.svelte b/web/src/lib/components/download-dialogs/download-book-dialog.svelte new file mode 100644 index 00000000..83b20a43 --- /dev/null +++ b/web/src/lib/components/download-dialogs/download-book-dialog.svelte @@ -0,0 +1,175 @@ + + + + + {#snippet basicModeContent()} + + {/snippet} + + {#if torrentsError} +
An error occurred: {torrentsError}
+ {/if} + + {#snippet rowSnippet(torrent)} + {torrent.title} + {(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB + {torrent.seeders} + {torrent.score} + {torrent.indexer ?? 'Unknown'} + + {#each torrent.flags as flag (flag)} + {flag} + {/each} + + + + + {/snippet} + +
+ + + + + Set File Path Suffix + + Optional suffix to differentiate between versions of the same book (e.g. "EPUB", "PDF"). + + +
+ + +
+
+ + +
+
+
diff --git a/web/src/lib/components/library-combobox.svelte b/web/src/lib/components/library-combobox.svelte index a5b5ac05..225ab1c5 100644 --- a/web/src/lib/components/library-combobox.svelte +++ b/web/src/lib/components/library-combobox.svelte @@ -15,8 +15,11 @@ media, mediaType }: { - media: components['schemas']['PublicShow'] | components['schemas']['PublicMovie']; - mediaType: 'tv' | 'movie'; + media: + | components['schemas']['PublicShow'] + | components['schemas']['PublicMovie'] + | components['schemas']['PublicArtist']; + mediaType: 'tv' | 'movie' | 'music' | 'books'; } = $props(); let open = $state(false); @@ -29,9 +32,15 @@ onMount(async () => { const tvLibraries = await client.GET('/api/v1/tv/shows/libraries'); const movieLibraries = await client.GET('/api/v1/movies/libraries'); + const musicLibraries = await client.GET('/api/v1/music/artists/libraries'); + const booksLibraries = await client.GET('/api/v1/books/authors/libraries'); if (mediaType === 'tv') { libraries = tvLibraries.data as components['schemas']['LibraryItem'][]; + } else if (mediaType === 'music') { + libraries = musicLibraries.data as components['schemas']['LibraryItem'][]; + } else if (mediaType === 'books') { + libraries = booksLibraries.data as components['schemas']['LibraryItem'][]; } else { libraries = movieLibraries.data as components['schemas']['LibraryItem'][]; } @@ -57,6 +66,20 @@ query: { library: selectedLabel } } }); + } else if (mediaType === 'music') { + response = await client.POST('/api/v1/music/artists/{artist_id}/library', { + params: { + path: { artist_id: media.id! }, + query: { library: selectedLabel } + } + }); + } else if (mediaType === 'books') { + response = await client.POST('/api/v1/books/authors/{author_id}/library', { + params: { + path: { author_id: media.id! }, + query: { library: selectedLabel } + } + }); } else { response = await client.POST('/api/v1/movies/{movie_id}/library', { params: { diff --git a/web/src/lib/components/nav/app-sidebar.svelte b/web/src/lib/components/nav/app-sidebar.svelte index 7cd50183..10d6a741 100644 --- a/web/src/lib/components/nav/app-sidebar.svelte +++ b/web/src/lib/components/nav/app-sidebar.svelte @@ -1,11 +1,13 @@
{:else} {#each media.slice(0, 3) as mediaItem (mediaItem.external_id)} - + {/each} {/if} - {#if isShow} - - {:else} - - {/if} +
diff --git a/web/src/lib/components/requests/request-album-dialog.svelte b/web/src/lib/components/requests/request-album-dialog.svelte new file mode 100644 index 00000000..8da6d202 --- /dev/null +++ b/web/src/lib/components/requests/request-album-dialog.svelte @@ -0,0 +1,127 @@ + + + + { + dialogOpen = true; + }} + > + Request Album + + + + Request {album.name} + + Request a download of {album.name} by {artist.name}. Select desired qualities to submit a + request. + + +
+ +
+ + + + {minQuality ? getAudioQualityString(parseInt(minQuality)) : 'Select Minimum Quality'} + + + {#each qualityOptions as option (option.value)} + {option.label} + {/each} + + +
+ + +
+ + + + {wantedQuality + ? getAudioQualityString(parseInt(wantedQuality)) + : 'Select Wanted Quality'} + + + {#each qualityOptions as option (option.value)} + {option.label} + {/each} + + +
+ + {#if submitRequestError} +

{submitRequestError}

+ {/if} +
+ + + + +
+
diff --git a/web/src/lib/components/requests/request-book-dialog.svelte b/web/src/lib/components/requests/request-book-dialog.svelte new file mode 100644 index 00000000..e38e3564 --- /dev/null +++ b/web/src/lib/components/requests/request-book-dialog.svelte @@ -0,0 +1,83 @@ + + + + { + dialogOpen = true; + }} + > + Request Book + + + + Request {book.name} + + Request a download of {book.name} by {author.name}. + + +
+

+ This will submit a request to download this book. An administrator will need to approve it + before it is automatically downloaded. +

+ {#if submitRequestError} +

{submitRequestError}

+ {/if} +
+ + + + +
+
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index c8d29ce6..1f51101c 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -28,6 +28,18 @@ export function getTorrentQualityString(value: number): string { return qualityMap[value] || 'unknown'; } +export const audioQualityMap: { [key: number]: string } = { + 1: 'Lossless (FLAC/ALAC)', + 2: 'High (320kbps)', + 3: 'Standard (192kbps)', + 4: 'Low (128kbps)', + 5: 'unknown' +}; + +export function getAudioQualityString(value: number): string { + return audioQualityMap[value] || 'unknown'; +} + export function getTorrentStatusString(value: number): string { return torrentStatusMap[value] || 'unknown'; } diff --git a/web/src/routes/dashboard/+page.svelte b/web/src/routes/dashboard/+page.svelte index 2a245f9e..5f5b9481 100644 --- a/web/src/routes/dashboard/+page.svelte +++ b/web/src/routes/dashboard/+page.svelte @@ -16,6 +16,9 @@ let recommendedMovies: components['schemas']['MetaDataProviderSearchResult'][] = $state([]); let moviesLoading = $state(true); + let recommendedArtists: components['schemas']['MetaDataProviderSearchResult'][] = $state([]); + let artistsLoading = $state(true); + onMount(async () => { client.GET('/api/v1/tv/recommended').then((res) => { recommendedShows = res.data as components['schemas']['MetaDataProviderSearchResult'][]; @@ -25,6 +28,11 @@ recommendedMovies = res.data as components['schemas']['MetaDataProviderSearchResult'][]; moviesLoading = false; }); + client.GET('/api/v1/music/recommended').then((res) => { + recommendedArtists = + (res.data as components['schemas']['MetaDataProviderSearchResult'][]) ?? []; + artistsLoading = false; + }); }); @@ -65,14 +73,21 @@

Trending Shows

- +

Trending Movies

+ +

Trending Music

+
diff --git a/web/src/routes/dashboard/books/+page.svelte b/web/src/routes/dashboard/books/+page.svelte new file mode 100644 index 00000000..690fbaf4 --- /dev/null +++ b/web/src/routes/dashboard/books/+page.svelte @@ -0,0 +1,70 @@ + + + + Books - MediaManager + + + +
+
+ + + + + + + +
+
+
+

Books

+ {#await authors} + + {:then authorList} +
+ {#each authorList as author (author.id)} + + + + {author.name} + {author.overview} + + + + + + + {:else} +
No authors added yet.
+ {/each} +
+ {/await} +
diff --git a/web/src/routes/dashboard/books/[authorId=uuid]/+layout.ts b/web/src/routes/dashboard/books/[authorId=uuid]/+layout.ts new file mode 100644 index 00000000..81a38ec3 --- /dev/null +++ b/web/src/routes/dashboard/books/[authorId=uuid]/+layout.ts @@ -0,0 +1,18 @@ +import type { LayoutLoad } from './$types'; +import client from '$lib/api'; + +export const load: LayoutLoad = async ({ params, fetch }) => { + const author = client.GET('/api/v1/books/authors/{author_id}', { + fetch: fetch, + params: { path: { author_id: params.authorId } } + }); + const torrents = client.GET('/api/v1/books/authors/{author_id}/torrents', { + fetch: fetch, + params: { path: { author_id: params.authorId } } + }); + + return { + authorData: await author.then((x) => x.data), + torrentsData: await torrents.then((x) => x.data) + }; +}; diff --git a/web/src/routes/dashboard/books/[authorId=uuid]/+page.svelte b/web/src/routes/dashboard/books/[authorId=uuid]/+page.svelte new file mode 100644 index 00000000..b4223664 --- /dev/null +++ b/web/src/routes/dashboard/books/[authorId=uuid]/+page.svelte @@ -0,0 +1,183 @@ + + + + {author.name} - MediaManager + + + +
+
+ + + + + + + +
+
+

+ {author.name} +

+
+
+
+ {#if author.id} + + {:else} +
+ +
+ {/if} +
+
+ + + Overview + + +

+ {author.overview} +

+
+
+
+
+ {#if user().is_superuser} + + + Administrator Controls + + + + + + + {/if} +
+
+
+ + + Books + + A list of all books by {author.name}. + + + + + A list of all books. + + + Name + Year + Format + Downloaded + + + + {#if author.books && author.books.length > 0} + {#each author.books as book (book.id)} + + goto( + resolve('/dashboard/books/[authorId]/[bookId]', { + authorId: author.id, + bookId: book.id + }) + )} + > + {book.name} + {book.year ?? 'N/A'} + {book.format} + + + + + {/each} + {:else} + + No book data available. + + {/if} + + + + +
+
+ + + Torrent Information + A list of all torrents associated with this author. + + + + {#if torrents && torrents.torrents} + + Torrents for {author.name}. + + + Title + Imported + + + + {#each torrents.torrents as torrent (torrent.torrent_id)} + + {torrent.torrent_title} + + + + + {/each} + + + {:else} +

No torrents associated.

+ {/if} +
+
+
+
diff --git a/web/src/routes/dashboard/books/[authorId=uuid]/[bookId=uuid]/+page.svelte b/web/src/routes/dashboard/books/[authorId=uuid]/[bookId=uuid]/+page.svelte new file mode 100644 index 00000000..19eee163 --- /dev/null +++ b/web/src/routes/dashboard/books/[authorId=uuid]/[bookId=uuid]/+page.svelte @@ -0,0 +1,125 @@ + + + + {book.name} - {author.name} - MediaManager + + + +
+
+ + + + + + + +
+
+

+ {book.name} +

+

+ {author.name} + {#if book.year} + · {book.year} + {/if} + · {book.format} +

+
+
+
+ + + Download Options + + + {#if user().is_superuser} + + {/if} + + + +
+
+ + + Book Files + Downloaded/downloading versions of this book. + + + + + A list of all downloaded/downloading versions of this book. + + + + File Path Suffix + Downloaded + + + + {#if bookFiles && bookFiles.length > 0} + {#each bookFiles as file (file)} + + + {file.file_path_suffix} + + + + + + {/each} + {:else} + + No book files yet. + + {/if} + + + + +
+
+
diff --git a/web/src/routes/dashboard/books/[authorId=uuid]/[bookId=uuid]/+page.ts b/web/src/routes/dashboard/books/[authorId=uuid]/[bookId=uuid]/+page.ts new file mode 100644 index 00000000..2e758939 --- /dev/null +++ b/web/src/routes/dashboard/books/[authorId=uuid]/[bookId=uuid]/+page.ts @@ -0,0 +1,26 @@ +import type { PageLoad } from './$types'; +import client from '$lib/api'; + +export const load: PageLoad = async ({ params, fetch }) => { + const book = client.GET('/api/v1/books/books/{book_id}', { + fetch: fetch, + params: { + path: { + book_id: params.bookId + } + } + }); + const files = client.GET('/api/v1/books/books/{book_id}/files', { + fetch: fetch, + params: { + path: { + book_id: params.bookId + } + } + }); + + return { + book: await book.then((x) => x.data), + bookFiles: await files.then((x) => x.data) + }; +}; diff --git a/web/src/routes/dashboard/books/add-author/+page.svelte b/web/src/routes/dashboard/books/add-author/+page.svelte new file mode 100644 index 00000000..a4bc69c1 --- /dev/null +++ b/web/src/routes/dashboard/books/add-author/+page.svelte @@ -0,0 +1,126 @@ + + + + Add Author - MediaManager + + + +
+
+ + + + + + + +
+
+ +
+
+

+ Add an Author +

+
+ + { + if (e.key === 'Enter' && !isSearching) { + search(searchTerm); + } + }} + /> +

Search OpenLibrary for an author to add.

+
+
+ +
+
+ + + + {#if results && results.length === 0} +

No authors found.

+ {:else if results} +
+ {#each results as result (result.external_id)} + + {/each} +
+ {/if} +
diff --git a/web/src/routes/dashboard/books/requests/+page.svelte b/web/src/routes/dashboard/books/requests/+page.svelte new file mode 100644 index 00000000..f6b38ee5 --- /dev/null +++ b/web/src/routes/dashboard/books/requests/+page.svelte @@ -0,0 +1,158 @@ + + + + Book Requests - MediaManager + + + +
+
+ + + + + + + +
+
+ +
+

+ Book Requests +

+ + + Requests + All book download requests. + + + + All book requests. + + + Author + Book + Requested By + Authorized + {#if user().is_superuser} + Actions + {/if} + + + + {#if requests && requests.length > 0} + {#each requests as request (request.id)} + + + + {request.author.name} + + + {request.book.name} + {request.requested_by?.email ?? 'Unknown'} + {request.authorized ? 'Yes' : 'No'} + {#if user().is_superuser} + + {#if !request.authorized} + + {:else} + + {/if} + + + {/if} + + {/each} + {:else} + + + No book requests yet. + + + {/if} + + + + +
diff --git a/web/src/routes/dashboard/books/requests/+page.ts b/web/src/routes/dashboard/books/requests/+page.ts new file mode 100644 index 00000000..5cf2bea4 --- /dev/null +++ b/web/src/routes/dashboard/books/requests/+page.ts @@ -0,0 +1,9 @@ +import type { PageLoad } from './$types'; +import client from '$lib/api'; + +export const load: PageLoad = async ({ fetch }) => { + const { data } = await client.GET('/api/v1/books/books/requests', { fetch: fetch }); + return { + requestsData: data + }; +}; diff --git a/web/src/routes/dashboard/books/torrents/+page.svelte b/web/src/routes/dashboard/books/torrents/+page.svelte new file mode 100644 index 00000000..6fe7abd2 --- /dev/null +++ b/web/src/routes/dashboard/books/torrents/+page.svelte @@ -0,0 +1,91 @@ + + + + Book Torrents - MediaManager + + + +
+
+ + + + + + + +
+
+ +
+

+ Book Torrents +

+ {#if authorTorrents && authorTorrents.length > 0} + {#each authorTorrents as authorTorrent (authorTorrent.author_id)} +
+ + + + + {authorTorrent.name} + + + + + + + + Title + Imported + + + + {#each authorTorrent.torrents as torrent (torrent.torrent_id)} + + {torrent.torrent_title} + + + + + {/each} + + + + +
+ {/each} + {:else} +
No Torrents added yet.
+ {/if} +
diff --git a/web/src/routes/dashboard/books/torrents/+page.ts b/web/src/routes/dashboard/books/torrents/+page.ts new file mode 100644 index 00000000..ff32d820 --- /dev/null +++ b/web/src/routes/dashboard/books/torrents/+page.ts @@ -0,0 +1,7 @@ +import type { PageLoad } from './$types'; +import client from '$lib/api'; + +export const load: PageLoad = async ({ fetch }) => { + const { data } = await client.GET('/api/v1/books/authors/torrents', { fetch: fetch }); + return { torrents: data }; +}; diff --git a/web/src/routes/dashboard/movies/add-movie/+page.svelte b/web/src/routes/dashboard/movies/add-movie/+page.svelte index 764bc01e..4669a212 100644 --- a/web/src/routes/dashboard/movies/add-movie/+page.svelte +++ b/web/src/routes/dashboard/movies/add-movie/+page.svelte @@ -149,7 +149,7 @@ md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5" > {#each results as result (result.external_id)} - + {/each} {/if} diff --git a/web/src/routes/dashboard/music/+page.svelte b/web/src/routes/dashboard/music/+page.svelte new file mode 100644 index 00000000..ae2f2cd7 --- /dev/null +++ b/web/src/routes/dashboard/music/+page.svelte @@ -0,0 +1,74 @@ + + + + Music - MediaManager + + + +
+
+ + + + + + + +
+
+
+

+ Music +

+ {#await artists} + + {:then artistList} +
+ {#each artistList as artist (artist.id)} + + + + {artist.name} + {artist.overview} + + + + + + + {:else} +
+ No artists added yet. +
+ {/each} +
+ {/await} +
diff --git a/web/src/routes/dashboard/music/[artistId=uuid]/+layout.ts b/web/src/routes/dashboard/music/[artistId=uuid]/+layout.ts new file mode 100644 index 00000000..8c764998 --- /dev/null +++ b/web/src/routes/dashboard/music/[artistId=uuid]/+layout.ts @@ -0,0 +1,18 @@ +import type { LayoutLoad } from './$types'; +import client from '$lib/api'; + +export const load: LayoutLoad = async ({ params, fetch }) => { + const artist = client.GET('/api/v1/music/artists/{artist_id}', { + fetch: fetch, + params: { path: { artist_id: params.artistId } } + }); + const torrents = client.GET('/api/v1/music/artists/{artist_id}/torrents', { + fetch: fetch, + params: { path: { artist_id: params.artistId } } + }); + + return { + artistData: await artist.then((x) => x.data), + torrentsData: await torrents.then((x) => x.data) + }; +}; diff --git a/web/src/routes/dashboard/music/[artistId=uuid]/+page.svelte b/web/src/routes/dashboard/music/[artistId=uuid]/+page.svelte new file mode 100644 index 00000000..bef0ddef --- /dev/null +++ b/web/src/routes/dashboard/music/[artistId=uuid]/+page.svelte @@ -0,0 +1,193 @@ + + + + {artist.name} - MediaManager + + + +
+
+ + + + + + + +
+
+

+ {artist.name} +

+
+
+
+ {#if artist.id} + + {:else} +
+ +
+ {/if} +
+
+ + + Overview + + +

+ {artist.overview} +

+ {#if artist.country} +

Country: {artist.country}

+ {/if} + {#if artist.disambiguation} +

({artist.disambiguation})

+ {/if} +
+
+
+
+ {#if user().is_superuser} + + + Administrator Controls + + + + + + + {/if} +
+
+
+ + + Albums + + A list of all albums for {artist.name}. + + + + + A list of all albums. + + + Name + Year + Type + Downloaded + + + + {#if artist.albums && artist.albums.length > 0} + {#each artist.albums as album (album.id)} + + goto( + resolve('/dashboard/music/[artistId]/[albumId]', { + artistId: artist.id, + albumId: album.id + }) + )} + > + {album.name} + {album.year ?? 'N/A'} + {album.album_type} + + + + + {/each} + {:else} + + No album data available. + + {/if} + + + + +
+
+ + + Torrent Information + A list of all torrents associated with this artist. + + + + {#if torrents && torrents.torrents} + + Torrents for {artist.name}. + + + Title + Quality + Imported + + + + {#each torrents.torrents as torrent (torrent.torrent_id)} + + {torrent.torrent_title} + {getAudioQualityString(torrent.quality)} + + + + + {/each} + + + {:else} +

No torrents associated.

+ {/if} +
+
+
+
diff --git a/web/src/routes/dashboard/music/[artistId=uuid]/[albumId=uuid]/+page.svelte b/web/src/routes/dashboard/music/[artistId=uuid]/[albumId=uuid]/+page.svelte new file mode 100644 index 00000000..e8456f79 --- /dev/null +++ b/web/src/routes/dashboard/music/[artistId=uuid]/[albumId=uuid]/+page.svelte @@ -0,0 +1,175 @@ + + + + {album.name} - {artist.name} - MediaManager + + + +
+
+ + + + + + + +
+
+

+ {album.name} +

+

+ {artist.name} + {#if album.year} + · {album.year} + {/if} + · {album.album_type} +

+
+
+
+ + + Download Options + + + {#if user().is_superuser} + + {/if} + + + +
+
+ + + Album Files + Downloaded/downloading versions of this album. + + + + + A list of all downloaded/downloading versions of this album. + + + + Quality + File Path Suffix + Downloaded + + + + {#if albumFiles && albumFiles.length > 0} + {#each albumFiles as file (file)} + + + {getAudioQualityString(file.quality)} + + + {file.file_path_suffix} + + + + + + {/each} + {:else} + + No album files yet. + + {/if} + + + + +
+
+
+ + + Tracks + + Track listing for {album.name}. + + + + + Track listing. + + + # + Title + Duration + + + + {#if album.tracks && album.tracks.length > 0} + {#each album.tracks as track (track.id)} + + {track.number} + {track.title} + + {#if track.duration_ms} + {Math.floor(track.duration_ms / 60000)}:{String( + Math.floor((track.duration_ms % 60000) / 1000) + ).padStart(2, '0')} + {:else} + --:-- + {/if} + + + {/each} + {:else} + + No track data available. + + {/if} + + + + +
+
diff --git a/web/src/routes/dashboard/music/[artistId=uuid]/[albumId=uuid]/+page.ts b/web/src/routes/dashboard/music/[artistId=uuid]/[albumId=uuid]/+page.ts new file mode 100644 index 00000000..bceee5fa --- /dev/null +++ b/web/src/routes/dashboard/music/[artistId=uuid]/[albumId=uuid]/+page.ts @@ -0,0 +1,26 @@ +import type { PageLoad } from './$types'; +import client from '$lib/api'; + +export const load: PageLoad = async ({ params, fetch }) => { + const album = client.GET('/api/v1/music/albums/{album_id}', { + fetch: fetch, + params: { + path: { + album_id: params.albumId + } + } + }); + const files = client.GET('/api/v1/music/albums/{album_id}/files', { + fetch: fetch, + params: { + path: { + album_id: params.albumId + } + } + }); + + return { + album: await album.then((x) => x.data), + albumFiles: await files.then((x) => x.data) + }; +}; diff --git a/web/src/routes/dashboard/music/add-artist/+page.svelte b/web/src/routes/dashboard/music/add-artist/+page.svelte new file mode 100644 index 00000000..e4e61c09 --- /dev/null +++ b/web/src/routes/dashboard/music/add-artist/+page.svelte @@ -0,0 +1,126 @@ + + + + Add Artist - MediaManager + + + +
+
+ + + + + + + +
+
+ +
+
+

+ Add an Artist +

+
+ + { + if (e.key === 'Enter' && !isSearching) { + search(searchTerm); + } + }} + /> +

Search MusicBrainz for an artist to add.

+
+
+ +
+
+ + + + {#if results && results.length === 0} +

No artists found.

+ {:else if results} +
+ {#each results as result (result.external_id)} + + {/each} +
+ {/if} +
diff --git a/web/src/routes/dashboard/music/requests/+page.svelte b/web/src/routes/dashboard/music/requests/+page.svelte new file mode 100644 index 00000000..32563547 --- /dev/null +++ b/web/src/routes/dashboard/music/requests/+page.svelte @@ -0,0 +1,163 @@ + + + + Album Requests - MediaManager + + + +
+
+ + + + + + + +
+
+ +
+

+ Album Requests +

+ + + Requests + All album download requests. + + + + All album requests. + + + Artist + Album + Wanted Quality + Min Quality + Requested By + Authorized + {#if user().is_superuser} + Actions + {/if} + + + + {#if requests && requests.length > 0} + {#each requests as request (request.id)} + + + + {request.artist.name} + + + {request.album.name} + {getAudioQualityString(request.wanted_quality)} + {getAudioQualityString(request.min_quality)} + {request.requested_by?.email ?? 'Unknown'} + {request.authorized ? 'Yes' : 'No'} + {#if user().is_superuser} + + {#if !request.authorized} + + {:else} + + {/if} + + + {/if} + + {/each} + {:else} + + + No album requests yet. + + + {/if} + + + + +
diff --git a/web/src/routes/dashboard/music/requests/+page.ts b/web/src/routes/dashboard/music/requests/+page.ts new file mode 100644 index 00000000..e273447a --- /dev/null +++ b/web/src/routes/dashboard/music/requests/+page.ts @@ -0,0 +1,9 @@ +import type { PageLoad } from './$types'; +import client from '$lib/api'; + +export const load: PageLoad = async ({ fetch }) => { + const { data } = await client.GET('/api/v1/music/albums/requests', { fetch: fetch }); + return { + requestsData: data + }; +}; diff --git a/web/src/routes/dashboard/music/torrents/+page.svelte b/web/src/routes/dashboard/music/torrents/+page.svelte new file mode 100644 index 00000000..27557a52 --- /dev/null +++ b/web/src/routes/dashboard/music/torrents/+page.svelte @@ -0,0 +1,94 @@ + + + + Music Torrents - MediaManager + + + +
+
+ + + + + + + +
+
+ +
+

+ Music Torrents +

+ {#if artistTorrents && artistTorrents.length > 0} + {#each artistTorrents as artistTorrent (artistTorrent.artist_id)} +
+ + + + + {artistTorrent.name} + + + + + + + + Title + Quality + Imported + + + + {#each artistTorrent.torrents as torrent (torrent.torrent_id)} + + {torrent.torrent_title} + {getAudioQualityString(torrent.quality)} + + + + + {/each} + + + + +
+ {/each} + {:else} +
No Torrents added yet.
+ {/if} +
diff --git a/web/src/routes/dashboard/music/torrents/+page.ts b/web/src/routes/dashboard/music/torrents/+page.ts new file mode 100644 index 00000000..2b173a89 --- /dev/null +++ b/web/src/routes/dashboard/music/torrents/+page.ts @@ -0,0 +1,7 @@ +import type { PageLoad } from './$types'; +import client from '$lib/api'; + +export const load: PageLoad = async ({ fetch }) => { + const { data } = await client.GET('/api/v1/music/artists/torrents', { fetch: fetch }); + return { torrents: data }; +}; diff --git a/web/src/routes/dashboard/tv/add-show/+page.svelte b/web/src/routes/dashboard/tv/add-show/+page.svelte index 877138b8..fe7e0af4 100644 --- a/web/src/routes/dashboard/tv/add-show/+page.svelte +++ b/web/src/routes/dashboard/tv/add-show/+page.svelte @@ -150,7 +150,7 @@ md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5" > {#each data as dataItem (dataItem.external_id)} - + {/each} {/if}