fix(downloads): a conversion's commit, a stopped upload, the fast join and a clip's copy hold up

- The fast join gives each ffconcat entry its own media-only format whitelist, so a library file
  can no longer be opened as a nested concat list (the list's whitelist was handed down to it).
- A file the guarded probe cannot read is a download-only file, not a guess from yt-dlp's info.
- A conversion's or an ingest's commit that raises is re-read in a fresh session before any file
  is removed: a commit that landed keeps the file its row now names.
- An original that cannot be removed after conversion is logged; the upload stays ready.
- An upload whose conversion is cancelled or deleted midway is freed once nothing holds it; a
  paused one is kept.
- A shared link says "preparing" only while its video's job is queued or running.
- The admin storage view counts every file on disk ("Files on disk"), not only ready ones.
- A clip's failure title, unknown failure and cancel dialog, and a clip's and an upload's delete
  dialog speak of what the job made; an upload's quota refusal explains that the original is kept.
- /watch announces "preparing" as an alert and moves focus to the new Retry after an action.
This commit is contained in:
2026-10-06 21:02:07 +02:00
parent d44d8d6dda
commit ed6facb703
19 changed files with 661 additions and 88 deletions
@@ -10,7 +10,9 @@ import type { DownloadJob } from "../lib/api";
const mocks = vi.hoisted(() => ({
jobs: [] as unknown[],
confirm: vi.fn(async (_opts: { title: string; confirmLabel?: string }) => false),
confirm: vi.fn(
async (_opts: { title: string; message?: string; confirmLabel?: string }) => false,
),
}));
vi.mock("react-i18next", async (importOriginal) => {
@@ -141,4 +143,41 @@ describe("DownloadCenter confirm dialogs", () => {
"downloads.confirm.cancelUploadAction",
]);
});
it("cancelling a clip speaks of the clip", async () => {
mocks.jobs = [{ ...job(4, "running"), job_kind: "edit" }];
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
</QueryClientProvider>,
);
await pick("Clip 4", "downloads.actions.cancel");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
const [opts] = mocks.confirm.mock.calls[0]!;
expect([opts.title, opts.message, opts.confirmLabel]).toEqual([
"downloads.confirm.cancelEditTitle",
"downloads.confirm.cancelEditBody",
"downloads.confirm.cancelEditAction",
]);
});
it.each([
["upload", "downloads.confirm.deleteUploadTitle", "downloads.confirm.deleteUploadBody"],
["edit", "downloads.confirm.deleteEditTitle", "downloads.confirm.deleteEditBody"],
["download", "downloads.confirm.deleteTitle", "downloads.confirm.deleteBody"],
] as const)("deleting a finished %s says what happens to its file", async (kind, title, body) => {
// Only a download's file may stay cached for other users; an upload's or a clip's is theirs alone.
mocks.jobs = [{ ...job(5, "done"), job_kind: kind }];
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ }));
await screen.findByText("Clip 5");
await pick("Clip 5", "downloads.actions.delete");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
const [opts] = mocks.confirm.mock.calls[0]!;
expect([opts.title, opts.message]).toEqual([title, body]);
});
});
+21 -1
View File
@@ -224,13 +224,33 @@ describe("WatchPage honest failures", () => {
);
const page = render(<WatchPage />);
const retry = await page.findByRole("button", { name: "Try again" });
expect(page.getByRole("status").textContent).toContain("This video is being prepared.");
expect(page.getByRole("alert").textContent).toContain("This video is being prepared.");
expect(page.queryByText(GONE)).toBeNull();
fireEvent.click(retry);
await waitFor(() => expect(page.container.querySelector("video")).not.toBeNull());
});
it("a Retry that is answered 'still preparing' says so and keeps the focus on Retry", async () => {
// The answer replaces the button the viewer was on: announced (an alert, inserted filled), and
// the new Retry takes the focus instead of leaving it on <body>.
window.history.replaceState({}, "", "/watch/tok-preparing");
const preparing = () => res(404, { detail: { code: 404, reason: "video_preparing" } });
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(() => Promise.resolve(preparing())),
);
const page = render(<WatchPage />);
const first = await page.findByRole("button", { name: "Try again" });
expect(document.activeElement).not.toBe(first); // the first load moves no focus
fireEvent.click(first);
await waitFor(() =>
expect(document.activeElement).toBe(page.getByRole("button", { name: "Try again" })),
);
expect(page.getByRole("alert").textContent).toContain("This video is being prepared.");
});
async function tryPassword(second: () => Promise<unknown>) {
vi.stubGlobal(
"fetch",
+24 -4
View File
@@ -114,6 +114,10 @@ export default function WatchPage() {
// Bumped by Retry, which asks again.
const [attempt, setAttempt] = useState(0);
// Set once the viewer acted (Retry, the password): the state that answers replaces the button they
// were on, so its own Retry takes the focus rather than leaving it on <body>. Not on the first load,
// where nothing had the focus to lose.
const [acted, setActed] = useState(false);
useEffect(() => {
if (!token) return; // the seed above already says "gone"
send(`/api/public/watch/${encodeURIComponent(token)}`)
@@ -136,6 +140,7 @@ export default function WatchPage() {
.catch(() => setStatus("error"));
}, [token, attempt]);
const retry = () => {
setActed(true);
setStatus("loading");
setAttempt((n) => n + 1);
};
@@ -167,6 +172,7 @@ export default function WatchPage() {
};
const unlock = async () => {
setActed(true);
setUnlocking(true);
setError(null);
try {
@@ -203,11 +209,19 @@ export default function WatchPage() {
{status === "preparing" && (
<div className="flex-1 grid place-items-center text-center">
{/* Announced like the failure below: it replaces the loading line. */}
<div role="status">
{/* An alert, like the failure below: it is inserted already filled, which a polite status
region is not reliably announced for — and a Retry that answers "still preparing"
would otherwise say nothing at all. */}
<div role="alert">
<div className="text-lg font-semibold">{T.preparing}</div>
<div className="text-sm text-muted mt-1">{T.preparingHint}</div>
<Button variant="primary" size="md" onClick={retry} className="mt-4">
<Button
variant="primary"
size="md"
onClick={retry}
autoFocus={acted}
className="mt-4"
>
{T.retry}
</Button>
</div>
@@ -220,7 +234,13 @@ export default function WatchPage() {
<div role="alert">
<div className="text-lg font-semibold">{T.failed}</div>
<div className="text-sm text-muted mt-1">{T.failedHint}</div>
<Button variant="primary" size="md" onClick={retry} className="mt-4">
<Button
variant="primary"
size="md"
onClick={retry}
autoFocus={acted}
className="mt-4"
>
{T.retry}
</Button>
</div>
+1 -1
View File
@@ -186,7 +186,7 @@ export function AdminSystem() {
{s && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
{[
[t("downloads.admin.readyFiles"), String(s.ready_files)],
[t("downloads.admin.filesOnDisk"), String(s.files_on_disk)],
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes, lang)],
[
t("downloads.admin.cap"),
+31 -11
View File
@@ -16,7 +16,7 @@ import {
Trash2,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type DownloadJob } from "../lib/api";
import { api, type DownloadJob, type DownloadJobKind } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
@@ -46,6 +46,29 @@ const CANCEL_UPLOAD = {
body: "downloads.confirm.cancelUploadBody",
action: "downloads.confirm.cancelUploadAction",
};
const CANCEL_EDIT = {
title: "downloads.confirm.cancelEditTitle",
body: "downloads.confirm.cancelEditBody",
action: "downloads.confirm.cancelEditAction",
};
// 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. A clip is being cut, not
// downloaded.
const CANCEL_COPY: Record<DownloadJobKind, typeof CANCEL_DOWNLOAD> = {
download: CANCEL_DOWNLOAD,
upload: CANCEL_UPLOAD,
edit: CANCEL_EDIT,
};
// Deleting a download may leave its shared cache file for other holders. An upload and a clip are
// their owner's alone (a clip's asset is keyed by its user), so deleting one removes the file for good.
const DELETE_COPY: Record<DownloadJobKind, { title: string; body: string }> = {
download: { title: "downloads.confirm.deleteTitle", body: "downloads.confirm.deleteBody" },
upload: {
title: "downloads.confirm.deleteUploadTitle",
body: "downloads.confirm.deleteUploadBody",
},
edit: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
};
export function useDownloadActions({
onPlay,
@@ -159,10 +182,8 @@ export function useDownloadActions({
label: t("downloads.actions.cancel"),
icon: icon(Ban),
danger: true,
// 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;
const copy = CANCEL_COPY[job.job_kind ?? "download"];
void confirmThen(t(copy.title), t(copy.body), t(copy.action), () =>
act.mutate({ id: job.id, op: "cancel" }),
);
@@ -233,13 +254,12 @@ export function useDownloadActions({
label: t("downloads.actions.delete"),
icon: icon(Trash2),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.deleteTitle"),
t("downloads.confirm.deleteBody"),
t("downloads.actions.delete"),
() => act.mutate({ id: job.id, op: "delete" }),
),
onSelect: () => {
const copy = DELETE_COPY[job.job_kind ?? "download"];
void confirmThen(t(copy.title), t(copy.body), t("downloads.actions.delete"), () =>
act.mutate({ id: job.id, op: "delete" }),
);
},
},
],
);