Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -58,6 +68,7 @@
"SeasonRequest",
"Show",
"Torrent",
"Track",
"User",
]

Expand Down
82 changes: 82 additions & 0 deletions alembic/versions/122dd19d0818_add_book_tables.py
Original file line number Diff line number Diff line change
@@ -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")
97 changes: 97 additions & 0 deletions alembic/versions/6679fc11aa8f_add_music_tables.py
Original file line number Diff line number Diff line change
@@ -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 ###
18 changes: 17 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -169,4 +179,10 @@ primary_languages = [""]
default_language = "en"

[metadata.tvdb]
tvdb_relay_url = "https://metadata-relay.dorninger.co/tvdb"
tvdb_relay_url = "https://metadata-relay.dorninger.co/tvdb"

[metadata.musicbrainz]
enabled = true

[metadata.openlibrary]
enabled = true
3 changes: 3 additions & 0 deletions media_manager/books/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import logging

log = logging.getLogger(__name__)
70 changes: 70 additions & 0 deletions media_manager/books/dependencies.py
Original file line number Diff line number Diff line change
@@ -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)]
Loading
Loading