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"
+23 -2
View File
@@ -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(
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4
View File
@@ -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: (