Files
2026-08-22 08:26:10 +12:00

672 lines
26 KiB
Go

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 = 2
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"
// Short free-form display text, bounded by MaxLength.
preferenceText preferenceKind = "text"
)
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"`
MaxLength int `json:"maxLength,omitempty"`
// Uppercase folds a text value to capitals. It belongs to the definition rather than
// to the kind: initials are read as capitals, and a person's name is not — folding
// every text setting would have the launcher greeting somebody as MATT.
Uppercase bool `json:"uppercase,omitempty"`
// Placeholder is what the console shows in an empty field, which for these settings is
// what happens when nobody fills it in. Blank is a legal value for both of them, so the
// field has to say what blank means or it reads as a setting that was never finished.
Placeholder string `json:"placeholder,omitempty"`
AdminOnly bool `json:"adminOnly,omitempty"`
Default any `json:"default"`
}
func option(value, label string) preferenceOption {
return preferenceOption{Value: value, Label: label}
}
var preferenceCatalogue = []preferenceDefinition{
{
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,
Uppercase: true, Placeholder: "Generated from their name",
},
{
// The friendly name Memby addresses somebody by, and nothing more: it is not a
// second username and nothing is keyed on it. Blank is the ordinary state — the
// television falls back to the account name — so this is only worth setting where
// the account name is not what anybody would call the person.
Key: "shortName", Name: "Short name", Area: "Profile",
Description: "The friendly name Memby greets this person by. Leave blank to use their account name.",
Kind: preferenceText, Default: "", MaxLength: shortNameMaxLength, AdminOnly: true,
Placeholder: "Their account name",
},
{
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(),
},
{
// The marks, kept apart from the palette because they are a different decision
// about legibility rather than about taste — a household watching from a sofa may
// well want the solid pack on every scheme they own.
//
// The options come from iconPackOptions() in themes.go for the reason themeId's
// come from the catalogue: a pack added there cannot become a value this rejects,
// and a pack removed cannot stay selectable here.
Key: "iconSet", Name: "Icon set", Area: "Presentation",
Description: "Which set of marks this viewer's televisions draw.",
Kind: preferenceChoice, Default: defaultIconPackID,
Options: iconPackOptions(),
},
{
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{},
},
}
// shortNameMaxLength bounds the friendly name. It is a first name on a launcher, not a
// field to write a sentence in, and the greeting it lands in shares its line with a clock.
const shortNameMaxLength = 24
// 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
}
// 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
}
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
}
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) {
if definition.Uppercase {
return strings.ToUpper(trimmed)
}
return trimmed
}
}
}
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
}
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),
))
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, ", ")
case preferenceText:
text, _ := value.(string)
if text == "" {
return "Automatic"
}
return text
}
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
}