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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -11,7 +11,7 @@ const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
|
||||
|
||||
const BLANK: DownloadSpec = {
|
||||
mode: "av",
|
||||
max_height: 1080,
|
||||
max_height: 720,
|
||||
container: "mp4",
|
||||
audio_format: "m4a",
|
||||
vcodec: null,
|
||||
|
||||
Reference in New Issue
Block a user