0.2.76 - Icon Packs

This commit is contained in:
ponzischeme89
2026-08-18 14:59:29 +12:00
parent 36d171e51b
commit 8c847c59b8
70 changed files with 5267 additions and 673 deletions
+1
View File
@@ -55,6 +55,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("GET /admin/api/ingest", s.adminAuth(s.handleAdminIngest))
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
mux.Handle("GET /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings))
mux.Handle("POST /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings))
+58
View File
@@ -0,0 +1,58 @@
package api
import (
"net/http"
"github.com/ponzischeme89/memby/server/internal/store"
)
// What the *arr webhooks have been doing, for the Imports page.
//
// It exists because a webhook is the one part of this gateway that fails *silently*: a
// token typed wrongly into Sonarr, a URL the container cannot be reached on, or a
// notification never enabled all look exactly like a household in which nothing has been
// imported lately. Without this an operator's only recourse is reading container logs.
// ingestEventLimit is how much of the log the page carries. Enough to cover an evening's
// imports and a season pack, which is what somebody is looking at when they open it.
const ingestEventLimit = 50
type adminIngestResponse struct {
// Configured says whether each hook would answer at all. Both unset is the honest
// explanation of an empty table, and the page says so rather than leaving an operator
// to conclude the feature is broken.
SonarrConfigured bool `json:"sonarrConfigured"`
RadarrConfigured bool `json:"radarrConfigured"`
SettleSeconds int `json:"settleSeconds"`
SyncMinutes int `json:"syncMinutes"`
Counts store.IngestCounts `json:"counts"`
Recent []store.IngestJob `json:"recent"`
}
func (s *Server) handleAdminIngest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
response := adminIngestResponse{
SonarrConfigured: s.cfg.SonarrWebhookToken != "",
RadarrConfigured: s.cfg.RadarrWebhookToken != "",
SettleSeconds: int(s.ingestSettle().Seconds()),
SyncMinutes: int(s.LibrarySyncInterval().Minutes()),
Recent: []store.IngestJob{},
}
counts, err := s.store.IngestStateCounts(ctx)
if err != nil {
s.loggerFor(ctx).Error("ingest counts failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read the import queue")
return
}
response.Counts = counts
recent, err := s.store.RecentIngests(ctx, ingestEventLimit)
if err != nil {
s.loggerFor(ctx).Error("ingest history failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read the import queue")
return
}
response.Recent = recent
writeJSON(w, http.StatusOK, response)
}
+4
View File
@@ -13,6 +13,10 @@ import (
const (
alertKindSonarrAired = "sonarr-aired"
alertKindRadarrImport = "radarr-import"
// A new episode that has finished scanning in. Distinct from sonarr-aired, which is
// about an episode that has been broadcast and is *not* here yet — the two are opposite
// halves of the same wait and a viewer reads them differently.
alertKindSonarrImport = "sonarr-import"
alertKindLibrarySync = "library-updated"
alertKindServerDown = "server-unreachable"
alertKindServerUp = "server-restored"
+21 -5
View File
@@ -59,8 +59,11 @@ type Server struct {
credits *credits.Service
creditsLoad *credits.PlaybackLoad
syncer syncerHandle
log *slog.Logger
events *serverlogging.Buffer
// ingester records what Sonarr and Radarr say changed. Nil where no webhook token is
// configured, which is also what makes both hooks 404.
ingester ingesterHandle
log *slog.Logger
events *serverlogging.Buffer
// adminEvents is the administrative feed: the console's notification bell and every
// outgoing integration read from it. Distinct from `events` above, which is the
// structured log ring — a log line is what the gateway did, an admin event is
@@ -96,6 +99,9 @@ type Server struct {
// logged by name.
playbackTitles playbackTitles
// ingestRuns collapses a season pack's worth of finished scans into one banner.
ingestRuns ingestRuns
recommendationBuilds recommendationBuilds
maintenance maintenanceState
quietTime quietTimeState
@@ -130,6 +136,7 @@ type Deps struct {
Credits *credits.Service
CreditsLoad *credits.PlaybackLoad
Syncer syncerHandle
Ingester ingesterHandle
Log *slog.Logger
Events *serverlogging.Buffer
@@ -157,6 +164,7 @@ func New(cfg config.Config, deps Deps) *Server {
credits: deps.Credits,
creditsLoad: deps.CreditsLoad,
syncer: deps.Syncer,
ingester: deps.Ingester,
log: deps.Log,
events: deps.Events,
@@ -314,9 +322,17 @@ func (s *Server) Routes() http.Handler {
// status even while every normal /v1 operation is deliberately unavailable.
mux.Handle("GET /v1/status", s.authed(s.handleServiceStatus))
mux.Handle("/v1/", s.maintenanceGate(v1))
// Radarr pushes here when an import finishes. Outside the gate on purpose: an event
// arriving during maintenance would otherwise be lost rather than delayed.
mux.Handle("POST /hooks/radarr", s.quietTimeGate(http.HandlerFunc(s.handleRadarrWebhook)))
// Sonarr and Radarr push here when something lands, is upgraded, is renamed or is
// deleted. Outside the maintenance gate on purpose: an event arriving during
// maintenance would otherwise be lost rather than delayed.
//
// Outside the *quiet-time* gate too, which the Radarr hook was previously inside. That
// gate answers 503, and neither *arr re-delivers — so a quiet hour used to silently
// discard every import that happened during it. The durable queue is what makes the
// distinction possible: the hook records the news whatever the hour, and the worker is
// where quiet time is honoured.
mux.HandleFunc("POST /hooks/radarr", s.handleRadarrWebhook)
mux.HandleFunc("POST /hooks/sonarr", s.handleSonarrWebhook)
// State the canonical console URL explicitly. The console and its assets live below
// /admin/, while a bare /admin is routinely typed and some reverse proxies do not
// preserve ServeMux's implicit trailing-slash redirect for a mounted subtree.
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"context"
"crypto/subtle"
"encoding/json"
"net/http"
"github.com/ponzischeme89/memby/server/internal/library"
)
// The two things that push into the gateway.
//
// Radarr's hook was already here, announcing a film as news. Both hooks now also *record*
// what changed, which is the half that replaces asking Emby every hour whether anything
// had happened: Sonarr and Radarr are the things that put files on disk, so they are the
// things that know.
//
// Recording is all a hook does. The lookup, the import and the retry all belong to the
// worker in internal/library, which is what lets these answer in a millisecond and, more
// importantly, what lets them answer *at all* during quiet hours — see below.
// ingesterHandle is the slice of the ingest worker the API needs, so api does not depend
// on the concrete type for testing. Same arrangement as syncerHandle.
type ingesterHandle interface {
Enqueue(ctx context.Context, source string, requests []library.IngestRequest) (int, error)
}
// handleSonarrWebhook accepts Sonarr's import, upgrade, rename and delete notifications.
//
// Unconfigured means absent — the stance /admin and the Radarr hook already take: a
// deployment that never set a token must not expose an endpoint anything can post to.
func (s *Server) handleSonarrWebhook(w http.ResponseWriter, r *http.Request) {
if s.cfg.SonarrWebhookToken == "" {
http.NotFound(w, r)
return
}
if subtle.ConstantTimeCompare(
[]byte(webhookToken(r)), []byte(s.cfg.SonarrWebhookToken),
) != 1 {
writeError(w, http.StatusUnauthorized, "invalid webhook token")
return
}
var payload library.SonarrWebhook
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid webhook payload")
return
}
// Sonarr's Test button posts a stub. Answering 200 without recording work about a
// series that does not exist is what makes that button mean "reachable".
if library.IsTestEvent(payload.EventType) {
s.loggerFor(r.Context()).Info("sonarr webhook test received")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "test": true})
return
}
queued := s.queueIngest(r, "sonarr", payload.EventType, library.SonarrRequests(payload))
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": queued})
}
// queueIngest records the work a notification implies and reports how much of it was news.
//
// The context is deliberately detached from the request. A webhook is answered in a
// millisecond and Sonarr closes the connection; hanging the insert off the request would
// abandon exactly the deliveries that arrive in bursts, which is what a season pack is.
func (s *Server) queueIngest(
r *http.Request, source, eventType string, requests []library.IngestRequest,
) int {
log := s.loggerFor(r.Context())
if s.ingester == nil {
return 0
}
if len(requests) == 0 {
// A grab, a health check, an unfollowed series whose files stayed on disk: all
// real events, none of them a reason to re-read anything.
log.Debug("webhook implies no catalogue work", "source", source, "event", eventType)
return 0
}
ctx := context.WithoutCancel(r.Context())
queued, err := s.ingester.Enqueue(ctx, source, requests)
if err != nil {
// The event is lost, which is the one failure worth an error line here: the *arrs
// do not re-deliver, so nothing will bring this news again. The reconciliation
// sweep is what eventually covers it.
log.Error("could not record webhook work",
"source", source, "event", eventType, "error", err)
return queued
}
if queued > 0 {
log.Info("arr ingest queued",
"event", "arr_ingest", "source", source, "webhook_event", eventType,
"outcome", "queued", "items", queued, "reason", requests[0].Reason)
}
return queued
}
+168
View File
@@ -0,0 +1,168 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/library"
)
// recordingIngester stands in for the worker. The hook's whole job is to record, so what
// it recorded is the only thing worth asserting on here.
type recordingIngester struct {
sources []string
requests []library.IngestRequest
}
func (r *recordingIngester) Enqueue(
_ context.Context, source string, requests []library.IngestRequest,
) (int, error) {
r.sources = append(r.sources, source)
r.requests = append(r.requests, requests...)
return len(requests), nil
}
func sonarrHookRequest(body string) *http.Request {
return httptest.NewRequest(
http.MethodPost, "/hooks/sonarr?token=hook-secret", strings.NewReader(body))
}
func TestSonarrWebhookIsHiddenUntilATokenIsConfigured(t *testing.T) {
s := &Server{cfg: config.Config{}, log: discardLogger()}
rec := httptest.NewRecorder()
s.handleSonarrWebhook(rec, sonarrHookRequest("{}"))
if rec.Code != http.StatusNotFound {
t.Fatalf("got %d, want 404 for an unconfigured hook", rec.Code)
}
}
func TestSonarrWebhookRejectsAWrongToken(t *testing.T) {
ingester := &recordingIngester{}
s := &Server{
cfg: config.Config{SonarrWebhookToken: "hook-secret"},
log: discardLogger(),
ingester: ingester,
}
rec := httptest.NewRecorder()
s.handleSonarrWebhook(rec, httptest.NewRequest(
http.MethodPost, "/hooks/sonarr?token=guess", strings.NewReader("{}")))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("got %d, want 401", rec.Code)
}
if len(ingester.requests) != 0 {
t.Fatal("an unauthorised delivery recorded work")
}
}
func TestSonarrWebhookRecordsAnImport(t *testing.T) {
ingester := &recordingIngester{}
s := &Server{
cfg: config.Config{SonarrWebhookToken: "hook-secret"},
log: discardLogger(),
ingester: ingester,
}
rec := httptest.NewRecorder()
s.handleSonarrWebhook(rec, sonarrHookRequest(`{
"eventType":"Download",
"series":{"id":12,"title":"Blue Bloods","year":2010},
"episodes":[{"id":551,"seasonNumber":6,"episodeNumber":7}],
"episodeFile":{"id":8123}
}`))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if len(ingester.requests) != 1 {
t.Fatalf("expected one recorded request, got %d", len(ingester.requests))
}
request := ingester.requests[0]
if request.Kind != library.KindEpisode || request.Episode != 7 || request.Season != 6 {
t.Fatalf("unexpected request: %+v", request)
}
if ingester.sources[0] != "sonarr" {
t.Fatalf("unexpected source: %q", ingester.sources[0])
}
}
// The Test button must answer without recording work about a series that does not exist.
func TestSonarrWebhookTestEventRecordsNothing(t *testing.T) {
ingester := &recordingIngester{}
s := &Server{
cfg: config.Config{SonarrWebhookToken: "hook-secret"},
log: discardLogger(),
ingester: ingester,
}
rec := httptest.NewRecorder()
s.handleSonarrWebhook(rec, sonarrHookRequest(
`{"eventType":"Test","series":{"id":1,"title":"Test Title"}}`))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if len(ingester.requests) != 0 {
t.Fatalf("the test event recorded work: %+v", ingester.requests)
}
}
// A Radarr upgrade is silent as news and still a reason to re-read the row. The two
// judgements are made in different places and this is what pins them apart.
func TestRadarrUpgradeIsRecordedThoughItIsNotAnnounced(t *testing.T) {
ingester := &recordingIngester{}
s := &Server{
cfg: config.Config{
RadarrWebhookToken: "hook-secret",
RadarrAlertWindow: 0, // no announcement is possible
},
log: discardLogger(),
ingester: ingester,
}
rec := httptest.NewRecorder()
s.handleRadarrWebhook(rec, httptest.NewRequest(
http.MethodPost, "/hooks/radarr?token=hook-secret", strings.NewReader(`{
"eventType":"Download","isUpgrade":true,
"movie":{"id":44,"title":"Arrival","year":2016},
"movieFile":{"id":441,"quality":"Bluray-1080p"}
}`)))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if len(ingester.requests) != 1 {
t.Fatalf("the upgrade was not recorded: %+v", ingester.requests)
}
if ingester.requests[0].Reason != library.ReasonUpgrade {
t.Fatalf("unexpected reason: %q", ingester.requests[0].Reason)
}
if ingester.requests[0].Action != library.ActionRefresh {
t.Fatalf("an upgrade must refresh, got %q", ingester.requests[0].Action)
}
}
// A grab is a real event and not a reason to re-read anything.
func TestSonarrGrabRecordsNothing(t *testing.T) {
ingester := &recordingIngester{}
s := &Server{
cfg: config.Config{SonarrWebhookToken: "hook-secret"},
log: discardLogger(),
ingester: ingester,
}
rec := httptest.NewRecorder()
s.handleSonarrWebhook(rec, sonarrHookRequest(
`{"eventType":"Grab","series":{"id":12,"title":"Blue Bloods"}}`))
if rec.Code != http.StatusOK || len(ingester.requests) != 0 {
t.Fatalf("got %d with %d requests", rec.Code, len(ingester.requests))
}
}
+21
View File
@@ -129,6 +129,15 @@ func (s *Server) embyHealthInterval() time.Duration {
s.cfg.EmbyHealthInterval)
}
// LibrarySyncInterval is how often the catalogue sweep runs. Exported because the syncer's
// schedule reads it every tick rather than closing over it at start-up — a setting read
// once at start-up is not a setting, and an operator lengthening the sweep after wiring up
// the webhooks must not have to restart the container to see it take effect.
func (s *Server) LibrarySyncInterval() time.Duration {
return overrideWindow(s.gatewaySettings.get().LibrarySyncMinutes, time.Minute,
s.cfg.SyncInterval)
}
// overrideWindow reads one of the three settings that can be switched off: a negative
// value is off, zero is "whatever was deployed", anything else is the override in the
// given unit.
@@ -153,6 +162,7 @@ type deployedGatewaySettings struct {
SonarrAlertMinutes int `json:"sonarrAlertMinutes"`
RadarrAlertMinutes int `json:"radarrAlertMinutes"`
EmbyHealthSeconds int `json:"embyHealthSeconds"`
LibrarySyncMinutes int `json:"librarySyncMinutes"`
}
func (s *Server) deployedSettings() deployedGatewaySettings {
@@ -167,6 +177,7 @@ func (s *Server) deployedSettings() deployedGatewaySettings {
SonarrAlertMinutes: int(s.cfg.SonarrAlertWindow / time.Minute),
RadarrAlertMinutes: int(s.cfg.RadarrAlertWindow / time.Minute),
EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second),
LibrarySyncMinutes: int(s.cfg.SyncInterval / time.Minute),
}
}
@@ -184,3 +195,13 @@ func levelName(level slog.Level) string {
return "error"
}
}
// ingestSettle is what the console prints beside the webhook activity, and it is a helper
// for the same reason the others here are: the delay is configuration, and the page must
// report the value actually in force rather than the constant it defaults to.
func (s *Server) ingestSettle() time.Duration {
if s.cfg.IngestSettleDelay > 0 {
return s.cfg.IngestSettleDelay
}
return time.Minute
}
+14
View File
@@ -77,6 +77,20 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) {
},
})
sched.Register(scheduler.Task{
ID: "ingest-cleanup",
Name: "Import queue cleanup",
Group: "Housekeeping",
Description: fmt.Sprintf(
"Removes settled Sonarr and Radarr import records older than %d days. Work still waiting is never removed.",
int(store.IngestRetention/(24*time.Hour))),
Interval: 24 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneIngests(ctx, store.IngestRetention)
return countDetail(removed, "import record"), err
},
})
sched.Register(scheduler.Task{
ID: "task-history-cleanup",
Name: "Task history cleanup",
+210
View File
@@ -0,0 +1,210 @@
package api
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/library"
)
// News about a finished Sonarr or Radarr scan.
//
// The webhook is not the news. Both *arrs fire the moment they have moved a file, and Emby
// has not scanned it in yet — which is why the banner this replaces had to say a film would
// be available "shortly", and why an episode could not be announced at all: there was
// nothing truthful to say about one until it was actually there. The gateway now knows when
// that moment arrives, because the ingest worker is what makes it arrive, so the
// announcement is made from the far end of the scan and says the title is ready.
//
// The cost is that the news is a minute or two later than the webhook, and that a title Emby
// never manages to scan is never announced. Both are the right way round: a notice about
// something a viewer can press Play on is worth more than an earlier one about something
// they cannot.
const (
// ingestRunWindow is how long two imports count as one piece of news. A season pack
// arrives as a dozen webhooks over a couple of minutes, and a household does not want a
// dozen banners about it — it wants to be told the show has new episodes.
ingestRunWindow = 15 * time.Minute
// trackedIngestRuns bounds the tally. A household imports a handful of things at once;
// this is generous enough that a season pack always collapses and small enough that it
// can never grow into a leak.
trackedIngestRuns = 64
)
// AnnounceLibraryIngest turns a completed scan into the banner every open television shows.
//
// Only a genuine import is announced. An upgrade is deliberately silent — the title was
// already there, and "new episode" would be a lie about a file that was replaced with a
// better copy — and so are a rename and a delete, which are housekeeping rather than news.
// That judgement lives here rather than in the worker: the worker's business is that the
// row changed, this is the separate question of whether anybody should be told.
func (s *Server) AnnounceLibraryIngest(ctx context.Context, result library.IngestResult) {
if result.Reason != library.ReasonImport {
return
}
switch result.Kind {
case library.KindMovie:
s.announceImportedMovie(ctx, result)
case library.KindEpisode:
s.announceImportedEpisode(ctx, result)
}
// A series-level result is a rename settling or a show written ahead of its first
// episode. Neither is a title somebody can watch, and the episode that follows is.
}
func (s *Server) announceImportedMovie(ctx context.Context, result library.IngestResult) {
window := s.radarrAlertWindow()
title := strings.TrimSpace(result.Name)
if window <= 0 || title == "" || result.ItemID == "" {
return
}
now := time.Now().UTC()
name := title
if result.Year > 0 {
name = fmt.Sprintf("%s (%d)", title, result.Year)
}
s.publishAlert(ctx, clientAlert{
// Keyed on the Emby item, so a repeated delivery of one import is one banner while
// a film deleted and re-imported is news again. Clients dedupe on this id forever.
ID: "ingest:movie:" + result.ItemID,
Kind: alertKindRadarrImport,
Label: "NEW MOVIE ADDED",
Title: name,
Message: fmt.Sprintf("%s is ready to watch.", title),
ItemID: result.ItemID,
ImageTag: result.ImageTag,
AiredAt: now.Format(time.RFC3339),
}, window)
s.loggerFor(ctx).Info("library ingest announced",
"event", "arr_ingest_alert", "source", result.Source, "kind", result.Kind,
"title", name, "item", result.ItemID)
}
func (s *Server) announceImportedEpisode(ctx context.Context, result library.IngestResult) {
// The Sonarr window, so MEMBY_SONARR_ALERT_WINDOW=0 switches episode news off exactly
// as it does the "aired, coming soon" one, without touching films.
window := s.sonarrAlertWindow()
series := strings.TrimSpace(result.SeriesName)
if window <= 0 || series == "" || result.ItemID == "" {
return
}
now := time.Now().UTC()
run := s.ingestRuns.record(
seasonRunKey(series, result.Season), result.ItemID,
episodeSummary(result), now, ingestRunWindow,
)
s.publishAlert(ctx, clientAlert{
// The run's *first* episode anchors the id, so every later arrival in the same
// season pack replaces one banner rather than stacking another — and next week's
// episode, arriving after the window has closed, starts a run of its own and is
// therefore its own news rather than one the fleet has already dismissed as seen.
ID: "ingest:episode:" + run.Anchor,
Kind: alertKindSonarrImport,
Label: "NEW EPISODE ADDED",
Title: series,
Message: episodeRunMessage(run),
ItemID: result.ItemID,
ImageTag: result.ImageTag,
AiredAt: now.Format(time.RFC3339),
}, window)
s.loggerFor(ctx).Info("library ingest announced",
"event", "arr_ingest_alert", "source", result.Source, "kind", result.Kind,
"series", series, "episodes", run.Count, "item", result.ItemID)
}
// episodeSummary is how one episode is named in a banner: "S03E05 — The Bear". The code
// alone is what a viewer scanning a shelf recognises, and the title is what tells them it
// is the one they were waiting for, so both are kept where both exist.
func episodeSummary(result library.IngestResult) string {
code := ""
if result.Season > 0 || result.Episode > 0 {
code = fmt.Sprintf("S%02dE%02d", result.Season, result.Episode)
}
title := strings.TrimSpace(result.Name)
switch {
case code == "":
return title
case title == "" || strings.EqualFold(title, result.SeriesName):
return code
default:
return fmt.Sprintf("%s — %s", code, title)
}
}
// episodeRunMessage words one arrival by name and several by count. Naming the last of six
// would be arbitrary — nothing makes it the one worth mentioning — where the count is the
// thing the viewer actually wants to know.
func episodeRunMessage(run ingestRun) string {
if run.Count > 1 {
return fmt.Sprintf("%d new episodes are ready to watch.", run.Count)
}
if run.Latest == "" {
return "A new episode is ready to watch."
}
return fmt.Sprintf("%s is ready to watch.", run.Latest)
}
func seasonRunKey(series string, season int) string {
return fmt.Sprintf("%s|%d", library.NormalizedTitle(series), season)
}
// ingestRun is what a season's imports have amounted to so far.
type ingestRun struct {
// Anchor is the first item id seen in this run, and is what keeps a burst of banners
// collapsed onto one.
Anchor string
Count int
Latest string
}
// ingestRuns collapses a burst of imports of one season into a single piece of news.
//
// Deliberately in memory and deliberately lossy, the playbackTitles arrangement: a gateway
// restarted half way through a season pack announces the rest as a second run, which is a
// far better trade than a table recording what a banner said.
type ingestRuns struct {
mu sync.Mutex
runs map[string]*runState
order []string
}
type runState struct {
anchor string
count int
latest string
until time.Time
}
// record folds one import into its season's run and reports where that run now stands. A
// run whose window has closed is replaced rather than extended, so a show importing an
// episode a week is a separate notice every week.
func (r *ingestRuns) record(
key, itemID, summary string, now time.Time, window time.Duration,
) ingestRun {
r.mu.Lock()
defer r.mu.Unlock()
if r.runs == nil {
r.runs = make(map[string]*runState, trackedIngestRuns)
}
state, live := r.runs[key]
if !live || !state.until.After(now) {
if !live {
r.order = append(r.order, key)
if len(r.order) > trackedIngestRuns {
delete(r.runs, r.order[0])
r.order = r.order[1:]
}
}
state = &runState{anchor: itemID}
r.runs[key] = state
}
state.count++
state.latest = summary
state.until = now.Add(window)
return ingestRun{Anchor: state.anchor, Count: state.count, Latest: state.latest}
}
+155
View File
@@ -0,0 +1,155 @@
package api
import (
"strings"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/library"
)
func episodeResult(season, episode int, title string) library.IngestResult {
return library.IngestResult{
Source: "sonarr",
Kind: library.KindEpisode,
Reason: library.ReasonImport,
ItemID: "emby-" + title,
Name: title,
SeriesName: "The Bear",
Season: season,
Episode: episode,
}
}
func TestEpisodeSummaryNamesTheEpisodeBothWays(t *testing.T) {
if got := episodeSummary(episodeResult(3, 5, "Children")); got != "S03E05 — Children" {
t.Errorf("summary = %q, want the code and the title", got)
}
// Emby records plenty of episodes under the show's own name, and "S03E05 — The Bear"
// reads as a mistake where the code alone reads as an episode.
same := episodeResult(3, 5, "The Bear")
if got := episodeSummary(same); got != "S03E05" {
t.Errorf("summary = %q, want the code alone when the title repeats the series", got)
}
untitled := episodeResult(3, 5, "")
if got := episodeSummary(untitled); got != "S03E05" {
t.Errorf("summary = %q, want the code alone", got)
}
}
// A season pack is one piece of news. Every arrival replaces the same banner, which is
// what the shared anchor is for, and the wording moves from the episode to the count.
func TestIngestRunsCollapseASeasonPack(t *testing.T) {
var runs ingestRuns
now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC)
first := runs.record("bear|3", "emby-1", "S03E01", now, ingestRunWindow)
if first.Count != 1 || first.Anchor != "emby-1" {
t.Fatalf("first = %+v, want a run of one anchored on it", first)
}
if got := episodeRunMessage(first); got != "S03E01 is ready to watch." {
t.Errorf("message = %q, want the episode named", got)
}
second := runs.record("bear|3", "emby-2", "S03E02", now.Add(30*time.Second), ingestRunWindow)
if second.Anchor != "emby-1" {
t.Errorf("anchor = %q, want the run's first episode so the banner is replaced", second.Anchor)
}
if second.Count != 2 {
t.Errorf("count = %d, want 2", second.Count)
}
if got := episodeRunMessage(second); got != "2 new episodes are ready to watch." {
t.Errorf("message = %q, want the count once there is more than one", got)
}
}
// Next week's episode is its own news. Televisions dedupe on the alert id forever, so a
// run that reused last week's anchor would be silently swallowed on every set in the house.
func TestIngestRunsStartAfreshOnceTheWindowHasClosed(t *testing.T) {
var runs ingestRuns
now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC)
runs.record("bear|3", "emby-1", "S03E01", now, ingestRunWindow)
later := runs.record("bear|3", "emby-2", "S03E02", now.Add(ingestRunWindow+time.Minute), ingestRunWindow)
if later.Anchor != "emby-2" {
t.Errorf("anchor = %q, want a new run", later.Anchor)
}
if later.Count != 1 {
t.Errorf("count = %d, want a run of one", later.Count)
}
}
// Two shows importing at once are two pieces of news, not one run of four episodes.
func TestIngestRunsAreKeptPerSeason(t *testing.T) {
var runs ingestRuns
now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC)
runs.record(seasonRunKey("The Bear", 3), "bear-1", "S03E01", now, ingestRunWindow)
other := runs.record(seasonRunKey("Slow Horses", 4), "horses-1", "S04E01", now, ingestRunWindow)
if other.Count != 1 || other.Anchor != "horses-1" {
t.Fatalf("other show = %+v, want a run of its own", other)
}
// And the same show under a different spelling is still the same show, the rule the
// schedule row and the ingest worker already match titles by.
same := runs.record(seasonRunKey("the bear!", 3), "bear-2", "S03E02", now, ingestRunWindow)
if same.Anchor != "bear-1" || same.Count != 2 {
t.Fatalf("same season = %+v, want it folded into the first run", same)
}
}
// The tally is memory the gateway can afford to lose, so it must also be memory it cannot
// grow without bound.
func TestIngestRunsAreBounded(t *testing.T) {
var runs ingestRuns
now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC)
for i := 0; i < trackedIngestRuns*2; i++ {
runs.record(time.Duration(i).String(), "item", "S01E01", now, ingestRunWindow)
}
if len(runs.runs) > trackedIngestRuns {
t.Fatalf("tracking %d runs, want a cap of %d", len(runs.runs), trackedIngestRuns)
}
}
// Only an import is news. An upgrade replaced a file that was already watchable, and a
// rename or a delete is housekeeping — announcing any of them trains viewers to look away.
func TestOnlyAnImportIsAnnounced(t *testing.T) {
s := &Server{log: discardLogger()}
for _, reason := range []string{
library.ReasonUpgrade, library.ReasonRename, library.ReasonDelete,
} {
result := episodeResult(3, 5, "Children")
result.Reason = reason
// A nil cache would be reached by publishAlert if this announced anything; it
// returns early on one, so the assertion is that nothing is recorded either.
s.AnnounceLibraryIngest(t.Context(), result)
if len(s.ingestRuns.runs) != 0 {
t.Fatalf("%s was treated as news", reason)
}
}
}
// A series-level result is a rename settling or a show written ahead of its first episode.
// Neither is something anybody can press Play on.
func TestASeriesRefreshIsNotAnnounced(t *testing.T) {
s := &Server{log: discardLogger()}
s.AnnounceLibraryIngest(t.Context(), library.IngestResult{
Source: "sonarr", Kind: library.KindSeries, Reason: library.ReasonImport,
ItemID: "series-1", Name: "The Bear", SeriesName: "The Bear",
})
if len(s.ingestRuns.runs) != 0 {
t.Fatal("a series refresh was announced")
}
}
func TestMovieRunMessageSaysTheFilmIsThere(t *testing.T) {
// The wording is the whole point of moving the announcement behind the scan: the
// banner published from the webhook could only ever promise the film was coming.
run := ingestRun{Anchor: "a", Count: 1, Latest: "S01E01"}
if strings.Contains(episodeRunMessage(run), "shortly") {
t.Error("the message still promises rather than states")
}
}
+1
View File
@@ -61,6 +61,7 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
"/v1/my-shows": "my-shows",
"/admin/api/status": "admin",
"/hooks/radarr": "webhooks",
"/hooks/sonarr": "webhooks",
"/install": "installer",
"/updates/latest.apk": "updates",
"/something-nobody-has-written": "api",
+13
View File
@@ -136,6 +136,19 @@ var preferenceCatalogue = []preferenceDefinition{
Kind: preferenceChoice, Default: defaultThemeID,
Options: themeOptions(),
},
{
// The marks, kept apart from the palette because they are a different decision
// about legibility rather than about taste — a household watching from a sofa may
// well want the solid pack on every scheme they own.
//
// The options come from iconPackOptions() in themes.go for the reason themeId's
// come from the catalogue: a pack added there cannot become a value this rejects,
// and a pack removed cannot stay selectable here.
Key: "iconSet", Name: "Icon set", Area: "Presentation",
Description: "Which set of marks this viewer's televisions draw.",
Kind: preferenceChoice, Default: defaultIconPackID,
Options: iconPackOptions(),
},
{
Key: "welcomeQuoteStyle", Name: "Welcome tone", Area: "Presentation",
Description: "Tone of the short line shown after signing in.",
+39 -68
View File
@@ -3,10 +3,10 @@ package api
import (
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/library"
)
// Radarr's import notification arrives as a webhook, which is why this is the one part
@@ -31,6 +31,30 @@ type radarrWebhookPayload struct {
ID int `json:"id"`
Quality string `json:"quality"`
} `json:"movieFile"`
// The delete events carry the file id at the top level rather than under movieFile,
// and say whether the media went with the entry. Both are read by the catalogue half
// only; the banner has nothing to say about a deletion.
MovieFileID int `json:"movieFileId"`
DeletedFiles bool `json:"deletedFiles"`
}
// ingestPayload hands the same notification to the catalogue rules.
//
// Written out rather than shared as one struct because the two halves genuinely read
// different fields for different reasons — the banner wants the quality string, the
// catalogue wants the deletion flags — and a single type would grow whichever field
// either of them needed next.
func ingestPayload(payload radarrWebhookPayload) library.RadarrWebhook {
var out library.RadarrWebhook
out.EventType = payload.EventType
out.IsUpgrade = payload.IsUpgrade
out.Movie.ID = payload.Movie.ID
out.Movie.Title = payload.Movie.Title
out.Movie.Year = payload.Movie.Year
out.MovieFile.ID = payload.MovieFile.ID
out.MovieFileID = payload.MovieFileID
out.DeletedFiles = payload.DeletedFiles
return out
}
// handleRadarrWebhook accepts Radarr's "On Import" notification.
@@ -66,23 +90,20 @@ func (s *Server) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) {
return
}
alert, ok := radarrImportAlert(payload, time.Now().UTC())
if !ok {
// A grab, a rename, a health check or an upgrade of something already in the
// library: all real events, none of them "a new film is here".
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
return
// Recording is now the whole of what this hook does. The banner used to be published
// from right here, which meant it was published before Emby had scanned the film in —
// hence its wording, that the film would be available "shortly". It is announced from
// the far end of the scan instead (AnnounceLibraryIngest), where it can say the film is
// actually there and where an episode can be announced on the same terms.
//
// An upgrade is still recorded and still silent as news: the file genuinely changed, so
// the row must be re-read, but the film was already there.
queued := s.queueIngest(r, "radarr", payload.EventType, library.RadarrRequests(ingestPayload(payload)))
if queued > 0 {
s.loggerFor(r.Context()).Debug("radarr import recorded",
"movie", payload.Movie.Title, "quality", payload.MovieFile.Quality)
}
window := s.radarrAlertWindow()
if window <= 0 {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
return
}
s.publishAlert(r.Context(), alert, window)
s.loggerFor(r.Context()).Info("radarr import announced",
"movie", alert.Title, "alert_id", alert.ID, "quality", payload.MovieFile.Quality)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": true})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": queued})
}
// webhookToken accepts the shared secret three ways because Radarr's webhook settings
@@ -100,53 +121,3 @@ func webhookToken(r *http.Request) string {
}
return strings.TrimSpace(r.URL.Query().Get("token"))
}
// radarrImportAlert turns an import notification into the banner a TV shows, or reports
// that this event is not worth announcing.
//
// An upgrade is deliberately silent: the film was already there, and "new movie added"
// would be a lie about a file that was replaced with a better copy.
func radarrImportAlert(payload radarrWebhookPayload, now time.Time) (clientAlert, bool) {
if !isRadarrImportEvent(payload.EventType) || payload.IsUpgrade {
return clientAlert{}, false
}
title := strings.TrimSpace(payload.Movie.Title)
if title == "" || payload.Movie.ID <= 0 {
return clientAlert{}, false
}
// Keyed on the file, so a title deleted and re-imported is news again while a
// repeated delivery of the same import is not. Clients dedupe on this id forever.
id := fmt.Sprintf("radarr:%d:file:%d", payload.Movie.ID, payload.MovieFile.ID)
if payload.MovieFile.ID <= 0 {
id = fmt.Sprintf("radarr:%d:imported:%d", payload.Movie.ID, now.Unix())
}
name := title
if payload.Movie.Year > 0 {
name = fmt.Sprintf("%s (%d)", title, payload.Movie.Year)
}
return clientAlert{
ID: id,
Kind: alertKindRadarrImport,
Label: "NEW MOVIE ADDED",
Title: name,
Message: fmt.Sprintf("%s will be available in Emby shortly.", title),
// The image proxy already serves Radarr covers under this id and tag, so the
// banner shows the poster before Emby has finished scanning the film in.
ItemID: fmt.Sprintf("radarr:%d", payload.Movie.ID),
ImageTag: "radarr",
AiredAt: now.UTC().Format(time.RFC3339),
}, true
}
// isRadarrImportEvent matches the event Radarr fires once a downloaded file has been
// imported into the library. The name has moved between versions, so both are accepted.
func isRadarrImportEvent(eventType string) bool {
switch strings.ToLower(strings.TrimSpace(eventType)) {
case "download", "moviefileimported":
return true
default:
return false
}
}
-90
View File
@@ -12,96 +12,6 @@ import (
"github.com/ponzischeme89/memby/server/internal/config"
)
func importPayload(movieID, fileID int, title string, year int) radarrWebhookPayload {
var payload radarrWebhookPayload
payload.EventType = "Download"
payload.Movie.ID = movieID
payload.Movie.Title = title
payload.Movie.Year = year
payload.MovieFile.ID = fileID
return payload
}
func TestRadarrImportAlertAnnouncesANewFilm(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
alert, ok := radarrImportAlert(importPayload(412, 9001, "Mr. Smith Goes to Washington", 1939), now)
if !ok {
t.Fatal("expected an import to be announced")
}
if alert.ID != "radarr:412:file:9001" {
t.Errorf("alert id = %q, want it keyed on the imported file", alert.ID)
}
if alert.Kind != alertKindRadarrImport {
t.Errorf("kind = %q, want %q", alert.Kind, alertKindRadarrImport)
}
if alert.Label == "" {
t.Error("want a label: the app cannot know the wording for a kind it predates")
}
if alert.Title != "Mr. Smith Goes to Washington (1939)" {
t.Errorf("title = %q, want the year alongside it", alert.Title)
}
if !strings.Contains(alert.Message, "available in Emby shortly") {
t.Errorf("message = %q, want it to say the film is on its way", alert.Message)
}
// The image proxy serves Radarr covers under this pair, so the banner has a poster
// before Emby has scanned the film in.
if alert.ItemID != "radarr:412" || alert.ImageTag != "radarr" {
t.Errorf("artwork = %q/%q, want the radarr media cover", alert.ItemID, alert.ImageTag)
}
if alert.AiredAt != now.Format(time.RFC3339) {
t.Errorf("airedAt = %q, want the import time so it sorts with the rest", alert.AiredAt)
}
}
func TestRadarrImportAlertIgnoresEventsThatAreNotANewFilm(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
upgrade := importPayload(412, 9002, "Mr. Smith Goes to Washington", 1939)
upgrade.IsUpgrade = true
grab := importPayload(413, 0, "Some Film", 2024)
grab.EventType = "Grab"
untitled := importPayload(414, 9003, " ", 2024)
unknownMovie := importPayload(0, 9004, "No Id", 2024)
for name, payload := range map[string]radarrWebhookPayload{
"quality upgrade of a film already there": upgrade,
"grabbed but not imported": grab,
"no title": untitled,
"no movie id": unknownMovie,
} {
if _, ok := radarrImportAlert(payload, now); ok {
t.Errorf("%s: expected no alert", name)
}
}
}
func TestRadarrImportAlertAcceptsTheNewerEventName(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
payload := importPayload(415, 9005, "Rear Window", 1954)
payload.EventType = "MovieFileImported"
if _, ok := radarrImportAlert(payload, now); !ok {
t.Error("expected the alternate import event name to be announced")
}
}
// A file id is what makes a repeated notification the same news; without one the id
// falls back to the clock so a re-import is not silently swallowed.
func TestRadarrImportAlertWithoutAFileIDIsStillAnnounced(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
alert, ok := radarrImportAlert(importPayload(416, 0, "Sabotage", 1936), now)
if !ok {
t.Fatal("expected an alert")
}
if !strings.HasPrefix(alert.ID, "radarr:416:imported:") {
t.Errorf("alert id = %q, want a time-keyed fallback", alert.ID)
}
}
func TestAppendAlertPrunesExpiredAndDeduplicates(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
existing := []storedAlert{
+81 -4
View File
@@ -68,6 +68,39 @@ const (
decorationBlossom = "blossom"
)
// 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"),
}
}
type themeDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -83,6 +116,14 @@ type themeDefinition struct {
// 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"`
// 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"`
}
const (
@@ -164,6 +205,7 @@ var themeCatalogue = []themeDefinition{
ID: themeHalloween, Name: "Halloween", Seasonal: true,
Description: "Pumpkin orange on black, for the last week of October.",
Decoration: decorationBats,
IconSet: iconPackFontAwesome,
Palette: themePalette{
Surface: "#FF0A0704", SurfaceRaised: "#FF17100A", Accent: "#FFFF8A1F",
OnSurface: "#FFF2E7DA", MutedText: "#FFE2D2BE", QuietText: "#FFBBA48C",
@@ -341,6 +383,12 @@ type resolvedTheme struct {
// 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"`
// 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"`
// 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.
@@ -364,6 +412,7 @@ type resolvedTheme struct {
// a season. The only switch is seasonalEnabled, and that is the operator's feature flag.
func resolveTheme(
chosen string,
chosenIconPack string,
allowed []string,
seasonalEnabled bool,
decorationsEnabled bool,
@@ -394,15 +443,38 @@ func resolveTheme(
decoration = applied.Decoration
}
// 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
}
resolved := resolvedTheme{
ID: applied.ID, Name: applied.Name, Palette: applied.Palette,
Seasonal: seasonal, Locked: seasonal, Chosen: pick.ID, Reason: reason,
Decoration: decoration,
Decoration: decoration, IconSet: iconPack,
}
resolved.Revision = themeRevision(resolved)
return resolved
}
// 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
}
// 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.
@@ -422,7 +494,7 @@ func themeRevision(resolved resolvedTheme) string {
for _, part := range []string{
strconv.Itoa(themeSchemaVersion), resolved.ID, resolved.Chosen,
strconv.FormatBool(resolved.Seasonal), strconv.FormatBool(resolved.Locked), resolved.Reason,
resolved.Decoration,
resolved.Decoration, resolved.IconSet,
palette.Surface, palette.SurfaceRaised, palette.Accent, palette.OnSurface,
palette.MutedText, palette.QuietText, palette.Hairline, palette.RatingsSurface,
} {
@@ -442,12 +514,17 @@ func themeRevision(resolved resolvedTheme) string {
// 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)
iconPack, _ := preferenceDefault("iconSet").(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 {
decoded := decodePreferences(stored.Preferences)
if value, ok := decoded["themeId"].(string); ok {
chosen = value
}
if value, ok := decoded["iconSet"].(string); ok {
iconPack = value
}
} else {
s.loggerFor(ctx).Warn("theme preference unavailable", "error", err)
}
@@ -458,7 +535,7 @@ func (s *Server) themeFor(ctx context.Context, sess store.Session) resolvedTheme
}
}
return resolveTheme(
chosen, allowed,
chosen, iconPack, allowed,
s.featureEnabled(ctx, featureSeasonalThemes),
s.featureEnabled(ctx, featureSeasonalDecorations),
s.now(),
+96 -11
View File
@@ -77,7 +77,7 @@ func TestEasterWindowIsTheLongWeekend(t *testing.T) {
// A season is the one thing on this feature nobody on a television can decline, so the
// tests that matter most are the ones asserting that no argument suppresses it.
func TestSeasonOutranksTheViewer(t *testing.T) {
resolved := resolveTheme(themePlum, nil, true, true, date(2026, time.December, 20))
resolved := resolveTheme(themePlum, "", nil, true, true, date(2026, time.December, 20))
if resolved.ID != themeChristmas {
t.Fatalf("applied theme = %q, want %q", resolved.ID, themeChristmas)
}
@@ -98,7 +98,7 @@ func TestSeasonOutranksTheViewer(t *testing.T) {
// grantable per person, and an operator restricting a viewer to one palette must not be a
// way of exempting them from Christmas.
func TestAllowlistDoesNotApplyToSeasons(t *testing.T) {
resolved := resolveTheme(themePlum, []string{themeEmber}, true, true, date(2026, time.October, 31))
resolved := resolveTheme(themePlum, "", []string{themeEmber}, true, true, date(2026, time.October, 31))
if resolved.ID != themeHalloween {
t.Fatalf("applied theme = %q, want %q", resolved.ID, themeHalloween)
}
@@ -110,7 +110,7 @@ func TestAllowlistDoesNotApplyToSeasons(t *testing.T) {
}
func TestSeasonsOffLeavesTheViewersChoice(t *testing.T) {
resolved := resolveTheme(themeEmber, nil, false, true, date(2026, time.December, 20))
resolved := resolveTheme(themeEmber, "", nil, false, true, date(2026, time.December, 20))
if resolved.ID != themeEmber {
t.Fatalf("applied theme = %q, want %q", resolved.ID, themeEmber)
}
@@ -138,7 +138,7 @@ func TestResolveThemeFallbacks(t *testing.T) {
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
got := resolveTheme(testCase.chosen, testCase.allowed, true, true, ordinary)
got := resolveTheme(testCase.chosen, "", testCase.allowed, true, true, ordinary)
if got.ID != testCase.want {
t.Fatalf("resolveTheme(%q, %v) = %q, want %q",
testCase.chosen, testCase.allowed, got.ID, testCase.want)
@@ -150,14 +150,14 @@ func TestResolveThemeFallbacks(t *testing.T) {
// The revision is the whole delivery mechanism: a television refetches the palette only when
// this moves. If it did not move when a season began, no set in the house would repaint.
func TestThemeRevisionTracksTheAnswer(t *testing.T) {
ordinary := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 14))
christmas := resolveTheme(themePlum, nil, true, true, date(2026, time.December, 20))
ordinary := resolveTheme(themePlum, "", nil, true, true, date(2026, time.June, 14))
christmas := resolveTheme(themePlum, "", nil, true, true, date(2026, time.December, 20))
if ordinary.Revision == christmas.Revision {
t.Fatal("the revision must change when the season does, or nothing refetches")
}
// And it must be stable, or every poll would look like a change and every set would
// fetch the palette six times a minute.
again := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 15))
again := resolveTheme(themePlum, "", nil, true, true, date(2026, time.June, 15))
if ordinary.Revision != again.Revision {
t.Fatalf("the revision moved on an ordinary day: %s then %s", ordinary.Revision, again.Revision)
}
@@ -168,10 +168,10 @@ func TestThemeRevisionTracksTheAnswer(t *testing.T) {
// every day of the year.
func TestDecorationsAreSeasonalAndSeparatelySwitchable(t *testing.T) {
christmas := date(2026, time.December, 20)
if got := resolveTheme(themePlum, nil, true, true, christmas); got.Decoration != decorationSnow {
if got := resolveTheme(themePlum, "", nil, true, true, christmas); got.Decoration != decorationSnow {
t.Fatalf("decoration = %q, want %q", got.Decoration, decorationSnow)
}
off := resolveTheme(themePlum, nil, true, false, christmas)
off := resolveTheme(themePlum, "", nil, true, false, christmas)
if off.Decoration != "" {
t.Fatalf("decorations off still returned %q", off.Decoration)
}
@@ -179,10 +179,10 @@ func TestDecorationsAreSeasonalAndSeparatelySwitchable(t *testing.T) {
t.Fatal("turning decorations off must keep the seasonal palette")
}
// The revision has to move, or a set already snowing is never told to stop.
if off.Revision == resolveTheme(themePlum, nil, true, true, christmas).Revision {
if off.Revision == resolveTheme(themePlum, "", nil, true, true, christmas).Revision {
t.Fatal("the revision must change when the decoration does")
}
ordinary := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 14))
ordinary := resolveTheme(themePlum, "", nil, true, true, date(2026, time.June, 14))
if ordinary.Decoration != "" {
t.Fatalf("a chosen theme carries a decoration: %q", ordinary.Decoration)
}
@@ -277,3 +277,88 @@ func TestNormalizeThemeAllowlist(t *testing.T) {
t.Fatalf("allowlist is not in catalogue order: %v", ordered)
}
}
// --- Icon packs ---------------------------------------------------------------------------
// The gateway's half of the wire contract with ui/theme/MembyIconPacks.kt. Its own
// MembyIconPackTest pins the same three slugs from the television's end; change one
// without the other and a household is sent marks nothing can draw.
func TestIconPackOptionsAreTheKnownPacks(t *testing.T) {
for _, option := range iconPackOptions() {
if !knownIconPack(option.Value) {
t.Fatalf("offered icon pack %q is not one the resolver will accept", option.Value)
}
}
if !knownIconPack(defaultIconPackID) {
t.Fatalf("the default icon pack %q is not selectable", defaultIconPackID)
}
}
// A slug the resolver does not recognise must never reach a television. The set would fall
// back on its own, but a gateway echoing a retired pack would make every screen in the
// house look wrong in the same way while reporting it had done what it was asked.
func TestUnknownIconPackFallsBackToTheShippedMarks(t *testing.T) {
ordinary := time.Date(2026, time.May, 3, 12, 0, 0, 0, time.UTC)
for _, chosen := range []string{"", "tabler", "material "} {
got := resolveTheme(themeMidnight, chosen, nil, true, true, ordinary)
if got.IconSet != defaultIconPackID {
t.Fatalf("resolveTheme(icon %q) = %q, want %q", chosen, got.IconSet, defaultIconPackID)
}
}
}
// The viewer's marks stand on every scheme they can choose, and only a season may replace
// them — the same asymmetry decorations have, and for the same reason: a season is a look,
// where a scheme somebody picked to live with all year must not silently take their marks
// away with no way to tell which choice did it.
func TestOnlyASeasonMayReplaceTheViewersMarks(t *testing.T) {
ordinary := time.Date(2026, time.May, 3, 12, 0, 0, 0, time.UTC)
if got := resolveTheme(themePlum, iconPackLucide, nil, true, true, ordinary); got.IconSet != iconPackLucide {
t.Fatalf("an ordinary day = %q, want the viewer's %q", got.IconSet, iconPackLucide)
}
halloween := time.Date(2026, time.October, 30, 20, 0, 0, 0, time.UTC)
got := resolveTheme(themePlum, iconPackLucide, nil, true, true, halloween)
if !got.Seasonal {
t.Fatalf("expected the Halloween window to be seasonal")
}
if got.IconSet != iconPackFontAwesome {
t.Fatalf("Halloween = %q, want its own %q", got.IconSet, iconPackFontAwesome)
}
if got.Chosen != themePlum {
t.Fatalf("the viewer's own choice should survive underneath a season, got %q", got.Chosen)
}
// Seasons off: the viewer keeps both halves.
if off := resolveTheme(themePlum, iconPackLucide, nil, false, true, halloween); off.IconSet != iconPackLucide {
t.Fatalf("with seasons off = %q, want the viewer's %q", off.IconSet, iconPackLucide)
}
}
// Only seasonal themes may declare marks of their own, the rule that holds the asymmetry
// above in place by construction rather than by a check at the point of use.
func TestOnlySeasonalThemesDeclareAnIconSet(t *testing.T) {
for _, theme := range themeCatalogue {
if theme.Seasonal {
if theme.IconSet != "" && !knownIconPack(theme.IconSet) {
t.Fatalf("%s names an icon pack %q nothing can draw", theme.ID, theme.IconSet)
}
continue
}
if theme.IconSet != "" {
t.Fatalf("selectable theme %s takes the viewer's marks away", theme.ID)
}
}
}
// The revision is the entire delivery mechanism: televisions compare it and refetch only
// when it moves. A pack change the hash did not notice would reach nobody until something
// else about the theme happened to change.
func TestThemeRevisionTracksTheIconSet(t *testing.T) {
ordinary := time.Date(2026, time.May, 3, 12, 0, 0, 0, time.UTC)
material := resolveTheme(themeMidnight, iconPackMaterial, nil, true, true, ordinary)
lucide := resolveTheme(themeMidnight, iconPackLucide, nil, true, true, ordinary)
if material.Revision == lucide.Revision {
t.Fatalf("the same revision %q for two different icon packs", material.Revision)
}
}