package api import ( "context" "encoding/json" "hash/fnv" "net/http" "slices" "sort" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/store" ) // The colour a television paints itself, decided here rather than there. // // The whole feature is server-owned for the same reason the row composition and the // subtitle choice are: a palette that shipped in the APK could only change with a release, // and these sets are sideloaded one at a time. Deciding it here means an operator can hand // a household a new scheme, restrict what a particular viewer may choose, and — the part // that has to happen without anybody doing anything — put the whole house into a seasonal // theme on the right morning and take it away again afterwards. // // Two kinds of theme, and the difference is the point: // // - A **selectable** theme is the viewer's own choice, held as the ordinary synced // preference `themeId` and picked in Settings → Appearance from whatever the operator // has allowed them. // - A **seasonal** theme is not a choice at all. It is in force for its dates and nothing // on the television can decline it — there is no "off" in the picker, because a switch // for it is exactly what somebody would leave switched off in October and never think // about again. The one control that exists is the operator's, as a feature flag, and it // is all-or-nothing for the whole house. // // Both are palettes and nothing else. A theme changes colour; it never changes what a row // contains, where a control sits, or whether a feature exists — so a theme this build has // never heard of is at worst the wrong shade, never a launcher that will not draw. const themeSchemaVersion = 1 // themePalette is the whole vocabulary a theme may set, and it is deliberately the exact // token list in the television's ui/theme/DesignTokens.kt. A palette carrying a colour the // TV has no slot for would be a promise the client cannot keep; a palette missing one is a // theme that half-applies, which reads as a bug rather than as a design. // // Colours are "#RRGGBB" or "#AARRGGBB" — the alpha-first order Android writes, because that // is the one end that has to parse them. type themePalette struct { Surface string `json:"surface"` SurfaceRaised string `json:"surfaceRaised"` Accent string `json:"accent"` OnSurface string `json:"onSurface"` MutedText string `json:"mutedText"` QuietText string `json:"quietText"` Hairline string `json:"hairline"` RatingsSurface string `json:"ratingsSurface"` } // The decorations a theme may ask a television to draw over its launcher. A slug rather // than a description of the animation: the drawing lives on the TV, in Compose, and the // gateway has no business describing shapes to it. A client that does not recognise one // draws nothing, which is why this can gain a decoration before the fleet has the build // that knows it — the MembyHeroLabel precedent. const ( decorationSnow = "snow" decorationBats = "bats" decorationBlossom = "blossom" ) type themeDefinition struct { ID string `json:"id"` Name string `json:"name"` // Description is what the picker prints under the name. One short line: the viewer is // reading it from across a room and the swatch is doing most of the work. Description string `json:"description"` // Seasonal themes are never offered in the picker and never stored as anybody's choice. Seasonal bool `json:"seasonal"` Palette themePalette `json:"palette"` // Decoration is what drifts over the launcher while this theme is on. Only seasonal // themes carry one: a scheme somebody chose to look at every day of the year must not // have things falling across it, and a viewer who wanted that would have no way to stop // it. It is empty on every selectable theme by construction rather than by a check at // the point of use. Decoration string `json:"decoration,omitempty"` } const ( themeMidnight = "midnight" themeGraphite = "graphite" themeMidnightB = "indigo" themeEmber = "ember" themeForest = "forest" themePlum = "plum" themeHalloween = "halloween" themeChristmas = "christmas" themeEaster = "easter" ) // defaultThemeID is what a viewer who has never chosen gets, and what an illegal choice // falls back to. It is the palette the app shipped with before themes existed, so nothing // changes appearance on the day this lands. const defaultThemeID = themeMidnight // themeCatalogue is the only place a theme is declared: the picker, the admin console's // allowlist editor and the set of legal `themeId` values all read it. // // The neutrals move with the accent rather than staying fixed. A single accent swapped into // one grey shell reads as a stray coloured button rather than as a theme, and on a panel // this dark a hairline that does not carry a hint of the accent disappears entirely. var themeCatalogue = []themeDefinition{ { ID: themeMidnight, Name: "Midnight", Description: "The Memby original — near-black and Emby green.", Palette: themePalette{ Surface: "#FF090B0D", SurfaceRaised: "#FF101418", Accent: "#FF52B54B", OnSurface: "#FFE2E5E8", MutedText: "#FFD0D6DB", QuietText: "#FFAEB7BF", Hairline: "#28FFFFFF", RatingsSurface: "#FF20252A", }, }, { ID: themeGraphite, Name: "Graphite", Description: "Warm grey and amber, easier on a bright room.", Palette: themePalette{ Surface: "#FF0D0C0A", SurfaceRaised: "#FF181614", Accent: "#FFE0A33C", OnSurface: "#FFE9E5DE", MutedText: "#FFD8D2C8", QuietText: "#FFB6AEA1", Hairline: "#28FFF3DC", RatingsSurface: "#FF262320", }, }, { ID: themeMidnightB, Name: "Indigo", Description: "Deep blue with a cool electric accent.", Palette: themePalette{ Surface: "#FF07090F", SurfaceRaised: "#FF111726", Accent: "#FF5C8DFF", OnSurface: "#FFE1E6F0", MutedText: "#FFCBD4E4", QuietText: "#FFA5B0C6", Hairline: "#28C7D8FF", RatingsSurface: "#FF1D2435", }, }, { ID: themeEmber, Name: "Ember", Description: "Charcoal and a low red, for watching in the dark.", Palette: themePalette{ Surface: "#FF0C0808", SurfaceRaised: "#FF181111", Accent: "#FFE05B4A", OnSurface: "#FFEDE3E1", MutedText: "#FFDACECB", QuietText: "#FFB8A7A3", Hairline: "#28FFD5CE", RatingsSurface: "#FF261B1A", }, }, { ID: themeForest, Name: "Forest", Description: "Muted green on a near-black that leans warm.", Palette: themePalette{ Surface: "#FF080B09", SurfaceRaised: "#FF111713", Accent: "#FF7FC08A", OnSurface: "#FFE3E8E3", MutedText: "#FFCFD8CF", QuietText: "#FFA9B5AA", Hairline: "#28D2F0D6", RatingsSurface: "#FF1E2620", }, }, { ID: themePlum, Name: "Plum", Description: "Aubergine and soft violet.", Palette: themePalette{ Surface: "#FF0B080D", SurfaceRaised: "#FF171020", Accent: "#FFB37FE0", OnSurface: "#FFE7E2EC", MutedText: "#FFD5CCDD", QuietText: "#FFB0A4BC", Hairline: "#28E4D2FF", RatingsSurface: "#FF241B2D", }, }, // --- Seasonal. Never offered, never stored, never declined. ------------------------ { ID: themeHalloween, Name: "Halloween", Seasonal: true, Description: "Pumpkin orange on black, for the last week of October.", Decoration: decorationBats, Palette: themePalette{ Surface: "#FF0A0704", SurfaceRaised: "#FF17100A", Accent: "#FFFF8A1F", OnSurface: "#FFF2E7DA", MutedText: "#FFE2D2BE", QuietText: "#FFBBA48C", Hairline: "#28FFB870", RatingsSurface: "#FF26190E", }, }, { ID: themeChristmas, Name: "Christmas", Seasonal: true, Description: "Pine and holly red, through December.", Decoration: decorationSnow, Palette: themePalette{ Surface: "#FF060A07", SurfaceRaised: "#FF0E1710", Accent: "#FFE0403F", OnSurface: "#FFEAF0E9", MutedText: "#FFD6E0D5", QuietText: "#FFAEBCAE", Hairline: "#28CFE8CF", RatingsSurface: "#FF19261B", }, }, { ID: themeEaster, Name: "Easter", Seasonal: true, Description: "Pale spring colours over the Easter weekend.", Decoration: decorationBlossom, Palette: themePalette{ Surface: "#FF0A0910", SurfaceRaised: "#FF15131F", Accent: "#FF9BD3F0", OnSurface: "#FFEDE9F2", MutedText: "#FFDCD6E4", QuietText: "#FFB6AEC4", Hairline: "#28D8E9F7", RatingsSurface: "#FF211E2E", }, }, } func themeDefinitionFor(id string) (themeDefinition, bool) { for _, theme := range themeCatalogue { if theme.ID == id { return theme, true } } return themeDefinition{}, false } // selectableThemes is the catalogue a viewer could ever be offered, before the operator's // per-user allowlist narrows it. Seasonal themes are absent by construction rather than // filtered at the point of use, so there is no code path that can offer one as a choice. func selectableThemes() []themeDefinition { themes := make([]themeDefinition, 0, len(themeCatalogue)) for _, theme := range themeCatalogue { if !theme.Seasonal { themes = append(themes, theme) } } return themes } func selectableThemeIDs() []string { ids := make([]string, 0, len(themeCatalogue)) for _, theme := range selectableThemes() { ids = append(ids, theme.ID) } return ids } // themeOptions renders the selectable catalogue as preference options, so the `themeId` // entry in preferenceCatalogue cannot drift from the themes that actually exist. // // It lists every selectable theme rather than only the ones a given viewer may pick, // because normalizePreferences is pure and per-viewer policy is not a vocabulary question. // The allowlist is applied at resolution instead — see resolveTheme. func themeOptions() []preferenceOption { options := make([]preferenceOption, 0, len(themeCatalogue)) for _, theme := range selectableThemes() { options = append(options, option(theme.ID, theme.Name)) } return options } // --- The seasons ------------------------------------------------------------------------ // themeSeason is one window in the calendar and the theme it puts the house into. type themeSeason struct { theme string // contains answers for a local date. A function rather than a pair of dates because // Easter is not on one. contains func(year int, month time.Month, day int) bool } // seasons are checked in order and the first match wins, which only matters if two windows // ever overlap. They do not today, and the ordering is what stops a future one silently // producing two answers. var seasons = []themeSeason{ { // The last week of October and All Saints' Day. It starts a week out rather than on // the day: a theme nobody sees until the evening of the 31st is one nobody sees. theme: themeHalloween, contains: func(_ int, month time.Month, day int) bool { return (month == time.October && day >= 25) || (month == time.November && day == 1) }, }, { // December up to and including Boxing Day. It stops before New Year deliberately — // the tree is down, and a red-and-green launcher on the 30th reads as a server // nobody is maintaining. theme: themeChristmas, contains: func(_ int, month time.Month, day int) bool { return month == time.December && day <= 26 }, }, { // Good Friday to Easter Monday, computed rather than listed: Easter moves, and a // hard-coded table is a feature with an expiry date on it. theme: themeEaster, contains: func(year int, month time.Month, day int) bool { sunday := easterSunday(year) date := time.Date(year, month, day, 0, 0, 0, 0, time.UTC) return !date.Before(sunday.AddDate(0, 0, -2)) && !date.After(sunday.AddDate(0, 0, 1)) }, }, } // easterSunday is the anonymous Gregorian computus. It is arithmetic with no calendar // library behind it and no table to go stale, which is the only reason Easter is affordable // as a season at all. func easterSunday(year int) time.Time { a := year % 19 b := year / 100 c := year % 100 d := b / 4 e := b % 4 f := (b + 8) / 25 g := (b - f + 1) / 3 h := (19*a + b - d - g + 15) % 30 i := c / 4 k := c % 4 l := (32 + 2*e + 2*i - h - k) % 7 m := (a + 11*h + 22*l) / 451 month := (h + l - 7*m + 114) / 31 day := ((h + l - 7*m + 114) % 31) + 1 return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) } // seasonalThemeFor is the theme in force on a given day, or "" for most of the year. // // Pure, and takes the time rather than reading the clock, so every window can be tested at // both of its edges without waiting for October. The date is read in whatever location the // caller hands it in: the gateway runs on the household's own machine, and "Christmas" means // the calendar on the wall in that house, not a UTC instant. func seasonalThemeFor(now time.Time) string { year, month, day := now.Date() for _, season := range seasons { if season.contains(year, month, day) { return season.theme } } return "" } // --- Resolution ------------------------------------------------------------------------- // resolvedTheme is the answer a television is given: one palette, and enough about where it // came from for the picker to explain itself. type resolvedTheme struct { ID string `json:"id"` Name string `json:"name"` Palette themePalette `json:"palette"` // Seasonal says this palette was not chosen by anybody. Seasonal bool `json:"seasonal"` // Locked is what the picker obeys: while it is true the viewer's own choice is still // stored and still shown, but it cannot be changed and is not what is on screen. It is // a separate field from Seasonal rather than the same one, because a future reason to // lock a theme (an operator pinning one, say) must not have to claim to be a season. Locked bool `json:"locked"` // Chosen is the viewer's own selection, still theirs underneath a season. Without it // the picker would have nothing to show as selected for the fortnight a season is up, // and would look as though the choice had been forgotten. Chosen string `json:"chosen"` // Decoration is what the launcher draws over itself: "snow", "bats", "blossom", or // empty for the whole rest of the year. Empty is also what an operator who has turned // the decorations off gets, which is why it is resolved here rather than read off the // theme by the television — a set holding a cached Christmas palette must not keep // snowing after the switch has been thrown. Decoration string `json:"decoration,omitempty"` // Reason is the sentence the picker prints while it is locked. The gateway's wording, // the MembyHeroLabel precedent, so a season invented later reads correctly on today's // build rather than as a blank space where an explanation should be. Reason string `json:"reason,omitempty"` // Revision changes whenever the bytes of this answer would change. The status poll // carries it and the television refetches only when it moves — which is what makes a // season arriving overnight cost one request rather than a palette on every poll. // // A string, not a number: it is a 64-bit hash, and JSON numbers are float64 in both // the admin console and anything else that reads this. It is only ever compared for // equality, so its being opaque costs nothing. Revision string `json:"revision"` } // resolveTheme is the whole rule, and it is pure. // // Order matters and is the feature: a season outranks the viewer, the viewer outranks the // default, and the operator's allowlist is applied to the viewer's choice but never to a // season. That last part is what "cannot be removed or controlled by the user" means in // code — there is no argument to this function that a television could send which suppresses // a season. The only switch is seasonalEnabled, and that is the operator's feature flag. func resolveTheme( chosen string, allowed []string, seasonalEnabled bool, decorationsEnabled bool, now time.Time, ) resolvedTheme { // The viewer's own choice first, so it is reported even while a season covers it. pick, ok := themeDefinitionFor(chosen) if !ok || pick.Seasonal || !themeAllowed(pick.ID, allowed) { pick, _ = themeDefinitionFor(defaultThemeID) } applied, seasonal, reason := pick, false, "" if seasonalEnabled { if id := seasonalThemeFor(now); id != "" { if season, found := themeDefinitionFor(id); found { applied, seasonal = season, true reason = season.Name + " is on for everyone until it is over." } } } // Decorations are a second switch, not a consequence of the first. A household on a // weak box may well want the December palette and nothing moving over it, and a // decoration is by far the more expensive half — it is the only thing in the app that // animates continuously while somebody is browsing. decoration := "" if seasonal && decorationsEnabled { decoration = applied.Decoration } resolved := resolvedTheme{ ID: applied.ID, Name: applied.Name, Palette: applied.Palette, Seasonal: seasonal, Locked: seasonal, Chosen: pick.ID, Reason: reason, Decoration: decoration, } resolved.Revision = themeRevision(resolved) return resolved } // themeAllowed applies the operator's per-user list. An empty list is *permissive*: no row // has ever been written for the great majority of households, and reading that as "this // person may have no themes" would empty every picker in the house the day this ships. func themeAllowed(id string, allowed []string) bool { if len(allowed) == 0 { return true } return slices.Contains(allowed, id) } // themeRevision is a hash of the answer rather than a counter in a table, because there is // no write to attach a counter to: this changes when the calendar turns over or when a // deployment edits the catalogue, and neither of those is a row anybody updates. func themeRevision(resolved resolvedTheme) string { digest := fnv.New64a() palette := resolved.Palette for _, part := range []string{ strconv.Itoa(themeSchemaVersion), resolved.ID, resolved.Chosen, strconv.FormatBool(resolved.Seasonal), strconv.FormatBool(resolved.Locked), resolved.Reason, resolved.Decoration, palette.Surface, palette.SurfaceRaised, palette.Accent, palette.OnSurface, palette.MutedText, palette.QuietText, palette.Hairline, palette.RatingsSurface, } { _, _ = digest.Write([]byte(part)) _, _ = digest.Write([]byte{0}) } return strconv.FormatUint(digest.Sum64(), 10) } // --- Serving ------------------------------------------------------------------------------ // themeFor resolves this viewer's theme from the three things it depends on: their stored // choice, the operator's allowlist for them, and the clock. // // Every read failure degrades to the default rather than to an error. A launcher that will // not open because a colour could not be looked up would be an absurd trade, and the // palette it falls back to is the one the app shipped with. func (s *Server) themeFor(ctx context.Context, sess store.Session) resolvedTheme { chosen, _ := preferenceDefault("themeId").(string) allowed := []string(nil) if s.store != nil && sess.EmbyUserID != "" { if stored, err := s.store.UserPreferences(ctx, sess.EmbyUserID); err == nil { if value, ok := decodePreferences(stored.Preferences)["themeId"].(string); ok { chosen = value } } else { s.loggerFor(ctx).Warn("theme preference unavailable", "error", err) } if list, err := s.store.UserThemes(ctx, sess.EmbyUserID); err == nil { allowed = list } else { s.loggerFor(ctx).Warn("theme allowlist unavailable", "error", err) } } return resolveTheme( chosen, allowed, s.featureEnabled(ctx, featureSeasonalThemes), s.featureEnabled(ctx, featureSeasonalDecorations), s.now(), ) } // now is the gateway's own clock, in its own location. Seasons are calendar dates in the // house the server sits in; see seasonalThemeFor. func (s *Server) now() time.Time { return time.Now() } // themeStatus is the summary /v1/status carries: enough for a television to know whether // what it is painted with is still right, and nothing more. func themeStatus(resolved resolvedTheme) map[string]any { return map[string]any{ "id": resolved.ID, "revision": resolved.Revision, "seasonal": resolved.Seasonal, "locked": resolved.Locked, } } type themeResponse struct { SchemaVersion int `json:"schemaVersion"` Theme resolvedTheme `json:"theme"` Available []themeDefinition `json:"available"` } // handleTheme is the full document, fetched only when the revision on the status poll moves. // // It carries the *available* list as well as the applied palette, so the television's picker // is drawn from the server's answer for this particular viewer rather than from a catalogue // compiled into the APK. That is what makes the per-user allowlist real: a theme an operator // has withheld is not a greyed-out row on the TV, it is a row that was never sent. func (s *Server) handleTheme(w http.ResponseWriter, r *http.Request, sess store.Session) { resolved := s.themeFor(r.Context(), sess) allowed := []string(nil) if s.store != nil && sess.EmbyUserID != "" { if list, err := s.store.UserThemes(r.Context(), sess.EmbyUserID); err == nil { allowed = list } } available := []themeDefinition{} for _, theme := range selectableThemes() { if themeAllowed(theme.ID, allowed) { available = append(available, theme) } } // A viewer whose allowlist has been emptied down to nothing legal still gets the // default, or Settings → Appearance is a page with no rows on it and no way back to one. if len(available) == 0 { if fallback, ok := themeDefinitionFor(defaultThemeID); ok { available = append(available, fallback) } } writeJSON(w, http.StatusOK, themeResponse{ SchemaVersion: themeSchemaVersion, Theme: resolved, Available: available, }) } // --- The operator's allowlist --------------------------------------------------------------- // normalizeThemeAllowlist is what stands between a hand-edited admin request and a viewer // with a picker full of themes that do not exist. Unknown and seasonal ids are dropped — // a season is not a thing that can be granted or withheld per person — and the result is // ordered by the catalogue so two operators saving the same set store the same row. // // A list that selects everything selectable is stored as nothing at all, which keeps the // permissive default meaning one thing: "the operator has not restricted this person". func normalizeThemeAllowlist(ids []string) []string { kept := []string{} for _, theme := range selectableThemes() { if slices.Contains(ids, theme.ID) { kept = append(kept, theme.ID) } } if len(kept) == len(selectableThemes()) { return []string{} } return kept } type adminThemesRequest struct { Themes []string `json:"themes"` } func (s *Server) handleAdminUserThemes(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.PathValue("userID")) if userID == "" { writeError(w, http.StatusBadRequest, "user is required") return } var req adminThemesRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } allowed := normalizeThemeAllowlist(req.Themes) if err := s.store.SetUserThemes(r.Context(), userID, allowed); err != nil { s.loggerFor(r.Context()).Error("theme allowlist write failed", "user", userID, "error", err) writeError(w, http.StatusInternalServerError, "could not save those themes") return } s.loggerFor(r.Context()).Info("themes allowed for viewer", "user", userID, "themes", themeListLabel(allowed)) writeJSON(w, http.StatusOK, map[string]any{"themes": allowed}) } // themeListLabel is for the log line, where "all" says more than an empty array does. func themeListLabel(allowed []string) string { if len(allowed) == 0 { return "all" } sorted := append([]string{}, allowed...) sort.Strings(sorted) return strings.Join(sorted, ",") }