2026-08-06 22:33:56 +12:00
|
|
|
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
|
2026-08-17 11:41:36 +12:00
|
|
|
// 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
|
2026-08-06 22:33:56 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// record folds one probe result in and reports whether the published verdict changed.
|
2026-08-17 11:41:36 +12:00
|
|
|
func (h *embyHealth) record(ok bool, version string, now time.Time) {
|
2026-08-06 22:33:56 +12:00
|
|
|
h.mu.Lock()
|
|
|
|
|
defer h.mu.Unlock()
|
|
|
|
|
h.state.checkedAt = now
|
2026-08-17 11:41:36 +12:00
|
|
|
if version != "" {
|
|
|
|
|
h.state.version = version
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
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"`
|
2026-08-17 11:41:36 +12:00
|
|
|
// 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"`
|
2026-08-06 22:33:56 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func embyHealthFor(state embyHealthState) embyHealthPayload {
|
|
|
|
|
payload := embyHealthPayload{
|
|
|
|
|
Monitored: state.monitored,
|
|
|
|
|
Reachable: state.reachable || !state.monitored,
|
|
|
|
|
RetrySeconds: int(state.retryEvery / time.Second),
|
2026-08-17 11:41:36 +12:00
|
|
|
Version: state.version,
|
2026-08-06 22:33:56 +12:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|