diff --git a/backend/alembic/versions/0076_media_asset_kind.py b/backend/alembic/versions/0076_media_asset_kind.py new file mode 100644 index 00000000..b85b4d41 --- /dev/null +++ b/backend/alembic/versions/0076_media_asset_kind.py @@ -0,0 +1,41 @@ +"""media_assets.media_kind (E7 S5a: the video|audio|file discriminator for the media library) + +Adds a stored media-kind to each asset so the library can gate its actions on it (play/edit for +media, download-only for a generic uploaded file) instead of re-deriving from codecs in prose. The +column is the single home of the invariant `downloads.mediakind.derive` computes at finalize. + +Backfill for existing rows: a stored video codec -> 'video', an audio-only codec -> 'audio', an +editor derivative (source_kind='edit', which carries no codecs but is always a video clip) -> +'video'. Rows still pending/errored (no codecs, not an edit) stay NULL — kind is set when they +finalize. Empty `media_kind` never reaches the UI: only `ready`/`done` rows render actions, and +those are all backfilled here. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0076_media_asset_kind" +down_revision: Union[str, None] = "0075_playlist_sync_baseline" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("media_assets", sa.Column("media_kind", sa.String(length=16), nullable=True)) + op.execute( + """ + UPDATE media_assets + SET media_kind = CASE + WHEN vcodec IS NOT NULL THEN 'video' + WHEN acodec IS NOT NULL THEN 'audio' + WHEN source_kind = 'edit' THEN 'video' + ELSE NULL + END + """ + ) + + +def downgrade() -> None: + op.drop_column("media_assets", "media_kind") diff --git a/backend/app/config.py b/backend/app/config.py index d48d6de9..0af52517 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -208,6 +208,10 @@ class Settings(BaseSettings): download_default_max_bytes: int = 5_368_709_120 # 5 GiB download_default_max_concurrent: int = 2 download_default_max_jobs: int = 50 + # Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker + # finishes), an upload's size is known at ingest, so this bounds a single file directly; the + # user's overall byte footprint is still governed by download_default_max_bytes. + download_upload_max_bytes: int = 524_288_000 # 500 MiB # Retention: an asset expires this many days after its last access; the GC job then # deletes it (once no job references it) after an additional grace window. download_retention_days: int = 30 diff --git a/backend/app/downloads/links.py b/backend/app/downloads/links.py index 0344bf17..41b3cdd5 100644 --- a/backend/app/downloads/links.py +++ b/backend/app/downloads/links.py @@ -80,13 +80,13 @@ def public_meta( Resolves the owner's per-download display overrides (custom title/channel) over the asset's auto-extracted values, and surfaces the source + any extra reference links as clickable URLs.""" - from app.downloads import service + from app.downloads import mediakind, service source_url = service.reference_url(job, asset) - # Audio-only assets (mp3 rips, extracted audio) carry a codec but no video stream. WebKit (all - # iOS browsers) refuses to play them inside a