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) { if s.quietTimeActive() { s.quietTimeUnavailable(w) return } 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() quiet := s.quietTimeStatus(time.Now()) if quiet.Active { state.Enabled = true state.Message = quiet.Message } 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 { published, sonarr := s.publishedAlerts(r.Context()), s.sonarrAiredAlerts(r.Context()) if len(published) > 0 || len(sonarr) > 0 { prefs, err := s.notificationPreferencesFor(r.Context(), sess.EmbyUserID) if err != nil { s.loggerFor(r.Context()).Warn("notification preferences unavailable", "error", err) alerts = mergeAlerts(published, sonarr) } else { // Filter before trimming so three muted Sonarr stories cannot crowd a // permitted Radarr or service notice out of the small client queue. alerts = mergeAlerts( filterClientAlerts(published, prefs), filterClientAlerts(sonarr, prefs), ) } } } compatible, compatibilityMessage := compatibilityFor(r) featurePolicy := s.currentFeaturePolicy(r.Context()) metadataHero := s.metadataHeroSettings.get() writeJSON(w, http.StatusOK, map[string]any{ "maintenance": state.Enabled, "quietTime": quiet.Active, "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 small metadata hero presentation document rides the existing poll so a saved // layout or colour reaches an open launcher immediately, without manufacturing a // per-user preference revision for a global change. "metadataHeroContentOrder": metadataHero.ContentOrder, "metadataHeroTimeRemainingColour": metadataHero.TimeRemainingColour, // 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)), // The hero, as one opaque revision rather than the cards themselves — the theme // precedent, for the same reason. A television refetches its heroes only when this // moves, which is what turns an operator pinning a title into a change that arrives // on the poll the set is already making rather than at the next cold start. It also // keys the cached answers, so a revision that has moved always names a fresh build. "hero": heroStatus(heroRevision( s.currentHeroPolicy(r.Context()), sess.EmbyUserID, time.Now(), s.heroLocation(), )), // Whether this viewer may ask the household for titles. Per person rather than per // household, so it cannot ride the feature map beside it: the allowlist is the // operator's decision about one account, and every television is polling this // anyway. It is what keeps the Requests entry out of the switcher for everybody // else — the handlers refuse regardless, but a menu item that only ever produces a // refusal is worse than no menu item. "requests": map[string]any{"allowed": s.requestAllowed(r, sess)}, }) }