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:
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(" · ");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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})` : "");
|
||||
}
|
||||
Reference in New Issue
Block a user