Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions iosApp/Tests/Fixtures/SettingsContract/SOURCE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repository=https://github.com/Silo-Server/silo-server
path=contracts/settings/v1
manifest_revision=9
manifest_revision=14
fixture_version=1

Both files are byte-identical copies of the server's canonical settings
Expand All @@ -14,10 +14,10 @@ vendored manifest's revision, and the generated SettingKey.revision stop
agreeing, which is the whole point of carrying the pair rather than the
fixture alone.

conformance.json commit=e97d4861dfd019092dd955c4378649c48c298309
manifest.json commit=be1c7675fc8186cdecee7002e5c03a48e9820005
copied from commit=9d037ab6071d37d3185a203600dce810c01d044d
generated with commit=9d037ab6071d37d3185a203600dce810c01d044d (cmd/settingsgen -lang swift)
conformance.json commit=86ea1a1baf9bae2ce02b17f915817b2c76aae2b9
manifest.json commit=86ea1a1baf9bae2ce02b17f915817b2c76aae2b9
copied from commit=86ea1a1baf9bae2ce02b17f915817b2c76aae2b9
generated with commit=86ea1a1baf9bae2ce02b17f915817b2c76aae2b9 (cmd/settingsgen -lang swift)

manifest.json is vendored whole, including the maintainer `notes` the server
strips before serving /api/v1/settings/contract. Keeping it byte-identical is
Expand All @@ -32,5 +32,5 @@ contract rather than by a hand-copied table.

