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

208 lines
6.8 KiB
Go
Raw Normal View History

2026-08-17 19:09:17 +12:00
package api
import (
"context"
"log/slog"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/store"
)
// gatewaySettingsState caches the operator's overrides in memory, the way maintenance is
// cached: several of these are read on the request path — the household's timezone is
// read by every home response — and none of them is worth a query.
type gatewaySettingsState struct {
mu sync.RWMutex
value store.GatewaySettings
}
func (g *gatewaySettingsState) get() store.GatewaySettings {
g.mu.RLock()
defer g.mu.RUnlock()
return g.value
}
func (g *gatewaySettingsState) set(value store.GatewaySettings) {
g.mu.Lock()
defer g.mu.Unlock()
g.value = value
}
// LoadGatewaySettings primes the cache and applies the settings that live in the running
// process rather than being read where they are used. Called at boot and after a write.
func (s *Server) LoadGatewaySettings(ctx context.Context) error {
if s.store == nil {
return nil
}
settings, err := s.store.GatewaySettings(ctx)
if err != nil {
return err
}
s.gatewaySettings.set(settings)
s.applyLogLevel(settings.LogLevel)
return nil
}
// WatchGatewaySettings re-reads the overrides periodically, so a change made directly in
// the database — or by another instance — is picked up without a restart. The same reason
// WatchMaintenance exists.
func (s *Server) WatchGatewaySettings(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.LoadGatewaySettings(ctx); err != nil {
s.log.Warn("gateway settings refresh failed",
"component", "settings", "error", err)
}
}
}
}
// applyLogLevel moves the running process's log level. It is a no-op on a gateway wired
// without a level variable — every unit test in this package — and an empty override
// restores the level the container was started with, which is what makes clearing the
// setting in the console a real undo rather than a value the operator has to remember.
func (s *Server) applyLogLevel(level string) {
if s.logLevel == nil {
return
}
if level == "" {
s.logLevel.Set(s.deployedLogLevel)
return
}
s.logLevel.Set(logging.ParseLevel(level))
}
// --- effective values -------------------------------------------------------
//
// Each of these is "the override, or what was deployed". They are the only readers of the
// cached document, so a setting is added by adding one of these beside its config field
// rather than by teaching every call site that an override exists.
// householdLocation is the household's idea of what day it is. Everything that groups by
// a local day reads it: the schedule rows, the hero rotation, sign-in history and the
// first-use notification.
func (s *Server) householdLocation() *time.Location {
if name := s.gatewaySettings.get().Timezone; name != "" {
if location, err := time.LoadLocation(name); err == nil {
return location
}
}
if s.cfg.SonarrLocation != nil {
return s.cfg.SonarrLocation
}
return time.Local
}
// householdTimezoneName is what the console and the sign-in history print. It names the
// zone in force rather than the one deployed, so a page cannot claim a grouping that is
// not the one the rows were grouped by.
func (s *Server) householdTimezoneName() string {
return s.householdLocation().String()
}
func (s *Server) sessionIdleExpiry() time.Duration {
if days := s.gatewaySettings.get().SessionIdleDays; days > 0 {
return time.Duration(days) * 24 * time.Hour
}
return s.cfg.SessionIdleExpiry
}
func (s *Server) sonarrAlertWindow() time.Duration {
return overrideWindow(s.gatewaySettings.get().SonarrAlertMinutes, time.Minute,
s.cfg.SonarrAlertWindow)
}
func (s *Server) radarrAlertWindow() time.Duration {
return overrideWindow(s.gatewaySettings.get().RadarrAlertMinutes, time.Minute,
s.cfg.RadarrAlertWindow)
}
func (s *Server) embyHealthInterval() time.Duration {
return overrideWindow(s.gatewaySettings.get().EmbyHealthSeconds, time.Second,
s.cfg.EmbyHealthInterval)
}
2026-08-18 14:59:29 +12:00
// LibrarySyncInterval is how often the catalogue sweep runs. Exported because the syncer's
// schedule reads it every tick rather than closing over it at start-up — a setting read
// once at start-up is not a setting, and an operator lengthening the sweep after wiring up
// the webhooks must not have to restart the container to see it take effect.
func (s *Server) LibrarySyncInterval() time.Duration {
return overrideWindow(s.gatewaySettings.get().LibrarySyncMinutes, time.Minute,
s.cfg.SyncInterval)
}
2026-08-17 19:09:17 +12:00
// overrideWindow reads one of the three settings that can be switched off: a negative
// value is off, zero is "whatever was deployed", anything else is the override in the
// given unit.
func overrideWindow(value int, unit, deployed time.Duration) time.Duration {
switch {
case value < 0:
return 0
case value > 0:
return time.Duration(value) * unit
default:
return deployed
}
}
// 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.
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"`
2026-08-18 14:59:29 +12:00
LibrarySyncMinutes int `json:"librarySyncMinutes"`
2026-08-17 19:09:17 +12:00
}
func (s *Server) deployedSettings() deployedGatewaySettings {
timezone := ""
if s.cfg.SonarrLocation != nil {
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),
2026-08-18 14:59:29 +12:00
LibrarySyncMinutes: int(s.cfg.SyncInterval / time.Minute),
2026-08-17 19:09:17 +12:00
}
}
func levelName(level slog.Level) string {
switch {
case level < slog.LevelDebug:
return "trace"
case level < slog.LevelInfo:
return "debug"
case level < slog.LevelWarn:
return "info"
case level < slog.LevelError:
return "warn"
default:
return "error"
}
}
2026-08-18 14:59:29 +12:00
// ingestSettle is what the console prints beside the webhook activity, and it is a helper
// for the same reason the others here are: the delay is configuration, and the page must
// report the value actually in force rather than the constant it defaults to.
func (s *Server) ingestSettle() time.Duration {
if s.cfg.IngestSettleDelay > 0 {
return s.cfg.IngestSettleDelay
}
return time.Minute
}