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 + + + {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],