feat(settings): generate typed bindings for all four languages

One generator rather than one per repo. The 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.

Go and TypeScript land in this repo; Kotlin and Swift are written into the
sibling client checkouts, skipped with a note when they are not present so
a server-only developer can still run it. Output is sorted by key so an
unrelated manifest edit does not produce spurious diffs.

The Kotlin output is the interesting one: it generates the DeviceSettings
allowlist Android maintained by hand, plus the BOOLEAN_KEYS/INT_KEYS/
DOUBLE_KEYS classification it kept as a *second* hand-maintained table that
had to agree with the first. Both are manifest questions now, so the whole
class of "wrote a local key to the server" and "flushed a value the store
could not parse" bugs stops being possible by construction.

The TypeScript output carries the full definition table — labels, controls,
enum members, bounds — so web/src/lib/settingsManifest.ts can be deleted
rather than kept in sync: it declared 17 definitions against the contract's
49, with its own two-scope model that does not match the contract's five.

make verify-settings-bindings fails when the committed output disagrees
with the manifest, wired into CI, so a manifest change cannot merge leaving
every client reading stale keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-28 00:05:57 +00:00
co-authored by Claude Opus 5
parent cdbb2997fd
commit 24ee99523d
6 changed files with 1460 additions and 1 deletions
+5
View File
@@ -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
+38 -1
View File
@@ -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
+374
View File
@@ -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 <path> -package org.siloserver.silo.model.settings
// settingsgen -lang swift -out <path>
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<SettingKey, SettingDefinition> = {\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<String> = 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<String> = 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<String> = 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")
}
+171
View File
@@ -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,
}
Executable
BIN
View File
Binary file not shown.
+872
View File
@@ -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<SettingKey, SettingDefinition> = {
"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" },
],
},
};