diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx
index 81eb2b12..74e5be65 100644
--- a/frontend/src/components/PlexBrowse.tsx
+++ b/frontend/src/components/PlexBrowse.tsx
@@ -15,6 +15,7 @@ import SortControl from "./SortControl";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
+import { plexLang } from "../i18n";
import {
useInfiniteQuery,
useQuery,
@@ -117,6 +118,9 @@ export default function PlexBrowse() {
const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
const { setScope, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
const dq = useDebounced(q.trim(), 350);
+ // Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so
+ // a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch.
+ const lang = plexLang();
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
const plexChips = usePlexActiveFilters();
@@ -191,7 +195,7 @@ export default function PlexBrowse() {
}, [sub]);
const browseQ = useInfiniteQuery({
- queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState),
+ queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang),
enabled: !!scope && sub.view.kind === "grid",
initialPageParam: 0,
queryFn: ({ pageParam }) =>
@@ -201,6 +205,7 @@ export default function PlexBrowse() {
field: fieldState,
sort: sortState,
show,
+ lang,
filters,
offset: pageParam as number,
limit: PAGE,
@@ -211,6 +216,17 @@ export default function PlexBrowse() {
},
});
+ // Genre chips are language-specific strings (the HU column stores "Dráma", the EN one "Drama"), so a
+ // selection made in one language matches nothing against the other's column and would silently empty
+ // the grid. Drop the genre selection when the language flips (only on an actual change, not mount).
+ const prevLangRef = useRef(lang);
+ useEffect(() => {
+ if (prevLangRef.current !== lang) {
+ prevLangRef.current = lang;
+ if (filters.genres.length) setFilters({ ...filters, genres: [] });
+ }
+ }, [lang, filters, setFilters]);
+
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
const total = browseQ.data?.pages[0]?.total ?? 0;
// Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the
@@ -690,7 +706,8 @@ function PlexInfoView({
onOpenPerson: (name: string) => void;
}) {
const { t } = useTranslation();
- const q = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) });
+ const lang = plexLang();
+ const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
return (
@@ -1302,7 +1319,8 @@ function PlexShowView({
const { t } = useTranslation();
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
- const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) });
+ const lang = plexLang();
+ const q = useQuery({ queryKey: qk.plexShow(showId, lang), queryFn: () => api.plexShow(showId, lang) });
const d = q.data;
const [addTarget, setAddTarget] = useState
(null);
const [collOpen, setCollOpen] = useState(false);
@@ -1533,7 +1551,8 @@ function PlexSeasonView({
}) {
const { t } = useTranslation();
const qc = useQueryClient();
- const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) });
+ const lang = plexLang();
+ const q = useQuery({ queryKey: qk.plexShow(showId, lang), queryFn: () => api.plexShow(showId, lang) });
const d = q.data;
const se = d?.seasons.find((s) => s.id === seasonId);
const [addTarget, setAddTarget] = useState(null);
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx
index d320bb85..a76c670b 100644
--- a/frontend/src/components/PlexInfo.tsx
+++ b/frontend/src/components/PlexInfo.tsx
@@ -195,37 +195,42 @@ export default function PlexInfo({
{detail.studio}
)}
- {detail.imdb_rating != null && (
- // decorative rating gold — intentionally NOT a status token
-
-
- {detail.imdb_url && (
- {
+ // IMDb score when we have one (with the IMDb link); otherwise the Plex audience
+ // score (no IMDb link/branding, so a non-IMDb score is never mislabelled as IMDb).
+ const score = detail.imdb_rating ?? detail.rating;
+ if (score == null) return null;
+ const isImdb = detail.imdb_rating != null;
+ return (
+ // decorative rating gold — intentionally NOT a status token
+
+
- )}
-
- )}
+
+ {score.toFixed(1)}
+
+ {isImdb && detail.imdb_url && (
+
+
+
+ )}
+
+ );
+ })()}
{detail.tagline && (
diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx
index 1133e502..a1a051b1 100644
--- a/frontend/src/components/PlexSidebar.tsx
+++ b/frontend/src/components/PlexSidebar.tsx
@@ -1,6 +1,7 @@
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
+import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
@@ -80,9 +81,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
onOpenPlaylist(pl.id);
}
const { collectionTitle: _ct, ...facetKey } = filters;
+ const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch
const facetsQ = useQuery({
- queryKey: qk.plexFacets(scope, show, facetKey),
- queryFn: () => api.plexFacets(scope, filters, show),
+ queryKey: qk.plexFacets(scope, show, facetKey, lang),
+ queryFn: () => api.plexFacets(scope, filters, show, lang),
enabled: !!scope,
placeholderData: (prev) => prev,
});
diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts
index ee0cd262..fca55657 100644
--- a/frontend/src/i18n/index.ts
+++ b/frontend/src/i18n/index.ts
@@ -56,4 +56,11 @@ export function setLanguage(code: LangCode): void {
document.documentElement.lang = code;
}
+// The Plex overlay serves only EN or HU metadata (E5 S6). Collapse the active UI language to one of
+// those for the `?lang=` param / query keys. Components read `i18n.language` off useTranslation (which
+// re-renders on switch); this singleton reader is for non-component code (e.g. plexPlayerHooks).
+export function plexLang(): "en" | "hu" {
+ return i18n.language?.startsWith("hu") ? "hu" : "en";
+}
+
export default i18n;
diff --git a/frontend/src/i18n/parity.test.ts b/frontend/src/i18n/parity.test.ts
new file mode 100644
index 00000000..72369f7c
--- /dev/null
+++ b/frontend/src/i18n/parity.test.ts
@@ -0,0 +1,52 @@
+// E5 S6: every locale namespace must carry the SAME key set in English and Hungarian. A key present
+// in one language but not the other renders the raw key string (missing translation) or is dead
+// weight (orphaned translation); this test fails loudly on either, per namespace.
+import { describe, expect, it } from "vitest";
+
+const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record<
+ string,
+ { default: Record
}
+>;
+
+function flatten(obj: Record, prefix = ""): string[] {
+ const out: string[] = [];
+ for (const [k, v] of Object.entries(obj)) {
+ const key = prefix ? `${prefix}.${k}` : k;
+ if (v && typeof v === "object" && !Array.isArray(v)) {
+ out.push(...flatten(v as Record, key));
+ } else {
+ out.push(key);
+ }
+ }
+ return out.sort();
+}
+
+// { namespace: { en: {...}, hu: {...} } }
+const byNs: Record>> = {};
+for (const [path, mod] of Object.entries(mods)) {
+ const m = path.match(/\/locales\/([^/]+)\/([^/]+)\.json$/);
+ const lang = m?.[1];
+ const ns = m?.[2];
+ if (!lang || !ns) continue;
+ (byNs[ns] ??= {})[lang] = mod.default;
+}
+
+describe("i18n en/hu key parity", () => {
+ it("loaded both languages for every namespace", () => {
+ expect(Object.keys(byNs).length).toBeGreaterThan(0);
+ for (const [ns, langs] of Object.entries(byNs)) {
+ expect({ ns, langs: Object.keys(langs).sort() }).toEqual({ ns, langs: ["en", "hu"] });
+ }
+ });
+
+ for (const [ns, langs] of Object.entries(byNs)) {
+ it(`${ns}: en and hu declare identical keys`, () => {
+ const en = flatten(langs.en ?? {});
+ const hu = flatten(langs.hu ?? {});
+ expect({
+ missingInHu: en.filter((k) => !hu.includes(k)),
+ missingInEn: hu.filter((k) => !en.includes(k)),
+ }).toEqual({ missingInHu: [], missingInEn: [] });
+ });
+ }
+});
diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts
index 4289d9b2..b411ca67 100644
--- a/frontend/src/lib/api/plex.ts
+++ b/frontend/src/lib/api/plex.ts
@@ -121,6 +121,7 @@ export interface PlexItemDetail {
imdb_rating?: number | null;
imdb_id?: string | null;
imdb_url?: string | null;
+ rating?: number | null; // Plex audience score — shown when there's no IMDb score
content_rating?: string | null;
genres: string[];
directors: string[];
@@ -285,6 +286,7 @@ export const plexApi = {
field?: PlexSearchField; // search scope-pill (all | title | people | summary | episode)
sort?: SortState;
show?: string;
+ lang?: string; // active UI language: "hu" serves the HU overlay twin, else EN (default)
offset?: number;
limit?: number;
filters?: PlexFilters;
@@ -297,16 +299,18 @@ export const plexApi = {
u.set("sort_dir", p.sort.dir);
}
if (p.show && p.show !== "all") u.set("show", p.show);
+ if (p.lang === "hu") u.set("lang", "hu"); // en is the server default; omit to keep URLs stable
if (p.offset) u.set("offset", String(p.offset));
if (p.limit) u.set("limit", String(p.limit));
if (p.filters) appendPlexFilters(u, p.filters);
return req(`/api/plex/library?${u.toString()}`);
},
// Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group
- // narrows the others and the bounds match the visible result set.
- plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise => {
+ // narrows the others and the bounds match the visible result set. `lang` picks the genre vocabulary.
+ plexFacets: (scope: string, f?: PlexFilters, show?: string, lang?: string): Promise => {
const u = new URLSearchParams({ scope });
if (show && show !== "all") u.set("show", show);
+ if (lang === "hu") u.set("lang", "hu");
if (f) appendPlexFilters(u, f);
return req(`/api/plex/facets?${u.toString()}`);
},
@@ -400,8 +404,8 @@ export const plexApi = {
method: "PUT",
body: JSON.stringify({ item_rating_keys: itemRks }),
}),
- plexShow: (id: string): Promise =>
- req(`/api/plex/show/${encodeURIComponent(id)}`),
+ plexShow: (id: string, lang?: string): Promise =>
+ req(`/api/plex/show/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`),
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, {
@@ -413,8 +417,8 @@ export const plexApi = {
method: "POST",
body: JSON.stringify({ watched }),
}),
- plexItem: (id: string): Promise =>
- req(`/api/plex/item/${encodeURIComponent(id)}`),
+ plexItem: (id: string, lang?: string): Promise =>
+ req(`/api/plex/item/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`),
// Cards for the actor filter-chips currently applied: name + cached headshot + in-scope film/show count.
plexPeopleCards: (names: string[], scope: string): Promise<{ people: PlexPersonCard[] }> =>
req(
diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts
index 8c684a55..91baf42a 100644
--- a/frontend/src/lib/plexPlayerHooks.ts
+++ b/frontend/src/lib/plexPlayerHooks.ts
@@ -17,6 +17,7 @@ import {
} from "./fullscreen";
import { api, type PlexItemDetail, type PlexMarker } from "./api";
import { qk } from "./queryKeys";
+import { plexLang } from "../i18n";
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
@@ -126,7 +127,10 @@ export function usePlexProgress(opts: {
/* eslint-disable react-hooks/exhaustive-deps */
if (absRef.current > 0 && durationRef.current > 0) {
const pos = Math.floor(absRef.current);
- qc.setQueryData(qk.plexItem(id), (old) => (old ? { ...old, position_seconds: pos } : old));
+ // Match the language-keyed detail query (E5 S6) so the optimistic resume patch lands.
+ qc.setQueryData(qk.plexItem(id, plexLang()), (old) =>
+ old ? { ...old, position_seconds: pos } : old,
+ );
}
/* eslint-enable react-hooks/exhaustive-deps */
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts
index 5e11e1a4..145569c3 100644
--- a/frontend/src/lib/queryKeys.ts
+++ b/frontend/src/lib/queryKeys.ts
@@ -92,9 +92,13 @@ export const qk = {
plexWatchLink: () => ["plex-watch-link"] as const,
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest],
- plexShow: (showId?: string) =>
- showId !== undefined ? (["plex-show", showId] as const) : (["plex-show"] as const),
- plexItem: (id: string) => ["plex-item", id] as const,
+ // Optional trailing `lang` (E5 S6): a language switch remounts the detail with a new key so HU/EN
+ // metadata is cached separately. Omitting it (e.g. for a prefix invalidation) still matches both.
+ plexShow: (showId?: string, lang?: string) =>
+ showId !== undefined
+ ? (["plex-show", showId, ...(lang ? [lang] : [])] as const)
+ : (["plex-show"] as const),
+ plexItem: (id: string, lang?: string) => ["plex-item", id, ...(lang ? [lang] : [])] as const,
plexPlaylists: (...rest: unknown[]) => ["plex-playlists", ...rest],
plexPlaylist: (playlistId: number) => ["plex-playlist", playlistId] as const,
plexCollections: (...rest: unknown[]) => ["plex-collections", ...rest],