fix(downloads): E11.5 epic-end review round 1

A refused Facebook cookie on a group post is no longer "no video in this post":
measured on dev, Facebook answers with its login page (no post id at all), so the
adapter now raises its own POST_NOT_SHOWN verdict and the catalog explains it as
the refused cookie. The adapter owns both post verdicts and the "cannot parse
data" wall wording, so the Chrome retry and the catalog read one constant. The
group-post "no formats" entry is cookie-scoped (with a cookie yt-dlp only gets the
post's video link) and uses the adapter's own group-post test instead of a
"/groups/" substring.

Job transitions that move an asset's ref count (resume, cancel, delete,
redownload) re-read the job under a row lock, asset first, so two overlapping
requests can no longer release the same hold. get_or_create_asset loses an insert
race inside a SAVEPOINT instead of rolling back the caller's pending writes (Retry
lost its cleared error). Every worker-side job failure goes through
finalize_if_running and writes error_detail, so a cancel that landed first is kept
and a failure never shows an earlier one's copy. The worker asks the download
child's own cookie gate (credentials.usable_cookie) when a job inherits a failed
asset.

UI: the Facebook copy no longer points at a Retry button the failure window does
not have, says "paste" like Settings, and the HU wording is fixed
("Újraletöltés" on the re-download confirm). A category this client has no copy
for falls back to the generic sentence instead of a raw key.

