feat(plex): every card rail in the reader's language; an episode in a poster rail stands as its show (E30 S1b)

The grid served Hungarian titles, but every other card surface (Discover rails, the
show page's episodes, search, collections, the person page, playlists, Home's resume
rail, hero and comfort shelf) served English. The card serializers now take a
required keyword-only lang (a forgotten call site fails instead of going English), and
each of those reads takes ?lang=hu, with the language in its query key. A Hungarian
search finds an episode by its Hungarian title too.

An episode on a poster rail showed its 16:9 still cropped into a poster, titled only
with the episode name; it now shows the show's poster and name, with S·E · title under
it (option A). Plex's generic season names (Season 1, Specials) are rebuilt from the
number in the reader's language; a season with a name of its own keeps it.

Home's comfort-shelf move wrote its optimistic order to the key without the language,
which nothing read any more; it writes the query's own key.
This commit is contained in:
2026-09-29 02:12:32 +02:00
parent 13d95ef70e
commit 632e39d458
37 changed files with 673 additions and 203 deletions
+27 -6
View File
@@ -9,6 +9,7 @@
// identical card straight back and the button looks dead.
// i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { setLanguage } from "../i18n";
import { StrictMode } from "react";
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react";
@@ -461,7 +462,7 @@ describe("Home", () => {
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.budget.short")); // the empty-state branch needs a band
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0));
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0, "en"));
// A press that RESOLVES with nothing left: the honest empty state.
discoverPick.mockResolvedValueOnce({ budget: "short", hero: null });
@@ -509,7 +510,7 @@ describe("Home", () => {
// A never-resolving refetch: the point is what the page shows WHILE it waits.
discover.mockReturnValue(new Promise(() => {}));
fireEvent.click(screen.getByText("home.budget.short"));
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0));
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0, "en"));
// Still the old answer — not a spinner that would take the chips away with it.
expect(screen.getByText("Hero v1")).toBeTruthy();
});
@@ -523,7 +524,7 @@ describe("Home", () => {
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), ["youtube:v1"], 1),
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), ["youtube:v1"], 1, "en"),
);
expect(await screen.findByText("Rerolled")).toBeTruthy();
});
@@ -584,7 +585,7 @@ describe("Home", () => {
mount();
expect(await screen.findByText("Rerolled")).toBeTruthy();
expect(discover).toHaveBeenLastCalledWith("short", 0);
expect(discover).toHaveBeenLastCalledWith("short", 0, "en");
// The very same question — seed, the cards already seen and the press count all came back, not
// just the budget: the server answers each of them differently.
await waitFor(() => expect(discoverPick.mock.lastCall).toEqual(asked));
@@ -613,7 +614,7 @@ describe("Home", () => {
// …and on the press count the server reads as "which tier answers first": 2 leads with the
// SURPRISE tier, which is what a command labelled "Meglepetés" promised. A plain next press (1)
// led with the shelf, so anyone with one got "A polcodról" instead (owner's call, 2026-09-19).
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 2);
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 2, "en");
});
// The rotation carries on from the requested tier: the page's own button after a palette surprise
@@ -753,7 +754,13 @@ describe("Home", () => {
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenLastCalledWith("any", expect.any(Number), ["youtube:v1"], 1),
expect(discoverPick).toHaveBeenLastCalledWith(
"any",
expect.any(Number),
["youtube:v1"],
1,
"en",
),
);
await screen.findByText("Rerolled");
fireEvent.click(screen.getByText("home.hero.reroll"));
@@ -767,6 +774,7 @@ describe("Home", () => {
// reinstate the measured nine-card cycle) left the whole suite green.
["youtube:v1", "youtube:v9"],
2,
"en",
),
);
});
@@ -872,3 +880,16 @@ describe("Home's hero text", () => {
expect(text.querySelector("p.measure")).toBeNull();
});
});
describe("Home · the reader's language (E30)", () => {
it("asks for the page in Hungarian, so a Plex card comes back with its Hungarian title", async () => {
setLanguage("hu");
try {
mount();
await waitFor(() => expect(discover).toHaveBeenCalled());
expect(discover.mock.calls[0]?.[2]).toBe("hu");
} finally {
setLanguage("en");
}
});
});
+9 -6
View File
@@ -14,6 +14,7 @@ import {
Star,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import { plexLang } from "../i18n";
import {
api,
DISCOVER_BUDGETS,
@@ -720,17 +721,19 @@ export default function Home() {
}, [budget, seed, press, seen]);
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null);
// A Plex card's title is in the reader's language (E30), so the answer is cached per language.
const lang = plexLang();
const q = useQuery({
queryKey: qk.discover(budget),
queryFn: () => api.discover(budget, 0),
queryKey: qk.discover(budget, lang),
queryFn: () => api.discover(budget, 0, lang),
// Keep the previous budget's answer on screen while the new one loads. Without this the page
// falls back to `isPending` and replaces itself — chips included — with a spinner, so the
// control you just clicked vanishes under your cursor. (Same reason the feed does it.)
placeholderData: keepPreviousData,
});
const pickQ = useQuery({
queryKey: qk.discoverPick(budget, seed, seen, press),
queryFn: () => api.discoverPick(budget, seed, seen, press),
queryKey: qk.discoverPick(budget, lang, seed, seen, press),
queryFn: () => api.discoverPick(budget, seed, seen, press, lang),
enabled: seed !== 0,
staleTime: 0,
// Hold the previous hero while the next one is in flight. Without this the reroll's new query key
@@ -931,7 +934,7 @@ export default function Home() {
const moveInShelf = useCallback(
(card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => {
if (shelfSavingRef.current) return;
const key = qk.discover(budget);
const key = qk.discover(budget, lang); // the query's own key: setQueryData matches exactly
const cards = rendered;
const from = cards.findIndex((c) => c.pin_id === card.pin_id);
const target = shelfMoveTarget(from, to, cards.length);
@@ -965,7 +968,7 @@ export default function Home() {
void qc.invalidateQueries({ queryKey: qk.discover() });
});
},
[budget, qc, t],
[budget, lang, qc, t],
);
const reroll = useCallback(
@@ -5,6 +5,7 @@
// expanded (lazily, on click) into its episodes in season→episode order; a show that expands to zero
// episodes must NOT silently no-op but surface the `playEmpty` info notice. api.plexCollection /
// api.plexShow are stubbed so the queries resolve synchronously; notify is spied to assert the notice.
import { setLanguage } from "../i18n";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -119,7 +120,7 @@ describe("PlexCollectionView — Play-all", () => {
const onPlay = mount([card("show1", "show")]);
fireEvent.click(await screen.findByText("plex.collection.playAll"));
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
expect(spyShow).toHaveBeenCalledWith("show1");
expect(spyShow).toHaveBeenCalledWith("show1", "en");
expect(onPlay).toHaveBeenCalledWith("s1e1", ["s1e1", "s1e2", "s2e1"]);
});
@@ -205,3 +206,19 @@ describe("PlexCollectionView — after the sort joined the query key", () => {
expect(screen.queryByText("plex.collection.playAll")).toBeNull();
});
});
describe("PlexCollectionView — the reader's language (E30)", () => {
it("asks for the members, and a show it expands, in Hungarian", async () => {
setLanguage("hu");
try {
const spyShow = vi.spyOn(api, "plexShow").mockResolvedValue(showDetail([["s1e1"]]));
const onPlay = mount([card("show1", "show")]);
fireEvent.click(await screen.findByText("plex.collection.playAll"));
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.plexCollection).mock.calls[0]?.[2]).toBe("hu");
expect(spyShow).toHaveBeenCalledWith("show1", "hu");
} finally {
setLanguage("en");
}
});
});
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { plexLang } from "../i18n";
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
@@ -83,10 +84,11 @@ export default function PlexCollectionView({
// ONE key for the read and every write into it: when the sort joined the key, the watched toggle's
// optimistic write kept the old spelling and landed on an entry nothing reads (setQueryData matches
// exactly), so the click did nothing visible until a reload.
const detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir);
const lang = plexLang();
const detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir, lang);
const q = useQuery({
queryKey: detailKey,
queryFn: () => api.plexCollection(collectionId, sortPref),
queryFn: () => api.plexCollection(collectionId, sortPref, lang),
// A re-sort keeps the current page on screen while the new order loads. Without it every sort
// change was a key with no data yet, and the whole page — the Sort control and its open panel
// with it — was swapped for a spinner, so key and direction could not be set in one visit. Only
@@ -134,8 +136,8 @@ export default function PlexCollectionView({
items.map(async (c) => {
if (c.type !== "show") return [c.id];
const detail = await qc.fetchQuery({
queryKey: qk.plexShow(c.id),
queryFn: () => api.plexShow(c.id),
queryKey: qk.plexShow(c.id, lang),
queryFn: () => api.plexShow(c.id, lang),
});
return showEpisodeKeys(detail);
}),
@@ -5,6 +5,7 @@
// filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero,
// the Movies/Shows sections, and the "filter library by this person" action). i18n is key-passthrough
// and the api is mocked so the query resolves synchronously in the test.
import { setLanguage } from "../i18n";
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -123,7 +124,7 @@ describe("PlexPersonView", () => {
fireEvent.click(screen.getByText("plex.person.filterBy"));
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] });
expect(plexPerson).toHaveBeenCalledWith("Jane Doe");
expect(plexPerson).toHaveBeenCalledWith("Jane Doe", "en");
});
it("shows the empty message when the person has no titles", async () => {
@@ -148,3 +149,33 @@ describe("PlexPersonView", () => {
await screen.findByText("plex.person.empty");
});
});
describe("PlexPersonView — the reader's language (E30)", () => {
it("asks for the filmography in Hungarian", async () => {
setLanguage("hu");
try {
plexPerson.mockResolvedValue({
name: "Jane Doe",
photo: null,
also_known_as: [],
known_for: [],
movies: [card("m1", "Egy film")],
shows: [],
});
wrap(
<PlexPersonView
name="Jane Doe"
onBack={() => {}}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
onFilter={() => {}}
/>,
);
await screen.findByText("Egy film");
expect(plexPerson).toHaveBeenCalledWith("Jane Doe", "hu");
} finally {
setLanguage("en");
}
});
});
@@ -89,7 +89,7 @@ it("writes the order the server accepted into the detail cache, without waiting
// staleTime showed the reorder undone, and the next drag persisted that.
await waitFor(() =>
expect(
qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5))?.items.map((i) => i.id),
qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5, "en"))?.items.map((i) => i.id),
).toEqual(["2", "1", "3"]),
);
});
@@ -124,7 +124,7 @@ it("refetches the detail too, so a membership change that landed meanwhile is no
await waitFor(() => expect(screen.queryByText("Movie 3")).toBeNull());
await waitFor(() =>
expect(
qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5))?.items.map((i) => i.id),
qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5, "en"))?.items.map((i) => i.id),
).toEqual(["2", "1"]),
);
});
@@ -4,6 +4,7 @@
// beside them, a right-click, a long-press, Shift+F10) instead of a red X at rest; removing needs no
// confirm, since the toast's Undo puts the rows back; `j`/`k` walk the visible rows and Delete takes
// out the one the keyboard is on; the playlist's rename and delete sit in the header's "…".
import { setLanguage } from "../i18n";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -346,3 +347,16 @@ describe("PlexPlaylistView · a failed watched toggle", () => {
expect(menuRows()).toContain("plex.markWatched");
});
});
describe("PlexPlaylistView · the reader's language (E30)", () => {
it("asks for the rows in Hungarian", async () => {
setLanguage("hu");
try {
mount([movie("1")]);
await screen.findByText("Movie 1");
expect(api.plexPlaylist).toHaveBeenCalledWith(5, "hu");
} finally {
setLanguage("en");
}
});
});
+3 -2
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pin, PinOff } from "lucide-react";
import { plexLang } from "../i18n";
import { api, type DiscoverCard, type PinKind } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { notify } from "../lib/notifications";
@@ -104,8 +105,8 @@ export function useComfortPin(open: boolean): ComfortPin {
if (open && !everOpen) setEverOpen(true);
const shelf = useQuery({
queryKey: qk.comfortPins(),
queryFn: () => api.comfortPins(),
queryKey: qk.comfortPins(plexLang()),
queryFn: () => api.comfortPins(plexLang()),
enabled: everOpen,
// The shelf is hand-curated and short; re-asking on every menu open would be noise.
staleTime: 60_000,
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
//
// E30: what a Plex card says, in the reader's language. An episode in a poster rail stands as its show
// (the show's poster and name, the episode on the line under it), and a season Plex named generically
// ("Season 1", "Specials") is named from its number instead. i18n here echoes the key AND its values,
// so an assertion can see which words went into the line.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
const t = (key: string, o?: Record<string, unknown>) =>
o ? `${key}(${Object.values(o).join("|")})` : key;
return { ...actual, useTranslation: () => ({ t }) };
});
import { PlexPosterCard, SeasonCard } from "./plexCards";
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
afterEach(cleanup);
const watchedEpisode: PlexCard = {
id: "e5",
type: "episode",
title: "Visszaszámlálás",
thumb: "/api/plex/image/e5",
season_number: 2,
episode_number: 5,
show_title: "A 3-test-probléma",
show_id: "s9",
};
describe("an episode in a poster rail", () => {
it("stands as its show: the show's poster and name, the episode under it", () => {
render(<PlexPosterCard card={watchedEpisode} onOpen={vi.fn()} shelf />);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe("/api/plex/image/s9");
expect(screen.getByText("A 3-test-probléma")).toBeTruthy();
expect(screen.getByText("plex.episodeLine(2|5|Visszaszámlálás)")).toBeTruthy();
});
it("keeps its own landscape still where the rail is landscape, or the show is unknown", () => {
const { unmount } = render(<PlexPosterCard card={watchedEpisode} onOpen={vi.fn()} wide />);
expect(document.querySelector("img")?.getAttribute("src")).toBe("/api/plex/image/e5");
unmount();
render(<PlexPosterCard card={{ ...watchedEpisode, show_id: null }} onOpen={vi.fn()} />);
expect(document.querySelector("img")?.getAttribute("src")).toBe("/api/plex/image/e5");
expect(screen.getByText("Visszaszámlálás")).toBeTruthy();
});
});
const season = (title: string, n: number | null): PlexSeasonDetail => ({
id: "se",
season_number: n,
title,
thumb: "",
episode_count: 3,
status: "new",
episodes: [],
});
describe("a season's name", () => {
it.each([
["Season 1", 1, "plex.seasonTitle(1)"],
["Specials", 0, "plex.specials"],
["Book One: Water", 1, "Book One: Water"],
])("Plex's %j (season %s) reads %j", (title, n, shown) => {
render(<SeasonCard se={season(title, n)} onOpen={vi.fn()} />);
expect(screen.getByText(shown)).toBeTruthy();
});
});
+29 -10
View File
@@ -5,6 +5,7 @@ import { type ReactNode, useRef } from "react";
import { formatRuntime } from "../lib/format";
import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
import { seasonTitle } from "../lib/plexDetail";
import { navItemAttrs } from "../lib/listNav";
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
@@ -288,7 +289,12 @@ export function PlexPosterCard({
}) {
const { t } = useTranslation();
const preview = useMediaPreview();
const landscape = wide || card.type === "episode";
// An episode in a poster rail (Discover's "Recently watched") stands as its SHOW: the show's poster
// and name, the episode on the line under it (E30, option A). Its own 16:9 still in a poster slot
// was a cropped frame titled only "Countdown". Needs the show (`show_id`, from a leaf card).
const asShow = card.type === "episode" && !wide && !!card.show_id && !!card.show_title;
const landscape = wide || (card.type === "episode" && !asShow);
const name = asShow ? `${card.show_title} · ${card.title}` : card.title;
const ep = card.type === "show" ? (next ?? null) : null;
// movies/episodes play; a show opens its drill-down — unless it plays on (`next`)
const isPlayable = card.type !== "show" || !!ep;
@@ -320,7 +326,7 @@ export function PlexPosterCard({
dense
rootProps={preview.rootProps({
...menuProps?.(card),
...(navIndex !== undefined && navItemAttrs(navIndex, card.title)),
...(navIndex !== undefined && navItemAttrs(navIndex, name)),
})}
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll);
// a carousel turns it off (index.css `.media-rail`).
@@ -328,8 +334,12 @@ export function PlexPosterCard({
picture={
<PlexPicture
aspect={landscape ? "wide" : "poster"}
thumb={(wide && card.type !== "episode" && card.art) || card.thumb}
label={card.title}
thumb={
asShow
? `/api/plex/image/${encodeURIComponent(card.show_id ?? "")}`
: (wide && card.type !== "episode" && card.art) || card.thumb
}
label={name}
onOpen={onOpen}
onRestart={onRestart}
plays={isPlayable}
@@ -362,11 +372,19 @@ export function PlexPosterCard({
}
title={
<CardTitle onClick={onOpen} shelf={shelf}>
{card.title}
{asShow ? card.show_title : card.title}
</CardTitle>
}
meta={
epCode ? (
asShow ? (
<div className="truncate text-xs leading-6 text-muted">
{t("plex.episodeLine", {
season: card.season_number ?? "?",
episode: card.episode_number ?? "?",
title: card.title,
})}
</div>
) : epCode ? (
<div className="truncate text-xs leading-6 text-muted">{t("plex.nextUp", epCode)}</div>
) : (
(!!card.year || card.rating != null) && (
@@ -383,7 +401,7 @@ export function PlexPosterCard({
/>
<MediaPreview
preview={preview}
title={card.title}
title={name}
facts={
<span className="flex flex-wrap items-center gap-x-1.5">
{[card.year, corner].filter(Boolean).join(" · ")}
@@ -443,6 +461,7 @@ export function SeasonCard({
navIndex?: number;
}) {
const { t } = useTranslation();
const title = seasonTitle(se, t);
const watched = se.status === "watched";
const m = more(se, menuButtonProps, menuOpen);
return (
@@ -450,14 +469,14 @@ export function SeasonCard({
dense
rootProps={{
...menuProps?.(se),
...(navIndex !== undefined && navItemAttrs(navIndex, se.title)),
...(navIndex !== undefined && navItemAttrs(navIndex, title)),
}}
picture={
// A season opens its episode list: no Play on hover, since the click does not play.
<PlexPicture
aspect="poster"
thumb={se.thumb}
label={se.title}
label={title}
onOpen={onOpen}
plays={false}
bar={
@@ -481,7 +500,7 @@ export function SeasonCard({
</CornerBadge>
</PlexPicture>
}
title={<CardTitle onClick={onOpen}>{se.title}</CardTitle>}
title={<CardTitle onClick={onOpen}>{title}</CardTitle>}
more={m}
moreLabel={t("card.menu.more")}
/>
+17 -7
View File
@@ -34,7 +34,13 @@ import {
Filterable,
WatchedToggle,
} from "./plexDetailUi";
import { epLabel, tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail";
import {
epLabel,
seasonTitle,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailPrefs,
} from "../lib/plexDetail";
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
import { PlexMenu } from "./PlexMenu";
import { usePlexCardActions } from "./usePlexCardActions";
@@ -136,7 +142,9 @@ export function PlexPersonView({
// lists on one screen, and the popover anchors where it opened, so a second controller would only
// mean a second closed popover to keep shut (see ui/ContextMenu).
const menu = useContextMenu<PlexCard>();
const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) });
const lang = plexLang();
const personKey = qk.plexPerson(name, lang);
const q = useQuery({ queryKey: personKey, queryFn: () => api.plexPerson(name, lang) });
// E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
const { artBg, crossfade } = useDetailPrefs();
@@ -153,7 +161,7 @@ export function PlexPersonView({
const toggle = (card: PlexCard) =>
toggleWatchState(card, t, {
optimistic: (next) =>
qc.setQueryData<PlexPersonDetail>(qk.plexPerson(name), (old) =>
qc.setQueryData<PlexPersonDetail>(personKey, (old) =>
old
? {
...old,
@@ -402,7 +410,7 @@ export function PlexShowView({
setAddTarget({
kind: "group",
ratingKeys: se.episodes.map((e) => e.id),
title: `${show.title} — ${se.title}`,
title: `${show.title} — ${seasonTitle(se, t)}`,
})
: undefined;
// A season's one action list (E28 C1): the rows its card shows on hover, plus copy.
@@ -435,7 +443,7 @@ export function PlexShowView({
},
]
: []),
...copy(se.title, null).map((it) => ({ ...it, separatorBefore: true })),
...copy(seasonTitle(se, t), null).map((it) => ({ ...it, separatorBefore: true })),
];
};
@@ -750,7 +758,9 @@ export function PlexSeasonView({
<Button variant="link-muted" selectable onClick={onBack} className="text-sm">
{d.show.title}
</Button>
<PageHeading className="text-2xl font-semibold leading-tight">{se.title}</PageHeading>
<PageHeading className="text-2xl font-semibold leading-tight">
{seasonTitle(se, t)}
</PageHeading>
<p className="text-sm text-muted mt-1">
{t("plex.series.episodeCount", { count: se.episode_count })}
</p>
@@ -785,7 +795,7 @@ export function PlexSeasonView({
setAddTarget({
kind: "group",
ratingKeys: seasonKeys,
title: `${d.show.title} — ${se.title}`,
title: `${d.show.title} — ${seasonTitle(se, t)}`,
})
}
icon={ListPlus}
@@ -75,7 +75,7 @@ describe("usePlexPlaylistEdits · the reorder", () => {
act(() => hook.result.current.commit([movie("2"), movie("1")]));
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1"]));
await waitFor(() =>
expect(ids(qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5))!.items)).toEqual([
expect(ids(qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5, "en"))!.items)).toEqual([
"2",
"1",
]),
@@ -100,7 +100,11 @@ describe("usePlexPlaylistEdits · the reorder", () => {
// the same `q.data`, and could never correct a stale rollback.
detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never);
act(() =>
qc.setQueryData(qk.plexPlaylist(5), { id: 5, title: "F", items: [movie("1"), movie("3")] }),
qc.setQueryData(qk.plexPlaylist(5, "en"), {
id: 5,
title: "F",
items: [movie("1"), movie("3")],
}),
);
// The view has taken the refreshed list in BEFORE the reorder fails — the race as it happens.
// (react-query batches its notification, so without this wait the refresh landed only after the
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { plexLang } from "../i18n";
import { api, type PlexCard } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { notify } from "../lib/notifications";
@@ -15,9 +16,13 @@ export function usePlexPlaylistEdits(playlistId: number) {
const { t } = useTranslation();
const qc = useQueryClient();
const [busy, setBusy] = useState(false);
// The rows' titles are in the reader's language (E30), so the cache entry is per language — and ONE
// key serves the read and every write below (setQueryData matches exactly).
const lang = plexLang();
const key = qk.plexPlaylist(playlistId, lang);
const q = useQuery({
queryKey: qk.plexPlaylist(playlistId),
queryFn: () => api.plexPlaylist(playlistId),
queryKey: key,
queryFn: () => api.plexPlaylist(playlistId, lang),
});
// Local optimistic copy so drag/remove are instant; re-synced whenever the query returns.
const [items, setItems] = useState<PlexCard[]>([]);
@@ -43,7 +48,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
// it, and the next drag persisted an order containing it (deep-review round 5). `Playlists.tsx`'s
// sibling `restoreFromCache` reads it live for the same reason.
const putBack = () => {
const cur = qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId));
const cur = qc.getQueryData<{ items: PlexCard[] }>(key);
if (cur) setItems(cur.items);
invalidate();
};
@@ -63,9 +68,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
// next drag from there persisted it, silently undoing the one the server took. The
// YouTube sibling (`Playlists.tsx`) got this in round 5; this half was left behind (round
// 6). One accepted write, one cache entry that matches it.
qc.setQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId), (old) =>
old ? { ...old, items: next } : old,
);
qc.setQueryData<{ items: PlexCard[] }>(key, (old) => (old ? { ...old, items: next } : old));
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
// …and then REFETCH it, exactly as the sibling does. `next` is this render's membership, and
// the server only reorders the keys it was given — so anything that landed meanwhile (a row
@@ -112,8 +115,8 @@ export function usePlexPlaylistEdits(playlistId: number) {
onClick: () =>
void qc
.fetchQuery({
queryKey: qk.plexPlaylist(playlistId),
queryFn: () => api.plexPlaylist(playlistId),
queryKey: key,
queryFn: () => api.plexPlaylist(playlistId, lang),
staleTime: 0,
})
.then((now) => {
+3
View File
@@ -124,6 +124,9 @@
"markNextWatched": "Mark S{{season}}·E{{episode}} watched",
"markNextUnwatched": "Mark S{{season}}·E{{episode}} unwatched",
"nextUp": "Next: S{{season}}·E{{episode}}",
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
"seasonTitle": "Season {{n}}",
"specials": "Specials",
"watchedFailed": "Couldn't update the watched state — please try again",
"seasons": "{{count}} seasons",
"unified": {
+3
View File
@@ -124,6 +124,9 @@
"markNextWatched": "S{{season}}·E{{episode}} megnézettnek jelöl",
"markNextUnwatched": "S{{season}}·E{{episode}} nem-nézettnek jelöl",
"nextUp": "Következik: S{{season}}·E{{episode}}",
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
"seasonTitle": "{{n}}. évad",
"specials": "Különkiadások",
"watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra",
"seasons": "{{count}} évad",
"unified": {
+13
View File
@@ -50,3 +50,16 @@ describe("discoverApi query strings", () => {
expect(lastUrl()).not.toContain("exclude=");
});
});
describe("the reader's language (E30)", () => {
it("asks for Hungarian Plex cards on every Home read, and leaves English implied", async () => {
await discoverApi.discover("any", 0, "hu");
expect(lastUrl()).toContain("lang=hu");
await discoverApi.discoverPick("any", 1, [], 0, "hu");
expect(lastUrl()).toContain("lang=hu");
await discoverApi.comfortPins("hu");
expect(lastUrl()).toContain("lang=hu");
await discoverApi.discover("any", 0, "en");
expect(lastUrl()).not.toContain("lang=");
});
});
+8 -4
View File
@@ -71,8 +71,9 @@ export interface DiscoverPayload {
}
export const discoverApi = {
discover: (budget: DiscoverBudget, seed: number): Promise<DiscoverPayload> =>
req(`/api/discover?budget=${budget}&seed=${seed}`),
// `lang` "hu" serves a Plex card's Hungarian title (E30); en is the server default.
discover: (budget: DiscoverBudget, seed: number, lang?: string): Promise<DiscoverPayload> =>
req(`/api/discover?budget=${budget}&seed=${seed}${lang === "hu" ? "&lang=hu" : ""}`),
// The "Mást kérek" reroll: a new hero alone, so pressing it does not re-fetch the rails under the
// user's cursor.
// `exclude` is the rolling list of `<source>:<id>` keys already shown. Without it a reroll only
@@ -84,12 +85,15 @@ export const discoverApi = {
seed: number,
exclude: string[] = [],
press = 0,
lang?: string,
): Promise<{ budget: DiscoverBudget; hero: DiscoverHero | null }> =>
req(
`/api/discover/pick?budget=${budget}&seed=${seed}&start=${press}` +
(exclude.length ? `&exclude=${encodeURIComponent(exclude.join(","))}` : ""),
(exclude.length ? `&exclude=${encodeURIComponent(exclude.join(","))}` : "") +
(lang === "hu" ? "&lang=hu" : ""),
),
comfortPins: (): Promise<{ items: DiscoverCard[] }> => req("/api/discover/pins"),
comfortPins: (lang?: string): Promise<{ items: DiscoverCard[] }> =>
req(`/api/discover/pins${lang === "hu" ? "?lang=hu" : ""}`),
addComfortPin: (
kind: PinKind,
ref: string,
+6 -4
View File
@@ -495,12 +495,13 @@ export const plexApi = {
},
// One collection's hero + its members in the requested order (the Collection info page). The sort
// vocabulary is the browse grid's, so the same Sort control means the same thing on both.
plexCollection: (rk: string, sort?: SortState): Promise<PlexCollectionDetail> => {
plexCollection: (rk: string, sort?: SortState, lang?: string): Promise<PlexCollectionDetail> => {
const u = new URLSearchParams();
if (sort) {
u.set("sort", sort.key);
u.set("sort_dir", sort.dir);
}
if (lang === "hu") u.set("lang", "hu");
const qs = u.toString();
return req(`/api/plex/collections/${encodeURIComponent(rk)}${qs ? `?${qs}` : ""}`);
},
@@ -552,7 +553,8 @@ export const plexApi = {
const qs = p.toString();
return req(`/api/plex/playlists${qs ? `?${qs}` : ""}`);
},
plexPlaylist: (id: number): Promise<PlexPlaylistDetail> => req(`/api/plex/playlists/${id}`),
plexPlaylist: (id: number, lang?: string): Promise<PlexPlaylistDetail> =>
req(`/api/plex/playlists/${id}${lang === "hu" ? "?lang=hu" : ""}`),
plexCreatePlaylist: (title: string, item_rating_key?: string): Promise<PlexPlaylist> =>
req(`/api/plex/playlists`, {
method: "POST",
@@ -636,8 +638,8 @@ export const plexApi = {
`/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`,
),
// One cast/crew member's filmography: cached headshot + movies and shows (cast OR directors).
plexPerson: (name: string): Promise<PlexPersonDetail> =>
req(`/api/plex/person/${encodeURIComponent(name)}`),
plexPerson: (name: string, lang?: string): Promise<PlexPersonDetail> =>
req(`/api/plex/person/${encodeURIComponent(name)}${lang === "hu" ? "?lang=hu" : ""}`),
plexSession: (
id: string,
start = 0,
+42
View File
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
//
// E30: every Plex read that returns cards carries the reader's language, so the server can serve the
// Hungarian titles. The grid and the detail pages always did; these reads did not.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { plexApi } from "./plex";
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({}),
text: async () => "{}",
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => vi.unstubAllGlobals());
const lastUrl = (): string => {
const calls = fetchMock.mock.calls;
return String(calls[calls.length - 1]?.[0]);
};
describe("Plex card reads in the reader's language", () => {
it.each([
["a playlist", () => plexApi.plexPlaylist(5, "hu"), () => plexApi.plexPlaylist(5, "en")],
["a person", () => plexApi.plexPerson("Anna", "hu"), () => plexApi.plexPerson("Anna", "en")],
[
"a collection",
() => plexApi.plexCollection("c1", { key: "title", dir: "asc" }, "hu"),
() => plexApi.plexCollection("c1", { key: "title", dir: "asc" }, "en"),
],
])("%s asks for Hungarian, and leaves English implied", async (_what, hu, en) => {
await hu();
expect(lastUrl()).toContain("lang=hu");
await en();
expect(lastUrl()).not.toContain("lang=");
});
});
+17
View File
@@ -1,5 +1,6 @@
import { useLayoutEffect } from "react";
import { usePref, useSavePref } from "./usePref";
import type { TFunction } from "i18next";
import type { PlexCard } from "./api";
// The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display
@@ -149,3 +150,19 @@ export function epLabel(ep?: PlexCard | null): string | undefined {
return `S${ep.season_number} · E${ep.episode_number}`;
return ep.title;
}
// Plex names seasons in the library's language ("Season 1", "Specials"), so a Hungarian reader got
// English there (E30). A generic name is rebuilt from the number in the reader's language; a season
// with a name of its own ("Book One: Water") keeps it.
const GENERIC_SEASON = /^(season \d+|specials)$/i;
export function seasonTitle(
se: { title?: string | null; season_number?: number | null },
t: TFunction,
): string {
const n = se.season_number;
if (se.title && !GENERIC_SEASON.test(se.title.trim())) return se.title;
if (n === 0) return t("plex.specials");
if (n != null) return t("plex.seasonTitle", { n });
return se.title || t("plex.playlist.seasonUnknown");
}
+6 -3
View File
@@ -125,7 +125,7 @@ export const qk = {
// finish it, and it was still the answer on screen with a zeroed progress bar.
discover: (...rest: unknown[]) => ["discover", ...rest],
discoverPick: (...rest: unknown[]) => ["discover", "pick", ...rest],
comfortPins: () => ["comfort-pins"] as const,
comfortPins: (lang?: string) => ["comfort-pins", ...(lang ? [lang] : [])] as const,
plex: () => ["plex"] as const,
plexWatchLink: () => ["plex-watch-link"] as const,
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
@@ -150,11 +150,14 @@ export const qk = {
// that steers the background extraction, not part of the track's identity.
plexSubtitle: (id: string, ord: number) => ["plex-subtitle", id, ord] as const,
plexPlaylists: (...rest: unknown[]) => ["plex-playlists", ...rest],
plexPlaylist: (playlistId: number) => ["plex-playlist", playlistId] as const,
// `lang` as on plexShow: omit it for a prefix that matches both languages.
plexPlaylist: (playlistId: number, lang?: string) =>
["plex-playlist", playlistId, ...(lang ? [lang] : [])] as const,
plexCollections: (...rest: unknown[]) => ["plex-collections", ...rest],
// facetKey is a filters-derived OBJECT segment (not a scalar), so this key is heterogeneous too.
plexFacets: (...rest: unknown[]) => ["plex-facets", ...rest],
plexPeopleCards: (scope: string, actors: string[]) =>
["plex-people-cards", scope, actors] as const,
plexPerson: (name: string) => ["plex-person", name] as const,
plexPerson: (name: string, lang?: string) =>
["plex-person", name, ...(lang ? [lang] : [])] as const,
};