Android vendors the same pair at
shared/src/commonTest/resources/settings/v1/ in silo-android; the two SOURCE
notes are meant to stay in step. Android was at revision 7 when this copy moved
to revision 9.
notes are meant to stay in step. Android moves to revision 14 in its matching
text-opacity change.
65 changes: 64 additions & 1 deletion iosApp/Tests/Fixtures/SettingsContract/conformance.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"fixture_version": 1,
"manifest_revision": 9,
"manifest_revision": 14,
"description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.",
"cases": [
{
Expand Down Expand Up @@ -849,6 +849,7 @@
"fontSize": "large",
"fontFamily": "sans-serif",
"fontColor": "#ffffff",
"textOpacity": 100,
"backgroundColor": "#000000",
"backgroundStyle": "box",
"backgroundOpacity": 75,
Expand Down Expand Up @@ -1002,6 +1003,68 @@
"source": "default"
}
]
},
{
"name": "advisory_age_badge_is_off_until_a_profile_asks",
"description": "The advisory-age badge is per profile and off unless that profile turned it on. p1 opted in, so it resolves true from the profile; p2 stored nothing and falls to the contract default false. A profile row must never leak across profiles here, because the setting decides what a household parent sees about a child's viewing.",
"keys": ["catalog.show_advisory_age"],
"context": { "profile_id": "p2", "device_id": "d1" },
"stored": [
{
"key": "catalog.show_advisory_age",
"scope": "profile",
"profile_id": "p1",
"value": true
}
],
"expected": [
{
"key": "catalog.show_advisory_age",
"value": false,
"source": "default"
}
]
},
{
"name": "advisory_age_badge_resolves_from_the_opted_in_profile",
"description": "The same stored row resolves true for the profile that owns it, with source \"profile\" rather than the default.",
"keys": ["catalog.show_advisory_age"],
"context": { "profile_id": "p1", "device_id": "d1" },
"stored": [
{
"key": "catalog.show_advisory_age",
"scope": "profile",
"profile_id": "p1",
"value": true
}
],
"expected": [
{
"key": "catalog.show_advisory_age",
"value": true,
"source": "profile"
}
]
},
{
"name": "theme_music_is_opt_in",
"keys": ["ui.theme_music_enabled", "ui.theme_music_loop"],
"context": {"profile_id": "p1", "device_id": "browser"},
"stored": [],
"expected": [
{"key": "ui.theme_music_enabled", "value": false, "source": "default"},
{"key": "ui.theme_music_loop", "value": false, "source": "default"}
]
},
{
"name": "theme_music_device_overrides_profile",
"keys": ["ui.theme_music_enabled"],
"context": {"profile_id": "p1", "device_id": "browser"},
"stored": [
{"key": "ui.theme_music_enabled", "scope": "profile", "profile_id": "p1", "value": true},
{"key": "ui.theme_music_enabled", "scope": "profile_device", "profile_id": "p1", "device_id": "browser", "value": false}
],
"expected": [{"key": "ui.theme_music_enabled", "value": false, "source": "profile_device"}]
}
]
}
96 changes: 89 additions & 7 deletions iosApp/Tests/Fixtures/SettingsContract/manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"api_version": 1,
"revision": 9,
"revision": 14,
"option_sets": {
"playback_audio_languages": {
"type": "language_tag",
Expand Down Expand Up @@ -365,7 +365,7 @@
"recommended_control": "select",
"suggested_options": "playback_audio_languages",
"unset_label": "No preference",
"notes": "Migrates user_profiles.language as the roaming fallback. Existing user_device_settings values become real overrides, and the per-series value comes from AudioPreference.audio_language. AudioPreference.audio_track_index and track_signature stay specialized: they identify a concrete track, not a default. The legacy string-only endpoint has no way to send null, so it spells \"no preference\" as the empty string and both Android and web send that to clear the choice; its validator accepts \"\" and otherwise requires a well-formed tag via settingscontract.NormalizeLanguageTag. Migration maps \"\" to no stored row, the same way playback.subtitle_mode handles it."
"notes": "Migrates user_profiles.language as the roaming fallback. Existing user_device_settings values become real overrides, and the per-series value comes from AudioPreference.audio_language. AudioPreference.audio_track_index and track_signature stay specialized: they identify a concrete track, not a default. The legacy string-only endpoint has no way to send null, so it spells \"no preference\" as the empty string and both Android and web send that to clear the choice; its validator accepts \"\" and otherwise requires a well-formed tag via settingscontract.NormalizeLanguageTag. Migration maps \"\" to no stored row, the same way playback.subtitle_mode handles it. From revision 9, the private-use tag x-silo-original means play each item in its original language: the server resolves it against the item's original_language (falling back to the profile choice when that is unknown), as it already did for the legacy \"original\" value. It is a valid value of the existing language_tag schema, so this is additive; clients that do not recognize it should label it \"Original language\" rather than as a language. The Jellyfin-compatible API maps Jellyfin's AudioLanguagePreference \"OriginalLanguage\" to it."
},
{
"key": "playback.subtitle_language",
Expand Down Expand Up @@ -450,6 +450,7 @@
"backgroundColor": "#000000",
"backgroundStyle": "box",
"backgroundOpacity": 75,
"textOpacity": 100,
"textOutline": false,
"textOutlineColor": "#000000",
"position": "bottom"
Expand All @@ -458,7 +459,7 @@
"label": "Subtitle appearance",
"description": "How subtitles are drawn during playback.",
"recommended_control": "panel",
"notes": "Renamed from the unprefixed legacy key \"subtitle_appearance\". Every other canonical key carries a domain prefix, and preserving accidental key names is an explicit non-goal of the design. The rename touches three URL paths in internal/api/router.go, the admin device-settings routes, and the key constant in every client, so it cannot land without them. Migration copies the account-level legacy fallback to every existing profile and leaves device overrides unchanged, rewriting the key on each row. Revision 6 changes the fallback background from shadow to a 75% opaque box, matching the previous intended web default and the current Apple fallback. Connected clients receive the complete effective default from the server; clients should adopt revision 6 so their local and sparse-object decoding fallbacks match it. Stored profile and device overrides remain authoritative."
"notes": "Renamed from the unprefixed legacy key \"subtitle_appearance\". Every other canonical key carries a domain prefix, and preserving accidental key names is an explicit non-goal of the design. The rename touches three URL paths in internal/api/router.go, the admin device-settings routes, and the key constant in every client, so it cannot land without them. Migration copies the account-level legacy fallback to every existing profile and leaves device overrides unchanged, rewriting the key on each row. Revision 6 changes the fallback background from shadow to a 75% opaque box, matching the previous intended web default and the current Apple fallback. Connected clients receive the complete effective default from the server; clients should adopt revision 6 so their local and sparse-object decoding fallbacks match it. Stored profile and device overrides remain authoritative. Revision 14 adds the optional textOpacity property (1-100, default 100) controlling the opacity of the subtitle text itself, independent of backgroundOpacity; a value stored without it resolves to fully opaque text, matching pre-14 rendering."
},
{
"key": "playback.preferred_quality",
Expand Down Expand Up @@ -645,6 +646,20 @@
"recommended_control": "panel",
"notes": "Keys are canonical catalog original_language codes. Values are target BCP 47 language tags; x-silo-original means retain that source language. This key is separate from catalog.metadata_language so existing clients can continue changing the fallback without rewriting or discarding exceptions."
},
{
"key": "catalog.show_advisory_age",
"introduced_in": 10,
"persistence": "remote",
"allowed_scopes": ["profile"],
"resolution_order": ["profile", "default"],
"value_schema": { "type": "boolean" },
"default_value": false,
"category": "catalog",
"label": "Show advisory age",
"description": "Show a recommended minimum viewer age from an advisory service, such as Common Sense Media, on item detail.",
"recommended_control": "switch",
"notes": "Profile scope because the reader is a household parent deciding what a child may watch, and they browse on their own profile. Default false: the advisory is extra chrome most viewers do not want, and only the profiles that ask for it should carry it. This setting only controls whether the badge is shown: turning it on or off never changes what a profile is allowed to watch. Restricting a profile by advisory age is a separate access field, the profile's max_advisory_age, which a household manager sets on the profile itself. Coverage is partial by nature: the provider that supplies the advisory is rate limited per day, so on a large library some titles carry one and others do not."
},
{
"key": "player.hdr_enabled",
"introduced_in": 1,
Expand Down Expand Up @@ -836,7 +851,8 @@
"label": "Theme",
"description": "Colour theme for the Silo interface.",
"recommended_control": "select",
"notes": "Renamed from the unregistered legacy key \"ui_theme\", which the extension bag accepted without validation. Moved from account to profile scope: appearance is per household member, and the account row is copied to every profile during migration. Carries a device override because the right theme is partly a function of the screen and the room — a light theme on a phone in daylight, a dark one on a TV at night — which is the same reasoning that gives ui.text_scale one. Note that ui.custom_theme_vars and ui.custom_css stay profile-wide, so a profile's custom styling still applies on top of a device's theme override. Adding a theme is an additive enum widening. The admin-set default theme stays in server_settings and is not a user setting. Migration must also update internal/plugins/user_theme_lookup.go, which reads this value with raw SQL bound to both the old name and the account scope (SELECT value FROM user_settings WHERE user_id = $1 AND key = 'ui_theme') and feeds the X-Silo-Theme header on every plugin request. Left alone, that query matches nothing after the rename and every plugin UI silently falls back to its own theme, with no error to notice."
"deprecated": true,
"notes": "Deprecated in revision 12: the web client has one theme, Cinema Dark, which only the admin customizes (accent color plus the ui.admin_theme_vars and ui.admin_custom_css server settings), and it no longer reads or writes this key. Web-only (platforms: web). Stored values were deleted by migration 20260926233851_retire_profile_themes (SQLite user store schema v29). The definition stays published so a stale cached web bundle's write is accepted rather than failing; nothing reads what it stores. Renamed from the unregistered legacy key \"ui_theme\" at the settings cutover."
},
{
"key": "ui.text_scale",
Expand Down Expand Up @@ -914,7 +930,8 @@
"label": "Custom theme variables",
"description": "Per-token overrides applied on top of the selected theme.",
"recommended_control": "panel",
"notes": "Renamed from the unregistered legacy key \"ui_custom_theme_vars\", which stored arbitrary unvalidated JSON."
"deprecated": true,
"notes": "Deprecated in revision 12: the web client has one theme, Cinema Dark, which only the admin customizes (accent color plus the ui.admin_theme_vars and ui.admin_custom_css server settings), and it no longer reads or writes this key. Web-only (platforms: web). Stored values were deleted by migration 20260926233851_retire_profile_themes (SQLite user store schema v29). The definition stays published so a stale cached web bundle's write is accepted rather than failing; nothing reads what it stores. Renamed from the unregistered legacy key \"ui_custom_theme_vars\", which stored arbitrary unvalidated JSON."
},
{
"key": "ui.custom_css",
Expand All @@ -929,7 +946,8 @@
"label": "Custom CSS",
"description": "Raw CSS applied on top of the selected theme.",
"recommended_control": "text",
"notes": "Renamed from the unregistered legacy key \"ui_custom_css\". Sanitization stays in the web client (web/src/lib/cssSanitizer.ts); the contract only bounds length. This value is per-profile and is never applied to another profile's session."
"deprecated": true,
"notes": "Deprecated in revision 12: the web client has one theme, Cinema Dark, which only the admin customizes (accent color plus the ui.admin_theme_vars and ui.admin_custom_css server settings), and it no longer reads or writes this key. Web-only (platforms: web). Stored values were deleted by migration 20260926233851_retire_profile_themes (SQLite user store schema v29). The definition stays published so a stale cached web bundle's write is accepted rather than failing; nothing reads what it stores. Renamed from the unregistered legacy key \"ui_custom_css\". The contract only bounds length."
},
{
"key": "ui.date_format",
Expand Down Expand Up @@ -974,6 +992,20 @@
"recommended_control": "select",
"notes": "Moved from account to profile scope; the account row is copied to every profile during migration."
},
{
"key": "home.hide_watched_items",
"introduced_in": 12,
"persistence": "remote",
"allowed_scopes": ["profile"],
"resolution_order": ["profile", "default"],
"value_schema": { "type": "boolean" },
"default_value": false,
"platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"],
"category": "navigation",
"label": "Hide watched items from Home",
"description": "Remove watched items from ordinary Home sections while keeping Featured and watch-history sections unchanged.",
"recommended_control": "switch"
},
{
"key": "ui.library_page_state",
"introduced_in": 1,
Expand Down Expand Up @@ -1043,7 +1075,7 @@
"category": "appearance",
"label": "Poster badges",
"description": "Which badges appear on poster cards, and where.",
"notes": "Registered from the legacy unprefixed key card_overlays, which reached the server only through the unknown-key extension bag — stored as an arbitrary string with no validation. null means the user has expressed no preference, which is what lets the server-wide admin default in the overlay-config endpoint apply; writing a resolved-but-unchosen value would silently pin them. The server-wide default configuration stays in server_settings and is not a user setting. This key describes which badges to show, not whether to show them — that is ui.card_overlays_enabled."
"notes": "Registered from the legacy unprefixed key card_overlays, which reached the server only through the unknown-key extension bag — stored as an arbitrary string with no validation. null means the user has expressed no preference, which is what lets the server-wide admin default in the overlay-config endpoint apply; writing a resolved-but-unchosen value would silently pin them. The server-wide default configuration stays in server_settings and is not a user setting. This key describes which badges to show, not whether to show them — that is ui.card_overlays_enabled. Revision 13 adds the advisory_age overlay id; a server below revision 13 rejects a value that contains it, so a client offers that badge only when manifest_revision is at least 13."
},
{
"key": "ui.card_overlays_enabled",
Expand Down Expand Up @@ -1098,6 +1130,56 @@
"recommended_control": "switch",
"notes": "null means the profile has not chosen whether quick actions are enabled, so the server-wide default from the overlay-config endpoint applies. An explicit profile choice overrides the server-wide default in either direction."
},
{
"key": "ui.theme_music_enabled",
"introduced_in": 11,
"persistence": "remote",
"allowed_scopes": [
"profile",
"profile_device"
],
"resolution_order": [
"profile_device",
"profile",
"default"
],
"value_schema": {
"type": "boolean"
},
"default_value": false,
"platforms": [
"web"
],
"category": "playback",
"label": "Theme music",
"description": "Play local theme music while browsing movie and show detail pages.",
"recommended_control": "switch"
},
{
"key": "ui.theme_music_loop",
"introduced_in": 11,
"persistence": "remote",
"allowed_scopes": [
"profile",
"profile_device"
],
"resolution_order": [
"profile_device",
"profile",
"default"
],
"value_schema": {
"type": "boolean"
},
"default_value": false,
"platforms": [
"web"
],
"category": "playback",
"label": "Loop theme music",
"description": "Repeat theme music while the detail page stays open.",
"recommended_control": "switch"
},
{
"key": "ui.next_up_mode",
"introduced_in": 1,
Expand Down
Loading
Loading