Files
memby/server/internal/store/gateway_settings.go
T

196 lines
9.0 KiB
Go
Raw Normal View History

2026-08-17 19:09:17 +12:00
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// GatewaySettingsKey is the app_settings row backing the gateway's own settings.
const GatewaySettingsKey = "gateway_settings"
// GatewaySettings is the handful of server-level decisions an operator can change
// without a redeployment.
//
2026-08-22 21:47:54 +12:00
// 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
2026-08-17 19:09:17 +12:00
// 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
2026-08-22 21:47:54 +12:00
// 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.
2026-08-17 19:09:17 +12:00
//
// 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
// and 0 for deployed. A plain zero would make "turn this off" indistinguishable from
// "leave it alone", and the operator would find the setting quietly ignored.
type GatewaySettings struct {
// Timezone is an IANA name. It decides the household's local day, which is what the
// schedule rows, the hero rotation, the sign-in history and the first-use
// notifications are all grouped by.
Timezone string `json:"timezone"`
// LogLevel is one of trace, debug, info, warn, error. It takes effect immediately on
// the running process, which is the point of it being here: turning debug on to watch
// something happen is worth nothing if it costs a restart of the thing being watched.
LogLevel string `json:"logLevel"`
// SessionIdleDays is how long a television may go unused before it is signed out.
SessionIdleDays int `json:"sessionIdleDays"`
// SonarrAlertMinutes and RadarrAlertMinutes are how long a "just aired" or "new movie
// added" banner stays on offer to a television that was switched off at the time.
SonarrAlertMinutes int `json:"sonarrAlertMinutes"`
RadarrAlertMinutes int `json:"radarrAlertMinutes"`
2026-08-22 21:47:54 +12:00
// 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"`
2026-08-17 19:09:17 +12:00
// EmbyHealthSeconds is how often the reachability probe asks Emby whether it is there.
EmbyHealthSeconds int `json:"embyHealthSeconds"`
2026-08-19 18:08:00 +12:00
// SlowRequestMillis is how long a request must take before its log line carries a
// stage breakdown — Emby time, database time, cache outcome, gateway work. It is an
// override worth having because the useful threshold is a property of the household
// rather than of the build: an operator chasing one slow screen wants it at 100ms for
// an evening and back at 500 afterwards, and neither is a redeployment.
SlowRequestMillis int `json:"slowRequestMillis"`
2026-08-18 14:59:29 +12:00
// LibrarySyncMinutes is how often the catalogue sweep asks Emby what changed.
//
// It is an override worth having because the answer now depends on the household's
// wiring rather than on the gateway: with both *arr webhooks configured, a new file is
// in the catalogue within a minute of landing and the sweep is reconciliation for
// media Sonarr and Radarr do not manage — six hours rather than one. With no webhooks
// it is still the only way anything is discovered and must stay frequent.
LibrarySyncMinutes int `json:"librarySyncMinutes"`
2026-08-17 19:09:17 +12:00
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy,omitempty"`
}
// GatewaySettingsOff is the value a duration-like override takes to mean "switched off",
// as distinct from the zero that means "use the deployed value".
const GatewaySettingsOff = -1
// GatewayLogLevels is the vocabulary the console offers, in order of severity. It is here
// rather than in the API because normalisation has to refuse a level nothing can parse,
// and refusing is worth nothing if the list it refuses against lives somewhere else.
var GatewayLogLevels = []string{"trace", "debug", "info", "warn", "error"}
2026-08-22 21:47:54 +12:00
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,
}
2026-08-17 19:09:17 +12:00
func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings {
settings.Timezone = strings.TrimSpace(settings.Timezone)
if settings.Timezone != "" {
// A timezone that will not load is dropped rather than stored: the alternative is
// a row that every later read has to fail on, and the failure would surface as a
// household whose idea of "today" quietly reverted with nothing saying why.
if _, err := time.LoadLocation(settings.Timezone); err != nil {
settings.Timezone = ""
}
}
settings.LogLevel = strings.ToLower(strings.TrimSpace(settings.LogLevel))
if settings.LogLevel != "" && !containsString(GatewayLogLevels, settings.LogLevel) {
settings.LogLevel = ""
}
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)
2026-08-22 21:47:54 +12:00
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
}
2026-08-17 19:09:17 +12:00
settings.EmbyHealthSeconds = clampOverride(settings.EmbyHealthSeconds, 10, 3600, true)
2026-08-19 18:08:00 +12:00
// 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
// setting here whose switched-off state costs nothing but a column of text.
settings.SlowRequestMillis = clampOverride(settings.SlowRequestMillis, 1, 60000, true)
2026-08-18 14:59:29 +12:00
// A day is the ceiling rather than a week: however well the webhooks are working, the
// sweep is the only thing that ever notices a file somebody moved by hand.
settings.LibrarySyncMinutes = clampOverride(settings.LibrarySyncMinutes, 5, 24*60, true)
2026-08-17 19:09:17 +12:00
return settings
}
// clampOverride keeps 0 meaning "deployed" and, where the setting can be switched off,
// keeps every negative number meaning off rather than only -1 — an operator typing -5 has
// said the same thing, and storing it verbatim would produce a second value with the same
// meaning that every reader would have to know about.
func clampOverride(value, low, high int, offAllowed bool) int {
if value == 0 {
return 0
}
if value < 0 {
if offAllowed {
return GatewaySettingsOff
}
return 0
}
return max(low, min(value, high))
}
func containsString(values []string, value string) bool {
for _, candidate := range values {
if candidate == value {
return true
}
}
return false
}
func (s *Store) GatewaySettings(ctx context.Context) (GatewaySettings, error) {
var raw []byte
err := s.pool.QueryRow(ctx,
`SELECT value FROM app_settings WHERE key = $1`, GatewaySettingsKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
2026-08-22 21:47:54 +12:00
return normalizeGatewaySettings(GatewaySettings{}), nil
2026-08-17 19:09:17 +12:00
}
if err != nil {
return GatewaySettings{}, fmt.Errorf("store: read gateway settings: %w", err)
}
var settings GatewaySettings
if err := json.Unmarshal(raw, &settings); err != nil {
return GatewaySettings{}, fmt.Errorf("store: decode gateway settings: %w", err)
}
return normalizeGatewaySettings(settings), nil
}
// SetGatewaySettings writes the overrides and returns what was actually stored, because
// normalisation can refuse a value and the console must show what is in force rather than
// what was typed.
func (s *Store) SetGatewaySettings(
ctx context.Context, settings GatewaySettings,
) (GatewaySettings, error) {
settings = normalizeGatewaySettings(settings)
settings.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(settings)
if err != nil {
return GatewaySettings{}, err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
GatewaySettingsKey, string(raw))
if err != nil {
return GatewaySettings{}, fmt.Errorf("store: write gateway settings: %w", err)
}
return settings, nil
}