feat(downloads): a per-site failure catalog — Facebook's walls say why and what to do
A failed Facebook download read "Download failed": yt-dlp words the login wall (18+ reel, closed group) as "Cannot parse data", which no category matched. Measured on dev with the owner's cookie before writing a rule — the same text is the login wall without a cookie and a refused cookie with one. - errors.diagnose(msg, url, cookie_attached) tries a host-scoped catalog first (all-of markers, cookie state, link fragment) and falls back to the category. First entries: facebook.login_wall, facebook.cookie_rejected, facebook.group_post_video. The module doc is the recipe for the next entry. - download_jobs.error_detail (0084) + backfill of the stored Facebook parse failures that were unknown. - The pipeline's failure branch passes the link and whether the cookie went with it; resume and redownload clear code and detail (redownload never cleared error_code). - A job meeting an already-failed asset got the text but no code, so it read "Download failed" where the first job said why. It is diagnosed again, with the cookie state from credentials.is_authed_asset (now shared with the cookie gate); the redacted network text classifies back to network. - FE: failCopyKeys gives the row and the failure window one lookup — catalog copy, else the category. Copy in EN and HU; a contract test pins every backend entry to both locales.
This commit is contained in:
@@ -24,6 +24,7 @@ const baseJob: DownloadJob = {
|
||||
phase: null,
|
||||
error: null,
|
||||
error_code: null,
|
||||
error_detail: null,
|
||||
queue_pos: 0,
|
||||
created_at: null,
|
||||
source_kind: "youtube",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { api, type DownloadJob } from "../lib/api";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconBtn } from "./downloadRow";
|
||||
import { failCopyKeys } from "../lib/downloadFailure";
|
||||
|
||||
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
|
||||
// editing and the failure-detail window.
|
||||
@@ -142,8 +143,8 @@ export function ErrorModal({
|
||||
return (
|
||||
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<div className="font-medium">{t(`downloads.errors.fail.cat.${code}.short`)}</div>
|
||||
<p className="text-sm text-muted">{t(`downloads.errors.fail.cat.${code}.body`)}</p>
|
||||
<div className="font-medium">{t(failCopyKeys(job, "short"))}</div>
|
||||
<p className="text-sm text-muted">{t(failCopyKeys(job, "body"))}</p>
|
||||
{job.error && (
|
||||
<div>
|
||||
<button
|
||||
|
||||
@@ -24,6 +24,7 @@ const baseJob: DownloadJob = {
|
||||
phase: null,
|
||||
error: null,
|
||||
error_code: null,
|
||||
error_detail: null,
|
||||
queue_pos: 0,
|
||||
created_at: null,
|
||||
source_kind: "youtube",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { notify } from "../lib/notifications";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { failCopyKeys } from "../lib/downloadFailure";
|
||||
|
||||
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
|
||||
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
|
||||
@@ -260,7 +261,7 @@ export function DownloadRow({
|
||||
className="text-danger hover:opacity-80 inline-flex items-center gap-1 truncate"
|
||||
title={t("downloads.errors.fail.title")}
|
||||
>
|
||||
· {t(`downloads.errors.fail.cat.${job.error_code || "unknown"}.short`)}
|
||||
· {t(failCopyKeys(job, "short"))}
|
||||
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@@ -67,6 +67,13 @@ const adminRefusals = [
|
||||
...backend("routes/admin.py").matchAll(/\{\s*"code":\s*"(\w+)",\s*"reason":\s*"(\w+)"/g),
|
||||
].filter((m) => adminCodes.includes(m[1]!));
|
||||
const adminReasons = [...new Set(adminRefusals.map((m) => m[2]!))];
|
||||
// The download failure catalog's host-specific entries → `downloads.errors.fail.catalog.<detail>`.
|
||||
// A code holds a dot (`facebook.login_wall`), so the reverse check strips the prefix and the part
|
||||
// rather than splitting on dots.
|
||||
const failDetails = [
|
||||
...new Set([...backend("downloads/errors.py").matchAll(/detail="([\w.]+)"/g)].map((m) => m[1]!)),
|
||||
];
|
||||
const DETAIL = "downloads.errors.fail.catalog.";
|
||||
|
||||
/** The ids under `prefix` a locale names, `prefix.<id>` or `prefix.<id>.<anything>`. */
|
||||
const idsUnder = (keys: Set<string>, prefix: string) =>
|
||||
@@ -83,6 +90,7 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
|
||||
expect(quotaActions.length).toBeGreaterThan(0);
|
||||
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "setpw"]));
|
||||
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
||||
// Every code the frontend localizes is raised somewhere the scan can see.
|
||||
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
|
||||
});
|
||||
@@ -117,6 +125,18 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect(quotaActions.filter((a) => !idsUnder(keys, "quotaActions").has(a))).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${lang}: every download failure-catalog entry`, () => {
|
||||
expect(
|
||||
missing(failDetails.flatMap((d) => [`${DETAIL}${d}.short`, `${DETAIL}${d}.body`])),
|
||||
).toEqual([]);
|
||||
const named = new Set(
|
||||
[...keys]
|
||||
.filter((k) => k.startsWith(DETAIL))
|
||||
.map((k) => k.slice(DETAIL.length).replace(/\.(short|body)$/, "")),
|
||||
);
|
||||
expect([...named].filter((d) => !failDetails.includes(d))).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${lang}: every admin refusal reason`, () => {
|
||||
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
|
||||
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
|
||||
|
||||
@@ -287,6 +287,22 @@
|
||||
"short": "Storage full",
|
||||
"body": "This download was stopped because your storage quota is already used up. Delete something from your library, then try again."
|
||||
}
|
||||
},
|
||||
"catalog": {
|
||||
"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."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"group_post_video": {
|
||||
"short": "The video isn't reachable from the post link",
|
||||
"body": "This is the link of a group post, not of the video. Open the video on Facebook and paste its link."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cookies": {
|
||||
|
||||
@@ -287,6 +287,22 @@
|
||||
"short": "Betelt a tárhely",
|
||||
"body": "Ez a letöltés leállt, mert a tárhelykereted már elfogyott. Törölj valamit a könyvtáradból, aztán próbáld újra."
|
||||
}
|
||||
},
|
||||
"catalog": {
|
||||
"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."
|
||||
},
|
||||
"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ó."
|
||||
},
|
||||
"group_post_video": {
|
||||
"short": "A videó nem érhető el a poszt linkjéről",
|
||||
"body": "Ez a csoportposzt linkje, nem a videóé. Nyisd meg a videót a Facebookon, és annak a linkjét másold be."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cookies": {
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface DownloadJob {
|
||||
phase: string | null;
|
||||
error: string | null; // raw yt-dlp/ffmpeg text (technical detail)
|
||||
error_code: string | null; // failure category, see backend downloads.errors.classify
|
||||
error_detail: string | null; // host-specific failure-catalog entry, see downloads.errors.diagnose
|
||||
queue_pos: number;
|
||||
created_at: string | null;
|
||||
source_kind: string;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A failed download's explanation: the failure-catalog entry first, its category as the fallback
|
||||
// (E11.5 S1). The second case runs the REAL i18next with the real locales, because the fallback is
|
||||
// i18next's list-key lookup, not code of ours — an older client meeting a detail it has no copy for
|
||||
// must still say the category, not print a key.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import i18n from "../i18n";
|
||||
import { failCopyKeys } from "./downloadFailure";
|
||||
|
||||
describe("failCopyKeys", () => {
|
||||
it("puts a catalog entry before its category, and is the category alone without one", () => {
|
||||
expect(
|
||||
failCopyKeys({ error_code: "login_required", error_detail: "facebook.login_wall" }, "body"),
|
||||
).toEqual([
|
||||
"downloads.errors.fail.catalog.facebook.login_wall.body",
|
||||
"downloads.errors.fail.cat.login_required.body",
|
||||
]);
|
||||
expect(failCopyKeys({ error_code: "network", error_detail: null }, "short")).toEqual([
|
||||
"downloads.errors.fail.cat.network.short",
|
||||
]);
|
||||
expect(failCopyKeys({ error_code: null, error_detail: null }, "short")).toEqual([
|
||||
"downloads.errors.fail.cat.unknown.short",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the entry's copy, and the category's for an entry this client does not know", async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
expect(
|
||||
i18n.t(
|
||||
failCopyKeys(
|
||||
{ error_code: "login_required", error_detail: "facebook.login_wall" },
|
||||
"short",
|
||||
),
|
||||
),
|
||||
).toBe("Facebook sign-in required");
|
||||
expect(
|
||||
i18n.t(
|
||||
failCopyKeys({ error_code: "login_required", error_detail: "facebook.newer" }, "short"),
|
||||
),
|
||||
).toBe("Sign-in required");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
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.
|
||||
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];
|
||||
}
|
||||
Reference in New Issue
Block a user