Files
silo-server/internal/api/handlers/settings.go
T
QuickandClaude Opus 5 a1bf79c5f7 fix(settings): enforce the language-tag and step constraints the manifest declares
A sweep of all 43 manifest definitions against the running server (160 checks:
declared default, both boundaries, and deliberate violations for each remote
key) found two places where the live registry accepts what the contract
forbids. Both are fixed by calling the contract's own validators rather than
adding a second implementation.

playback.audio_language was checked as "32 characters or fewer", so the server
stored "!!!" for a field the manifest declares as language_tag — a value track
matching would then silently never match. It now requires a well-formed tag via
settingscontract.NormalizeLanguageTag. The empty string is still accepted: the
string-only endpoint has no way to send null, and both Android and web send ""
to clear the choice, so rejecting it would break clearing the preference.

player.playback_speed declared step 0.05 and nothing enforced it, so 0.26 was
stored — a value no client's stepper can represent and that every client would
silently snap on the next write. settingscontract.StepAligned is now exported
and used by both the contract validator and the registry, so there is one
definition of "on step" rather than two that can drift.

This gives the contract its first production consumer beyond the startup load,
which is the direction Phase 2 continues in.

Also fixes a genuinely flaky test that the new CI gate would have hit
intermittently: TestRemoveJellyfinCompatWebDisablesWebSetting used t.TempDir as
the install root, but the endpoint returns 202 and its goroutine keeps writing
there after the test body returns, so cleanup tripped "directory not empty"
roughly one run in four. Confirmed pre-existing and unrelated to settings; the
suite now passes six consecutive full-package runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:22:48 +00:00

1048 lines
32 KiB
Go

