0.3.05
This commit is contained in:
+8
-1
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 ",
|
||||
|
||||
Reference in New Issue
Block a user