Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,23 +234,32 @@ func (s *Server) recordings() *recording.Manager { return s.mgr.Recordings() }
// explain a recorder that stopped on its own -- see the field comment on
// recording.DiskUsage.Storage.
//
// The default engine's, because this endpoint is unscoped and that is the
// programme it speaks for everywhere else. On a real install the answer is the
// same whichever engine is asked: one volume, one floor, one install-wide
// Recording block (see engine.effectiveSettings, which overlays the ingest and
// nothing else).
//
// No engine means the zero verdict, and that is the TRUE answer rather than a
// fallback: nothing has been halted because nothing was recording.
// ANY ENGINE THAT HAS HALTED, not the default one (#579). The floor is
// install-wide -- one volume, one free-space limit, one Recording block, see
// engine.effectiveSettings -- but the HALT is not: it is one recorder child
// being stopped by the guard on THAT engine's own recording manager. On a
// two-programme install where programme 2 is recording and programme 1 is not,
// asking the default engine reported the zero verdict while programme 2's
// recorder had already been stopped: recording had halted and the one endpoint
// whose job is to explain a recorder that stopped on its own said nothing had.
//
// The FIRST halt in display order wins rather than a merge, because the
// question the banner asks is "has the floor stopped recording", which is
// answered by one programme having stopped, and Reason is a sentence for a
// human rather than a set to union.
//
// No engine, or no engine halted, means the zero verdict, and that is the TRUE
// answer rather than a fallback: nothing has been halted.
func (s *Server) storageVerdict() recording.StorageState {
if s.mgr == nil {
return recording.StorageState{}
}
e := s.mgr.Default()
if e == nil {
return recording.StorageState{}
for _, e := range s.mgr.Engines() {
if st := e.Recordings().Storage(); st.Halted {
return st
}
}
return e.Recordings().Storage()
return recording.StorageState{}
}

// Server wires the HTTP layer to everything else.
Expand Down
12 changes: 12 additions & 0 deletions internal/api/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,18 @@ func (s *Server) publishAudit(ev alerts.Event) {
}
eng := s.eng()
if eng == nil {
// SAID OUT LOUD, because the events that reach here when no engine is
// running are the security ones -- auditLoginFailed above all -- and an
// install whose engines failed to build is exactly when an operator
// most needs to know that repeated failed sign-ins went unreported.
// Dropping them was correct; dropping them in silence was the defect
// (#576): the alert rule is configured, the endpoint is healthy, and
// nothing anywhere says why nothing arrived.
//
// Debug, not Warn: on a fresh install with no source yet this is the
// normal state and every login would log a warning nobody can act on.
s.log.Debug("audit event not published: this install has no running programme",
"type", ev.Type)
return
}
// Notifier.Publish is nil-receiver safe, so an engine that has one but has
Expand Down
115 changes: 106 additions & 9 deletions internal/api/automation.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,11 @@ func (s *Server) handleTestAlertRule(w http.ResponseWriter, r *http.Request) {
writeStoreError(w, err)
return
}
n := s.eng().Alerts()
// engOrNil, not eng(): this route carries no requireSource, so it is reached
// on a build with no manager at all and eng() would panic inside
// Manager.Default before there is an engine to test. Read ONCE and use the
// answer.
n := s.engOrNil().Alerts()
if n == nil {
// IT IS THE NO-SOURCE REFUSAL WEARING A SUBSYSTEM'S NAME. Engine.New
// always builds an alerter, so Alerts() answers nil for exactly one
Expand Down Expand Up @@ -222,10 +226,50 @@ func (s *Server) handleAlertsMeta(w http.ResponseWriter, r *http.Request) {
"maxNameLen": alerts.MaxRuleNameLen,
"maxUrlLen": alerts.MaxURLLen,
},
"stats": s.eng().Alerts().Stats(),
"stats": s.alertStats(),
})
}

