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) ---------------------------------------------------------
+40 -11
View File
@@ -20,13 +20,35 @@ from xml.sax.saxutils import escape
# Reserve room for the " [id].ext" suffix + parent dirs within the 255-byte component limit.
_MAX_TITLE = 120
# User-facing (Content-Disposition) names can be longer than an on-disk component.
_MAX_DISPLAY = 150
# Filesystem-illegal on Windows/Plex + control chars.
_ILLEGAL = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
# Unicode categories we drop entirely: emoji/pictographs (So), modifier symbols/skin-tones
# (Sk), and every control/format/private/surrogate/unassigned char (C*). Letters (incl.
# accented á/ö/ü/ß), digits, and ordinary punctuation are KEPT — Siftlode is trilingual, so
# ASCII-folding would mangle Hungarian/German titles.
# (Sk), and every control/format/private/surrogate/unassigned char (C*).
_DROP_CATEGORIES = {"So", "Sk", "Cc", "Cf", "Cs", "Co", "Cn"}
# Latin letters NFKD can't decompose to an ASCII base — fold them explicitly (trilingual HU/DE
# plus common European). Everything else with a diacritic (á/é/ö/ő/ü/ű, ç, ñ…) NFKD-decomposes
# into base + a combining mark we then drop, and any remaining non-ASCII is stripped, so every
# generated name is pure ASCII: no spaces, no accents — portable and free of the `?` mojibake
# that accented names show in non-UTF-8 tools.
_FOLD_MAP = str.maketrans(
{
"ß": "ss", "": "SS", "ø": "o", "Ø": "O", "ł": "l", "Ł": "L", "đ": "d", "Đ": "D",
"ð": "d", "Ð": "D", "þ": "th", "Þ": "Th", "æ": "ae", "Æ": "AE", "œ": "oe", "Œ": "OE",
"ı": "i", "İ": "I", "·": " ", "": "-", "": "-",
}
)
def _ascii_fold(text: str) -> str:
"""Transliterate to pure ASCII: apply the explicit map, NFKD-split accented letters and drop
the combining marks (á→a, ő→o), then strip any leftover non-ASCII (non-Latin scripts that have
no ASCII base)."""
text = text.translate(_FOLD_MAP)
text = unicodedata.normalize("NFKD", text)
text = "".join(ch for ch in text if unicodedata.category(ch) != "Mn")
return text.encode("ascii", "ignore").decode("ascii")
@dataclass
@@ -41,16 +63,17 @@ class MediaMeta:
def sanitize(name: str, limit: int = _MAX_TITLE) -> str:
"""Turn an arbitrary (emoji-laden, clickbait) title into a clean, space-free path token.
"""Turn an arbitrary (emoji-laden, clickbait) title into a clean, space-free ASCII path token.
Steps: NFKC-normalize (fold fancy width/compat forms) → drop emoji/symbols/control chars →
strip filesystem-illegal chars → collapse whitespace and runs of the same punctuation →
join words with underscores → trim junk punctuation from the ends → length-cap. Accented
letters and digits survive; the result never contains spaces or path separators."""
ASCII-fold accents (á→a, ő→o, ß→ss) → strip filesystem-illegal chars → collapse whitespace and
runs of the same punctuation → join words with underscores → trim junk punctuation from the ends
→ length-cap. The result is pure ASCII with no spaces or path separators (digits survive)."""
text = unicodedata.normalize("NFKC", name or "")
text = "".join(
ch for ch in text if unicodedata.category(ch) not in _DROP_CATEGORIES
)
text = _ascii_fold(text)
text = _ILLEGAL.sub("", text)
# Collapse repeated punctuation ("!!!", "???", "-----") to a single mark.
text = re.sub(r"([^\w\s])\1{1,}", r"\1", text)
@@ -68,13 +91,19 @@ def sanitize(name: str, limit: int = _MAX_TITLE) -> str:
def display_filename(name: str) -> str:
"""User-facing download filename (Content-Disposition): strip emoji/symbols/control +
filesystem-illegal chars, but KEEP spaces, accents, and ordinary punctuation — unlike the
underscore-joined on-disk name, this is the readable name the user saves to their device."""
"""User-facing download filename (Content-Disposition) base: the same ASCII-slug policy as the
on-disk name — emoji/symbols/control + illegal chars dropped, accents folded, whitespace joined
with underscores — so a saved file is portable (no spaces, no accents). Only the length cap
differs (a served name may be longer than an on-disk path component)."""
text = unicodedata.normalize("NFKC", name or "")
text = "".join(ch for ch in text if unicodedata.category(ch) not in _DROP_CATEGORIES)
text = _ascii_fold(text)
text = _ILLEGAL.sub("", text)
text = re.sub(r"\s+", " ", text).strip(" .")
text = re.sub(r"([^\w\s])\1{1,}", r"\1", text)
text = re.sub(r"\s+", "_", text.strip())
text = re.sub(r"_{2,}", "_", text).strip("_.-·—–|,;:!?ّ ")
if len(text) > _MAX_DISPLAY:
text = text[:_MAX_DISPLAY].rstrip("_.-")
return text or "download"
+43 -1
View File
@@ -20,7 +20,7 @@ from app.config import settings
from app.db import get_db
from app.downloads import edit as editmod
from app.downloads import links as linksmod
from app.downloads import quota, service, storage
from app.downloads import formats, quota, service, storage
from app.models import (
DownloadJob,
DownloadLink,
@@ -448,6 +448,48 @@ def resume_download(
return _serialize_job(db, job)
@router.post("/{job_id}/redownload")
def redownload_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
"""Force a fresh re-download of a finished (or failed) job, rebuilding the file + Plex tree.
The SAME job row is kept, so any public share link (DownloadLink → job_id) keeps working and
resolves to the newly-downloaded file — a re-download never breaks a link you already sent out
(only a *failed* re-download leaves the job in `error`, which is the intended broken-link case).
Useful after a format/naming fix: the old file is deleted and re-fetched under the current rules
(the format-sig bump alone re-points this to a fresh asset)."""
job = _own_job(db, user, job_id)
if job.job_kind == "edit":
raise HTTPException(status_code=409, detail="Editor clips can't be re-downloaded.")
if job.status in ("queued", "running", "paused"):
raise HTTPException(status_code=409, detail="This download is still in progress.")
# Drop this job's hold on its current asset (deletes the file if it was the last holder), then
# resolve the asset for the current spec and force a genuinely fresh fetch even on a cache hit.
_release_asset(db, job)
spec = formats.normalize(job.profile_snapshot)
sig = formats.format_sig(spec)
asset = service.get_or_create_asset(db, job.source_kind, job.source_ref, sig)
if asset.status == "ready":
if asset.rel_path:
storage.delete_asset_files(settings.download_root, asset.rel_path)
asset.rel_path = None
asset.status = "pending"
service.populate_from_catalog(db, asset)
asset.ref_count = (asset.ref_count or 0) + 1
job.asset_id = asset.id
job.status = "queued"
job.progress = 0
job.phase = None
job.error = None
job.speed_bps = None
job.eta_s = None
db.commit()
return _serialize_job(db, job)
@router.post("/{job_id}/cancel")
def cancel_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
+10
View File
@@ -5,6 +5,8 @@ yt-dlp, so the feed, search, channel pages, and the download center all show tid
raw title is preserved in `Video.original_title` so this is reversible / re-derivable.
Rules (user-approved "option b"):
0. Strip a leading social engagement prefix ("1.6M views · 66K reactions | …") some sources
(Facebook) prepend, up to and including the pipe.
1. Drop emoji / pictographs / symbols / control chars (keep accents — HU/DE/…).
2. Strip trailing SEO hashtag clusters (word hashtags at the end); a numeric "#3" episode
marker survives.
@@ -21,6 +23,13 @@ import re
import unicodedata
_DROP_CATEGORIES = {"So", "Sk", "Cc", "Cf", "Cs", "Co", "Cn"}
# Social engagement prefix some sources (Facebook) prepend, e.g. "1.6M views · 66K reactions | ".
# Match one-or-more "<count> <label>" tokens (· / , separated) terminated by a pipe, then drop it.
_ENGAGEMENT_PREFIX = re.compile(
r"^\s*(?:\d[\d.,]*\s*[KMB]?\s*"
r"(?:views?|reactions?|likes?|comments?|shares?|plays?|followers?)\s*[·,;]?\s*)+\|\s*",
re.IGNORECASE,
)
_LETTER_RUN = re.compile(r"[^\W\d_]+", re.UNICODE)
_TRAILING_HASHTAGS = re.compile(r"(?:\s+#[^\s#]*[^\W\d\s_][^\s#]*)+\s*$", re.UNICODE)
_REPEAT_PUNCT = re.compile(r"([!?.,\-])\1{1,}")
@@ -49,6 +58,7 @@ def normalize_title(raw: str | None) -> str | None:
return raw
t = unicodedata.normalize("NFKC", raw)
t = "".join(ch for ch in t if unicodedata.category(ch) not in _DROP_CATEGORIES)
t = _ENGAGEMENT_PREFIX.sub("", t)
t = _TRAILING_HASHTAGS.sub("", t)
multi = [r for r in _LETTER_RUN.findall(t) if len(r) >= 2]
+20 -15
View File
@@ -398,31 +398,36 @@ 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."""
def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict) -> Path:
"""Guarantee the stored file plays in EVERY browser: H.264 + AAC/MP3 in mp4. If the download
(whatever the source shipped) isn't already that, re-encode the offending stream(s) — or just
remux when the codecs are fine but the container isn't. Gated on the mp4 compat profile, so an
explicit `vcodec` or non-mp4 custom profile opts out. Returns the file to store (converted, or
the original) and corrects `info`'s codec/container fields to match what's actually on disk."""
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):
vcodec, acodec = editmod.probe_codecs(produced)
is_mp4 = produced.suffix.lower() == ".mp4"
needs, reenc_v, reenc_a = editmod.browser_transcode_flags(vcodec, acodec, is_mp4)
if not needs:
return produced
dest = produced.with_name(produced.stem + ".ios.mp4")
log.info("job %s: re-encoding %s→h264 for iOS compatibility", job_id, vcodec)
dest = produced.with_name(produced.stem + ".browser.mp4")
action = "re-encode" if (reenc_v or reenc_a) else "remux"
log.info("job %s: %s (v=%s a=%s mp4=%s) -> browser-safe mp4", job_id, action, vcodec, acodec, is_mp4)
_set_job(job_id, phase="processing", progress=0, speed_bps=None, eta_s=None)
editmod.run_ffmpeg(
editmod.build_h264_transcode_cmd(produced, dest),
editmod.build_browser_safe_cmd(produced, dest, reenc_v, reenc_a),
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
# Keep the stored asset metadata truthful about what's now on disk.
if reenc_v:
info["vcodec"] = "h264"
if reenc_a:
info["acodec"] = "aac"
return dest
@@ -446,7 +451,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)
produced = _ensure_browser_playable(job_id, produced, spec, info)
ext = produced.suffix.lstrip(".").lower()
meta = storage.MediaMeta(
video_id=info.get("id") or source_ref,
+15 -8
View File
@@ -49,11 +49,18 @@ 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
def test_browser_transcode_flags_universal_playability():
F = editmod.browser_transcode_flags
# Already universal: H.264 + AAC in mp4 → no change.
assert F("h264", "aac", True) == (False, False, False)
assert F("h264", "mp3", True) == (False, False, False)
# Non-H.264 video → full video re-encode (AV1/VP9 fail iOS; HEVC fails Chrome/FF).
assert F("av1", "aac", True) == (True, True, False)
assert F("vp9", "opus", True) == (True, True, True)
assert F("hevc", "aac", True) == (True, True, False)
# H.264 but bad audio → audio-only transcode (video copied).
assert F("h264", "opus", True) == (True, False, True)
# Codecs fine but wrong container → remux only (no re-encode).
assert F("h264", "aac", False) == (True, False, False)
# A failed probe / missing stream is never re-encoded.
assert F(None, None, True) == (False, False, False)
+63
View File
@@ -0,0 +1,63 @@
"""ASCII-slug naming (no spaces, no accents) for on-disk paths and served filenames, and the
social engagement-prefix strip in title normalization."""
from app.downloads import storage
from app.titles import normalize_title
def _is_ascii_slug(s: str) -> bool:
return s.isascii() and " " not in s
def test_sanitize_folds_accents_to_ascii():
assert storage.sanitize("Barnabás Skriván") == "Barnabas_Skrivan"
assert storage.sanitize("Comedy Central Magyarország") == "Comedy_Central_Magyarorszag"
assert storage.sanitize("Marco Mobili Bútoráruház Veszprém") == "Marco_Mobili_Butoraruhaz_Veszprem"
# Hungarian double-acute (ő/ű) and German ß.
assert storage.sanitize("Előző Műsor") == "Elozo_Musor"
assert storage.sanitize("Straße") == "Strasse"
def test_sanitize_output_is_pure_ascii_without_spaces():
for name in ("Poénbomba", "Mágáné Zsuzsanna", "café déjà vu", "naïve œuvre"):
out = storage.sanitize(name)
assert _is_ascii_slug(out), out
def test_sanitize_non_latin_falls_back_not_crashes():
# A title with no ASCII base collapses to the untitled fallback rather than an empty name.
assert storage.sanitize("日本語") == "untitled"
def test_display_filename_uses_underscores_and_ascii():
out = storage.display_filename("Amikor a férfi éhesen ér haza")
assert out == "Amikor_a_ferfi_ehesen_er_haza"
assert _is_ascii_slug(out)
def test_download_filename_appends_single_extension():
from pathlib import Path
assert storage.download_filename("Szép Videó", "mp4", Path("x.mp4")) == "Szep_Video.mp4"
def test_rel_path_is_ascii_everywhere():
meta = storage.MediaMeta(
video_id="abc123", title="Áristom Ödön", uploader="Hajdú Balázs", upload_date=None
)
rel = storage.rel_path(meta, "mp4", "plex", asset_id=7)
assert _is_ascii_slug(rel.replace("/", ""))
assert "Hajdu_Balazs" in rel and "Aristom_Odon" in rel
def test_engagement_prefix_stripped():
assert normalize_title(
"1.6M views · 66K reactions | Amikor a férfi éhesen ér haza"
) == "Amikor a férfi éhesen ér haza"
assert normalize_title("13M views · 162K reactions |Valami cím") == "Valami cím"
assert normalize_title("500 views | Rövid") == "Rövid"
def test_engagement_prefix_does_not_touch_normal_titles():
# A pipe that isn't an engagement prefix must survive.
assert normalize_title("Vlog 12 | A nagy nap") == "Vlog 12 | A nagy nap"
assert normalize_title("How I built a boat") == "How I built a boat"
+2 -1
View File
@@ -88,7 +88,8 @@ class TestRelPath:
class TestDownloadFilename:
def test_appends_the_container_extension(self):
assert download_filename("My Video", "mp4", Path("/x/f.webm")) == "My Video.mp4"
# Served names follow the same ASCII-slug policy as on-disk names: spaces → underscores.
assert download_filename("My Video", "mp4", Path("/x/f.webm")) == "My_Video.mp4"
def test_does_not_double_an_extension_already_present(self):
assert download_filename("clip.mp4", "mp4", Path("/x/f.mp4")) == "clip.mp4"