From 94fcc40bde35b99e75c9648f86045fdd3d7ab27f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 29 Aug 2026 06:08:21 +0200 Subject: [PATCH] feat(downloads): guarantee every download is browser-playable (mp4/H.264/AAC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Download module's purpose is download -> share -> play in-browser, so the output must play in EVERY browser (iOS/Safari/WebKit included). The only universally-safe combo is mp4 + H.264 + AAC/MP3. - formats.normalize() now CLAMPS every spec to that profile (container->mp4, vcodec->None, audio_format opus->m4a) at the single point all paths flow through (picker, stored profile, raw API) — not just by hiding UI options. A source exposing no H.264 is still caught by the worker's browser-safe re-encode, so playability is guaranteed end-to-end. - build_ydl_opts drops the now-unreachable non-compat branches (+ _VCODEC_SORT). - _SIG_VERSION 3->4 so assets cached under an old non-compat spec — or a stale non-H.264 mp4 that slipped the v3 net (the HEVC Facebook dls) — re-derive. - ProfileEditor drops the container/codec pickers and Opus; audio is M4A/MP3. - i18n: remove the now-unused container/vcodec/any keys (HU+EN parity). --- backend/app/downloads/formats.py | 93 ++++++++++----------- backend/tests/test_formats.py | 42 +++++++--- frontend/src/components/ProfileEditor.tsx | 35 ++------ frontend/src/i18n/locales/en/downloads.json | 3 - frontend/src/i18n/locales/hu/downloads.json | 3 - 5 files changed, 83 insertions(+), 93 deletions(-) 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/tests/test_formats.py b/backend/tests/test_formats.py index c3edcd1b..b5803d05 100644 --- a/backend/tests/test_formats.py +++ b/backend/tests/test_formats.py @@ -1,6 +1,7 @@ -"""build_ydl_opts: the mp4 compat profile must SELECT H.264 (not merely prefer it), so shared -downloads play on iOS/Safari, while custom vcodec / non-mp4 profiles opt out. See the iPad AV1 -broken-player fix.""" +"""build_ydl_opts: EVERY download is clamped to the mp4/H.264 compat profile (E7 browser- +playability guarantee) and must SELECT H.264 (not merely prefer it), so shared downloads play in +every browser incl. iOS/Safari. Non-mp4 containers and non-H.264 vcodecs are coerced, not honoured. +See the iPad AV1 broken-player fix + E7 format narrowing.""" from app.downloads import edit as editmod from app.downloads import formats @@ -56,16 +57,37 @@ def test_max_height_caps_every_branch(): assert fmt.count("[height<=?720]") >= 3 # applied to each avc1/fallback branch -def test_explicit_vcodec_opts_out_of_avc1_selector(): +def test_explicit_vcodec_is_clamped_to_the_compat_selector(): + # E7 browser-playability guarantee: a non-H.264 vcodec no longer opts out — normalize() coerces + # vcodec→None, so the avc1 selector (and mp4 merge) is used regardless of what was requested. opts = _opts({"mode": "av", "container": "mp4", "vcodec": "vp9"}) - assert "vcodec^=avc1" not in opts["format"] - assert opts["format"] == "bestvideo+bestaudio/best" - assert "vcodec:vp9" in opts["format_sort"] + assert opts["format"].startswith("bestvideo[vcodec^=avc1]") + assert opts["merge_output_format"] == "mp4" + assert "vcodec:vp9" not in opts["format_sort"] -def test_non_mp4_container_opts_out(): - fmt = _opts({"mode": "av", "container": "webm"})["format"] - assert "vcodec^=avc1" not in fmt +def test_non_mp4_container_is_clamped_to_mp4(): + # A webm/mkv request is coerced to the mp4/H.264 compat profile (nothing plays mkv in-browser; + # webm/VP9 fails iOS), so the avc1 selector is used and the merge target is mp4. + opts = _opts({"mode": "av", "container": "webm"}) + assert opts["format"].startswith("bestvideo[vcodec^=avc1]") + assert opts["merge_output_format"] == "mp4" + + +def test_normalize_clamps_to_browser_playable(): + n = formats.normalize({"container": "mkv", "vcodec": "av1", "audio_format": "opus"}) + assert n["container"] == "mp4" + assert n["vcodec"] is None + assert n["audio_format"] == "m4a" + # A playable audio choice is preserved. + assert formats.normalize({"audio_format": "mp3"})["audio_format"] == "mp3" + + +def test_non_playable_specs_dedup_to_the_compat_identity(): + # Two requests that differ only in (now-clamped) non-playable fields share ONE cache asset. + a = formats.format_sig({"mode": "av", "max_height": 720, "container": "webm", "vcodec": "av1"}) + b = formats.format_sig({"mode": "av", "max_height": 720, "container": "mp4"}) + assert a == b def test_audio_only_unaffected(): diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx index b043d5a2..7466b520 100644 --- a/frontend/src/components/ProfileEditor.tsx +++ b/frontend/src/components/ProfileEditor.tsx @@ -195,6 +195,8 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) { + {/* Only browser-playable audio: AAC (m4a) + MP3. Opus is dropped — Safari plays it + unreliably as a bare file, and the module's purpose is in-browser playback. */} )} - {spec.mode !== "a" && ( - <> -
- - -
-
- - -
- - )} + {/* Container/codec are no longer user-selectable: every download is clamped to the + mp4/H.264/AAC compat profile (the only universally browser-playable combo) so a + shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */}
{spec.mode !== "a" && ( diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 68762c63..8338f218 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -151,10 +151,7 @@ "modeA": "Audio only", "quality": "Max quality", "best": "Best", - "container": "Container", "audioFormat": "Audio format", - "vcodec": "Video codec", - "any": "Any", "embedSubs": "Embed subtitles", "embedChapters": "Embed chapters", "embedThumbnail": "Embed thumbnail", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 37ac260e..b08efe38 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -151,10 +151,7 @@ "modeA": "Csak hang", "quality": "Max. minőség", "best": "Legjobb", - "container": "Konténer", "audioFormat": "Hangformátum", - "vcodec": "Videó codec", - "any": "Bármelyik", "embedSubs": "Feliratok beágyazása", "embedChapters": "Fejezetek beágyazása", "embedThumbnail": "Bélyegkép beágyazása",