diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6cec380..c2c75eb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,11 @@ jobs: git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" golangci-lint run --new-from-merge-base="origin/${BASE_REF}" ./... + # A manifest change that does not regenerate leaves every client reading + # stale keys, which the contract exists to prevent. + - name: Verify settings bindings are current + run: make verify-settings-bindings + # Runs the settings-contract gate among everything else: the embedded # manifest must parse, satisfy its own schema, hold every structural # invariant, and agree with the live settings registry on keys and diff --git a/Makefile b/Makefile index bb59a9a4..c4fa2162 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint test test-go test-web embed-stub clean jellyfin-web migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up +.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint test test-go test-web embed-stub clean jellyfin-web migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up settings-bindings verify-settings-bindings GIT_COMMON_DIR := $(strip $(shell git rev-parse --git-common-dir 2>/dev/null)) MAIN_CHECKOUT_ROOT := $(if $(GIT_COMMON_DIR),$(abspath $(GIT_COMMON_DIR)/..)) @@ -82,6 +82,43 @@ test-go: embed-stub test-web: cd web && pnpm exec vitest run $(WEBTEST_KNOWN_FAILURES) +# Regenerate the settings-contract bindings for every language. +# +# The client repos are siblings of this one (see CLAUDE.md); a missing checkout +# is skipped rather than failing, so a server-only developer can still run this. +SILO_ANDROID_DIR ?= $(abspath ../silo-android) +SILO_APPLE_DIR ?= $(abspath ../silo-apple) + +settings-bindings: + @mkdir -p internal/settingskeys + go run ./cmd/settingsgen -lang go -out internal/settingskeys/keys.go + gofmt -w internal/settingskeys/keys.go + go run ./cmd/settingsgen -lang ts -out web/src/lib/settingsContract.ts + @cd web && pnpm exec prettier --write src/lib/settingsContract.ts >/dev/null + @if [ -d "$(SILO_ANDROID_DIR)" ]; then \ + go run ./cmd/settingsgen -lang kotlin \ + -out "$(SILO_ANDROID_DIR)/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt"; \ + echo "wrote Kotlin bindings to $(SILO_ANDROID_DIR)"; \ + else \ + echo "skipping Kotlin: $(SILO_ANDROID_DIR) not checked out"; \ + fi + @if [ -d "$(SILO_APPLE_DIR)" ]; then \ + go run ./cmd/settingsgen -lang swift \ + -out "$(SILO_APPLE_DIR)/iosApp/iosApp/Networking/SettingKeys.generated.swift"; \ + echo "wrote Swift bindings to $(SILO_APPLE_DIR)"; \ + else \ + echo "skipping Swift: $(SILO_APPLE_DIR) not checked out"; \ + fi + +# Fail when the committed bindings disagree with the manifest, so a manifest +# change cannot merge without regenerating what every client reads. +verify-settings-bindings: + @go run ./cmd/settingsgen -lang go > /tmp/silo-settings-go.check + @gofmt /tmp/silo-settings-go.check > /tmp/silo-settings-go.fmt + @diff -u internal/settingskeys/keys.go /tmp/silo-settings-go.fmt \ + || { echo "::error::internal/settingskeys/keys.go is stale; run make settings-bindings"; exit 1; } + @echo "settings bindings are current" + # Check committed content for local machine path leaks. verify-local-paths: scripts/check-local-path-leaks.sh diff --git a/cmd/settingsgen/main.go b/cmd/settingsgen/main.go new file mode 100644 index 00000000..83b685b7 --- /dev/null +++ b/cmd/settingsgen/main.go @@ -0,0 +1,374 @@ +// Command settingsgen emits typed bindings for the settings contract. +// +// One generator for every language rather than one per repo: the whole point of +// the contract is that four codebases agree on keys, types, scopes and +// defaults, and four independently-written generators would be four chances to +// disagree. Each client repo vendors the manifest and runs this to regenerate. +// +// Usage: +// +// settingsgen -lang go -out internal/settingskeys/keys.go +// settingsgen -lang ts -out web/src/lib/settingsContract.ts +// settingsgen -lang kotlin -out -package org.siloserver.silo.model.settings +// settingsgen -lang swift -out +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "strings" + + "github.com/Silo-Server/silo-server/internal/settingscontract" +) + +func main() { + lang := flag.String("lang", "", "go, ts, kotlin or swift") + out := flag.String("out", "", "file to write (default stdout)") + pkg := flag.String("package", "", "package or namespace for the generated code") + flag.Parse() + + contract, err := settingscontract.Load() + if err != nil { + fail("loading contract: %v", err) + } + + var body []byte + switch *lang { + case "go": + body, err = generateGo(contract, defaultString(*pkg, "settingskeys")) + case "ts": + body, err = generateTypeScript(contract) + case "kotlin": + body, err = generateKotlin(contract, + defaultString(*pkg, "org.siloserver.silo.model.settings")) + case "swift": + body, err = generateSwift(contract) + default: + fail("unknown -lang %q: want go, ts, kotlin or swift", *lang) + } + if err != nil { + fail("generating %s: %v", *lang, err) + } + + if *out == "" { + _, _ = os.Stdout.Write(body) + return + } + if err := os.WriteFile(*out, body, 0o644); err != nil { //nolint:gosec // generated source + fail("writing %s: %v", *out, err) + } +} + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "settingsgen: "+format+"\n", args...) + os.Exit(1) +} + +func defaultString(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +// remoteAndLocal returns every definition, sorted by key so the output is +// stable: a generator whose output depends on manifest authoring order would +// produce spurious diffs on every unrelated manifest edit. +func sortedDefinitions(contract *settingscontract.Manifest) []*settingscontract.Definition { + defs := make([]*settingscontract.Definition, 0, len(contract.Definitions)) + for i := range contract.Definitions { + defs = append(defs, &contract.Definitions[i]) + } + sort.Slice(defs, func(i, j int) bool { return defs[i].Key < defs[j].Key }) + return defs +} + +// identifierFor turns a dotted key into a language identifier: +// playback.subtitle_language becomes PlaybackSubtitleLanguage. +func identifierFor(key string) string { + var out strings.Builder + for _, part := range strings.FieldsFunc(key, func(r rune) bool { + return r == '.' || r == '_' || r == '-' + }) { + out.WriteString(strings.ToUpper(part[:1])) + out.WriteString(part[1:]) + } + return out.String() +} + +// screamingCase turns a dotted key into PLAYBACK_SUBTITLE_LANGUAGE. +func screamingCase(key string) string { + replaced := strings.NewReplacer(".", "_", "-", "_").Replace(key) + return strings.ToUpper(replaced) +} + +const generatedHeader = `Code generated by cmd/settingsgen from contracts/settings/v1/manifest.json. DO NOT EDIT. + +Regenerate with: make settings-bindings + +Every key, type, scope and default here comes from the manifest, so a client +cannot drift from the server's contract by editing a constant. Adding a setting +is a manifest change plus a regeneration, never a hand-written key.` + +func generateGo(contract *settingscontract.Manifest, pkg string) ([]byte, error) { + var out bytes.Buffer + for _, line := range strings.Split(generatedHeader, "\n") { + out.WriteString(strings.TrimRight("// "+line, " ") + "\n") + } + fmt.Fprintf(&out, "\npackage %s\n\n", pkg) + fmt.Fprintf(&out, "// Revision is the manifest revision these bindings were generated from.\nconst Revision = %d\n\n", + contract.Revision) + + out.WriteString("// Setting keys, one constant per definition.\nconst (\n") + for _, def := range sortedDefinitions(contract) { + fmt.Fprintf(&out, "\t// %s\n", def.Label) + fmt.Fprintf(&out, "\t%s = %q\n", identifierFor(def.Key), def.Key) + } + out.WriteString(")\n\n") + + out.WriteString("// Remote lists every key the server stores.\nvar Remote = []string{\n") + for _, def := range sortedDefinitions(contract) { + if def.IsRemote() { + fmt.Fprintf(&out, "\t%s,\n", identifierFor(def.Key)) + } + } + out.WriteString("}\n\n") + + out.WriteString("// ClientLocal lists keys the contract defines but the server never stores.\n") + out.WriteString("var ClientLocal = []string{\n") + for _, def := range sortedDefinitions(contract) { + if !def.IsRemote() { + fmt.Fprintf(&out, "\t%s,\n", identifierFor(def.Key)) + } + } + out.WriteString("}\n") + return out.Bytes(), nil +} + +func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) { + var out bytes.Buffer + out.WriteString("/**\n") + for _, line := range strings.Split(generatedHeader, "\n") { + out.WriteString(strings.TrimRight(" * "+line, " ") + "\n") + } + out.WriteString(" */\n\n") + + fmt.Fprintf(&out, "export const SETTINGS_REVISION = %d;\n\n", contract.Revision) + + out.WriteString("export const SETTING_KEYS = {\n") + for _, def := range sortedDefinitions(contract) { + fmt.Fprintf(&out, " /** %s */\n", def.Label) + fmt.Fprintf(&out, " %s: %q,\n", screamingCase(def.Key), def.Key) + } + out.WriteString("} as const;\n\n") + out.WriteString("export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];\n\n") + + // The full definition table, so the UI can render controls from the + // contract rather than a hand-kept parallel manifest. + out.WriteString("export interface SettingDefinition {\n") + out.WriteString(" key: SettingKey;\n") + out.WriteString(" type: string;\n") + out.WriteString(" nullable: boolean;\n") + out.WriteString(" persistence: \"remote\" | \"client_local\";\n") + out.WriteString(" scopes: readonly string[];\n") + out.WriteString(" resolutionOrder: readonly string[];\n") + out.WriteString(" defaultValue: unknown;\n") + out.WriteString(" label: string;\n") + out.WriteString(" description: string;\n") + out.WriteString(" category: string;\n") + out.WriteString(" control?: string;\n") + out.WriteString(" unit?: string;\n") + out.WriteString(" values?: readonly { value: unknown; label: string }[];\n") + out.WriteString(" minimum?: number;\n") + out.WriteString(" maximum?: number;\n") + out.WriteString(" step?: number;\n") + out.WriteString("}\n\n") + + out.WriteString("export const SETTING_DEFINITIONS: Record = {\n") + for _, def := range sortedDefinitions(contract) { + fmt.Fprintf(&out, " %q: {\n", def.Key) + fmt.Fprintf(&out, " key: %q,\n", def.Key) + fmt.Fprintf(&out, " type: %q,\n", def.ValueSchema.Type) + fmt.Fprintf(&out, " nullable: %t,\n", def.ValueSchema.Nullable) + fmt.Fprintf(&out, " persistence: %q,\n", def.Persistence) + fmt.Fprintf(&out, " scopes: [%s],\n", quotedScopes(def)) + fmt.Fprintf(&out, " resolutionOrder: [%s],\n", quotedResolution(def)) + fmt.Fprintf(&out, " defaultValue: %s,\n", defaultLiteral(def)) + fmt.Fprintf(&out, " label: %s,\n", jsString(def.Label)) + fmt.Fprintf(&out, " description: %s,\n", jsString(def.Description)) + fmt.Fprintf(&out, " category: %q,\n", def.Category) + if def.Control != "" { + fmt.Fprintf(&out, " control: %q,\n", def.Control) + } + if def.Unit != "" { + fmt.Fprintf(&out, " unit: %q,\n", def.Unit) + } + if len(def.ValueSchema.Values) > 0 { + out.WriteString(" values: [\n") + for _, member := range def.ValueSchema.Values { + encoded, err := json.Marshal(member.Value) + if err != nil { + return nil, err + } + fmt.Fprintf(&out, " { value: %s, label: %s },\n", + encoded, jsString(member.Label)) + } + out.WriteString(" ],\n") + } + if minimum, ok := def.ValueSchema.Minimum.Current(); ok { + fmt.Fprintf(&out, " minimum: %s,\n", trimFloat(minimum)) + } + if maximum, ok := def.ValueSchema.Maximum.Current(); ok { + fmt.Fprintf(&out, " maximum: %s,\n", trimFloat(maximum)) + } + if def.ValueSchema.Step != nil { + fmt.Fprintf(&out, " step: %s,\n", trimFloat(*def.ValueSchema.Step)) + } + out.WriteString(" },\n") + } + out.WriteString("};\n") + return out.Bytes(), nil +} + +func generateKotlin(contract *settingscontract.Manifest, pkg string) ([]byte, error) { + var out bytes.Buffer + for _, line := range strings.Split(generatedHeader, "\n") { + out.WriteString(strings.TrimRight("// "+line, " ") + "\n") + } + fmt.Fprintf(&out, "\npackage %s\n\n", pkg) + + out.WriteString("object SettingKeys {\n") + fmt.Fprintf(&out, " const val REVISION = %d\n\n", contract.Revision) + for _, def := range sortedDefinitions(contract) { + fmt.Fprintf(&out, " /** %s */\n", def.Label) + fmt.Fprintf(&out, " const val %s = %q\n", screamingCase(def.Key), def.Key) + } + + // The allowlist Android maintained by hand, generated instead. The whole + // class of "wrote a local key to the server" bug is a manifest question now. + out.WriteString("\n /** Every key the server stores. Safe to flush. */\n") + out.WriteString(" val REMOTE: List = listOf(\n") + for _, def := range sortedDefinitions(contract) { + if def.IsRemote() { + fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key)) + } + } + out.WriteString(" )\n\n") + + out.WriteString(" /** Contract-known keys that never leave the device. */\n") + out.WriteString(" val CLIENT_LOCAL: List = listOf(\n") + for _, def := range sortedDefinitions(contract) { + if !def.IsRemote() { + fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key)) + } + } + out.WriteString(" )\n") + + // Type classification, which Android kept as a second hand-maintained table + // that had to agree with the first. + for _, group := range []struct { + name string + types []settingscontract.ValueType + }{ + {"BOOLEAN_KEYS", []settingscontract.ValueType{settingscontract.TypeBoolean}}, + {"INT_KEYS", []settingscontract.ValueType{settingscontract.TypeInteger}}, + {"DOUBLE_KEYS", []settingscontract.ValueType{settingscontract.TypeNumber}}, + } { + fmt.Fprintf(&out, "\n val %s: Set = setOf(\n", group.name) + for _, def := range sortedDefinitions(contract) { + for _, want := range group.types { + if def.ValueSchema.Type == want { + fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key)) + } + } + } + out.WriteString(" )\n") + } + + out.WriteString("}\n") + return out.Bytes(), nil +} + +func generateSwift(contract *settingscontract.Manifest) ([]byte, error) { + var out bytes.Buffer + for _, line := range strings.Split(generatedHeader, "\n") { + out.WriteString(strings.TrimRight("// "+line, " ") + "\n") + } + out.WriteString("\nimport Foundation\n\n") + + out.WriteString("/// Every setting the contract defines.\n") + out.WriteString("public enum SettingKey: String, CaseIterable, Sendable {\n") + for _, def := range sortedDefinitions(contract) { + fmt.Fprintf(&out, " /// %s\n", def.Label) + fmt.Fprintf(&out, " case %s = %q\n", lowerFirst(identifierFor(def.Key)), def.Key) + } + out.WriteString("}\n\n") + + out.WriteString("public extension SettingKey {\n") + fmt.Fprintf(&out, " static let revision = %d\n\n", contract.Revision) + + out.WriteString(" /// Keys the server stores. The rest never leave the device.\n") + out.WriteString(" static let remote: [SettingKey] = [\n") + for _, def := range sortedDefinitions(contract) { + if def.IsRemote() { + fmt.Fprintf(&out, " .%s,\n", lowerFirst(identifierFor(def.Key))) + } + } + out.WriteString(" ]\n\n") + + out.WriteString(" static let clientLocal: [SettingKey] = [\n") + for _, def := range sortedDefinitions(contract) { + if !def.IsRemote() { + fmt.Fprintf(&out, " .%s,\n", lowerFirst(identifierFor(def.Key))) + } + } + out.WriteString(" ]\n") + out.WriteString("}\n") + return out.Bytes(), nil +} + +func lowerFirst(value string) string { + if value == "" { + return value + } + return strings.ToLower(value[:1]) + value[1:] +} + +func quotedScopes(def *settingscontract.Definition) string { + parts := make([]string, 0, len(def.AllowedScopes)) + for _, entry := range def.AllowedScopes { + parts = append(parts, fmt.Sprintf("%q", entry.Scope)) + } + return strings.Join(parts, ", ") +} + +func quotedResolution(def *settingscontract.Definition) string { + parts := make([]string, 0, len(def.ResolutionOrder)) + for _, scope := range def.ResolutionOrder { + parts = append(parts, fmt.Sprintf("%q", scope)) + } + return strings.Join(parts, ", ") +} + +func defaultLiteral(def *settingscontract.Definition) string { + if len(def.DefaultValue) == 0 { + return "null" + } + return string(bytes.TrimSpace(def.DefaultValue)) +} + +func jsString(value string) string { + encoded, err := json.Marshal(value) + if err != nil { + return `""` + } + return string(encoded) +} + +func trimFloat(value float64) string { + return strings.TrimSuffix(fmt.Sprintf("%g", value), ".0") +} diff --git a/internal/settingskeys/keys.go b/internal/settingskeys/keys.go new file mode 100644 index 00000000..2ed7080f --- /dev/null +++ b/internal/settingskeys/keys.go @@ -0,0 +1,171 @@ +// Code generated by cmd/settingsgen from contracts/settings/v1/manifest.json. DO NOT EDIT. +// +// Regenerate with: make settings-bindings +// +// Every key, type, scope and default here comes from the manifest, so a client +// cannot drift from the server's contract by editing a constant. Adding a setting +// is a manifest change plus a regeneration, never a hand-written key. + +package settingskeys + +// Revision is the manifest revision these bindings were generated from. +const Revision = 1 + +// Setting keys, one constant per definition. +const ( + // Metadata language + CatalogMetadataLanguage = "catalog.metadata_language" + // Download quality + DownloadsDefaultQuality = "downloads.default_quality" + // Keep watched downloads + DownloadsKeepWatched = "downloads.keep_watched" + // Download over Wi-Fi only + DownloadsWifiOnly = "downloads.wifi_only" + // Show audiobooks + NavShowAudiobooks = "nav.show_audiobooks" + // Preferred audio language + PlaybackAudioLanguage = "playback.audio_language" + // Auto-play next episode + PlaybackAutoPlayNext = "playback.auto_play_next" + // Preview next episode + PlaybackAutoPlayNextPreview = "playback.auto_play_next_preview" + // Auto-skip credits + PlaybackAutoSkipCredits = "playback.auto_skip_credits" + // Auto-skip intros + PlaybackAutoSkipIntro = "playback.auto_skip_intro" + // Auto-skip recaps + PlaybackAutoSkipRecap = "playback.auto_skip_recap" + // Maximum bitrate + PlaybackMaxBitrateKbps = "playback.max_bitrate_kbps" + // Next up prompt + PlaybackNextUpPromptSeconds = "playback.next_up_prompt_seconds" + // Preferred quality + PlaybackPreferredQuality = "playback.preferred_quality" + // Show forced subtitles + PlaybackShowForcedSubtitles = "playback.show_forced_subtitles" + // Subtitle appearance + PlaybackSubtitleAppearance = "playback.subtitle_appearance" + // Preferred subtitle language + PlaybackSubtitleLanguage = "playback.subtitle_language" + // Subtitles + PlaybackSubtitleMode = "playback.subtitle_mode" + // Audio sync offset + PlayerAudioSyncMs = "player.audio_sync_ms" + // Dolby Vision + PlayerDolbyVisionEnabled = "player.dolby_vision_enabled" + // Dolby Vision Profile 7 fallback + PlayerDvProfile7Hdr10Fallback = "player.dv_profile7_hdr10_fallback" + // HDR + PlayerHdrEnabled = "player.hdr_enabled" + // Match content frame rate + PlayerMatchFrameRate = "player.match_frame_rate" + // Screen orientation + PlayerOrientationMode = "player.orientation_mode" + // Still watching prompt + PlayerPassoutThreshold = "player.passout_threshold" + // Picture in picture + PlayerPictureInPictureEnabled = "player.picture_in_picture_enabled" + // Playback speed + PlayerPlaybackSpeed = "player.playback_speed" + // Rewind on resume + PlayerResumeRewindSeconds = "player.resume_rewind_seconds" + // Seek cache + PlayerSeekCacheEnabled = "player.seek_cache_enabled" + // Default sleep timer + PlayerSleepTimerDefaultMinutes = "player.sleep_timer_default_minutes" + // Subtitle sync offset + PlayerSubtitleSyncMs = "player.subtitle_sync_ms" + // Video sizing + PlayerVideoGravity = "player.video_gravity" + // Search scope + SearchMediaScope = "search.media_scope" + // Match device caption settings + SubtitleMatchesDevice = "subtitle.matches_device" + // Poster badges + UiCardOverlays = "ui.card_overlays" + // Custom CSS + UiCustomCss = "ui.custom_css" + // Custom theme variables + UiCustomThemeVars = "ui.custom_theme_vars" + // Date format + UiDateFormat = "ui.date_format" + // Hidden libraries + UiDisabledLibraryIds = "ui.disabled_library_ids" + // High contrast + UiHighContrast = "ui.high_contrast" + // Library order + UiLibraryOrder = "ui.library_order" + // Remembered library view + UiLibraryPageState = "ui.library_page_state" + // Next up episodes + UiNextUpMode = "ui.next_up_mode" + // Remember library view + UiRememberLibraryPageState = "ui.remember_library_page_state" + // Pinned sidebar items + UiSidebarPins = "ui.sidebar_pins" + // Text size + UiTextScale = "ui.text_scale" + // Text weight + UiTextWeight = "ui.text_weight" + // Theme + UiTheme = "ui.theme" + // Time format + UiTimeFormat = "ui.time_format" +) + +// Remote lists every key the server stores. +var Remote = []string{ + CatalogMetadataLanguage, + PlaybackAudioLanguage, + PlaybackAutoPlayNext, + PlaybackAutoPlayNextPreview, + PlaybackAutoSkipCredits, + PlaybackAutoSkipIntro, + PlaybackAutoSkipRecap, + PlaybackMaxBitrateKbps, + PlaybackNextUpPromptSeconds, + PlaybackPreferredQuality, + PlaybackShowForcedSubtitles, + PlaybackSubtitleAppearance, + PlaybackSubtitleLanguage, + PlaybackSubtitleMode, + PlayerAudioSyncMs, + PlayerDolbyVisionEnabled, + PlayerDvProfile7Hdr10Fallback, + PlayerHdrEnabled, + PlayerMatchFrameRate, + PlayerOrientationMode, + PlayerPlaybackSpeed, + PlayerSeekCacheEnabled, + PlayerSleepTimerDefaultMinutes, + PlayerSubtitleSyncMs, + PlayerVideoGravity, + SearchMediaScope, + UiCardOverlays, + UiCustomCss, + UiCustomThemeVars, + UiDateFormat, + UiDisabledLibraryIds, + UiHighContrast, + UiLibraryOrder, + UiLibraryPageState, + UiNextUpMode, + UiRememberLibraryPageState, + UiSidebarPins, + UiTextScale, + UiTextWeight, + UiTheme, + UiTimeFormat, +} + +// ClientLocal lists keys the contract defines but the server never stores. +var ClientLocal = []string{ + DownloadsDefaultQuality, + DownloadsKeepWatched, + DownloadsWifiOnly, + NavShowAudiobooks, + PlayerPassoutThreshold, + PlayerPictureInPictureEnabled, + PlayerResumeRewindSeconds, + SubtitleMatchesDevice, +} diff --git a/settingsgen b/settingsgen new file mode 100755 index 00000000..b84f86f6 Binary files /dev/null and b/settingsgen differ diff --git a/web/src/lib/settingsContract.ts b/web/src/lib/settingsContract.ts new file mode 100644 index 00000000..37a89261 --- /dev/null +++ b/web/src/lib/settingsContract.ts @@ -0,0 +1,872 @@ +/** + * Code generated by cmd/settingsgen from contracts/settings/v1/manifest.json. DO NOT EDIT. + * + * Regenerate with: make settings-bindings + * + * Every key, type, scope and default here comes from the manifest, so a client + * cannot drift from the server's contract by editing a constant. Adding a setting + * is a manifest change plus a regeneration, never a hand-written key. + */ + +export const SETTINGS_REVISION = 1; + +export const SETTING_KEYS = { + /** Metadata language */ + CATALOG_METADATA_LANGUAGE: "catalog.metadata_language", + /** Download quality */ + DOWNLOADS_DEFAULT_QUALITY: "downloads.default_quality", + /** Keep watched downloads */ + DOWNLOADS_KEEP_WATCHED: "downloads.keep_watched", + /** Download over Wi-Fi only */ + DOWNLOADS_WIFI_ONLY: "downloads.wifi_only", + /** Show audiobooks */ + NAV_SHOW_AUDIOBOOKS: "nav.show_audiobooks", + /** Preferred audio language */ + PLAYBACK_AUDIO_LANGUAGE: "playback.audio_language", + /** Auto-play next episode */ + PLAYBACK_AUTO_PLAY_NEXT: "playback.auto_play_next", + /** Preview next episode */ + PLAYBACK_AUTO_PLAY_NEXT_PREVIEW: "playback.auto_play_next_preview", + /** Auto-skip credits */ + PLAYBACK_AUTO_SKIP_CREDITS: "playback.auto_skip_credits", + /** Auto-skip intros */ + PLAYBACK_AUTO_SKIP_INTRO: "playback.auto_skip_intro", + /** Auto-skip recaps */ + PLAYBACK_AUTO_SKIP_RECAP: "playback.auto_skip_recap", + /** Maximum bitrate */ + PLAYBACK_MAX_BITRATE_KBPS: "playback.max_bitrate_kbps", + /** Next up prompt */ + PLAYBACK_NEXT_UP_PROMPT_SECONDS: "playback.next_up_prompt_seconds", + /** Preferred quality */ + PLAYBACK_PREFERRED_QUALITY: "playback.preferred_quality", + /** Show forced subtitles */ + PLAYBACK_SHOW_FORCED_SUBTITLES: "playback.show_forced_subtitles", + /** Subtitle appearance */ + PLAYBACK_SUBTITLE_APPEARANCE: "playback.subtitle_appearance", + /** Preferred subtitle language */ + PLAYBACK_SUBTITLE_LANGUAGE: "playback.subtitle_language", + /** Subtitles */ + PLAYBACK_SUBTITLE_MODE: "playback.subtitle_mode", + /** Audio sync offset */ + PLAYER_AUDIO_SYNC_MS: "player.audio_sync_ms", + /** Dolby Vision */ + PLAYER_DOLBY_VISION_ENABLED: "player.dolby_vision_enabled", + /** Dolby Vision Profile 7 fallback */ + PLAYER_DV_PROFILE7_HDR10_FALLBACK: "player.dv_profile7_hdr10_fallback", + /** HDR */ + PLAYER_HDR_ENABLED: "player.hdr_enabled", + /** Match content frame rate */ + PLAYER_MATCH_FRAME_RATE: "player.match_frame_rate", + /** Screen orientation */ + PLAYER_ORIENTATION_MODE: "player.orientation_mode", + /** Still watching prompt */ + PLAYER_PASSOUT_THRESHOLD: "player.passout_threshold", + /** Picture in picture */ + PLAYER_PICTURE_IN_PICTURE_ENABLED: "player.picture_in_picture_enabled", + /** Playback speed */ + PLAYER_PLAYBACK_SPEED: "player.playback_speed", + /** Rewind on resume */ + PLAYER_RESUME_REWIND_SECONDS: "player.resume_rewind_seconds", + /** Seek cache */ + PLAYER_SEEK_CACHE_ENABLED: "player.seek_cache_enabled", + /** Default sleep timer */ + PLAYER_SLEEP_TIMER_DEFAULT_MINUTES: "player.sleep_timer_default_minutes", + /** Subtitle sync offset */ + PLAYER_SUBTITLE_SYNC_MS: "player.subtitle_sync_ms", + /** Video sizing */ + PLAYER_VIDEO_GRAVITY: "player.video_gravity", + /** Search scope */ + SEARCH_MEDIA_SCOPE: "search.media_scope", + /** Match device caption settings */ + SUBTITLE_MATCHES_DEVICE: "subtitle.matches_device", + /** Poster badges */ + UI_CARD_OVERLAYS: "ui.card_overlays", + /** Custom CSS */ + UI_CUSTOM_CSS: "ui.custom_css", + /** Custom theme variables */ + UI_CUSTOM_THEME_VARS: "ui.custom_theme_vars", + /** Date format */ + UI_DATE_FORMAT: "ui.date_format", + /** Hidden libraries */ + UI_DISABLED_LIBRARY_IDS: "ui.disabled_library_ids", + /** High contrast */ + UI_HIGH_CONTRAST: "ui.high_contrast", + /** Library order */ + UI_LIBRARY_ORDER: "ui.library_order", + /** Remembered library view */ + UI_LIBRARY_PAGE_STATE: "ui.library_page_state", + /** Next up episodes */ + UI_NEXT_UP_MODE: "ui.next_up_mode", + /** Remember library view */ + UI_REMEMBER_LIBRARY_PAGE_STATE: "ui.remember_library_page_state", + /** Pinned sidebar items */ + UI_SIDEBAR_PINS: "ui.sidebar_pins", + /** Text size */ + UI_TEXT_SCALE: "ui.text_scale", + /** Text weight */ + UI_TEXT_WEIGHT: "ui.text_weight", + /** Theme */ + UI_THEME: "ui.theme", + /** Time format */ + UI_TIME_FORMAT: "ui.time_format", +} as const; + +export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS]; + +export interface SettingDefinition { + key: SettingKey; + type: string; + nullable: boolean; + persistence: "remote" | "client_local"; + scopes: readonly string[]; + resolutionOrder: readonly string[]; + defaultValue: unknown; + label: string; + description: string; + category: string; + control?: string; + unit?: string; + values?: readonly { value: unknown; label: string }[]; + minimum?: number; + maximum?: number; + step?: number; +} + +export const SETTING_DEFINITIONS: Record = { + "catalog.metadata_language": { + key: "catalog.metadata_language", + type: "language_tag", + nullable: true, + persistence: "remote", + scopes: ["profile"], + resolutionOrder: ["profile", "default"], + defaultValue: null, + label: "Metadata language", + description: "Language Silo prefers for titles, descriptions, and artwork.", + category: "catalog", + control: "select", + }, + "downloads.default_quality": { + key: "downloads.default_quality", + type: "enum", + nullable: false, + persistence: "client_local", + scopes: ["client_local"], + resolutionOrder: ["client_local", "default"], + defaultValue: "original", + label: "Download quality", + description: "Quality preset used for new downloads.", + category: "downloads", + control: "select", + values: [ + { value: "1mbps", label: "1 Mbps" }, + { value: "2mbps", label: "2 Mbps" }, + { value: "5mbps", label: "5 Mbps" }, + { value: "10mbps", label: "10 Mbps" }, + { value: "20mbps", label: "20 Mbps" }, + { value: "original", label: "Original" }, + ], + }, + "downloads.keep_watched": { + key: "downloads.keep_watched", + type: "boolean", + nullable: false, + persistence: "client_local", + scopes: ["client_local"], + resolutionOrder: ["client_local", "default"], + defaultValue: false, + label: "Keep watched downloads", + description: "Do not suggest reclaiming space from downloads you have finished.", + category: "downloads", + control: "switch", + }, + "downloads.wifi_only": { + key: "downloads.wifi_only", + type: "boolean", + nullable: false, + persistence: "client_local", + scopes: ["client_local"], + resolutionOrder: ["client_local", "default"], + defaultValue: true, + label: "Download over Wi-Fi only", + description: "Only download while connected to Wi-Fi.", + category: "downloads", + control: "switch", + }, + "nav.show_audiobooks": { + key: "nav.show_audiobooks", + type: "boolean", + nullable: false, + persistence: "client_local", + scopes: ["client_local"], + resolutionOrder: ["client_local", "default"], + defaultValue: false, + label: "Show audiobooks", + description: "Show the Audiobooks section in navigation.", + category: "nav", + control: "switch", + }, + "playback.audio_language": { + key: "playback.audio_language", + type: "language_tag", + nullable: true, + persistence: "remote", + scopes: ["profile", "profile_device", "profile_library", "profile_series"], + resolutionOrder: ["profile_series", "profile_library", "profile_device", "profile", "default"], + defaultValue: null, + label: "Preferred audio language", + description: "Choose which spoken language Silo should prefer first.", + category: "playback", + control: "select", + }, + "playback.auto_play_next": { + key: "playback.auto_play_next", + type: "boolean", + nullable: false, + persistence: "remote", + scopes: ["profile", "profile_device"], + resolutionOrder: ["profile_device", "profile", "default"], + defaultValue: true, + label: "Auto-play next episode", + description: "Continue to the next episode automatically.", + category: "playback", + control: "switch", + }, + "playback.auto_play_next_preview": { + key: "playback.auto_play_next_preview", + type: "boolean", + nullable: false, + persistence: "remote", + scopes: ["profile", "profile_device"], + resolutionOrder: ["profile_device", "profile", "default"], + defaultValue: false, + label: "Preview next episode", + description: "Show a preview of the next episode while credits play.", + category: "playback", + control: "switch", + }, + "playback.auto_skip_credits": { + key: "playback.auto_skip_credits", + type: "boolean", + nullable: false, + persistence: "remote", + scopes: ["profile", "profile_device"], + resolutionOrder: ["profile_device", "profile", "default"], + defaultValue: false, + label: "Auto-skip credits", + description: "Move through end credits automatically when a skip is available.", + category: "playback", + control: "switch", + }, + "playback.auto_skip_intro": { + key: "playback.auto_skip_intro", + type: "boolean", + nullable: false, + persistence: "remote", + scopes: ["profile", "profile_device"], + resolutionOrder: ["profile_device", "profile", "default"], + defaultValue: false, + label: "Auto-skip intros", + description: "Jump past intros automatically when Silo can detect them.", + category: "playback", + control: "switch", + }, + "playback.auto_skip_recap": { + key: "playback.auto_skip_recap", + type: "boolean", + nullable: false, + persistence: "remote", + scopes: ["profile", "profile_device"], + resolutionOrder: ["profile_device", "profile", "default"], + defaultValue: false, + label: "Auto-skip recaps", + description: 'Skip "previously on" recaps automatically when Silo can detect them.', + category: "playback", + control: "switch", + }, + "playback.max_bitrate_kbps": { + key: "playback.max_bitrate_kbps", + type: "integer", + nullable: true, + persistence: "remote", + scopes: ["profile", "profile_device"], + resolutionOrder: ["profile_device", "profile", "default"], + defaultValue: null, + label: "Maximum bitrate", + description: + "Cap how much bandwidth playback may use. No cap means Silo picks for the chosen resolution.", + category: "playback", + control: "select", + unit: "kbps", + minimum: 100, + maximum: 200000, + }, + "playback.next_up_prompt_seconds": { + key: "playback.next_up_prompt_seconds", + type: "integer", + nullable: false, + persistence: "remote", + scopes: ["profile", "profile_device"], + resolutionOrder: ["profile_device", "profile", "default"], + defaultValue: 30, + label: "Next up prompt", + description: "How long before the end of an episode the next-up prompt appears.", + category: "playback", + control: "slider", + unit: "seconds", + minimum: 0, + maximum: 120, + }, + "playback.preferred_quality": { + key: "playback.preferred_quality", + type: "enum", + nullable: false, + persistence: "remote", + scopes: ["profile", "profile_device"], + resolutionOrder: ["profile_device", "profile", "default"], + defaultValue: "auto", + label: "Preferred quality", + description: "Pick the quality Silo should prefer.", + category: "playback", + control: "select", + values: [ + { value: "auto", label: "Auto" }, + { value: "480p", label: "480p" }, + { value: "720p", label: "720p" }, + { value: "1080p", label: "1080p" }, + { value: "2160p", label: "2160p / 4K" }, + { value: "original", label: "Original quality" }, + ], + }, + "playback.show_forced_subtitles": { + key: "playback.show_forced_subtitles", + type: "boolean", + nullable: false, + persistence: "remote", + scopes: ["profile", "profile_device", "profile_library", "profile_series"], + resolutionOrder: ["profile_series", "profile_library", "profile_device", "profile", "default"], + defaultValue: true, + label: "Show forced subtitles", + description: "Show subtitles for foreign-language dialogue even when subtitles are off.", + category: "playback", + control: "switch", + }, + "playback.subtitle_appearance": { + key: "playback.subtitle_appearance", + type: "object", + nullable: false, + persistence: "remote", + scopes: ["profile", "profile_device"], + resolutionOrder: ["profile_device", "profile", "default"], + defaultValue: { + fontSize: "large", + fontFamily: "sans-serif", + fontColor: "#ffffff", + backgroundColor: "#000000", + backgroundStyle: "shadow", + backgroundOpacity: 75, + textOutline: false, + textOutlineColor: "#000000", + position: "bottom", + }, + label: "Subtitle appearance", + description: "How subtitles are drawn during playback.", + category: "playback", + control: "panel", + }, + "playback.subtitle_language": { + key: "playback.subtitle_language", + type: "language_tag", + nullable: true, + persistence: "remote", + scopes: ["profile", "profile_device", "profile_library", "profile_series"], + resolutionOrder: ["profile_series", "profile_library", "profile_device", "profile", "default"], + defaultValue: null, + label: "Preferred subtitle language", + description: "Choose which subtitle language Silo should prefer first.", + category: "playback", + control: "select", + }, + "playback.subtitle_mode": { + key: "playback.subtitle_mode", + type: "enum", + nullable: false, + persistence: "remote", + scopes: ["profile", "profile_device", "profile_library", "profile_series"], + resolutionOrder: ["profile_series", "profile_library", "profile_device", "profile", "default"], + defaultValue: "auto", + label: "Subtitles", + description: "When Silo should turn subtitles on.", + category: "playback", + control: "select", + values: [ + { value: "auto", label: "Auto" }, + { value: "always", label: "Always on" }, + { value: "off", label: "Off" }, + ], + }, + "player.audio_sync_ms": { + key: "player.audio_sync_ms", + type: "integer", + nullable: false, + persistence: "remote", + scopes: ["profile_device"], + resolutionOrder: ["profile_device", "default"], + defaultValue: 0, + label: "Audio sync offset", + description: "Shift audio earlier or later to correct lip sync on this device.", + category: "player", + control: "slider", + unit: "milliseconds", + minimum: -5000, + maximum: 5000, + }, + "player.dolby_vision_enabled": { + key: "player.dolby_vision_enabled", + type: "boolean", + nullable: false, + persistence: "remote", + scopes: ["profile_device"], + resolutionOrder: ["profile_device", "default"], + defaultValue: true, + label: "Dolby Vision", + description: "Allow Dolby Vision output on this device.", + category: "player", + control: "switch", + }, + "player.dv_profile7_hdr10_fallback": { + key: "player.dv_profile7_hdr10_fallback", + type: "boolean", + nullable: false, + persistence: "remote", + scopes: ["profile_device"], + resolutionOrder: ["profile_device", "default"], + defaultValue: false, + label: "Dolby Vision Profile 7 fallback", + description: "Play Profile 7 sources as HDR10 when this device cannot decode them natively.", + category: "player", + control: "switch", + }, + "player.hdr_enabled": { + key: "player.hdr_enabled", + type: "boolean", + nullable: false, + persistence: "remote", + scopes: ["profile_device"], + resolutionOrder: ["profile_device", "default"], + defaultValue: true, + label: "HDR", + description: "Allow HDR output on this device.", + category: "player", + control: "switch", + }, + "player.match_frame_rate": { + key: "player.match_frame_rate", + type: "boolean", + nullable: false, + persistence: "remote", + scopes: ["profile_device"], + resolutionOrder: ["profile_device", "default"], + defaultValue: false, + label: "Match content frame rate", + description: "Switch the display refresh rate to match what is playing.", + category: "player", + control: "switch", + }, + "player.orientation_mode": { + key: "player.orientation_mode", + type: "enum", + nullable: false, + persistence: "remote", + scopes: ["profile_device"], + resolutionOrder: ["profile_device", "default"], + defaultValue: "landscapeLocked", + label: "Screen orientation", + description: "Whether the player rotates with the device.", + category: "player", + control: "select", + values: [ + { value: "landscapeLocked", label: "Landscape" }, + { value: "rotateFreely", label: "Rotate freely" }, + ], + }, + "player.passout_threshold": { + key: "player.passout_threshold", + type: "integer", + nullable: false, + persistence: "client_local", + scopes: ["client_local"], + resolutionOrder: ["client_local", "default"], + defaultValue: 3, + label: "Still watching prompt", + description: + "How many episodes auto-play before Silo asks whether you are still watching. 0 never asks.", + category: "player", + control: "stepper", + unit: "episodes", + minimum: 0, + maximum: 20, + }, + "player.picture_in_picture_enabled": { + key: "player.picture_in_picture_enabled", + type: "boolean", + nullable: false, + persistence: "client_local", + scopes: ["client_local"], + resolutionOrder: ["client_local", "default"], + defaultValue: true, + label: "Picture in picture", + description: "Keep playing in a floating window when you leave the player.", + category: "player", + control: "switch", + }, + "player.playback_speed": { + key: "player.playback_speed", + type: "number", + nullable: false, + persistence: "remote", + scopes: ["profile_device"], + resolutionOrder: ["profile_device", "default"], + defaultValue: 1.0, + label: "Playback speed", + description: "Default playback speed on this device.", + category: "player", + control: "slider", + unit: "x", + minimum: 0.25, + maximum: 3, + step: 0.05, + }, + "player.resume_rewind_seconds": { + key: "player.resume_rewind_seconds", + type: "integer", + nullable: false, + persistence: "client_local", + scopes: ["client_local"], + resolutionOrder: ["client_local", "default"], + defaultValue: 7, + label: "Rewind on resume", + description: + "Skip back this far when resuming a partly watched item, to re-establish context. 0 turns it off.", + category: "player", + control: "stepper", + unit: "seconds", + minimum: 0, + maximum: 30, + }, + "player.seek_cache_enabled": { + key: "player.seek_cache_enabled", + type: "boolean", + nullable: false, + persistence: "remote", + scopes: ["profile_device"], + resolutionOrder: ["profile_device", "default"], + defaultValue: true, + label: "Seek cache", + description: "Keep recently played segments buffered for faster seeking.", + category: "player", + control: "switch", + }, + "player.sleep_timer_default_minutes": { + key: "player.sleep_timer_default_minutes", + type: "integer", + nullable: false, + persistence: "remote", + scopes: ["profile_device"], + resolutionOrder: ["profile_device", "default"], + defaultValue: 30, + label: "Default sleep timer", + description: "Duration the sleep timer starts on when you turn it on. 0 leaves it off.", + category: "player", + control: "stepper", + unit: "minutes", + minimum: 0, + maximum: 240, + }, + "player.subtitle_sync_ms": { + key: "player.subtitle_sync_ms", + type: "integer", + nullable: false, + persistence: "remote", + scopes: ["profile_device"], + resolutionOrder: ["profile_device", "default"], + defaultValue: 0, + label: "Subtitle sync offset", + description: "Shift subtitles earlier or later on this device.", + category: "player", + control: "slider", + unit: "milliseconds", + minimum: -10000, + maximum: 10000, + }, + "player.video_gravity": { + key: "player.video_gravity", + type: "enum", + nullable: false, + persistence: "remote", + scopes: ["profile_device"], + resolutionOrder: ["profile_device", "default"], + defaultValue: "fit", + label: "Video sizing", + description: "How video fills the screen on this device.", + category: "player", + control: "select", + values: [ + { value: "fit", label: "Fit" }, + { value: "fill", label: "Fill" }, + { value: "stretch", label: "Stretch" }, + ], + }, + "search.media_scope": { + key: "search.media_scope", + type: "enum", + nullable: false, + persistence: "remote", + scopes: ["profile"], + resolutionOrder: ["profile", "default"], + defaultValue: "video", + label: "Search scope", + description: "What search covers by default.", + category: "search", + control: "select", + values: [ + { value: "all", label: "Everything" }, + { value: "video", label: "Movies and series" }, + { value: "audiobook", label: "Audiobooks" }, + ], + }, + "subtitle.matches_device": { + key: "subtitle.matches_device", + type: "boolean", + nullable: false, + persistence: "client_local", + scopes: ["client_local"], + resolutionOrder: ["client_local", "default"], + defaultValue: false, + label: "Match device caption settings", + description: "Use the operating system's caption style instead of Silo's.", + category: "playback", + control: "switch", + }, + "ui.card_overlays": { + key: "ui.card_overlays", + type: "object", + nullable: true, + persistence: "remote", + scopes: ["profile"], + resolutionOrder: ["profile", "default"], + defaultValue: null, + label: "Poster badges", + description: "Which badges appear on poster cards, and where.", + category: "appearance", + }, + "ui.custom_css": { + key: "ui.custom_css", + type: "string", + nullable: true, + persistence: "remote", + scopes: ["profile"], + resolutionOrder: ["profile", "default"], + defaultValue: null, + label: "Custom CSS", + description: "Raw CSS applied on top of the selected theme.", + category: "appearance", + control: "text", + }, + "ui.custom_theme_vars": { + key: "ui.custom_theme_vars", + type: "object", + nullable: true, + persistence: "remote", + scopes: ["profile"], + resolutionOrder: ["profile", "default"], + defaultValue: null, + label: "Custom theme variables", + description: "Per-token overrides applied on top of the selected theme.", + category: "appearance", + control: "panel", + }, + "ui.date_format": { + key: "ui.date_format", + type: "enum", + nullable: false, + persistence: "remote", + scopes: ["profile"], + resolutionOrder: ["profile", "default"], + defaultValue: "auto", + label: "Date format", + description: "How dates are written across the interface.", + category: "appearance", + control: "select", + values: [ + { value: "auto", label: "Match device" }, + { value: "DD/MM/YYYY", label: "" }, + { value: "MM/DD/YYYY", label: "" }, + { value: "YYYY-MM-DD", label: "" }, + ], + }, + "ui.disabled_library_ids": { + key: "ui.disabled_library_ids", + type: "object", + nullable: true, + persistence: "remote", + scopes: ["profile"], + resolutionOrder: ["profile", "default"], + defaultValue: null, + label: "Hidden libraries", + description: "Libraries you have hidden from your own browsing.", + category: "navigation", + }, + "ui.high_contrast": { + key: "ui.high_contrast", + type: "boolean", + nullable: false, + persistence: "remote", + scopes: ["profile", "profile_device"], + resolutionOrder: ["profile_device", "profile", "default"], + defaultValue: false, + label: "High contrast", + description: "Increase contrast across the interface.", + category: "appearance", + control: "switch", + }, + "ui.library_order": { + key: "ui.library_order", + type: "object", + nullable: true, + persistence: "remote", + scopes: ["profile"], + resolutionOrder: ["profile", "default"], + defaultValue: null, + label: "Library order", + description: "The order your libraries appear in.", + category: "navigation", + }, + "ui.library_page_state": { + key: "ui.library_page_state", + type: "object", + nullable: true, + persistence: "remote", + scopes: ["profile_device"], + resolutionOrder: ["profile_device", "default"], + defaultValue: null, + label: "Remembered library view", + description: "Saved browse state for each library.", + category: "navigation", + }, + "ui.next_up_mode": { + key: "ui.next_up_mode", + type: "enum", + nullable: false, + persistence: "remote", + scopes: ["profile"], + resolutionOrder: ["profile", "default"], + defaultValue: "combined", + label: "Next up episodes", + description: "Whether upcoming episodes stay with Continue Watching or get their own row.", + category: "navigation", + control: "select", + values: [ + { value: "combined", label: "With Continue Watching" }, + { value: "separate", label: "Separate row" }, + ], + }, + "ui.remember_library_page_state": { + key: "ui.remember_library_page_state", + type: "boolean", + nullable: false, + persistence: "remote", + scopes: ["profile_device"], + resolutionOrder: ["profile_device", "default"], + defaultValue: true, + label: "Remember library view", + description: "Return to where you left off when reopening a library.", + category: "navigation", + control: "switch", + }, + "ui.sidebar_pins": { + key: "ui.sidebar_pins", + type: "object", + nullable: true, + persistence: "remote", + scopes: ["profile"], + resolutionOrder: ["profile", "default"], + defaultValue: null, + label: "Pinned sidebar items", + description: "Sections and collections pinned into the sidebar.", + category: "navigation", + }, + "ui.text_scale": { + key: "ui.text_scale", + type: "enum", + nullable: false, + persistence: "remote", + scopes: ["profile", "profile_device"], + resolutionOrder: ["profile_device", "profile", "default"], + defaultValue: "default", + label: "Text size", + description: "Overall interface text size.", + category: "appearance", + control: "select", + values: [ + { value: "default", label: "Default" }, + { value: "large", label: "Large" }, + { value: "x-large", label: "Extra large" }, + ], + }, + "ui.text_weight": { + key: "ui.text_weight", + type: "enum", + nullable: false, + persistence: "remote", + scopes: ["profile", "profile_device"], + resolutionOrder: ["profile_device", "profile", "default"], + defaultValue: "default", + label: "Text weight", + description: "Use heavier interface text for readability.", + category: "appearance", + control: "select", + values: [ + { value: "default", label: "Default" }, + { value: "strong", label: "Bolder" }, + ], + }, + "ui.theme": { + key: "ui.theme", + type: "enum", + nullable: false, + persistence: "remote", + scopes: ["profile", "profile_device"], + resolutionOrder: ["profile_device", "profile", "default"], + defaultValue: "midnight-cinema", + label: "Theme", + description: "Colour theme for the Silo interface.", + category: "appearance", + control: "select", + values: [ + { value: "midnight-cinema", label: "Midnight Cinema" }, + { value: "cinema-light", label: "Cinema Light" }, + { value: "cobalt-studio", label: "Cobalt Studio" }, + { value: "oxblood-noir", label: "Oxblood Noir" }, + { value: "evergreen-studio", label: "Evergreen Studio" }, + ], + }, + "ui.time_format": { + key: "ui.time_format", + type: "enum", + nullable: false, + persistence: "remote", + scopes: ["profile"], + resolutionOrder: ["profile", "default"], + defaultValue: "auto", + label: "Time format", + description: "How clock times are written across the interface.", + category: "appearance", + control: "select", + values: [ + { value: "auto", label: "Match device" }, + { value: "12h", label: "12-hour" }, + { value: "24h", label: "24-hour" }, + ], + }, +};