package handlers
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// deviceSeenThrottle bounds how often a device's last_seen_at is refreshed from
// request traffic. Device-setting reads each registered the device (an upsert
// on a single per-device row), so a page that fetches many settings serialized
// hundreds of upserts on that row's lock. Skipping the upsert when the device
// was seen within this window removes the contention while keeping last_seen
// fresh to within the window.
const deviceSeenThrottle = 5 * time.Minute
const subtitleAppearanceSettingKey = "subtitle_appearance"
const (
legacyAndroidNextUpPromptSettingKey = "player.next_up_prompt_seconds"
canonicalNextUpPromptSettingKey = "playback.next_up_prompt_seconds"
)
const (
libraryPageStateSettingKey = "ui.library_page_state"
rememberLibraryPageStateSettingKey = "ui.remember_library_page_state"
searchMediaScopeSettingKey = "search.media_scope"
dateFormatSettingKey = "ui.date_format"
timeFormatSettingKey = "ui.time_format"
)
const (
deviceIDHeader = "X-Silo-Device-Id"
deviceNameHeader = "X-Silo-Device-Name"
devicePlatformHeader = "X-Silo-Device-Platform"
)
// ServerSettingReader reads individual keys from the server_settings table.
type ServerSettingReader interface {
Get(ctx context.Context, key string) (string, error)
}
// SettingsHandler handles user-scoped settings endpoints.
type SettingsHandler struct {
storeProvider userstore.UserStoreProvider
serverSettings ServerSettingReader
deviceSeen *cache.TTLCache[struct{}]
}
// NewSettingsHandler creates a new SettingsHandler.
func NewSettingsHandler(provider userstore.UserStoreProvider) *SettingsHandler {
return &SettingsHandler{
storeProvider: provider,
deviceSeen: cache.NewTTLCache[struct{}](),
}
}
// shouldRegisterDevice reports whether the device's last_seen_at should be
// refreshed now, throttling to one upsert per deviceSeenThrottle window per
// (profile, device). It marks the device seen before returning true so a burst
// of concurrent reads collapses to a single upsert instead of contending on the
// device row.
func (h *SettingsHandler) shouldRegisterDevice(profileID, deviceID string) bool {
if h == nil || h.deviceSeen == nil {
return true
}
key := profileID + "\x00" + deviceID
if _, seen := h.deviceSeen.Get(key); seen {
return false
}
h.deviceSeen.Set(key, struct{}{}, deviceSeenThrottle)
return true
}
// SetServerSettings configures the optional server settings reader for overlay config etc.
func (h *SettingsHandler) SetServerSettings(reader ServerSettingReader) {
h.serverSettings = reader
}
// --- Request/Response types ---
type setSettingRequest struct {
Value string `json:"value"`
}
type settingResponse struct {
Key string `json:"key"`
Value string `json:"value"`
}
type settingsListResponse struct {
Settings []settingResponse `json:"settings"`
}
type effectiveSettingResponse struct {
Key string `json:"key"`
ProfileID string `json:"profile_id,omitempty"`
UserValue string `json:"user_value,omitempty"`
DeviceValue string `json:"device_value,omitempty"`
EffectiveValue string `json:"effective_value"`
Source string `json:"source"`
HasDeviceOverride bool `json:"has_device_override"`
DeviceID string `json:"device_id,omitempty"`
DeviceName string `json:"device_name,omitempty"`
DevicePlatform string `json:"device_platform,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type effectiveSettingsResponse struct {
Settings []effectiveSettingResponse `json:"settings"`
}
type effectiveSubtitleAppearanceResponse struct {
Key string `json:"key"`
ProfileID string `json:"profile_id,omitempty"`
GlobalValue string `json:"global_value"`
DeviceValue string `json:"device_value,omitempty"`
EffectiveValue string `json:"effective_value"`
HasDeviceOverride bool `json:"has_device_override"`
DeviceID string `json:"device_id,omitempty"`
DeviceName string `json:"device_name,omitempty"`
DevicePlatform string `json:"device_platform,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type settingsScope string
const (
scopeUser settingsScope = "user"
scopeDevice settingsScope = "device"
)
type settingSpec struct {
Scope settingsScope
DefaultValue string
Validate func(string) error
}
var settingsRegistry = map[string]settingSpec{
"playback.preferred_quality": {
Scope: scopeDevice,
DefaultValue: "auto",
Validate: validateEnumSetting("playback.preferred_quality",
"auto", "original", "2160p", "1080p-high", "1080p-medium", "1080p", "1080p-8",
"720p-high", "720p-medium", "720p", "480p", "420p", "328p"),
},
"playback.audio_language": {
Scope: scopeDevice,
DefaultValue: "",
Validate: validateLanguageTagSetting("playback.audio_language"),
},
"playback.auto_skip_intro": {
Scope: scopeDevice,
DefaultValue: "false",
Validate: validateBoolSetting("playback.auto_skip_intro"),
},
"playback.auto_skip_credits": {
Scope: scopeDevice,
DefaultValue: "false",
Validate: validateBoolSetting("playback.auto_skip_credits"),
},
"playback.auto_skip_recap": {
Scope: scopeDevice,
DefaultValue: "false",
Validate: validateBoolSetting("playback.auto_skip_recap"),
},
"playback.auto_play_next_preview": {
Scope: scopeDevice,
DefaultValue: "false",
Validate: validateBoolSetting("playback.auto_play_next_preview"),
},
"playback.auto_play_next": {
Scope: scopeDevice,
DefaultValue: "true",
Validate: validateBoolSetting("playback.auto_play_next"),
},
canonicalNextUpPromptSettingKey: {
Scope: scopeDevice,
DefaultValue: "30",
Validate: validateIntRange(canonicalNextUpPromptSettingKey, 0, 120),
},
subtitleAppearanceSettingKey: {
Scope: scopeDevice,
DefaultValue: "",
Validate: validateJSONSetting(subtitleAppearanceSettingKey),
},
libraryPageStateSettingKey: {
Scope: scopeDevice,
DefaultValue: "",
Validate: validateJSONSetting(libraryPageStateSettingKey),
},
rememberLibraryPageStateSettingKey: {
Scope: scopeDevice,
DefaultValue: "true",
Validate: validateBoolSetting(rememberLibraryPageStateSettingKey),
},
// Preferred default scope for global/catalog search. "video" keeps
// results to movies and series; "all" mixes audiobooks in.
searchMediaScopeSettingKey: {
Scope: scopeUser,
DefaultValue: "video",
Validate: validateEnumSetting(searchMediaScopeSettingKey,
"all", "video", "audiobook"),
},
// Preferred display formats for dates and clock times. "auto" defers to
// the client's locale default.
dateFormatSettingKey: {
Scope: scopeUser,
DefaultValue: "auto",
Validate: validateEnumSetting(dateFormatSettingKey,
"auto", "DD/MM/YYYY", "MM/DD/YYYY", "YYYY-MM-DD"),
},
timeFormatSettingKey: {
Scope: scopeUser,
DefaultValue: "auto",
Validate: validateEnumSetting(timeFormatSettingKey, "auto", "12h", "24h"),
},
"player.hdr_enabled": {
Scope: scopeDevice,
DefaultValue: "true",
Validate: validateBoolSetting("player.hdr_enabled"),
},
"player.dolby_vision_enabled": {
Scope: scopeDevice,
DefaultValue: "true",
Validate: validateBoolSetting("player.dolby_vision_enabled"),
},
"player.dv_profile7_hdr10_fallback": {
Scope: scopeDevice,
DefaultValue: "false",
Validate: validateBoolSetting("player.dv_profile7_hdr10_fallback"),
},
"player.seek_cache_enabled": {
Scope: scopeDevice,
DefaultValue: "true",
Validate: validateBoolSetting("player.seek_cache_enabled"),
},
"player.playback_speed": {
Scope: scopeDevice,
DefaultValue: "1",
Validate: validateFloatRangeStep("player.playback_speed", 0.25, 3.0, 0.05),
},
"player.audio_sync_ms": {
Scope: scopeDevice,
DefaultValue: "0",
Validate: validateIntRange("player.audio_sync_ms", -5000, 5000),
},
"player.subtitle_sync_ms": {
Scope: scopeDevice,
DefaultValue: "0",
Validate: validateIntRange("player.subtitle_sync_ms", -10000, 10000),
},
"player.video_gravity": {
Scope: scopeDevice,
DefaultValue: "fit",
Validate: validateEnumSetting("player.video_gravity", "fit", "fill", "stretch"),
},
"player.orientation_mode": {
Scope: scopeDevice,
DefaultValue: "landscapeLocked",
Validate: validateEnumSetting("player.orientation_mode", "landscapeLocked", "rotateFreely"),
},
}
// --- Handler methods ---
// HandleListSettings handles GET /settings.
func (h *SettingsHandler) HandleListSettings(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
entries, err := store.ListSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list settings")
return
}
resp := settingsListResponse{
Settings: make([]settingResponse, 0, len(entries)),
}
for _, e := range entries {
if !keyUsesUserScope(e.Key) {
continue
}
resp.Settings = append(resp.Settings, settingResponse{
Key: e.Key,
Value: e.Value,
})
}
writeJSON(w, http.StatusOK, resp)
}
// HandleGetSetting handles GET /settings/{key}.
func (h *SettingsHandler) HandleGetSetting(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
key := chi.URLParam(r, "key")
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if !keyUsesUserScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
value, err := store.GetSetting(r.Context(), key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get setting")
return
}
if value == "" {
writeError(w, http.StatusNotFound, "not_found", "Setting not found")
return
}
writeJSON(w, http.StatusOK, settingResponse{
Key: key,
Value: value,
})
}
// HandleSetSetting handles PUT /settings/{key}.
func (h *SettingsHandler) HandleSetSetting(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
key := chi.URLParam(r, "key")
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if !keyUsesUserScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
return
}
var req setSettingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if err := validateRegisteredSetting(key, req.Value, scopeUser); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if err := store.SetSetting(r.Context(), key, req.Value); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set setting")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleDeleteSetting handles DELETE /settings/{key}.
func (h *SettingsHandler) HandleDeleteSetting(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
key := chi.URLParam(r, "key")
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if !keyUsesUserScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if err := store.DeleteSetting(r.Context(), key); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete setting")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleGetDeviceSetting handles GET /settings/device/{key}.
func (h *SettingsHandler) HandleGetDeviceSetting(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID, ok := activeProfileIDFromRequest(w, r)
if !ok {
return
}
key := chi.URLParam(r, "key")
device := deviceMetadataFromRequest(r)
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
requestedKey := key
key = canonicalDeviceSettingKey(key)
if !keyUsesDeviceScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeDevice))
return
}
if device.DeviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
h.registerRequestDevice(r.Context(), store, profileID, device)
value, err := getDeviceSettingWithLegacyFallback(r.Context(), store, profileID, device.DeviceID, key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get device setting")
return
}
if value == nil {
writeError(w, http.StatusNotFound, "not_found", "Setting not found")
return
}
writeJSON(w, http.StatusOK, settingResponse{
Key: requestedKey,
Value: value.Value,
})
}
// HandleSetDeviceSetting handles PUT /settings/device/{key}.
func (h *SettingsHandler) HandleSetDeviceSetting(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID, ok := activeProfileIDFromRequest(w, r)
if !ok {
return
}
key := chi.URLParam(r, "key")
device := deviceMetadataFromRequest(r)
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
key = canonicalDeviceSettingKey(key)
if !keyUsesDeviceScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeDevice))
return
}
if device.DeviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
return
}
var req setSettingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if err := validateRegisteredSetting(key, req.Value, scopeDevice); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if err := store.SetDeviceSetting(r.Context(), userstore.DeviceSettingEntry{
ProfileID: profileID,
DeviceID: device.DeviceID,
DeviceName: device.DeviceName,
DevicePlatform: device.DevicePlatform,
Key: key,
Value: req.Value,
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set device setting")
return
}
if legacyKey, ok := legacyDeviceSettingKey(key); ok {
if err := store.DeleteDeviceSetting(r.Context(), profileID, device.DeviceID, legacyKey); err != nil {
slog.WarnContext(r.Context(), "failed to clean up legacy device setting after canonical write",
"legacy_key", legacyKey,
"canonical_key", key,
"error", err,
)
}
}
w.WriteHeader(http.StatusNoContent)
}
// HandleDeleteDeviceSetting handles DELETE /settings/device/{key}.
func (h *SettingsHandler) HandleDeleteDeviceSetting(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID, ok := activeProfileIDFromRequest(w, r)
if !ok {
return
}
key := chi.URLParam(r, "key")
device := deviceMetadataFromRequest(r)
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
key = canonicalDeviceSettingKey(key)
if !keyUsesDeviceScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeDevice))
return
}
if device.DeviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
h.registerRequestDevice(r.Context(), store, profileID, device)
if legacyKey, ok := legacyDeviceSettingKey(key); ok {
if err := store.DeleteDeviceSetting(r.Context(), profileID, device.DeviceID, legacyKey); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting")
return
}
}
if err := store.DeleteDeviceSetting(r.Context(), profileID, device.DeviceID, key); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleGetEffectiveSettings handles GET /settings/effective?keys=key1,key2
func (h *SettingsHandler) HandleGetEffectiveSettings(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID, ok := activeProfileIDFromRequest(w, r)
if !ok {
return
}
keysParam := strings.TrimSpace(r.URL.Query().Get("keys"))
if keysParam == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Query parameter keys is required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
device := deviceMetadataFromRequest(r)
h.registerRequestDevice(r.Context(), store, profileID, device)
keys := parseSettingKeys(keysParam)
resp := effectiveSettingsResponse{
Settings: make([]effectiveSettingResponse, 0, len(keys)),
}
for _, key := range keys {
resolved, err := h.resolveEffectiveSetting(r.Context(), store, profileID, device, key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve effective settings")
return
}
resp.Settings = append(resp.Settings, resolved)
}
writeJSON(w, http.StatusOK, resp)
}
// HandleGetEffectiveSubtitleAppearance handles GET /settings/subtitle_appearance/effective.
func (h *SettingsHandler) HandleGetEffectiveSubtitleAppearance(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID, ok := activeProfileIDFromRequest(w, r)
if !ok {
return
}
device := deviceMetadataFromRequest(r)
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
h.registerRequestDevice(r.Context(), store, profileID, device)
resolved, err := h.resolveEffectiveSetting(r.Context(), store, profileID, device, subtitleAppearanceSettingKey)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get setting")
return
}
resp := effectiveSubtitleAppearanceResponse{
Key: subtitleAppearanceSettingKey,
ProfileID: resolved.ProfileID,
GlobalValue: resolved.UserValue,
DeviceValue: resolved.DeviceValue,
EffectiveValue: resolved.EffectiveValue,
HasDeviceOverride: resolved.HasDeviceOverride,
DeviceID: resolved.DeviceID,
DeviceName: resolved.DeviceName,
DevicePlatform: resolved.DevicePlatform,
UpdatedAt: resolved.UpdatedAt,
}
writeJSON(w, http.StatusOK, resp)
}
// HandleSetSubtitleAppearanceDeviceOverride handles PUT /settings/device/subtitle_appearance.
func (h *SettingsHandler) HandleSetSubtitleAppearanceDeviceOverride(w http.ResponseWriter, r *http.Request) {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("key", subtitleAppearanceSettingKey)
h.HandleSetDeviceSetting(w, r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx)))
}
// HandleDeleteSubtitleAppearanceDeviceOverride handles DELETE /settings/device/subtitle_appearance.
func (h *SettingsHandler) HandleDeleteSubtitleAppearanceDeviceOverride(w http.ResponseWriter, r *http.Request) {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("key", subtitleAppearanceSettingKey)
h.HandleDeleteDeviceSetting(w, r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx)))
}
type requestDeviceMetadata struct {
DeviceID string
DeviceName string
DevicePlatform string
}
func activeProfileIDFromRequest(w http.ResponseWriter, r *http.Request) (string, bool) {
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
return "", false
}
return profileID, true
}
func deviceMetadataFromRequest(r *http.Request) requestDeviceMetadata {
return requestDeviceMetadata{
DeviceID: clampHeaderValue(r.Header.Get(deviceIDHeader), 128),
DeviceName: clampHeaderValue(r.Header.Get(deviceNameHeader), 120),
DevicePlatform: clampHeaderValue(r.Header.Get(devicePlatformHeader), 40),
}
}
func (h *SettingsHandler) registerRequestDevice(
ctx context.Context,
store userstore.UserStore,
profileID string,
device requestDeviceMetadata,
) {
if strings.TrimSpace(profileID) == "" || strings.TrimSpace(device.DeviceID) == "" {
return
}
if store == nil {
return
}
if !h.shouldRegisterDevice(profileID, device.DeviceID) {
return
}
registry, ok := store.(userstore.DeviceRegistry)
if !ok {
return
}
if err := registry.RegisterDevice(ctx, userstore.DeviceEntry{
ProfileID: profileID,
DeviceID: device.DeviceID,
DeviceName: device.DeviceName,
DevicePlatform: device.DevicePlatform,
}); err != nil {
slog.WarnContext(ctx, "failed to register request device", "component", "api",
"profile_id", profileID,
"device_id", device.DeviceID,
"error", err,
)
}
}
func clampHeaderValue(value string, maxLen int) string {
value = strings.TrimSpace(value)
runes := []rune(value)
if len(runes) <= maxLen {
return value
}
return string(runes[:maxLen])
}
func parseSettingKeys(raw string) []string {
parts := strings.Split(raw, ",")
keys := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
key := strings.TrimSpace(part)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
keys = append(keys, key)
}
return keys
}
func validateRegisteredSetting(key, value string, expectedScope settingsScope) error {
key = canonicalDeviceSettingKey(key)
spec, ok := settingsRegistry[key]
if !ok {
return nil
}
if spec.Scope != expectedScope {
return fmt.Errorf("%s is not a %s setting", key, expectedScope)
}
if spec.Validate == nil {
return nil
}
return spec.Validate(value)
}
func keyUsesUserScope(key string) bool {
key = canonicalDeviceSettingKey(key)
spec, ok := settingsRegistry[key]
return !ok || spec.Scope == scopeUser
}
func keyUsesDeviceScope(key string) bool {
key = canonicalDeviceSettingKey(key)
spec, ok := settingsRegistry[key]
return ok && spec.Scope == scopeDevice
}
func isMigratedPlaybackSetting(key string) bool {
key = canonicalDeviceSettingKey(key)
switch key {
case "playback.preferred_quality",
"playback.audio_language",
"playback.auto_skip_intro",
"playback.auto_skip_credits",
"playback.auto_play_next",
canonicalNextUpPromptSettingKey:
return true
default:
return false
}
}
// canonicalDeviceSettingKey keeps the Android key shipped before the server's
// canonical playback namespace was finalized working without storing two
// independent values. New clients should use playback.next_up_prompt_seconds.
func canonicalDeviceSettingKey(key string) string {
if key == legacyAndroidNextUpPromptSettingKey {
return canonicalNextUpPromptSettingKey
}
return key
}
func legacyDeviceSettingKey(key string) (string, bool) {
if canonicalDeviceSettingKey(key) == canonicalNextUpPromptSettingKey {
return legacyAndroidNextUpPromptSettingKey, true
}
return "", false
}
// getDeviceSettingWithLegacyFallback is a read-only canonical-first lookup for
// the Android key used before the playback namespace was finalized. Canonical
// PUT and DELETE requests own migration and cleanup.
func getDeviceSettingWithLegacyFallback(
ctx context.Context,
store userstore.UserStore,
profileID, deviceID, key string,
) (*userstore.DeviceSettingEntry, error) {
override, err := store.GetDeviceSetting(ctx, profileID, deviceID, key)
if err != nil || override != nil {
return override, err
}
legacyKey, ok := legacyDeviceSettingKey(key)
if !ok {
return nil, nil
}
return store.GetDeviceSetting(ctx, profileID, deviceID, legacyKey)
}
func usesLegacyUserFallback(key string) bool {
return key == subtitleAppearanceSettingKey
}
func validateEnumSetting(key string, allowed ...string) func(string) error {
allowedSet := make(map[string]struct{}, len(allowed))
for _, value := range allowed {
allowedSet[value] = struct{}{}
}
return func(value string) error {
if _, ok := allowedSet[value]; ok {
return nil
}
return fmt.Errorf("%s must be one of %s", key, strings.Join(allowed, ", "))
}
}
func validateBoolSetting(key string) func(string) error {
return func(value string) error {
if value == "true" || value == "false" {
return nil
}
return fmt.Errorf("%s must be true or false", key)
}
}
func validateIntRange(key string, min, max int) func(string) error {
return func(value string) error {
parsed, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("%s must be an integer", key)
}
if parsed < min || parsed > max {
return fmt.Errorf("%s must be between %d and %d", key, min, max)
}
return nil
}
}
func validateFloatRange(key string, min, max float64) func(string) error {
return validateFloatRangeStep(key, min, max, 0)
}
// validateFloatRangeStep enforces the range and, when step is positive, that
// the value sits on the step grid anchored at min.
//
// The step check delegates to settingscontract.StepAligned so this endpoint
// enforces exactly what contracts/settings/v1/manifest.json declares. Before
// this, player.playback_speed advertised a 0.05 step that nothing enforced, so
// the server happily stored 0.26 — a value no client's stepper can represent
// and that every client would silently snap on the next write.
func validateFloatRangeStep(key string, min, max, step float64) func(string) error {
return func(value string) error {
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return fmt.Errorf("%s must be a number", key)
}
if math.IsNaN(parsed) || parsed < min || parsed > max {
return fmt.Errorf("%s must be between %g and %g", key, min, max)
}
if !settingscontract.StepAligned(parsed, min, step) {
return fmt.Errorf("%s must be a multiple of %g starting from %g", key, step, min)
}
return nil
}
}
// validateLanguageTagSetting accepts a BCP 47 language tag, or the empty string.
//
// The empty string is the legacy wire form for "no preference": the string-only
// settings API has no way to send null, and both the Android and web clients
// send "" to clear the choice. The contract expresses the same state as null,
// which is why every language definition there is nullable.
//
// Anything else must be a well-formed tag. The previous check was "32
// characters or fewer", so the server accepted "!!!" for a field the manifest
// declares as language_tag — and stored it where track matching would silently
// never match.
func validateLanguageTagSetting(key string) func(string) error {
return func(value string) error {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
if _, ok := settingscontract.NormalizeLanguageTag(trimmed); !ok {
return fmt.Errorf("%s must be a BCP 47 language tag such as en or en-US", key)
}
return nil
}
}
func validateJSONSetting(key string) func(string) error {
return func(value string) error {
var decoded any
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
return fmt.Errorf("%s must be valid JSON", key)
}
return nil
}
}
func (h *SettingsHandler) resolveEffectiveSetting(
ctx context.Context,
store userstore.UserStore,
profileID string,
device requestDeviceMetadata,
key string,
) (effectiveSettingResponse, error) {
requestedKey := key
key = canonicalDeviceSettingKey(key)
spec, hasSpec := settingsRegistry[key]
resolved := effectiveSettingResponse{
Key: requestedKey,
ProfileID: profileID,
DeviceID: device.DeviceID,
DeviceName: device.DeviceName,
DevicePlatform: device.DevicePlatform,
}
if hasSpec && spec.Scope == scopeDevice {
resolved.EffectiveValue = ""
resolved.Source = "default"
if spec.DefaultValue != "" {
resolved.EffectiveValue = spec.DefaultValue
} else {
resolved.Source = "unset"
}
if device.DeviceID != "" {
override, err := getDeviceSettingWithLegacyFallback(ctx, store, profileID, device.DeviceID, key)
if err != nil {
return effectiveSettingResponse{}, err
}
if override != nil {
resolved.DeviceValue = override.Value
resolved.EffectiveValue = override.Value
resolved.Source = "device"
resolved.HasDeviceOverride = true
resolved.DeviceName = override.DeviceName
resolved.DevicePlatform = override.DevicePlatform
resolved.UpdatedAt = override.UpdatedAt
return resolved, nil
}
if isMigratedPlaybackSetting(key) {
legacyValue, err := store.GetSetting(ctx, key)
if err != nil {
return effectiveSettingResponse{}, err
}
if legacyValue != "" {
entry := userstore.DeviceSettingEntry{
ProfileID: profileID,
DeviceID: device.DeviceID,
DeviceName: device.DeviceName,
DevicePlatform: device.DevicePlatform,
Key: key,
Value: legacyValue,
}
if err := store.SetDeviceSetting(ctx, entry); err != nil {
return effectiveSettingResponse{}, err
}
resolved.DeviceValue = legacyValue
resolved.EffectiveValue = legacyValue
resolved.Source = "device"
resolved.HasDeviceOverride = true
return resolved, nil
}
}
}
if usesLegacyUserFallback(key) {
legacyValue, err := store.GetSetting(ctx, key)
if err != nil {
return effectiveSettingResponse{}, err
}
if legacyValue != "" {
resolved.UserValue = legacyValue
resolved.EffectiveValue = legacyValue
resolved.Source = "user"
return resolved, nil
}
}
return resolved, nil
}
userValue, err := store.GetSetting(ctx, key)
if err != nil {
return effectiveSettingResponse{}, err
}
resolved.UserValue = userValue
resolved.EffectiveValue = userValue
resolved.Source = "user"
if resolved.EffectiveValue != "" {
return resolved, nil
}
if hasSpec && spec.DefaultValue != "" {
resolved.EffectiveValue = spec.DefaultValue
resolved.Source = "default"
return resolved, nil
}
resolved.Source = "unset"
return resolved, nil
}
// overlayConfigResponse is returned by GET /settings/overlay-config.
type overlayConfigResponse struct {
Enabled bool `json:"enabled"`
Defaults string `json:"defaults,omitempty"`
}
// HandleGetOverlayConfig returns the server-wide overlay configuration.
// Available to all authenticated users (not admin-only).
func (h *SettingsHandler) HandleGetOverlayConfig(w http.ResponseWriter, r *http.Request) {
resp := overlayConfigResponse{Enabled: true}
if h.serverSettings != nil {
if v, _ := h.serverSettings.Get(r.Context(), "overlays.enabled"); v == "false" {
resp.Enabled = false
}
if v, _ := h.serverSettings.Get(r.Context(), "defaults.card_overlays"); v != "" {
resp.Defaults = v
}
}
w.Header().Set("Cache-Control", "private, max-age=60")
writeJSON(w, http.StatusOK, resp)
}