mirror of
https://github.com/kerberos-io/agent.git
synced 2026-10-19 17:38:25 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user