From d70b291bb8ac9e4fc2540d50561bd22d381f85f9 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:17:49 -0400 Subject: [PATCH] feat(settings): add shared language option catalogs (#521) --- cmd/settingsgen/main.go | 154 ++++++++++++++++ cmd/settingsgen/main_test.go | 86 +++++++++ contracts/settings/v1/conformance.json | 2 +- contracts/settings/v1/manifest.json | 136 +++++++++++++- contracts/settings/v1/manifest.schema.json | 45 +++++ ...-platform-user-settings-contract-design.md | 21 +++ internal/api/handlers/settings_values.go | 168 ++++++++++++++++-- internal/api/handlers/settings_values_test.go | 25 +++ internal/api/router.go | 3 + internal/settingscontract/contract.go | 69 +++++-- internal/settingscontract/contract_test.go | 126 +++++++++++++ internal/settingscontract/validate.go | 65 +++++++ internal/settingskeys/keys.go | 2 +- web/src/hooks/queries/settingValues.ts | 2 + web/src/lib/languageOptions.test.ts | 47 +++-- web/src/lib/languageOptions.ts | 99 +++++++++-- web/src/lib/settingsConformance.json | 2 +- web/src/lib/settingsContract.ts | 151 +++++++++++++++- web/src/lib/settingsDisplay.ts | 17 +- web/src/pages/settings/LibrarySettings.tsx | 20 ++- web/src/pages/settings/PlaybackSettings.tsx | 22 ++- .../settings/SubtitleAppearanceSettings.tsx | 9 +- 22 files changed, 1189 insertions(+), 82 deletions(-) create mode 100644 cmd/settingsgen/main_test.go diff --git a/cmd/settingsgen/main.go b/cmd/settingsgen/main.go index 498a256c..a42af872 100644 --- a/cmd/settingsgen/main.go +++ b/cmd/settingsgen/main.go @@ -159,6 +159,30 @@ func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) { fmt.Fprintf(&out, "export const SETTINGS_REVISION = %d;\n\n", contract.Revision) + out.WriteString("export interface SettingSuggestedOption {\n") + out.WriteString(" value: string;\n") + out.WriteString(" introducedIn: number;\n") + out.WriteString("}\n\n") + out.WriteString("export interface SettingOptionSet {\n") + out.WriteString(" type: string;\n") + out.WriteString(" options: readonly SettingSuggestedOption[];\n") + out.WriteString("}\n\n") + out.WriteString("export const SETTING_OPTION_SETS = {\n") + for _, name := range sortedOptionSetNames(contract) { + optionSet := contract.OptionSets[name] + fmt.Fprintf(&out, " %s: {\n", name) + fmt.Fprintf(&out, " type: %q,\n", optionSet.Type) + out.WriteString(" options: [\n") + for _, option := range optionSet.Options { + fmt.Fprintf(&out, " { value: %q, introducedIn: %d },\n", + option.Value, option.IntroducedIn) + } + out.WriteString(" ],\n") + out.WriteString(" },\n") + } + out.WriteString("} as const satisfies Record;\n\n") + out.WriteString("export type SettingOptionSetId = keyof typeof SETTING_OPTION_SETS;\n\n") + out.WriteString("export const SETTING_KEYS = {\n") for _, def := range sortedDefinitions(contract) { fmt.Fprintf(&out, " /** %s */\n", def.Label) @@ -189,6 +213,8 @@ func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) { out.WriteString(" category: string;\n") out.WriteString(" control?: string;\n") out.WriteString(" unit?: string;\n") + out.WriteString(" suggestedOptions?: SettingOptionSetId;\n") + out.WriteString(" unsetLabel?: string;\n") out.WriteString(" values?: readonly { value: unknown; label: string; introducedIn: number }[];\n") out.WriteString(" /** Present on enums whose members are ranked, so a ceiling or floor has a direction. */\n") out.WriteString(" ordered?: boolean;\n") @@ -228,6 +254,12 @@ func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) { if def.Unit != "" { fmt.Fprintf(&out, " unit: %q,\n", def.Unit) } + if def.SuggestedOptions != "" { + fmt.Fprintf(&out, " suggestedOptions: %q,\n", def.SuggestedOptions) + } + if def.UnsetLabel != "" { + fmt.Fprintf(&out, " unsetLabel: %s,\n", jsString(def.UnsetLabel)) + } if len(def.ValueSchema.Values) > 0 { out.WriteString(" values: [\n") for _, member := range def.ValueSchema.Values { @@ -275,6 +307,19 @@ func generateKotlin(contract *settingscontract.Manifest, pkg string) ([]byte, er } fmt.Fprintf(&out, "\npackage %s\n\n", pkg) + out.WriteString("data class SettingSuggestedOption(\n") + out.WriteString(" val value: String,\n") + out.WriteString(" val introducedIn: Int,\n") + out.WriteString(")\n\n") + out.WriteString("data class SettingOptionSet(\n") + out.WriteString(" val type: String,\n") + out.WriteString(" val options: List,\n") + out.WriteString(")\n\n") + out.WriteString("data class SettingPresentation(\n") + out.WriteString(" val suggestedOptions: String? = null,\n") + out.WriteString(" val unsetLabel: String? = null,\n") + out.WriteString(")\n\n") + out.WriteString("object SettingKeys {\n") fmt.Fprintf(&out, " const val REVISION = %d\n\n", contract.Revision) for _, def := range sortedDefinitions(contract) { @@ -330,6 +375,46 @@ func generateKotlin(contract *settingscontract.Manifest, pkg string) ([]byte, er out.WriteString(" )\n") } + out.WriteString("}\n\n") + + out.WriteString("object SettingPresentationMetadata {\n") + out.WriteString(" val OPTION_SETS: Map = mapOf(\n") + for _, name := range sortedOptionSetNames(contract) { + optionSet := contract.OptionSets[name] + fmt.Fprintf(&out, " %q to SettingOptionSet(\n", name) + fmt.Fprintf(&out, " type = %q,\n", optionSet.Type) + out.WriteString(" options = listOf(\n") + for _, option := range optionSet.Options { + fmt.Fprintf(&out, " SettingSuggestedOption(%q, %d),\n", + option.Value, option.IntroducedIn) + } + out.WriteString(" ),\n") + out.WriteString(" ),\n") + } + out.WriteString(" )\n\n") + + out.WriteString(" val DEFINITIONS: Map = mapOf(\n") + for _, def := range sortedDefinitions(contract) { + if def.SuggestedOptions == "" && def.UnsetLabel == "" { + continue + } + fmt.Fprintf(&out, " SettingKeys.%s to SettingPresentation(\n", screamingCase(def.Key)) + if def.SuggestedOptions != "" { + fmt.Fprintf(&out, " suggestedOptions = %q,\n", def.SuggestedOptions) + } + if def.UnsetLabel != "" { + fmt.Fprintf(&out, " unsetLabel = %q,\n", def.UnsetLabel) + } + out.WriteString(" ),\n") + } + out.WriteString(" )\n\n") + out.WriteString(" fun suggestedValues(key: String, revision: Int = SettingKeys.REVISION): List {\n") + out.WriteString(" val setId = DEFINITIONS[key]?.suggestedOptions ?: return emptyList()\n") + out.WriteString(" return OPTION_SETS[setId]?.options\n") + out.WriteString(" ?.filter { it.introducedIn <= revision }\n") + out.WriteString(" ?.map { it.value }\n") + out.WriteString(" .orEmpty()\n") + out.WriteString(" }\n") out.WriteString("}\n") return out.Bytes(), nil } @@ -341,6 +426,19 @@ func generateSwift(contract *settingscontract.Manifest) ([]byte, error) { } out.WriteString("\nimport Foundation\n\n") + out.WriteString("public struct SettingSuggestedOption: Hashable, Sendable {\n") + out.WriteString(" public let value: String\n") + out.WriteString(" public let introducedIn: Int\n") + out.WriteString("}\n\n") + out.WriteString("public struct SettingOptionSet: Hashable, Sendable {\n") + out.WriteString(" public let type: String\n") + out.WriteString(" public let options: [SettingSuggestedOption]\n") + out.WriteString("}\n\n") + out.WriteString("public struct SettingPresentation: Hashable, Sendable {\n") + out.WriteString(" public let suggestedOptions: String?\n") + out.WriteString(" public let unsetLabel: String?\n") + out.WriteString("}\n\n") + out.WriteString("/// Every setting the contract defines.\n") out.WriteString("public enum SettingKey: String, CaseIterable, Sendable {\n") for _, def := range sortedDefinitions(contract) { @@ -368,6 +466,53 @@ func generateSwift(contract *settingscontract.Manifest) ([]byte, error) { } } out.WriteString(" ]\n") + out.WriteString("}\n\n") + + out.WriteString("public enum SettingPresentationMetadata {\n") + out.WriteString(" public static let optionSets: [String: SettingOptionSet] = [\n") + for _, name := range sortedOptionSetNames(contract) { + optionSet := contract.OptionSets[name] + fmt.Fprintf(&out, " %q: SettingOptionSet(\n", name) + fmt.Fprintf(&out, " type: %q,\n", optionSet.Type) + out.WriteString(" options: [\n") + for _, option := range optionSet.Options { + fmt.Fprintf(&out, " SettingSuggestedOption(value: %q, introducedIn: %d),\n", + option.Value, option.IntroducedIn) + } + out.WriteString(" ]\n") + out.WriteString(" ),\n") + } + out.WriteString(" ]\n\n") + + out.WriteString(" public static let definitions: [SettingKey: SettingPresentation] = [\n") + for _, def := range sortedDefinitions(contract) { + if def.SuggestedOptions == "" && def.UnsetLabel == "" { + continue + } + fmt.Fprintf(&out, " .%s: SettingPresentation(\n", lowerFirst(identifierFor(def.Key))) + if def.SuggestedOptions != "" { + fmt.Fprintf(&out, " suggestedOptions: %q,\n", def.SuggestedOptions) + } else { + out.WriteString(" suggestedOptions: nil,\n") + } + if def.UnsetLabel != "" { + fmt.Fprintf(&out, " unsetLabel: %q\n", def.UnsetLabel) + } else { + out.WriteString(" unsetLabel: nil\n") + } + out.WriteString(" ),\n") + } + out.WriteString(" ]\n\n") + out.WriteString(" public static func suggestedValues(\n") + out.WriteString(" for key: SettingKey,\n") + out.WriteString(" revision: Int = SettingKey.revision\n") + out.WriteString(" ) -> [String] {\n") + out.WriteString(" guard let setID = definitions[key]?.suggestedOptions,\n") + out.WriteString(" let optionSet = optionSets[setID] else { return [] }\n") + out.WriteString(" return optionSet.options\n") + out.WriteString(" .filter { $0.introducedIn <= revision }\n") + out.WriteString(" .map(\\.value)\n") + out.WriteString(" }\n") out.WriteString("}\n") return out.Bytes(), nil } @@ -379,6 +524,15 @@ func lowerFirst(value string) string { return strings.ToLower(value[:1]) + value[1:] } +func sortedOptionSetNames(contract *settingscontract.Manifest) []string { + names := make([]string, 0, len(contract.OptionSets)) + for name := range contract.OptionSets { + names = append(names, name) + } + sort.Strings(names) + return names +} + func quotedScopes(def *settingscontract.Definition) string { parts := make([]string, 0, len(def.AllowedScopes)) for _, entry := range def.AllowedScopes { diff --git a/cmd/settingsgen/main_test.go b/cmd/settingsgen/main_test.go new file mode 100644 index 00000000..0ea7d9e2 --- /dev/null +++ b/cmd/settingsgen/main_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "strings" + "testing" + + "github.com/Silo-Server/silo-server/internal/settingscontract" +) + +func TestPresentationMetadataIsGeneratedForEveryClientLanguage(t *testing.T) { + contract, err := settingscontract.Load() + if err != nil { + t.Fatalf("loading contract: %v", err) + } + + tests := []struct { + name string + generate func() ([]byte, error) + markers []string + }{ + { + name: "typescript", + generate: func() ([]byte, error) { return generateTypeScript(contract) }, + markers: []string{ + "export const SETTING_OPTION_SETS", + `catalog_metadata_languages`, + `suggestedOptions: "playback_subtitle_languages"`, + `unsetLabel: "None"`, + }, + }, + { + name: "kotlin", + generate: func() ([]byte, error) { + return generateKotlin(contract, "org.siloserver.silo.model.settings") + }, + markers: []string{ + "object SettingPresentationMetadata", + `"playback_audio_languages" to SettingOptionSet`, + `suggestedOptions = "catalog_metadata_languages"`, + `unsetLabel = "Library default"`, + }, + }, + { + name: "swift", + generate: func() ([]byte, error) { return generateSwift(contract) }, + markers: []string{ + "public enum SettingPresentationMetadata", + `"playback_subtitle_languages": SettingOptionSet`, + `suggestedOptions: "playback_audio_languages"`, + `unsetLabel: "No preference"`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + generated, err := tc.generate() + if err != nil { + t.Fatalf("generating: %v", err) + } + for _, marker := range tc.markers { + if !strings.Contains(string(generated), marker) { + t.Errorf("generated output omitted %q", marker) + } + } + }) + } +} + +func TestGeneratedOptionOrderMatchesTheManifest(t *testing.T) { + contract, err := settingscontract.Load() + if err != nil { + t.Fatalf("loading contract: %v", err) + } + generated, err := generateTypeScript(contract) + if err != nil { + t.Fatalf("generating TypeScript: %v", err) + } + + text := string(generated) + arabic := strings.Index(text, `{ value: "ar", introducedIn: 1 }`) + bengali := strings.Index(text, `{ value: "bn", introducedIn: 1 }`) + if arabic < 0 || bengali < 0 || arabic >= bengali { + t.Fatalf("generated option order does not preserve the manifest: ar=%d bn=%d", arabic, bengali) + } +} diff --git a/contracts/settings/v1/conformance.json b/contracts/settings/v1/conformance.json index b10c7a97..0545434b 100644 --- a/contracts/settings/v1/conformance.json +++ b/contracts/settings/v1/conformance.json @@ -1,6 +1,6 @@ { "fixture_version": 1, - "manifest_revision": 1, + "manifest_revision": 2, "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": [ { diff --git a/contracts/settings/v1/manifest.json b/contracts/settings/v1/manifest.json index 59b1c445..dd8cbf08 100644 --- a/contracts/settings/v1/manifest.json +++ b/contracts/settings/v1/manifest.json @@ -1,6 +1,134 @@ { "api_version": 1, - "revision": 1, + "revision": 2, + "option_sets": { + "playback_audio_languages": { + "type": "language_tag", + "options": [ + { "value": "ar", "introduced_in": 1 }, + { "value": "bn", "introduced_in": 1 }, + { "value": "bg", "introduced_in": 1 }, + { "value": "zh", "introduced_in": 1 }, + { "value": "hr", "introduced_in": 1 }, + { "value": "cs", "introduced_in": 1 }, + { "value": "da", "introduced_in": 1 }, + { "value": "nl", "introduced_in": 1 }, + { "value": "en", "introduced_in": 1 }, + { "value": "fi", "introduced_in": 1 }, + { "value": "fr", "introduced_in": 1 }, + { "value": "de", "introduced_in": 1 }, + { "value": "el", "introduced_in": 1 }, + { "value": "he", "introduced_in": 1 }, + { "value": "hi", "introduced_in": 1 }, + { "value": "hu", "introduced_in": 1 }, + { "value": "id", "introduced_in": 1 }, + { "value": "it", "introduced_in": 1 }, + { "value": "ja", "introduced_in": 1 }, + { "value": "ko", "introduced_in": 1 }, + { "value": "ms", "introduced_in": 1 }, + { "value": "no", "introduced_in": 1 }, + { "value": "fa", "introduced_in": 1 }, + { "value": "pl", "introduced_in": 1 }, + { "value": "pt", "introduced_in": 1 }, + { "value": "ro", "introduced_in": 1 }, + { "value": "ru", "introduced_in": 1 }, + { "value": "sk", "introduced_in": 1 }, + { "value": "sl", "introduced_in": 1 }, + { "value": "es", "introduced_in": 1 }, + { "value": "sv", "introduced_in": 1 }, + { "value": "ta", "introduced_in": 1 }, + { "value": "te", "introduced_in": 1 }, + { "value": "th", "introduced_in": 1 }, + { "value": "tr", "introduced_in": 1 }, + { "value": "uk", "introduced_in": 1 }, + { "value": "vi", "introduced_in": 1 } + ] + }, + "playback_subtitle_languages": { + "type": "language_tag", + "options": [ + { "value": "ar", "introduced_in": 1 }, + { "value": "bn", "introduced_in": 1 }, + { "value": "bg", "introduced_in": 1 }, + { "value": "zh", "introduced_in": 1 }, + { "value": "hr", "introduced_in": 1 }, + { "value": "cs", "introduced_in": 1 }, + { "value": "da", "introduced_in": 1 }, + { "value": "nl", "introduced_in": 1 }, + { "value": "en", "introduced_in": 1 }, + { "value": "fi", "introduced_in": 1 }, + { "value": "fr", "introduced_in": 1 }, + { "value": "de", "introduced_in": 1 }, + { "value": "el", "introduced_in": 1 }, + { "value": "he", "introduced_in": 1 }, + { "value": "hi", "introduced_in": 1 }, + { "value": "hu", "introduced_in": 1 }, + { "value": "id", "introduced_in": 1 }, + { "value": "it", "introduced_in": 1 }, + { "value": "ja", "introduced_in": 1 }, + { "value": "ko", "introduced_in": 1 }, + { "value": "ms", "introduced_in": 1 }, + { "value": "no", "introduced_in": 1 }, + { "value": "fa", "introduced_in": 1 }, + { "value": "pl", "introduced_in": 1 }, + { "value": "pt", "introduced_in": 1 }, + { "value": "ro", "introduced_in": 1 }, + { "value": "ru", "introduced_in": 1 }, + { "value": "sk", "introduced_in": 1 }, + { "value": "sl", "introduced_in": 1 }, + { "value": "es", "introduced_in": 1 }, + { "value": "sv", "introduced_in": 1 }, + { "value": "ta", "introduced_in": 1 }, + { "value": "te", "introduced_in": 1 }, + { "value": "th", "introduced_in": 1 }, + { "value": "tr", "introduced_in": 1 }, + { "value": "uk", "introduced_in": 1 }, + { "value": "vi", "introduced_in": 1 } + ] + }, + "catalog_metadata_languages": { + "type": "language_tag", + "options": [ + { "value": "ar", "introduced_in": 1 }, + { "value": "bn", "introduced_in": 1 }, + { "value": "bg", "introduced_in": 1 }, + { "value": "zh", "introduced_in": 1 }, + { "value": "hr", "introduced_in": 1 }, + { "value": "cs", "introduced_in": 1 }, + { "value": "da", "introduced_in": 1 }, + { "value": "nl", "introduced_in": 1 }, + { "value": "en", "introduced_in": 1 }, + { "value": "fi", "introduced_in": 1 }, + { "value": "fr", "introduced_in": 1 }, + { "value": "de", "introduced_in": 1 }, + { "value": "el", "introduced_in": 1 }, + { "value": "he", "introduced_in": 1 }, + { "value": "hi", "introduced_in": 1 }, + { "value": "hu", "introduced_in": 1 }, + { "value": "id", "introduced_in": 1 }, + { "value": "it", "introduced_in": 1 }, + { "value": "ja", "introduced_in": 1 }, + { "value": "ko", "introduced_in": 1 }, + { "value": "ms", "introduced_in": 1 }, + { "value": "no", "introduced_in": 1 }, + { "value": "fa", "introduced_in": 1 }, + { "value": "pl", "introduced_in": 1 }, + { "value": "pt", "introduced_in": 1 }, + { "value": "ro", "introduced_in": 1 }, + { "value": "ru", "introduced_in": 1 }, + { "value": "sk", "introduced_in": 1 }, + { "value": "sl", "introduced_in": 1 }, + { "value": "es", "introduced_in": 1 }, + { "value": "sv", "introduced_in": 1 }, + { "value": "ta", "introduced_in": 1 }, + { "value": "te", "introduced_in": 1 }, + { "value": "th", "introduced_in": 1 }, + { "value": "tr", "introduced_in": 1 }, + { "value": "uk", "introduced_in": 1 }, + { "value": "vi", "introduced_in": 1 } + ] + } + }, "definitions": [ { "key": "playback.audio_language", @@ -20,6 +148,8 @@ "label": "Preferred audio language", "description": "Choose which spoken language Silo should prefer first.", "recommended_control": "select", + "suggested_options": "playback_audio_languages", + "unset_label": "No preference", "notes": "Migrates user_profiles.language as the roaming fallback. Existing user_device_settings values become real overrides, and the per-series value comes from AudioPreference.audio_language. AudioPreference.audio_track_index and track_signature stay specialized: they identify a concrete track, not a default. The legacy string-only endpoint has no way to send null, so it spells \"no preference\" as the empty string and both Android and web send that to clear the choice; its validator accepts \"\" and otherwise requires a well-formed tag via settingscontract.NormalizeLanguageTag. Migration maps \"\" to no stored row, the same way playback.subtitle_mode handles it." }, { @@ -40,6 +170,8 @@ "label": "Preferred subtitle language", "description": "Choose which subtitle language Silo should prefer first.", "recommended_control": "select", + "suggested_options": "playback_subtitle_languages", + "unset_label": "None", "notes": "Migrates user_profiles.subtitle_language, LibraryPlaybackPreference.subtitle_language, and SubtitlePreference.subtitle_language. SubtitlePreference.subtitle_track_index, external_subtitle_path, and track_signature stay specialized." }, { @@ -254,6 +386,8 @@ "label": "Metadata language", "description": "Language Silo prefers for titles, descriptions, and artwork.", "recommended_control": "select", + "suggested_options": "catalog_metadata_languages", + "unset_label": "Library default", "notes": "Migrates user_profiles.preferred_metadata_language; that column is NOT NULL DEFAULT '', and the empty string means unset, so migration writes a row only where it is non-empty. Deliberately carries no constrained_by. An earlier draft declared an allowlist on policy input profile_preferred_metadata_language, which is circular: internal/policy/input.go populates that field from this very column and vendor/scope.rego relays it unchanged as a preference. Policy narrows nothing here, and an allowlist bound to a scalar equal to the current value would either be a no-op or reject every change the user makes." }, { diff --git a/contracts/settings/v1/manifest.schema.json b/contracts/settings/v1/manifest.schema.json index e49ec010..0ef0d44f 100644 --- a/contracts/settings/v1/manifest.schema.json +++ b/contracts/settings/v1/manifest.schema.json @@ -17,6 +17,12 @@ "type": "integer", "minimum": 1 }, + "option_sets": { + "description": "Advisory ordered vocabularies for open-value controls. They suggest values but never constrain the corresponding value schema.", + "type": "object", + "propertyNames": { "$ref": "#/$defs/optionSetName" }, + "additionalProperties": { "$ref": "#/$defs/optionSet" } + }, "definitions": { "type": "array", "items": { "$ref": "#/$defs/definition" } @@ -34,6 +40,36 @@ "type": "integer", "minimum": 1 }, + "optionSetName": { + "description": "Stable snake_case identifier for a reusable presentation vocabulary.", + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "maxLength": 128 + }, + "suggestedOption": { + "description": "One advisory value. Labels are localized by clients from the stable wire value.", + "type": "object", + "additionalProperties": false, + "required": ["value", "introduced_in"], + "properties": { + "value": { "type": "string", "minLength": 1 }, + "introduced_in": { "$ref": "#/$defs/revisionRef" } + } + }, + "optionSet": { + "description": "An ordered advisory value floor for an open setting. The server validates that its type matches every referring definition.", + "type": "object", + "additionalProperties": false, + "required": ["type", "options"], + "properties": { + "type": { "const": "language_tag" }, + "options": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/suggestedOption" } + } + } + }, "scopeName": { "description": "Storage identity a value attaches to. Whether a given scope is legal for a definition depends on its persistence class, which internal/settingscontract enforces.", "type": "string", @@ -286,6 +322,15 @@ "type": "string", "enum": ["switch", "select", "slider", "stepper", "text", "color", "panel"] }, + "suggested_options": { + "description": "Advisory option-set reference for an open-value control. It does not turn the value schema into an enum.", + "$ref": "#/$defs/optionSetName" + }, + "unset_label": { + "description": "Context-specific label for the nullable/unset row in a generated control.", + "type": "string", + "minLength": 1 + }, "deprecated": { "type": "boolean", "default": false }, "notes": { "description": "Maintainer commentary. Not served in the public manifest.", diff --git a/docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md b/docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md index 6cd9e393..55674d05 100644 --- a/docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md +++ b/docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md @@ -387,6 +387,27 @@ Rules: - Arbitrary untyped JSON is not allowed. Existing `subtitle_appearance` becomes an `object` with a versioned schema. +### Open language option sets + +`language_tag` remains an open type. The manifest may publish named advisory `option_sets`, and a +language definition may reference one with `suggested_options` plus context-specific nullable copy +in `unset_label`. These fields drive picker presentation; they never turn a language into a closed +enum or make an otherwise valid BCP 47 value fail validation. + +Audio, subtitle, and metadata settings use separate named sets even when their initial values are +the same. That keeps their product policy independently evolvable without teaching generators or +clients about particular setting keys. Every option carries `introduced_in`, retains manifest +order, and is filtered against the connected server revision like enum members and scopes. + +The choices rendered by a client are the stable contract floor, unioned with valid language values +observed by the deployment and the exact current stored value. The server returns that runtime +union as `suggested_values` on effective-value entries. Clients must retain a valid current value +even when it is absent from both other sources, so a regional or legacy tag never leaves a picker +with no selected row. Semantic aliases are de-duplicated for presentation, with the exact current +wire value winning. Region and script variants are not aliases and remain distinct choices. +Display names are localized with the platform's CLDR/ICU facilities rather +than stored as English labels in the contract. + ## Scopes and identity The remote scopes are: diff --git a/internal/api/handlers/settings_values.go b/internal/api/handlers/settings_values.go index 793353bc..8f93183b 100644 --- a/internal/api/handlers/settings_values.go +++ b/internal/api/handlers/settings_values.go @@ -23,8 +23,10 @@ import ( "github.com/Silo-Server/silo-server/internal/catalog" evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/settingscontract" + "github.com/Silo-Server/silo-server/internal/settingskeys" "github.com/Silo-Server/silo-server/internal/settingsresolve" "github.com/Silo-Server/silo-server/internal/userstore" + "golang.org/x/text/language" ) // SettingValuesHandler serves the canonical settings API: the contract itself, @@ -36,10 +38,11 @@ import ( // it lives at rather than being one of two hardcoded scopes, and an unknown key // is refused rather than stored in an open extension bag. type SettingValuesHandler struct { - storeProvider userstore.UserStoreProvider - contract *settingscontract.Manifest - resolver *settingsresolve.Resolver - libraryLookup libraryLookup + storeProvider userstore.UserStoreProvider + contract *settingscontract.Manifest + resolver *settingsresolve.Resolver + libraryLookup libraryLookup + languageSource languageSuggestionSource // deviceSeen throttles device-registry refreshes, one upsert per // deviceSeenThrottle window per (profile, device) — the same shape the @@ -51,6 +54,11 @@ type SettingValuesHandler struct { EventsHub *evt.Hub } +type languageSuggestionSource interface { + ListAudioLanguages(context.Context, catalog.BrowseFilters) ([]string, error) + ListSubtitleLanguages(context.Context, catalog.BrowseFilters) ([]string, error) +} + // SetLibraryLookup wires the catalog lookup used to reject profile_library // identities that do not name a real library. The same lookup serves session // and admin mutations so the two routes cannot create different orphan rows. @@ -58,6 +66,13 @@ func (h *SettingValuesHandler) SetLibraryLookup(lookup libraryLookup) { h.libraryLookup = lookup } +// SetLanguageSuggestionSource wires deployment-observed media languages into +// effective setting responses. The contract option set remains the stable +// floor; a missing source or failed catalog lookup simply returns that floor. +func (h *SettingValuesHandler) SetLanguageSuggestionSource(source languageSuggestionSource) { + h.languageSource = source +} + // NewSettingValuesHandler builds the handler over the embedded contract. func NewSettingValuesHandler( provider userstore.UserStoreProvider, @@ -135,6 +150,10 @@ type effectiveSettingValueResponse struct { RequestedValue json.RawMessage `json:"requested_value,omitempty"` ConstrainedBy *settingscontract.Constraint `json:"constrained_by,omitempty"` PermittedValues []json.RawMessage `json:"permitted_values,omitempty"` + // SuggestedValues is advisory presentation data for an open setting. It is + // the stable contract floor plus values observed in this viewer's catalog + // and the current effective value. It never acts as a write allowlist. + SuggestedValues []string `json:"suggested_values,omitempty"` DefinitionRevision int `json:"definition_revision"` UpdatedAt string `json:"updated_at,omitempty"` @@ -579,10 +598,7 @@ func (h *SettingValuesHandler) HandleGetEffective(w http.ResponseWriter, r *http return } - out := make([]effectiveSettingValueResponse, 0, len(resolved)) - for _, eff := range resolved { - out = append(out, effectiveToResponse(eff)) - } + out := h.effectiveResponses(r, resolved) writeJSON(w, http.StatusOK, map[string]any{ "settings": out, fieldRevision: h.contract.Revision, @@ -698,13 +714,15 @@ func (h *SettingValuesHandler) HandlePostEffective(w http.ResponseWriter, r *htt ContextID string `json:"context_id"` Settings []effectiveSettingValueResponse `json:"settings"` } + allResolved := make([]settingsresolve.Effective, 0) + for _, values := range resolved { + allResolved = append(allResolved, values...) + } + observed := h.observedLanguageSuggestions(r, allResolved) out := make([]contextResponse, len(resolved)) for i, values := range resolved { out[i].ContextID = strings.TrimSpace(body.Contexts[i].ContextID) - out[i].Settings = make([]effectiveSettingValueResponse, 0, len(values)) - for _, eff := range values { - out[i].Settings = append(out[i].Settings, effectiveToResponse(eff)) - } + out[i].Settings = h.effectiveResponsesWithObserved(values, observed) } writeJSON(w, http.StatusOK, map[string]any{ "contexts": out, @@ -1015,6 +1033,132 @@ func effectiveToResponse(eff settingsresolve.Effective) effectiveSettingValueRes return out } +func (h *SettingValuesHandler) effectiveResponses( + r *http.Request, + resolved []settingsresolve.Effective, +) []effectiveSettingValueResponse { + observed := h.observedLanguageSuggestions(r, resolved) + return h.effectiveResponsesWithObserved(resolved, observed) +} + +func (h *SettingValuesHandler) effectiveResponsesWithObserved( + resolved []settingsresolve.Effective, + observed map[string][]string, +) []effectiveSettingValueResponse { + out := make([]effectiveSettingValueResponse, 0, len(resolved)) + for _, eff := range resolved { + response := effectiveToResponse(eff) + def, ok := h.contract.Lookup(eff.Key) + if ok && def.SuggestedOptions != "" { + optionSet := h.contract.OptionSets[def.SuggestedOptions] + floor := make([]string, 0, len(optionSet.Options)) + for _, option := range optionSet.OptionsAtRevision(h.contract.Revision) { + floor = append(floor, option.Value) + } + response.SuggestedValues = mergeLanguageSuggestions( + floor, observed[eff.Key], eff.Value, + ) + } + out = append(out, response) + } + return out +} + +func (h *SettingValuesHandler) observedLanguageSuggestions( + r *http.Request, + resolved []settingsresolve.Effective, +) map[string][]string { + result := make(map[string][]string) + if h.languageSource == nil { + return result + } + + wantsAudio, wantsSubtitles := false, false + for _, eff := range resolved { + switch eff.Key { + case settingskeys.PlaybackAudioLanguage: + wantsAudio = true + case settingskeys.PlaybackSubtitleLanguage: + wantsSubtitles = true + } + } + if !wantsAudio && !wantsSubtitles { + return result + } + + filters := catalog.BrowseFilters{} + if scope, ok := access.GetScope(r.Context()); ok { + filters.LibraryIDs = scope.AllowedLibraryIDs + filters.DisabledLibraryIDs = scope.DisabledLibraryIDs + filters.MaxContentRating = scope.MaxContentRating + } + if wantsAudio { + values, err := h.languageSource.ListAudioLanguages(r.Context(), filters) + if err != nil { + slog.WarnContext(r.Context(), "settings: listing audio language suggestions", + "component", "settings", "error", err) + } else { + result[settingskeys.PlaybackAudioLanguage] = values + } + } + if wantsSubtitles { + values, err := h.languageSource.ListSubtitleLanguages(r.Context(), filters) + if err != nil { + slog.WarnContext(r.Context(), "settings: listing subtitle language suggestions", + "component", "settings", "error", err) + } else { + result[settingskeys.PlaybackSubtitleLanguage] = values + } + } + return result +} + +// mergeLanguageSuggestions keeps the contract's stable authored order, then +// appends deployment-observed languages. Semantic aliases such as eng/en are +// deduplicated through the catalog's ISO canonicalizer. When the current +// stored value is one of those aliases it replaces the row's wire value so a +// picker always has an exact selectable tag for its current selection. +func mergeLanguageSuggestions( + floor []string, + observed []string, + current json.RawMessage, +) []string { + values := make([]string, 0, len(floor)+len(observed)+1) + indexByLanguage := make(map[string]int, cap(values)) + appendValue := func(value string, replace bool) { + normalized, ok := settingscontract.NormalizeLanguageTag(value) + if !ok { + return + } + identity := normalized + if tag, err := language.Parse(normalized); err == nil { + // x/text collapses true ISO aliases (eng/en) while retaining + // meaningful script and region specificity (pt/pt-BR). + identity = tag.String() + } + if index, exists := indexByLanguage[identity]; exists { + if replace { + values[index] = normalized + } + return + } + indexByLanguage[identity] = len(values) + values = append(values, normalized) + } + + for _, value := range floor { + appendValue(value, false) + } + for _, value := range observed { + appendValue(value, false) + } + var currentValue string + if err := json.Unmarshal(current, ¤tValue); err == nil { + appendValue(currentValue, true) + } + return values +} + // hashMutationRequest fingerprints what a mutation id was used for, so a reused // id carrying different content is a conflict rather than a silent replay of // the wrong write. diff --git a/internal/api/handlers/settings_values_test.go b/internal/api/handlers/settings_values_test.go index b9fd2ae2..67584aef 100644 --- a/internal/api/handlers/settings_values_test.go +++ b/internal/api/handlers/settings_values_test.go @@ -8,6 +8,7 @@ import ( "errors" "net/http" "net/http/httptest" + "slices" "strings" "testing" @@ -827,3 +828,27 @@ func TestCapabilitiesReportTheContractRevision(t *testing.T) { t.Errorf("reported %d scopes, want 5", len(body.Scopes)) } } + +func TestMergeLanguageSuggestionsKeepsFloorObservedAndCurrent(t *testing.T) { + got := mergeLanguageSuggestions( + []string{"en", "fr", "pt"}, + []string{"eng", "deu", "not a tag", "fr"}, + json.RawMessage(`"pt-BR"`), + ) + want := []string{"en", "fr", "pt", "deu", "pt-BR"} + if !slices.Equal(got, want) { + t.Errorf("mergeLanguageSuggestions() = %v, want %v", got, want) + } +} + +func TestMergeLanguageSuggestionsUsesExactCurrentAlias(t *testing.T) { + got := mergeLanguageSuggestions( + []string{"en", "fr"}, + []string{"eng", "fra"}, + json.RawMessage(`"eng"`), + ) + want := []string{"eng", "fr"} + if !slices.Equal(got, want) { + t.Errorf("mergeLanguageSuggestions() = %v, want %v", got, want) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 639a3985..33f3281c 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -858,6 +858,9 @@ func NewRouter(deps Dependencies) chi.Router { } else if deps.DB != nil { settingValuesHandler.SetLibraryLookup(catalog.NewFolderRepository(deps.DB)) } + if deps.DB != nil { + settingValuesHandler.SetLanguageSuggestionSource(catalog.NewBrowseRepository(deps.DB)) + } } homeDismissalHandler = handlers.NewHomeDismissalHandler(deps.UserStoreProvider) homeDismissalHandler.EventsHub = deps.EventsHub diff --git a/internal/settingscontract/contract.go b/internal/settingscontract/contract.go index f53aa498..17785f77 100644 --- a/internal/settingscontract/contract.go +++ b/internal/settingscontract/contract.go @@ -93,8 +93,12 @@ type Manifest struct { // no revision rule can express. APIVersion int `json:"api_version"` // Revision is bumped by every manifest PR and is what clients filter - // definitions, scopes, enum members, and bounds against. + // definitions, scopes, enum members, option suggestions, and bounds against. Revision int `json:"revision"` + // OptionSets are advisory presentation vocabularies. They never constrain + // what a value schema accepts: an open language_tag remains open even when + // its definition points at a suggested option set. + OptionSets map[string]OptionSet `json:"option_sets,omitempty"` // Definitions is ordered as authored; use Lookup for access by key. Definitions []Definition `json:"definitions"` @@ -103,25 +107,58 @@ type Manifest struct { // Definition is the canonical contract for one setting. type Definition struct { - Key string `json:"key"` - IntroducedIn int `json:"introduced_in"` - Persistence Persistence `json:"persistence"` - AllowedScopes []ScopeEntry `json:"allowed_scopes"` - ResolutionOrder []Scope `json:"resolution_order"` - ValueSchema ValueSchema `json:"value_schema"` - DefaultValue json.RawMessage `json:"default_value"` - ConstrainedBy *Constraint `json:"constrained_by,omitempty"` - Platforms []string `json:"platforms,omitempty"` - Category string `json:"category"` - Label string `json:"label"` - Description string `json:"description"` - Unit string `json:"unit,omitempty"` - Control string `json:"recommended_control,omitempty"` - Deprecated bool `json:"deprecated,omitempty"` + Key string `json:"key"` + IntroducedIn int `json:"introduced_in"` + Persistence Persistence `json:"persistence"` + AllowedScopes []ScopeEntry `json:"allowed_scopes"` + ResolutionOrder []Scope `json:"resolution_order"` + ValueSchema ValueSchema `json:"value_schema"` + DefaultValue json.RawMessage `json:"default_value"` + ConstrainedBy *Constraint `json:"constrained_by,omitempty"` + Platforms []string `json:"platforms,omitempty"` + Category string `json:"category"` + Label string `json:"label"` + Description string `json:"description"` + Unit string `json:"unit,omitempty"` + Control string `json:"recommended_control,omitempty"` + SuggestedOptions string `json:"suggested_options,omitempty"` + UnsetLabel string `json:"unset_label,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` // Notes is maintainer commentary. It is stripped from the public manifest. Notes string `json:"notes,omitempty"` } +// OptionSet is an ordered, advisory list of values a client may present for +// an open setting. Type must match the referring definition's value schema. +// The options are kept in authored order so every generated client starts +// from the same stable floor before adding deployment-observed values. +type OptionSet struct { + Type ValueType `json:"type"` + Options []SuggestedOption `json:"options"` +} + +// SuggestedOption is one presentation value and the earliest contract +// revision against which it is safe to offer. It deliberately carries no +// display label: clients localize language names with their platform locale +// data instead of requiring a server release for every translation. +type SuggestedOption struct { + Value string `json:"value"` + IntroducedIn int `json:"introduced_in"` +} + +// OptionsAtRevision returns the authored suggestions visible to a peer at +// revision. The returned slice is a copy and preserves manifest order. +func (s OptionSet) OptionsAtRevision(revision int) []SuggestedOption { + options := make([]SuggestedOption, 0, len(s.Options)) + for _, option := range s.Options { + if option.IntroducedIn > revision { + continue + } + options = append(options, option) + } + return options +} + // ScopeEntry is a scope, optionally tagged with the revision that added it to // its definition. It unmarshals from either a bare string or an object so the // common case stays readable in the manifest. diff --git a/internal/settingscontract/contract_test.go b/internal/settingscontract/contract_test.go index a7f2178e..2a1e84c5 100644 --- a/internal/settingscontract/contract_test.go +++ b/internal/settingscontract/contract_test.go @@ -574,6 +574,46 @@ func TestPublicManifestStripsMaintainerNotes(t *testing.T) { if !strings.Contains(string(raw), `"notes"`) { t.Fatal("no definition carries notes, so the stripping test proves nothing") } + if !strings.Contains(string(public), `"option_sets"`) { + t.Fatal("public manifest omitted advisory option_sets") + } +} + +func TestLanguageOptionSetsAreCanonicalAndReferenced(t *testing.T) { + manifest, err := Load() + if err != nil { + t.Fatalf("loading manifest: %v", err) + } + + wantReferences := map[string]string{ + "playback.audio_language": "playback_audio_languages", + "playback.subtitle_language": "playback_subtitle_languages", + "catalog.metadata_language": "catalog_metadata_languages", + } + for key, optionSetID := range wantReferences { + def, ok := manifest.Lookup(key) + if !ok { + t.Fatalf("%s is not registered", key) + } + if def.SuggestedOptions != optionSetID { + t.Errorf("%s suggested_options = %q, want %q", + key, def.SuggestedOptions, optionSetID) + } + optionSet, ok := manifest.OptionSets[optionSetID] + if !ok { + t.Fatalf("%s references missing option set %q", key, optionSetID) + } + if len(optionSet.Options) < 30 { + t.Errorf("%s has only %d suggestions; want the shared language floor", + optionSetID, len(optionSet.Options)) + } + for _, option := range optionSet.Options { + normalized, valid := NormalizeLanguageTag(option.Value) + if !valid || normalized != option.Value { + t.Errorf("%s contains non-canonical value %q", optionSetID, option.Value) + } + } + } } func TestRevisionAwareFilteringHidesNewerElements(t *testing.T) { @@ -613,6 +653,92 @@ func TestRevisionAwareFilteringHidesNewerElements(t *testing.T) { if got := def.EnumValuesAtRevision(9); len(got) != 2 { t.Errorf("EnumValuesAtRevision(9) = %v, want both members", got) } + + optionSet := OptionSet{Options: []SuggestedOption{ + {Value: "en", IntroducedIn: 5}, + {Value: "fr", IntroducedIn: 9}, + }} + if got := optionSet.OptionsAtRevision(5); len(got) != 1 || got[0].Value != "en" { + t.Errorf("OptionsAtRevision(5) = %v, want [en]", got) + } + if got := optionSet.OptionsAtRevision(9); len(got) != 2 { + t.Errorf("OptionsAtRevision(9) = %v, want both suggestions", got) + } +} + +func TestOptionSetValidationRejectsBrokenPresentationMetadata(t *testing.T) { + base := func() *Manifest { + return &Manifest{ + APIVersion: 1, + Revision: 2, + OptionSets: map[string]OptionSet{ + "languages": { + Type: TypeLanguageTag, + Options: []SuggestedOption{ + {Value: "en", IntroducedIn: 1}, + }, + }, + }, + Definitions: []Definition{{ + Key: fixtureKey, + IntroducedIn: 1, + Persistence: PersistenceRemote, + AllowedScopes: []ScopeEntry{{Scope: ScopeProfile}}, + ResolutionOrder: []Scope{ScopeProfile, ScopeDefault}, + ValueSchema: ValueSchema{Type: TypeLanguageTag, Nullable: true}, + DefaultValue: json.RawMessage(jsonNull), + Category: "playback", + Label: "Example", + Description: "Example.", + SuggestedOptions: "languages", + UnsetLabel: "None", + }}, + } + } + + cases := map[string]func(*Manifest){ + "missing reference": func(m *Manifest) { + m.Definitions[0].SuggestedOptions = "missing" + }, + "type mismatch": func(m *Manifest) { + m.Definitions[0].ValueSchema.Type = TypeString + max := 20 + m.Definitions[0].ValueSchema.MaxLength = &max + m.Definitions[0].DefaultValue = json.RawMessage(`null`) + }, + "duplicate value": func(m *Manifest) { + set := m.OptionSets["languages"] + set.Options = append(set.Options, SuggestedOption{Value: "en", IntroducedIn: 1}) + m.OptionSets["languages"] = set + }, + "non-canonical tag": func(m *Manifest) { + set := m.OptionSets["languages"] + set.Options[0].Value = "EN_us" + m.OptionSets["languages"] = set + }, + "future revision": func(m *Manifest) { + set := m.OptionSets["languages"] + set.Options[0].IntroducedIn = 3 + m.OptionSets["languages"] = set + }, + "unset on non-nullable": func(m *Manifest) { + m.Definitions[0].ValueSchema.Nullable = false + m.Definitions[0].DefaultValue = json.RawMessage(`"en"`) + }, + } + + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + manifest := base() + mutate(manifest) + if err := manifest.index(); err != nil { + t.Fatalf("indexing: %v", err) + } + if err := manifest.Validate(nil); err == nil { + t.Fatal("Validate accepted broken presentation metadata") + } + }) + } } // TestWidenedBoundsStayResolvableAtOlderRevisions is the case a bare scalar diff --git a/internal/settingscontract/validate.go b/internal/settingscontract/validate.go index 974b9f38..6a2ed7c1 100644 --- a/internal/settingscontract/validate.go +++ b/internal/settingscontract/validate.go @@ -35,16 +35,81 @@ func (m *Manifest) Validate(objectSchemas map[string]*jsonschema.Schema) error { errs = append(errs, errors.New("manifest declares no definitions")) } + for name, optionSet := range m.OptionSets { + if err := optionSet.validate(name, m.Revision); err != nil { + errs = append(errs, err) + } + } + for i := range m.Definitions { def := &m.Definitions[i] if err := def.validate(m.Revision, objectSchemas); err != nil { errs = append(errs, fmt.Errorf("%s: %w", def.Key, err)) } + if err := def.validatePresentation(m.OptionSets); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", def.Key, err)) + } } return errors.Join(errs...) } +func (s OptionSet) validate(name string, manifestRevision int) error { + var errs []error + if s.Type != TypeLanguageTag { + errs = append(errs, fmt.Errorf( + "option set %q has unsupported type %q", name, s.Type)) + } + if len(s.Options) == 0 { + errs = append(errs, fmt.Errorf("option set %q has no options", name)) + } + + seen := make(map[string]struct{}, len(s.Options)) + for _, option := range s.Options { + if option.IntroducedIn < 1 || option.IntroducedIn > manifestRevision { + errs = append(errs, fmt.Errorf( + "option set %q value %q has introduced_in %d outside 1..%d", + name, option.Value, option.IntroducedIn, manifestRevision)) + } + if s.Type == TypeLanguageTag { + normalized, ok := NormalizeLanguageTag(option.Value) + if !ok { + errs = append(errs, fmt.Errorf( + "option set %q value %q is not a language tag", name, option.Value)) + } else if normalized != option.Value { + errs = append(errs, fmt.Errorf( + "option set %q value %q is not canonical; use %q", + name, option.Value, normalized)) + } + } + if _, duplicate := seen[option.Value]; duplicate { + errs = append(errs, fmt.Errorf( + "option set %q repeats value %q", name, option.Value)) + } + seen[option.Value] = struct{}{} + } + return errors.Join(errs...) +} + +func (d *Definition) validatePresentation(optionSets map[string]OptionSet) error { + var errs []error + if d.SuggestedOptions != "" { + optionSet, ok := optionSets[d.SuggestedOptions] + if !ok { + errs = append(errs, fmt.Errorf( + "suggested_options references unknown option set %q", d.SuggestedOptions)) + } else if optionSet.Type != d.ValueSchema.Type { + errs = append(errs, fmt.Errorf( + "suggested_options %q has type %q, want %q", + d.SuggestedOptions, optionSet.Type, d.ValueSchema.Type)) + } + } + if d.UnsetLabel != "" && !d.ValueSchema.Nullable { + errs = append(errs, errors.New("unset_label requires a nullable value schema")) + } + return errors.Join(errs...) +} + func (d *Definition) validate(manifestRevision int, objectSchemas map[string]*jsonschema.Schema) error { var errs []error diff --git a/internal/settingskeys/keys.go b/internal/settingskeys/keys.go index 2ed7080f..b8de3c81 100644 --- a/internal/settingskeys/keys.go +++ b/internal/settingskeys/keys.go @@ -9,7 +9,7 @@ package settingskeys // Revision is the manifest revision these bindings were generated from. -const Revision = 1 +const Revision = 2 // Setting keys, one constant per definition. const ( diff --git a/web/src/hooks/queries/settingValues.ts b/web/src/hooks/queries/settingValues.ts index b528411a..69eef38c 100644 --- a/web/src/hooks/queries/settingValues.ts +++ b/web/src/hooks/queries/settingValues.ts @@ -43,6 +43,8 @@ export interface EffectiveSetting { stored_value?: T; constrained?: boolean; constraint_kind?: "ceiling" | "floor" | "allowlist" | "locked"; + /** Advisory values: contract floor, deployment-observed tags, and current value. */ + suggested_values?: string[]; /** The scope holding the value, so a reset can target it exactly. */ scope?: SettingScope; library_id?: number; diff --git a/web/src/lib/languageOptions.test.ts b/web/src/lib/languageOptions.test.ts index 9e2825bf..6209a207 100644 --- a/web/src/lib/languageOptions.test.ts +++ b/web/src/lib/languageOptions.test.ts @@ -1,20 +1,43 @@ import { describe, expect, it } from "vitest"; -import { LANGUAGES } from "@/player/utils/languageNames"; -import { LANGUAGE_OPTIONS, NAMED_LANGUAGE_OPTIONS } from "./languageOptions"; +import { SETTING_KEYS } from "./settingsContract"; +import { languageOptionsFor, namedLanguageOptionsFor } from "./languageOptions"; describe("languageOptions", () => { - it("covers every language the shared list knows", () => { - // The hand-written settings registry this replaced enumerated its own - // subset, so a language the player could name was not always selectable in - // settings. Deriving the list means the two cannot drift. - expect(NAMED_LANGUAGE_OPTIONS.map((option) => option.value)).toEqual( - LANGUAGES.map((language) => language.code), - ); + it("uses the definition-specific generated option set", () => { + const options = namedLanguageOptionsFor(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE); + + expect(options.map((option) => option.value)).toContain("en"); + expect(options.map((option) => option.value)).toContain("te"); + expect(options.some((option) => option.value === "")).toBe(false); }); - it("prefixes the unset entry only on the nullable list", () => { - expect(LANGUAGE_OPTIONS[0]).toEqual({ value: "", label: "No preference" }); - expect(NAMED_LANGUAGE_OPTIONS.some((option) => option.value === "")).toBe(false); + it("merges deployment values and preserves an exact current regional tag", () => { + const options = namedLanguageOptionsFor(SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE, "pt-BR", [ + "eng", + "pt", + "es-MX", + ]); + + expect(options.map((option) => option.value)).toContain("en"); + expect(options.map((option) => option.value)).not.toContain("eng"); + expect(options.map((option) => option.value)).toContain("es-MX"); + expect(options.map((option) => option.value)).toContain("pt-BR"); + expect(options.map((option) => option.value)).toContain("pt"); + }); + + it("uses each nullable definition's context-specific unset copy", () => { + expect(languageOptionsFor(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE)[0]).toEqual({ + value: "", + label: "No preference", + }); + expect(languageOptionsFor(SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE)[0]).toEqual({ + value: "", + label: "None", + }); + expect(languageOptionsFor(SETTING_KEYS.CATALOG_METADATA_LANGUAGE)[0]).toEqual({ + value: "", + label: "Library default", + }); }); }); diff --git a/web/src/lib/languageOptions.ts b/web/src/lib/languageOptions.ts index bab1f9a0..592ac854 100644 --- a/web/src/lib/languageOptions.ts +++ b/web/src/lib/languageOptions.ts @@ -1,4 +1,9 @@ -import { LANGUAGES } from "@/player/utils/languageNames"; +import { + SETTING_DEFINITIONS, + SETTING_OPTION_SETS, + SETTINGS_REVISION, + type SettingKey, +} from "@/lib/settingsContract"; /** One choice in a settings dropdown. */ export interface SettingOption { @@ -6,24 +11,80 @@ export interface SettingOption { label: string; } -/** - * Every language a setting can name, in display order. - * - * Derived from the shared language list rather than enumerated here, because - * the contract types language settings as BCP 47 rather than as an enum — the - * manifest has no member list to render, so this is the one place a language - * dropdown's contents come from. - */ -export const NAMED_LANGUAGE_OPTIONS: SettingOption[] = LANGUAGES.map(({ code, label }) => ({ - value: code, - label, -})); +const languageNames = new Intl.DisplayNames(undefined, { type: "language" }); + +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; + } +} /** - * The same list with a leading "no preference" entry, which is how a nullable - * language setting spells its unset state (the contract stores null). + * Suggested values for one open language setting. + * + * The generated contract list is the stable floor. A newer server may add + * values observed in this deployment's catalog, and the current stored tag is + * always synthesized into the list so an open value such as `pt-BR` never + * leaves a select with no matching row. True language aliases are de-duplicated, + * with the exact current wire value winning so the control remains selected. */ -export const LANGUAGE_OPTIONS: SettingOption[] = [ - { value: "", label: "No preference" }, - ...NAMED_LANGUAGE_OPTIONS, -]; +export function namedLanguageOptionsFor( + key: SettingKey, + currentValue?: string | null, + runtimeValues: readonly string[] = [], + revision = SETTINGS_REVISION, +): SettingOption[] { + const definition = SETTING_DEFINITIONS[key]; + const setId = definition.suggestedOptions; + const contractValues = setId + ? SETTING_OPTION_SETS[setId].options + .filter((option) => option.introducedIn <= revision) + .map((option) => option.value) + : []; + + const values: string[] = []; + const indexByLanguage = new Map(); + const add = (value: string, replace: boolean) => { + const trimmed = value.trim(); + if (!trimmed) return; + const identity = languageIdentity(trimmed); + const existing = indexByLanguage.get(identity); + if (existing !== undefined) { + if (replace) values[existing] = trimmed; + return; + } + indexByLanguage.set(identity, values.length); + values.push(trimmed); + }; + + contractValues.forEach((value) => add(value, false)); + runtimeValues.forEach((value) => add(value, false)); + if (currentValue) add(currentValue, true); + + return values.map((value) => ({ value, label: languageLabel(value) })); +} + +/** The nullable list, using the contract's context-specific unset copy. */ +export function languageOptionsFor( + key: SettingKey, + currentValue?: string | null, + runtimeValues: readonly string[] = [], + revision = SETTINGS_REVISION, +): SettingOption[] { + const named = namedLanguageOptionsFor(key, currentValue, runtimeValues, revision); + const definition = SETTING_DEFINITIONS[key]; + if (!definition.nullable) return named; + return [{ value: "", label: definition.unsetLabel ?? "Unset" }, ...named]; +} diff --git a/web/src/lib/settingsConformance.json b/web/src/lib/settingsConformance.json index b10c7a97..0545434b 100644 --- a/web/src/lib/settingsConformance.json +++ b/web/src/lib/settingsConformance.json @@ -1,6 +1,6 @@ { "fixture_version": 1, - "manifest_revision": 1, + "manifest_revision": 2, "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": [ { diff --git a/web/src/lib/settingsContract.ts b/web/src/lib/settingsContract.ts index 104bbc02..5764a352 100644 --- a/web/src/lib/settingsContract.ts +++ b/web/src/lib/settingsContract.ts @@ -8,7 +8,148 @@ * is a manifest change plus a regeneration, never a hand-written key. */ -export const SETTINGS_REVISION = 1; +export const SETTINGS_REVISION = 2; + +export interface SettingSuggestedOption { + value: string; + introducedIn: number; +} + +export interface SettingOptionSet { + type: string; + options: readonly SettingSuggestedOption[]; +} + +export const SETTING_OPTION_SETS = { + catalog_metadata_languages: { + type: "language_tag", + options: [ + { value: "ar", introducedIn: 1 }, + { value: "bn", introducedIn: 1 }, + { value: "bg", introducedIn: 1 }, + { value: "zh", introducedIn: 1 }, + { value: "hr", introducedIn: 1 }, + { value: "cs", introducedIn: 1 }, + { value: "da", introducedIn: 1 }, + { value: "nl", introducedIn: 1 }, + { value: "en", introducedIn: 1 }, + { value: "fi", introducedIn: 1 }, + { value: "fr", introducedIn: 1 }, + { value: "de", introducedIn: 1 }, + { value: "el", introducedIn: 1 }, + { value: "he", introducedIn: 1 }, + { value: "hi", introducedIn: 1 }, + { value: "hu", introducedIn: 1 }, + { value: "id", introducedIn: 1 }, + { value: "it", introducedIn: 1 }, + { value: "ja", introducedIn: 1 }, + { value: "ko", introducedIn: 1 }, + { value: "ms", introducedIn: 1 }, + { value: "no", introducedIn: 1 }, + { value: "fa", introducedIn: 1 }, + { value: "pl", introducedIn: 1 }, + { value: "pt", introducedIn: 1 }, + { value: "ro", introducedIn: 1 }, + { value: "ru", introducedIn: 1 }, + { value: "sk", introducedIn: 1 }, + { value: "sl", introducedIn: 1 }, + { value: "es", introducedIn: 1 }, + { value: "sv", introducedIn: 1 }, + { value: "ta", introducedIn: 1 }, + { value: "te", introducedIn: 1 }, + { value: "th", introducedIn: 1 }, + { value: "tr", introducedIn: 1 }, + { value: "uk", introducedIn: 1 }, + { value: "vi", introducedIn: 1 }, + ], + }, + playback_audio_languages: { + type: "language_tag", + options: [ + { value: "ar", introducedIn: 1 }, + { value: "bn", introducedIn: 1 }, + { value: "bg", introducedIn: 1 }, + { value: "zh", introducedIn: 1 }, + { value: "hr", introducedIn: 1 }, + { value: "cs", introducedIn: 1 }, + { value: "da", introducedIn: 1 }, + { value: "nl", introducedIn: 1 }, + { value: "en", introducedIn: 1 }, + { value: "fi", introducedIn: 1 }, + { value: "fr", introducedIn: 1 }, + { value: "de", introducedIn: 1 }, + { value: "el", introducedIn: 1 }, + { value: "he", introducedIn: 1 }, + { value: "hi", introducedIn: 1 }, + { value: "hu", introducedIn: 1 }, + { value: "id", introducedIn: 1 }, + { value: "it", introducedIn: 1 }, + { value: "ja", introducedIn: 1 }, + { value: "ko", introducedIn: 1 }, + { value: "ms", introducedIn: 1 }, + { value: "no", introducedIn: 1 }, + { value: "fa", introducedIn: 1 }, + { value: "pl", introducedIn: 1 }, + { value: "pt", introducedIn: 1 }, + { value: "ro", introducedIn: 1 }, + { value: "ru", introducedIn: 1 }, + { value: "sk", introducedIn: 1 }, + { value: "sl", introducedIn: 1 }, + { value: "es", introducedIn: 1 }, + { value: "sv", introducedIn: 1 }, + { value: "ta", introducedIn: 1 }, + { value: "te", introducedIn: 1 }, + { value: "th", introducedIn: 1 }, + { value: "tr", introducedIn: 1 }, + { value: "uk", introducedIn: 1 }, + { value: "vi", introducedIn: 1 }, + ], + }, + playback_subtitle_languages: { + type: "language_tag", + options: [ + { value: "ar", introducedIn: 1 }, + { value: "bn", introducedIn: 1 }, + { value: "bg", introducedIn: 1 }, + { value: "zh", introducedIn: 1 }, + { value: "hr", introducedIn: 1 }, + { value: "cs", introducedIn: 1 }, + { value: "da", introducedIn: 1 }, + { value: "nl", introducedIn: 1 }, + { value: "en", introducedIn: 1 }, + { value: "fi", introducedIn: 1 }, + { value: "fr", introducedIn: 1 }, + { value: "de", introducedIn: 1 }, + { value: "el", introducedIn: 1 }, + { value: "he", introducedIn: 1 }, + { value: "hi", introducedIn: 1 }, + { value: "hu", introducedIn: 1 }, + { value: "id", introducedIn: 1 }, + { value: "it", introducedIn: 1 }, + { value: "ja", introducedIn: 1 }, + { value: "ko", introducedIn: 1 }, + { value: "ms", introducedIn: 1 }, + { value: "no", introducedIn: 1 }, + { value: "fa", introducedIn: 1 }, + { value: "pl", introducedIn: 1 }, + { value: "pt", introducedIn: 1 }, + { value: "ro", introducedIn: 1 }, + { value: "ru", introducedIn: 1 }, + { value: "sk", introducedIn: 1 }, + { value: "sl", introducedIn: 1 }, + { value: "es", introducedIn: 1 }, + { value: "sv", introducedIn: 1 }, + { value: "ta", introducedIn: 1 }, + { value: "te", introducedIn: 1 }, + { value: "th", introducedIn: 1 }, + { value: "tr", introducedIn: 1 }, + { value: "uk", introducedIn: 1 }, + { value: "vi", introducedIn: 1 }, + ], + }, +} as const satisfies Record; + +export type SettingOptionSetId = keyof typeof SETTING_OPTION_SETS; export const SETTING_KEYS = { /** Metadata language */ @@ -133,6 +274,8 @@ export interface SettingDefinition { category: string; control?: string; unit?: string; + suggestedOptions?: SettingOptionSetId; + unsetLabel?: string; values?: readonly { value: unknown; label: string; introducedIn: number }[]; /** Present on enums whose members are ranked, so a ceiling or floor has a direction. */ ordered?: boolean; @@ -166,6 +309,8 @@ export const SETTING_DEFINITIONS: Record = { description: "Language Silo prefers for titles, descriptions, and artwork.", category: "catalog", control: "select", + suggestedOptions: "catalog_metadata_languages", + unsetLabel: "Library default", }, "downloads.default_quality": { key: "downloads.default_quality", @@ -250,6 +395,8 @@ export const SETTING_DEFINITIONS: Record = { description: "Choose which spoken language Silo should prefer first.", category: "playback", control: "select", + suggestedOptions: "playback_audio_languages", + unsetLabel: "No preference", }, "playback.auto_play_next": { key: "playback.auto_play_next", @@ -442,6 +589,8 @@ export const SETTING_DEFINITIONS: Record = { description: "Choose which subtitle language Silo should prefer first.", category: "playback", control: "select", + suggestedOptions: "playback_subtitle_languages", + unsetLabel: "None", }, "playback.subtitle_mode": { key: "playback.subtitle_mode", diff --git a/web/src/lib/settingsDisplay.ts b/web/src/lib/settingsDisplay.ts index d664265b..836f5973 100644 --- a/web/src/lib/settingsDisplay.ts +++ b/web/src/lib/settingsDisplay.ts @@ -3,7 +3,7 @@ import { type SettingDefinition, type SettingKey, } from "@/lib/settingsContract"; -import { LANGUAGE_OPTIONS, type SettingOption } from "@/lib/languageOptions"; +import { languageOptionsFor, type SettingOption } from "@/lib/languageOptions"; /** * Display helpers over the generated settings contract. @@ -57,14 +57,13 @@ export function isStructuredSetting(definition: SettingDisplay | null): boolean /** * The option list for a select, in manifest order. * - * Enum members come straight from the contract. Language tags get the shared - * language list instead, because the manifest declares them by type rather than - * by enumerating every tag. A nullable definition gains a leading "unset" entry - * so a control can express clearing the value. + * Enum members come straight from the contract. Open language tags use the + * definition's generated advisory option set. A nullable definition gains a + * leading, context-specific unset entry so a control can express clearing it. */ export function optionsFor(definition: SettingDisplay): SettingOption[] { if (definition.type === "language_tag") { - return LANGUAGE_OPTIONS; + return languageOptionsFor(definition.key); } const members = (definition.values ?? []).map((member) => ({ value: String(member.value), @@ -92,8 +91,10 @@ export function formatSettingValue(key: string, value: string | null | undefined return value === "true" ? "Enabled" : "Disabled"; } if (definition.type === "language_tag") { - const match = LANGUAGE_OPTIONS.find((option) => option.value === (value ?? "")); - return match?.label ?? value ?? "No preference"; + const match = languageOptionsFor(definition.key, value).find( + (option) => option.value === (value ?? ""), + ); + return match?.label ?? value ?? definition.unsetLabel ?? "Unset"; } if (definition.values?.length) { const fallback = value ?? defaultValueToString(definition); diff --git a/web/src/pages/settings/LibrarySettings.tsx b/web/src/pages/settings/LibrarySettings.tsx index 62cffffc..1c4f4172 100644 --- a/web/src/pages/settings/LibrarySettings.tsx +++ b/web/src/pages/settings/LibrarySettings.tsx @@ -48,7 +48,7 @@ import { NONE_VALUE, SUBTITLE_MODE_OPTIONS, } from "./libraryPlaybackPreferences"; -import { NAMED_LANGUAGE_OPTIONS } from "@/lib/languageOptions"; +import { namedLanguageOptionsFor } from "@/lib/languageOptions"; import { toast } from "sonner"; import { ChevronDown, ChevronRight, Eye, EyeOff, GripVertical, RotateCcw } from "lucide-react"; import { @@ -241,6 +241,20 @@ function LibraryCard({ const playbackPending = setValue.isPending || clearValue.isPending; const summaryText = buildLibraryPlaybackSummaryFromState(editorState); const hasOverride = hasLibraryPlaybackOverride(editorState); + const audioLanguageOptions = namedLanguageOptionsFor( + SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE, + editorState.audioLanguage === INHERIT_VALUE || editorState.audioLanguage === NONE_VALUE + ? undefined + : editorState.audioLanguage, + effective?.[SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE]?.suggested_values, + ); + const subtitleLanguageOptions = namedLanguageOptionsFor( + SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE, + editorState.subtitleLanguage === INHERIT_VALUE || editorState.subtitleLanguage === NONE_VALUE + ? undefined + : editorState.subtitleLanguage, + effective?.[SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE]?.suggested_values, + ); /** * Applies an editor state as canonical per-key writes at profile_library. @@ -367,7 +381,7 @@ function LibraryCard({ {buildInheritedLanguageLabel(profileDefaults.audioLanguage ?? "")} - {NAMED_LANGUAGE_OPTIONS.map((language) => ( + {audioLanguageOptions.map((language) => ( {language.label} @@ -385,7 +399,7 @@ function LibraryCard({ {buildInheritedSubtitleLanguageLabel(profileDefaults.subtitleLanguage ?? "")} None - {NAMED_LANGUAGE_OPTIONS.map((language) => ( + {subtitleLanguageOptions.map((language) => ( {language.label} diff --git a/web/src/pages/settings/PlaybackSettings.tsx b/web/src/pages/settings/PlaybackSettings.tsx index 92fdb233..fa9a0eca 100644 --- a/web/src/pages/settings/PlaybackSettings.tsx +++ b/web/src/pages/settings/PlaybackSettings.tsx @@ -10,7 +10,7 @@ import { SelectValue, } from "@/components/ui/select"; import { optionsFor } from "@/lib/settingsDisplay"; -import { NAMED_LANGUAGE_OPTIONS } from "@/lib/languageOptions"; +import { namedLanguageOptionsFor } from "@/lib/languageOptions"; 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"; @@ -173,6 +173,18 @@ export default function PlaybackSettings() { }; const nextUpMode = read(SETTING_KEYS.UI_NEXT_UP_MODE); + const audioLanguage = read(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE); + const metadataLanguage = read(SETTING_KEYS.CATALOG_METADATA_LANGUAGE); + const audioLanguageOptions = namedLanguageOptionsFor( + SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE, + audioLanguage, + effective?.[SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE]?.suggested_values, + ); + const metadataLanguageOptions = namedLanguageOptionsFor( + SETTING_KEYS.CATALOG_METADATA_LANGUAGE, + metadataLanguage, + effective?.[SETTING_KEYS.CATALOG_METADATA_LANGUAGE]?.suggested_values, + ); // Whether the profile has actually chosen, which is what gates the reset // affordance: the resolved value is the default until a row exists. const nextUpChosen = effective?.[SETTING_KEYS.UI_NEXT_UP_MODE]?.source === "profile"; @@ -209,7 +221,7 @@ export default function PlaybackSettings() { control={(id) => (
(SETTING_KEYS.CATALOG_METADATA_LANGUAGE) ?? "none"} + value={metadataLanguage ?? "none"} onValueChange={(value) => saveValue(SETTING_KEYS.CATALOG_METADATA_LANGUAGE, value === "none" ? null : value) } @@ -248,7 +260,7 @@ export default function PlaybackSettings() { Library default - {NAMED_LANGUAGE_OPTIONS.map((language) => ( + {metadataLanguageOptions.map((language) => ( {language.label} diff --git a/web/src/pages/settings/SubtitleAppearanceSettings.tsx b/web/src/pages/settings/SubtitleAppearanceSettings.tsx index 4deb6bd4..66a93969 100644 --- a/web/src/pages/settings/SubtitleAppearanceSettings.tsx +++ b/web/src/pages/settings/SubtitleAppearanceSettings.tsx @@ -17,7 +17,7 @@ import { useEffectiveSettings } from "@/hooks/queries/settingValues"; import { useProfileDefaultWriter } from "@/hooks/queries/profileDefaults"; import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract"; import { optionsFor } from "@/lib/settingsDisplay"; -import { NAMED_LANGUAGE_OPTIONS } from "@/lib/languageOptions"; +import { namedLanguageOptionsFor } from "@/lib/languageOptions"; import { SETTING_DEFINITIONS } from "@/lib/settingsContract"; import { BACKGROUND_STYLE_OPTIONS, @@ -140,6 +140,11 @@ export default function SubtitleAppearanceSettings() { // control can read its value straight off the answer without a local literal. const subtitleLanguage = (behavior?.[SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE]?.value as string | null | undefined) ?? ""; + const subtitleLanguageOptions = namedLanguageOptionsFor( + SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE, + subtitleLanguage, + behavior?.[SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE]?.suggested_values, + ); const subtitleMode = (behavior?.[SETTING_KEYS.PLAYBACK_SUBTITLE_MODE]?.value as string | undefined) ?? "auto"; const showForcedSubtitles = @@ -250,7 +255,7 @@ export default function SubtitleAppearanceSettings() { None - {NAMED_LANGUAGE_OPTIONS.map((language) => ( + {subtitleLanguageOptions.map((language) => ( {language.label}