feat(metadata): add original-language preferences (#526)

* feat(metadata): add original-language preferences

* fix(settings): show metadata exceptions immediately

* docs(settings): add metadata language screenshot

* fix(settings): make language exceptions responsive

* fix(settings): standardize language display names
This commit is contained in:
Quick
2026-07-31 15:23:30 -04:00
committed by GitHub
parent d70b291bb8
commit 73488d1bfa
46 changed files with 1572 additions and 437 deletions
@@ -0,0 +1,133 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ORIGINAL_METADATA_LANGUAGE } from "@/lib/metadataLanguagePreferences";
import { MetadataLanguageSetting } from "./MetadataLanguageSetting";
const languageOptions = [
{ value: "en", label: "English" },
{ value: "ja", label: "Japanese" },
{ value: "no", label: "Norwegian" },
];
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver;
window.HTMLElement.prototype.hasPointerCapture ??= () => false;
window.HTMLElement.prototype.scrollIntoView ??= () => {};
describe("MetadataLanguageSetting", () => {
afterEach(cleanup);
it("shows a source-language exception and removes only that rule", () => {
const onOverridesChange = vi.fn();
render(
<MetadataLanguageSetting
fallback="en"
overrides={{ no: ORIGINAL_METADATA_LANGUAGE, ja: "en" }}
languageOptions={languageOptions}
onFallbackChange={vi.fn()}
onOverridesChange={onOverridesChange}
/>,
);
expect(screen.getByText("Norwegian")).toBeTruthy();
expect(screen.getByText("Japanese")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Remove Norwegian exception" }));
expect(onOverridesChange).toHaveBeenCalledWith({ ja: "en" });
});
it("keeps the add action disabled until an original language is chosen", () => {
render(
<MetadataLanguageSetting
fallback={ORIGINAL_METADATA_LANGUAGE}
overrides={{}}
languageOptions={languageOptions}
onFallbackChange={vi.fn()}
onOverridesChange={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Add exception" }).hasAttribute("disabled")).toBe(
true,
);
expect(screen.getByLabelText("Original language for new exception")).toBeTruthy();
});
it("shows a new exception immediately while its save is still pending", async () => {
const user = userEvent.setup();
const onOverridesChange = vi.fn(() => new Promise<void>(() => {}));
render(
<MetadataLanguageSetting
fallback="en"
overrides={{}}
languageOptions={languageOptions}
onFallbackChange={vi.fn()}
onOverridesChange={onOverridesChange}
/>,
);
await user.click(screen.getByLabelText("Original language for new exception"));
await user.click(screen.getByRole("option", { name: "Norwegian" }));
await user.click(screen.getByRole("button", { name: "Add exception" }));
expect(screen.getByLabelText("Metadata language for Norwegian")).toHaveTextContent(
"Original language",
);
expect(onOverridesChange).toHaveBeenCalledWith({ no: ORIGINAL_METADATA_LANGUAGE });
});
it("rolls back an optimistic exception when its save fails", async () => {
const user = userEvent.setup();
let rejectSave: (reason: Error) => void = () => {};
const onOverridesChange = vi.fn(
() =>
new Promise<void>((_, reject) => {
rejectSave = reject;
}),
);
render(
<MetadataLanguageSetting
fallback="en"
overrides={{}}
languageOptions={languageOptions}
onFallbackChange={vi.fn()}
onOverridesChange={onOverridesChange}
/>,
);
await user.click(screen.getByLabelText("Original language for new exception"));
await user.click(screen.getByRole("option", { name: "Norwegian" }));
await user.click(screen.getByRole("button", { name: "Add exception" }));
expect(screen.getByLabelText("Metadata language for Norwegian")).toBeInTheDocument();
rejectSave(new Error("save failed"));
await waitFor(() =>
expect(screen.queryByLabelText("Metadata language for Norwegian")).not.toBeInTheDocument(),
);
});
it("keeps a custom target selectable when it is outside the advisory catalog", () => {
render(
<MetadataLanguageSetting
fallback="en"
overrides={{ no: "pt-BR" }}
languageOptions={languageOptions}
onFallbackChange={vi.fn()}
onOverridesChange={vi.fn()}
/>,
);
expect(screen.getByLabelText("Metadata language for Norwegian").textContent).toContain(
"Portuguese",
);
});
});
@@ -0,0 +1,208 @@
import { useMemo, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { SettingRow } from "@/components/settings/SettingRow";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { withCurrentLanguageOption, type SettingOption } from "@/lib/languageOptions";
import { getLanguageName } from "@/lib/languageNames";
import {
ORIGINAL_METADATA_LANGUAGE,
withMetadataLanguageOverride,
withoutMetadataLanguageOverride,
type MetadataLanguageOverrides,
} from "@/lib/metadataLanguagePreferences";
interface MetadataLanguageSettingProps {
fallback: string | null;
overrides: MetadataLanguageOverrides;
languageOptions: readonly SettingOption[];
disabled?: boolean;
onFallbackChange: (language: string | null) => void;
onOverridesChange: (overrides: MetadataLanguageOverrides) => void | Promise<void>;
}
const NO_PREFERENCE = "__library_default";
function overridesEqual(
left: MetadataLanguageOverrides,
right: MetadataLanguageOverrides,
): boolean {
const leftEntries = Object.entries(left);
return (
leftEntries.length === Object.keys(right).length &&
leftEntries.every(([source, target]) => right[source] === target)
);
}
export function MetadataLanguageSetting({
fallback,
overrides,
languageOptions,
disabled = false,
onFallbackChange,
onOverridesChange,
}: MetadataLanguageSettingProps) {
const [newSource, setNewSource] = useState("");
const [optimisticOverrides, setOptimisticOverrides] = useState<{
base: MetadataLanguageOverrides;
value: MetadataLanguageOverrides;
} | null>(null);
const currentOverrides =
optimisticOverrides !== null && overridesEqual(overrides, optimisticOverrides.base)
? optimisticOverrides.value
: overrides;
const entries = useMemo(
() =>
Object.entries(currentOverrides).sort(([left], [right]) =>
getLanguageName(left).localeCompare(getLanguageName(right)),
),
[currentOverrides],
);
const namedOptions = useMemo(
() => languageOptions.filter((language) => language.value !== ORIGINAL_METADATA_LANGUAGE),
[languageOptions],
);
const availableSources = namedOptions.filter((language) => !(language.value in currentOverrides));
const optionsForTarget = (target: string) => {
if (target === ORIGINAL_METADATA_LANGUAGE) return namedOptions;
return withCurrentLanguageOption(namedOptions, target);
};
const changeOverrides = async (next: MetadataLanguageOverrides) => {
const pending = { base: overrides, value: next };
setOptimisticOverrides(pending);
try {
await onOverridesChange(next);
} catch {
setOptimisticOverrides((current) => (current === pending ? null : current));
}
};
const addException = () => {
if (!newSource) return;
void changeOverrides(
withMetadataLanguageOverride(currentOverrides, newSource, ORIGINAL_METADATA_LANGUAGE),
);
setNewSource("");
};
return (
<SettingRow
label="Metadata language"
description="Choose the fallback for titles and descriptions, then add exceptions based on each item's original language. Missing descriptions can be translated automatically when AI translation is enabled."
control={(id) => (
<div className="w-full min-w-0 space-y-3 md:w-[430px]">
<Select
value={fallback ?? NO_PREFERENCE}
onValueChange={(value) => onFallbackChange(value === NO_PREFERENCE ? null : value)}
>
<SelectTrigger id={id} className="w-full" disabled={disabled}>
<SelectValue placeholder="Library default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PREFERENCE}>Library default</SelectItem>
<SelectItem value={ORIGINAL_METADATA_LANGUAGE}>Original language</SelectItem>
{namedOptions.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
{entries.length > 0 ? (
<div className="border-border/60 divide-border/60 divide-y border-y">
{entries.map(([source, target]) => (
<div
key={source}
className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)_auto]"
>
<span className="truncate text-sm">{getLanguageName(source)}</span>
<Select
value={target}
onValueChange={(value) =>
void changeOverrides(
withMetadataLanguageOverride(currentOverrides, source, value),
)
}
>
<SelectTrigger
aria-label={`Metadata language for ${getLanguageName(source)}`}
className="col-span-2 col-start-1 row-start-2 h-9 w-full sm:col-span-1 sm:col-start-2 sm:row-start-1 sm:h-8"
disabled={disabled}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ORIGINAL_METADATA_LANGUAGE}>Original language</SelectItem>
{optionsForTarget(target).map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="col-start-2 row-start-1 size-9 sm:col-start-3 sm:size-8"
disabled={disabled}
aria-label={`Remove ${getLanguageName(source)} exception`}
onClick={() =>
void changeOverrides(
withoutMetadataLanguageOverride(currentOverrides, source),
)
}
>
<Trash2 className="size-3.5" aria-hidden="true" />
</Button>
</div>
))}
</div>
) : null}
{availableSources.length > 0 ? (
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<Select value={newSource} onValueChange={setNewSource}>
<SelectTrigger
aria-label="Original language for new exception"
className="h-10 w-full min-w-0 flex-1 sm:h-9 sm:w-auto"
disabled={disabled}
>
<SelectValue placeholder="Choose original language" />
</SelectTrigger>
<SelectContent>
{availableSources.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
className="h-10 w-full sm:h-8 sm:w-auto"
disabled={disabled || !newSource}
onClick={addException}
>
<Plus className="mr-1.5 size-3.5" aria-hidden="true" />
Add exception
</Button>
</div>
) : null}
</div>
)}
/>
);
}
+1 -34
View File
@@ -1,34 +1 @@
// Catalog filter values for `original_language` are ISO 639-1 codes ("en",
// "fr", "ja"). Surfaces show friendly names ("English", "French", "Japanese")
// while keeping the underlying value untouched so query rules stay valid.
let cachedDisplayNames: Intl.DisplayNames | null | undefined;
function getDisplayNames(): Intl.DisplayNames | null {
if (cachedDisplayNames !== undefined) {
return cachedDisplayNames;
}
try {
cachedDisplayNames = new Intl.DisplayNames(["en"], { type: "language" });
} catch {
cachedDisplayNames = null;
}
return cachedDisplayNames;
}
export function formatLanguage(code: string): string {
const trimmed = code.trim();
if (!trimmed) return "";
const dn = getDisplayNames();
if (!dn) return trimmed.toUpperCase();
try {
const name = dn.of(trimmed);
if (name && name !== trimmed) {
// Intl.DisplayNames returns lowercase for some locales — capitalize.
return name.charAt(0).toUpperCase() + name.slice(1);
}
} catch {
// Fall through to fallback.
}
return trimmed.toUpperCase();
}
export { formatLanguage } from "@/lib/languageNames";
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
canonicalLanguageTag,
englishLanguageName,
getLanguageName,
normalizeLanguageCode,
} from "./languageNames";
describe("languageNames", () => {
it("uses bundled English CLDR names for ISO 639-1 and ISO 639-3 values", () => {
expect(englishLanguageName("sa")).toBe("Sanskrit");
expect(englishLanguageName("se")).toBe("Northern Sami");
expect(englishLanguageName("sm")).toBe("Samoan");
expect(englishLanguageName("yue")).toBe("Cantonese");
});
it("preserves region and script specificity in names", () => {
expect(englishLanguageName("pt-BR")).toBe("Brazilian Portuguese");
expect(englishLanguageName("sr-Latn")).toBe("Serbian (Latin)");
});
it("canonicalizes ISO aliases without changing regional identity", () => {
expect(canonicalLanguageTag("eng")).toBe("en");
expect(canonicalLanguageTag("pt_BR")).toBe("pt-BR");
expect(normalizeLanguageCode("fre-CA")).toBe("fr");
});
it("distinguishes an unassigned tag from a translated language name", () => {
expect(englishLanguageName("xx")).toBeNull();
expect(getLanguageName("xx")).toBe("Unknown language (xx)");
});
});
+90
View File
@@ -0,0 +1,90 @@
import "@formatjs/intl-displaynames/polyfill-force.js";
import "@formatjs/intl-displaynames/locale-data/en.js";
const englishLanguageNames = new Intl.DisplayNames(["en"], {
type: "language",
fallback: "none",
});
const englishScriptNames = new Intl.DisplayNames(["en"], {
type: "script",
fallback: "none",
});
const englishRegionNames = new Intl.DisplayNames(["en"], {
type: "region",
fallback: "none",
});
function displayName(names: Intl.DisplayNames, value: string): string | null {
try {
return names.of(value) ?? null;
} catch {
return null;
}
}
/** Canonical BCP 47 identity used only for comparison; wire values stay untouched. */
export function canonicalLanguageTag(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
try {
return new Intl.Locale(trimmed.replace(/_/g, "-")).toString();
} catch {
return null;
}
}
/** Stable identity that de-duplicates ISO aliases without collapsing script or region subtags. */
export function languageIdentity(value: string): string {
return canonicalLanguageTag(value) ?? value.trim().toLowerCase();
}
/**
* Returns a deterministic English CLDR name for an ISO/BCP 47 value.
*
* Some CLDR releases omit a precomposed language-and-script label. In that
* case, compose the independently standardized language, script, and region
* names so tags such as `sr-Latn` remain distinct from `sr`.
*/
export function englishLanguageName(value: string): string | null {
const canonical = canonicalLanguageTag(value);
if (!canonical) return null;
let locale: Intl.Locale;
try {
locale = new Intl.Locale(canonical);
} catch {
return null;
}
const language = displayName(englishLanguageNames, locale.language);
if (!language) return null;
const exact = displayName(englishLanguageNames, canonical);
const qualifiers = [
locale.script ? displayName(englishScriptNames, locale.script) : null,
locale.region ? displayName(englishRegionNames, locale.region) : null,
].filter((part): part is string => Boolean(part));
if (exact && (qualifiers.length === 0 || exact !== language)) return exact;
return qualifiers.length > 0 ? `${language} (${qualifiers.join(", ")})` : language;
}
/** User-facing name with an explicit fallback for unassigned or invalid tags. */
export function getLanguageName(value: string): string {
const trimmed = value.trim();
if (!trimmed) return "Unknown";
return englishLanguageName(trimmed) ?? `Unknown language (${trimmed})`;
}
/** Empty-preserving variant for optional metadata and filter labels. */
export function formatLanguage(value: string): string {
const trimmed = value.trim();
return trimmed ? getLanguageName(trimmed) : "";
}
/** Canonical ISO language subtag used for language matching and override keys. */
export function normalizeLanguageCode(value: string | null | undefined): string {
const canonical = canonicalLanguageTag(value ?? "");
if (!canonical) return "";
return new Intl.Locale(canonical).language;
}
+39 -1
View File
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import { SETTING_KEYS } from "./settingsContract";
import { languageOptionsFor, namedLanguageOptionsFor } from "./languageOptions";
import {
languageOptionsFor,
namedLanguageOptionsFor,
withCurrentLanguageOption,
} from "./languageOptions";
describe("languageOptions", () => {
it("uses the definition-specific generated option set", () => {
@@ -26,6 +30,25 @@ describe("languageOptions", () => {
expect(options.map((option) => option.value)).toContain("pt");
});
it("uses English names for ISO and BCP 47 values instead of exposing raw tags", () => {
const options = namedLanguageOptionsFor(SETTING_KEYS.CATALOG_METADATA_LANGUAGE, undefined, [
"sa",
"se",
"yue",
"pt-BR",
"sr-Latn",
"xx",
]);
const labels = Object.fromEntries(options.map((option) => [option.value, option.label]));
expect(labels.sa).toBe("Sanskrit");
expect(labels.se).toBe("Northern Sami");
expect(labels.yue).toBe("Cantonese");
expect(labels["pt-BR"]).toBe("Brazilian Portuguese");
expect(labels["sr-Latn"]).toBe("Serbian (Latin)");
expect(labels.xx).toBe("Unknown language (xx)");
});
it("uses each nullable definition's context-specific unset copy", () => {
expect(languageOptionsFor(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE)[0]).toEqual({
value: "",
@@ -40,4 +63,19 @@ describe("languageOptions", () => {
label: "Library default",
});
});
it("keeps an exact current ISO alias without showing its semantic duplicate", () => {
const options = withCurrentLanguageOption(
[
{ value: "en", label: "English" },
{ value: "fr", label: "French" },
],
"eng",
);
expect(options).toEqual([
{ value: "eng", label: "English" },
{ value: "fr", label: "French" },
]);
});
});
+16 -18
View File
@@ -4,6 +4,7 @@ import {
SETTINGS_REVISION,
type SettingKey,
} from "@/lib/settingsContract";
import { getLanguageName, languageIdentity } from "@/lib/languageNames";
/** One choice in a settings dropdown. */
export interface SettingOption {
@@ -11,24 +12,21 @@ export interface SettingOption {
label: string;
}
const languageNames = new Intl.DisplayNames(undefined, { type: "language" });
/** Preserve an exact open value while replacing any semantic ISO alias already shown. */
export function withCurrentLanguageOption(
options: readonly SettingOption[],
currentValue: string,
): SettingOption[] {
const currentIdentity = languageIdentity(currentValue);
const current = { value: currentValue, label: getLanguageName(currentValue) };
const existing = options.findIndex(
(option) => languageIdentity(option.value) === currentIdentity,
);
if (existing < 0) return [current, ...options];
function languageIdentity(value: string): string {
try {
// Canonicalize true aliases (eng/en) without discarding meaningful
// script or region specificity (pt/pt-BR).
return new Intl.Locale(value).toString();
} catch {
return value.trim().toLowerCase();
}
}
function languageLabel(value: string): string {
try {
return languageNames.of(value) ?? value;
} catch {
return value;
}
const merged = [...options];
merged[existing] = current;
return merged;
}
/**
@@ -73,7 +71,7 @@ export function namedLanguageOptionsFor(
runtimeValues.forEach((value) => add(value, false));
if (currentValue) add(currentValue, true);
return values.map((value) => ({ value, label: languageLabel(value) }));
return values.map((value) => ({ value, label: getLanguageName(value) }));
}
/** The nullable list, using the contract's context-specific unset copy. */
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import {
ORIGINAL_METADATA_LANGUAGE,
normalizeMetadataLanguageOverrides,
withMetadataLanguageOverride,
withoutMetadataLanguageOverride,
} from "./metadataLanguagePreferences";
describe("metadata language preferences", () => {
it("normalizes source aliases and drops malformed cached entries", () => {
expect(
normalizeMetadataLanguageOverrides({
nor: ORIGINAL_METADATA_LANGUAGE,
JA: "en",
invalid_language: "fr",
de: 42,
}),
).toEqual({ no: ORIGINAL_METADATA_LANGUAGE, ja: "en" });
});
it("adds and removes one original-language exception without mutating the input", () => {
const initial = { ja: "en" };
const added = withMetadataLanguageOverride(initial, "nor", ORIGINAL_METADATA_LANGUAGE);
expect(added).toEqual({ ja: "en", no: ORIGINAL_METADATA_LANGUAGE });
expect(initial).toEqual({ ja: "en" });
expect(withoutMetadataLanguageOverride(added, "no")).toEqual({ ja: "en" });
});
});
@@ -0,0 +1,44 @@
import { normalizeLanguageCode } from "@/lib/languageNames";
/** Valid private-use BCP 47 tag understood by the server as item-original. */
export const ORIGINAL_METADATA_LANGUAGE = "x-silo-original";
export type MetadataLanguageOverrides = Record<string, string>;
/**
* Treat the settings response as untrusted JSON at the component boundary.
* The server validates writes, but this also keeps a stale/broken cached value
* from making the editor unusable.
*/
export function normalizeMetadataLanguageOverrides(value: unknown): MetadataLanguageOverrides {
if (value === null || typeof value !== "object" || Array.isArray(value)) return {};
const normalized: MetadataLanguageOverrides = {};
for (const [source, target] of Object.entries(value)) {
const sourceCode = normalizeLanguageCode(source);
if (!/^[a-z]{2,3}$/.test(sourceCode) || typeof target !== "string" || !target.trim()) {
continue;
}
normalized[sourceCode] = target.trim();
}
return normalized;
}
export function withMetadataLanguageOverride(
overrides: MetadataLanguageOverrides,
source: string,
target: string,
): MetadataLanguageOverrides {
const sourceCode = normalizeLanguageCode(source);
if (!/^[a-z]{2,3}$/.test(sourceCode) || !target.trim()) return overrides;
return { ...overrides, [sourceCode]: target.trim() };
}
export function withoutMetadataLanguageOverride(
overrides: MetadataLanguageOverrides,
source: string,
): MetadataLanguageOverrides {
const next = { ...overrides };
delete next[normalizeLanguageCode(source)];
return next;
}
+22 -1
View File
@@ -1,6 +1,6 @@
{
"fixture_version": 1,
"manifest_revision": 2,
"manifest_revision": 3,
"description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.",
"cases": [
{
@@ -438,6 +438,27 @@
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
"expected": [{ "key": "catalog.metadata_language", "value": "fr", "source": "profile" }]
},
{
"name": "metadata_language_exceptions_resolve_as_one_object",
"description": "The original-language exception map is a single profile-scoped value; resolution preserves every source-to-target entry together.",
"keys": ["catalog.metadata_language_overrides"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "catalog.metadata_language_overrides",
"scope": "profile",
"profile_id": "p1",
"value": { "ja": "en", "no": "x-silo-original" }
}
],
"expected": [
{
"key": "catalog.metadata_language_overrides",
"value": { "ja": "en", "no": "x-silo-original" },
"source": "profile"
}
]
},
{
"name": "locked_replaces_a_differing_choice",
"description": "locked is total: the policy value replaces the user's outright, and the authored choice is preserved as stored_value so it takes effect the day the lock lifts.",
+19 -2
View File
@@ -8,7 +8,7 @@
* is a manifest change plus a regeneration, never a hand-written key.
*/
export const SETTINGS_REVISION = 2;
export const SETTINGS_REVISION = 3;
export interface SettingSuggestedOption {
value: string;
@@ -154,6 +154,8 @@ export type SettingOptionSetId = keyof typeof SETTING_OPTION_SETS;
export const SETTING_KEYS = {
/** Metadata language */
CATALOG_METADATA_LANGUAGE: "catalog.metadata_language",
/** Metadata language exceptions */
CATALOG_METADATA_LANGUAGE_OVERRIDES: "catalog.metadata_language_overrides",
/** Download quality */
DOWNLOADS_DEFAULT_QUALITY: "downloads.default_quality",
/** Keep watched downloads */
@@ -306,12 +308,27 @@ export const SETTING_DEFINITIONS: Record<SettingKey, SettingDefinition> = {
resolutionOrder: ["profile", "default"],
defaultValue: null,
label: "Metadata language",
description: "Language Silo prefers for titles, descriptions, and artwork.",
description: "Fallback language Silo prefers for titles, descriptions, and artwork.",
category: "catalog",
control: "select",
suggestedOptions: "catalog_metadata_languages",
unsetLabel: "Library default",
},
"catalog.metadata_language_overrides": {
key: "catalog.metadata_language_overrides",
type: "object",
nullable: false,
persistence: "remote",
introducedIn: 3,
scopes: ["profile"],
scopeIntroducedIn: [3],
resolutionOrder: ["profile", "default"],
defaultValue: {},
label: "Metadata language exceptions",
description: "Preferred metadata language for items in specific original languages.",
category: "catalog",
control: "panel",
},
"downloads.default_quality": {
key: "downloads.default_quality",
type: "enum",
@@ -26,6 +26,10 @@ describe("formatLanguageName", () => {
expect(formatLanguageName("english subtitles")).toBe("English Subtitles");
});
it("preserves an already readable language name", () => {
expect(formatLanguageName("English")).toBe("English");
});
it("returns empty string for undefined", () => {
expect(formatLanguageName(undefined)).toBe("");
});
@@ -1,19 +1,7 @@
import type { VersionAudioTrack, VersionSubtitleTrack, VersionVideoTrack } from "@/api/types";
import { englishLanguageName, getLanguageName } from "@/lib/languageNames";
import { formatBitrate, formatChannels, formatSampleRate } from "@/lib/mediaFormat";
export const LANGUAGE_NAMES: Record<string, string> = {
cze: "Czech",
ces: "Czech",
deu: "German",
eng: "English",
fra: "French",
fre: "French",
ita: "Italian",
jpn: "Japanese",
por: "Portuguese",
spa: "Spanish",
};
export function formatPageCount(pages?: number): string {
if (!pages || pages <= 0) return "";
return `${pages.toLocaleString()} ${pages === 1 ? "page" : "pages"}`;
@@ -35,7 +23,9 @@ export function formatLanguageName(language?: string): string {
const trimmed = language.trim();
const normalized = trimmed.toLowerCase();
if (!normalized) return "";
if (LANGUAGE_NAMES[normalized]) return LANGUAGE_NAMES[normalized];
const standardizedName = englishLanguageName(trimmed);
if (standardizedName) return standardizedName;
if (normalized.length > 3) {
return trimmed
@@ -44,18 +34,7 @@ export function formatLanguageName(language?: string): string {
.map((part) => part[0]?.toUpperCase() + part.slice(1).toLowerCase())
.join(" ");
}
if (typeof Intl !== "undefined" && "DisplayNames" in Intl) {
try {
const displayNames = new Intl.DisplayNames(undefined, { type: "language" });
const match = displayNames.of(normalized);
if (match) return match;
} catch {
// Fall back to the raw code below.
}
}
return normalized.toUpperCase();
return getLanguageName(trimmed);
}
const SOURCE_HINT_PATTERNS: Array<{ pattern: RegExp; canonical: string }> = [
@@ -79,6 +79,7 @@ describe("PlaybackSettings", () => {
expect(batched?.[0].keys).toContain(SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO);
expect(batched?.[0].keys).toContain(SETTING_KEYS.UI_NEXT_UP_MODE);
expect(batched?.[0].keys).toContain(SETTING_KEYS.CATALOG_METADATA_LANGUAGE);
expect(batched?.[0].keys).toContain(SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES);
});
it("saves a toggle as typed JSON at profile scope", () => {
+27 -24
View File
@@ -1,5 +1,6 @@
import { SettingsGroup } from "@/components/settings/SettingsGroup";
import { SettingRow } from "@/components/settings/SettingRow";
import { MetadataLanguageSetting } from "@/components/settings/MetadataLanguageSetting";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
@@ -11,6 +12,10 @@ import {
} from "@/components/ui/select";
import { optionsFor } from "@/lib/settingsDisplay";
import { namedLanguageOptionsFor } from "@/lib/languageOptions";
import {
normalizeMetadataLanguageOverrides,
type MetadataLanguageOverrides,
} from "@/lib/metadataLanguagePreferences";
import { SETTING_DEFINITIONS, SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
import { QUALITY_PRESETS, describeQuality, presetById, presetIdFor } from "@/lib/qualityPresets";
import { useEffectiveSettings } from "@/hooks/queries/settingValues";
@@ -38,6 +43,7 @@ const PLAYBACK_KEYS: SettingKey[] = [
SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO,
SETTING_KEYS.PLAYBACK_AUTO_SKIP_RECAP,
SETTING_KEYS.CATALOG_METADATA_LANGUAGE,
SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES,
SETTING_KEYS.UI_NEXT_UP_MODE,
];
@@ -190,6 +196,17 @@ export default function PlaybackSettings() {
const nextUpChosen = effective?.[SETTING_KEYS.UI_NEXT_UP_MODE]?.source === "profile";
const pending = isSaving;
const saveMetadataOverrides = (overrides: MetadataLanguageOverrides) => {
const request =
Object.keys(overrides).length === 0
? resetProfileDefault(SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES)
: saveProfileDefault(SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES, overrides);
return request.catch((error) => {
toast.error("Failed to save metadata language exceptions");
throw error;
});
};
async function resetNextUpMode() {
// Nothing stored is the state a reset asks for, which the shared reset
// already treats as success.
@@ -244,31 +261,17 @@ export default function PlaybackSettings() {
)}
/>
<SettingRow
label="Metadata language"
description="Show titles and descriptions in this language when available. Descriptions can be translated automatically when the server has AI translation enabled."
control={(id) => (
<div className="w-full">
<Select
value={metadataLanguage ?? "none"}
onValueChange={(value) =>
saveValue(SETTING_KEYS.CATALOG_METADATA_LANGUAGE, value === "none" ? null : value)
}
>
<SelectTrigger id={id} className="w-full sm:w-[220px]" disabled={pending}>
<SelectValue placeholder="Library default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Library default</SelectItem>
{metadataLanguageOptions.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<MetadataLanguageSetting
fallback={metadataLanguage}
overrides={normalizeMetadataLanguageOverrides(
read<unknown>(SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES),
)}
languageOptions={metadataLanguageOptions}
disabled={pending}
onFallbackChange={(language) =>
saveValue(SETTING_KEYS.CATALOG_METADATA_LANGUAGE, language)
}
onOverridesChange={saveMetadataOverrides}
/>
<SettingRow
@@ -1,8 +1,8 @@
import type { EffectiveSettingsMap, SettingIdentity } from "@/hooks/queries/settingValues";
import { getLanguageName } from "@/lib/languageNames";
import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
import { optionsFor } from "@/lib/settingsDisplay";
import { SETTING_DEFINITIONS } from "@/lib/settingsContract";
import { getLanguageName } from "@/player/utils/languageNames";
export const INHERIT_VALUE = "inherit";
export const NONE_VALUE = "none";
+46 -172
View File
@@ -1,173 +1,46 @@
/** ISO 639-1 (2-letter) language codes → full display name. */
const LANGUAGE_NAMES_2: Record<string, string> = {
en: "English",
es: "Spanish",
fr: "French",
de: "German",
it: "Italian",
pt: "Portuguese",
nl: "Dutch",
pl: "Polish",
ru: "Russian",
zh: "Chinese",
ja: "Japanese",
ko: "Korean",
ar: "Arabic",
tr: "Turkish",
sv: "Swedish",
da: "Danish",
no: "Norwegian",
fi: "Finnish",
hu: "Hungarian",
cs: "Czech",
ro: "Romanian",
he: "Hebrew",
th: "Thai",
vi: "Vietnamese",
el: "Greek",
bg: "Bulgarian",
hr: "Croatian",
sk: "Slovak",
sl: "Slovenian",
uk: "Ukrainian",
id: "Indonesian",
ms: "Malay",
hi: "Hindi",
ta: "Tamil",
te: "Telugu",
bn: "Bengali",
fa: "Persian",
};
import { getLanguageName } from "@/lib/languageNames";
/** ISO 639-2/B (3-letter) language codes → full display name. */
const LANGUAGE_NAMES_3: Record<string, string> = {
eng: "English",
spa: "Spanish",
fre: "French",
fra: "French",
ger: "German",
deu: "German",
ita: "Italian",
por: "Portuguese",
dut: "Dutch",
nld: "Dutch",
pol: "Polish",
rus: "Russian",
chi: "Chinese",
zho: "Chinese",
jpn: "Japanese",
kor: "Korean",
ara: "Arabic",
tur: "Turkish",
swe: "Swedish",
dan: "Danish",
nor: "Norwegian",
fin: "Finnish",
hun: "Hungarian",
cze: "Czech",
ces: "Czech",
rum: "Romanian",
ron: "Romanian",
heb: "Hebrew",
tha: "Thai",
vie: "Vietnamese",
gre: "Greek",
ell: "Greek",
bul: "Bulgarian",
hrv: "Croatian",
slo: "Slovak",
slk: "Slovak",
slv: "Slovenian",
ukr: "Ukrainian",
ind: "Indonesian",
may: "Malay",
msa: "Malay",
hin: "Hindi",
tam: "Tamil",
tel: "Telugu",
ben: "Bengali",
per: "Persian",
fas: "Persian",
};
export { getLanguageName, normalizeLanguageCode } from "@/lib/languageNames";
const LANGUAGE_CODE_ALIASES: Record<string, string> = {
eng: "en",
spa: "es",
fre: "fr",
fra: "fr",
ger: "de",
deu: "de",
ita: "it",
por: "pt",
dut: "nl",
nld: "nl",
pol: "pl",
rus: "ru",
chi: "zh",
zho: "zh",
jpn: "ja",
kor: "ko",
ara: "ar",
tur: "tr",
swe: "sv",
dan: "da",
nor: "no",
fin: "fi",
hun: "hu",
cze: "cs",
ces: "cs",
rum: "ro",
ron: "ro",
heb: "he",
tha: "th",
vie: "vi",
gre: "el",
ell: "el",
bul: "bg",
hrv: "hr",
slo: "sk",
slk: "sk",
slv: "sl",
ukr: "uk",
ind: "id",
may: "ms",
msa: "ms",
hin: "hi",
tam: "ta",
tel: "te",
ben: "bn",
per: "fa",
fas: "fa",
};
/** Combined lookup: supports both 2-letter and 3-letter codes. */
const LANGUAGE_NAMES: Record<string, string> = {
...LANGUAGE_NAMES_2,
...LANGUAGE_NAMES_3,
};
export function normalizeLanguageCode(code: string | null | undefined): string {
const normalized = (code ?? "").trim().toLowerCase();
if (!normalized) return "";
const separator = normalized.search(/[-_]/);
const base = separator >= 0 ? normalized.slice(0, separator) : normalized;
return LANGUAGE_CODE_ALIASES[base] ?? base;
}
/**
* Returns the full language name for an ISO 639-1 or 639-2 code.
* Falls back to the code itself (uppercased first letter) if unknown.
*/
export function getLanguageName(code: string): string {
if (!code) return "Unknown";
const lower = code.toLowerCase();
const normalized = normalizeLanguageCode(code);
return (
LANGUAGE_NAMES_2[normalized] ??
LANGUAGE_NAMES[lower] ??
code.charAt(0).toUpperCase() + code.slice(1)
);
}
const COMMON_LANGUAGE_CODES = [
"en",
"es",
"fr",
"de",
"it",
"pt",
"nl",
"pl",
"ru",
"zh",
"ja",
"ko",
"ar",
"tr",
"sv",
"da",
"no",
"fi",
"hu",
"cs",
"ro",
"he",
"th",
"vi",
"el",
"bg",
"hr",
"sk",
"sl",
"uk",
"id",
"ms",
"hi",
"ta",
"te",
"bn",
"fa",
] as const;
/** Language option for dropdowns (search modal, etc). */
export interface LanguageOption {
@@ -175,7 +48,8 @@ export interface LanguageOption {
label: string;
}
/** Sorted list of languages for UI dropdowns (uses 2-letter codes for API compatibility). */
export const LANGUAGES: LanguageOption[] = Object.entries(LANGUAGE_NAMES_2)
.map(([code, label]) => ({ code, label }))
.sort((a, b) => a.label.localeCompare(b.label));
/** Sorted common-language list; labels come from the shared English CLDR resolver. */
export const LANGUAGES: LanguageOption[] = COMMON_LANGUAGE_CODES.map((code) => ({
code,
label: getLanguageName(code),
})).sort((a, b) => a.label.localeCompare(b.label));
+2 -2
View File
@@ -36,8 +36,8 @@ describe("getLanguageName", () => {
expect(getLanguageName("ENG")).toBe("English");
});
it("falls back to capitalized code for unknown", () => {
expect(getLanguageName("xx")).toBe("Xx");
it("labels an unassigned code explicitly", () => {
expect(getLanguageName("xx")).toBe("Unknown language (xx)");
});
it("returns 'Unknown' for empty string", () => {