2026-08-09 08:25:50 +12:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-18 14:59:29 +12:00
|
|
|
// The icon packs a theme may draw its marks from. Slugs, for the reason the decorations
|
|
|
|
|
// above are slugs: the shapes live on the television, in ui/theme/MembyIconPacks.kt, and
|
|
|
|
|
// the gateway has no business describing geometry to it. A client that does not recognise
|
|
|
|
|
// one draws the marks it shipped with — so this list may gain a pack before the fleet has
|
|
|
|
|
// the build that knows it, the MembyHeroLabel precedent again.
|
|
|
|
|
//
|
|
|
|
|
// The reason to want any of this is that Material's marks are the marks every Android app
|
|
|
|
|
// on the television already wears. Moving a household off them is a decision an operator
|
|
|
|
|
// should be able to make in the gateway, not one that waits on an APK reaching every set.
|
|
|
|
|
const (
|
|
|
|
|
iconPackMaterial = "material"
|
|
|
|
|
iconPackLucide = "lucide"
|
|
|
|
|
iconPackFontAwesome = "fontawesome"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// defaultIconPackID is the marks the app shipped with, so nothing changes appearance on the
|
|
|
|
|
// day this lands.
|
|
|
|
|
const defaultIconPackID = iconPackMaterial
|
|
|
|
|
|
|
|
|
|
// iconPackOptions is the vocabulary of the `iconSet` preference, and the only place a pack
|
|
|
|
|
// is declared. A pack this list does not name is one no viewer and no operator can select.
|
|
|
|
|
//
|
|
|
|
|
// The wording is about how the marks read from a sofa, because that is the whole of the
|
|
|
|
|
// choice: stroke sets are drawn for 16-24px on a monitor, and on a rail chip at three
|
|
|
|
|
// metres they go thin where a solid mark keeps its shape.
|
|
|
|
|
func iconPackOptions() []preferenceOption {
|
|
|
|
|
return []preferenceOption{
|
|
|
|
|
option(iconPackMaterial, "Material — Android's own marks"),
|
|
|
|
|
option(iconPackLucide, "Lucide — lighter, drawn as outlines"),
|
|
|
|
|
option(iconPackFontAwesome, "Font Awesome — solid, clearest at a distance"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 08:25:50 +12:00
|
|
|
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"`
|
2026-08-18 14:59:29 +12:00
|
|
|
// IconSet lets a theme bring its own marks, and like Decoration only a seasonal theme
|
|
|
|
|
// carries one. Empty means the viewer's own choice stands.
|
|
|
|
|
//
|
|
|
|
|
// The asymmetry with the palette is deliberate. A season *is* a look, so it may say
|
|
|
|
|
// what the marks are; a scheme somebody picked to live with all year must not silently
|
|
|
|
|
// take their marks away, because there would be no way to tell which of the two
|
|
|
|
|
// choices had done it.
|
|
|
|
|
IconSet string `json:"iconSet,omitempty"`
|
2026-08-09 08:25:50 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-08-18 14:59:29 +12:00
|
|
|
IconSet: iconPackFontAwesome,
|
2026-08-09 08:25:50 +12:00
|
|
|
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"`
|
2026-08-18 14:59:29 +12:00
|
|
|
// IconSet is the pack the television draws its marks from: "material", "lucide",
|
|
|
|
|
// "fontawesome". Resolved here rather than derived on the set from the theme id,
|
|
|
|
|
// exactly as Decoration is — a television holding a cached seasonal answer must stop
|
|
|
|
|
// using that season's marks when the switch is thrown, and it has no way to know that
|
|
|
|
|
// on its own.
|
|
|
|
|
IconSet string `json:"iconSet,omitempty"`
|
2026-08-09 08:25:50 +12:00
|
|
|
// 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,
|
2026-08-18 14:59:29 +12:00
|
|
|
chosenIconPack string,
|
2026-08-09 08:25:50 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 14:59:29 +12:00
|
|
|
// The viewer's marks, unless the season brought its own. Note that this is *not* gated
|
|
|
|
|
// on decorationsEnabled: that switch is about the cost of a continuous animation on a
|
|
|
|
|
// weak box, and a set of icons costs nothing to draw.
|
|
|
|
|
iconPack := chosenIconPack
|
|
|
|
|
if !knownIconPack(iconPack) {
|
|
|
|
|
iconPack = defaultIconPackID
|
|
|
|
|
}
|
|
|
|
|
if seasonal && applied.IconSet != "" {
|
|
|
|
|
iconPack = applied.IconSet
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 08:25:50 +12:00
|
|
|
resolved := resolvedTheme{
|
|
|
|
|
ID: applied.ID, Name: applied.Name, Palette: applied.Palette,
|
|
|
|
|
Seasonal: seasonal, Locked: seasonal, Chosen: pick.ID, Reason: reason,
|
2026-08-18 14:59:29 +12:00
|
|
|
Decoration: decoration, IconSet: iconPack,
|
2026-08-09 08:25:50 +12:00
|
|
|
}
|
|
|
|
|
resolved.Revision = themeRevision(resolved)
|
|
|
|
|
return resolved
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 14:59:29 +12:00
|
|
|
// knownIconPack keeps an unreadable or retired slug out of the answer. The television
|
|
|
|
|
// would fall back on its own — membyIconPackFor answers with Material for anything it does
|
|
|
|
|
// not know — but a gateway that echoed a pack nobody can draw would make every set in the
|
|
|
|
|
// house look broken in the same way while reporting that it had done what it was asked.
|
|
|
|
|
func knownIconPack(id string) bool {
|
|
|
|
|
switch id {
|
|
|
|
|
case iconPackMaterial, iconPackLucide, iconPackFontAwesome:
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 08:25:50 +12:00
|
|
|
// 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,
|
2026-08-18 14:59:29 +12:00
|
|
|
resolved.Decoration, resolved.IconSet,
|
2026-08-09 08:25:50 +12:00
|
|
|
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)
|
2026-08-18 14:59:29 +12:00
|
|
|
iconPack, _ := preferenceDefault("iconSet").(string)
|
2026-08-09 08:25:50 +12:00
|
|
|
allowed := []string(nil)
|
|
|
|
|
if s.store != nil && sess.EmbyUserID != "" {
|
|
|
|
|
if stored, err := s.store.UserPreferences(ctx, sess.EmbyUserID); err == nil {
|
2026-08-18 14:59:29 +12:00
|
|
|
decoded := decodePreferences(stored.Preferences)
|
|
|
|
|
if value, ok := decoded["themeId"].(string); ok {
|
2026-08-09 08:25:50 +12:00
|
|
|
chosen = value
|
|
|
|
|
}
|
2026-08-18 14:59:29 +12:00
|
|
|
if value, ok := decoded["iconSet"].(string); ok {
|
|
|
|
|
iconPack = value
|
|
|
|
|
}
|
2026-08-09 08:25:50 +12:00
|
|
|
} 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(
|
2026-08-18 14:59:29 +12:00
|
|
|
chosen, iconPack, allowed,
|
2026-08-09 08:25:50 +12:00
|
|
|
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, ",")
|
|
|
|
|
}
|