feat(i18n): the server's closed English vocabularies named in the reader's language (E30 S2)

- lib/vocab + the `vocab` locale area: TMDB crew jobs and departments, auto-tags (D1 A: the data
  stays English, the catalog is named at its one source, useTags), built-in download profiles,
  YouTube topics; held key-for-key to the backend's own lists by backendIds.contract.
- Countries by ISO code: TMDB parse stores {code, name}; rows stored before are coded from TMDB's
  own country list in one request (run_enrich); Intl names them, historic codes (YU, SU, XI…)
  from the catalog.
- Player tracks named from the language tag, forced/SDH flags and codec tail, not Plex's
  server-language displayTitle; no invented "Audio N" on the server.
- Download lifecycle notices (shared / expiring / expired / evicted) named from data; the share
  notice carries the title.
- Scheduler: the job result rides along as data and is named per key (dashboard + run notice).
- Invite status named; a render crash's notice no longer shows the engine's English message.
- Fix: YouTube links …/wiki/Humour, the tagger looked for "Humor" — Comedy never came from a topic.
This commit is contained in:
2026-09-29 21:21:19 +02:00
parent f6298d29af
commit f3a85916ec
50 changed files with 1475 additions and 94 deletions
+28 -15
View File
@@ -27,7 +27,7 @@ import {
Volume2,
VolumeX,
} from "lucide-react";
import { api, HttpError } from "../lib/api";
import { api, HttpError, type PlexTrackNaming } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import {
audioOrdForLang,
@@ -88,6 +88,7 @@ import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Slider } from "./ui/NativeControls";
import { formatUnit } from "../lib/format";
import { trackLabel } from "../lib/vocab";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
@@ -131,13 +132,16 @@ const DOUBLE_TAP_MS = 300;
type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?: boolean };
export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
// `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
// through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change
// rebuilt loadSession → re-ran the detail effect → reloaded the session from the STALE saved resume
// position (losing live progress). See the detail effect's deps.
const tRef = useRef(t);
tRef.current = t;
// E30: a track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)").
const audioName = (a: PlexTrackNaming) => trackLabel(t, a, "audio", i18n.language);
const subtitleName = (x: PlexTrackNaming) => trackLabel(t, x, "subtitle", i18n.language);
const qc = useQueryClient();
const [id, setId] = useState(itemId);
// Serve the player's title/summary/rich detail in the active language (E5 S6), matching the grid.
@@ -1238,16 +1242,21 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
audio: () => {
const ord = cycleAudio();
if (ord === undefined) return; // one track (or none): nothing to say
const label = detail?.audio_streams.find((a) => a.ord === (ord ?? 0))?.label;
flashOsd(`${t("plex.player.audio")} · ${label ?? "—"}`);
const track = detail?.audio_streams.find((a) => a.ord === (ord ?? 0));
flashOsd(
`${t("plex.player.audio")} · ${track ? trackLabel(t, track, "audio", i18n.language) : "—"}`,
);
},
subtitle: () => {
const ord = cycleSubtitle();
if (ord === undefined) return;
const track = ord == null ? null : detail?.subtitle_streams.find((x) => x.ord === ord);
const label =
ord == null
? t("plex.player.subOff")
: (detail?.subtitle_streams.find((x) => x.ord === ord)?.label ?? "—");
: track
? trackLabel(t, track, "subtitle", i18n.language)
: "—";
flashOsd(`${t("plex.player.subtitles")} · ${label}`);
},
info: () => setInfoOpen((v) => !v),
@@ -1309,6 +1318,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
cycleSubtitle,
flashOsd,
t,
i18n.language,
detail,
prevId,
nextId,
@@ -1336,6 +1346,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0;
// While scrubbing the head follows the finger (preview); otherwise it tracks playback.
const headPct = scrub != null && duration > 0 ? (scrub / duration) * 100 : pct;
// The tracks playing now, for the stats overlay.
const currentAudio = detail?.audio_streams.find((x) => x.ord === (audioOrd ?? 0));
const currentSub = detail?.subtitle_streams.find((x) => x.ord === subOrd);
// Portaled to <body>, like every other full-screen overlay here (Modal, PlayerModal, ChatDock,
// BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the
@@ -1412,7 +1425,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
kind="subtitles"
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset, subGen)}
srcLang={selectedSub.language ?? undefined}
label={selectedSub.label}
label={subtitleName(selectedSub)}
{...subTrackListeners}
default
/>
@@ -1466,7 +1479,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
) : (
<>
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
<span>{t("plex.player.subPreparing", { label: selectedSub.label })}</span>
<span>{t("plex.player.subPreparing", { label: subtitleName(selectedSub) })}</span>
</>
)}
</div>
@@ -1783,14 +1796,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
tone="media"
role="menuitemradio"
checked={(audioOrd ?? 0) === a.ord}
label={a.label}
label={audioName(a)}
onSelect={() => changeAudio(a.ord)}
className={`${TRACK_ROW} ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
>
<MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
<span className="truncate">{a.label}</span>
<span className="truncate">{audioName(a)}</span>
</PopoverItem>
))}
</div>
@@ -1815,14 +1828,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
tone="media"
role="menuitemradio"
checked={subOrd === sub.ord}
label={sub.label}
label={subtitleName(sub)}
onSelect={() => changeSubtitle(sub.ord)}
className={`${TRACK_ROW} ${
subOrd === sub.ord ? "text-accent" : "text-white"
}`}
>
<MenuCheck kind="one" on={subOrd === sub.ord} />
<span className="truncate">{sub.label}</span>
<span className="truncate">{subtitleName(sub)}</span>
</PopoverItem>
))}
</div>
@@ -1896,13 +1909,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
videoRef={videoRef}
hlsRef={hlsRef}
fragMeterRef={fragMeterRef}
audioLabel={
detail?.audio_streams.find((a) => a.ord === (audioOrd ?? 0))?.label ?? undefined
}
audioLabel={currentAudio ? audioName(currentAudio) : undefined}
subLabel={
subOrd == null
? t("plex.player.subOff")
: (detail?.subtitle_streams.find((x) => x.ord === subOrd)?.label ?? undefined)
: currentSub
? subtitleName(currentSub)
: undefined
}
playable={detail?.playable ?? ""}
hwRef={hwRef}