fix(downloads): an upload's cancel, progress, failure and notice speak of an upload

- Cancelling an upload asks "Delete upload?" and says the uploaded file is
  removed from the server with it.
- The phone-safe conversion's real ffmpeg % shows as a bar ("Converting for
  phones"), not an indeterminate pulse.
- A failed upload is titled "The upload isn't ready", with its own reasons
  (convert_failed, retryable; upload_missing) instead of download copy.
- The inbox says "Upload ready to share" for a converted upload.
This commit is contained in:
2026-10-06 03:19:32 +02:00
parent dba4758c59
commit 6073a006b8
14 changed files with 157 additions and 21 deletions
@@ -124,4 +124,21 @@ describe("DownloadCenter confirm dialogs", () => {
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
]);
});
it("cancelling an upload says the uploaded file goes with it", async () => {
// Nothing to fetch again: the cancel deletes the original from the server.
mocks.jobs = [{ ...job(3, "queued"), job_kind: "upload" }];
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
</QueryClientProvider>,
);
await pick("Clip 3", "downloads.actions.cancel");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
const [opts] = mocks.confirm.mock.calls[0]!;
expect([opts.title, opts.confirmLabel]).toEqual([
"downloads.confirm.cancelUploadTitle",
"downloads.confirm.cancelUploadAction",
]);
});
});
@@ -364,3 +364,19 @@ describe("a download-lifecycle notice", () => {
expect(screen.queryByText("download")).toBeNull();
});
});
describe("NotificationsPanel · a finished job's title", () => {
it("names what finished: a download, a clip, an upload", () => {
h.server = [
server(1),
server(2, { data: { title: "Ready 2", kind: "edit" } }),
server(3, { data: { title: "Ready 3", kind: "upload" } }),
];
h.client = [];
mount();
const titleOf = (text: string) => rowOf(text).textContent ?? "";
expect(titleOf("Ready 1")).toContain("inbox.downloadDone.title");
expect(titleOf("Ready 2")).toContain("inbox.editDone.title");
expect(titleOf("Ready 3")).toContain("inbox.uploadDone.title");
});
});
@@ -446,6 +446,11 @@ const DOWNLOAD_LIFECYCLE: Record<string, string | undefined> = {
download_expired: "downloadExpired",
download_evicted: "downloadEvicted",
};
// A `download_done` notice's title by the producer's `kind`; any other kind reads as a download.
const DONE_TITLE: Record<string, string | undefined> = {
edit: "inbox.editDone.title",
upload: "inbox.uploadDone.title",
};
function serverKind(n: AppNotification): "scheduler" | "download" | null {
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
@@ -552,10 +557,11 @@ function NotificationRow({
title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" });
body = d.title || null;
} else if (isDownloadDone) {
// "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body
// (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled.
// "Download ready" / "Edit ready" / "Upload ready" per the producer's kind, with the video's title
// as the body (shipped in data.title). Same notification type; the copy differs so an edit or an
// upload isn't called a download.
const d = n.data as { title?: string | null; kind?: string };
title = t(d.kind === "edit" ? "inbox.editDone.title" : "inbox.downloadDone.title");
title = t(DONE_TITLE[d.kind ?? ""] ?? "inbox.downloadDone.title");
body = d.title || n.body;
}
return (
+3 -3
View File
@@ -7,7 +7,7 @@ import Modal, { ModalFooter } from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { Button } from "./ui/Button";
import { IconBtn } from "./downloadRow";
import { failCopyKeys } from "../lib/downloadFailure";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import { Input } from "./ui/Input";
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
@@ -112,7 +112,7 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
// retry control regardless. `unknown` is deliberately NOT here: an unclassified failure is most often an
// auth wall we couldn't parse (a login-walled Facebook post reads as a bare "no format"), so a
// prominent Retry would mislead; force-retrying from the row stays available for genuine transients.
const RETRYABLE_ERRORS = ["postprocess", "network"];
const RETRYABLE_ERRORS = ["postprocess", "network", "convert_failed"];
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
@@ -130,7 +130,7 @@ export function ErrorModal({
const [showRaw, setShowRaw] = useState(false);
const retryable = RETRYABLE_ERRORS.includes(code);
return (
<Modal initialFocus="close" title={t("downloads.errors.fail.title")} onClose={onClose}>
<Modal initialFocus="close" title={t(failTitleKey(job))} onClose={onClose}>
<div className="space-y-3">
<div className="font-medium">{t(failCopyKeys(job, "short"))}</div>
<p className="text-sm text-muted">{t(failCopyKeys(job, "body"))}</p>
@@ -292,3 +292,27 @@ describe("DownloadRow · no picture", () => {
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
});
});
// The phone-safe conversion reports ffmpeg's real progress, so it gets the % bar a download has;
// a yt-dlp post-step reports none and keeps the pulse.
describe("DownloadRow · a running conversion", () => {
const running = (phase: string): DownloadJob => ({
...baseJob,
status: "running",
progress: 40,
phase,
});
it("shows its phase and its %", () => {
const { container } = render(<DownloadRow job={running("converting")} />);
expect(screen.getByText("downloads.phase.converting")).toBeTruthy();
expect(screen.getByText("40%")).toBeTruthy();
expect(container.querySelector(".animate-pulse")).toBeNull();
});
it("a post-step with no % keeps the pulse", () => {
const { container } = render(<DownloadRow job={running("merging")} />);
expect(screen.queryByText("40%")).toBeNull();
expect(container.querySelector(".animate-pulse")).not.toBeNull();
});
});
+6 -4
View File
@@ -14,7 +14,7 @@ import {
relativeTime,
} from "../lib/format";
import { navItemAttrs } from "../lib/listNav";
import { failCopyKeys } from "../lib/downloadFailure";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
@@ -34,12 +34,14 @@ const STATUS_CLS: Record<string, string> = {
};
// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse).
// "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar.
const DOWNLOAD_PHASES = ["video", "audio", "editing"];
// "editing" (the phase-2 editor) and "converting" (the phone-safe conversion of a download or an
// upload) report a real % from ffmpeg's out_time, so they get a bar.
const DOWNLOAD_PHASES = ["video", "audio", "editing", "converting"];
const PHASE_KEYS = [
"video",
"audio",
"editing",
"converting",
"merging",
"audio_extract",
"thumbnail",
@@ -313,7 +315,7 @@ export function DownloadRow({
<Pressable
onClick={() => onShowError?.(job)}
className="text-danger inline-block leading-6 min-h-6 touch-target pointer-coarse:leading-(--touch-target)"
title={t("downloads.errors.fail.title")}
title={t(failTitleKey(job))}
>
· {t(failCopyKeys(job, "short"))}{" "}
<span className="text-muted underline">
+20 -7
View File
@@ -35,6 +35,18 @@ import { menuGroups } from "./ui/menuGroups";
export type DownloadTab = "queue" | "done" | "shared";
export type JobOp = "pause" | "resume" | "cancel" | "delete" | "redownload";
// The Cancel confirmation of a queued job; an upload's names what it deletes.
const CANCEL_DOWNLOAD = {
title: "downloads.confirm.cancelTitle",
body: "downloads.confirm.cancelBody",
action: "downloads.confirm.cancelAction",
};
const CANCEL_UPLOAD = {
title: "downloads.confirm.cancelUploadTitle",
body: "downloads.confirm.cancelUploadBody",
action: "downloads.confirm.cancelUploadAction",
};
export function useDownloadActions({
onPlay,
onEdit,
@@ -147,13 +159,14 @@ export function useDownloadActions({
label: t("downloads.actions.cancel"),
icon: icon(Ban),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.cancelTitle"),
t("downloads.confirm.cancelBody"),
t("downloads.confirm.cancelAction"),
() => act.mutate({ id: job.id, op: "cancel" }),
),
// Cancelling an upload deletes the file it uploaded (there is nothing to fetch again), so
// its confirmation says so — "stop the download" would read as safe to undo.
onSelect: () => {
const copy = job.job_kind === "upload" ? CANCEL_UPLOAD : CANCEL_DOWNLOAD;
void confirmThen(t(copy.title), t(copy.body), t(copy.action), () =>
act.mutate({ id: job.id, op: "cancel" }),
);
},
},
],
);
+14 -1
View File
@@ -63,7 +63,8 @@
"sponsorblock": "Removing sponsors",
"metadata": "Writing metadata",
"processing": "Processing",
"editing": "Editing"
"editing": "Editing",
"converting": "Converting for phones"
},
"actions": {
"pause": "Pause",
@@ -151,6 +152,9 @@
"cancelTitle": "Cancel download?",
"cancelBody": "This stops the download and removes it from your queue.",
"cancelAction": "Stop download",
"cancelUploadTitle": "Delete upload?",
"cancelUploadBody": "The file you uploaded is removed from the server along with it.",
"cancelUploadAction": "Delete upload",
"deleteTitle": "Delete download?",
"deleteBody": "This deletes it from your downloads. The file may stay cached for other users until it expires.",
"removeSharedTitle": "Remove from your list?",
@@ -226,6 +230,7 @@
},
"fail": {
"title": "Couldn't download",
"titleUpload": "The upload isn't ready",
"detailsLink": "details",
"detailsToggle": "Technical details",
"cat": {
@@ -257,6 +262,14 @@
"short": "Couldn't process the video",
"body": "The video downloaded but a processing step (merge or convert) failed. A retry often fixes this."
},
"convert_failed": {
"short": "Couldn't convert the upload",
"body": "Your file couldn't be converted into the format every phone plays. It may be damaged, or in a format the server can't read. The technical details below may help."
},
"upload_missing": {
"short": "Uploaded file missing",
"body": "The file you uploaded is no longer on the server. Upload it again."
},
"source_missing": {
"short": "Source unavailable",
"body": "The original download this clip is based on is no longer available."
+3
View File
@@ -22,6 +22,9 @@
"editDone": {
"title": "Edit ready"
},
"uploadDone": {
"title": "Upload ready to share"
},
"maintenance": {
"title": "Videos removed",
"body_one": "{{count}} saved or playlisted video was removed because it's no longer available on YouTube.",
+14 -1
View File
@@ -63,7 +63,8 @@
"sponsorblock": "Szponzorok eltávolítása",
"metadata": "Metaadat írása",
"processing": "Feldolgozás",
"editing": "Szerkesztés"
"editing": "Szerkesztés",
"converting": "Átalakítás telefonra"
},
"actions": {
"pause": "Szünet",
@@ -151,6 +152,9 @@
"cancelTitle": "Megszakítod a letöltést?",
"cancelBody": "Ez leállítja a letöltést és eltávolítja a sorból.",
"cancelAction": "Letöltés leállítása",
"cancelUploadTitle": "Törlöd a feltöltést?",
"cancelUploadBody": "A feltöltött fájl is törlődik a szerverről.",
"cancelUploadAction": "Feltöltés törlése",
"deleteTitle": "Törlöd a letöltést?",
"deleteBody": "Ez törli a letöltéseid közü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?",
@@ -226,6 +230,7 @@
},
"fail": {
"title": "Nem sikerült letölteni",
"titleUpload": "A feltöltés nem készült el",
"detailsLink": "részletek",
"detailsToggle": "Technikai részletek",
"cat": {
@@ -257,6 +262,14 @@
"short": "Nem sikerült feldolgozni a videót",
"body": "A videó letöltődött, de egy feldolgozási lépés (összefűzés vagy konvertálás) meghiúsult. Újrapróbálásra gyakran megjavul."
},
"convert_failed": {
"short": "Nem sikerült átalakítani",
"body": "A fájlt nem sikerült olyan formátumra alakítani, amit minden telefon lejátszik. Lehet, hogy sérült, vagy a szerver nem ismeri a formátumát. Az alábbi technikai részletek segíthetnek."
},
"upload_missing": {
"short": "Hiányzik a feltöltött fájl",
"body": "A feltöltött fájl már nincs a szerveren. Töltsd fel újra."
},
"source_missing": {
"short": "A forrás nem elérhető",
"body": "Az eredeti letöltés, amelyből ez a vágás készült, már nem érhető el."
+3
View File
@@ -22,6 +22,9 @@
"editDone": {
"title": "Vágás kész"
},
"uploadDone": {
"title": "A feltöltés megosztható"
},
"maintenance": {
"title": "Videók eltávolítva",
"body_one": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhető a YouTube-on.",
+1 -1
View File
@@ -89,7 +89,7 @@ export interface DownloadJob {
display_uploader: string | null; // user override of the channel name
display_uploader_url: string | null; // user override of the channel link
extra_links: string[]; // extra reference URLs (beyond source_url)
job_kind?: DownloadJobKind; // "download" (default) | "edit" (a per-user trim/crop derivative)
job_kind?: DownloadJobKind; // see DownloadJobKind; absent reads as "download"
source_job_id?: number | null; // parent download an edit was cut from
edit_spec?: EditSpec | null;
can_download: boolean;
+20 -1
View File
@@ -6,7 +6,7 @@
// must still say the category, not print a key.
import { describe, expect, it } from "vitest";
import i18n from "../i18n";
import { failCopyKeys } from "./downloadFailure";
import { failCopyKeys, failTitleKey } from "./downloadFailure";
describe("failCopyKeys", () => {
it("puts a catalog entry before its category, and is the category alone without one", () => {
@@ -47,3 +47,22 @@ describe("failCopyKeys", () => {
);
});
});
describe("failTitleKey", () => {
it("titles an upload's failure as a conversion, anything else as a download", async () => {
await i18n.changeLanguage("en");
// the locale has the copy (a missing key would come back as the key itself)
expect(i18n.t(failTitleKey({ job_kind: "upload" }))).not.toMatch(/^downloads\./);
expect(failTitleKey({ job_kind: "upload" })).toBe("downloads.errors.fail.titleUpload");
expect(failTitleKey({ job_kind: "edit" })).toBe("downloads.errors.fail.title");
expect(failTitleKey({})).toBe("downloads.errors.fail.title");
});
it("an upload's own failures read as an upload's", async () => {
await i18n.changeLanguage("en");
for (const code of ["convert_failed", "upload_missing"]) {
const short = i18n.t(failCopyKeys({ error_code: code, error_detail: null }, "short"));
expect(short).not.toBe(i18n.t("downloads.errors.fail.cat.unknown.short"));
}
});
});
+7
View File
@@ -18,3 +18,10 @@ export function failCopyKeys(
...(code === "unknown" ? [] : [category("unknown")]),
];
}
/** The title over a failed job's explanation: an upload's failure is a conversion, not a download. */
export function failTitleKey(job: Pick<DownloadJob, "job_kind">): string {
return job.job_kind === "upload"
? "downloads.errors.fail.titleUpload"
: "downloads.errors.fail.title";
}