feat(downloads): ASCII-slug names, universal browser playability, force re-download

Mini-epic "Downloads correctness & UX" (S1–S3), on top of the iOS-AV1 + 720p fixes.

S1 — naming & titles:
- ASCII-fold on-disk dir/file names AND the served (Content-Disposition) filename: no
  spaces (underscore-joined), no accents (á→a, ő→o, ß→ss), pure ASCII — portable and free
  of the `?` mojibake accented names show in non-UTF-8 tools. (Forward-only; existing files
  clean up via re-download.)
- Strip a leading social engagement prefix ("1.6M views · 66K reactions | …") from titles.

S2 — universal browser playability (generalizes the iOS re-encode):
- ensure_browser_playable guarantees the stored file is H.264 + AAC/MP3 in mp4 — the only
  combo every browser decodes. Re-encode just the offending stream(s) (video for AV1/VP9/HEVC,
  audio for Opus/etc), or remux when only the container is wrong. Gated on the mp4 compat
  profile (an explicit vcodec / non-mp4 custom profile opts out).

S3 — force re-download (preserves share links):
- POST /downloads/{job_id}/redownload keeps the job row so its public DownloadLink survives
  and resolves to the freshly-downloaded file; deletes the old file and re-fetches under the
  current rules. A failed re-download leaves the job in error (the intended broken-link case).
- Library "Re-download" action (icon + confirm), api method, hu/en strings.

Tests: naming/title-strip (test_naming), browser_transcode_flags matrix; existing
download_filename expectation updated for the underscore policy.
This commit is contained in:
2026-07-22 02:04:27 +02:00
parent 8fd0049dce
commit e46f215ada
12 changed files with 272 additions and 66 deletions
+44 -26
View File
@@ -268,44 +268,62 @@ 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")
# --- ffmpeg: universal browser playability --------------------------------------------------
# A downloaded file must play in EVERY browser, whatever the source shipped. Universal = H.264
# video + AAC/MP3 audio in an mp4 container: H.264 is the only video codec all browsers decode
# (VP9/AV1 fail on Safari/iOS; HEVC fails on Chrome/Firefox), and mp4/AAC is the safe pairing.
# Anything else is fixed post-download: re-encode the offending stream(s), or just remux when the
# codecs are already fine but the container isn't.
_BROWSER_SAFE_VCODEC = ("h264",)
_BROWSER_SAFE_ACODEC = ("aac", "mp3")
def probe_vcodec(path: Path) -> str | None:
"""Return the file's primary video codec name (lowercased) via ffprobe, or None if unknown."""
def probe_codecs(path: Path) -> tuple[str | None, str | None]:
"""Return (video_codec, audio_codec) lowercased via one ffprobe call; None for a missing
stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream)."""
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
"stream=codec_name", "-of", "default=nw=1:nk=1", str(path)],
["ffprobe", "-v", "error", "-show_entries", "stream=codec_type,codec_name",
"-of", "default=nw=1", str(path)],
capture_output=True, text=True, timeout=30, check=True,
).stdout.strip().lower()
).stdout.lower()
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
return None
return out or None
return None, None
vcodec = acodec = None
ctype = None
for line in out.splitlines():
if line.startswith("codec_type="):
ctype = line.split("=", 1)[1].strip()
elif line.startswith("codec_name="):
name = line.split("=", 1)[1].strip() or None
if ctype == "video" and vcodec is None:
vcodec = name
elif ctype == "audio" and acodec is None:
acodec = name
return vcodec, acodec
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 browser_transcode_flags(vcodec: str | None, acodec: str | None, is_mp4: bool) -> tuple[bool, bool, bool]:
"""Decide what (if anything) makes a file universally browser-playable. Returns
(needs_change, reencode_video, reencode_audio). needs_change=False => already H.264 + AAC/MP3
in mp4. A None codec (no such stream, or a failed probe) is never re-encoded."""
rv = bool(vcodec) and vcodec not in _BROWSER_SAFE_VCODEC
ra = bool(acodec) and acodec not in _BROWSER_SAFE_ACODEC
return (rv or ra or not is_mp4), rv, ra
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 [
def build_browser_safe_cmd(src: Path, dest: Path, reencode_video: bool, reencode_audio: bool) -> list[str]:
"""ffmpeg command that lands `src` as a browser-safe mp4 (faststart): re-encode only the
stream(s) that need it (libx264 / aac), stream-copy the rest, always into mp4. `-progress
pipe:1` streams machine-readable progress for run_ffmpeg."""
cmd = [
"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),
"-progress", "pipe:1", "-nostats", "-i", str(src),
]
cmd += ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20"] if reencode_video else ["-c:v", "copy"]
cmd += ["-c:a", "aac", "-b:a", "192k"] if reencode_audio else ["-c:a", "copy"]
cmd += ["-movflags", "+faststart", str(dest)]
return cmd
# --- filmstrip (editor scrub track) ---------------------------------------------------------