Files
memby/server/internal/api/themes.go
T
2026-08-09 08:25:50 +12:00

578 lines
23 KiB
Go

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, ",")
}