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
+12 -20
View File
@@ -3,35 +3,27 @@ import { ExternalLink } from "lucide-react";
import type { ChannelDetail } from "../lib/api";
import { linkify } from "../lib/linkify";
import { Badge } from "./ui/Badge";
import { languageName, regionName, vocab } from "../lib/vocab";
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
function displayName(code: string, lang: string, type: "region" | "language"): string {
try {
return (
new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ??
code
);
} catch {
return code;
}
}
// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play".
function parseKeywords(s: string): string[] {
return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean);
}
// topicCategories are Wikipedia URLs (…/wiki/Music) → a readable label, de-duplicated.
function topicLabels(urls: string[]): string[] {
// topicCategories are Wikipedia URLs (…/wiki/Music) → the page slug ("Pop_music"), de-duplicated. The
// slug is the vocabulary word (E30): named in the reader's language, else shown with spaces.
function topicSlugs(urls: string[]): string[] {
const seen = new Set<string>();
for (const url of urls) {
const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url;
let label = seg;
let slug = seg;
try {
label = decodeURIComponent(seg);
slug = decodeURIComponent(seg);
} catch {
/* keep raw */
}
label = label.replace(/_/g, " ").trim();
if (label) seen.add(label);
slug = slug.trim();
if (slug) seen.add(slug);
}
return [...seen];
}
@@ -43,7 +35,7 @@ function topicLabels(urls: string[]): string[] {
*/
export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
const { t, i18n } = useTranslation();
const topics = ch.topic_categories?.length ? topicLabels(ch.topic_categories) : [];
const topics = ch.topic_categories?.length ? topicSlugs(ch.topic_categories) : [];
const keywords = ch.keywords ? parseKeywords(ch.keywords) : [];
const hasAboutDetails =
!!ch.country || !!ch.default_language || topics.length > 0 || keywords.length > 0;
@@ -86,14 +78,14 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
aria-hidden
/>
)}
{displayName(ch.country, i18n.language, "region")}
{regionName(t, ch.country, i18n.language)}
</span>
</div>
)}
{ch.default_language && (
<div className="flex gap-2">
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
<span>{displayName(ch.default_language, i18n.language, "language")}</span>
<span>{languageName(ch.default_language, i18n.language, ch.default_language)}</span>
</div>
)}
{topics.length > 0 && (
@@ -102,7 +94,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
<div className="flex flex-wrap gap-1.5">
{/* Future search epic: these topic chips become clickable filters. */}
{topics.map((tp) => (
<Badge key={tp}>{tp}</Badge>
<Badge key={tp}>{vocab(t, "topic", tp, tp.replace(/_/g, " "))}</Badge>
))}
</div>
</div>