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