2026-08-06 22:33:56 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"net/http"
|
|
|
|
|
"slices"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// The vocabulary of a viewer's TV settings, and the only place that decides what a legal
|
|
|
|
|
// value is.
|
|
|
|
|
//
|
|
|
|
|
// It is a catalogue rather than a struct for the same reason featureCatalogue is: the
|
|
|
|
|
// admin console renders its editor straight from this list, so a new setting is one entry
|
|
|
|
|
// here plus the matching key on the television — never a database migration and never a
|
|
|
|
|
// second copy of the rules in a web page. The client and the operator are then editing
|
|
|
|
|
// provably the same document, which is the whole point of moving settings off the TV.
|
|
|
|
|
//
|
|
|
|
|
// Anything that identifies a *television* rather than a person is deliberately absent:
|
|
|
|
|
// the device name, the update source, the screensaver's rotation and ring colour. Those
|
|
|
|
|
// belong to the box in the room and must not follow someone to another one.
|
2026-08-17 11:41:36 +12:00
|
|
|
const preferenceSchemaVersion = 2
|
2026-08-06 22:33:56 +12:00
|
|
|
|
|
|
|
|
type preferenceKind string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
// A single on/off switch.
|
|
|
|
|
preferenceToggle preferenceKind = "toggle"
|
|
|
|
|
// Exactly one of Options.
|
|
|
|
|
preferenceChoice preferenceKind = "choice"
|
|
|
|
|
// An ordered subset of Options, never empty — the order is the setting.
|
|
|
|
|
preferenceMulti preferenceKind = "multi"
|
|
|
|
|
// An ordered list of free-form ids the server has no vocabulary for (server-composed
|
|
|
|
|
// row ids, which change without an app release and so cannot be enumerated here).
|
|
|
|
|
preferenceList preferenceKind = "list"
|
|
|
|
|
// One of Numbers.
|
|
|
|
|
preferenceNumber preferenceKind = "number"
|
2026-08-17 11:41:36 +12:00
|
|
|
// Short free-form display text, bounded by MaxLength.
|
|
|
|
|
preferenceText preferenceKind = "text"
|
2026-08-06 22:33:56 +12:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type preferenceOption struct {
|
|
|
|
|
Value string `json:"value"`
|
|
|
|
|
Label string `json:"label"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type preferenceDefinition struct {
|
|
|
|
|
Key string `json:"key"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Description string `json:"description"`
|
|
|
|
|
Area string `json:"area"`
|
|
|
|
|
Kind preferenceKind `json:"kind"`
|
|
|
|
|
Options []preferenceOption `json:"options,omitempty"`
|
|
|
|
|
Numbers []int `json:"numbers,omitempty"`
|
|
|
|
|
// Unit names what a number counts, for the admin console's editor. Without it every
|
|
|
|
|
// number reads as minutes, which is what the first one to exist happened to be.
|
2026-08-17 11:41:36 +12:00
|
|
|
Unit string `json:"unit,omitempty"`
|
|
|
|
|
MaxLength int `json:"maxLength,omitempty"`
|
|
|
|
|
AdminOnly bool `json:"adminOnly,omitempty"`
|
|
|
|
|
Default any `json:"default"`
|
2026-08-06 22:33:56 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func option(value, label string) preferenceOption {
|
|
|
|
|
return preferenceOption{Value: value, Label: label}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var preferenceCatalogue = []preferenceDefinition{
|
2026-08-17 11:41:36 +12:00
|
|
|
{
|
|
|
|
|
Key: "profileInitials", Name: "Profile initials", Area: "Profile",
|
|
|
|
|
Description: "Up to two characters shown in this person's user-switcher avatar. Leave blank to generate them from their name.",
|
|
|
|
|
Kind: preferenceText, Default: "", MaxLength: 2, AdminOnly: true,
|
|
|
|
|
},
|
2026-08-06 22:33:56 +12:00
|
|
|
{
|
|
|
|
|
Key: "homeSections", Name: "Home rows", Area: "Home",
|
|
|
|
|
Description: "Which built-in rows the launcher shows, in order.",
|
|
|
|
|
Kind: preferenceMulti, Default: []string{"continue", "favorites", "latest"},
|
|
|
|
|
Options: []preferenceOption{
|
|
|
|
|
option("continue", "Continue watching"),
|
|
|
|
|
option("favorites", "Favourites"),
|
|
|
|
|
option("latest", "Latest movies"),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Key: "homeCardDensity", Name: "Card size", Area: "Home",
|
|
|
|
|
Description: "How large the cards on browse rows are.",
|
|
|
|
|
Kind: preferenceChoice, Default: "standard",
|
|
|
|
|
Options: []preferenceOption{
|
|
|
|
|
option("compact", "Compact"), option("standard", "Standard"), option("large", "Large"),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Key: "homeArtworkStyle", Name: "Card artwork", Area: "Home",
|
|
|
|
|
Description: "Poster or backdrop artwork on browse rows.",
|
|
|
|
|
Kind: preferenceChoice, Default: "automatic",
|
|
|
|
|
Options: []preferenceOption{
|
|
|
|
|
option("automatic", "Automatic"), option("poster", "Posters"), option("backdrop", "Backdrops"),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Key: "showHomeCardMetadata", Name: "Card metadata", Area: "Home",
|
|
|
|
|
Description: "Show the year, runtime and badges beneath each card.",
|
|
|
|
|
Kind: preferenceToggle, Default: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Key: "showRatingsStrip", Name: "Ratings", Area: "Home",
|
|
|
|
|
Description: "Show third-party ratings on browse and detail pages.",
|
|
|
|
|
Kind: preferenceToggle, Default: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Key: "hideWatchedMovies", Name: "Hide watched films", Area: "Home",
|
|
|
|
|
Description: "Keep fully watched films out of browse rows and the hero.",
|
|
|
|
|
Kind: preferenceToggle, Default: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Key: "showTitleLogo", Name: "Title logos", Area: "Presentation",
|
|
|
|
|
Description: "Use each title's logo artwork in place of plain text.",
|
|
|
|
|
Kind: preferenceToggle, Default: true,
|
|
|
|
|
},
|
2026-08-09 08:25:50 +12:00
|
|
|
{
|
|
|
|
|
// The one thing about themes a viewer decides. The options are the selectable
|
|
|
|
|
// catalogue in themes.go rather than a list written out here, so a theme added
|
|
|
|
|
// there cannot become a value this rejects.
|
|
|
|
|
//
|
|
|
|
|
// Note what this key does *not* control: whether a season is in force. That is
|
|
|
|
|
// resolved server-side on top of this choice (resolveTheme), so a viewer's stored
|
|
|
|
|
// selection survives underneath Halloween rather than being overwritten by it.
|
|
|
|
|
// Note also that the per-user allowlist is not expressed here — this vocabulary is
|
|
|
|
|
// the same for everybody, and an operator's restriction is applied at resolution.
|
|
|
|
|
Key: "themeId", Name: "Colour scheme", Area: "Presentation",
|
|
|
|
|
Description: "Which palette this viewer's televisions paint themselves.",
|
|
|
|
|
Kind: preferenceChoice, Default: defaultThemeID,
|
|
|
|
|
Options: themeOptions(),
|
|
|
|
|
},
|
2026-08-06 22:33:56 +12:00
|
|
|
{
|
|
|
|
|
Key: "welcomeQuoteStyle", Name: "Welcome tone", Area: "Presentation",
|
|
|
|
|
Description: "Tone of the short line shown after signing in.",
|
|
|
|
|
Kind: preferenceChoice, Default: "neutral",
|
|
|
|
|
Options: []preferenceOption{
|
|
|
|
|
option("neutral", "Neutral"), option("positive", "Positive"), option("homicidal", "Homicidal"),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Key: "autoPlayNextEpisode", Name: "Auto-play next episode", Area: "Playback",
|
|
|
|
|
Description: "Roll into the next episode when one finishes.",
|
|
|
|
|
Kind: preferenceToggle, Default: true,
|
|
|
|
|
},
|
2026-08-12 13:08:53 +12:00
|
|
|
{
|
|
|
|
|
Key: "playNextEpisodePreview", Name: "Next-episode recap or preview", Area: "Playback",
|
|
|
|
|
Description: "With auto-play on, play a matched YouTube recap or preview two minutes before the episode ends.",
|
|
|
|
|
Kind: preferenceToggle, Default: false,
|
|
|
|
|
},
|
2026-08-06 22:33:56 +12:00
|
|
|
{
|
|
|
|
|
Key: "showTenMinuteReminder", Name: "Ten-minute reminder", Area: "Playback",
|
|
|
|
|
Description: "Show the lower-third when ten minutes are left.",
|
|
|
|
|
Kind: preferenceToggle, Default: true,
|
|
|
|
|
},
|
2026-08-07 10:44:17 +12:00
|
|
|
{
|
|
|
|
|
// The vocabulary is duplicated on the television (data/SkipIntroPreference.kt),
|
|
|
|
|
// which normalises anything it does not recognise — so a mode added here reaches an
|
|
|
|
|
// older set as "prompt" rather than as silence.
|
|
|
|
|
Key: "skipIntroMode", Name: "Skip the title sequence", Area: "Playback",
|
|
|
|
|
Description: "What to do when an episode reaches its opening titles.",
|
|
|
|
|
Kind: preferenceChoice, Default: skipIntroPrompt,
|
|
|
|
|
Options: []preferenceOption{
|
|
|
|
|
option(skipIntroPrompt, "Offer a button"),
|
|
|
|
|
option(skipIntroAuto, "Skip automatically"),
|
|
|
|
|
option(skipIntroOff, "Do nothing"),
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-08-15 22:26:17 +12:00
|
|
|
// `speedUpCredits` was here. It is deliberately not a viewer preference any more: the
|
|
|
|
|
// closing-credits pane is how this client ends an episode, and an opt-out made it a
|
|
|
|
|
// feature half the household never saw. The operator's `end_credits` flag remains the
|
|
|
|
|
// one switch, which is the right level for it — it governs a subsystem that reads media
|
|
|
|
|
// bytes, and turning it off is a decision about the server rather than about taste.
|
|
|
|
|
//
|
|
|
|
|
// Removing the key from the catalogue is also how the stored values are cleaned up:
|
|
|
|
|
// normalizePreferences drops what it does not recognise, so a viewer who had turned it
|
|
|
|
|
// off gets the pane back on their next sync with nothing having to migrate anything.
|
2026-08-06 22:33:56 +12:00
|
|
|
{
|
|
|
|
|
Key: "subtitlesEnabled", Name: "Subtitles", Area: "Playback",
|
|
|
|
|
Description: "Turn a subtitle track on automatically when the title has one.",
|
|
|
|
|
Kind: preferenceToggle, Default: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Key: "subtitleLanguage", Name: "Subtitle language", Area: "Playback",
|
|
|
|
|
Description: "Which language to choose when subtitles are turned on.",
|
|
|
|
|
Kind: preferenceChoice, Default: subtitleLanguageAuto,
|
|
|
|
|
Options: subtitleLanguageOptions(),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
// The vocabulary is duplicated on the television (data/SeekPreference.kt), which
|
|
|
|
|
// normalises anything it does not recognise — so this list may grow an interval
|
|
|
|
|
// before every set in the house has the release that understands it.
|
|
|
|
|
Key: "seekIntervalSeconds", Name: "Skip interval", Area: "Playback",
|
|
|
|
|
Description: "How far one press of Left or Right moves playback, in seconds.",
|
|
|
|
|
Kind: preferenceNumber, Default: 10, Numbers: []int{10, 20, 30},
|
|
|
|
|
Unit: "seconds",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Key: "forYouMinutes", Name: "For You duration", Area: "Playback",
|
|
|
|
|
Description: "Last time budget chosen in For You. 0 means no limit.",
|
|
|
|
|
Kind: preferenceNumber, Default: 0, Numbers: []int{0, 30, 60, 120},
|
|
|
|
|
Unit: "minutes",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Key: "homeRowOrder", Name: "Row order", Area: "Home layout",
|
|
|
|
|
Description: "Server row ids in the order this viewer arranged them.",
|
|
|
|
|
Kind: preferenceList, Default: []string{},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Key: "homePinnedRows", Name: "Pinned rows", Area: "Home layout",
|
|
|
|
|
Description: "Server row ids kept at the top of the launcher.",
|
|
|
|
|
Kind: preferenceList, Default: []string{},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Key: "homeHiddenRows", Name: "Hidden rows", Area: "Home layout",
|
|
|
|
|
Description: "Server row ids this viewer has hidden.",
|
|
|
|
|
Kind: preferenceList, Default: []string{},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// maxListEntries bounds the free-form id lists. They come from a television, and a row
|
|
|
|
|
// list long enough to matter is already a bug on that end.
|
|
|
|
|
const maxListEntries = 200
|
|
|
|
|
|
|
|
|
|
// maxListEntryLength bounds one id. Emby ids are short; a server row id is a slug.
|
|
|
|
|
const maxListEntryLength = 120
|
|
|
|
|
|
|
|
|
|
func preferenceDefinitionFor(key string) (preferenceDefinition, bool) {
|
|
|
|
|
for _, definition := range preferenceCatalogue {
|
|
|
|
|
if definition.Key == key {
|
|
|
|
|
return definition, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return preferenceDefinition{}, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// normalizePreferences returns a complete, legal document: every known key present, every
|
|
|
|
|
// unknown key dropped, every illegal value replaced by its default.
|
|
|
|
|
//
|
|
|
|
|
// Complete rather than sparse because both readers want it that way — a television
|
|
|
|
|
// applies the answer wholesale instead of merging, and the admin console renders an editor
|
|
|
|
|
// with nothing missing. Pure, and the one piece of this feature worth testing hard: it is
|
|
|
|
|
// what stands between a hand-edited admin request and a launcher that cannot draw a row.
|
|
|
|
|
func normalizePreferences(raw map[string]any) map[string]any {
|
|
|
|
|
result := make(map[string]any, len(preferenceCatalogue))
|
|
|
|
|
for _, definition := range preferenceCatalogue {
|
|
|
|
|
result[definition.Key] = normalizePreference(definition, raw[definition.Key])
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 11:41:36 +12:00
|
|
|
// Device writes must carry admin-owned fields forward even when the client predates them.
|
|
|
|
|
// Without this merge, changing an unrelated setting on an older television would silently
|
|
|
|
|
// put profile initials back on automatic.
|
|
|
|
|
func preserveAdminPreferences(incoming map[string]any, stored json.RawMessage) map[string]any {
|
|
|
|
|
merged := make(map[string]any, len(incoming)+1)
|
|
|
|
|
for key, value := range incoming {
|
|
|
|
|
merged[key] = value
|
|
|
|
|
}
|
|
|
|
|
current := decodePreferences(stored)
|
|
|
|
|
for _, definition := range preferenceCatalogue {
|
|
|
|
|
if definition.AdminOnly {
|
|
|
|
|
merged[definition.Key] = current[definition.Key]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return merged
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
func normalizePreference(definition preferenceDefinition, value any) any {
|
|
|
|
|
switch definition.Kind {
|
|
|
|
|
case preferenceToggle:
|
|
|
|
|
if typed, ok := value.(bool); ok {
|
|
|
|
|
return typed
|
|
|
|
|
}
|
|
|
|
|
case preferenceChoice:
|
|
|
|
|
if typed, ok := value.(string); ok && hasOption(definition.Options, typed) {
|
|
|
|
|
return typed
|
|
|
|
|
}
|
|
|
|
|
case preferenceMulti:
|
|
|
|
|
if selected := stringList(value); len(selected) > 0 {
|
|
|
|
|
kept := []string{}
|
|
|
|
|
for _, entry := range selected {
|
|
|
|
|
if hasOption(definition.Options, entry) && !slices.Contains(kept, entry) {
|
|
|
|
|
kept = append(kept, entry)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// An empty selection is a launcher with no rows at all, so the default
|
|
|
|
|
// stands in rather than being honoured as a choice.
|
|
|
|
|
if len(kept) > 0 {
|
|
|
|
|
return kept
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
case preferenceList:
|
|
|
|
|
kept := []string{}
|
|
|
|
|
for _, entry := range stringList(value) {
|
|
|
|
|
entry = strings.TrimSpace(entry)
|
|
|
|
|
// Newlines are how the television stores these; one inside an id would
|
|
|
|
|
// come back as two rows.
|
|
|
|
|
if entry == "" || len(entry) > maxListEntryLength ||
|
|
|
|
|
strings.ContainsAny(entry, "\n\r") || slices.Contains(kept, entry) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
kept = append(kept, entry)
|
|
|
|
|
if len(kept) == maxListEntries {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return kept
|
|
|
|
|
case preferenceNumber:
|
|
|
|
|
if number, ok := asInt(value); ok && slices.Contains(definition.Numbers, number) {
|
|
|
|
|
return number
|
|
|
|
|
}
|
2026-08-17 11:41:36 +12:00
|
|
|
case preferenceText:
|
|
|
|
|
if typed, ok := value.(string); ok {
|
|
|
|
|
trimmed := strings.TrimSpace(typed)
|
|
|
|
|
if !strings.ContainsAny(trimmed, "\n\r") &&
|
|
|
|
|
(definition.MaxLength <= 0 || len([]rune(trimmed)) <= definition.MaxLength) {
|
|
|
|
|
return strings.ToUpper(trimmed)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
}
|
|
|
|
|
return defaultValue(definition)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// defaultValue hands back a fresh copy of a slice default. Returning the catalogue's own
|
|
|
|
|
// slice would let a caller mutating the result edit the catalogue for the whole process.
|
|
|
|
|
func defaultValue(definition preferenceDefinition) any {
|
|
|
|
|
if values, ok := definition.Default.([]string); ok {
|
|
|
|
|
return append([]string{}, values...)
|
|
|
|
|
}
|
|
|
|
|
return definition.Default
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hasOption(options []preferenceOption, value string) bool {
|
|
|
|
|
for _, candidate := range options {
|
|
|
|
|
if candidate.Value == value {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func stringList(value any) []string {
|
|
|
|
|
switch typed := value.(type) {
|
|
|
|
|
case []string:
|
|
|
|
|
return typed
|
|
|
|
|
case []any:
|
|
|
|
|
values := make([]string, 0, len(typed))
|
|
|
|
|
for _, entry := range typed {
|
|
|
|
|
if text, ok := entry.(string); ok {
|
|
|
|
|
values = append(values, text)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return values
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// asInt accepts float64 because that is what encoding/json produces for every number.
|
|
|
|
|
func asInt(value any) (int, bool) {
|
|
|
|
|
switch typed := value.(type) {
|
|
|
|
|
case float64:
|
|
|
|
|
return int(typed), typed == float64(int(typed))
|
|
|
|
|
case int:
|
|
|
|
|
return typed, true
|
|
|
|
|
}
|
|
|
|
|
return 0, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func decodePreferences(raw json.RawMessage) map[string]any {
|
|
|
|
|
values := map[string]any{}
|
|
|
|
|
if len(raw) > 0 {
|
|
|
|
|
_ = json.Unmarshal(raw, &values)
|
|
|
|
|
}
|
|
|
|
|
return normalizePreferences(values)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type preferencesResponse struct {
|
|
|
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
|
|
|
Revision int64 `json:"revision"`
|
|
|
|
|
UpdatedAt any `json:"updatedAt,omitempty"`
|
|
|
|
|
Source string `json:"source,omitempty"`
|
|
|
|
|
Preferences map[string]any `json:"preferences"`
|
|
|
|
|
Catalogue []preferenceDefinition `json:"catalogue"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func preferencesPayload(stored store.UserPreferences) preferencesResponse {
|
|
|
|
|
payload := preferencesResponse{
|
|
|
|
|
SchemaVersion: preferenceSchemaVersion, Revision: stored.Revision,
|
|
|
|
|
Source: stored.Source, Preferences: decodePreferences(stored.Preferences),
|
|
|
|
|
Catalogue: preferenceCatalogue,
|
|
|
|
|
}
|
|
|
|
|
if !stored.UpdatedAt.IsZero() {
|
|
|
|
|
payload.UpdatedAt = stored.UpdatedAt
|
|
|
|
|
}
|
|
|
|
|
return payload
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type preferencesRequest struct {
|
|
|
|
|
// Revision is the revision the television believes it is editing. A mismatch is a
|
|
|
|
|
// 409 and never a retry: the loser re-reads, because the other writer is usually the
|
|
|
|
|
// operator and silently overwriting them is exactly what this endpoint must not do.
|
|
|
|
|
Revision int64 `json:"revision"`
|
|
|
|
|
Preferences map[string]any `json:"preferences"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handlePreferences is one handler for both verbs because they answer with the same
|
|
|
|
|
// document, and a client that has just written wants what was stored, not what it sent.
|
|
|
|
|
func (s *Server) handlePreferences(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
|
|
|
|
if r.Method == http.MethodGet {
|
|
|
|
|
stored, err := s.store.UserPreferences(r.Context(), sess.EmbyUserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.loggerFor(r.Context()).Error("preferences read failed", "error", err)
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not read your settings")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.recordPreferenceAck(r, sess, stored.Revision)
|
|
|
|
|
writeJSON(w, http.StatusOK, preferencesPayload(stored))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var req preferencesRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-17 11:41:36 +12:00
|
|
|
current, err := s.store.UserPreferences(r.Context(), sess.EmbyUserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not read your settings")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
raw, err := json.Marshal(normalizePreferences(
|
|
|
|
|
preserveAdminPreferences(req.Preferences, current.Preferences),
|
|
|
|
|
))
|
2026-08-06 22:33:56 +12:00
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "could not read those settings")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
stored, err := s.store.SetUserPreferences(r.Context(), sess.EmbyUserID, store.PreferenceWrite{
|
|
|
|
|
Preferences: raw, ExpectedRevision: req.Revision, Source: "device",
|
|
|
|
|
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName, ClientVersion: sess.ClientVersion,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrPreferencesConflict) {
|
|
|
|
|
// The current document rides along with the 409 so the television can adopt
|
|
|
|
|
// it without a second round trip — which matters, because the usual cause is
|
|
|
|
|
// an operator push it has not caught up with yet.
|
|
|
|
|
current, readErr := s.store.UserPreferences(r.Context(), sess.EmbyUserID)
|
|
|
|
|
if readErr != nil {
|
|
|
|
|
writeError(w, http.StatusConflict, "your settings changed elsewhere; reload them")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
// The television adopts what came back rather than retrying, so this set now
|
|
|
|
|
// holds the winner's revision as surely as if it had fetched it.
|
|
|
|
|
s.recordPreferenceAck(r, sess, current.Revision)
|
|
|
|
|
writeJSON(w, http.StatusConflict, preferencesPayload(current))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.loggerFor(r.Context()).Error("preferences write failed", "error", err)
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "could not save your settings")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.recordPreferenceAck(r, sess, stored.Revision)
|
|
|
|
|
s.loggerFor(r.Context()).Info("preferences saved", "revision", stored.Revision, "source", "device")
|
|
|
|
|
writeJSON(w, http.StatusOK, preferencesPayload(stored))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// recordPreferenceAck notes that this television now holds this revision.
|
|
|
|
|
//
|
|
|
|
|
// Best-effort on purpose, and never in front of the response: the viewer's settings have
|
|
|
|
|
// already been read or written correctly, and losing the operator's view of which sets are
|
|
|
|
|
// up to date is not worth failing that. A set whose ack is dropped records the next one.
|
|
|
|
|
func (s *Server) recordPreferenceAck(r *http.Request, sess store.Session, revision int64) {
|
|
|
|
|
if s.store == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.RecordPreferenceAck(r.Context(), sess.EmbyUserID, sess.DeviceID,
|
|
|
|
|
sess.DeviceName, sess.ClientVersion, revision); err != nil {
|
|
|
|
|
s.loggerFor(r.Context()).Warn("preference acknowledgement not recorded", "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// subtitlePreferenceFor reads this viewer's subtitle choice out of their synced settings,
|
|
|
|
|
// falling back to the catalogue defaults for anyone who has never expressed one.
|
|
|
|
|
//
|
|
|
|
|
// A read failure is not an error a viewer should ever see: it costs them the preferred
|
|
|
|
|
// language for one launch, and refusing to resolve playback over a settings row that would
|
|
|
|
|
// not load would be a much worse trade.
|
|
|
|
|
func (s *Server) subtitlePreferenceFor(ctx context.Context, sess store.Session) (bool, string) {
|
|
|
|
|
enabled, _ := preferenceDefault("subtitlesEnabled").(bool)
|
|
|
|
|
language, _ := preferenceDefault("subtitleLanguage").(string)
|
|
|
|
|
if s.store == nil || sess.EmbyUserID == "" {
|
|
|
|
|
return enabled, language
|
|
|
|
|
}
|
|
|
|
|
stored, err := s.store.UserPreferences(ctx, sess.EmbyUserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.loggerFor(ctx).Warn("subtitle preference unavailable", "error", err)
|
|
|
|
|
return enabled, language
|
|
|
|
|
}
|
|
|
|
|
document := decodePreferences(stored.Preferences)
|
|
|
|
|
if value, ok := document["subtitlesEnabled"].(bool); ok {
|
|
|
|
|
enabled = value
|
|
|
|
|
}
|
|
|
|
|
if value, ok := document["subtitleLanguage"].(string); ok {
|
|
|
|
|
language = value
|
|
|
|
|
}
|
|
|
|
|
return enabled, language
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// preferenceChange is one setting that differs between two revisions, described in the
|
|
|
|
|
// words the catalogue uses rather than in the keys and raw values the document holds.
|
|
|
|
|
type preferenceChange struct {
|
|
|
|
|
Key string `json:"key"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Area string `json:"area"`
|
|
|
|
|
Before string `json:"before"`
|
|
|
|
|
After string `json:"after"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// preferenceChanges is what a history row actually says: "Card size: Standard → Large",
|
|
|
|
|
// not two JSON documents for the operator to compare by eye.
|
|
|
|
|
//
|
|
|
|
|
// Pure, and driven entirely by the catalogue, so a setting added tomorrow is described
|
|
|
|
|
// without touching this. It walks the catalogue rather than the documents on purpose —
|
|
|
|
|
// a key that has since been retired is not a change anybody can act on, and a value stored
|
|
|
|
|
// before a setting existed would otherwise read as one appearing out of nothing.
|
|
|
|
|
func preferenceChanges(before, after map[string]any) []preferenceChange {
|
|
|
|
|
changes := []preferenceChange{}
|
|
|
|
|
for _, definition := range preferenceCatalogue {
|
|
|
|
|
was := normalizePreference(definition, before[definition.Key])
|
|
|
|
|
now := normalizePreference(definition, after[definition.Key])
|
|
|
|
|
if preferenceValueLabel(definition, was) == preferenceValueLabel(definition, now) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
changes = append(changes, preferenceChange{
|
|
|
|
|
Key: definition.Key, Name: definition.Name, Area: definition.Area,
|
|
|
|
|
Before: preferenceValueLabel(definition, was),
|
|
|
|
|
After: preferenceValueLabel(definition, now),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return changes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// preferenceValueLabel is one stored value as a person reads it. Comparing labels rather
|
|
|
|
|
// than values is deliberate: two documents that render identically have not changed
|
|
|
|
|
// anything an operator could see, and a history row saying otherwise is noise.
|
|
|
|
|
func preferenceValueLabel(definition preferenceDefinition, value any) string {
|
|
|
|
|
switch definition.Kind {
|
|
|
|
|
case preferenceToggle:
|
|
|
|
|
if enabled, _ := value.(bool); enabled {
|
|
|
|
|
return "On"
|
|
|
|
|
}
|
|
|
|
|
return "Off"
|
|
|
|
|
case preferenceChoice:
|
|
|
|
|
selected, _ := value.(string)
|
|
|
|
|
for _, option := range definition.Options {
|
|
|
|
|
if option.Value == selected {
|
|
|
|
|
return option.Label
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return selected
|
|
|
|
|
case preferenceNumber:
|
|
|
|
|
number, ok := asInt(value)
|
|
|
|
|
if !ok {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if number == 0 && definition.Unit != "" {
|
|
|
|
|
return "No limit"
|
|
|
|
|
}
|
|
|
|
|
if definition.Unit != "" {
|
|
|
|
|
return strconv.Itoa(number) + " " + definition.Unit
|
|
|
|
|
}
|
|
|
|
|
return strconv.Itoa(number)
|
|
|
|
|
case preferenceMulti:
|
|
|
|
|
labels := []string{}
|
|
|
|
|
for _, entry := range stringList(value) {
|
|
|
|
|
label := entry
|
|
|
|
|
for _, option := range definition.Options {
|
|
|
|
|
if option.Value == entry {
|
|
|
|
|
label = option.Label
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
labels = append(labels, label)
|
|
|
|
|
}
|
|
|
|
|
if len(labels) == 0 {
|
|
|
|
|
return "None"
|
|
|
|
|
}
|
|
|
|
|
return strings.Join(labels, ", ")
|
|
|
|
|
case preferenceList:
|
|
|
|
|
entries := stringList(value)
|
|
|
|
|
if len(entries) == 0 {
|
|
|
|
|
return "None"
|
|
|
|
|
}
|
|
|
|
|
return strings.Join(entries, ", ")
|
2026-08-17 11:41:36 +12:00
|
|
|
case preferenceText:
|
|
|
|
|
text, _ := value.(string)
|
|
|
|
|
if text == "" {
|
|
|
|
|
return "Automatic"
|
|
|
|
|
}
|
|
|
|
|
return text
|
2026-08-06 22:33:56 +12:00
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func preferenceDefault(key string) any {
|
|
|
|
|
definition, ok := preferenceDefinitionFor(key)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return defaultValue(definition)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// preferenceRevisionFor is the number /v1/status carries. A read failure reports 0 rather
|
|
|
|
|
// than an error: the poll's other job is maintenance, and losing that because a settings
|
|
|
|
|
// row could not be read would be a much worse trade.
|
|
|
|
|
func (s *Server) preferenceRevisionFor(r *http.Request, sess store.Session) int64 {
|
|
|
|
|
if s.store == nil || sess.EmbyUserID == "" {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
revision, err := s.store.UserPreferenceRevision(r.Context(), sess.EmbyUserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.loggerFor(r.Context()).Warn("preference revision unavailable", "error", err)
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
return revision
|
|
|
|
|
}
|