This commit is contained in:
ponzischeme89
2026-08-22 21:47:54 +12:00
parent d2f7f84cbf
commit 9a1f44da46
27 changed files with 452 additions and 110 deletions
+8 -1
View File
@@ -294,7 +294,7 @@ The same calendar data drives a slide-in banner on open clients: when an episode
time passes and Sonarr has not imported it yet, `/v1/status` starts returning an alert —
```json
{"maintenance": false, "message": "", "alerts": [
{"maintenance": false, "message": "", "notificationDisplay": "home_only", "alerts": [
{"id": "sonarr:7:42:aired", "kind": "sonarr-aired", "title": "Northbound",
"message": "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
"itemId": "sonarr:7:42", "imageTag": "sonarr", "airedAt": "2026-07-27T21:00:00+12:00"}]}
@@ -310,6 +310,13 @@ The server has no idea which TVs have seen what, so the client owns that: it per
ids it has shown and displays each alert once, for ten seconds, without taking focus — a
ring on the banner counts that down, since the viewer cannot dismiss it by remote.
The global **Notification display** setting on Gateway settings controls every viewer and
television. `everywhere` permits banners while browsing and over active playback;
`home_only` permits browsing but not movies, episodes or trailers; `off` returns no banner
alerts and the client renders none. Home only is the default for a new or older settings
document, and clients also treat a missing or unknown value as Home only. The policy rides
this status poll, so changing it takes effect on open sets without publishing an APK.
Clients poll only while a Memby screen is in the foreground, and record an alert as shown
only when the banner is actually on screen. So keep offering an alert for the whole window
rather than once: a TV that was showing its screensaver when the episode aired will pick
+22 -15
View File
@@ -11,10 +11,10 @@ import (
)
// 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.
// chosen, the deployed or server defaults, and what is therefore in force. A settings page
// that showed only the first would leave every empty environment-backed field looking like
// a value of nothing, when it means "whatever .env says" — and the operator has no other
// way to see that without opening a file on the NAS.
type adminGatewaySettingsResponse struct {
Settings store.GatewaySettings `json:"settings"`
Deployed deployedGatewaySettings `json:"deployed"`
@@ -22,7 +22,8 @@ type adminGatewaySettingsResponse struct {
// 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"`
LogLevels []string `json:"logLevels"`
NotificationDisplays []string `json:"notificationDisplays"`
// 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"`
@@ -34,17 +35,19 @@ func (s *Server) gatewaySettingsResponse() 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),
SlowRequestMillis: effectiveSlowRequestMillis(s.slowRequestThreshold()),
LibrarySyncMinutes: int(s.LibrarySyncInterval() / time.Minute),
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),
NotificationDisplay: s.notificationDisplay(),
EmbyHealthSeconds: int(s.embyHealthInterval() / time.Second),
SlowRequestMillis: effectiveSlowRequestMillis(s.slowRequestThreshold()),
LibrarySyncMinutes: int(s.LibrarySyncInterval() / time.Minute),
},
LogLevels: store.GatewayLogLevels,
Version: buildinfo.Version(),
LogLevels: store.GatewayLogLevels,
NotificationDisplays: store.GatewayNotificationDisplays,
Version: buildinfo.Version(),
}
}
@@ -94,6 +97,7 @@ func (s *Server) handleAdminGatewaySettings(w http.ResponseWriter, r *http.Reque
s.loggerFor(r.Context()).Info("gateway settings changed",
"timezone", s.householdTimezoneName(), "log_level", s.effectiveLogLevel(),
"notification_display", s.notificationDisplay(),
"session_idle_days", int(s.sessionIdleExpiry()/(24*time.Hour)),
"operator", operator)
if summary := gatewaySettingsChanges(previous, stored); summary != "" {
@@ -130,6 +134,9 @@ func gatewaySettingsChanges(before, after store.GatewaySettings) string {
if before.RadarrAlertMinutes != after.RadarrAlertMinutes {
changes = append(changes, "film alert window")
}
if before.NotificationDisplay != after.NotificationDisplay {
changes = append(changes, "notification display")
}
if before.EmbyHealthSeconds != after.EmbyHealthSeconds {
changes = append(changes, "Emby health probe")
}
+33 -19
View File
@@ -125,6 +125,17 @@ func (s *Server) radarrAlertWindow() time.Duration {
s.cfg.RadarrAlertWindow)
}
// notificationDisplay is the household-wide banner policy. The store normalises the
// cached value, and this fallback keeps tests and a not-yet-primed server safe too.
func (s *Server) notificationDisplay() string {
switch display := s.gatewaySettings.get().NotificationDisplay; display {
case store.NotificationDisplayEverywhere, store.NotificationDisplayOff:
return display
default:
return store.NotificationDisplayHomeOnly
}
}
// slowRequestThreshold is the line between a request that logs a breakdown and one that
// does not. Switched off it returns a duration no request can exceed rather than zero,
// because zero would mean *every* request carried one — the opposite of what the operator
@@ -166,18 +177,20 @@ func overrideWindow(value int, unit, deployed time.Duration) time.Duration {
}
}
// 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.
// deployedGatewaySettings describes the defaults a cleared field falls back to: deployed
// values for environment-backed settings, and Home only for the native notification
// policy. Deliberately not the same shape as the stored document: 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"`
SlowRequestMillis int `json:"slowRequestMillis"`
LibrarySyncMinutes int `json:"librarySyncMinutes"`
Timezone string `json:"timezone"`
LogLevel string `json:"logLevel"`
SessionIdleDays int `json:"sessionIdleDays"`
SonarrAlertMinutes int `json:"sonarrAlertMinutes"`
RadarrAlertMinutes int `json:"radarrAlertMinutes"`
NotificationDisplay string `json:"notificationDisplay"`
EmbyHealthSeconds int `json:"embyHealthSeconds"`
SlowRequestMillis int `json:"slowRequestMillis"`
LibrarySyncMinutes int `json:"librarySyncMinutes"`
}
func (s *Server) deployedSettings() deployedGatewaySettings {
@@ -186,14 +199,15 @@ func (s *Server) deployedSettings() deployedGatewaySettings {
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),
SlowRequestMillis: int(s.cfg.SlowRequestThreshold / time.Millisecond),
LibrarySyncMinutes: int(s.cfg.SyncInterval / time.Minute),
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),
NotificationDisplay: store.NotificationDisplayHomeOnly,
EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second),
SlowRequestMillis: int(s.cfg.SlowRequestThreshold / time.Millisecond),
LibrarySyncMinutes: int(s.cfg.SyncInterval / time.Minute),
}
}
@@ -54,6 +54,17 @@ func TestGatewaySettingsChangesReportsOnlyWhatMoved(t *testing.T) {
}
}
func TestNotificationDisplayDefaultsToHomeOnly(t *testing.T) {
s := &Server{}
if got := s.notificationDisplay(); got != store.NotificationDisplayHomeOnly {
t.Fatalf("unset notification display = %q, want %q", got, store.NotificationDisplayHomeOnly)
}
s.gatewaySettings.set(store.GatewaySettings{NotificationDisplay: store.NotificationDisplayEverywhere})
if got := s.notificationDisplay(); got != store.NotificationDisplayEverywhere {
t.Fatalf("notification display = %q, want everywhere", got)
}
}
func TestLevelNameCoversTheVocabulary(t *testing.T) {
for _, level := range store.GatewayLogLevels {
if got := levelName(parseTestLevel(t, level)); got != level {
+3 -1
View File
@@ -109,9 +109,10 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
message = store.DefaultMaintenanceMessage
}
alerts := []clientAlert{}
notificationDisplay := s.notificationDisplay()
// Nothing to celebrate while the service is down, and the client is showing the
// maintenance screen anyway.
if !state.Enabled {
if !state.Enabled && notificationDisplay != store.NotificationDisplayOff {
published, sonarr := s.publishedAlerts(r.Context()), s.sonarrAiredAlerts(r.Context())
if len(published) > 0 || len(sonarr) > 0 {
prefs, err := s.notificationPreferencesFor(r.Context(), sess.EmbyUserID)
@@ -136,6 +137,7 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
"quietTime": quiet.Active,
"message": message,
"alerts": alerts,
"notificationDisplay": notificationDisplay,
"compatible": compatible,
"compatibilityMessage": compatibilityMessage,
"clientVersion": clientVersion(r),
+30 -4
View File
@@ -17,13 +17,14 @@ const GatewaySettingsKey = "gateway_settings"
// GatewaySettings is the handful of server-level decisions an operator can change
// without a redeployment.
//
// Every field is an *override* of the value the container was started with, and the zero
// value means "whatever was deployed". That is the whole design: `.env` remains the
// Most fields are an *override* of the value the container was started with, and their
// zero value means "whatever was deployed". That is the whole design: `.env` remains the
// configuration — it is what a fresh container comes up with, what deploy-server.ps1
// writes and what an operator locked out of the console still has — and this row is a
// runtime amendment to it, so the console can always show the deployed value beside the
// one in force and an operator can put a setting back by clearing it rather than by
// remembering what it used to be.
// remembering what it used to be. NotificationDisplay is the exception: it is a native
// server policy with a Home-only default rather than an environment-backed value.
//
// A window that can legitimately be *off* therefore needs a value distinct from "not
// overridden", which is why the two alert windows and the health interval take -1 for off
@@ -44,6 +45,10 @@ type GatewaySettings struct {
// added" banner stays on offer to a television that was switched off at the time.
SonarrAlertMinutes int `json:"sonarrAlertMinutes"`
RadarrAlertMinutes int `json:"radarrAlertMinutes"`
// NotificationDisplay is where informational banners may be drawn across every
// viewer and television. It is server-owned rather than a per-viewer preference:
// one household should not have different interruption rules on different sets.
NotificationDisplay string `json:"notificationDisplay"`
// EmbyHealthSeconds is how often the reachability probe asks Emby whether it is there.
EmbyHealthSeconds int `json:"embyHealthSeconds"`
// SlowRequestMillis is how long a request must take before its log line carries a
@@ -74,6 +79,20 @@ const GatewaySettingsOff = -1
// and refusing is worth nothing if the list it refuses against lives somewhere else.
var GatewayLogLevels = []string{"trace", "debug", "info", "warn", "error"}
const (
NotificationDisplayEverywhere = "everywhere"
NotificationDisplayHomeOnly = "home_only"
NotificationDisplayOff = "off"
)
// GatewayNotificationDisplays is the complete wire vocabulary, served to the console so
// it cannot offer a value the gateway would refuse.
var GatewayNotificationDisplays = []string{
NotificationDisplayEverywhere,
NotificationDisplayHomeOnly,
NotificationDisplayOff,
}
func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings {
settings.Timezone = strings.TrimSpace(settings.Timezone)
if settings.Timezone != "" {
@@ -91,6 +110,13 @@ func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings {
settings.SessionIdleDays = clampOverride(settings.SessionIdleDays, 1, 3650, false)
settings.SonarrAlertMinutes = clampOverride(settings.SonarrAlertMinutes, 1, 24*60, true)
settings.RadarrAlertMinutes = clampOverride(settings.RadarrAlertMinutes, 1, 7*24*60, true)
settings.NotificationDisplay = strings.ToLower(strings.TrimSpace(settings.NotificationDisplay))
if !containsString(GatewayNotificationDisplays, settings.NotificationDisplay) {
// Missing covers both a new installation and a row written by an older gateway.
// Unknown is equally conservative: silence from the setting must never authorise
// an interruption over active playback.
settings.NotificationDisplay = NotificationDisplayHomeOnly
}
settings.EmbyHealthSeconds = clampOverride(settings.EmbyHealthSeconds, 10, 3600, true)
// The floor is 1ms rather than 0 because "breakdown on everything" is a real thing to
// want for a few minutes, and off is a real thing to want too — this is the one
@@ -133,7 +159,7 @@ func (s *Store) GatewaySettings(ctx context.Context) (GatewaySettings, error) {
err := s.pool.QueryRow(ctx,
`SELECT value FROM app_settings WHERE key = $1`, GatewaySettingsKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return GatewaySettings{}, nil
return normalizeGatewaySettings(GatewaySettings{}), nil
}
if err != nil {
return GatewaySettings{}, fmt.Errorf("store: read gateway settings: %w", err)
@@ -21,6 +21,25 @@ func TestNormalizeGatewaySettingsRefusesWhatItCannotUse(t *testing.T) {
}
}
func TestNormalizeGatewaySettingsDefaultsNotificationDisplayToHomeOnly(t *testing.T) {
for _, value := range []string{"", "unknown"} {
settings := normalizeGatewaySettings(GatewaySettings{NotificationDisplay: value})
if settings.NotificationDisplay != NotificationDisplayHomeOnly {
t.Fatalf("notification display %q became %q, want %q",
value, settings.NotificationDisplay, NotificationDisplayHomeOnly)
}
}
}
func TestNormalizeGatewaySettingsKeepsNotificationDisplayVocabulary(t *testing.T) {
for _, value := range GatewayNotificationDisplays {
settings := normalizeGatewaySettings(GatewaySettings{NotificationDisplay: " " + value + " "})
if settings.NotificationDisplay != value {
t.Fatalf("notification display %q became %q", value, settings.NotificationDisplay)
}
}
}
func TestNormalizeGatewaySettingsKeepsTheThreeMeanings(t *testing.T) {
settings := normalizeGatewaySettings(GatewaySettings{
Timezone: "Pacific/Auckland", LogLevel: " Debug ",