feat(settings): add shared language option catalogs (#521)

This commit is contained in:
Quick
2026-07-30 18:17:49 -04:00
committed by GitHub
parent b1b093007c
commit d70b291bb8
22 changed files with 1189 additions and 82 deletions
+154
View File
@@ -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<string, SettingOptionSet>;\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<SettingSuggestedOption>,\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<String, SettingOptionSet> = 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<String, SettingPresentation> = 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<String> {\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 {
+86
View File
@@ -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)
}
}
+1 -1
View File
@@ -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": [
{
+135 -1
View File
@@ -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."
},
{
@@ -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.",
@@ -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:
+156 -12
View File
@@ -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, &currentValue); 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.
@@ -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)
}
}
+3
View File
@@ -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
+53 -16
View File
@@ -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.
+126
View File
@@ -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
+65
View File
@@ -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
+1 -1
View File
@@ -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 (
+2
View File
@@ -43,6 +43,8 @@ export interface EffectiveSetting<T = unknown> {
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;
+35 -12
View File
@@ -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",
});
});
});
+80 -19
View File
@@ -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<string, number>();
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];
}
+1 -1
View File
@@ -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": [
{
+150 -1
View File
@@ -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<string, SettingOptionSet>;
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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",
+9 -8
View File
@@ -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);
+17 -3
View File
@@ -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({
<SelectItem value={INHERIT_VALUE}>
{buildInheritedLanguageLabel(profileDefaults.audioLanguage ?? "")}
</SelectItem>
{NAMED_LANGUAGE_OPTIONS.map((language) => (
{audioLanguageOptions.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
@@ -385,7 +399,7 @@ function LibraryCard({
{buildInheritedSubtitleLanguageLabel(profileDefaults.subtitleLanguage ?? "")}
</SelectItem>
<SelectItem value={NONE_VALUE}>None</SelectItem>
{NAMED_LANGUAGE_OPTIONS.map((language) => (
{subtitleLanguageOptions.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
+17 -5
View File
@@ -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<string>(SETTING_KEYS.UI_NEXT_UP_MODE);
const audioLanguage = read<string | null>(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE);
const metadataLanguage = read<string | null>(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) => (
<div className="w-full">
<Select
value={read<string | null>(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE) ?? "none"}
value={audioLanguage ?? "none"}
onValueChange={(value) =>
// The contract spells "no preference" as null, where the
// legacy profile column spelled it as the empty string.
@@ -221,7 +233,7 @@ export default function PlaybackSettings() {
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No preference</SelectItem>
{NAMED_LANGUAGE_OPTIONS.map((language) => (
{audioLanguageOptions.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
@@ -238,7 +250,7 @@ export default function PlaybackSettings() {
control={(id) => (
<div className="w-full">
<Select
value={read<string | null>(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() {
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Library default</SelectItem>
{NAMED_LANGUAGE_OPTIONS.map((language) => (
{metadataLanguageOptions.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
@@ -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() {
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{NAMED_LANGUAGE_OPTIONS.map((language) => (
{subtitleLanguageOptions.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>