Files
memby/server/internal/api/emby_health.go
T
2026-08-17 19:09:17 +12:00

143 lines
4.9 KiB
Go

package api
import (
"sync"
"time"
)
// The live answer to "is Emby answering right now", as opposed to the one-shot alerts in
// server_alerts.go that announce the moment it changed.
//
// These are two different jobs and both are worth having. An alert is news: it slides in,
// says Emby stopped communicating and goes away, which is right for someone already
// watching. This is *state*: a television switched on twenty minutes into an outage was
// never told anything, and the one thing it can usefully show is a bar saying so and how
// long until the next attempt. It is the reason /v1/status carries it rather than the
// alert list — an alert that has fallen out of its window is gone, and this has not.
const (
// How many probes in a row must fail before the bar appears.
//
// Deliberately lower than embyFailureThreshold, which gates the announcement. The
// announcement is a one-shot that cannot be taken back, so it waits to be sure; the
// bar clears itself the moment Emby answers again, so being early costs a minute of
// a red strip rather than a false claim left standing.
embyOutageThreshold = 2
)
// embyHealth is the probe's finding, cached in memory for the status poll to read. Not
// persisted: it describes this instant, and a value restored from a restart would be a
// claim about a probe that never ran.
type embyHealth struct {
mu sync.RWMutex
state embyHealthState
}
type embyHealthState struct {
// monitored is false when MEMBY_EMBY_HEALTH_INTERVAL turns the probe off. The client
// must then ignore reachable entirely rather than trust a value nothing updates.
monitored bool
reachable bool
since time.Time
checkedAt time.Time
retryEvery time.Duration
consecutive int
// version is what Emby last said it was. Kept across a failed probe on purpose: the
// About page reads it, and blanking it during an outage would replace a fact that is
// still true with nothing.
version string
}
func (h *embyHealth) get() embyHealthState {
h.mu.RLock()
defer h.mu.RUnlock()
return h.state
}
// begin records that the probe is running and how often, so the bar can say when the next
// attempt is due before any probe has completed.
func (h *embyHealth) begin(interval time.Duration, now time.Time) {
h.mu.Lock()
defer h.mu.Unlock()
h.state = embyHealthState{
monitored: true, reachable: true, since: now, retryEvery: interval,
}
}
// retune follows the operator changing the probe's cadence, or switching it off, without
// disturbing what the probe has already found. It is deliberately not `begin`: that
// starts a fresh watch and drops the Emby version with it, which the About page reads and
// which is still true whatever the interval is now.
func (h *embyHealth) retune(interval time.Duration, now time.Time) {
h.mu.Lock()
defer h.mu.Unlock()
if interval <= 0 {
h.state.monitored = false
h.state.retryEvery = 0
return
}
if !h.state.monitored {
h.state = embyHealthState{
monitored: true, reachable: true, since: now, retryEvery: interval,
version: h.state.version,
}
return
}
h.state.retryEvery = interval
}
// record folds one probe result in and reports whether the published verdict changed.
func (h *embyHealth) record(ok bool, version string, now time.Time) {
h.mu.Lock()
defer h.mu.Unlock()
h.state.checkedAt = now
if version != "" {
h.state.version = version
}
if ok {
h.state.consecutive = 0
if !h.state.reachable {
h.state.reachable = true
h.state.since = now
}
return
}
h.state.consecutive++
if h.state.reachable && h.state.consecutive >= embyOutageThreshold {
h.state.reachable = false
// Dated from the probe that crossed the threshold rather than the first failure:
// the bar reports what the server knows, and until the threshold was crossed it
// did not know anything.
h.state.since = now
}
}
// embyHealthPayload is the shape /v1/status carries. Times are RFC3339 so an old client
// that cannot parse them still renders the bar from reachable and retrySeconds alone.
type embyHealthPayload struct {
Monitored bool `json:"monitored"`
Reachable bool `json:"reachable"`
Since string `json:"since,omitempty"`
CheckedAt string `json:"checkedAt,omitempty"`
RetrySeconds int `json:"retrySeconds"`
// Version is Emby's own, for the television's About page. Omitted rather than sent
// empty, so a client can tell "not known yet" from "known to be blank" — the first
// probe may not have completed, and with the probe switched off none ever will.
Version string `json:"version,omitempty"`
}
func embyHealthFor(state embyHealthState) embyHealthPayload {
payload := embyHealthPayload{
Monitored: state.monitored,
Reachable: state.reachable || !state.monitored,
RetrySeconds: int(state.retryEvery / time.Second),
Version: state.version,
}
if !state.since.IsZero() {
payload.Since = state.since.UTC().Format(time.RFC3339)
}
if !state.checkedAt.IsZero() {
payload.CheckedAt = state.checkedAt.UTC().Format(time.RFC3339)
}
return payload
}