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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user