Release v0.2.34
This commit is contained in:
@@ -0,0 +1,577 @@
|
||||
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, ",")
|
||||
}
|
||||
Reference in New Issue
Block a user