feat(settings): add shared language option catalogs (#521)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,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": [
|
||||
{
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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": [
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user