package api import ( "context" "net/http" "sync" "time" "github.com/ponzischeme89/memby/server/internal/buildinfo" "github.com/ponzischeme89/memby/server/internal/store" ) // maintenanceState caches the operator switch in memory so the hot path never queries // Postgres, while Postgres stays the source of truth across restarts. type maintenanceState struct { mu sync.RWMutex value store.Maintenance } func (m *maintenanceState) get() store.Maintenance { m.mu.RLock() defer m.mu.RUnlock() return m.value } func (m *maintenanceState) set(value store.Maintenance) { m.mu.Lock() defer m.mu.Unlock() m.value = value } // LoadMaintenance primes the cached switch. Called at boot, and after every toggle. func (s *Server) LoadMaintenance(ctx context.Context) error { state, err := s.store.Maintenance(ctx) if err != nil { return err } s.maintenance.set(state) if state.Enabled { s.log.Warn("starting in maintenance mode", "component", "maintenance", "message", state.Message) } return nil } // WatchMaintenance re-reads the switch periodically, so a change made directly in the // database (or by another instance) is picked up without a restart. func (s *Server) WatchMaintenance(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.LoadMaintenance(ctx); err != nil { s.log.Warn("maintenance refresh failed", "component", "maintenance", "error", err) } } } } // maintenanceGate turns the whole client API off, independently of Emby. // // 503 with a machine-readable `maintenance: true` so the TV can show the operator's // message rather than a generic network error. Admin routes and health checks are // deliberately outside this gate — you need them most while the app is down. func (s *Server) maintenanceGate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { state := s.maintenance.get() if !state.Enabled { next.ServeHTTP(w, r) return } message := state.Message if message == "" { message = store.DefaultMaintenanceMessage } // Retry-After keeps well-behaved clients from hammering a service that has // already said it is unavailable. w.Header().Set("Retry-After", "300") writeJSON(w, http.StatusServiceUnavailable, map[string]any{ "error": message, "maintenance": true, "message": message, }) }) } // handleServiceStatus is the live control channel clients poll while the app is open. // It sits outside maintenanceGate so maintenance can interrupt playback rather than only // being discovered the next time a content request happens to run. // It also carries informational alerts, because this poll is the one thing the app is // already listening to — a push channel would be a second connection for a banner. func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, sess store.Session) { state := s.maintenance.get() message := state.Message if state.Enabled && message == "" { message = store.DefaultMaintenanceMessage } alerts := []clientAlert{} // Nothing to celebrate while the service is down, and the client is showing the // maintenance screen anyway. if !state.Enabled { if found := mergeAlerts( s.publishedAlerts(r.Context()), s.sonarrAiredAlerts(r.Context()), ); len(found) > 0 { alerts = found } } compatible, compatibilityMessage := compatibilityFor(r) featurePolicy := s.currentFeaturePolicy(r.Context()) writeJSON(w, http.StatusOK, map[string]any{ "maintenance": state.Enabled, "message": message, "alerts": alerts, "compatible": compatible, "compatibilityMessage": compatibilityMessage, "clientVersion": clientVersion(r), "clientProtocol": clientProtocol(r), "gatewayVersion": buildinfo.Version(), "serverProtocol": ProtocolVersion, "featureSchemaVersion": featureSchemaVersion, "featureRevision": featurePolicy.Revision, "safeMode": featurePolicy.SafeMode, "features": featureMap(featurePolicy, clientProtocolNumber(r), clientCapabilities(r)), // Whether Emby itself is answering, as distinct from whether Memby is. The TV // direct-plays from Emby, so this is the difference between "the film stopped for // no reason" and a bar saying what happened and when the next attempt is due. "emby": embyHealthFor(s.embyHealth.get()), // One number, not the document: the TV compares it with what it has and only // fetches /v1/preferences when they differ. That is what turns this poll into the // delivery channel for an operator pushing someone's settings. "preferencesRevision": s.preferenceRevisionFor(r, sess), // The theme, as an id and a revision rather than the palette itself — the // preferencesRevision precedent, for the same reason. The set refetches /v1/theme // only when one of these moves, which is what makes a season arriving at midnight // cost one request per television instead of a palette on every ten-second poll. // It rides the poll rather than the sign-in because that is the whole point: a // season has to reach a set that is already switched on, without anybody doing // anything. "theme": themeStatus(s.themeFor(r.Context(), sess)), }) }