// alertStats is the delivery counters for the WHOLE INSTALL.
//
// EVERY notifier, not the default engine's. Alert rules are install-wide -- one
// alert_rules table, read by every engine's notifier -- but the counters are
// not: each notifier keeps its own. So the rule editor's "sent / failed /
// coalesced" panel showed roughly one programme's share of the truth on a
// two-programme install, with nothing saying it was a share. Summing is the
// only reading that matches what the panel claims to describe, which is what
// this install has delivered.
//
// The two non-counters are handled as what they are: LastSent is the most
// recent across notifiers, and LastError the most recent non-empty one, because
// "when did anything last get through" and "what went wrong last" are questions
// about the install, not about a programme.
func (s *Server) alertStats() alerts.Stats {
var out alerts.Stats
if s.mgr == nil {
return out
}
var errAt time.Time
for _, e := range s.mgr.Engines() {
st := e.Alerts().Stats()
out.Queued += st.Queued
out.Dropped += st.Dropped
out.Coalesced += st.Coalesced
out.Pending += st.Pending
out.Sent += st.Sent
out.Failed += st.Failed
out.Retries += st.Retries
out.Deferred += st.Deferred
if st.LastSent.After(out.LastSent) {
out.LastSent = st.LastSent
}
if st.LastError != "" && (errAt.IsZero() || st.LastSent.After(errAt)) {
out.LastError, errAt = st.LastError, st.LastSent
}
}
return out
}

// ---------------------------------------------------------------- schedules

