diff --git a/internal/api/handlers/settings.go b/internal/api/handlers/settings.go index 9111bdcb..ded532cf 100644 --- a/internal/api/handlers/settings.go +++ b/internal/api/handlers/settings.go @@ -754,12 +754,35 @@ func validateRegisteredSetting(key, value string, expectedScope settingsScope) e return spec.Validate(value) } +// keyUsesUserScope reports whether a key is stored at account scope by the +// legacy endpoints. +// +// This used to return true for any *unregistered* key, which is the extension +// bag: a client could invent a production setting unilaterally and the server +// stored it as an unvalidated string. That is how six ui.* settings and five +// orphan keys reached production untyped, and closing it is the point of the +// contract. +// +// An unknown key is now simply not a user setting, so the legacy write path +// rejects it and the canonical API — which validates against the manifest — is +// the only way to store anything new. func keyUsesUserScope(key string) bool { key = canonicalDeviceSettingKey(key) spec, ok := settingsRegistry[key] - return !ok || spec.Scope == scopeUser + if !ok { + // The jellycompat DisplayPreferences blobs ride this table under + // synthetic keys. They are that subsystem's storage rather than user + // settings, and they move to dedicated storage in the follow-up; until + // then they must keep working. + return strings.HasPrefix(key, jellycompatSettingPrefix) + } + return spec.Scope == scopeUser } +// jellycompatSettingPrefix marks rows that belong to the Jellyfin +// compatibility layer rather than to the user settings system. +const jellycompatSettingPrefix = "jellycompat:" + func keyUsesDeviceScope(key string) bool { key = canonicalDeviceSettingKey(key) spec, ok := settingsRegistry[key] diff --git a/internal/jellycompat/handlers_displayprefs.go b/internal/jellycompat/handlers_displayprefs.go index 51e24e5a..5f58cfac 100644 --- a/internal/jellycompat/handlers_displayprefs.go +++ b/internal/jellycompat/handlers_displayprefs.go @@ -9,6 +9,9 @@ import ( "github.com/go-chi/chi/v5" + "github.com/Silo-Server/silo-server/internal/settingscontract" + "github.com/Silo-Server/silo-server/internal/settingskeys" + "github.com/Silo-Server/silo-server/internal/settingsresolve" "github.com/Silo-Server/silo-server/internal/userstore" ) @@ -127,20 +130,54 @@ func defaultDisplayPreferences(id, client string) displayPreferencesDTO { } } +// seedFromProfile fills a fresh DisplayPreferences document from the user's +// real settings, so a Jellyfin client's first read reflects choices made in +// Silo rather than empty defaults. +// +// Resolved at profile scope with no device: this seeds what a Jellyfin client +// sees, and those clients do not carry Silo's device identity. A device +// override leaking in here would hand one device's settings to every Jellyfin +// client on the account. func (h *DisplayPreferencesHandler) seedFromProfile(r *http.Request, session *Session, dto *displayPreferencesDTO) { store, err := h.storeProvider.ForUser(r.Context(), session.StreamAppUserID) if err != nil { return } - profile, err := store.GetProfile(r.Context(), session.ProfileID) - if err != nil || profile == nil { + + contract, err := settingscontract.Load() + if err != nil { return } - if profile.SubtitleLanguage != "" { - dto.CustomPrefs["subtitleLanguage"] = profile.SubtitleLanguage + resolved, err := settingsresolve.New(contract).Resolve(r.Context(), store, + settingsresolve.Context{ProfileID: session.ProfileID}, + []string{ + settingskeys.PlaybackSubtitleLanguage, + settingskeys.PlaybackSubtitleMode, + settingskeys.PlaybackAutoSkipCredits, + }, nil) + if err != nil { + return } - if profile.SubtitleMode != "" { - dto.CustomPrefs["subtitleMode"] = profile.SubtitleMode + + for _, eff := range resolved { + switch eff.Key { + case settingskeys.PlaybackSubtitleLanguage: + var language string + if json.Unmarshal(eff.Value, &language) == nil && language != "" { + dto.CustomPrefs["subtitleLanguage"] = language + } + case settingskeys.PlaybackSubtitleMode: + var mode string + if json.Unmarshal(eff.Value, &mode) == nil && mode != "" { + dto.CustomPrefs["subtitleMode"] = mode + } + case settingskeys.PlaybackAutoSkipCredits: + // Jellyfin spells this as the inverse: the overlay is what plays + // instead of skipping. + var skip bool + if json.Unmarshal(eff.Value, &skip) == nil { + dto.CustomPrefs["enableNextVideoInfoOverlay"] = strconv.FormatBool(!skip) + } + } } - dto.CustomPrefs["enableNextVideoInfoOverlay"] = strconv.FormatBool(!profile.AutoSkipCredits) }