Files
memby/server/internal/store/gateway_settings.go
T
2026-08-28 23:00:02 +12:00

218 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
//
// 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. 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
// 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"`
// 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
// 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"`
// 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"`
// HomeTTLSeconds and RecommendTTLHours are how long the launcher payload and the
// personalised recommendation pool stay cached. They cannot be switched off — a TTL of
// nothing means every home load rebuilds — so zero keeps meaning "deployed" and any
// positive value is the override.
HomeTTLSeconds int `json:"homeTtlSeconds"`
RecommendTTLHours int `json:"recommendTtlHours"`
// ForYouRebuildHour is the household-local hour (023) the daily For You rebuild is due
// at. It is a pointer because 0 is a legitimate hour, so nil — not zero — is what means
// "whatever was deployed".
ForYouRebuildHour *int `json:"forYouRebuildHour,omitempty"`
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"}
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 != "" {
// 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)
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
// setting here whose switched-off state costs nothing but a column of text.
settings.SlowRequestMillis = clampOverride(settings.SlowRequestMillis, 1, 60000, true)
// 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)
settings.HomeTTLSeconds = clampOverride(settings.HomeTTLSeconds, 5, 3600, false)
settings.RecommendTTLHours = clampOverride(settings.RecommendTTLHours, 1, 168, false)
if settings.ForYouRebuildHour != nil {
hour := *settings.ForYouRebuildHour
if hour < 0 || hour > 23 {
// An hour outside the clock is dropped rather than clamped: 25 is a typo, and
// silently reading it as 23 would run the rebuild at a time nobody asked for.
settings.ForYouRebuildHour = nil
}
}
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) {
return normalizeGatewaySettings(GatewaySettings{}), nil
}
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
}