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:
@@ -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) ---------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user