package api import ( "context" "log/slog" "math" "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) } // notificationDisplay is the household-wide banner policy. The store normalises the // cached value, and this fallback keeps tests and a not-yet-primed server safe too. func (s *Server) notificationDisplay() string { switch display := s.gatewaySettings.get().NotificationDisplay; display { case store.NotificationDisplayEverywhere, store.NotificationDisplayOff: return display default: return store.NotificationDisplayHomeOnly } } // slowRequestThreshold is the line between a request that logs a breakdown and one that // does not. Switched off it returns a duration no request can exceed rather than zero, // because zero would mean *every* request carried one — the opposite of what the operator // asked for. func (s *Server) slowRequestThreshold() time.Duration { threshold := overrideWindow(s.gatewaySettings.get().SlowRequestMillis, time.Millisecond, s.cfg.SlowRequestThreshold) if threshold <= 0 { return math.MaxInt64 } return threshold } func (s *Server) embyHealthInterval() time.Duration { return overrideWindow(s.gatewaySettings.get().EmbyHealthSeconds, time.Second, s.cfg.EmbyHealthInterval) } // 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) } // homeTTL and recommendTTL are "the override, or what was deployed" for the two cache // lifetimes an operator can reach. They cannot be switched off, so a non-positive stored // value falls through to the deployed duration. func (s *Server) homeTTL() time.Duration { if seconds := s.gatewaySettings.get().HomeTTLSeconds; seconds > 0 { return time.Duration(seconds) * time.Second } return s.cfg.HomeTTL } func (s *Server) recommendTTL() time.Duration { if hours := s.gatewaySettings.get().RecommendTTLHours; hours > 0 { return time.Duration(hours) * time.Hour } return s.cfg.RecommendTTL } // forYouRebuildHour is the household-local hour the daily For You rebuild is due at. func (s *Server) forYouRebuildHour() int { if hour := s.gatewaySettings.get().ForYouRebuildHour; hour != nil { return *hour } return s.cfg.ForYouRebuildHour } // 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 the defaults a cleared field falls back to: deployed // values for environment-backed settings, and Home only for the native notification // policy. Deliberately not the same shape as the stored document: 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"` NotificationDisplay string `json:"notificationDisplay"` EmbyHealthSeconds int `json:"embyHealthSeconds"` SlowRequestMillis int `json:"slowRequestMillis"` LibrarySyncMinutes int `json:"librarySyncMinutes"` HomeTTLSeconds int `json:"homeTtlSeconds"` RecommendTTLHours int `json:"recommendTtlHours"` ForYouRebuildHour int `json:"forYouRebuildHour"` } 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), NotificationDisplay: store.NotificationDisplayHomeOnly, EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second), SlowRequestMillis: int(s.cfg.SlowRequestThreshold / time.Millisecond), LibrarySyncMinutes: int(s.cfg.SyncInterval / time.Minute), HomeTTLSeconds: int(s.cfg.HomeTTL / time.Second), RecommendTTLHours: int(s.cfg.RecommendTTL / time.Hour), ForYouRebuildHour: s.cfg.ForYouRebuildHour, } } 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" } } // 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 }