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"` 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) 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 }