fix(watch): a link whose video is being made again says so, in the page's language
The public watch route answers video_preparing for an asset that is pending or downloading, and the page shows "being prepared — try again" with a Retry instead of "this link is gone". watch.html ships one lang; the page now sets documentElement.lang to the language its strings were picked in.
This commit is contained in:
@@ -46,6 +46,10 @@ def _link_or_404(db: Session, token: str) -> DownloadLink:
|
|||||||
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
|
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
|
||||||
job = db.get(DownloadJob, link.job_id)
|
job = db.get(DownloadJob, link.job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
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:
|
if job is None or asset is None or asset.status != "ready" or not asset.rel_path:
|
||||||
raise refused(404, "video_gone")
|
raise refused(404, "video_gone")
|
||||||
# Verify the file is actually on disk here (not just flagged ready), so the watch page never
|
# Verify the file is actually on disk here (not just flagged ready), so the watch page never
|
||||||
|
|||||||
@@ -211,6 +211,26 @@ describe("WatchPage honest failures", () => {
|
|||||||
expect(page.queryByRole("button", { name: "Try again" })).toBeNull();
|
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(<WatchPage />);
|
||||||
|
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<unknown>) {
|
async function tryPassword(second: () => Promise<unknown>) {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@@ -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() {
|
export default function WatchPage() {
|
||||||
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
|
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
|
// 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.
|
// to load, and "loading" was committed and painted before the effect said so.
|
||||||
const [status, setStatus] = useState<"loading" | "ready" | "password" | "gone" | "error">(
|
const [status, setStatus] = useState<
|
||||||
token ? "loading" : "gone",
|
"loading" | "ready" | "password" | "gone" | "preparing" | "error"
|
||||||
);
|
>(token ? "loading" : "gone");
|
||||||
const [meta, setMeta] = useState<Meta | null>(null);
|
const [meta, setMeta] = useState<Meta | null>(null);
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -110,16 +117,16 @@ export default function WatchPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return; // the seed above already says "gone"
|
if (!token) return; // the seed above already says "gone"
|
||||||
send(`/api/public/watch/${encodeURIComponent(token)}`)
|
send(`/api/public/watch/${encodeURIComponent(token)}`)
|
||||||
.then((r) => {
|
.then((r): Promise<Meta | "preparing" | "gone"> => {
|
||||||
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed). Anything
|
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed) — or that
|
||||||
// else — a 5xx while the server restarts, a dropped connection — is a failure worth retrying,
|
// its video is being made again (`notFoundState`). Anything else — a 5xx while the server
|
||||||
// not a dead link.
|
// restarts, a dropped connection — is a failure worth retrying, not a dead link.
|
||||||
if (r.status === 404) return null;
|
if (r.status === 404) return notFoundState(r);
|
||||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
return r.json() as Promise<Meta>;
|
return r.json() as Promise<Meta>;
|
||||||
})
|
})
|
||||||
.then((m) => {
|
.then((m) => {
|
||||||
if (!m) setStatus("gone");
|
if (typeof m === "string") setStatus(m);
|
||||||
else if (m.needs_password) setStatus("password");
|
else if (m.needs_password) setStatus("password");
|
||||||
else {
|
else {
|
||||||
setMeta(m);
|
setMeta(m);
|
||||||
@@ -151,7 +158,7 @@ export default function WatchPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (r.status === 404) {
|
if (r.status === 404) {
|
||||||
setStatus("gone");
|
setStatus(await notFoundState(r));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
@@ -194,6 +201,19 @@ export default function WatchPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{status === "preparing" && (
|
||||||
|
<div className="flex-1 grid place-items-center text-center">
|
||||||
|
{/* Announced like the failure below: it replaces the loading line. */}
|
||||||
|
<div role="status">
|
||||||
|
<div className="text-lg font-semibold">{T.preparing}</div>
|
||||||
|
<div className="text-sm text-muted mt-1">{T.preparingHint}</div>
|
||||||
|
<Button variant="primary" size="md" onClick={retry} className="mt-4">
|
||||||
|
{T.retry}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<div className="flex-1 grid place-items-center text-center">
|
<div className="flex-1 grid place-items-center text-center">
|
||||||
{/* Announced: it replaces the loading line, and this page has no live region of its own. */}
|
{/* Announced: it replaces the loading line, and this page has no live region of its own. */}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,8 @@ const STR = {
|
|||||||
repeat: "Repeat",
|
repeat: "Repeat",
|
||||||
failed: "Couldn't load this link.",
|
failed: "Couldn't load this link.",
|
||||||
failedHint: "Check your connection and try again.",
|
failedHint: "Check your connection and try again.",
|
||||||
|
preparing: "This video is being prepared.",
|
||||||
|
preparingHint: "Try again in a few minutes.",
|
||||||
retry: "Try again",
|
retry: "Try again",
|
||||||
tooMany: "Too many attempts. Try again in a few minutes.",
|
tooMany: "Too many attempts. Try again in a few minutes.",
|
||||||
unlockFailed: "Couldn't check the password. Try again.",
|
unlockFailed: "Couldn't check the password. Try again.",
|
||||||
@@ -40,6 +42,8 @@ const STR = {
|
|||||||
repeat: "Ismétlés",
|
repeat: "Ismétlés",
|
||||||
failed: "Nem sikerült betölteni a linket.",
|
failed: "Nem sikerült betölteni a linket.",
|
||||||
failedHint: "Ellenőrizd a kapcsolatot, és próbáld újra.",
|
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",
|
retry: "Újrapróbálás",
|
||||||
tooMany: "Túl sok próbálkozás. Próbáld újra pár perc múlva.",
|
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.",
|
unlockFailed: "Nem sikerült ellenőrizni a jelszót. Próbáld újra.",
|
||||||
@@ -62,6 +66,8 @@ const STR = {
|
|||||||
repeat: "Wiederholen",
|
repeat: "Wiederholen",
|
||||||
failed: "Der Link konnte nicht geladen werden.",
|
failed: "Der Link konnte nicht geladen werden.",
|
||||||
failedHint: "Prüfe deine Verbindung und versuche es erneut.",
|
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",
|
retry: "Erneut versuchen",
|
||||||
tooMany: "Zu viele Versuche. Versuche es in ein paar Minuten erneut.",
|
tooMany: "Zu viele Versuche. Versuche es in ein paar Minuten erneut.",
|
||||||
unlockFailed: "Das Passwort konnte nicht geprüft werden. Versuche es 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)
|
? ((navigator.language || "en").slice(0, 2) as keyof typeof STR)
|
||||||
: "en";
|
: "en";
|
||||||
export const T = STR[lang];
|
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;
|
||||||
|
|||||||
@@ -191,6 +191,7 @@
|
|||||||
"unknown_setting": "Unknown setting: {{key}}",
|
"unknown_setting": "Unknown setting: {{key}}",
|
||||||
"value_missing": "A value is required",
|
"value_missing": "A value is required",
|
||||||
"video_gone": "This video is no longer available",
|
"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}}”",
|
"view_name_taken": "You already have a view named “{{name}}”",
|
||||||
"watch_sync_off": "Turn on Plex watch-sync first",
|
"watch_sync_off": "Turn on Plex watch-sync first",
|
||||||
"weak_password": "The password must be at least {{min}} characters",
|
"weak_password": "The password must be at least {{min}} characters",
|
||||||
|
|||||||
@@ -191,6 +191,7 @@
|
|||||||
"unknown_setting": "Ismeretlen beállítás: {{key}}",
|
"unknown_setting": "Ismeretlen beállítás: {{key}}",
|
||||||
"value_missing": "Adj meg egy értéket",
|
"value_missing": "Adj meg egy értéket",
|
||||||
"video_gone": "Ez a videó már nem érhető el",
|
"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",
|
"view_name_taken": "Már van „{{name}}” nevű nézeted",
|
||||||
"watch_sync_off": "Előbb kapcsold be a Plex megtekintés-szinkront",
|
"watch_sync_off": "Előbb kapcsold be a Plex megtekintés-szinkront",
|
||||||
"weak_password": "A jelszónak legalább {{min}} karakterből kell állnia",
|
"weak_password": "A jelszónak legalább {{min}} karakterből kell állnia",
|
||||||
|
|||||||
Reference in New Issue
Block a user