Auto-stop stale manual recordings

Add heartbeat-aware lifecycle management for manual/live-view recordings. The agent now tracks manual recording start time and viewer heartbeats, auto-stops recordings when heartbeats lapse or a max duration is reached, and clears state on stop/restart. MQTT recording payloads gain a `heartbeat` flag so keep-alives refresh active sessions without unintentionally restarting recordings after auto-stop.
This commit is contained in:
Cédric Verstraeten
2026-07-13 16:29:46 +02:00
parent 1a0e6bf153
commit bd34e9d836
5 changed files with 133 additions and 23 deletions
+60 -3
View File
@@ -50,6 +50,55 @@ func publishRecordingState(mqttClient mqtt.Client, hubKey string, configuration
}
}
const (
// manualRecordingHeartbeatTimeout is how long the agent keeps a manual
// (live-view / remote) recording alive after the LAST viewer heartbeat. The
// frontend re-sends the record command every ~15s while the user stays on the
// page; if several heartbeats are missed (the viewer closed the tab, went idle
// or lost connectivity) the recorder auto-stops the recording so the camera
// doesn't record forever when the "stop" message never arrives.
manualRecordingHeartbeatTimeout = 45 * time.Second
// manualRecordingMaxDuration caps a single manual recording so a forgotten
// record button can't record indefinitely even while the viewer keeps sending
// heartbeats. After this the recording auto-stops and the viewer must press
// record again to continue.
manualRecordingMaxDuration = 5 * time.Minute
)
// manualRecordingExpired reports whether an active manual (live-view) recording
// has outlived its viewer heartbeat window or the maximum duration cap. When it
// has, it clears the manual-recording state (so the motion recorder lets the
// current clip close normally and broadcasts recording:false) and returns true.
// It is a no-op returning false when no manual recording is active.
func manualRecordingExpired(communication *models.Communication, now int64) bool {
if communication.IsRecordingManual.IsNotSet() {
return false
}
manualStart := communication.RecordingManualStart.Load()
maxDurationReached := manualStart > 0 && now-manualStart > manualRecordingMaxDuration.Milliseconds()
// The heartbeat timeout only applies once the viewer has proven it supports
// heartbeats (an older frontend that starts a recording but never heartbeats
// still records up to the max-duration cap instead of being cut off early).
heartbeatExpired := false
if communication.RecordingManualHeartbeatSeen.IsSet() {
lastHeartbeat := communication.RecordingManualHeartbeat.Load()
heartbeatExpired = lastHeartbeat > 0 && now-lastHeartbeat > manualRecordingHeartbeatTimeout.Milliseconds()
}
if !heartbeatExpired && !maxDurationReached {
return false
}
if heartbeatExpired {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): auto-stopping manual recording, no viewer heartbeat within timeout.")
} else {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): auto-stopping manual recording, maximum duration reached.")
}
communication.IsRecordingManual.UnSet()
communication.RecordingManualHeartbeat.Store(0)
communication.RecordingManualStart.Store(0)
communication.RecordingManualHeartbeatSeen.UnSet()
return true
}
func CleanupRecordingDirectory(configDirectory string, configuration *models.Configuration) {
autoClean := configuration.Config.AutoClean
if autoClean != "true" {
@@ -227,6 +276,9 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// Start each capture session with manual recording off, so a leftover
// request from before a restart/reconnect doesn't silently persist.
communication.IsRecordingManual.UnSet()
communication.RecordingManualHeartbeat.Store(0)
communication.RecordingManualStart.Store(0)
communication.RecordingManualHeartbeatSeen.UnSet()
if config.Capture.Recording == "false" {
log.Log.Info("capture.main.HandleRecordStream(): disabled, we will not record anything.")
@@ -674,7 +726,10 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// motion timestamp every iteration so the post-recording timeout
// never fires. The clip still rolls over at maxRecordingPeriod and
// is restarted below, until the viewer stops the manual recording.
if communication.IsRecordingManual.IsSet() {
// It also auto-stops when the viewer's heartbeat lapses (closed page
// or idle) or the max remote-recording duration is reached, so a
// missed "stop" message can't keep the camera recording forever.
if communication.IsRecordingManual.IsSet() && !manualRecordingExpired(communication, now) {
motionTimestamp = now
}
@@ -751,8 +806,10 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// If the viewer still has a manual recording running, this clip just
// rolled over at the max length — immediately kick off the next
// segment so recording stays continuous until they stop it.
if communication.IsRecordingManual.IsSet() {
// segment so recording stays continuous until they stop it. Skip the
// restart when the recording has expired (heartbeat lapsed or max
// duration reached), so it ends here instead of recording forever.
if communication.IsRecordingManual.IsSet() && !manualRecordingExpired(communication, time.Now().UnixMilli()) {
select {
case communication.HandleMotion <- models.MotionDataPartial{Timestamp: time.Now().Unix(), NumberOfChanges: 100000000}:
default:
+3
View File
@@ -76,6 +76,9 @@ func Bootstrap(ctx context.Context, configDirectory string, configuration *model
communication.HandleLiveHLS = make(chan string, 1)
communication.IsConfiguring = abool.New()
communication.IsRecordingManual = abool.New()
communication.RecordingManualHeartbeat = &atomic.Int64{}
communication.RecordingManualStart = &atomic.Int64{}
communication.RecordingManualHeartbeatSeen = abool.New()
cameraSettings := &models.Camera{}
+26 -8
View File
@@ -52,12 +52,30 @@ type Communication struct {
// recorder keeps recording (it does not auto-close on the post-recording
// timeout) until the viewer stops it again. It is independent of motion
// detection so it also works when nothing is moving.
IsRecordingManual *abool.AtomicBool
Queue *packets.Queue
SubQueue *packets.Queue
Image string
CameraConnected bool
MainStreamConnected bool
SubStreamConnected bool
HasBackChannel bool
IsRecordingManual *abool.AtomicBool
// RecordingManualHeartbeat holds the unix-milliseconds timestamp of the last
// heartbeat received from the live view while a manual recording is active.
// The frontend re-sends the record command every few seconds while the user
// stays on the page; if the heartbeats stop (the viewer closed the tab, went
// idle or lost connectivity) the recorder auto-stops the manual recording so
// it can't record forever when the "stop" message never arrives.
RecordingManualHeartbeat *atomic.Int64
// RecordingManualStart holds the unix-milliseconds timestamp at which the
// current manual recording started. It bounds a manual recording to a maximum
// duration (see capture.manualRecordingMaxDuration) so a forgotten record
// button can't record indefinitely even while the viewer stays active.
RecordingManualStart *atomic.Int64
// RecordingManualHeartbeatSeen is set once the current manual recording has
// received at least one heartbeat, i.e. the viewer proved it supports
// heartbeating. Only then does the recorder enforce the heartbeat timeout; a
// viewer that starts a recording but never heartbeats (an older frontend)
// still records up to the max-duration cap instead of being cut off early.
RecordingManualHeartbeatSeen *abool.AtomicBool
Queue *packets.Queue
SubQueue *packets.Queue
Image string
CameraConnected bool
MainStreamConnected bool
SubStreamConnected bool
HasBackChannel bool
}
+9
View File
@@ -154,6 +154,15 @@ type RecordPayload struct {
// recording (and keeps it running), false stops it. Older clients that only
// send a timestamp default to false; the live view always sets it explicitly.
Recording bool `json:"recording"`
// Heartbeat marks a keep-alive re-send (with Recording=true) from a viewer
// that supports heartbeating, as opposed to the initial start (the record
// button). While a user stays on the page the live view re-sends the record
// command every few seconds; the agent uses this flag to (a) refresh the
// recording's keep-alive without restarting an already auto-stopped clip from
// a stray heartbeat, and (b) only enable the heartbeat-timeout auto-stop once
// it has actually seen a heartbeat — so older viewers that never heartbeat
// still record up to the max-duration cap instead of being cut off early.
Heartbeat bool `json:"heartbeat"`
}
// We received a preset position request, we'll request it through onvif and send it back.
+35 -12
View File
@@ -381,23 +381,46 @@ func HandleRecording(mqttClient mqtt.Client, hubKey string, payload models.Paylo
}
if recordPayload.Recording {
// Start a manual recording from the live view (record button). Keep it
// running until the viewer stops it again — the motion recorder honours
// communication.IsRecordingManual and won't auto-close on the
// post-recording timeout while it's set. We also inject a motion event
// so the recording starts immediately, even when nothing is moving.
log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording started.")
communication.IsRecordingManual.Set()
select {
case communication.HandleMotion <- models.MotionDataPartial{Timestamp: timestamp, NumberOfChanges: 100000000}:
default:
log.Log.Warning("routers.mqtt.main.HandleRecording(): motion channel full, manual recording start not queued.")
now := time.Now().UnixMilli()
if recordPayload.Heartbeat {
// Keep-alive from a viewer that supports heartbeats. Only refresh while
// a manual recording is actually running; if it already auto-stopped
// (heartbeat timeout / max duration) we IGNORE it so a stray heartbeat
// can't restart a recording we just ended. Seeing a heartbeat also arms
// the recorder's heartbeat-timeout auto-stop.
if communication.IsRecordingManual.IsSet() {
communication.RecordingManualHeartbeat.Store(now)
communication.RecordingManualHeartbeatSeen.Set()
log.Log.Debug("routers.mqtt.main.HandleRecording(): manual recording heartbeat received.")
} else {
log.Log.Debug("routers.mqtt.main.HandleRecording(): ignoring heartbeat, no active manual recording.")
}
} else {
// Explicit start from the live view (record button). Start a manual
// recording and keep it running — the motion recorder honours
// communication.IsRecordingManual and won't auto-close on the
// post-recording timeout while it's set. We also inject a motion event
// so the recording starts immediately, even when nothing is moving.
communication.RecordingManualHeartbeat.Store(now)
if communication.IsRecordingManual.SetToIf(false, true) {
communication.RecordingManualStart.Store(now)
communication.RecordingManualHeartbeatSeen.UnSet()
log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording started.")
select {
case communication.HandleMotion <- models.MotionDataPartial{Timestamp: timestamp, NumberOfChanges: 100000000}:
default:
log.Log.Warning("routers.mqtt.main.HandleRecording(): motion channel full, manual recording start not queued.")
}
}
}
} else {
// Stop the manual recording; the motion recorder closes the clip once the
// post-recording window elapses.
// post-recording window elapses. Clear the heartbeat/start markers too.
log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording stopped.")
communication.IsRecordingManual.UnSet()
communication.RecordingManualHeartbeat.Store(0)
communication.RecordingManualStart.Store(0)
communication.RecordingManualHeartbeatSeen.UnSet()
}
}