0.2.76 - Icon Packs
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user