diff --git a/backend/app/routes/public.py b/backend/app/routes/public.py index 90bd0a4d..d8402e32 100644 --- a/backend/app/routes/public.py +++ b/backend/app/routes/public.py @@ -46,6 +46,10 @@ def _link_or_404(db: Session, token: str) -> DownloadLink: def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]: job = db.get(DownloadJob, link.job_id) asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None + if asset is not None and asset.status in ("pending", "downloading"): + # Being (re)made — a re-download, or a conversion — so the link works again once it is ready: + # its own reason, so the page can say "try again shortly" rather than "this link is gone". + raise refused(404, "video_preparing") if job is None or asset is None or asset.status != "ready" or not asset.rel_path: raise refused(404, "video_gone") # Verify the file is actually on disk here (not just flagged ready), so the watch page never diff --git a/frontend/src/components/WatchPage.dom.test.tsx b/frontend/src/components/WatchPage.dom.test.tsx index 1395cab8..9bcab3db 100644 --- a/frontend/src/components/WatchPage.dom.test.tsx +++ b/frontend/src/components/WatchPage.dom.test.tsx @@ -211,6 +211,26 @@ describe("WatchPage honest failures", () => { expect(page.queryByRole("button", { name: "Try again" })).toBeNull(); }); + it("a 404 whose video is being made again says to come back, with a retry", async () => { + window.history.replaceState({}, "", "/watch/tok-preparing"); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(res(404, { detail: { code: 404, reason: "video_preparing" } })) + .mockResolvedValue( + res(200, { needs_password: false, file_url: "/f.mp4", title: "A clip" }), + ), + ); + const page = render(); + const retry = await page.findByRole("button", { name: "Try again" }); + expect(page.getByRole("status").textContent).toContain("This video is being prepared."); + expect(page.queryByText(GONE)).toBeNull(); + + fireEvent.click(retry); + await waitFor(() => expect(page.container.querySelector("video")).not.toBeNull()); + }); + async function tryPassword(second: () => Promise) { vi.stubGlobal( "fetch", diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx index 355cb66d..cb1a0023 100644 --- a/frontend/src/components/WatchPage.tsx +++ b/frontend/src/components/WatchPage.tsx @@ -76,13 +76,20 @@ function displayUrl(url: string): string { } } +/** What a 404 from the link means: `video_preparing` is a link whose video is being made again (a + * re-download, a conversion) and will work shortly; any other is a link that is gone. */ +async function notFoundState(r: Response): Promise<"preparing" | "gone"> { + const body = (await r.json().catch(() => null)) as { detail?: { reason?: string } } | null; + return body?.detail?.reason === "video_preparing" ? "preparing" : "gone"; +} + export default function WatchPage() { const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? ""; // Seeded from the URL rather than corrected by the effect below: with no token there is nothing // to load, and "loading" was committed and painted before the effect said so. - const [status, setStatus] = useState<"loading" | "ready" | "password" | "gone" | "error">( - token ? "loading" : "gone", - ); + const [status, setStatus] = useState< + "loading" | "ready" | "password" | "gone" | "preparing" | "error" + >(token ? "loading" : "gone"); const [meta, setMeta] = useState(null); const [password, setPassword] = useState(""); const [error, setError] = useState(null); @@ -110,16 +117,16 @@ export default function WatchPage() { useEffect(() => { if (!token) return; // the seed above already says "gone" send(`/api/public/watch/${encodeURIComponent(token)}`) - .then((r) => { - // Only a 404 says the link is gone (unknown, expired, revoked, or its file removed). Anything - // else — a 5xx while the server restarts, a dropped connection — is a failure worth retrying, - // not a dead link. - if (r.status === 404) return null; + .then((r): Promise => { + // Only a 404 says the link is gone (unknown, expired, revoked, or its file removed) — or that + // its video is being made again (`notFoundState`). Anything else — a 5xx while the server + // restarts, a dropped connection — is a failure worth retrying, not a dead link. + if (r.status === 404) return notFoundState(r); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise; }) .then((m) => { - if (!m) setStatus("gone"); + if (typeof m === "string") setStatus(m); else if (m.needs_password) setStatus("password"); else { setMeta(m); @@ -151,7 +158,7 @@ export default function WatchPage() { return; } if (r.status === 404) { - setStatus("gone"); + setStatus(await notFoundState(r)); return; } if (!r.ok) throw new Error(`HTTP ${r.status}`); @@ -194,6 +201,19 @@ export default function WatchPage() { )} + {status === "preparing" && ( +
+ {/* Announced like the failure below: it replaces the loading line. */} +
+
{T.preparing}
+
{T.preparingHint}
+ +
+
+ )} + {status === "error" && (
{/* Announced: it replaces the loading line, and this page has no live region of its own. */} diff --git a/frontend/src/components/watchStrings.dom.test.ts b/frontend/src/components/watchStrings.dom.test.ts new file mode 100644 index 00000000..9d470878 --- /dev/null +++ b/frontend/src/components/watchStrings.dom.test.ts @@ -0,0 +1,30 @@ +// @vitest-environment jsdom +// +// The shared-link page ships one watch.html for every visitor, so the document's language is set +// from the language its strings were picked in — a screen reader picks its voice by it, and the +// browser offers to translate by it. A Hungarian visitor once got lang="en" over Hungarian text. +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +async function pageIn(language: string) { + vi.stubGlobal("navigator", { ...navigator, language }); + vi.resetModules(); + return import("./watchStrings"); +} + +describe("the watch page's document language", () => { + it.each([ + ["hu-HU", "hu"], + ["de-AT", "de"], + ["en-GB", "en"], + ["fr-FR", "en"], // no French strings: the page is in English, and says so + ])("a %s browser reads a page marked %s", async (language, want) => { + const { lang } = await pageIn(language); + expect(lang).toBe(want); + expect(document.documentElement.lang).toBe(want); + }); +}); diff --git a/frontend/src/components/watchStrings.ts b/frontend/src/components/watchStrings.ts index 18ab3c88..caca9def 100644 --- a/frontend/src/components/watchStrings.ts +++ b/frontend/src/components/watchStrings.ts @@ -18,6 +18,8 @@ const STR = { repeat: "Repeat", failed: "Couldn't load this link.", failedHint: "Check your connection and try again.", + preparing: "This video is being prepared.", + preparingHint: "Try again in a few minutes.", retry: "Try again", tooMany: "Too many attempts. Try again in a few minutes.", unlockFailed: "Couldn't check the password. Try again.", @@ -40,6 +42,8 @@ const STR = { repeat: "Ismétlés", failed: "Nem sikerült betölteni a linket.", failedHint: "Ellenőrizd a kapcsolatot, és próbáld újra.", + preparing: "A videó előkészítése folyamatban.", + preparingHint: "Próbáld újra néhány perc múlva.", retry: "Újrapróbálás", tooMany: "Túl sok próbálkozás. Próbáld újra pár perc múlva.", unlockFailed: "Nem sikerült ellenőrizni a jelszót. Próbáld újra.", @@ -62,6 +66,8 @@ const STR = { repeat: "Wiederholen", failed: "Der Link konnte nicht geladen werden.", failedHint: "Prüfe deine Verbindung und versuche es erneut.", + preparing: "Dieses Video wird gerade vorbereitet.", + preparingHint: "Versuche es in ein paar Minuten erneut.", retry: "Erneut versuchen", tooMany: "Zu viele Versuche. Versuche es in ein paar Minuten erneut.", unlockFailed: "Das Passwort konnte nicht geprüft werden. Versuche es erneut.", @@ -76,3 +82,7 @@ export const lang = ? ((navigator.language || "en").slice(0, 2) as keyof typeof STR) : "en"; export const T = STR[lang]; + +// The document says the language its text is in: a screen reader picks its voice by it, and the +// browser offers to translate by it. watch.html can only ship one; this page picks its own. +document.documentElement.lang = lang; diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index b01b9ff5..239658fb 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -191,6 +191,7 @@ "unknown_setting": "Unknown setting: {{key}}", "value_missing": "A value is required", "video_gone": "This video is no longer available", + "video_preparing": "This video is being prepared. Try again in a few minutes.", "view_name_taken": "You already have a view named “{{name}}”", "watch_sync_off": "Turn on Plex watch-sync first", "weak_password": "The password must be at least {{min}} characters", diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index b622dbc9..0b9125e7 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -191,6 +191,7 @@ "unknown_setting": "Ismeretlen beállítás: {{key}}", "value_missing": "Adj meg egy értéket", "video_gone": "Ez a videó már nem érhető el", + "video_preparing": "A videó előkészítése folyamatban. Próbáld újra néhány perc múlva.", "view_name_taken": "Már van „{{name}}” nevű nézeted", "watch_sync_off": "Előbb kapcsold be a Plex megtekintés-szinkront", "weak_password": "A jelszónak legalább {{min}} karakterből kell állnia",