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. const preferenceSchemaVersion = 1 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" ) 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. Unit string `json:"unit,omitempty"` Default any `json:"default"` } func option(value, label string) preferenceOption { return preferenceOption{Value: value, Label: label} } var preferenceCatalogue = []preferenceDefinition{ { 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, }, { // 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(), }, { 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, }, { 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, }, { Key: "showTenMinuteReminder", Name: "Ten-minute reminder", Area: "Playback", Description: "Show the lower-third when ten minutes are left.", Kind: preferenceToggle, Default: true, }, { // 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"), }, }, // `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. { 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 } 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 } } 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 } raw, err := json.Marshal(normalizePreferences(req.Preferences)) 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, ", ") } 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 }