Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
55 changes: 55 additions & 0 deletions alembic/versions/c3f8b2a9d1e4_add_media_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""add media assets

Revision ID: c3f8b2a9d1e4
Revises: a1d7c2f4e8b9
Create Date: 2026-06-27 00:00:00.000000

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "c3f8b2a9d1e4"
down_revision: str | Sequence[str] | None = "a1d7c2f4e8b9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
op.add_column("books", sa.Column("file_media_id", sa.Uuid(), nullable=True))
op.add_column("books", sa.Column("cover_media_id", sa.Uuid(), nullable=True))

op.create_table(
"media_assets",
sa.Column("asset_id", sa.Uuid(), nullable=False),
sa.Column("owner_user_id", sa.Uuid(), nullable=False),
sa.Column("book_id", sa.Uuid(), nullable=False),
sa.Column("kind", sa.String(length=32), nullable=False),
sa.Column("original_filename", sa.String(length=512), nullable=False),
sa.Column("content_type", sa.String(length=255), nullable=False),
sa.Column("extension", sa.String(length=16), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column("sha256", sa.String(length=64), nullable=False),
sa.Column("storage_path", sa.String(length=2048), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["book_id"], ["books.book_id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["owner_user_id"], ["users.user_id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("asset_id"),
)
op.create_index(op.f("ix_media_assets_book_id"), "media_assets", ["book_id"], unique=False)
op.create_index(op.f("ix_media_assets_owner_user_id"), "media_assets", ["owner_user_id"], unique=False)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f("ix_media_assets_owner_user_id"), table_name="media_assets")
op.drop_index(op.f("ix_media_assets_book_id"), table_name="media_assets")
op.drop_table("media_assets")
op.drop_column("books", "cover_media_id")
op.drop_column("books", "file_media_id")
2 changes: 2 additions & 0 deletions papyrus/api/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
dev_powersync_sandbox,
files,
goals,
media,
notes,
progress,
reading_profiles,
Expand All @@ -37,6 +38,7 @@
api_router.include_router(progress.router, prefix="/progress", tags=["Progress"])
api_router.include_router(goals.router, prefix="/goals", tags=["Goals"])
api_router.include_router(sync.router, prefix="/sync", tags=["Sync"])
api_router.include_router(media.router, prefix="/media", tags=["Media"])
api_router.include_router(storage.router, prefix="/storage", tags=["Storage"])
api_router.include_router(files.router, prefix="/files", tags=["Files"])
api_router.include_router(reading_profiles.router, prefix="/reading-profiles", tags=["Reading Profiles"])
Expand Down
58 changes: 58 additions & 0 deletions papyrus/api/routes/media.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Authenticated private media routes."""

from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, File, Form, Request, Response, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession

from papyrus.api.deps import CurrentUserId
from papyrus.config import get_settings
from papyrus.core.database import get_db
from papyrus.core.exceptions import NotFoundError
from papyrus.core.rate_limit import limiter
from papyrus.schemas.media import MediaAssetResponse, MediaUsageResponse
from papyrus.services import media as media_service

router = APIRouter()
DBSession = Annotated[AsyncSession, Depends(get_db)]


@router.post("", response_model=MediaAssetResponse, status_code=status.HTTP_201_CREATED, summary="Upload private media")
@limiter.limit(lambda: f"{get_settings().rate_limit_upload}/minute")
async def upload_media(
request: Request,
user_id: CurrentUserId,
db: DBSession,
book_id: Annotated[UUID, Form()],
kind: Annotated[str, Form()],
file: Annotated[UploadFile, File()],
) -> MediaAssetResponse:
"""Upload a book file or cover image for the authenticated user."""
asset = await media_service.upload_media(db, user_id, book_id=book_id, kind=kind, file=file)
return MediaAssetResponse.model_validate(asset)


@router.get("/usage", response_model=MediaUsageResponse, summary="Get media storage usage")
async def get_media_usage(user_id: CurrentUserId, db: DBSession) -> MediaUsageResponse:
"""Return authenticated user's file storage usage."""
used_bytes, quota_bytes, available_bytes = await media_service.usage(db, user_id)
return MediaUsageResponse(used_bytes=used_bytes, quota_bytes=quota_bytes, available_bytes=available_bytes)


@router.get("/{asset_id}", summary="Download private media")
async def download_media(user_id: CurrentUserId, db: DBSession, asset_id: UUID) -> FileResponse:
"""Download an owned media asset."""
asset = await media_service.get_owned_asset(db, user_id, asset_id)
path = media_service.asset_path(asset)
if not path.exists():
raise NotFoundError("Media asset was not found")
return FileResponse(path, media_type=asset.content_type, filename=asset.original_filename)


@router.delete("/{asset_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete private media")
async def delete_media(user_id: CurrentUserId, db: DBSession, asset_id: UUID) -> Response:
"""Delete an owned media asset."""
await media_service.delete_media(db, user_id, asset_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
1 change: 1 addition & 0 deletions papyrus/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class Settings(BaseSettings):
powersync_service_url: str = "http://localhost:8081"
powersync_service_port: int = 8081
file_storage_quota_bytes: int = 1_073_741_824
media_storage_root: str = ".papyrus-media"
powersync_jwks_uri: str | None = None
powersync_source_role: str | None = None
powersync_source_password: str | None = None
Expand Down
2 changes: 2 additions & 0 deletions papyrus/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from papyrus.core.database import Base
from papyrus.models.auth import AuthExchangeCode, AuthSession, EmailActionToken, PasswordCredential, UserIdentity
from papyrus.models.media import MediaAsset
from papyrus.models.powersync_demo import PowerSyncDemoItem
from papyrus.models.sync import SyncBook
from papyrus.models.user import User
Expand All @@ -9,6 +10,7 @@
"AuthSession",
"Base",
"EmailActionToken",
"MediaAsset",
"PasswordCredential",
"PowerSyncDemoItem",
"SyncBook",
Expand Down
32 changes: 32 additions & 0 deletions papyrus/models/media.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Uploaded private media assets."""

from __future__ import annotations

from datetime import datetime
from uuid import UUID, uuid4

from sqlalchemy import DateTime, ForeignKey, Integer, String, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column

from papyrus.core.database import Base


class MediaAsset(Base):
"""A private uploaded book file or cover image owned by one user."""

__tablename__ = "media_assets"

asset_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid4)
owner_user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True
)
book_id: Mapped[UUID] = mapped_column(ForeignKey("books.book_id", ondelete="CASCADE"), nullable=False, index=True)
kind: Mapped[str] = mapped_column(String(32), nullable=False)
original_filename: Mapped[str] = mapped_column(String(512), nullable=False)
content_type: Mapped[str] = mapped_column(String(255), nullable=False)
extension: Mapped[str] = mapped_column(String(16), nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
sha256: Mapped[str] = mapped_column(String(64), nullable=False)
storage_path: Mapped[str] = mapped_column(String(2048), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
2 changes: 2 additions & 0 deletions papyrus/models/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ class SyncBook(Base):
page_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
cover_image_url: Mapped[str | None] = mapped_column(String(2048), nullable=True)
file_media_id: Mapped[UUID | None] = mapped_column(Uuid, nullable=True)
cover_media_id: Mapped[UUID | None] = mapped_column(Uuid, nullable=True)
reading_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
current_page: Mapped[int | None] = mapped_column(Integer, nullable=True)
current_position: Mapped[float | None] = mapped_column(Float, nullable=True)
Expand Down
33 changes: 33 additions & 0 deletions papyrus/schemas/media.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Media asset schemas."""

from datetime import datetime
from uuid import UUID

from pydantic import BaseModel, ConfigDict


class MediaAssetResponse(BaseModel):
"""Uploaded media asset metadata."""

model_config = ConfigDict(from_attributes=True)

asset_id: UUID
owner_user_id: UUID
book_id: UUID
kind: str
original_filename: str
content_type: str
extension: str
size_bytes: int
sha256: str
storage_path: str
created_at: datetime | None = None
updated_at: datetime | None = None


class MediaUsageResponse(BaseModel):
"""User media storage quota usage."""

used_bytes: int
quota_bytes: int
available_bytes: int
2 changes: 2 additions & 0 deletions papyrus/schemas/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"page_count",
"description",
"cover_image_url",
"file_media_id",
"cover_media_id",
"reading_status",
"current_page",
"current_position",
Expand Down
Loading
Loading