diff --git a/VERSION b/VERSION index 0c55b9cf..e218073d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.75.0 \ No newline at end of file +0.76.0 \ No newline at end of file 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/edit.py b/backend/app/downloads/edit.py index a4a37b8d..2ae70af6 100644 --- a/backend/app/downloads/edit.py +++ b/backend/app/downloads/edit.py @@ -438,18 +438,42 @@ _BROWSER_SAFE_VCODEC = ("h264",) _BROWSER_SAFE_ACODEC = ("aac", "mp3") -def parse_probe_streams(payload: str) -> tuple[str | None, str | None]: - """(video_codec, audio_codec) out of ffprobe's JSON stream list, lowercased. - - JSON, not the flat `default=nw=1` output: there the fields arrive in ffprobe's own struct - order, which puts `codec_name` BEFORE `codec_type`, so a line-by-line "remember the last - codec_type" parser attributes every name to the PREVIOUS stream. Measured on real downloads: - an ordinary H.264+AAC mp4 probed as `v=aac a=None` — the browser-playability check then - believed the video stream was AAC and re-encoded a perfectly fine file on every download.""" +def run_ffprobe_json( + path: Path, + *, + show_streams: bool = False, + show_format: bool = False, + entries: str | None = None, +) -> dict | None: + """One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit, + missing binary, unparseable output). The SINGLE ffprobe-JSON-invocation point in the downloads + package: the browser-playability codec probe here and the upload-ingest probe (`upload._probe`) + both go through it, so the subprocess flags/timeout/error handling live in one place instead of + two drifting copies.""" + args = ["ffprobe", "-v", "error"] + if show_streams: + args.append("-show_streams") + if show_format: + args.append("-show_format") + if entries: + args += ["-show_entries", entries] + args += ["-of", "json", str(path)] try: - streams = json.loads(payload or "{}").get("streams") or [] - except ValueError: - return None, None + out = subprocess.run(args, capture_output=True, text=True, timeout=30, check=True).stdout + return json.loads(out) + except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError): + return None + + +def _codecs_from_streams(streams: list) -> tuple[str | None, str | None]: + """(video_codec, audio_codec) lowercased from an ffprobe JSON stream list — the first of each kind. + + Parsed from the JSON `streams` list, NOT ffprobe's flat `default=nw=1` output: there the fields + arrive in ffprobe's own struct order, which puts `codec_name` BEFORE `codec_type`, so a line-by-line + "remember the last codec_type" parser attributes every name to the PREVIOUS stream. Measured on real + downloads: an ordinary H.264+AAC mp4 probed as `v=aac a=None` — the browser-playability check then + believed the video stream was AAC and re-encoded a perfectly fine file on every download. Reading each + stream dict by its own `codec_type` is immune to that ordering.""" vcodec = acodec = None for s in streams: name = (s.get("codec_name") or "").lower() or None @@ -464,26 +488,10 @@ def parse_probe_streams(payload: str) -> tuple[str | None, str | None]: def probe_codecs(path: Path) -> tuple[str | None, str | None]: """Return (video_codec, audio_codec) lowercased via one ffprobe call; None for a missing stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream).""" - try: - out = subprocess.run( - [ - "ffprobe", - "-v", - "error", - "-show_entries", - "stream=codec_type,codec_name", - "-of", - "json", - str(path), - ], - capture_output=True, - text=True, - timeout=30, - check=True, - ).stdout - except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): + data = run_ffprobe_json(path, entries="stream=codec_type,codec_name") + if data is None: return None, None - return parse_probe_streams(out) + return _codecs_from_streams(data.get("streams") or []) def probe_duration(path: Path) -> int: diff --git a/backend/app/downloads/formats.py b/backend/app/downloads/formats.py index 5ad7dce6..cf836462 100644 --- a/backend/app/downloads/formats.py +++ b/backend/app/downloads/formats.py @@ -5,13 +5,16 @@ of truth for what file gets produced. `format_sig` hashes exactly the fields tha OUTPUT BYTES, so two users picking equivalent settings share one cached MediaAsset. `build_ydl_opts` turns a spec into a yt-dlp options dict (format selector + postprocessors). -spec shape (all keys optional; defaults applied by `normalize`): +spec shape (all keys optional; defaults + browser-playability clamp applied by `normalize`): mode "av" (audio+video) | "v" (video-only) | "a" (audio-only) max_height int | null (null = best) - container "mp4" | "mkv" | "webm" | null (merge/remux target for av/v) - audio_format "m4a" | "mp3" | "opus" | null (extract target for mode "a") - vcodec "h264" | "vp9" | "av1" | null (preference, best-effort) + container always coerced to "mp4" (the only universally browser-playable container) + audio_format "m4a" | "mp3" (mode "a" extract target; anything else — e.g. opus — → "m4a") + vcodec always coerced to null → H.264 selected (see `normalize`); no opt-out embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool + +`normalize` clamps container/vcodec/audio_format so EVERY download plays in-browser (E7); the +`container`/`vcodec` keys survive only so an old stored profile deserializes without error. """ import hashlib @@ -50,10 +53,12 @@ _DEFAULTS = { # prefer H.264+AAC (browser/iOS-compatible) rather than yt-dlp's default VP9/Opus best. # v3: mp4 compat now *selects* H.264 (avc1) with a progressive fallback + re-encodes the rare # AV1/VP9-only result to H.264, so cached AV1 assets must re-derive (fixes iOS broken-player). -_SIG_VERSION = 3 +# v4: EVERY download is clamped to the mp4/H.264/AAC compat profile (no more mkv/webm/VP9/AV1/Opus +# opt-out), so any asset cached under an old non-compat spec — or a stale non-H.264 mp4 that slipped +# the v3 net — must re-derive through the current browser-safe guarantee. +_SIG_VERSION = 4 _SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"] -_VCODEC_SORT = {"h264": "vcodec:h264", "vp9": "vcodec:vp9", "av1": "vcodec:av01"} def normalize(spec: dict) -> dict: @@ -76,6 +81,18 @@ def normalize(spec: dict) -> dict: # Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful. if s["mode"] == "a": s["embed_subs"] = False + # Browser-playability guarantee (E7): the Download module exists to download → share → PLAY + # in-browser, so every output must play in EVERY browser, iOS/Safari/WebKit included. The only + # universally-safe combo is mp4 + H.264 + AAC/MP3, so we CLAMP the spec to it HERE — the single + # point every path (picker, stored profile, raw API) flows through — instead of only hiding + # options in the UI. Non-mp4 containers (mkv doesn't play at all; webm/VP9/AV1 fail iOS) and any + # explicit vcodec are coerced to the compat profile; Opus audio (Safari-flaky as a bare file) + # falls back to AAC/m4a. A source that exposes no H.264 is still caught by the worker's + # browser-safe re-encode (`_ensure_browser_playable`), so playback is guaranteed end-to-end. + s["container"] = "mp4" + s["vcodec"] = None + if s["audio_format"] not in ("m4a", "mp3"): + s["audio_format"] = "m4a" return s @@ -153,51 +170,33 @@ def build_ydl_opts( ) else: hcap = f"[height<=?{h}]" if h else "" - # Compatibility-first for mp4 (the universal container): produce H.264 video + AAC audio - # so shared/downloaded files play in EVERY browser, including iOS/Safari. WebKit (which - # every iOS browser, Brave included, is forced to use) decodes only H.264/H.265 + AAC - # inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there, even though Chromium - # plays it fine. A custom profile that explicitly sets `vcodec` opts out. - compat = s["container"] == "mp4" and s["vcodec"] is None + # Every video download is the mp4/H.264 compat profile (normalize() clamps container→mp4, + # vcodec→None), so shared/downloaded files play in EVERY browser, iOS/Safari included. WebKit + # (which every iOS browser, Brave included, is forced to use) decodes only H.264/H.265 + AAC + # inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there — so H.264 is selected at the + # SELECTION level, not merely preferred: a sort key can't conjure an H.264 stream a source + # doesn't expose as video-only. A source that ships only non-H.264 is caught after download by + # the worker's browser-safe re-encode (`_ensure_browser_playable`). if s["mode"] == "v": - if compat: - # Video-only: keep it audio-free. Prefer an H.264 *video-only* stream, then any - # video-only, and only fall to a progressive (audio-bearing) `best` as a last - # resort — same audio-free-when-possible guarantee as the old `bestvideo/best`. - opts["format"] = ( - f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}" - f"/best[vcodec^=avc1]{hcap}/best{hcap}" - ) - else: - opts["format"] = f"bestvideo{hcap}/best{hcap}" + # Video-only: keep it audio-free. Prefer an H.264 video-only stream, then any video-only, + # and only fall to a progressive (audio-bearing) `best` as a last resort. + opts["format"] = ( + f"bestvideo[vcodec^=avc1]{hcap}/bestvideo{hcap}/best[vcodec^=avc1]{hcap}/best{hcap}" + ) else: # av - if compat: - # Prefer H.264 at the SELECTION level, not just via format_sort: a sort key is only - # a *preference* and can't conjure an H.264 stream a source doesn't expose as - # video-only. A non-YouTube source (e.g. Facebook) may ship AV1 as the best - # video-only stream and H.264 only as a *progressive* (a+v) format — which - # `bestvideo` skips — so `bestvideo+bestaudio` picked AV1 and iOS broke. This chain - # tries avc1 video-only, then the avc1 progressive, then anything. - opts["format"] = ( - f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]" - f"/best[vcodec^=avc1]{hcap}" - f"/bestvideo{hcap}+bestaudio/best{hcap}" - ) - else: - opts["format"] = f"bestvideo{hcap}+bestaudio/best{hcap}" - if s["container"]: - opts["merge_output_format"] = s["container"] - sort = [] - # Keep the codec preference as a tiebreaker (ranks H.264/AAC above resolution among the - # selected candidates); the selector above is the hard guarantee, this is belt-and-braces. - if compat: - sort += ["vcodec:h264", "acodec:aac"] + # avc1 video-only + m4a audio, then the avc1 progressive, then anything (re-encoded). + opts["format"] = ( + f"bestvideo[vcodec^=avc1]{hcap}+bestaudio[acodec^=mp4a]" + f"/best[vcodec^=avc1]{hcap}" + f"/bestvideo{hcap}+bestaudio/best{hcap}" + ) + opts["merge_output_format"] = "mp4" + # Codec preference tiebreaker (ranks H.264/AAC among the selected candidates); the selector + # above is the hard guarantee, this is belt-and-braces. + sort = ["vcodec:h264", "acodec:aac"] if h: sort.append(f"res:{h}") - if s["vcodec"] in _VCODEC_SORT: - sort.append(_VCODEC_SORT[s["vcodec"]]) - if sort: - opts["format_sort"] = sort + opts["format_sort"] = sort # SponsorBlock first (marks segments), then strip them from the media + chapters. if s["sponsorblock"]: 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