diff --git a/backend/app/downloads/errors.py b/backend/app/downloads/errors.py index 0f50f62d..846dcb03 100644 --- a/backend/app/downloads/errors.py +++ b/backend/app/downloads/errors.py @@ -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"), diff --git a/backend/tests/test_download_errors.py b/backend/tests/test_download_errors.py index 92738261..8a1535b9 100644 --- a/backend/tests/test_download_errors.py +++ b/backend/tests/test_download_errors.py @@ -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: ": "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(): diff --git a/frontend/src/components/downloadModals.error.dom.test.tsx b/frontend/src/components/downloadModals.error.dom.test.tsx new file mode 100644 index 00000000..91873dce --- /dev/null +++ b/frontend/src/components/downloadModals.error.dom.test.tsx @@ -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()), + 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( {}} />); + 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(); + }); +}); diff --git a/frontend/src/components/downloadModals.tsx b/frontend/src/components/downloadModals.tsx index ae264ce9..5f901257 100644 --- a/frontend/src/components/downloadModals.tsx +++ b/frontend/src/components/downloadModals.tsx @@ -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. diff --git a/frontend/src/i18n/backendIds.contract.test.ts b/frontend/src/i18n/backendIds.contract.test.ts index 96269317..f2a3671d 100644 --- a/frontend/src/i18n/backendIds.contract.test.ts +++ b/frontend/src/i18n/backendIds.contract.test.ts @@ -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.`. 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.`: 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`])), diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 99476c47..94a31c4b 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -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." diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 0ecd6fd1..fa04dd98 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -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."