fix(downloads): a site the downloader no longer understands says so, and offers a retry
yt-dlp asks for a bug report on an extraction failure it did not expect: the site answered in a way its extractor does not read (measured on a TikTok link: fails on every attempt under yt-dlp 2026.07.04, downloads under 2026.08.19). That failure read as "Something went wrong" with no retry; it is now its own category, site_changed, with a Retry in the failure window. A contract test holds every failure category to its copy in both languages.
This commit is contained in:
@@ -144,6 +144,13 @@ _RULES: tuple[tuple[str, str], ...] = (
|
||||
("postprocessing", "postprocess"),
|
||||
("produced no output file", "postprocess"),
|
||||
("no output file", "postprocess"),
|
||||
# yt-dlp's own verdict that its extractor did not understand the site's answer: it appends this
|
||||
# request for a bug report to an UNEXPECTED extraction failure, never to an expected one (a private,
|
||||
# removed or geo-blocked video says so in words matched above). Measured 2026-10-12: a TikTok link
|
||||
# failed with it on every attempt under yt-dlp 2026.07.04 and downloads under 2026.08.19 — the
|
||||
# site changed and a newer yt-dlp is the cure. After the media-format and post-processing rules,
|
||||
# whose texts can carry the same request; before the network ones, which are about the transport.
|
||||
("please report this issue", "site_changed"),
|
||||
# Network / transport.
|
||||
("unable to download", "network"),
|
||||
("http error", "network"),
|
||||
|
||||
@@ -5,6 +5,14 @@ from app.downloads import errors as dlerrors
|
||||
from app.downloads.errors import classify
|
||||
|
||||
|
||||
# Verbatim from dev job 128 (2026-10-09, yt-dlp 2026.07.04); the same link downloads under 2026.08.19.
|
||||
_TIKTOK_UNEXPECTED = (
|
||||
"ERROR: [TikTok] 7418778475890232609: Unexpected response from webpage request; please report "
|
||||
"this issue on https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue "
|
||||
"template. Confirm you are on the latest version using yt-dlp -U"
|
||||
)
|
||||
|
||||
|
||||
def test_real_ytdlp_messages_map_to_categories():
|
||||
# Verbatim-shaped yt-dlp / ffmpeg output → the category the UI explains.
|
||||
cases = {
|
||||
@@ -32,6 +40,7 @@ def test_real_ytdlp_messages_map_to_categories():
|
||||
"The source file is missing.": "source_missing",
|
||||
"ERROR: unable to download video data: HTTP Error 403: Forbidden": "network",
|
||||
"ERROR: Unable to download webpage: <urlopen error timed out>": "network",
|
||||
_TIKTOK_UNEXPECTED: "site_changed",
|
||||
}
|
||||
for msg, expected in cases.items():
|
||||
assert classify(msg) == expected, f"{msg!r} → {classify(msg)!r}, expected {expected!r}"
|
||||
@@ -211,13 +220,25 @@ def test_our_post_without_a_video_verdict_names_the_group_link():
|
||||
|
||||
|
||||
def test_a_catalog_entry_never_leaves_its_host():
|
||||
# "Cannot parse data" is yt-dlp's wording for a broken extractor on every site.
|
||||
# "Cannot parse data" is yt-dlp's wording for a broken extractor on every site: elsewhere it is
|
||||
# only the category — the site changed under the extractor.
|
||||
other = _FB_PARSE.replace("[facebook]", "[vimeo]")
|
||||
assert dlerrors.diagnose(other, url="https://vimeo.com/1") == ("unknown", None)
|
||||
assert dlerrors.diagnose(other, url="https://vimeo.com/1") == ("site_changed", None)
|
||||
# The Facebook text for a link that is not Facebook's (a look-alike host) is not the Facebook wall.
|
||||
assert dlerrors.diagnose(_FB_PARSE, url="https://facebook.com.evil/reel/1") == ("unknown", None)
|
||||
assert dlerrors.diagnose(_FB_PARSE, url="https://facebook.com.evil/reel/1") == (
|
||||
"site_changed",
|
||||
None,
|
||||
)
|
||||
# No link known: the category only.
|
||||
assert dlerrors.diagnose(_FB_PARSE) == ("unknown", None)
|
||||
assert dlerrors.diagnose(_FB_PARSE) == ("site_changed", None)
|
||||
|
||||
|
||||
def test_a_bug_report_request_keeps_the_more_specific_category():
|
||||
# yt-dlp asks for the bug report on these too; what they say about the media wins.
|
||||
assert classify(_FB_NO_FORMATS) == "format_unavailable"
|
||||
assert classify("ERROR: [x] 1: Unable to extract title; please report this issue") == (
|
||||
"format_unavailable"
|
||||
)
|
||||
|
||||
|
||||
def test_diagnose_falls_back_to_the_category():
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The failure window offers a Retry only where a retry can help: a transient failure, or a site the
|
||||
// downloader did not understand (an update on the server cures it). Never for an unclassified one.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { DownloadJob } from "../lib/api";
|
||||
import { ErrorModal } from "./downloadModals";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key),
|
||||
}),
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function open(error_code: string) {
|
||||
const onRetry = vi.fn();
|
||||
const job = { id: 1, status: "error", error: "ERROR: x", error_code } as unknown as DownloadJob;
|
||||
render(<ErrorModal job={job} onRetry={onRetry} onClose={() => {}} />);
|
||||
return onRetry;
|
||||
}
|
||||
|
||||
describe("ErrorModal", () => {
|
||||
it("offers a retry when the site changed under the downloader", () => {
|
||||
const onRetry = open("site_changed");
|
||||
expect(screen.getByText("downloads.errors.fail.cat.site_changed.short")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "downloads.actions.retry" }));
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("offers none for an unclassified failure", () => {
|
||||
open("unknown");
|
||||
expect(screen.queryByRole("button", { name: "downloads.actions.retry" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -106,7 +106,9 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
|
||||
// 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", "convert_failed"];
|
||||
// `site_changed` is here: the site answered in a way the downloader did not understand, which a
|
||||
// downloader update on the server cures — the same link then goes through on a retry.
|
||||
const RETRYABLE_ERRORS = ["postprocess", "network", "convert_failed", "site_changed"];
|
||||
|
||||
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
|
||||
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
|
||||
|
||||
@@ -86,6 +86,18 @@ const failDetails = [
|
||||
...new Set([...backend("downloads/errors.py").matchAll(/detail="([\w.]+)"/g)].map((m) => m[1]!)),
|
||||
];
|
||||
const DETAIL = "downloads.errors.fail.catalog.";
|
||||
// Every category `classify` / the catalog can give a failure → `downloads.errors.fail.cat.<code>`. A
|
||||
// category without copy never fails loudly: the row falls back to the "unknown" sentence.
|
||||
const failCategories = [
|
||||
...new Set([
|
||||
...[
|
||||
...(backend("downloads/errors.py").split("_RULES: tuple")[1] ?? "")
|
||||
.split("\n)\n")[0]!
|
||||
.matchAll(/,\s*"(\w+)"\),/g),
|
||||
].map((m) => m[1]!),
|
||||
...[...backend("downloads/errors.py").matchAll(/code="(\w+)"/g)].map((m) => m[1]!),
|
||||
]),
|
||||
];
|
||||
|
||||
// What a structured 404 says is gone → `errors.notFound.<what>`: every `not_found("x")` and every
|
||||
// `owned_or_404(..., what="x")` / `get_or_404(..., what="x")` under routes/, read from the sources.
|
||||
@@ -317,6 +329,18 @@ describe("backend ids have their names in both languages", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it(`${lang}: every download failure category`, () => {
|
||||
expect(failCategories.length).toBeGreaterThan(5);
|
||||
expect(
|
||||
missing(
|
||||
failCategories.flatMap((c) => [
|
||||
`downloads.errors.fail.cat.${c}.short`,
|
||||
`downloads.errors.fail.cat.${c}.body`,
|
||||
]),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${lang}: every download failure-catalog entry`, () => {
|
||||
expect(
|
||||
missing(failDetails.flatMap((d) => [`${DETAIL}${d}.short`, `${DETAIL}${d}.body`])),
|
||||
|
||||
@@ -294,6 +294,10 @@
|
||||
"short": "Source unavailable",
|
||||
"body": "The original download this clip is based on is no longer available."
|
||||
},
|
||||
"site_changed": {
|
||||
"short": "The site has changed",
|
||||
"body": "The site answered in a way the downloader doesn't understand yet – it has probably changed. An update of the downloader on the server usually fixes this, so try again later."
|
||||
},
|
||||
"network": {
|
||||
"short": "Network error",
|
||||
"body": "A network error interrupted the download. A retry usually fixes this."
|
||||
|
||||
@@ -294,6 +294,10 @@
|
||||
"short": "A forrás nem elérhető",
|
||||
"body": "Az eredeti letöltés, amelyből ez a vágás készült, már nem érhető el."
|
||||
},
|
||||
"site_changed": {
|
||||
"short": "Az oldal megváltozott",
|
||||
"body": "Az oldal olyan választ adott, amit a letöltő még nem ért – valószínűleg megváltozott. Ezt általában a szerveren futó letöltő frissítése javítja, ezért próbáld újra később."
|
||||
},
|
||||
"network": {
|
||||
"short": "Hálózati hiba",
|
||||
"body": "Hálózati hiba szakította meg a letöltést. Újrapróbálásra általában megjavul."
|
||||
|
||||
Reference in New Issue
Block a user