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]}")
|
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) ---------------------------------------------------------
|
# --- filmstrip (editor scrub track) ---------------------------------------------------------
|
||||||
|
|
||||||
_SB_COLS = 12
|
_SB_COLS = 12
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ _DEFAULTS = {
|
|||||||
# Bump when the spec→output-bytes mapping changes so an identical spec gets a fresh cache
|
# 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
|
# 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.
|
# 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"]
|
_SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"]
|
||||||
_VCODEC_SORT = {"h264": "vcodec:h264", "vp9": "vcodec:vp9", "av1": "vcodec:av01"}
|
_VCODEC_SORT = {"h264": "vcodec:h264", "vp9": "vcodec:vp9", "av1": "vcodec:av01"}
|
||||||
@@ -142,22 +144,41 @@ def build_ydl_opts(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
hcap = f"[height<=?{h}]" if h 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":
|
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
|
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"]:
|
if s["container"]:
|
||||||
opts["merge_output_format"] = s["container"]
|
opts["merge_output_format"] = s["container"]
|
||||||
sort = []
|
sort = []
|
||||||
# Compatibility-first for mp4 (the universal container): prefer H.264 video + AAC
|
# Keep the codec preference as a tiebreaker (ranks H.264/AAC above resolution among the
|
||||||
# audio so shared/downloaded files play in EVERY browser, including iOS/Safari.
|
# selected candidates); the selector above is the hard guarantee, this is belt-and-braces.
|
||||||
# WebKit (which every iOS browser, Brave included, is forced to use) decodes only
|
if compat:
|
||||||
# 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:
|
|
||||||
sort += ["vcodec:h264", "acodec:aac"]
|
sort += ["vcodec:h264", "acodec:aac"]
|
||||||
if h:
|
if h:
|
||||||
sort.append(f"res:{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:
|
def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict) -> None:
|
||||||
staging = _staging_dir(asset_id)
|
staging = _staging_dir(asset_id)
|
||||||
try:
|
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")
|
raise RuntimeError("yt-dlp produced no output file")
|
||||||
produced = cands[0]
|
produced = cands[0]
|
||||||
|
|
||||||
|
produced = _ensure_ios_compatible(job_id, produced, spec, info)
|
||||||
ext = produced.suffix.lstrip(".").lower()
|
ext = produced.suffix.lstrip(".").lower()
|
||||||
meta = storage.MediaMeta(
|
meta = storage.MediaMeta(
|
||||||
video_id=info.get("id") or source_ref,
|
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()
|
db.commit()
|
||||||
log.info("job %s done: %s", job_id, rel)
|
log.info("job %s done: %s", job_id, rel)
|
||||||
|
|
||||||
except _Aborted:
|
except (_Aborted, editmod.EditAborted):
|
||||||
# Paused/canceled mid-download: free the asset for a later retry; keep the job's status
|
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
|
||||||
# (the route set it to paused/canceled). ref_count is adjusted by the route.
|
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
|
||||||
_reset_asset_pending(asset_id)
|
_reset_asset_pending(asset_id)
|
||||||
log.info("job %s aborted (pause/cancel)", job_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
|
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 = {
|
const BLANK: DownloadSpec = {
|
||||||
mode: "av",
|
mode: "av",
|
||||||
max_height: 1080,
|
max_height: 720,
|
||||||
container: "mp4",
|
container: "mp4",
|
||||||
audio_format: "m4a",
|
audio_format: "m4a",
|
||||||
vcodec: null,
|
vcodec: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user