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"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Scissors,
|
||||
Settings2,
|
||||
Share2,
|
||||
@@ -672,14 +673,22 @@ export default function DownloadCenter() {
|
||||
|
||||
const invalidate = () => invalidateDownloads(qc);
|
||||
const act = useMutation({
|
||||
mutationFn: ({ id, op }: { id: number; op: "pause" | "resume" | "cancel" | "delete" }) =>
|
||||
mutationFn: ({
|
||||
id,
|
||||
op,
|
||||
}: {
|
||||
id: number;
|
||||
op: "pause" | "resume" | "cancel" | "delete" | "redownload";
|
||||
}) =>
|
||||
op === "pause"
|
||||
? api.pauseDownload(id)
|
||||
: op === "resume"
|
||||
? api.resumeDownload(id)
|
||||
: op === "cancel"
|
||||
? api.cancelDownload(id)
|
||||
: api.deleteDownload(id),
|
||||
: op === "redownload"
|
||||
? api.redownloadDownload(id)
|
||||
: api.deleteDownload(id),
|
||||
onSuccess: invalidate,
|
||||
// No onError: a failed job action returns 400/409/422/500, already surfaced by the global
|
||||
// error modal (api.ts req) with the backend's specific message — a caller toast would double it.
|
||||
@@ -824,6 +833,18 @@ export default function DownloadCenter() {
|
||||
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.redownloadTitle"),
|
||||
t("downloads.confirm.redownloadBody"),
|
||||
() => act.mutate({ id: job.id, op: "redownload" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.redownload")}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"editDetails": "Edit details",
|
||||
"share": "Share",
|
||||
"edit": "Edit / trim",
|
||||
"redownload": "Re-download (rebuild file)",
|
||||
"removeShared": "Remove from my list"
|
||||
},
|
||||
"empty": {
|
||||
@@ -125,7 +126,9 @@
|
||||
"deleteTitle": "Remove download?",
|
||||
"deleteBody": "This removes it from your list. The file may stay cached for other users until it expires.",
|
||||
"removeSharedTitle": "Remove from your list?",
|
||||
"removeSharedBody": "This removes it from your shared list only — it won't delete the owner's file."
|
||||
"removeSharedBody": "This removes it from your shared list only — it won't delete the owner's file.",
|
||||
"redownloadTitle": "Re-download this file?",
|
||||
"redownloadBody": "The current file is deleted and downloaded again under the latest format and naming rules. Any share links you've sent keep working and will point to the new file (a failed re-download leaves the link broken)."
|
||||
},
|
||||
"profiles": {
|
||||
"manage": "Manage formats",
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"editDetails": "Adatok szerkesztése",
|
||||
"share": "Megosztás",
|
||||
"edit": "Szerkesztés / vágás",
|
||||
"redownload": "Újraletöltés (fájl újraépítése)",
|
||||
"removeShared": "Eltávolítás a listámból"
|
||||
},
|
||||
"empty": {
|
||||
@@ -125,7 +126,9 @@
|
||||
"deleteTitle": "Eltávolítod a letöltést?",
|
||||
"deleteBody": "Ez eltávolítja a listádról. A fájl a lejáratáig még a gyorsítótárban maradhat mások számára.",
|
||||
"removeSharedTitle": "Eltávolítod a listádból?",
|
||||
"removeSharedBody": "Csak a te megosztott listádból tűnik el — a tulajdonos fájlját nem törli."
|
||||
"removeSharedBody": "Csak a te megosztott listádból tűnik el — a tulajdonos fájlját nem törli.",
|
||||
"redownloadTitle": "Újratöltöd ezt a fájlt?",
|
||||
"redownloadBody": "A jelenlegi fájl törlődik és újra letöltődik a legfrissebb formátum- és elnevezési szabályokkal. A már elküldött megosztási linkek működnek tovább, és az új fájlra fognak mutatni (sikertelen újratöltés esetén a link törötten marad)."
|
||||
},
|
||||
"profiles": {
|
||||
"manage": "Formátumok kezelése",
|
||||
|
||||
@@ -1673,6 +1673,10 @@ export const api = {
|
||||
req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }),
|
||||
cancelDownload: (id: number): Promise<DownloadJob> =>
|
||||
req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }),
|
||||
// Force a fresh re-download of a finished/failed job — keeps the job (and its share links),
|
||||
// deletes the old file, and rebuilds it under the current format/naming rules.
|
||||
redownloadDownload: (id: number): Promise<DownloadJob> =>
|
||||
req(`/api/downloads/${id}/redownload`, { method: "POST" }, { idempotent: true }),
|
||||
deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
|
||||
// Update a download's display metadata (title / channel name+link / extra reference URLs).
|
||||
updateDownloadMeta: (
|
||||
|
||||
Reference in New Issue
Block a user