// scheduleView is a stored schedule plus the two things only the server can
Expand Down Expand Up @@ -456,8 +500,20 @@ func (s *Server) handleScheduleRuns(w http.ResponseWriter, r *http.Request) {
// capturer, which knows its own retention, and only falls back to counting the
// directory. With no engine there is nothing to ask, so the count comes off
// disk against the same defaults engine.New would have configured.
//
// WHICH capturer is asked is not a detail, which is why the preference goes
// through scopedEngine (#578). The file list is install-wide and complete
// either way -- one clips directory -- but the retention window and the buffer
// state come from a capturer, and on a two-programme install where only Studio
// B's buffer is on, the default engine answered with a window nothing was
// enforcing. scopedEngine keeps the zero- and one-source answer exactly as it
// was, which is what lets this route go on serving an operator who has just
// deleted their last source and is trying to get their material off the box.
func (s *Server) handleListClips(w http.ResponseWriter, r *http.Request) {
e := s.engOrNil()
e, ok := s.scopedEngine(w, r)
if !ok {
return
}

var (
list []clips.Clip
Expand Down Expand Up @@ -496,7 +552,19 @@ func (s *Server) handleListClips(w http.ResponseWriter, r *http.Request) {
})
}

// handleCaptureClip writes a file off one programme's rolling buffer.
//
// SCOPED BECAUSE IT WRITES, and because what it writes is unrecoverable: the
// operator watching Studio B pressed the button, got a 201 and a .ts of MAIN's
// output, under a filename that names no programme and an audit event that
// names none either. The buffer is rolling, so by the time anybody notices, the
// moment they wanted has aged out of Studio B's buffer and cannot be captured
// again.
func (s *Server) handleCaptureClip(w http.ResponseWriter, r *http.Request) {
eng, ok := s.scopedEngine(w, r)
if !ok {
return
}
var req struct {
// Seconds <= 0 means the whole window, which is what the big button
// sends; longer than the window is clamped by the capturer, not refused.
Expand All @@ -505,7 +573,7 @@ func (s *Server) handleCaptureClip(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &req) {
return
}
clip, err := s.eng().Clip(req.Seconds)
clip, err := eng.Clip(req.Seconds)
switch {
case errors.Is(err, clips.ErrEmpty):
// 409, not 500: nothing is wrong, there is simply no history yet. The
Expand All @@ -524,7 +592,18 @@ func (s *Server) handleCaptureClip(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]any{"clip": clip})
}

// handleSetClipBuffer starts or stops one programme's rolling buffer, which
// spends disk and CPU for as long as it is on.
//
// Unscoped, turning capture on for Studio B started MAIN's buffer, returned
// Main's ClipBuffer() as the confirmation, and left Studio B's off -- so the
// next press of the clip button answered 409 "the clip buffer is empty" for a
// feature the operator had just switched on and been told was on.
func (s *Server) handleSetClipBuffer(w http.ResponseWriter, r *http.Request) {
eng, ok := s.scopedEngine(w, r)
if !ok {
return
}
var req struct {
Enabled bool `json:"enabled"`
// 0 keeps the current window, so a page that only toggles the switch
Expand All @@ -534,11 +613,13 @@ func (s *Server) handleSetClipBuffer(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &req) {
return
}
if err := s.eng().SetClipBuffer(req.Enabled, req.WindowSeconds); err != nil {
if err := eng.SetClipBuffer(req.Enabled, req.WindowSeconds); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, s.eng().ClipBuffer())
// The SAME engine that was just written to. Two reaches here would let the
// response confirm a programme other than the one that changed.
writeJSON(w, http.StatusOK, eng.ClipBuffer())
}

// handleDeleteClip removes a file from disk, so it answers with no source for
Expand Down Expand Up @@ -601,8 +682,15 @@ func (s *Server) handleDownloadClip(w http.ResponseWriter, r *http.Request) {

// ----------------------------------------------------------------- loudness

// handleLoudness is the compliance read, and a compliance figure attributed to
// the wrong programme is the worst kind of wrong number: a broadcaster reads a
// PASSING verdict for a programme nothing measured.
func (s *Server) handleLoudness(w http.ResponseWriter, r *http.Request) {
reports := s.eng().Loudness()
eng, ok := s.scopedEngine(w, r)
if !ok {
return
}
reports := eng.Loudness()
if reports == nil {
reports = []meters.Report{}
}
Expand All @@ -611,7 +699,7 @@ func (s *Server) handleLoudness(w http.ResponseWriter, r *http.Request) {
// no way to seed its Monitor switch and seeded it `true`, so a remount
// drew the switch ON over a monitor that was off -- and then explained
// the empty list as "nothing to measure yet".
"enabled": s.eng().LoudnessMonitorEnabled(),
"enabled": eng.LoudnessMonitorEnabled(),
"reports": reports,
"bounds": map[string]float64{
"toleranceLu": meters.ToleranceLU,
Expand All @@ -622,14 +710,23 @@ func (s *Server) handleLoudness(w http.ResponseWriter, r *http.Request) {
})
}

// handleSetLoudnessMonitor starts or stops a real FFmpeg analyser child.
//
// Unscoped, turning monitoring on for Studio B started MAIN's analyser tier --
// a process on a programme nobody asked about -- and answered {"enabled":true}.
// Studio B was never measured, so its compliance verdict never existed at all.
func (s *Server) handleSetLoudnessMonitor(w http.ResponseWriter, r *http.Request) {
eng, ok := s.scopedEngine(w, r)
if !ok {
return
}
var req struct {
Enabled bool `json:"enabled"`
}
if !decodeJSON(w, r, &req) {
return
}
if err := s.eng().SetLoudnessMonitor(req.Enabled); err != nil {
if err := eng.SetLoudnessMonitor(req.Enabled); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
Expand Down
23 changes: 21 additions & 2 deletions internal/api/clips.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,18 +199,37 @@ func (s *Server) clipSegmentView(p clipPart) clipSegmentView {
// listing six tracks a recording does not have is a worse answer than listing
// none. The measured count from the index is unaffected either way.
func (s *Server) clipTracks(tl clipTimeline) []clipTrackView {
// THE CLIP'S OWN PROGRAMME, not the default one. The recording row carries
// source_id, and reading it is what stops a clip cut from Studio B being
// labelled with Main's track names -- an operator reading "Presenter mic"
// over somebody else's channel. Nil for a row written before sources
// existed, and the default is the honest answer there because there was
// only ever one programme to have recorded it.
eng := s.engineForSource(tl.anchor.SourceID)
if eng == nil {
eng = s.engOrNil()
}

n := tl.anchor.Tracks
if n <= 0 {
if src, known := s.engOrNil().SourceKnown(); known {
if src, known := eng.SourceKnown(); known {
n = len(src.Tracks)
}
}
if n <= 0 {
return []clipTrackView{}
}

// Annotations from the programme that recorded it, falling back to the
// settings mirror. handlePutAnnotations writes that mirror for the default
// programme only, so on a multi-source install it names Main's tracks
// whatever the clip actually holds.
byIndex := map[int]routing.TrackAnnotation{}
if settings, err := s.store.GetSettings(); err == nil {
if src, known := eng.SourceKnown(); known && len(src.Annotations) > 0 {
for _, a := range src.Annotations {
byIndex[a.Track] = a
}
} else if settings, err := s.store.GetSettings(); err == nil {
for _, a := range settings.Ingest.Annotations {
byIndex[a.Track] = a
}
Expand Down
Loading
Loading