From 52c15adaf2fb12cc828f87d00aee368f73714552 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 14:54:28 +0200 Subject: [PATCH] fix(downloads): a row whose remote picture fails too falls back to its glyph The remote thumbnail was the last resort with no fallback of its own, so a dead CDN link drew the browser's broken-image mark. A failed remote URL is remembered like a failed poster, and the row shows its film / music glyph. --- frontend/src/components/downloadRow.dom.test.tsx | 14 +++++++++++++- frontend/src/components/downloadRow.tsx | 8 +++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/downloadRow.dom.test.tsx b/frontend/src/components/downloadRow.dom.test.tsx index 7e6274a2..02bb05ea 100644 --- a/frontend/src/components/downloadRow.dom.test.tsx +++ b/frontend/src/components/downloadRow.dom.test.tsx @@ -5,7 +5,7 @@ // render when present and be absent when the asset has no container yet (queued/running before the worker // fills it). i18n is key-passthrough here. import { describe, it, expect, afterEach, vi } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -255,4 +255,16 @@ describe("DownloadRow · no picture", () => { ); expect(audio.querySelector("svg.lucide-music")).not.toBeNull(); }); + // E28 review C5: the remote thumbnail was the last resort with no fallback of its own — when it + // failed too (a dead CDN link), the row drew the browser's broken-image mark. + it("falls back to the glyph when the remote picture fails too", () => { + const { container } = render( + , + ); + const img = container.querySelector("img")!; + expect(img.getAttribute("src")).toBe("https://cdn.example/dead.jpg"); + fireEvent.error(img); + expect(container.querySelector("img")).toBeNull(); + expect(container.querySelector("svg.lucide-film")).not.toBeNull(); + }); }); diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx index c149b10f..38b7e678 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/components/downloadRow.tsx @@ -94,7 +94,12 @@ function Thumb({ job }: { job: DownloadJob }) { // back, not written off for the life of the recycled slot. Convergent, so it belongs in render. const [failedPrimary, setFailedPrimary] = useState(null); if (failedPrimary !== null && failedPrimary !== primary) setFailedPrimary(null); - const src = failedPrimary && failedPrimary === primary ? remote : primary; + // The remote picture is the last resort, and it fails too (a dead CDN link): remembered the same + // way, so the row falls to the glyph below instead of the browser's broken-image mark (E28 C5). + const [failedRemote, setFailedRemote] = useState(null); + if (failedRemote !== null && failedRemote !== remote) setFailedRemote(null); + const candidate = failedPrimary && failedPrimary === primary ? remote : primary; + const src = candidate && candidate === failedRemote ? null : candidate; // A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/ // archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail. if (job.asset?.media_kind === "file") { @@ -114,6 +119,7 @@ function Thumb({ job }: { job: DownloadJob }) { className="w-full h-full object-cover" onError={() => { if (src !== remote) setFailedPrimary(primary); + else setFailedRemote(remote); }} /> ) : (