0.1.52 Gateway

This commit is contained in:
ponzischeme89
2026-08-17 19:09:17 +12:00
parent d5a8d3ad88
commit 1da91e40a1
46 changed files with 1626 additions and 106 deletions
+3
View File
@@ -56,6 +56,8 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
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))
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
mux.Handle("POST /admin/api/quiet-time", s.adminAuth(s.handleAdminQuietTime))
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
@@ -867,4 +869,5 @@ func (s *Server) handleAdminViews(w http.ResponseWriter, r *http.Request) {
type syncerHandle interface {
Running() bool
Sync(ctx context.Context, kind, trigger string) (library.Result, error)
Find(ctx context.Context, term string, limit int) ([]json.RawMessage, error)
}
+4
View File
@@ -286,6 +286,10 @@ func (s *Server) handleAdminDeleteDevice(w http.ResponseWriter, r *http.Request)
s.loggerFor(r.Context()).Warn("device version cleanup failed",
"removed_device_id", deviceID, "error", err)
}
if err := s.store.DeleteDeviceActivityDays(r.Context(), deviceID); err != nil {
s.loggerFor(r.Context()).Warn("device activity cleanup failed",
"removed_device_id", deviceID, "error", err)
}
w.WriteHeader(http.StatusNoContent)
}
@@ -0,0 +1,149 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/buildinfo"
"github.com/ponzischeme89/memby/server/internal/store"
)
// adminGatewaySettingsResponse is deliberately three things at once: what the operator has
// chosen, what the container was started with, and what is therefore in force. A settings
// page that showed only the first would leave every empty field looking like a value of
// nothing, when an empty field here means "whatever .env says" — and the operator has no
// other way to see what that is without opening a file on the NAS.
type adminGatewaySettingsResponse struct {
Settings store.GatewaySettings `json:"settings"`
Deployed deployedGatewaySettings `json:"deployed"`
Effective deployedGatewaySettings `json:"effective"`
// LogLevels is the vocabulary rather than a list the console keeps its own copy of,
// for the reason the preference catalogue is served with the accounts page: a value
// the server would refuse must never be offerable.
LogLevels []string `json:"logLevels"`
// Version and Timezone name the process this page is about, so the page can identify
// the gateway it is changing without a second request.
Version string `json:"version"`
}
func (s *Server) gatewaySettingsResponse() adminGatewaySettingsResponse {
settings := s.gatewaySettings.get()
return adminGatewaySettingsResponse{
Settings: settings,
Deployed: s.deployedSettings(),
Effective: deployedGatewaySettings{
Timezone: s.householdTimezoneName(),
LogLevel: s.effectiveLogLevel(),
SessionIdleDays: int(s.sessionIdleExpiry() / (24 * time.Hour)),
SonarrAlertMinutes: int(s.sonarrAlertWindow() / time.Minute),
RadarrAlertMinutes: int(s.radarrAlertWindow() / time.Minute),
EmbyHealthSeconds: int(s.embyHealthInterval() / time.Second),
},
LogLevels: store.GatewayLogLevels,
Version: buildinfo.Version(),
}
}
func (s *Server) effectiveLogLevel() string {
if s.logLevel != nil {
return levelName(s.logLevel.Level())
}
return levelName(s.deployedLogLevel)
}
// handleAdminGatewaySettings serves the page and accepts a write on the same path, which
// is the shape the *arr policy routes already take.
func (s *Server) handleAdminGatewaySettings(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
writeJSON(w, http.StatusOK, s.gatewaySettingsResponse())
return
}
var req store.GatewaySettings
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
previous := s.gatewaySettings.get()
_, operator, _ := s.browserSession(r, adminSessionPurpose)
req.UpdatedBy = operator
stored, err := s.store.SetGatewaySettings(r.Context(), req)
if err != nil {
s.loggerFor(r.Context()).Error("gateway settings write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save gateway settings")
return
}
// Applied here rather than waited for: the watcher would pick this up within thirty
// seconds, and an operator who has just turned debug logging on and gone to look at
// the log would spend that half minute believing it had not worked.
s.gatewaySettings.set(stored)
s.applyLogLevel(stored.LogLevel)
s.loggerFor(r.Context()).Info("gateway settings changed",
"timezone", s.householdTimezoneName(), "log_level", s.effectiveLogLevel(),
"session_idle_days", int(s.sessionIdleExpiry()/(24*time.Hour)),
"operator", operator)
if summary := gatewaySettingsChanges(previous, stored); summary != "" {
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeSettingsChanged,
Severity: adminevents.SeverityWarning,
Title: "Gateway settings changed",
Summary: summary,
Actor: operator,
Link: "/admin/settings",
})
}
writeJSON(w, http.StatusOK, s.gatewaySettingsResponse())
}
// gatewaySettingsChanges is what the notification says, and returns empty when nothing
// moved. The console re-posts the whole document, so a save that changed nothing is an
// ordinary event — the preferenceChanges rule, for the same reason: a feed that records
// every press of Save is one nobody reads.
func gatewaySettingsChanges(before, after store.GatewaySettings) string {
changes := []string{}
if before.Timezone != after.Timezone {
changes = append(changes, "timezone")
}
if before.LogLevel != after.LogLevel {
changes = append(changes, "log level")
}
if before.SessionIdleDays != after.SessionIdleDays {
changes = append(changes, "session expiry")
}
if before.SonarrAlertMinutes != after.SonarrAlertMinutes {
changes = append(changes, "episode alert window")
}
if before.RadarrAlertMinutes != after.RadarrAlertMinutes {
changes = append(changes, "film alert window")
}
if before.EmbyHealthSeconds != after.EmbyHealthSeconds {
changes = append(changes, "Emby health probe")
}
switch len(changes) {
case 0:
return ""
case 1:
return "The gateway's " + changes[0] + " was changed"
default:
return "The gateway's " + joinPhrase(changes) + " were changed"
}
}
func joinPhrase(values []string) string {
switch len(values) {
case 0:
return ""
case 1:
return values[0]
}
out := ""
for index, value := range values[:len(values)-1] {
if index > 0 {
out += ", "
}
out += value
}
return out + " and " + values[len(values)-1]
}
@@ -30,6 +30,8 @@ type integrationEvent struct {
var integrationEventCatalogue = []integrationEvent{
{adminevents.TypeLogin, "User signed in", "A television signed in with a known device.", "Access"},
{adminevents.TypeDeviceRegistered, "New device", "A television signed in for the first time.", "Access"},
{adminevents.TypeDeviceFirstUse, "First use today", "A television opened Memby for the first time that day.", "Access"},
{adminevents.TypeDeviceUpdated, "App updated", "A television finished updating to a new build.", "Access"},
{adminevents.TypeLoginFailed, "Sign-in refused", "Emby refused the credentials offered.", "Access"},
{adminevents.TypeLogout, "User signed out", "A television signed itself out.", "Access"},
{adminevents.TypeDeviceRemoved, "Device removed", "A television was removed from an account.", "Access"},
@@ -37,6 +39,7 @@ var integrationEventCatalogue = []integrationEvent{
{adminevents.TypeAdminSignIn, "Admin sign-in", "Somebody signed into this console.", "Access"},
{adminevents.TypeServerStarted, "Server started", "The gateway came up, usually after a deployment.", "System"},
{adminevents.TypeMaintenanceChanged, "Maintenance changed", "Memby was taken offline or brought back.", "System"},
{adminevents.TypeSettingsChanged, "Gateway settings changed", "A server-level setting was changed in the console.", "System"},
{adminevents.TypeTaskCompleted, "Scheduled task finished", "A background job did some work.", "System"},
{adminevents.TypeTaskFailed, "Scheduled task failed", "A background job could not complete.", "System"},
{adminevents.TypeIntegrationFailed, "Integration failed", "An outgoing webhook could not be delivered.", "System"},
+1 -1
View File
@@ -131,7 +131,7 @@ func (s *Server) handleAdminRecommendations(w http.ResponseWriter, r *http.Reque
contextName = "default"
}
intent := recommend.RankIntent{
ID: "admin:" + contextName, Now: now, Location: s.cfg.SonarrLocation,
ID: "admin:" + contextName, Now: now, Location: s.householdLocation(),
HouseholdScores: household, Compatibility: map[string]float64{},
}
switch contextName {
+3 -6
View File
@@ -180,7 +180,8 @@ func liveAlerts(stored []storedAlert, now time.Time) []clientAlert {
// sonarrAiredAlerts reads the calendar through the same cache the five-day schedule row
// uses, so polling clients never cost a Sonarr request of their own.
func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
if !s.sonarrEnabled(ctx) || s.cfg.SonarrAlertWindow <= 0 {
window := s.sonarrAlertWindow()
if !s.sonarrEnabled(ctx) || window <= 0 {
return nil
}
row, err := s.sonarrAiringTodayRow(ctx)
@@ -199,11 +200,7 @@ func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
}
items = append(items, item)
}
location := s.cfg.SonarrLocation
if location == nil {
location = time.Local
}
return buildSonarrAlerts(items, time.Now().In(location), s.cfg.SonarrAlertWindow)
return buildSonarrAlerts(items, time.Now().In(s.householdLocation()), window)
}
// buildSonarrAlerts announces episodes that have aired but are not in Emby yet — the
+25 -1
View File
@@ -99,7 +99,14 @@ type Server struct {
recommendationBuilds recommendationBuilds
maintenance maintenanceState
quietTime quietTimeState
updatePolicy updatePolicyCache
// gatewaySettings is the operator's runtime amendment to what the container was
// started with. deployedLogLevel is remembered beside the live level variable
// because clearing an override has to restore something, and the level variable
// itself has by then been moved.
gatewaySettings gatewaySettingsState
logLevel *slog.LevelVar
deployedLogLevel slog.Level
updatePolicy updatePolicyCache
// embyHealth is the reachability probe's live finding, which /v1/status publishes so
// a TV can show why playback stopped even if it missed the announcement.
embyHealth embyHealth
@@ -129,6 +136,10 @@ type Deps struct {
AdminEvents *adminevents.Bus
Scheduler *scheduler.Scheduler
Integrations *integrations.Dispatcher
// LogLevel is the live level of the process's own logger, so the console can turn
// debug on and watch the thing it turned it on for. Nil is allowed and means the
// level is fixed at whatever the container was started with.
LogLevel *slog.LevelVar
}
func New(cfg config.Config, deps Deps) *Server {
@@ -152,9 +163,19 @@ func New(cfg config.Config, deps Deps) *Server {
adminEvents: deps.AdminEvents,
scheduler: deps.Scheduler,
integrations: deps.Integrations,
logLevel: deps.LogLevel,
deployedLogLevel: deployedLevel(deps.LogLevel),
}
}
func deployedLevel(level *slog.LevelVar) slog.Level {
if level == nil {
return slog.LevelInfo
}
return level.Level()
}
// publishAdmin reports something an operator would want to know about.
//
// Every caller treats it as fire-and-forget, which is why it returns nothing: the feed is
@@ -409,6 +430,9 @@ func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) stor
s.log.Warn("device version record failed",
"device_id", sess.DeviceID, "error", err)
}
// And it is the only place an operator would otherwise learn that an update
// they offered was actually taken.
s.announceDeviceUpdate(r.Context(), sess, previousVersion)
}
}
return sess
+7
View File
@@ -297,6 +297,10 @@ func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request, curr
s.loggerFor(r.Context()).Warn("device version cleanup failed",
"removed_device_id", deviceID, "error", err)
}
if err := s.store.DeleteDeviceActivityDays(r.Context(), deviceID); err != nil {
s.loggerFor(r.Context()).Warn("device activity cleanup failed",
"removed_device_id", deviceID, "error", err)
}
// A device disappearing from a household is worth a line: the next thing that TV
// reports is a sign-in, and the two together explain each other.
s.loggerFor(r.Context()).Info("device signed out remotely", "removed_device_id", deviceID)
@@ -335,6 +339,9 @@ func (s *Server) retireSupersededDevices(ctx context.Context, devices []store.Su
if err := s.store.DeleteDeviceVersions(ctx, ids...); err != nil {
s.loggerFor(ctx).Warn("device version cleanup failed", "error", err)
}
if err := s.store.DeleteDeviceActivityDays(ctx, ids...); err != nil {
s.loggerFor(ctx).Warn("device activity cleanup failed", "error", err)
}
s.loggerFor(ctx).Info("device identity superseded", "retired_device_ids", ids)
}
+1 -4
View File
@@ -86,10 +86,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request, _ store.
}
func (s *Server) sonarrLocation() *time.Location {
if s.cfg.SonarrLocation != nil {
return s.cfg.SonarrLocation
}
return time.Local
return s.householdLocation()
}
func (s *Server) sonarrCalendarMonth(
+86
View File
@@ -0,0 +1,86 @@
package api
import (
"context"
"fmt"
"net/url"
"time"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/store"
)
// noteDailyFirstUse announces the first time a television opened Memby on a given day.
//
// It is called from the home request rather than from the auth middleware, which every
// call passes through, because "opened Memby" means the launcher composing. A set left on
// overnight polls /v1/status every ten seconds, so anchoring on any authenticated request
// would announce that set at midnight — an event nobody did, timed to the hour nobody is
// reading the feed.
//
// It is called before the cached response is served, deliberately: a household whose home
// row cache is still warm from the last set to switch on has still just been opened by
// this one, and the mark is a single indexed insert either way.
//
// Everything about it is best-effort. It cannot fail a launcher.
func (s *Server) noteDailyFirstUse(ctx context.Context, sess store.Session) {
if s.store == nil || sess.DeviceID == "" {
return
}
first, err := s.store.MarkDeviceDay(
ctx, sess.DeviceID, sess.EmbyUserID, time.Now().In(s.sonarrLocation()),
)
if err != nil {
s.loggerFor(ctx).Warn("device day mark failed",
"device_id", sess.DeviceID, "error", err)
return
}
if !first {
return
}
s.loggerFor(ctx).Info("first use today",
"device_id", sess.DeviceID, "client", clientLogValue(sess.ClientVersion))
s.publishAdmin(ctx, adminevents.Event{
Type: adminevents.TypeDeviceFirstUse,
Title: "Opened Memby",
Summary: fmt.Sprintf("%s opened Memby on %s for the first time today",
displayName(sess.Username), displayName(sess.DeviceName)),
Actor: sess.Username, Target: sess.DeviceName,
Link: "/admin/devices/" + url.PathEscape(sess.DeviceID),
Metadata: adminevents.Meta(map[string]any{
"deviceId": sess.DeviceID, "userId": sess.EmbyUserID,
"version": sess.ClientVersion,
}),
})
}
// announceDeviceUpdate reports a television that has finished updating itself.
//
// A set that updates in place never signs in again, so the first request carrying the new
// build is the only evidence there is that the update worked — and an update that was
// offered and never taken looks exactly like one that was, until this arrives.
//
// `from` being empty is not an update: it is a television this gateway had never been
// told the build of, which is an old APK or a session predating identity capture, and
// announcing it as an upgrade would claim a version moved when nothing is known to have
// moved. A version that went *backwards* is still announced, because a sideloaded
// downgrade is news an operator wants at least as much as an upgrade.
func (s *Server) announceDeviceUpdate(ctx context.Context, sess store.Session, from string) {
if from == "" || sess.ClientVersion == "" || from == sess.ClientVersion {
return
}
s.loggerFor(ctx).Info("client build changed",
"device_id", sess.DeviceID, "from", from, "to", sess.ClientVersion)
s.publishAdmin(ctx, adminevents.Event{
Type: adminevents.TypeDeviceUpdated,
Title: "App updated",
Summary: fmt.Sprintf("%s updated from %s to %s",
displayName(sess.DeviceName), from, sess.ClientVersion),
Actor: sess.Username, Target: sess.DeviceName,
Link: "/admin/devices/" + url.PathEscape(sess.DeviceID),
Metadata: adminevents.Meta(map[string]any{
"deviceId": sess.DeviceID, "userId": sess.EmbyUserID,
"from": from, "to": sess.ClientVersion,
}),
})
}
+22
View File
@@ -63,6 +63,28 @@ func (h *embyHealth) begin(interval time.Duration, now time.Time) {
}
}
// retune follows the operator changing the probe's cadence, or switching it off, without
// disturbing what the probe has already found. It is deliberately not `begin`: that
// starts a fresh watch and drops the Emby version with it, which the About page reads and
// which is still true whatever the interval is now.
func (h *embyHealth) retune(interval time.Duration, now time.Time) {
h.mu.Lock()
defer h.mu.Unlock()
if interval <= 0 {
h.state.monitored = false
h.state.retryEvery = 0
return
}
if !h.state.monitored {
h.state = embyHealthState{
monitored: true, reachable: true, since: now, retryEvery: interval,
version: h.state.version,
}
return
}
h.state.retryEvery = interval
}
// record folds one probe result in and reports whether the published verdict changed.
func (h *embyHealth) record(ok bool, version string, now time.Time) {
h.mu.Lock()
+186
View File
@@ -0,0 +1,186 @@
package api
import (
"context"
"log/slog"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/store"
)
// gatewaySettingsState caches the operator's overrides in memory, the way maintenance is
// cached: several of these are read on the request path — the household's timezone is
// read by every home response — and none of them is worth a query.
type gatewaySettingsState struct {
mu sync.RWMutex
value store.GatewaySettings
}
func (g *gatewaySettingsState) get() store.GatewaySettings {
g.mu.RLock()
defer g.mu.RUnlock()
return g.value
}
func (g *gatewaySettingsState) set(value store.GatewaySettings) {
g.mu.Lock()
defer g.mu.Unlock()
g.value = value
}
// LoadGatewaySettings primes the cache and applies the settings that live in the running
// process rather than being read where they are used. Called at boot and after a write.
func (s *Server) LoadGatewaySettings(ctx context.Context) error {
if s.store == nil {
return nil
}
settings, err := s.store.GatewaySettings(ctx)
if err != nil {
return err
}
s.gatewaySettings.set(settings)
s.applyLogLevel(settings.LogLevel)
return nil
}
// WatchGatewaySettings re-reads the overrides periodically, so a change made directly in
// the database — or by another instance — is picked up without a restart. The same reason
// WatchMaintenance exists.
func (s *Server) WatchGatewaySettings(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.LoadGatewaySettings(ctx); err != nil {
s.log.Warn("gateway settings refresh failed",
"component", "settings", "error", err)
}
}
}
}
// applyLogLevel moves the running process's log level. It is a no-op on a gateway wired
// without a level variable — every unit test in this package — and an empty override
// restores the level the container was started with, which is what makes clearing the
// setting in the console a real undo rather than a value the operator has to remember.
func (s *Server) applyLogLevel(level string) {
if s.logLevel == nil {
return
}
if level == "" {
s.logLevel.Set(s.deployedLogLevel)
return
}
s.logLevel.Set(logging.ParseLevel(level))
}
// --- effective values -------------------------------------------------------
//
// Each of these is "the override, or what was deployed". They are the only readers of the
// cached document, so a setting is added by adding one of these beside its config field
// rather than by teaching every call site that an override exists.
// householdLocation is the household's idea of what day it is. Everything that groups by
// a local day reads it: the schedule rows, the hero rotation, sign-in history and the
// first-use notification.
func (s *Server) householdLocation() *time.Location {
if name := s.gatewaySettings.get().Timezone; name != "" {
if location, err := time.LoadLocation(name); err == nil {
return location
}
}
if s.cfg.SonarrLocation != nil {
return s.cfg.SonarrLocation
}
return time.Local
}
// householdTimezoneName is what the console and the sign-in history print. It names the
// zone in force rather than the one deployed, so a page cannot claim a grouping that is
// not the one the rows were grouped by.
func (s *Server) householdTimezoneName() string {
return s.householdLocation().String()
}
func (s *Server) sessionIdleExpiry() time.Duration {
if days := s.gatewaySettings.get().SessionIdleDays; days > 0 {
return time.Duration(days) * 24 * time.Hour
}
return s.cfg.SessionIdleExpiry
}
func (s *Server) sonarrAlertWindow() time.Duration {
return overrideWindow(s.gatewaySettings.get().SonarrAlertMinutes, time.Minute,
s.cfg.SonarrAlertWindow)
}
func (s *Server) radarrAlertWindow() time.Duration {
return overrideWindow(s.gatewaySettings.get().RadarrAlertMinutes, time.Minute,
s.cfg.RadarrAlertWindow)
}
func (s *Server) embyHealthInterval() time.Duration {
return overrideWindow(s.gatewaySettings.get().EmbyHealthSeconds, time.Second,
s.cfg.EmbyHealthInterval)
}
// 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.
func overrideWindow(value int, unit, deployed time.Duration) time.Duration {
switch {
case value < 0:
return 0
case value > 0:
return time.Duration(value) * unit
default:
return deployed
}
}
// deployedGatewaySettings describes what the container was started with, so the console
// can show the value a cleared field falls back to. Deliberately not the same shape as
// the overrides: these are facts, not choices, and nothing may write them back.
type deployedGatewaySettings struct {
Timezone string `json:"timezone"`
LogLevel string `json:"logLevel"`
SessionIdleDays int `json:"sessionIdleDays"`
SonarrAlertMinutes int `json:"sonarrAlertMinutes"`
RadarrAlertMinutes int `json:"radarrAlertMinutes"`
EmbyHealthSeconds int `json:"embyHealthSeconds"`
}
func (s *Server) deployedSettings() deployedGatewaySettings {
timezone := ""
if s.cfg.SonarrLocation != nil {
timezone = s.cfg.SonarrLocation.String()
}
return deployedGatewaySettings{
Timezone: timezone,
LogLevel: levelName(s.deployedLogLevel),
SessionIdleDays: int(s.cfg.SessionIdleExpiry / (24 * time.Hour)),
SonarrAlertMinutes: int(s.cfg.SonarrAlertWindow / time.Minute),
RadarrAlertMinutes: int(s.cfg.RadarrAlertWindow / time.Minute),
EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second),
}
}
func levelName(level slog.Level) string {
switch {
case level < slog.LevelDebug:
return "trace"
case level < slog.LevelInfo:
return "debug"
case level < slog.LevelWarn:
return "info"
case level < slog.LevelError:
return "warn"
default:
return "error"
}
}
@@ -0,0 +1,68 @@
package api
import (
"log/slog"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The three meanings a duration-like override carries. Zero is not "off" — that is the
// whole point of the -1, and reading it as off would silently disable an alert window an
// operator had merely left alone.
func TestOverrideWindowSeparatesOffFromDeployed(t *testing.T) {
deployed := 15 * time.Minute
if got := overrideWindow(0, time.Minute, deployed); got != deployed {
t.Fatalf("zero should fall back to the deployed value, got %s", got)
}
if got := overrideWindow(store.GatewaySettingsOff, time.Minute, deployed); got != 0 {
t.Fatalf("a negative override should switch the window off, got %s", got)
}
if got := overrideWindow(45, time.Minute, deployed); got != 45*time.Minute {
t.Fatalf("a positive override should be taken in the given unit, got %s", got)
}
}
// The probe loop must keep ticking while it is switched off, or turning it back on would
// need a restart of the gateway — which is the thing the setting exists to avoid.
func TestEmbyProbeDelayKeepsTickingWhileOff(t *testing.T) {
if got := embyProbeDelay(0); got != time.Minute {
t.Fatalf("a switched-off probe should still wake up, got %s", got)
}
if got := embyProbeDelay(30 * time.Second); got != 30*time.Second {
t.Fatalf("a live probe should wait its interval, got %s", got)
}
}
func TestGatewaySettingsChangesReportsOnlyWhatMoved(t *testing.T) {
before := store.GatewaySettings{Timezone: "Pacific/Auckland", SessionIdleDays: 30}
if summary := gatewaySettingsChanges(before, before); summary != "" {
t.Fatalf("an unchanged save must not be news, got %q", summary)
}
one := before
one.LogLevel = "debug"
if summary := gatewaySettingsChanges(before, one); summary != "The gateway's log level was changed" {
t.Fatalf("one change reads wrong: %q", summary)
}
two := one
two.SessionIdleDays = 60
summary := gatewaySettingsChanges(before, two)
if summary != "The gateway's log level and session expiry were changed" {
t.Fatalf("two changes read wrong: %q", summary)
}
}
func TestLevelNameCoversTheVocabulary(t *testing.T) {
for _, level := range store.GatewayLogLevels {
if got := levelName(parseTestLevel(t, level)); got != level {
t.Fatalf("%s round-tripped as %s", level, got)
}
}
}
func parseTestLevel(t *testing.T, name string) slog.Level {
t.Helper()
return logging.ParseLevel(name)
}
+1 -4
View File
@@ -1055,10 +1055,7 @@ func (s *Server) heroPremiereCandidates(ctx context.Context, now time.Time) []he
if s.sonarr == nil || s.store == nil {
return nil
}
location := s.cfg.SonarrLocation
if location == nil {
location = time.Local
}
location := s.householdLocation()
dayStart := localDayStart(now.In(location), location)
key := heroPremiereCachePrefix + dayStart.Format("2006-01-02")
+47 -6
View File
@@ -97,20 +97,61 @@ func heroScheduleTimeZone(location *time.Location) string {
return location.String()
}
// heroSearchLimit is what either half of the picker's search may contribute, and what the
// merged answer is trimmed back to.
const heroSearchLimit = 20
// handleAdminHeroSearch answers the picker from the imported catalogue and from Emby.
//
// The catalogue alone is up to a sync interval stale, so a film imported this afternoon
// was simply not findable here until the next hourly pass — and pinning is the one hero
// decision an operator makes about a title *because* it has just arrived. Emby is asked
// as well and `Find` imports what it returns, which is what makes a fresh id usable by the
// policy validation and by the hero row itself rather than only by this list.
//
// Either half may fail without failing the search: a stale answer and a live one are both
// better than an error, and the two are deliberately asked in that order so a gateway with
// no Emby credentials configured still has a picker.
func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
items, err := s.store.SearchLibrary(r.Context(), r.URL.Query().Get("q"), 20)
term := strings.TrimSpace(r.URL.Query().Get("q"))
items, err := s.store.SearchLibrary(r.Context(), term, heroSearchLimit)
if err != nil {
s.loggerFor(r.Context()).Error("hero library search failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not search the library")
return
}
results := make([]heroAdminItem, 0, len(items))
for _, raw := range items {
if item, ok := adminHeroItem(raw); ok {
results = append(results, item)
if s.syncer != nil && term != "" {
found, findErr := s.syncer.Find(r.Context(), term, heroSearchLimit)
if findErr != nil {
s.loggerFor(r.Context()).Warn("hero live search unavailable", "error", findErr)
}
items = append(items, found...)
}
writeJSON(w, http.StatusOK, map[string]any{"items": results})
writeJSON(w, http.StatusOK, map[string]any{"items": mergeHeroSearchResults(items, heroSearchLimit)})
}
// mergeHeroSearchResults turns both halves of the search into one list.
//
// Almost every title comes back from both, so the dedupe is the ordinary case rather than
// the exception, and it keeps the first sighting: the catalogue answers first, and its
// ranking is the one an operator has been reading all along. Anything an id appears in
// only once is either a title Emby has and the last import missed — the whole point — or
// one deleted from Emby that the catalogue has not swept yet.
func mergeHeroSearchResults(items []json.RawMessage, limit int) []heroAdminItem {
results := make([]heroAdminItem, 0, len(items))
seen := make(map[string]bool, len(items))
for _, raw := range items {
if len(results) >= limit {
break
}
item, ok := adminHeroItem(raw)
if !ok || seen[item.ID] {
continue
}
seen[item.ID] = true
results = append(results, item)
}
return results
}
func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
+66
View File
@@ -0,0 +1,66 @@
package api
import (
"encoding/json"
"testing"
)
func heroSearchPayload(id, name, itemType string) json.RawMessage {
return json.RawMessage(`{"Id":"` + id + `","Name":"` + name + `","Type":"` + itemType + `"}`)
}
// The picker asks two sources that mostly agree, so one title arriving twice is the
// ordinary case rather than a fault, and the catalogue's ranking is the one kept.
func TestHeroSearchMergeKeepsTheFirstSighting(t *testing.T) {
merged := mergeHeroSearchResults([]json.RawMessage{
heroSearchPayload("1", "Arrival", "Movie"),
heroSearchPayload("2", "Severance", "Series"),
heroSearchPayload("1", "Arrival", "Movie"),
}, 20)
if len(merged) != 2 {
t.Fatalf("merged %d titles, want 2", len(merged))
}
if merged[0].ID != "1" || merged[1].ID != "2" {
t.Fatalf("merge reordered the catalogue's answer: %+v", merged)
}
}
// The whole reason Emby is asked: a title imported since the last pass is in one half of
// the answer only, and it must survive into the list an operator can pin from.
func TestHeroSearchMergeAdoptsATitleTheCatalogueMissed(t *testing.T) {
merged := mergeHeroSearchResults([]json.RawMessage{
heroSearchPayload("1", "Arrival", "Movie"),
heroSearchPayload("1", "Arrival", "Movie"),
heroSearchPayload("9", "Arrival of the Birds", "Movie"),
}, 20)
if len(merged) != 2 || merged[1].ID != "9" {
t.Fatalf("a title only Emby knew about was dropped: %+v", merged)
}
}
// A hero is a film or a show. An episode matching the term, and a synthetic schedule card
// that cannot be played at all, are both things the picker must not offer.
func TestHeroSearchMergeOffersOnlyPinnableTitles(t *testing.T) {
merged := mergeHeroSearchResults([]json.RawMessage{
heroSearchPayload("1", "Severance S01E01", "Episode"),
json.RawMessage(`{"Id":"2","Name":"Dune","Type":"Movie","MembySource":"radarr"}`),
heroSearchPayload("3", "Dune", "Movie"),
}, 20)
if len(merged) != 1 || merged[0].ID != "3" {
t.Fatalf("picker offered something unpinnable: %+v", merged)
}
}
func TestHeroSearchMergeTrimsToTheLimit(t *testing.T) {
items := make([]json.RawMessage, 0, 30)
for index := range 30 {
items = append(items, heroSearchPayload(string(rune('a'+index)), "Title", "Movie"))
}
if merged := mergeHeroSearchResults(items, 20); len(merged) != 20 {
t.Fatalf("merged %d titles, want the limit of 20", len(merged))
}
}
+2 -4
View File
@@ -69,6 +69,7 @@ type homeResponse struct {
// handleHome answers the entire launcher in one round trip.
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
s.noteDailyFirstUse(ctx, sess)
limit := queryInt(r, "limit", 24, 100)
sonarrSchedule := s.sonarrEnabled(r.Context()) && supportsSonarrSchedule(r)
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
@@ -203,10 +204,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
wg.Add(1)
go func() {
defer wg.Done()
location := s.cfg.SonarrLocation
if location == nil {
location = time.UTC
}
location := s.householdLocation()
window := homeForYouWindowAt(time.Now().In(location))
prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, window.Minutes)
if err != nil {
+19 -2
View File
@@ -51,6 +51,20 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) {
},
})
sched.Register(scheduler.Task{
ID: "device-activity-cleanup",
Name: "Device activity cleanup",
Group: "Housekeeping",
Description: fmt.Sprintf(
"Removes the daily first-use marks that are older than %d days.",
int(store.DeviceActivityRetention/(24*time.Hour))),
Interval: 24 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneDeviceActivityDays(ctx, store.DeviceActivityRetention)
return countDetail(removed, "activity mark"), err
},
})
sched.Register(scheduler.Task{
ID: "integration-cleanup",
Name: "Integration delivery cleanup",
@@ -103,10 +117,13 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) {
Group: "Housekeeping",
Description: fmt.Sprintf(
"Retires gateway tokens unused for %d days, along with the Emby token each one holds.",
int(s.cfg.SessionIdleExpiry/(24*time.Hour))),
int(s.sessionIdleExpiry()/(24*time.Hour))),
Interval: 6 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.DeleteIdleSessions(ctx, s.cfg.SessionIdleExpiry)
// Read at run time rather than closed over: the description above is written
// once when the task is registered, but the sweep itself must follow the
// operator's setting without a restart.
removed, err := s.store.DeleteIdleSessions(ctx, s.sessionIdleExpiry())
return countDetail(removed, "idle session"), err
},
})
+2 -8
View File
@@ -87,10 +87,7 @@ func (s *Server) parseFilterDate(raw string, endOfDay bool) time.Time {
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
return parsed
}
location := s.cfg.SonarrLocation
if location == nil {
location = time.UTC
}
location := s.householdLocation()
parsed, err := time.ParseInLocation("2006-01-02", raw, location)
if err != nil {
return time.Time{}
@@ -105,10 +102,7 @@ func (s *Server) parseFilterDate(raw string, endOfDay bool) time.Time {
// asked to do the grouping in it rather than the console doing it in the browser's zone,
// for the reason store.LoginDays gives.
func (s *Server) zoneName() string {
if s.cfg.SonarrLocation != nil {
return s.cfg.SonarrLocation.String()
}
return "UTC"
return s.householdTimezoneName()
}
type adminLoginsResponse struct {
+39 -7
View File
@@ -13,9 +13,23 @@ import (
)
const radarrCalendarCachePrefix = "radarr:calendar:v3:"
const radarrScheduleDays = 5
// radarrScheduleDays is a month where the Sonarr schedule row's is a week, because films
// and episodes arrive at quite different rates. A household's Sonarr calendar fills a week
// several times over; its Radarr calendar, measured against the real catalogue, yields
// about one title a fortnight — at five days the row was empty or held a single card most
// of the time, which reads as a broken shelf rather than a quiet month.
const radarrScheduleDays = 30
// radarrTheatricalDelayDays is the median cinema-to-digital gap for recent releases, used
// only to estimate a digital date Radarr has not published. It shares a figure with
// radarrScheduleDays by coincidence, not by meaning — they are free to move apart.
const radarrTheatricalDelayDays = 30
// radarrWeekdayLabelDays is how far out a bare weekday still names an unambiguous day.
// Beyond it the label carries a date, or a release five weeks away reads as this Friday.
const radarrWeekdayLabelDays = 6
type radarrRelease struct {
at time.Time
estimated bool
@@ -185,7 +199,7 @@ func toRadarrScheduleItem(movie radarr.Movie, release radarrRelease, now time.Ti
}
localRelease := release.at.In(location)
item.MembyAirsAt = localRelease.Format(time.RFC3339)
item.MembyAirDayLabel = scheduleAirDayLabel(localRelease, now, location)
item.MembyAirDayLabel = radarrReleaseDayLabel(localRelease, now, location)
item.MembyAirLabel = digitalReleaseLabel(localRelease, now, location, release.estimated)
switch {
case movie.HasFile:
@@ -205,11 +219,27 @@ func toRadarrScheduleItem(movie radarr.Movie, release radarrRelease, now time.Ti
return item
}
// radarrReleaseDayLabel is the card's own day chip. It is Radarr's rather than
// scheduleAirDayLabel because that one answers for a seven-day Sonarr window, where every
// date it can be handed is inside the coming week and a weekday is never ambiguous.
func radarrReleaseDayLabel(release, now time.Time, location *time.Location) string {
today := localDayStart(now.In(location), location)
releaseDay := localDayStart(release.In(location), location)
switch {
case releaseDay.Equal(today):
return "Today"
case releaseDay.Equal(today.AddDate(0, 0, 1)):
return "Tomorrow"
case releaseDay.Before(today.AddDate(0, 0, radarrWeekdayLabelDays+1)):
return releaseDay.Format("Monday")
default:
return releaseDay.Format("2 Jan")
}
}
func digitalReleaseLabel(release, now time.Time, location *time.Location, estimated bool) string {
release = release.In(location)
now = now.In(location)
today := localDayStart(now, location)
releaseDay := localDayStart(release, location)
today := localDayStart(now.In(location), location)
releaseDay := localDayStart(release.In(location), location)
prefix := "Digital release "
if estimated {
prefix = "Estimated digital release "
@@ -219,8 +249,10 @@ func digitalReleaseLabel(release, now time.Time, location *time.Location, estima
return prefix + "today"
case releaseDay.Equal(today.AddDate(0, 0, 1)):
return prefix + "tomorrow"
case releaseDay.Before(today.AddDate(0, 0, radarrWeekdayLabelDays+1)):
return prefix + releaseDay.Format("Monday")
default:
return prefix + release.Format("Monday")
return prefix + releaseDay.Format("2 January")
}
}
+3 -2
View File
@@ -73,12 +73,13 @@ func (s *Server) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
return
}
if s.cfg.RadarrAlertWindow <= 0 {
window := s.radarrAlertWindow()
if window <= 0 {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
return
}
s.publishAlert(r.Context(), alert, s.cfg.RadarrAlertWindow)
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})
+65 -24
View File
@@ -8,50 +8,91 @@ import (
"github.com/ponzischeme89/memby/server/internal/radarr"
)
func TestBuildRadarrRowUsesDigitalReleasesAndEstimatedCinemaFallbackInFiveDayWindow(t *testing.T) {
func TestBuildRadarrRowUsesDigitalReleasesAndEstimatedCinemaFallbackInMonthWindow(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 30, 9, 0, 0, 0, location)
digitalToday := time.Date(2026, 7, 30, 0, 0, 0, 0, location).UTC()
digitalSunday := time.Date(2026, 8, 2, 0, 0, 0, 0, location).UTC()
outside := time.Date(2026, 8, 4, 0, 0, 0, 0, location).UTC()
theatricalOnly := time.Date(2026, 7, 1, 0, 0, 0, 0, location).UTC()
oldDigital := time.Date(1993, 4, 9, 0, 0, 0, 0, location).UTC()
modernRerelease := time.Date(2026, 7, 2, 0, 0, 0, 0, location).UTC()
day := func(y int, m time.Month, d int) time.Time {
return time.Date(y, m, d, 0, 0, 0, 0, location).UTC()
}
digitalToday := day(2026, 7, 30)
digitalSunday := day(2026, 8, 2)
// Three weeks out: inside the month window, past the point a weekday still names a day.
digitalLater := day(2026, 8, 20)
// The day the window closes, which is exclusive.
beyondWindow := day(2026, 8, 29)
theatricalOnly := day(2026, 7, 1)
oldDigital := day(1993, 4, 9)
modernRerelease := day(2026, 7, 2)
row, err := buildRadarrRow([]radarr.Movie{
{ID: 1, Title: "Today", DigitalRelease: &digitalToday, Monitored: true},
{ID: 2, Title: "Sunday", DigitalRelease: &digitalSunday, Monitored: true},
{ID: 3, Title: "Outside", DigitalRelease: &outside, Monitored: true},
{ID: 3, Title: "Three Weeks Out", DigitalRelease: &digitalLater, Monitored: true},
{ID: 4, Title: "Cinema Only", InCinemas: &theatricalOnly, Monitored: true},
{ID: 5, Title: "Old Digital Release", Year: 1993, DigitalRelease: &oldDigital, InCinemas: &modernRerelease, Monitored: true},
{ID: 6, Title: "Beyond Window", DigitalRelease: &beyondWindow, Monitored: true},
}, now, location)
if err != nil {
t.Fatal(err)
}
if row.ID != "radarr-upcoming-movies" || row.Kind != "movie-schedule" ||
row.Title != "Upcoming Movie releases" || len(row.Items) != 3 {
row.Title != "Upcoming Movie releases" || len(row.Items) != 4 {
t.Fatalf("unexpected row: %+v", row)
}
var first, second radarrScheduleItem
if err := json.Unmarshal(row.Items[0], &first); err != nil {
t.Fatal(err)
items := make([]radarrScheduleItem, len(row.Items))
for i, raw := range row.Items {
if err := json.Unmarshal(raw, &items[i]); err != nil {
t.Fatal(err)
}
}
if err := json.Unmarshal(row.Items[1], &second); err != nil {
t.Fatal(err)
want := []struct {
id, airLabel, dayLabel string
}{
{"radarr:1", "Digital release today", "Today"},
{"radarr:4", "Estimated digital release tomorrow", "Tomorrow"},
{"radarr:2", "Digital release Sunday", "Sunday"},
{"radarr:3", "Digital release 20 August", "20 Aug"},
}
if first.ID != "radarr:1" || first.MembyAirLabel != "Digital release today" {
t.Fatalf("unexpected first item: %+v", first)
for i, expect := range want {
got := items[i]
if got.ID != expect.id || got.MembyAirLabel != expect.airLabel ||
got.MembyAirDayLabel != expect.dayLabel {
t.Fatalf("item %d: got id=%s air=%q day=%q, want id=%s air=%q day=%q",
i, got.ID, got.MembyAirLabel, got.MembyAirDayLabel,
expect.id, expect.airLabel, expect.dayLabel)
}
}
if second.ID != "radarr:4" || second.MembyAirLabel != "Estimated digital release tomorrow" ||
second.MembyAvailabilityText != "Estimated digital release" {
t.Fatalf("unexpected second item: %+v", second)
if items[1].MembyAvailabilityText != "Estimated digital release" {
t.Fatalf("unexpected estimated availability: %+v", items[1])
}
var third radarrScheduleItem
if err := json.Unmarshal(row.Items[2], &third); err != nil {
t.Fatal(err)
}
// A weekday only names an unambiguous day inside the coming week. The month-long window
// routinely holds dates past that, where "Friday" would read as this Friday.
func TestRadarrReleaseLabelsCarryADateBeyondTheComingWeek(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 30, 9, 0, 0, 0, location)
cases := []struct {
offset int
dayLabel string
airLabel string
}{
{0, "Today", "Digital release today"},
{1, "Tomorrow", "Digital release tomorrow"},
{2, "Saturday", "Digital release Saturday"},
{radarrWeekdayLabelDays, "Wednesday", "Digital release Wednesday"},
{radarrWeekdayLabelDays + 1, "6 Aug", "Digital release 6 August"},
{21, "20 Aug", "Digital release 20 August"},
}
if third.ID != "radarr:2" || third.MembyAirLabel != "Digital release Sunday" {
t.Fatalf("unexpected third item: %+v", third)
for _, tc := range cases {
release := now.AddDate(0, 0, tc.offset)
if got := radarrReleaseDayLabel(release, now, location); got != tc.dayLabel {
t.Errorf("offset %d: day label = %q, want %q", tc.offset, got, tc.dayLabel)
}
if got := digitalReleaseLabel(release, now, location, false); got != tc.airLabel {
t.Errorf("offset %d: air label = %q, want %q", tc.offset, got, tc.airLabel)
}
}
}
+1 -1
View File
@@ -169,7 +169,7 @@ func (s *Server) personalizeTitles(
profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID)
cfg := s.weightedConfig()
now := time.Now()
location := s.cfg.SonarrLocation
location := s.householdLocation()
for index := range rows {
row := &rows[index]
if progressRow(row.ID) {
+27 -10
View File
@@ -112,27 +112,34 @@ func librarySyncTitle(changed int) string {
//
// Only *transitions* are announced. A server that is down stays down, and repeating it
// every minute would bury everything else.
func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Duration) {
if interval <= 0 {
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
// The cadence is read on every tick rather than fixed at start-up, because it is an
// operator setting the console can change: a loop that captured it would leave the one
// probe an operator is most likely to want to slow down or switch off needing a restart
// of the gateway to do either. A probe that is off still ticks, slowly, so that turning
// it back on does not need one either.
func (s *Server) WatchEmbyReachability(ctx context.Context) {
// Start out assuming reachable: a gateway booting while Emby is down should not
// open with a banner about a state nobody has seen change.
reachable := true
failures := 0
interval := s.embyHealthInterval()
// The same probe feeds the live state on /v1/status, so one request to Emby answers
// both "did this just change" and "is it working right now". Declared before the
// first tick so a client asking during the opening minute learns the retry interval.
s.embyHealth.begin(interval, time.Now().UTC())
s.embyHealth.retune(interval, time.Now().UTC())
timer := time.NewTimer(embyProbeDelay(interval))
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if s.quietTimeActive() {
case <-timer.C:
if next := s.embyHealthInterval(); next != interval {
interval = next
s.embyHealth.retune(interval, time.Now().UTC())
}
timer.Reset(embyProbeDelay(interval))
if interval <= 0 || s.quietTimeActive() {
continue
}
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
@@ -160,6 +167,16 @@ func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Durati
}
}
// embyProbeDelay is how long to wait before looking again. With the probe switched off it
// is the interval at which the loop asks whether it has been switched back on, which is a
// minute because nothing is watching and nothing is being asked of Emby.
func embyProbeDelay(interval time.Duration) time.Duration {
if interval <= 0 {
return time.Minute
}
return interval
}
// reachabilityAlert is timestamped per transition, so the "back online" banner never
// collides with the "not responding" one it replaces.
func (s *Server) reachabilityAlert(up bool) clientAlert {