Tests: route serialization of error_detail, the probed duration through a real
_download, the page-read cap and a failed read, a Retry off a pending asset, the
confirm-button labels, the lock and savepoint races.
This commit is contained in:
2026-09-14 00:50:05 +02:00
parent b17d92d7c2
commit e887233210
20 changed files with 610 additions and 112 deletions
@@ -0,0 +1,103 @@
// @vitest-environment jsdom
//
// The Download Center's confirm dialogs name their ACTION on the confirm button (E11.5): the shared
// `confirmThen` once labelled it with the dialog's question — "Re-download this file?" on the button,
// and the cancel dialog's own "Cancel" beside "Cancel". i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DownloadJob } from "../lib/api";
const mocks = vi.hoisted(() => ({
jobs: [] as unknown[],
confirm: vi.fn(async (_opts: { title: string; confirmLabel?: string }) => false),
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: (key: readonly unknown[]) => ({
data: key[0] === "downloads" ? mocks.jobs : undefined,
isError: false,
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) }));
// The page toolbar (title + tabs) renders into the app shell's slot, which a bare render has none of.
vi.mock("./ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import DownloadCenter from "./DownloadCenter";
const job = (id: number, status: DownloadJob["status"]): DownloadJob => ({
id,
status,
progress: 0,
speed_bps: null,
eta_s: null,
phase: null,
error: null,
error_code: null,
error_detail: null,
queue_pos: 0,
created_at: null,
source_kind: "youtube",
source_ref: `v${id}`,
source_url: null,
poster_url: null,
profile_id: null,
spec: {
mode: "av",
max_height: null,
container: null,
audio_format: null,
vcodec: null,
embed_subs: false,
embed_chapters: false,
embed_thumbnail: false,
sponsorblock: false,
},
display_name: `Clip ${id}`,
display_uploader: null,
display_uploader_url: null,
extra_links: [],
can_download: status === "done",
expired: false,
asset: null,
});
afterEach(() => {
cleanup();
mocks.confirm.mockClear();
});
describe("DownloadCenter confirm dialogs", () => {
it("label the confirm button with the action, not the dialog's question", async () => {
mocks.jobs = [job(1, "running"), job(2, "done")];
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
</QueryClientProvider>,
);
fireEvent.click(screen.getByTitle("downloads.actions.cancel"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
// The tab's label shares its button with a count badge.
fireEvent.click(screen.getByRole("button", { name: /downloads\.tabs\.done/ }));
fireEvent.click(await screen.findByTitle("downloads.actions.redownload"));
fireEvent.click(screen.getByTitle("downloads.actions.delete"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(3));
expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([
["downloads.confirm.cancelTitle", "downloads.confirm.cancelAction"],
["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"],
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
]);
});
});
+3 -2
View File
@@ -119,8 +119,9 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
}
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/
// sign-in/unsupported-host/…), where a retry can't help — the queue row still keeps its own retry
// control regardless. `unknown` is deliberately NOT here: an unclassified failure is most often an
// unsupported-host/…), where a retry can't help, or need a fix outside this window first (sign-in: a
// cookie in Settings — its copy then sends the user to the row) — the queue row still keeps its own
// 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"];
+2 -2
View File
@@ -294,11 +294,11 @@
"facebook": {
"login_wall": {
"short": "Facebook sign-in required",
"body": "Facebook only shows this to signed-in users (e.g. 18+ content or a closed group). Add your Facebook cookies under Settings → Account → Download authentication, then press Retry."
"body": "Facebook only shows this to signed-in users (e.g. 18+ content or a closed group). Add your Facebook cookies under Settings → Account → Download authentication, then retry the download from the list."
},
"cookie_rejected": {
"short": "Facebook didn't accept the cookies",
"body": "The cookies have probably expired, or you signed out in that browser. Export them again, upload them, and retry. If it still fails, the content has been removed or isn't visible to you."
"body": "The cookies have probably expired, or you signed out in that browser. Export them again, paste them in under Settings → Account → Download authentication, then retry the download from the list. If it still fails, the content has been removed or isn't visible to you."
},
"group_post_video": {
"short": "No video found in this post",
+7 -7
View File
@@ -157,9 +157,9 @@
"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.",
"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).",
"redownloadAction": "Újratöltés"
"redownloadTitle": "Újraletö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 újraletöltés esetén a link törötten marad).",
"redownloadAction": "Újraletöltés"
},
"profiles": {
"manage": "Formátumok kezelése",
@@ -251,7 +251,7 @@
},
"login_required": {
"short": "Bejelentkezés szükséges",
"body": "Ehhez a videóhoz bejelentkezett fiók kell (korhatáros, csak tagoknak szóló vagy zárt csoport). Töltsd fel a cookie-kat ehhez az oldalhoz a Beállítások → Fiók → Letöltés-hitelesítés alatt a letöltéshez."
"body": "Ehhez a videóhoz bejelentkezett fiók kell (korhatáros, csak tagoknak szóló vagy zárt csoport). Illeszd be a cookie-kat ehhez az oldalhoz a Beállítások → Fiók → Letöltés-hitelesítés alatt a letöltéshez."
},
"unsupported_host": {
"short": "Nem támogatott oldal",
@@ -294,11 +294,11 @@
"facebook": {
"login_wall": {
"short": "Facebook-bejelentkezés szükséges",
"body": "A Facebook ezt csak bejelentkezve mutatja (pl. 18+ tartalom vagy zárt csoport). Add meg a Facebook-cookie-kat a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd nyomd meg az Újra gombot."
"body": "A Facebook ezt csak bejelentkezve mutatja (pl. 18+ tartalom vagy zárt csoport). Illeszd be a Facebook-cookie-kat a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd a listában indítsd újra a letöltést."
},
"cookie_rejected": {
"short": "A Facebook nem fogadta el a cookie-t",
"body": "A cookie valószínűleg lejárt, vagy kijelentkeztél abban a böngészőben. Exportáld újra, töltsd fel, és próbáld újra. Ha így sem megy, a tartalom törölt, vagy számodra nem látható."
"short": "A Facebook nem fogadta el a cookie-kat",
"body": "A cookie-k valószínűleg lejártak, vagy kijelentkeztél abban a böngészőben. Exportáld újra őket, illeszd be a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd a listában indítsd újra a letöltést. Ha így sem megy, a tartalmat törölték, vagy nem látható számodra."
},
"group_post_video": {
"short": "Ebben a posztban nem található videó",
+6
View File
@@ -15,9 +15,11 @@ describe("failCopyKeys", () => {
).toEqual([
"downloads.errors.fail.catalog.facebook.login_wall.body",
"downloads.errors.fail.cat.login_required.body",
"downloads.errors.fail.cat.unknown.body",
]);
expect(failCopyKeys({ error_code: "network", error_detail: null }, "short")).toEqual([
"downloads.errors.fail.cat.network.short",
"downloads.errors.fail.cat.unknown.short",
]);
expect(failCopyKeys({ error_code: null, error_detail: null }, "short")).toEqual([
"downloads.errors.fail.cat.unknown.short",
@@ -39,5 +41,9 @@ describe("failCopyKeys", () => {
failCopyKeys({ error_code: "login_required", error_detail: "facebook.newer" }, "short"),
),
).toBe("Sign-in required");
// A category this client has no copy for (a newer backend): the generic sentence, never a key.
expect(i18n.t(failCopyKeys({ error_code: "rate_limited", error_detail: null }, "short"))).toBe(
"Download failed",
);
});
});
+10 -7
View File
@@ -3,15 +3,18 @@ import type { DownloadJob } 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. One helper for
// the queue row's headline and the failure window, so the two can never explain the same job
// differently.
// 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.
export function failCopyKeys(
job: Pick<DownloadJob, "error_code" | "error_detail">,
part: "short" | "body",
): string[] {
const category = `downloads.errors.fail.cat.${job.error_code || "unknown"}.${part}`;
return job.error_detail
? [`downloads.errors.fail.catalog.${job.error_detail}.${part}`, category]
: [category];
const category = (code: string) => `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")]),
];
}