238 lines
8.1 KiB
Go
238 lines
8.1 KiB
Go
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)
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
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),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|