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
+6 -1
View File
@@ -190,6 +190,8 @@ function logParams(query: LogQuery, extra: Record<string, string | number | null
return s ? `?${s}` : "";
}
export type JobResult = { [key: string]: number | string | JobResult };
export interface SchedulerJob {
id: string;
interval_minutes: number;
@@ -198,7 +200,10 @@ export interface SchedulerJob {
status: "ok" | "error" | "skipped" | null;
last_started: string | null;
last_finished: string | null;
last_result: string | null;
last_result: string | null; // the English one-liner — the fallback for a job with no `result`
// The last result as data (E30): counts, a `skipped` / `reason` word, a nested dict per phase —
// named in the reader's language by lib/jobResult.
result: JobResult | null;
last_error: string | null;
// Live progress while running (null when idle or for jobs that don't report it).
// total is null for indeterminate progress (e.g. enrichment drains an unknown queue).
+15 -6
View File
@@ -73,7 +73,8 @@ export interface PlexTmdb {
keywords: string[];
crew: PlexTmdbCrew[];
studios: string[];
production_countries: string[];
// ISO code (null on a row enriched before E30 whose name TMDB's list lacked) + TMDB's English name.
production_countries: { code: string | null; name: string }[];
recommendations: { tmdb_id?: number | null; title?: string | null; in_library: boolean }[];
backdrops: string[];
}
@@ -132,6 +133,16 @@ export interface PlexMarker {
start_s: number;
end_s: number;
}
// What the player names an audio/subtitle track from (E30, `lib/vocab` trackLabel). Plex's own title
// (`label`) is in the Plex SERVER's language, so it is only the fallback for a track with no language tag.
export interface PlexTrackNaming {
ord: number;
label: string | null;
lang_tag?: string | null;
forced?: boolean;
hearing_impaired?: boolean;
detail?: string | null; // the codec tail, "AC3 5.1"
}
export interface PlexItemDetail {
id: string;
kind: "movie" | "episode";
@@ -157,15 +168,13 @@ export interface PlexItemDetail {
studio?: string | null;
tagline?: string | null;
markers: PlexMarker[];
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
audio_streams: (PlexTrackNaming & { language?: string | null; default: boolean })[];
// `text`=true → offered as a WebVTT <track>; image subs (PGS/VobSub) are text=false (hidden).
subtitle_streams: {
ord: number;
label: string;
subtitle_streams: (PlexTrackNaming & {
language?: string | null;
codec?: string;
text: boolean;
}[];
})[];
status: string;
position_seconds: number;
show_title?: string | null;
+41
View File
@@ -0,0 +1,41 @@
// @vitest-environment jsdom
//
// E30: a scheduler job's result in the reader's language, through the REAL catalog. The server's own
// one-liner was "recent={'channels_processed': 0, …}, deep={…}" — English keys and a Python repr.
import { afterEach, describe, expect, it } from "vitest";
import i18n, { setLanguage } from "../i18n";
import { jobResultText } from "./jobResult";
afterEach(() => setLanguage("en"));
describe("a job's result", () => {
it("names each count, and a phase's own counts inside it", () => {
setLanguage("hu");
const t = i18n.getFixedT("hu");
expect(jobResultText(t, { new: 3, failed: 0 })).toBe("új videó: 3 · sikertelen: 0");
expect(
jobResultText(t, {
recent: { channels_processed: 0, videos_added: 0, reason: "no service user" },
deep: { channels_processed: 2, videos_added: 5, reconciled_full: 1 },
}),
).toBe(
"friss (feldolgozott csatorna: 0 · hozzáadott videó: 0 · ok: nincs szolgáltatásfiók) · " +
"mély (feldolgozott csatorna: 2 · hozzáadott videó: 5 · teljesen egyeztetve: 1)",
);
});
it("names a skip reason; an upstream error stays its own text", () => {
const t = i18n.getFixedT("hu");
expect(jobResultText(t, { enriched: 0, skipped: "tmdb_api_key not set" })).toBe(
"kiegészítve: 0 · kihagyva: nincs TMDB API-kulcs",
);
expect(jobResultText(t, { error: "Plex returned 500" })).toBe("hiba: Plex returned 500");
});
it("shows a key or word it does not know as it is, readable", () => {
const t = i18n.getFixedT("hu");
expect(jobResultText(t, { brand_new_count: 3, skipped: "moon is full" })).toBe(
"brand new count: 3 · kihagyva: moon is full",
);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { TFunction } from "i18next";
import type { JobResult } from "./api";
import { formatNumber } from "./format";
import { vocabKey } from "./vocab";
/** A scheduler job's result in the reader's language (E30): "új videó: 3 · sikertelen: 0". The server
* stored "new=3, failed=0" — English keys, a Python dict repr for a nested phase. A count's key is
* named from `scheduler.result`, a `skipped` / `reason` word from `scheduler.skip`; an unnamed key or
* word shows as it is, and an `error` stays the upstream's own text (it is diagnostic). */
export function jobResultText(t: TFunction, result: JobResult): string {
return Object.entries(result)
.map(([key, value]) => {
const label = t(`scheduler.result.${key}`, { defaultValue: key.replace(/_/g, " ") });
if (typeof value === "number") return `${label}: ${formatNumber(value)}`;
if (typeof value === "string")
return key === "error"
? `${label}: ${value}`
: `${label}: ${t(`scheduler.skip.${vocabKey(value)}`, { defaultValue: value })}`;
return `${label} (${jobResultText(t, value)})`;
})
.join(" · ");
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "./api";
import { parseSort } from "./feedSort";
import { useTags } from "./useTags";
/** One narrowing the user has applied, with the label to show and the way to undo just this one. */
export interface ActiveFilter {
@@ -47,7 +48,7 @@ export function useActiveFilters(
setFilters: (f: FeedFilters) => void,
): ActiveFilter[] {
const { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
const tagsQuery = useTags();
const channelsQuery = useQuery({
queryKey: qk.channels(),
queryFn: api.channels,
+50
View File
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
//
// E30 (D1 A): the tag catalog as every reader sees it. A system (auto) tag is stored in English and
// named in the reader's language; a user's own tag stays as they typed it, even when it happens to be
// a vocabulary word.
import { describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { setLanguage } from "../i18n";
import { api, type Tag } from "./api";
import { useTags } from "./useTags";
const tag = (id: number, name: string, system: boolean): Tag => ({
id,
name,
system,
color: null,
category: "topic",
channel_count: 1,
});
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("the tag catalog", () => {
it("names an auto-tag in the reader's language, a user's tag as typed", async () => {
vi.spyOn(api, "tags").mockResolvedValue([
tag(1, "Health & Fitness", true),
tag(2, "Hungarian", true),
tag(3, "Gaming", false), // the user's own, spelled like a vocabulary word
tag(4, "Somethingnew", true), // an auto-tag the catalog does not name yet
]);
setLanguage("hu");
try {
const { result } = renderHook(() => useTags(), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data!.map((tg) => tg.name)).toEqual([
"Egészség és fitnesz",
"Magyar",
"Gaming",
"Somethingnew",
]);
} finally {
setLanguage("en");
}
});
});
+20
View File
@@ -0,0 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type Tag } from "./api";
import { qk } from "./queryKeys";
import { vocab } from "./vocab";
/** The tag catalog, every reader's ONE way to it: a system (auto) tag is stored under its English name
* ("Health & Fitness", "Hungarian") and named here in the reader's language (E30, decision D1 A — the
* data stays language-neutral, filters and saved views hold ids). A user's own tag is theirs, as typed.
* Naming it once, at the source, is what reaches every chip, column, menu and sort that shows one. */
export function useTags(opts: { enabled?: boolean; staleTime?: number } = {}) {
const { t } = useTranslation();
return useQuery({
queryKey: qk.tags(),
queryFn: api.tags,
...opts,
select: (tags: Tag[]) =>
tags.map((tg) => (tg.system ? { ...tg, name: vocab(t, "autoTag", tg.name) } : tg)),
});
}
+115
View File
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
//
// E30: the server's closed English vocabularies, named in the reader's language — through the REAL
// catalog, so a missing or misnamed key shows here as the English word coming through.
import { describe, expect, it } from "vitest";
import i18n from "../i18n";
import { languageName, profileName, regionName, trackLabel, vocab } from "./vocab";
const hu = i18n.getFixedT("hu");
const en = i18n.getFixedT("en");
describe("a vocabulary word", () => {
it.each([
["crewJob", "Director of Photography", "Operatőr"],
["crewJob", "Executive Producer", "Vezető producer"],
["department", "Acting", "Színészet"],
["autoTag", "Health & Fitness", "Egészség és fitnesz"],
["autoTag", "Hungarian", "Magyar"],
["profile", "Video only (MP4)", "Csak videó (MP4)"],
["topic", "Lifestyle_(sociology)", "Életmód"],
] as const)("%s %j reads %j in Hungarian", (group, word, shown) => {
expect(vocab(hu, group, word)).toBe(shown);
});
it("the English reader sees the word as it was", () => {
expect(vocab(en, "crewJob", "Director of Photography")).toBe("Director of Photography");
expect(vocab(en, "topic", "Pop_music", "Pop music")).toBe("Pop music");
});
it("an unknown word is shown, not hidden or keyed", () => {
expect(vocab(hu, "crewJob", "Gaffer")).toBe("Gaffer");
expect(vocab(hu, "topic", "Knitting_(craft)", "Knitting (craft)")).toBe("Knitting (craft)");
});
});
describe("a country", () => {
it("is named from its code in the reader's language", () => {
expect(regionName(hu, "US", "hu", "United States of America")).toBe("Egyesült Államok");
expect(regionName(en, "DE", "en", "Germany")).toBe("Germany");
});
it("that no longer exists keeps its own name — Intl would call Yugoslavia Serbia", () => {
expect(regionName(hu, "YU", "hu", "Yugoslavia")).toBe("Jugoszlávia");
expect(regionName(hu, "SU", "hu", "Soviet Union")).toBe("Szovjetunió");
expect(regionName(en, "XI", "en", "Northern Ireland")).toBe("Northern Ireland");
});
it("without a code shows the stored name", () => {
expect(regionName(hu, null, "hu", "Hungary")).toBe("Hungary");
});
});
describe("a language", () => {
it("stands alone capitalized, region kept", () => {
expect(languageName("hu", "hu")).toBe("Magyar");
expect(languageName("en-US", "en")).toBe("American English");
});
it("falls back to what was given for a tag Intl cannot name", () => {
expect(languageName("qqq", "hu", "Klingon")).toBe("Klingon");
expect(languageName("und", "hu", "Unknown")).toBe("Unknown"); // "undetermined": no language
});
});
describe("a player track", () => {
const track = { ord: 0, label: "English (AC3 5.1)", lang_tag: "en-US", detail: "AC3 5.1" };
it("is named from its language, not Plex's server-language title", () => {
expect(trackLabel(hu, track, "audio", "hu")).toBe("Angol (AC3 5.1)");
expect(trackLabel(en, track, "audio", "en")).toBe("English (AC3 5.1)");
});
it("says forced / SDH and translates the channel word", () => {
expect(
trackLabel(
hu,
{ ord: 1, label: "magyar Forced", lang_tag: "hu", forced: true },
"subtitle",
"hu",
),
).toBe("Magyar, kényszerített");
expect(
trackLabel(
hu,
{ ord: 2, label: "中文", lang_tag: "zh", detail: "EAC3 Stereo" },
"audio",
"hu",
),
).toBe("Kínai (EAC3 sztereó)");
expect(
trackLabel(
en,
{ ord: 3, label: "English SDH", lang_tag: "en", hearing_impaired: true },
"subtitle",
"en",
),
).toBe("English, SDH");
});
it("with no language keeps Plex's title, and with nothing is numbered", () => {
expect(trackLabel(hu, { ord: 0, label: "Commentary" }, "audio", "hu")).toBe("Commentary");
expect(trackLabel(hu, { ord: 2, label: null, lang_tag: "und" }, "audio", "hu")).toBe(
"3. hangsáv",
);
expect(trackLabel(hu, { ord: 1, label: null }, "subtitle", "hu")).toBe("2. felirat");
expect(trackLabel(en, { ord: 0, label: null }, "audio", "en")).toBe("Audio 1");
});
});
describe("a download profile", () => {
it("built in, is named in the reader's language; the user's own stays as typed", () => {
expect(profileName(hu, { name: "Audio (MP3)", is_builtin: true })).toBe("Hang (MP3)");
expect(profileName(hu, { name: "Audio (MP3)", is_builtin: false })).toBe("Audio (MP3)");
});
});
+83
View File
@@ -0,0 +1,83 @@
import type { TFunction } from "i18next";
import type { PlexTrackNaming } from "./api";
// E30: the closed vocabularies the server stores in ENGLISH — a TMDB crew job or department, an
// auto-tag, a built-in download profile, a YouTube topic — named in the reader's language. The data
// stays language-neutral (it is filtered and matched on); only the NAME is translated, here, from the
// `vocab` locale area. A word the area does not hold shows as stored, so a new server value is never
// hidden — and the backend's own lists are held key-for-key to the area by backendIds.contract.
type Group = "crewJob" | "department" | "autoTag" | "profile" | "topic";
/** A vocabulary word's key: its letters and digits ("Health & Fitness" → "HealthFitness"). */
export function vocabKey(word: string): string {
return word.replace(/[^A-Za-z0-9]/g, "");
}
/** One stored English word of `group`, in the reader's language (`fallback`, by default the word
* itself, when the area does not hold it). */
export function vocab(t: TFunction, group: Group, word: string, fallback = word): string {
const key = vocabKey(word);
return key ? t(`vocab.${group}.${key}`, { defaultValue: fallback }) : fallback;
}
/** A download profile's name: a built-in one is seeded under an English name and named here; a user's
* own is theirs, as typed. */
export function profileName(t: TFunction, p: { name: string; is_builtin: boolean }): string {
return p.is_builtin ? vocab(t, "profile", p.name) : p.name;
}
function intlName(code: string, lang: string, type: "region" | "language"): string | undefined {
try {
return new Intl.DisplayNames([lang], { type, fallback: "none" }).of(code);
} catch {
return undefined; // not a well-formed code
}
}
/** A country from its ISO 3166 code. A code no longer in use (Yugoslavia, the Soviet Union) is
* named from `vocab.region` — Intl renames those to today's state (YU → Serbia) or cannot name them
* (XI, TMDB's Northern Ireland); `fallback` (the stored English name) when neither can. */
export function regionName(
t: TFunction,
code: string | null | undefined,
lang: string,
fallback = "",
): string {
if (!code) return fallback;
const up = code.toUpperCase();
return (
t(`vocab.region.${up}`, { defaultValue: "" }) || intlName(up, lang, "region") || fallback || up
);
}
/** A language from its BCP 47 tag ("en-US" → "American English"), capitalized to stand alone as a
* label ("magyar" → "Magyar"); `fallback` for a tag Intl cannot name ("und", a typo). */
export function languageName(tag: string | null | undefined, lang: string, fallback = ""): string {
const name = tag ? intlName(tag.replace(/_/g, "-"), lang, "language") : undefined;
if (!name || name === tag) return fallback;
return name.charAt(0).toLocaleUpperCase(lang) + name.slice(1);
}
/** An audio or subtitle track's name in the reader's language: "Angol (AC3 5.1)", "Magyar,
* kényszerített". Built from the language tag; a track without one keeps Plex's own title, and a
* track with neither is numbered. */
export function trackLabel(
t: TFunction,
track: PlexTrackNaming,
kind: "audio" | "subtitle",
lang: string,
): string {
// The language alone: Plex tags one track "en-US" and the next "en", and both are just English here.
const name = languageName(track.lang_tag?.split(/[-_]/)[0], lang);
if (!name) return track.label || t(`vocab.track.${kind}N`, { n: track.ord + 1 });
const flags = [
track.forced ? t("vocab.track.forced") : null,
track.hearing_impaired ? t("vocab.track.sdh") : null,
].filter(Boolean);
// The codec tail is technical and language-neutral but for its channel word ("AC3 Stereo").
const detail = track.detail
?.replace(/\bStereo\b/i, t("vocab.track.stereo"))
.replace(/\bMono\b/i, t("vocab.track.mono"));
return [name, ...flags].join(", ") + (detail ? ` (${detail})` : "");
}