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:
2026-09-13 21:53:25 +02:00
parent ca5e446fbd
commit 18c11ca437
20 changed files with 582 additions and 13 deletions
+1
View File
@@ -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;
+43
View File
@@ -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");
});
});
+17
View File
@@ -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];
}