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
+2 -2
View File
@@ -47,7 +47,7 @@ export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error"
// "download" (default), "edit" (a per-user trim/crop derivative cut from another job), or "upload"
// (a locally-uploaded file — no re-fetchable source, so re-download is rejected backend-side).
// Local (not exported): consumers compare `job.job_kind` against literals, never name the type.
type DownloadJobKind = "download" | "edit" | "upload";
export type DownloadJobKind = "download" | "edit" | "upload";
// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into
// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
@@ -137,7 +137,7 @@ export interface DownloadUsage {
}
export interface AdminStorage {
ready_files: number;
files_on_disk: number;
total_bytes: number;
total_assets: number;
total_cap_bytes: number;
+52 -4
View File
@@ -49,13 +49,61 @@ describe("failCopyKeys", () => {
});
describe("failTitleKey", () => {
it("titles an upload's failure as a conversion, anything else as a download", async () => {
it("titles a failure by what the job was making", 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({ job_kind: "edit" })).toBe("downloads.errors.fail.titleEdit");
expect(failTitleKey({ job_kind: "download" })).toBe("downloads.errors.fail.title");
expect(failTitleKey({})).toBe("downloads.errors.fail.title");
for (const lng of ["en", "hu"]) {
await i18n.changeLanguage(lng);
// the locale has the copy (a missing key would come back as the key itself)
for (const job_kind of ["upload", "edit"] as const)
expect(i18n.t(failTitleKey({ job_kind }))).not.toMatch(/^downloads\./);
}
});
it("an upload's quota refusal is worded as its conversion's, in both languages", async () => {
// The converted file outgrew the cap; the original stays. Not "this download was stopped because
// your quota is already used up" — the download sentence stays for a download.
const upload = {
error_code: "quota_exceeded",
error_detail: null,
job_kind: "upload",
} as const;
const download = { ...upload, job_kind: "download" } as const;
for (const lng of ["en", "hu"]) {
await i18n.changeLanguage(lng);
const body = i18n.t(failCopyKeys(upload, "body"));
expect(body).not.toMatch(/^downloads\./);
expect(body).not.toBe(i18n.t(failCopyKeys(download, "body")));
expect(i18n.t(failCopyKeys(download, "body"))).toBe(
i18n.t("downloads.errors.fail.cat.quota_exceeded.body"),
);
}
});
it("a clip that fails for no known reason says so of a clip, in both languages", async () => {
// Not "Couldn't download / Something went wrong with this download" — nothing was downloaded.
const clip = { error_code: "unknown", error_detail: null, job_kind: "edit" } as const;
const download = { ...clip, job_kind: "download" } as const;
for (const lng of ["en", "hu"]) {
await i18n.changeLanguage(lng);
for (const part of ["short", "body"] as const) {
const own = i18n.t(failCopyKeys(clip, part));
expect(own).not.toMatch(/^downloads\./);
expect(own).not.toBe(i18n.t(failCopyKeys(download, part)));
}
}
// a category with no copy of its own falls back to the clip's unknown sentence, not the download's
expect(
failCopyKeys({ error_code: "brand_new", error_detail: null, job_kind: "edit" }, "body"),
).toEqual([
"downloads.errors.fail.edit.brand_new.body",
"downloads.errors.fail.cat.brand_new.body",
"downloads.errors.fail.edit.unknown.body",
"downloads.errors.fail.cat.unknown.body",
]);
});
it("an upload's own failures read as an upload's", async () => {
+22 -11
View File
@@ -1,27 +1,38 @@
import type { DownloadJob } from "./api";
import type { DownloadJob, DownloadJobKind } from "./api";
// The i18n keys that explain a failed download, most specific first — the list form `t()` takes, so
// the first key a locale has wins. A failure-catalog entry (`error_detail`, e.g. `facebook.login_wall`)
// names what went wrong on that site and what to do; the category (`error_code`) is the generic
// sentence, and the fallback when an older client meets a detail it has no copy for; a category it has
// no copy for falls back to the unknown one, so the row never prints a key. One helper for the queue
// row's headline and the failure window, so the two can never explain the same job differently.
// no copy for falls back to the unknown one, so the row never prints a key. A job that is not a
// download (an upload's conversion, a clip) may word a category its own way, ahead of the shared
// sentence — "this download was stopped" is not what happened to it — and its own unknown sentence
// comes before the download's. One helper for the queue row's headline and the failure window, so
// the two can never explain the same job differently.
export function failCopyKeys(
job: Pick<DownloadJob, "error_code" | "error_detail">,
job: Pick<DownloadJob, "error_code" | "error_detail" | "job_kind">,
part: "short" | "body",
): string[] {
const category = (code: string) => `downloads.errors.fail.cat.${code}.${part}`;
const kind = job.job_kind ?? "download";
const category = (code: string) => [
...(kind === "download" ? [] : [`downloads.errors.fail.${kind}.${code}.${part}`]),
`downloads.errors.fail.cat.${code}.${part}`,
];
const code = job.error_code || "unknown";
return [
...(job.error_detail ? [`downloads.errors.fail.catalog.${job.error_detail}.${part}`] : []),
category(code),
...(code === "unknown" ? [] : [category("unknown")]),
...category(code),
...(code === "unknown" ? [] : category("unknown")),
];
}
/** The title over a failed job's explanation: an upload's failure is a conversion, not a download. */
const TITLE_KEYS: Record<DownloadJobKind, string> = {
download: "downloads.errors.fail.title",
upload: "downloads.errors.fail.titleUpload", // its failure is a conversion, not a download
edit: "downloads.errors.fail.titleEdit", // a clip being cut
};
/** The title over a failed job's explanation, worded for what the job was making. */
export function failTitleKey(job: Pick<DownloadJob, "job_kind">): string {
return job.job_kind === "upload"
? "downloads.errors.fail.titleUpload"
: "downloads.errors.fail.title";
return TITLE_KEYS[job.job_kind ?? "download"];
}