fix(downloads): make shared files iOS-playable + default to 720p

Shared /watch links showed a "broken play button" on iPad (any iOS
browser — all forced onto WebKit) while playing fine on desktop Chrome.
Root cause: the stored mp4's video codec was AV1 (confirmed via ffprobe),
which WebKit can't decode. The existing H.264 guard was only a
format_sort *preference*, so a non-YouTube source (Facebook) that ships
AV1 as its best video-only stream slipped through.

- B1: for the mp4 compat profile, prefer H.264 at the SELECTION level
  with a progressive fallback (bestvideo[vcodec^=avc1]+bestaudio /
  best[vcodec^=avc1] / anything) so an avc1 progressive is chosen over
  an AV1-only video-only stream.
- B2: re-encode the rare AV1/VP9-only result to H.264+AAC (+faststart)
  in the worker before storing, gated on the compat profile; corrects
  the asset codec metadata to match.
- Bump _SIG_VERSION 2->3 so cached AV1 assets re-derive.
- Default download preset is now 720p (migration 0058 re-seeds builtins
  720p-first; ProfileEditor BLANK 1080->720).
- Tests for the selector, compat opt-outs, and the transcode predicate.
This commit is contained in:
2026-07-22 01:19:31 +02:00
parent 51781819db
commit 8fd0049dce
6 changed files with 236 additions and 16 deletions
@@ -0,0 +1,71 @@
"""reset builtin download profiles: 720p is now the default (first) preset
Revision ID: 0058_dl_default_720p
Revises: 0057_plex_ppl_enriched
Create Date: 2026-07-22
The download dialog preselects the first builtin (lowest sort_order). Make 720p that default —
it plays everywhere, downloads faster, and is plenty for the shared /watch player — with 1080p and
Best still one click away. Re-seeds the builtin rows so fresh installs and existing DBs converge
(same mechanism as 0040). Existing jobs are unaffected (they snapshot their spec; a profile_id that
pointed at a replaced row just SET NULLs).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0058_dl_default_720p"
down_revision: Union[str, None] = "0057_plex_ppl_enriched"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _spec(mode, height=None, container=None, audio_format=None):
return {
"mode": mode,
"max_height": height,
"container": container,
"audio_format": audio_format,
"vcodec": None,
"embed_subs": False,
"embed_chapters": False,
"embed_thumbnail": False,
"sponsorblock": False,
}
# 720p first (the default preselection), then 1080p, Best, then the rest.
_BUILTINS = [
("720p (MP4)", _spec("av", 720, "mp4"), 10),
("1080p (MP4)", _spec("av", 1080, "mp4"), 20),
("Best (MP4)", _spec("av", None, "mp4"), 30),
("Audio (M4A)", _spec("a", None, None, "m4a"), 40),
("Audio (MP3)", _spec("a", None, None, "mp3"), 50),
("Video only (MP4)", _spec("v", None, "mp4"), 60),
]
_profiles = sa.table(
"download_profiles",
sa.column("user_id", sa.Integer),
sa.column("name", sa.String),
sa.column("spec", sa.JSON),
sa.column("is_builtin", sa.Boolean),
sa.column("sort_order", sa.Integer),
)
def upgrade() -> None:
op.execute("DELETE FROM download_profiles WHERE is_builtin = true")
op.bulk_insert(
_profiles,
[
{"user_id": None, "name": n, "spec": s, "is_builtin": True, "sort_order": o}
for n, s, o in _BUILTINS
],
)
def downgrade() -> None:
# No-op: the previous builtin set (1080p first) was seeded by 0040; not worth reconstructing.
pass
+40
View File
@@ -268,6 +268,46 @@ def run_ffmpeg(cmd, total_s, on_progress, should_cancel) -> None:
raise RuntimeError(f"ffmpeg failed ({ret}): {err[:300]}")
# --- ffmpeg: iOS/Safari codec compatibility -------------------------------------------------
# Apple WebKit (every iOS browser, Brave included) decodes only H.264/H.265 in mp4 — an AV1/VP9
# file plays on desktop Chrome but shows a "broken player" on iPad. The mp4 compat profile now
# *selects* H.264, but a source may expose no H.264 stream at all, so this is the last-ditch net.
_IOS_SAFE_VCODECS = ("h264", "hevc")
def probe_vcodec(path: Path) -> str | None:
"""Return the file's primary video codec name (lowercased) via ffprobe, or None if unknown."""
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
"stream=codec_name", "-of", "default=nw=1:nk=1", str(path)],
capture_output=True, text=True, timeout=30, check=True,
).stdout.strip().lower()
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
return None
return out or None
def needs_ios_transcode(vcodec: str | None) -> bool:
"""True when an mp4's video codec won't play in Apple WebKit and should be re-encoded to H.264.
None (unknown/ffprobe failed) is treated as safe so we never re-encode on a bad probe."""
return bool(vcodec) and vcodec not in _IOS_SAFE_VCODECS and vcodec != "none"
def build_h264_transcode_cmd(src: Path, dest: Path) -> list[str]:
"""Re-encode `src` to an iOS-safe H.264 + AAC mp4 (faststart), preserving resolution.
`-progress pipe:1` streams machine-readable progress for run_ffmpeg."""
return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
"-progress", "pipe:1", "-nostats",
"-i", str(src),
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
"-c:a", "aac", "-b:a", "192k",
"-movflags", "+faststart",
str(dest),
]
# --- filmstrip (editor scrub track) ---------------------------------------------------------
_SB_COLS = 12
+33 -12
View File
@@ -47,7 +47,9 @@ _DEFAULTS = {
# Bump when the spec→output-bytes mapping changes so an identical spec gets a fresh cache
# identity instead of hitting a stale asset produced by the old rule. v2: mp4 downloads now
# prefer H.264+AAC (browser/iOS-compatible) rather than yt-dlp's default VP9/Opus best.
_SIG_VERSION = 2
# 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
_SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"]
_VCODEC_SORT = {"h264": "vcodec:h264", "vp9": "vcodec:vp9", "av1": "vcodec:av01"}
@@ -142,22 +144,41 @@ 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
if s["mode"] == "v":
opts["format"] = f"bestvideo{hcap}/best{hcap}"
if compat:
opts["format"] = (
f"bestvideo[vcodec^=avc1]{hcap}/best[vcodec^=avc1]{hcap}"
f"/bestvideo{hcap}/best{hcap}"
)
else:
opts["format"] = f"bestvideo{hcap}/best{hcap}"
else: # av
opts["format"] = f"bestvideo{hcap}+bestaudio/best{hcap}"
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 = []
# Compatibility-first for mp4 (the universal container): prefer 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/Opus-in-mp4 file shows a broken player there,
# even though Chromium plays it fine. These sort keys rank codec ABOVE resolution, so
# a 720p H.264 stream is chosen over a 1080p-VP9-only one; VP9/AV1 remain a graceful
# fallback only when no H.264 stream exists. A custom profile that explicitly sets
# `vcodec` opts out (its codec choice wins over the compatibility default).
if s["container"] == "mp4" and s["vcodec"] is None:
# 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"]
if h:
sort.append(f"res:{h}")
+32 -3
View File
@@ -398,6 +398,34 @@ def _download_settings() -> tuple[str, int]:
)
def _ensure_ios_compatible(job_id: int, produced: Path, spec: dict, info: dict) -> Path:
"""Last-ditch iOS net: if the mp4 compat profile still yielded an Apple-undecodable codec
(AV1/VP9 — happens when a source exposes no H.264 stream at all), re-encode to H.264 + AAC so
shared /watch files play on iPad. Gated on the compat profile so an explicit `vcodec` or a
non-mp4 container is respected. Returns the file to store (re-encoded, or the original), and
corrects `info`'s codec fields so the stored asset metadata matches the actual file."""
if spec.get("container") != "mp4" or spec.get("vcodec"):
return produced
if produced.suffix.lower() != ".mp4":
return produced
vcodec = editmod.probe_vcodec(produced)
if not editmod.needs_ios_transcode(vcodec):
return produced
dest = produced.with_name(produced.stem + ".ios.mp4")
log.info("job %s: re-encoding %s→h264 for iOS compatibility", job_id, vcodec)
_set_job(job_id, phase="processing", progress=0, speed_bps=None, eta_s=None)
editmod.run_ffmpeg(
editmod.build_h264_transcode_cmd(produced, dest),
total_s=int(info["duration"]) if info.get("duration") else 0,
on_progress=lambda pct: _set_job(job_id, progress=int(pct), phase="processing"),
should_cancel=lambda: _job_status(job_id) not in ("running", None),
)
produced.unlink(missing_ok=True)
info["vcodec"], info["acodec"] = "h264", "aac" # asset metadata must match the stored file
return dest
def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict) -> None:
staging = _staging_dir(asset_id)
try:
@@ -418,6 +446,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
raise RuntimeError("yt-dlp produced no output file")
produced = cands[0]
produced = _ensure_ios_compatible(job_id, produced, spec, info)
ext = produced.suffix.lstrip(".").lower()
meta = storage.MediaMeta(
video_id=info.get("id") or source_ref,
@@ -479,9 +508,9 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
db.commit()
log.info("job %s done: %s", job_id, rel)
except _Aborted:
# Paused/canceled mid-download: free the asset for a later retry; keep the job's status
# (the route set it to paused/canceled). ref_count is adjusted by the route.
except (_Aborted, editmod.EditAborted):
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
_reset_asset_pending(asset_id)
log.info("job %s aborted (pause/cancel)", job_id)
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
+59
View File
@@ -0,0 +1,59 @@
"""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."""
from app.downloads import edit as editmod
from app.downloads import formats
def _opts(spec):
return formats.build_ydl_opts(spec, "/tmp/%(id)s.%(ext)s", lambda d: None)
def test_mp4_default_selects_avc1_with_progressive_fallback():
fmt = _opts({"mode": "av", "container": "mp4"})["format"]
# avc1 video-only first, then the avc1 progressive, then an unrestricted fallback.
assert fmt.startswith("bestvideo[vcodec^=avc1]+bestaudio[acodec^=mp4a]")
assert "/best[vcodec^=avc1]" in fmt
assert fmt.endswith("bestvideo+bestaudio/best")
def test_mp4_default_keeps_h264_sort_tiebreaker():
sort = _opts({"mode": "av", "container": "mp4"})["format_sort"]
assert "vcodec:h264" in sort and "acodec:aac" in sort
def test_video_only_mp4_selects_avc1():
fmt = _opts({"mode": "v", "container": "mp4"})["format"]
assert fmt.startswith("bestvideo[vcodec^=avc1]")
assert "+bestaudio" not in fmt # video-only never merges audio
def test_max_height_caps_every_branch():
fmt = _opts({"mode": "av", "container": "mp4", "max_height": 720})["format"]
assert fmt.count("[height<=?720]") >= 3 # applied to each avc1/fallback branch
def test_explicit_vcodec_opts_out_of_avc1_selector():
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"]
def test_non_mp4_container_opts_out():
fmt = _opts({"mode": "av", "container": "webm"})["format"]
assert "vcodec^=avc1" not in fmt
def test_audio_only_unaffected():
assert _opts({"mode": "a", "audio_format": "m4a"})["format"] == "bestaudio/best"
def test_needs_ios_transcode_flags_only_undecodable_codecs():
assert editmod.needs_ios_transcode("av1") is True
assert editmod.needs_ios_transcode("vp9") is True
assert editmod.needs_ios_transcode("h264") is False
assert editmod.needs_ios_transcode("hevc") is False
# An unknown/failed probe must NOT trigger a needless re-encode.
assert editmod.needs_ios_transcode(None) is False
assert editmod.needs_ios_transcode("none") is False