From fe0b34cd00e5b98fe7824a8ef8b7559d44fcc00c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 29 Aug 2026 08:02:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(downloads):=20local=20file=20upload=20inge?= =?UTF-8?q?st=20=E2=80=94=20media=20library=20backend=20(E7=20S5a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the non-yt-dlp ingest path so a user-uploaded file joins the same download library and inherits its function set. Any file type is accepted: media (video/audio) gets the full set (metadata, share, play/edit), a generic file (pdf/zip/docx/…) is download-only. - MediaAsset.media_kind ("video"|"audio"|"file") — the stored discriminator that gates library actions. Set at every finalize (download probe, editor carries the source's kind, upload probe); consolidates the ad-hoc audio-detect that lived only in links.py into one invariant (downloads.mediakind.derive). Migration 0076 adds the column + backfills existing rows. - downloads/upload.py: ingest_upload probes the file (ffprobe → codecs/dims/duration), places it in a per-user .uploads tree, and creates an already-ready asset + done job (the same cache-hit shortcut enqueue uses). Uploads never expire (irreplaceable). - POST /api/downloads/upload: streaming multipart with the per-file cap (download_upload_max_bytes, default 500 MiB) enforced on actual bytes read; quota charges the known size up front (quota.check_upload). - Security: a generic file is always served force-download (attachment + application/octet-stream) on both the in-app and public endpoints, so uploaded HTML/SVG can't execute inline. Edit + redownload reject non-media / upload sources. - python-multipart added (first multipart endpoint in the app). --- .../alembic/versions/0076_media_asset_kind.py | 41 ++++ backend/app/config.py | 4 + backend/app/downloads/links.py | 15 +- backend/app/downloads/mediakind.py | 23 +++ backend/app/downloads/pipeline.py | 3 +- backend/app/downloads/quota.py | 16 ++ backend/app/downloads/service.py | 9 +- backend/app/downloads/storage.py | 22 +++ backend/app/downloads/upload.py | 158 +++++++++++++++ backend/app/models.py | 6 + backend/app/routes/downloads.py | 82 +++++++- backend/app/routes/public.py | 14 +- backend/app/sysconfig.py | 1 + backend/requirements.lock | 1 + backend/requirements.txt | 3 + backend/tests/test_links.py | 15 +- backend/tests/test_upload.py | 184 ++++++++++++++++++ 17 files changed, 578 insertions(+), 19 deletions(-) create mode 100644 backend/alembic/versions/0076_media_asset_kind.py create mode 100644 backend/app/downloads/mediakind.py create mode 100644 backend/app/downloads/upload.py create mode 100644 backend/tests/test_upload.py 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