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
+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