From 57cfcc3c4afad8f76cdbd9f726daed246c1d6aff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 01:46:22 +0000 Subject: [PATCH 01/32] Initial plan From 1be352c79074a4ab8ce431a9215e07e0613b5002 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 01:58:48 +0000 Subject: [PATCH 02/32] fix: add stopped state for CMD_STOP, fixing paused=true response bug When CMD_STOP is sent via AJAX using ZMS MJPEG streaming, the response was incorrectly returning paused=true instead of indicating a stopped state (paused=false). Changes: - Add `stopped` boolean to StreamBase (zm_stream.h) - MonitorStream: CMD_STOP now sets stopped=true, paused=false instead of paused=true; run loop skips frame sending when stopped - EventStream: CMD_STOP sets stopped=true (was already setting paused=false); run loop skips frame sending when stopped - All other play/pause commands reset stopped=false - Both streams include stopped field in the status response struct - stream.php unpacks the new stopped field from MSG_DATA_WATCH and MSG_DATA_EVENT responses - MonitorStream.js handles stopped status in UI (shows 'Stopped' mode) - EventStream.js tracks stopped state from server response Fixes issue: CMD_STOP response paused=true should be paused=false Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/ba9cb47a-a3e8-4e13-aec7-c9cd258e2a3d Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- src/zm_eventstream.cpp | 18 ++++++++++++++++-- src/zm_monitorstream.cpp | 21 +++++++++++++++++++-- src/zm_stream.h | 2 ++ web/ajax/stream.php | 6 +++--- web/js/EventStream.js | 6 +++++- web/js/MonitorStream.js | 7 ++++++- 6 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/zm_eventstream.cpp b/src/zm_eventstream.cpp index 75e24cb96..93d4e9e06 100644 --- a/src/zm_eventstream.cpp +++ b/src/zm_eventstream.cpp @@ -449,10 +449,12 @@ void EventStream::processCommand(const CmdMsg *msg) { switch ((MsgCommand)msg->msg_data[0]) { case CMD_PAUSE : Debug(1, "Got PAUSE command"); + stopped = false; paused = true; break; case CMD_PLAY : { Debug(1, "Got PLAY command"); + stopped = false; paused = false; // If we are in single event mode and at the last frame, replay the current event @@ -476,6 +478,7 @@ void EventStream::processCommand(const CmdMsg *msg) { } case CMD_VARPLAY : { Debug(1, "Got VARPLAY command"); + stopped = false; paused = false; replay_rate = ntohs(((unsigned char)msg->msg_data[2]<<8)|(unsigned char)msg->msg_data[1])-32768; if (replay_rate > 50 * ZM_RATE_BASE) { @@ -489,10 +492,12 @@ void EventStream::processCommand(const CmdMsg *msg) { } case CMD_STOP : Debug(1, "Got STOP command"); + stopped = true; paused = false; break; case CMD_FASTFWD : { Debug(1, "Got FAST FWD command"); + stopped = false; paused = false; // Set play rate switch (replay_rate) { @@ -517,6 +522,7 @@ void EventStream::processCommand(const CmdMsg *msg) { break; } case CMD_SLOWFWD : { + stopped = false; paused = true; replay_rate = ZM_RATE_BASE; step = 1; @@ -526,6 +532,7 @@ void EventStream::processCommand(const CmdMsg *msg) { break; } case CMD_SLOWREV : { + stopped = false; paused = true; replay_rate = ZM_RATE_BASE; step = -1; @@ -535,6 +542,7 @@ void EventStream::processCommand(const CmdMsg *msg) { } case CMD_FASTREV : Debug(1, "Got FAST REV command"); + stopped = false; paused = false; // Set play rate switch (replay_rate) { @@ -693,6 +701,7 @@ void EventStream::processCommand(const CmdMsg *msg) { int zoom; int scale; bool paused; + bool stopped; } status_data = {}; { @@ -707,6 +716,7 @@ void EventStream::processCommand(const CmdMsg *msg) { status_data.zoom = zoom; status_data.scale = scale; status_data.paused = paused; + status_data.stopped = stopped; FPSeconds elapsed = now - last_fps_update; if (elapsed.count() > 0) { @@ -719,10 +729,11 @@ void EventStream::processCommand(const CmdMsg *msg) { status_data.fps = actual_fps; - Debug(2, "Event:%" PRIu64 ", Duration %f, Paused:%d, progress:%f Rate:%d, Zoom:%d Scale:%d", + Debug(2, "Event:%" PRIu64 ", Duration %f, Paused:%d, Stopped:%d, progress:%f Rate:%d, Zoom:%d Scale:%d", status_data.event_id, FPSeconds(status_data.duration).count(), status_data.paused, + status_data.stopped, FPSeconds(status_data.progress).count(), status_data.rate, status_data.zoom, @@ -1037,7 +1048,10 @@ void EventStream::runStream() { send_frame = false; TimePoint::duration time_since_last_send = now - last_frame_sent; - if (!paused) { + if (stopped) { + // In stopped state, do nothing except wait for a new command + send_frame = false; + } else if (!paused) { // Figure out if we should send this frame Debug(3, "not paused at curr_frame_id (%d-1) mod frame_mod(%d)", curr_frame_id, frame_mod); // If we are streaming and this frame is due to be sent diff --git a/src/zm_monitorstream.cpp b/src/zm_monitorstream.cpp index 68532864c..6cb7f625e 100644 --- a/src/zm_monitorstream.cpp +++ b/src/zm_monitorstream.cpp @@ -92,12 +92,14 @@ void MonitorStream::processCommand(const CmdMsg *msg) { switch ((MsgCommand)msg->msg_data[0]) { case CMD_PAUSE : Debug(1, "Got PAUSE command"); + stopped = false; paused = true; delayed = true; last_frame_sent = now; break; case CMD_PLAY : Debug(1, "Got PLAY command"); + stopped = false; if (paused) { paused = false; delayed = true; @@ -106,6 +108,7 @@ void MonitorStream::processCommand(const CmdMsg *msg) { break; case CMD_VARPLAY : Debug(1, "Got VARPLAY command"); + stopped = false; if (paused) { paused = false; delayed = true; @@ -114,11 +117,13 @@ void MonitorStream::processCommand(const CmdMsg *msg) { break; case CMD_STOP : Debug(1, "Got STOP command"); - paused = true; + stopped = true; + paused = false; delayed = false; break; case CMD_FASTFWD : Debug(1, "Got FAST FWD command"); + stopped = false; if (paused) { paused = false; delayed = true; @@ -156,6 +161,7 @@ void MonitorStream::processCommand(const CmdMsg *msg) { } case CMD_SLOWFWD : Debug(1, "Got SLOW FWD command"); + stopped = false; paused = true; delayed = true; replay_rate = ZM_RATE_BASE; @@ -163,6 +169,7 @@ void MonitorStream::processCommand(const CmdMsg *msg) { break; case CMD_SLOWREV : Debug(1, "Got SLOW REV command"); + stopped = false; paused = true; delayed = true; replay_rate = ZM_RATE_BASE; @@ -170,6 +177,7 @@ void MonitorStream::processCommand(const CmdMsg *msg) { break; case CMD_FASTREV : Debug(1, "Got FAST REV command"); + stopped = false; if (paused) { paused = false; delayed = true; @@ -256,6 +264,7 @@ void MonitorStream::processCommand(const CmdMsg *msg) { int score; int analysing; bool analysis_image; + bool stopped; } status_data; status_data.id = monitor->Id(); @@ -299,6 +308,7 @@ void MonitorStream::processCommand(const CmdMsg *msg) { } // end monitor_mutex scope status_data.delayed = delayed; status_data.paused = paused; + status_data.stopped = stopped; status_data.rate = replay_rate; status_data.delay = FPSeconds(now - last_frame_sent).count(); status_data.zoom = zoom; @@ -306,13 +316,14 @@ void MonitorStream::processCommand(const CmdMsg *msg) { status_data.analysis_image = (frame_type == FRAME_ANALYSIS) && monitor->ShmValid() && (monitor->Analysing() != Monitor::ANALYSING_NONE); - Debug(2, "viewing fps: %.2f capture_fps: %.2f analysis_fps: %.2f Buffer Level:%d, Delayed:%d, Paused:%d, Rate:%d, delay:%.3f, Zoom:%d, Enabled:%d Forced:%d score: %d analysis_image: %d", + Debug(2, "viewing fps: %.2f capture_fps: %.2f analysis_fps: %.2f Buffer Level:%d, Delayed:%d, Paused:%d, Stopped:%d, Rate:%d, delay:%.3f, Zoom:%d, Enabled:%d Forced:%d score: %d analysis_image: %d", status_data.fps, status_data.capture_fps, status_data.analysis_fps, status_data.buffer_level, status_data.delayed, status_data.paused, + status_data.stopped, status_data.rate, status_data.delay, status_data.zoom, @@ -639,6 +650,12 @@ void MonitorStream::runStream() { if (frame_type == FRAME_ANALYSIS) monitor->setLastAnalysisViewed(); + if (stopped) { + // In stopped state, do nothing except wait for a new command + std::this_thread::sleep_for(MAX_SLEEP); + continue; + } + if (paused) { if (!was_paused) { int index = monitor->shared_data->last_write_index % monitor->image_buffer_count; diff --git a/src/zm_stream.h b/src/zm_stream.h index 0422d8cc1..9993a8581 100644 --- a/src/zm_stream.h +++ b/src/zm_stream.h @@ -125,6 +125,7 @@ class StreamBase { char sock_path_lock[108]; int lock_fd; bool paused; + bool stopped; int step; bool send_twice; // flag to send the same frame twice @@ -188,6 +189,7 @@ class StreamBase { sd(-1), lock_fd(0), paused(false), + stopped(false), step(0), maxfps(DEFAULT_MAXFPS), base_fps(0.0), diff --git a/web/ajax/stream.php b/web/ajax/stream.php index 6f8c77593..408174f12 100644 --- a/web/ajax/stream.php +++ b/web/ajax/stream.php @@ -151,7 +151,7 @@ default : $data = unpack('ltype', $msg); switch ( $data['type'] ) { case MSG_DATA_WATCH : - $data = unpack('ltype/imonitor/istate/dfps/dcapturefps/danalysisfps/ilevel/irate/ddelay/izoom/iscale/Cdelayed/Cpaused/Cenabled/Cforced/iscore/ianalysing/Canalysisimage', $msg); + $data = unpack('ltype/imonitor/istate/dfps/dcapturefps/danalysisfps/ilevel/irate/ddelay/izoom/iscale/Cdelayed/Cpaused/Cenabled/Cforced/iscore/ianalysing/Canalysisimage/Cstopped', $msg); $data['fps'] = round( $data['fps'], 2 ); $data['capturefps'] = round( $data['capturefps'], 2 ); $data['analysisfps'] = round( $data['analysisfps'], 2 ); @@ -176,10 +176,10 @@ case MSG_DATA_WATCH : case MSG_DATA_EVENT : if ( PHP_INT_SIZE===4 || version_compare( phpversion(), '5.6.0', '<') ) { ZM\Debug('Using old unpack methods to handle 64bit event id'); - $data = unpack('ltype/ieventlow/ieventhigh/dduration/dprogress/dfps/irate/izoom/iscale/Cpaused', $msg); + $data = unpack('ltype/ieventlow/ieventhigh/dduration/dprogress/dfps/irate/izoom/iscale/Cpaused/Cstopped', $msg); $data['event'] = $data['eventhigh'] << 32 | $data['eventlow']; } else { - $data = unpack('ltype/Qevent/dduration/dprogress/dfps/irate/izoom/iscale/Cpaused', $msg); + $data = unpack('ltype/Qevent/dduration/dprogress/dfps/irate/izoom/iscale/Cpaused/Cstopped', $msg); } $data['rate'] /= RATE_BASE; $data['zoom'] = round($data['zoom']/SCALE_BASE, 1); diff --git a/web/js/EventStream.js b/web/js/EventStream.js index 24dc64ced..52eb8ee99 100644 --- a/web/js/EventStream.js +++ b/web/js/EventStream.js @@ -32,6 +32,7 @@ function EventStream(config) { this.img = null; this.started = false; this.paused = false; + this.stopped = false; this.currentEventId = null; this.rate = 100; this.status = null; @@ -457,10 +458,13 @@ function EventStream(config) { } } - // Track paused state from server + // Track paused and stopped state from server if (this.status.paused !== undefined) { this.paused = !!this.status.paused; } + if (this.status.stopped !== undefined) { + this.stopped = !!this.status.stopped; + } // Notify consumer if (this.onStatus) { diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index baed62744..a063f2e6d 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -1271,7 +1271,12 @@ function MonitorStream(monitorData) { const delayString = secsToTime(this.status.delay); - if (this.status.paused == true) { + if (this.status.stopped == true) { + $j('#modeValue'+this.id).text('Stopped'); + $j('#rate'+this.id).addClass('hidden'); + $j('#delay'+this.id).addClass('hidden'); + $j('#level'+this.id).addClass('hidden'); + } else if (this.status.paused == true) { $j('#modeValue'+this.id).text('Paused'); $j('#rate'+this.id).addClass('hidden'); $j('#delayValue'+this.id).text(delayString); From 35b453c9356467da3ea8106ffd1369cc97689a1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 02:11:15 +0000 Subject: [PATCH 03/32] fix: improve stopped state handling in EventStream run loop Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/ba9cb47a-a3e8-4e13-aec7-c9cd258e2a3d Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- src/zm_eventstream.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/zm_eventstream.cpp b/src/zm_eventstream.cpp index 93d4e9e06..cf08b81d4 100644 --- a/src/zm_eventstream.cpp +++ b/src/zm_eventstream.cpp @@ -1049,8 +1049,9 @@ void EventStream::runStream() { TimePoint::duration time_since_last_send = now - last_frame_sent; if (stopped) { - // In stopped state, do nothing except wait for a new command - send_frame = false; + // In stopped state, skip all frame processing until a new command is received. + // send_frame is already false from initialization above. + delta = MAX_SLEEP; } else if (!paused) { // Figure out if we should send this frame Debug(3, "not paused at curr_frame_id (%d-1) mod frame_mod(%d)", curr_frame_id, frame_mod); @@ -1083,7 +1084,7 @@ void EventStream::runStream() { } // end if streaming stepping or doing nothing // time_to_event > 0 means that we are not in the event - if (time_to_event > Seconds(0) and mode == MODE_ALL) { + if (!stopped && time_to_event > Seconds(0) and mode == MODE_ALL) { Debug(1, "Time since last send = %.2f s", FPSeconds(time_since_last_send).count()); if (time_since_last_send > Seconds(1)) { char frame_text[64]; @@ -1131,7 +1132,7 @@ void EventStream::runStream() { frame_count++; } - if (!paused && !event_data->frames.empty() + if (!paused && !stopped && !event_data->frames.empty() && curr_frame_id >= 1 && curr_frame_id <= (int)event_data->frames.size()) { // Get current frame data, curr_frame_id may have changed FrameData *last_frame_data = &event_data->frames[curr_frame_id-1]; @@ -1194,14 +1195,14 @@ void EventStream::runStream() { ); } // end if not at end of event } else { - // Paused + // Paused or stopped delta = MAX_SLEEP; // We are paused, so might be stepping //if ( step != 0 )// Adding 0 is cheaper than an if 0 // curr_frame_id starts at 1 though, so we might skip the first frame? curr_frame_id += step; - } // end if !paused + } // end if !paused && !stopped } // end scope for mutex lock if (type == STREAM_SINGLE) { From ea11498dc1fa65ca4ffe44e7f727c5381313fcf2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 02:41:57 +0000 Subject: [PATCH 04/32] docs: document CMD_PLAY vs CMD_VARPLAY and CMD_STOP in MsgCommand enum Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/f82e2bf4-0e4e-48a4-bccd-42c61e66c6b3 Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- src/zm_stream.h | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/zm_stream.h b/src/zm_stream.h index 9993a8581..f729cc504 100644 --- a/src/zm_stream.h +++ b/src/zm_stream.h @@ -75,7 +75,11 @@ class StreamBase { typedef enum { CMD_NONE=0, CMD_PAUSE, + // CMD_PLAY resumes or starts playback at normal speed (1x, i.e. replay_rate = ZM_RATE_BASE). + // Use CMD_VARPLAY to resume at an arbitrary rate. CMD_PLAY, + // CMD_STOP halts all streaming activity. Unlike CMD_PAUSE, no keepalive frames are sent + // and the stream does no work until a new command is received. CMD_STOP, CMD_FASTFWD, CMD_SLOWFWD, @@ -88,6 +92,13 @@ class StreamBase { CMD_PREV, CMD_NEXT, CMD_SEEK, + // CMD_VARPLAY resumes or starts playback at a caller-specified rate. + // The desired rate is packed as a big-endian uint16 offset by +32768 so that the range + // [-32768, +32767] maps to [0, 65535]. ZM_RATE_BASE (100) represents 1x speed, so: + // 32868 (= 32768 + 100) encodes 1x forward playback, + // 32668 (= 32768 - 100) encodes 1x reverse playback. + // Negative rates play in reverse; rates > ZM_RATE_BASE play faster than real-time. + // MSG payload: msg_data[1..2] = (rate + 32768) as network-byte-order uint16. CMD_VARPLAY, CMD_GET_IMAGE, CMD_QUIT, From 602edfe7810dc3d57a80ec62e785ad222d2d7a79 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 14 May 2026 17:43:52 -0400 Subject: [PATCH 05/32] fix: avoid HierarchyRequestError in submitThisForm on console view submitThisForm() did form.prepend(filter) unconditionally when in left sidebar mode. On the console view, #monitorFiltersForm is nested inside #fbpanel, so prepending the filter into its own descendant threw HierarchyRequestError: The new child is an ancestor of the parent. Triggered by pressing Enter inside the sidebar filter, which routes through handleKeydownGeneral -> submitThisForm(). Guard the prepend with filter.contains(form); the montagereview/watch flows the prepend was written for are unaffected. --- web/skins/classic/js/skin.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/skins/classic/js/skin.js b/web/skins/classic/js/skin.js index b979bf263..58b337d3d 100644 --- a/web/skins/classic/js/skin.js +++ b/web/skins/classic/js/skin.js @@ -615,7 +615,10 @@ function submitThisForm(param = null) { // Let's hide the old filter so that it doesn't appear during the transfer... filter.style.display = 'none'; // We return the filter to its place in the form, since in the left side menu the filter should always be inside the form. - form.prepend(filter); + // Skip if filter is already an ancestor of form (e.g. console: #fbpanel > #monitorFiltersForm), which would cause HierarchyRequestError. + if (!filter.contains(form)) { + form.prepend(filter); + } } if (param && typeof param === 'string') { //ON WATCH PAGE WHEN SELECTING A MONITOR, the object is transferred as PARAM!!! var uri = "?" + $j(form).serialize() + param; From f8581da34b9b7cbfbefbb736032338d98a1f7794 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 23:05:26 +0000 Subject: [PATCH 06/32] Initial plan From cbee86f397193d55c0829ffde96f0d263efa35ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 23:37:14 +0000 Subject: [PATCH 07/32] fix: GroupId filter persists after browser refresh on Console view - Add getFilterFromRequestOrCookie() helper that reads filter values from $_REQUEST first, then falls back to zmFilter_* cookies, mirroring the getFilterSelection() behavior on the PHP page-render side - All monitor filters now use this helper so they persist after F5/navigation - Replace plain SQL subquery for GroupId with Group::get_group_sql() which correctly includes monitors in child groups, consistent with the PHP side - Add integer validation for ID-based filters (GroupId, ServerId, StorageId, MonitorId) to guard against tampered cookie values - Add require_once for Group.php in queryRequest() fixes #4745 Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/4f4372ca-129e-4845-93a2-75ee9c7ecede Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- web/ajax/console.php | 67 +++++++++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/web/ajax/console.php b/web/ajax/console.php index aca7d36e8..94178c685 100644 --- a/web/ajax/console.php +++ b/web/ajax/console.php @@ -55,9 +55,25 @@ ajaxError('Unrecognised action '.$_REQUEST['action'].' or insufficient permissio // FUNCTION DEFINITIONS // +function getFilterFromRequestOrCookie($name) { + if (isset($_REQUEST[$name])) { + return $_REQUEST[$name]; + } + $cookieName = 'zmFilter_' . $name; + if (isset($_COOKIE[$cookieName])) { + $cookieValue = $_COOKIE[$cookieName]; + if ($cookieValue && $cookieValue !== '') { + $decoded = json_decode($cookieValue, true); + return ($decoded !== null) ? $decoded : $cookieValue; + } + } + return null; +} + function queryRequest() { global $user, $Servers; require_once('includes/Monitor.php'); + require_once('includes/Group.php'); require_once('includes/Group_Monitor.php'); $data = array( @@ -92,34 +108,42 @@ function queryRequest() { $sort = isset($_REQUEST['sort']) ? $_REQUEST['sort'] : 'Sequence'; $order = isset($_REQUEST['order']) ? strtoupper($_REQUEST['order']) : 'ASC'; - // Build monitor query with filters from request parameters (stateless) + // Build monitor query with filters from request parameters, falling back to cookies $conditions = array(); $values = array(); - // Get filter values directly from request + // Get filter values from request, falling back to cookies for persistence after page refresh $request_filters = array( - 'GroupId' => isset($_REQUEST['GroupId']) ? $_REQUEST['GroupId'] : null, - 'ServerId' => isset($_REQUEST['ServerId']) ? $_REQUEST['ServerId'] : null, - 'StorageId' => isset($_REQUEST['StorageId']) ? $_REQUEST['StorageId'] : null, - 'Capturing' => isset($_REQUEST['Capturing']) ? $_REQUEST['Capturing'] : null, - 'Analysing' => isset($_REQUEST['Analysing']) ? $_REQUEST['Analysing'] : null, - 'Recording' => isset($_REQUEST['Recording']) ? $_REQUEST['Recording'] : null, - 'Status' => isset($_REQUEST['Status']) ? $_REQUEST['Status'] : null, - 'MonitorId' => isset($_REQUEST['MonitorId']) ? $_REQUEST['MonitorId'] : null, - 'MonitorName' => isset($_REQUEST['MonitorName']) ? $_REQUEST['MonitorName'] : null, - 'Source' => isset($_REQUEST['Source']) ? $_REQUEST['Source'] : null + 'GroupId' => getFilterFromRequestOrCookie('GroupId'), + 'ServerId' => getFilterFromRequestOrCookie('ServerId'), + 'StorageId' => getFilterFromRequestOrCookie('StorageId'), + 'Capturing' => getFilterFromRequestOrCookie('Capturing'), + 'Analysing' => getFilterFromRequestOrCookie('Analysing'), + 'Recording' => getFilterFromRequestOrCookie('Recording'), + 'Status' => getFilterFromRequestOrCookie('Status'), + 'MonitorId' => getFilterFromRequestOrCookie('MonitorId'), + 'MonitorName' => getFilterFromRequestOrCookie('MonitorName'), + 'Source' => getFilterFromRequestOrCookie('Source') ); - // Apply request filters to SQL + // Apply GroupId filter using get_group_sql() to include child groups. + // Validate that group IDs are integers to guard against tampered cookie values. if ($request_filters['GroupId']) { - $GroupIds = is_array($request_filters['GroupId']) ? $request_filters['GroupId'] : array($request_filters['GroupId']); - $conditions[] = 'M.Id IN (SELECT MonitorId FROM Groups_Monitors WHERE GroupId IN (' . implode(',', array_fill(0, count($GroupIds), '?')) . '))'; - $values = array_merge($values, $GroupIds); + $groupIds = is_array($request_filters['GroupId']) ? $request_filters['GroupId'] : array($request_filters['GroupId']); + $groupIds = array_filter($groupIds, function($id) { return ctype_digit((string)$id); }); + if (count($groupIds)) { + $groupSql = ZM\Group::get_group_sql($groupIds); + if ($groupSql) { + $conditions[] = $groupSql; + } + } } foreach (array('ServerId','StorageId') as $filter) { if ($request_filters[$filter]) { $filter_values = is_array($request_filters[$filter]) ? $request_filters[$filter] : array($request_filters[$filter]); + // Validate that ID values are integers + $filter_values = array_filter($filter_values, function($id) { return ctype_digit((string)$id); }); if (count($filter_values)) { $conditions[] = 'M.'.$filter.' IN (' . implode(',', array_fill(0, count($filter_values), '?')) . ')'; $values = array_merge($values, $filter_values); @@ -203,12 +227,15 @@ function queryRequest() { }); } - // Apply MonitorId filter + // Apply MonitorId filter (validate IDs are integers) if ($request_filters['MonitorId']) { $monitor_ids = is_array($request_filters['MonitorId']) ? $request_filters['MonitorId'] : array($request_filters['MonitorId']); - $filtered_monitors = array_filter($filtered_monitors, function($monitor) use ($monitor_ids) { - return in_array($monitor['Id'], $monitor_ids); - }); + $monitor_ids = array_filter($monitor_ids, function($id) { return ctype_digit((string)$id); }); + if (count($monitor_ids)) { + $filtered_monitors = array_filter($filtered_monitors, function($monitor) use ($monitor_ids) { + return in_array($monitor['Id'], $monitor_ids); + }); + } } $data['total'] = count($filtered_monitors); From 4e64ea6b6b80bbcc17464d54e5d3c1c4b2e3a224 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 23:38:15 +0000 Subject: [PATCH 08/32] fix: use filter_var FILTER_VALIDATE_INT for stricter ID validation in queryRequest Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/4f4372ca-129e-4845-93a2-75ee9c7ecede Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- web/ajax/console.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/web/ajax/console.php b/web/ajax/console.php index 94178c685..bd11c9d7e 100644 --- a/web/ajax/console.php +++ b/web/ajax/console.php @@ -127,10 +127,12 @@ function queryRequest() { ); // Apply GroupId filter using get_group_sql() to include child groups. - // Validate that group IDs are integers to guard against tampered cookie values. + // Validate that group IDs are positive integers to guard against tampered cookie values. if ($request_filters['GroupId']) { $groupIds = is_array($request_filters['GroupId']) ? $request_filters['GroupId'] : array($request_filters['GroupId']); - $groupIds = array_filter($groupIds, function($id) { return ctype_digit((string)$id); }); + $groupIds = array_values(array_filter($groupIds, function($id) { + return filter_var($id, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) !== false; + })); if (count($groupIds)) { $groupSql = ZM\Group::get_group_sql($groupIds); if ($groupSql) { @@ -142,8 +144,10 @@ function queryRequest() { foreach (array('ServerId','StorageId') as $filter) { if ($request_filters[$filter]) { $filter_values = is_array($request_filters[$filter]) ? $request_filters[$filter] : array($request_filters[$filter]); - // Validate that ID values are integers - $filter_values = array_filter($filter_values, function($id) { return ctype_digit((string)$id); }); + // Validate that ID values are positive integers + $filter_values = array_values(array_filter($filter_values, function($id) { + return filter_var($id, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) !== false; + })); if (count($filter_values)) { $conditions[] = 'M.'.$filter.' IN (' . implode(',', array_fill(0, count($filter_values), '?')) . ')'; $values = array_merge($values, $filter_values); @@ -227,10 +231,12 @@ function queryRequest() { }); } - // Apply MonitorId filter (validate IDs are integers) + // Apply MonitorId filter (validate IDs are positive integers) if ($request_filters['MonitorId']) { $monitor_ids = is_array($request_filters['MonitorId']) ? $request_filters['MonitorId'] : array($request_filters['MonitorId']); - $monitor_ids = array_filter($monitor_ids, function($id) { return ctype_digit((string)$id); }); + $monitor_ids = array_values(array_filter($monitor_ids, function($id) { + return filter_var($id, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) !== false; + })); if (count($monitor_ids)) { $filtered_monitors = array_filter($filtered_monitors, function($monitor) use ($monitor_ids) { return in_array($monitor['Id'], $monitor_ids); From b4a2146afdc7423d57071094d7ee8b5c2fe0390f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 12:00:56 +0000 Subject: [PATCH 09/32] fix: use getFilterSelection() and validCardinal() per reviewer feedback - Remove getFilterFromRequestOrCookie(); require _monitor_filters.php and call getFilterSelection() directly instead (addresses duplicate logic) - Replace filter_var(FILTER_VALIDATE_INT) with validCardinal() for consistency with existing codebase utility functions - Guard MonitorName and Source against getFilterSelection() returning an array when the cookie value happens to be valid JSON refs #4745 Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/3d5e3926-51cc-4da8-8707-eb82c7d5db29 Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- web/ajax/console.php | 60 ++++++++++++++++---------------------------- 1 file changed, 22 insertions(+), 38 deletions(-) diff --git a/web/ajax/console.php b/web/ajax/console.php index bd11c9d7e..5216ed7c6 100644 --- a/web/ajax/console.php +++ b/web/ajax/console.php @@ -55,26 +55,12 @@ ajaxError('Unrecognised action '.$_REQUEST['action'].' or insufficient permissio // FUNCTION DEFINITIONS // -function getFilterFromRequestOrCookie($name) { - if (isset($_REQUEST[$name])) { - return $_REQUEST[$name]; - } - $cookieName = 'zmFilter_' . $name; - if (isset($_COOKIE[$cookieName])) { - $cookieValue = $_COOKIE[$cookieName]; - if ($cookieValue && $cookieValue !== '') { - $decoded = json_decode($cookieValue, true); - return ($decoded !== null) ? $decoded : $cookieValue; - } - } - return null; -} - function queryRequest() { global $user, $Servers; require_once('includes/Monitor.php'); require_once('includes/Group.php'); require_once('includes/Group_Monitor.php'); + require_once(__DIR__ . '/../views/_monitor_filters.php'); $data = array( 'total' => 0, @@ -112,27 +98,29 @@ function queryRequest() { $conditions = array(); $values = array(); - // Get filter values from request, falling back to cookies for persistence after page refresh + // Get filter values from request, falling back to cookies for persistence after page refresh. + // getFilterSelection() reads $_REQUEST first, then the zmFilter_* cookie. $request_filters = array( - 'GroupId' => getFilterFromRequestOrCookie('GroupId'), - 'ServerId' => getFilterFromRequestOrCookie('ServerId'), - 'StorageId' => getFilterFromRequestOrCookie('StorageId'), - 'Capturing' => getFilterFromRequestOrCookie('Capturing'), - 'Analysing' => getFilterFromRequestOrCookie('Analysing'), - 'Recording' => getFilterFromRequestOrCookie('Recording'), - 'Status' => getFilterFromRequestOrCookie('Status'), - 'MonitorId' => getFilterFromRequestOrCookie('MonitorId'), - 'MonitorName' => getFilterFromRequestOrCookie('MonitorName'), - 'Source' => getFilterFromRequestOrCookie('Source') + 'GroupId' => getFilterSelection('GroupId'), + 'ServerId' => getFilterSelection('ServerId'), + 'StorageId' => getFilterSelection('StorageId'), + 'Capturing' => getFilterSelection('Capturing'), + 'Analysing' => getFilterSelection('Analysing'), + 'Recording' => getFilterSelection('Recording'), + 'Status' => getFilterSelection('Status'), + 'MonitorId' => getFilterSelection('MonitorId'), + 'MonitorName' => getFilterSelection('MonitorName'), + 'Source' => getFilterSelection('Source') ); + // Text filters must be strings; guard against a cookie value that happens to be valid JSON. + if (is_array($request_filters['MonitorName'])) $request_filters['MonitorName'] = ''; + if (is_array($request_filters['Source'])) $request_filters['Source'] = ''; // Apply GroupId filter using get_group_sql() to include child groups. - // Validate that group IDs are positive integers to guard against tampered cookie values. + // Use validCardinal() to sanitize ID values before use. if ($request_filters['GroupId']) { $groupIds = is_array($request_filters['GroupId']) ? $request_filters['GroupId'] : array($request_filters['GroupId']); - $groupIds = array_values(array_filter($groupIds, function($id) { - return filter_var($id, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) !== false; - })); + $groupIds = array_values(array_filter(array_map('validCardinal', $groupIds))); if (count($groupIds)) { $groupSql = ZM\Group::get_group_sql($groupIds); if ($groupSql) { @@ -144,10 +132,8 @@ function queryRequest() { foreach (array('ServerId','StorageId') as $filter) { if ($request_filters[$filter]) { $filter_values = is_array($request_filters[$filter]) ? $request_filters[$filter] : array($request_filters[$filter]); - // Validate that ID values are positive integers - $filter_values = array_values(array_filter($filter_values, function($id) { - return filter_var($id, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) !== false; - })); + // Use validCardinal() to sanitize ID values + $filter_values = array_values(array_filter(array_map('validCardinal', $filter_values))); if (count($filter_values)) { $conditions[] = 'M.'.$filter.' IN (' . implode(',', array_fill(0, count($filter_values), '?')) . ')'; $values = array_merge($values, $filter_values); @@ -231,12 +217,10 @@ function queryRequest() { }); } - // Apply MonitorId filter (validate IDs are positive integers) + // Apply MonitorId filter (use validCardinal() to sanitize ID values) if ($request_filters['MonitorId']) { $monitor_ids = is_array($request_filters['MonitorId']) ? $request_filters['MonitorId'] : array($request_filters['MonitorId']); - $monitor_ids = array_values(array_filter($monitor_ids, function($id) { - return filter_var($id, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1))) !== false; - })); + $monitor_ids = array_values(array_filter(array_map('validCardinal', $monitor_ids))); if (count($monitor_ids)) { $filtered_monitors = array_filter($filtered_monitors, function($monitor) use ($monitor_ids) { return in_array($monitor['Id'], $monitor_ids); From 173af3bc8d55650473b880a726685e20b2a5fe69 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 15 May 2026 18:56:13 +0300 Subject: [PATCH 10/32] Fix: Fixed overlapping ".eventStats" and dropdown tags (event.css) Closed issue: https://github.com/ZoneMinder/zoneminder/issues/4831#issuecomment-4460535978 --- web/skins/classic/css/base/views/event.css | 1 + 1 file changed, 1 insertion(+) diff --git a/web/skins/classic/css/base/views/event.css b/web/skins/classic/css/base/views/event.css index 70ee82eb5..9253d72ca 100644 --- a/web/skins/classic/css/base/views/event.css +++ b/web/skins/classic/css/base/views/event.css @@ -120,6 +120,7 @@ height: 100%; } .eventStats { padding-left: 0; + z-index: 1; /* margin-right: 5px; */ } From 43412fda8f460b156430e306b45dd1c15b50d248 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Fri, 15 May 2026 21:58:05 +0300 Subject: [PATCH 11/32] Fix: ".chosen-drop" should always be in the foreground (sidebar.css) --- web/skins/classic/css/base/sidebar.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/skins/classic/css/base/sidebar.css b/web/skins/classic/css/base/sidebar.css index 48773938d..3b1806528 100644 --- a/web/skins/classic/css/base/sidebar.css +++ b/web/skins/classic/css/base/sidebar.css @@ -312,6 +312,10 @@ body #sidebarMain .sub-menu-list { height: 27px !important; } +.extruder .extruder-content .chosen-container .chosen-drop { + z-index: 1100; +} + /* Clear Filter Button Select Multiple Selection */ .extruder .extruder-content .term-value-wrapper { position: relative; /* Enable absolute positioning for child */ From 6faa106af13bd4942612ffbe7f5f6f269f15965c Mon Sep 17 00:00:00 2001 From: abi Date: Thu, 14 May 2026 00:35:33 +0300 Subject: [PATCH 12/32] Fix FreeBSD arm builds --- src/zm_signal.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/zm_signal.cpp b/src/zm_signal.cpp index e4586b95b..9f2b5bbf5 100644 --- a/src/zm_signal.cpp +++ b/src/zm_signal.cpp @@ -137,9 +137,17 @@ RETSIGTYPE zm_die_handler(int signal) ip = (void *)(uc->uc_mcontext.gregs[REG_EIP]); #endif #elif defined(__aarch64__) +#if defined(__FreeBSD_kernel__) || defined(__FreeBSD__) + ip = (void *)(uc->uc_mcontext.mc_gpregs.gp_elr); +#elif ip = (void *)(uc->uc_mcontext.pc); +#endif #elif defined(__arm__) +#if defined(__FreeBSD_kernel__) || defined(__FreeBSD__) + ip = (void *)(uc->uc_mcontext.__gregs[_REG_PC]); +#elif ip = (void *)(uc->uc_mcontext.arm_pc); +#endif #endif // Print the fault address and instruction pointer From 878a9dab143b26dc4bcdb63bd9cd8e60c992f9e2 Mon Sep 17 00:00:00 2001 From: abi Date: Thu, 14 May 2026 20:27:36 +0300 Subject: [PATCH 13/32] Since kFreeBSD was amd64 and i386 archs only, remove unnecessary checks --- src/zm_signal.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/zm_signal.cpp b/src/zm_signal.cpp index 9f2b5bbf5..67e4f6b50 100644 --- a/src/zm_signal.cpp +++ b/src/zm_signal.cpp @@ -137,13 +137,13 @@ RETSIGTYPE zm_die_handler(int signal) ip = (void *)(uc->uc_mcontext.gregs[REG_EIP]); #endif #elif defined(__aarch64__) -#if defined(__FreeBSD_kernel__) || defined(__FreeBSD__) +#if defined(__FreeBSD__) ip = (void *)(uc->uc_mcontext.mc_gpregs.gp_elr); #elif ip = (void *)(uc->uc_mcontext.pc); #endif #elif defined(__arm__) -#if defined(__FreeBSD_kernel__) || defined(__FreeBSD__) +#if defined(__FreeBSD__) ip = (void *)(uc->uc_mcontext.__gregs[_REG_PC]); #elif ip = (void *)(uc->uc_mcontext.arm_pc); From 5c0f9a1b14968862d8fdb34afff0dd4829d2aa86 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 14 May 2026 18:10:58 -0400 Subject: [PATCH 14/32] fix: guard ZM_WEB_REFRESH_LOGS against missing Config row The ZM_WEB_{H,M,L}_REFRESH_LOGS settings were added to ConfigData.pm.in recently and won't be present in the Config table until the user runs zmupdate.pl -f. Until then, referencing the constant directly throws an uncaught "Undefined constant" fatal that blocks the entire skin from loading. Use the defined()? ... :0 pattern already in place for ZM_WEB_VIEWING_TIMEOUT so the page renders (with log auto-refresh off) until the Config sync runs. --- web/skins/classic/includes/config.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/skins/classic/includes/config.php b/web/skins/classic/includes/config.php index 84753bc28..09b3cfdc6 100644 --- a/web/skins/classic/includes/config.php +++ b/web/skins/classic/includes/config.php @@ -103,7 +103,7 @@ switch ( $_COOKIE['zmBandwidth'] ) { define( 'ZM_WEB_REFRESH_IMAGE', ZM_WEB_H_REFRESH_IMAGE ); // How often the watched image is refreshed (if not streaming) define( 'ZM_WEB_REFRESH_STATUS', ZM_WEB_H_REFRESH_STATUS ); // How often the little status frame refreshes itself in the watch window define( 'ZM_WEB_REFRESH_EVENTS', ZM_WEB_H_REFRESH_EVENTS ); // How often the event listing is refreshed in the watch window, only for recent events - define( 'ZM_WEB_REFRESH_LOGS', ZM_WEB_H_REFRESH_LOGS ); // How often (in seconds) the listing is refreshed in the log window + define( 'ZM_WEB_REFRESH_LOGS', defined('ZM_WEB_H_REFRESH_LOGS') ? ZM_WEB_H_REFRESH_LOGS : 0 ); // How often (in seconds) the listing is refreshed in the log window define( 'ZM_WEB_CAN_STREAM', ZM_WEB_H_CAN_STREAM ); // Override the automatic detection of browser streaming capability define( 'ZM_WEB_STREAM_METHOD', ZM_WEB_H_STREAM_METHOD ); // Which method should be used to send video streams to your browser define( 'ZM_WEB_DEFAULT_SCALE', ZM_WEB_H_DEFAULT_SCALE ); // What the default scaling factor applied to 'live' or 'event' views is (%) @@ -123,7 +123,7 @@ switch ( $_COOKIE['zmBandwidth'] ) { define( 'ZM_WEB_REFRESH_IMAGE', ZM_WEB_M_REFRESH_IMAGE ); // How often the watched image is refreshed (if not streaming) define( 'ZM_WEB_REFRESH_STATUS', ZM_WEB_M_REFRESH_STATUS ); // How often the little status frame refreshes itself in the watch window define( 'ZM_WEB_REFRESH_EVENTS', ZM_WEB_M_REFRESH_EVENTS ); // How often the event listing is refreshed in the watch window, only for recent events - define( 'ZM_WEB_REFRESH_LOGS', ZM_WEB_M_REFRESH_LOGS ); // How often (in seconds) the listing is refreshed in the log window + define( 'ZM_WEB_REFRESH_LOGS', defined('ZM_WEB_M_REFRESH_LOGS') ? ZM_WEB_M_REFRESH_LOGS : 0 ); // How often (in seconds) the listing is refreshed in the log window define( 'ZM_WEB_CAN_STREAM', ZM_WEB_M_CAN_STREAM ); // Override the automatic detection of browser streaming capability define( 'ZM_WEB_STREAM_METHOD', ZM_WEB_M_STREAM_METHOD ); // Which method should be used to send video streams to your browser define( 'ZM_WEB_DEFAULT_SCALE', ZM_WEB_M_DEFAULT_SCALE ); // What the default scaling factor applied to 'live' or 'event' views is (%) @@ -143,7 +143,7 @@ switch ( $_COOKIE['zmBandwidth'] ) { define( 'ZM_WEB_REFRESH_IMAGE', ZM_WEB_L_REFRESH_IMAGE ); // How often the watched image is refreshed (if not streaming) define( 'ZM_WEB_REFRESH_STATUS', ZM_WEB_L_REFRESH_STATUS ); // How often the little status frame refreshes itself in the watch window define( 'ZM_WEB_REFRESH_EVENTS', ZM_WEB_L_REFRESH_EVENTS ); // How often the event listing is refreshed in the watch window, only for recent events - define( 'ZM_WEB_REFRESH_LOGS', ZM_WEB_L_REFRESH_LOGS ); // How often (in seconds) the listing is refreshed in the log window + define( 'ZM_WEB_REFRESH_LOGS', defined('ZM_WEB_L_REFRESH_LOGS') ? ZM_WEB_L_REFRESH_LOGS : 0 ); // How often (in seconds) the listing is refreshed in the log window define( 'ZM_WEB_CAN_STREAM', ZM_WEB_L_CAN_STREAM ); // Override the automatic detection of browser streaming capability define( 'ZM_WEB_STREAM_METHOD', ZM_WEB_L_STREAM_METHOD ); // Which method should be used to send video streams to your browser define( 'ZM_WEB_DEFAULT_SCALE', ZM_WEB_L_DEFAULT_SCALE ); // What the default scaling factor applied to 'live' or 'event' views is (%) From 52cfaff3fc234c012b3928a0500cea17b06b4943 Mon Sep 17 00:00:00 2001 From: abi Date: Sat, 16 May 2026 01:27:23 +0300 Subject: [PATCH 15/32] typo --- src/zm_signal.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/zm_signal.cpp b/src/zm_signal.cpp index 67e4f6b50..a2926a260 100644 --- a/src/zm_signal.cpp +++ b/src/zm_signal.cpp @@ -139,13 +139,13 @@ RETSIGTYPE zm_die_handler(int signal) #elif defined(__aarch64__) #if defined(__FreeBSD__) ip = (void *)(uc->uc_mcontext.mc_gpregs.gp_elr); -#elif +#else ip = (void *)(uc->uc_mcontext.pc); #endif #elif defined(__arm__) #if defined(__FreeBSD__) ip = (void *)(uc->uc_mcontext.__gregs[_REG_PC]); -#elif +#else ip = (void *)(uc->uc_mcontext.arm_pc); #endif #endif From b2d7d6065cfe27da38a73302164c1cb0de8285fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 22:30:11 +0000 Subject: [PATCH 16/32] fix: use getSkinFile() for _monitor_filters.php include path in console.php Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/ec7e935d-1966-49f1-b782-669b625cd648 Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- web/ajax/console.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/ajax/console.php b/web/ajax/console.php index 5216ed7c6..e79250cfc 100644 --- a/web/ajax/console.php +++ b/web/ajax/console.php @@ -60,7 +60,7 @@ function queryRequest() { require_once('includes/Monitor.php'); require_once('includes/Group.php'); require_once('includes/Group_Monitor.php'); - require_once(__DIR__ . '/../views/_monitor_filters.php'); + require_once getSkinFile('views/_monitor_filters.php'); $data = array( 'total' => 0, From 0e532cd77b3debd5a71bb79f4551aadaef53e428 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 15 May 2026 19:28:00 -0400 Subject: [PATCH 17/32] fix: correct HLS fragment byte-range and duration tracking refs #4757 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The m3u8 manifest written by VideoStore had two interlocking bugs that produced duplicate entries with bogus 0.040s durations and a missing or mis-pointed final fragment, which made video.js seek backwards. Root cause: with movflags=frag_keyframe the mov muxer flushes fragment N to disk *inside* av_interleaved_write_frame() when keyframe N+1 arrives. The previous code snapshotted avio_tell() *before* that call, so its last_fragment_offset_ described the next fragment's start while the fragment at that offset hadn't actually been flushed yet. writeM3U8 then push_back'd a tentative entry off that stale state on every live update, and the next keyframe push pushed a second entry at the same offset+size with the correct duration. Now we snapshot avio_tell() *after* av_interleaved_write_frame(), which gives the actual end of the just-flushed fragment, and record fragment N-1 there. writeM3U8 no longer mutates fragments_ — it just emits the list. The final fragment (no later keyframe to close it) is recorded by a new finalize() method on VideoStore that runs av_interleaved_write_frame flush + av_write_trailer, then parses the trailing mfro box to subtract the mfra trailer size from the file length. Event::~Event() calls finalize() before writeM3U8(true); the VideoStore destructor skips its own trailer write when finalize() has already run. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_event.cpp | 7 +- src/zm_videostore.cpp | 156 +++++++++++++++++++++++++++++------------- src/zm_videostore.h | 17 ++++- 3 files changed, 126 insertions(+), 54 deletions(-) diff --git a/src/zm_event.cpp b/src/zm_event.cpp index d941d5565..4bb68eb78 100644 --- a/src/zm_event.cpp +++ b/src/zm_event.cpp @@ -202,9 +202,12 @@ Event::~Event() { /* Close the video file */ // We close the videowriter first, because if we finish the event, we might try to view the file, but we aren't done writing it yet. if (videoStore != nullptr) { - // Finalize last fragment before closing the video store + // Flush the trailer + record the final fragment before writing the m3u8. + // finalize() must run before writeM3U8 so the manifest contains every + // fragment (including the one no later keyframe was around to close). + videoStore->finalize(); + std::string m3u8_path = path + "/index.m3u8"; - // Write temporary m3u8 with incomplete filename (writeM3U8 finalizes last fragment) std::string video_url_tmp = "index.php?view=view_video&eid=" + std::to_string(id) + "&file=" + video_incomplete_file; videoStore->writeM3U8(m3u8_path, video_url_tmp, true); diff --git a/src/zm_videostore.cpp b/src/zm_videostore.cpp index acdc9d231..617662359 100644 --- a/src/zm_videostore.cpp +++ b/src/zm_videostore.cpp @@ -76,7 +76,8 @@ VideoStore::VideoStore( reorder_queue_size(0), last_fragment_offset_(0), last_fragment_start_dts_(AV_NOPTS_VALUE), - init_segment_end_(0) { + init_segment_end_(0), + finalized_(false) { FFMPEGInit(); swscale.init(); opkt = av_packet_ptr{av_packet_alloc()}; @@ -706,7 +707,7 @@ VideoStore::~VideoStore() { } } - if (oc->pb) { + if (!finalized_ && oc->pb) { flush_codecs(); // Flush Queues @@ -1551,26 +1552,31 @@ int VideoStore::write_packet(AVPacket *pkt, AVStream *stream) { Debug(3, "next_dts for stream %d has become %" PRId64 " last_dts %" PRId64, stream->index, next_dts[stream->index], last_dts[stream->index]); - // HLS fragment tracking: with frag_keyframe movflag, FFmpeg creates a new - // moof+mdat at each video keyframe. We record the byte range of each fragment - // by checking the file position before and after the write call. - // - // Strategy: before writing a video keyframe, snapshot the file position. - // This marks the end of the previous fragment. We record that fragment and - // start tracking the new one. bool is_video_keyframe = (stream == video_out_stream) && (pkt->flags & AV_PKT_FLAG_KEY); + // Snapshot the keyframe's dts before the write call may modify the packet. + int64_t this_keyframe_dts = is_video_keyframe ? pkt->dts : AV_NOPTS_VALUE; + int ret = av_interleaved_write_frame(oc, pkt); + if (ret != 0) { + Error("Error writing packet: %s", av_make_error_string(ret).c_str()); + } else { + Debug(4, "Success writing packet"); + } + + // HLS fragment tracking: with movflags=frag_keyframe, the muxer flushes the + // previous fragment to disk inside av_interleaved_write_frame() when a new + // keyframe arrives. So the position *after* this call equals the end of the + // just-flushed fragment, and last_fragment_offset_/_dts_ describe that + // fragment. Record it, then move tracking to the new fragment. if (is_video_keyframe && oc && oc->pb) { - // Force flush any buffered data so the file position reflects all previous writes avio_flush(oc->pb); - int64_t pos_now = avio_tell(oc->pb); + int64_t pos_after = avio_tell(oc->pb); - if (last_fragment_start_dts_ != AV_NOPTS_VALUE && pos_now > last_fragment_offset_) { - // Record the completed fragment - int64_t frag_size = pos_now - last_fragment_offset_; + if (last_fragment_start_dts_ != AV_NOPTS_VALUE && pos_after > last_fragment_offset_) { + int64_t frag_size = pos_after - last_fragment_offset_; double duration = 0; if (video_out_stream->time_base.den > 0) { - duration = static_cast(pkt->dts - last_fragment_start_dts_) + duration = static_cast(this_keyframe_dts - last_fragment_start_dts_) * video_out_stream->time_base.num / video_out_stream->time_base.den; } @@ -1580,49 +1586,101 @@ int VideoStore::write_packet(AVPacket *pkt, AVStream *stream) { fragments_.size() - 1, last_fragment_offset_, frag_size, duration); } } - // New fragment starts here - last_fragment_offset_ = pos_now; - last_fragment_start_dts_ = pkt->dts; - } - - // Initialize tracking after init segment is written - if (last_fragment_start_dts_ == AV_NOPTS_VALUE && is_video_keyframe) { - if (oc && oc->pb) { - last_fragment_offset_ = avio_tell(oc->pb); - } - last_fragment_start_dts_ = pkt->dts; - } - - int ret = av_interleaved_write_frame(oc, pkt); - if (ret != 0) { - Error("Error writing packet: %s", av_make_error_string(ret).c_str()); - } else { - Debug(4, "Success writing packet"); + last_fragment_offset_ = pos_after; + last_fragment_start_dts_ = this_keyframe_dts; } return ret; } // end int VideoStore::write_packet(AVPacket *pkt, AVStream *stream) -void VideoStore::writeM3U8(const std::string &m3u8_path, const std::string &video_url, bool is_complete) { - // Finalize last fragment if there's data after the last recorded fragment - if (oc && oc->pb) { - int64_t file_end = avio_tell(oc->pb); - if (file_end > last_fragment_offset_ && last_fragment_start_dts_ != AV_NOPTS_VALUE) { - int64_t frag_size = file_end - last_fragment_offset_; - // Estimate duration from last known DTS - double duration = 0; - if (video_out_stream && video_out_stream->time_base.den > 0 && - last_dts.count(video_out_stream->index) && last_dts[video_out_stream->index] != AV_NOPTS_VALUE) { - duration = static_cast(last_dts[video_out_stream->index] + last_duration[video_out_stream->index] - last_fragment_start_dts_) - * video_out_stream->time_base.num - / video_out_stream->time_base.den; - } - if (duration > 0 && frag_size > 0) { - fragments_.push_back({last_fragment_offset_, frag_size, duration}); +void VideoStore::finalize() { + if (finalized_) return; + finalized_ = true; + + if (!oc || !oc->pb) return; + + flush_codecs(); + + Debug(4, "Flushing interleaved queues"); + av_interleaved_write_frame(oc, nullptr); + + Debug(1, "Writing trailer"); + int rc = av_write_trailer(oc); + if (rc < 0) { + Error("Error writing trailer %s", av_err2str(rc)); + } else { + Debug(3, "Success Writing trailer"); + } + + // After av_write_trailer, the file contains init+fragments_1..N + mfra trailer. + // Capture the on-disk length so we can size the final fragment. + avio_flush(oc->pb); + int64_t file_size = avio_tell(oc->pb); + + // Close the output file before reading it back to inspect the mfra box. + if (!(out_format->flags & AVFMT_NOFILE)) { + Debug(4, "Closing"); + if ((rc = avio_close(oc->pb)) < 0) { + Error("Error closing avio %s", av_err2str(rc)); + } + } + oc->pb = nullptr; + + // The MOV muxer writes an mfra (Movie Fragment Random Access) box at the end + // of the file when fragmentation is on. Its trailing mfro box is exactly 16 + // bytes and contains the mfra size, so we can subtract that to find where + // the final fragment's mdat actually ends. + int64_t fragment_n_end = file_size; + if (filename && file_size >= 16) { + FILE *fp = fopen(filename, "rb"); + if (fp) { + if (fseeko(fp, file_size - 16, SEEK_SET) == 0) { + uint8_t mfro[16]; + if (fread(mfro, 1, 16, fp) == 16) { + uint32_t box_size = (static_cast(mfro[0]) << 24) + | (static_cast(mfro[1]) << 16) + | (static_cast(mfro[2]) << 8) + | static_cast(mfro[3]); + if (box_size == 16 + && mfro[4] == 'm' && mfro[5] == 'f' && mfro[6] == 'r' && mfro[7] == 'o') { + uint32_t mfra_size = (static_cast(mfro[12]) << 24) + | (static_cast(mfro[13]) << 16) + | (static_cast(mfro[14]) << 8) + | static_cast(mfro[15]); + if (mfra_size > 0 && static_cast(mfra_size) <= file_size) { + fragment_n_end = file_size - mfra_size; + Debug(1, "mfra trailer is %u bytes; final fragment ends at %" PRId64, + mfra_size, fragment_n_end); + } + } + } } + fclose(fp); } } + // Record the final fragment that no subsequent keyframe was around to record. + if (last_fragment_start_dts_ != AV_NOPTS_VALUE + && fragment_n_end > last_fragment_offset_ + && video_out_stream && video_out_stream->time_base.den > 0 + && last_dts.count(video_out_stream->index) + && last_dts[video_out_stream->index] != AV_NOPTS_VALUE) { + int64_t frag_size = fragment_n_end - last_fragment_offset_; + double duration = static_cast( + last_dts[video_out_stream->index] + + last_duration[video_out_stream->index] + - last_fragment_start_dts_) + * video_out_stream->time_base.num + / video_out_stream->time_base.den; + if (duration > 0 && frag_size > 0) { + fragments_.push_back({last_fragment_offset_, frag_size, duration}); + Debug(1, "HLS final fragment: offset=%" PRId64 " size=%" PRId64 " duration=%.3f", + last_fragment_offset_, frag_size, duration); + } + } +} + +void VideoStore::writeM3U8(const std::string &m3u8_path, const std::string &video_url, bool is_complete) { if (fragments_.empty()) return; // Calculate max duration for EXT-X-TARGETDURATION (must be integer, rounded up) diff --git a/src/zm_videostore.h b/src/zm_videostore.h index 855227e88..852e05f38 100644 --- a/src/zm_videostore.h +++ b/src/zm_videostore.h @@ -93,11 +93,17 @@ class VideoStore { size_t reorder_queue_size; std::map>> reorder_queues; - // HLS fragment tracking + // HLS fragment tracking. With movflags=frag_keyframe, FFmpeg's mov muxer + // doesn't write a fragment to disk until the *next* keyframe arrives (or + // until av_write_trailer is called). So when keyframe N arrives, fragment + // N-1 is what just got flushed. We snapshot avio_tell *after* + // av_interleaved_write_frame() to capture the position past that flush, and + // record fragment N-1 then. std::vector fragments_; - int64_t last_fragment_offset_; // byte offset where current fragment started - int64_t last_fragment_start_dts_; // DTS of first video keyframe in current fragment + int64_t last_fragment_offset_; // byte offset where the current (in-progress) fragment starts + int64_t last_fragment_start_dts_; // DTS of the keyframe that started the current fragment int64_t init_segment_end_; // byte offset where init segment (ftyp+moov) ends + bool finalized_; // true once finalize() has run trailer + last-fragment recording bool setup_resampler(); int write_packet(AVPacket *pkt, AVStream *stream); @@ -124,6 +130,11 @@ class VideoStore { const std::vector &fragments() const { return fragments_; } int64_t init_segment_end() const { return init_segment_end_; } void writeM3U8(const std::string &path, const std::string &video_url, bool is_complete); + // Flush queues, write trailer, close output, and record the final fragment. + // Call this before writeM3U8(true) so the manifest contains every fragment. + // Safe to call once; subsequent calls are no-ops. The destructor will skip + // the trailer write if finalize() has already run. + void finalize(); const char *get_codec() { if (chosen_codec_data) From 671e1c361fe91b8f3dcec25dc188390cb328d150 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 15 May 2026 23:01:00 -0400 Subject: [PATCH 18/32] fix: stop warning on URL/session user mismatch in getAuthUser The auth hash is stateless (HMAC over secret+username+password+IP+date) and intentionally independent of the PHP session. Stream/image URLs minted while user A was logged in keep working for user A's hash even after the active session has rotated to B (re-login, session timeout + new login, another tab, etc.), which is the design. The eager Warning was firing on these legitimate cross-session requests and producing log noise. A genuinely tampered request without a valid hash still falls through to the existing failure-path Info log, which now also reports sessionUser for diagnostics. --- web/includes/auth.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/web/includes/auth.php b/web/includes/auth.php index 022825407..a1d45c74b 100644 --- a/web/includes/auth.php +++ b/web/includes/auth.php @@ -193,15 +193,6 @@ function getAuthUser($auth) { $sessionUser = isset($_SESSION['username']) ? $_SESSION['username'] : null; $filterUser = $requestedUser !== null ? $requestedUser : $sessionUser; - if ($requestedUser !== null && $sessionUser !== null) { - $usersMatch = ZM_CASE_INSENSITIVE_USERNAMES - ? (strcasecmp($requestedUser, $sessionUser) === 0) - : ($requestedUser === $sessionUser); - if (!$usersMatch) { - ZM\Warning("Auth user mismatch: URL user='$requestedUser' but session username='$sessionUser'. This may indicate a stale auth hash from a previous login, cross-tab session contamination, or a tampered request."); - } - } - ZM\Debug("getAuthUser: validating auth='$auth' filterUser='".($filterUser ?? '')."' xff='$xff' directAddr='$directAddr' usingRemoteAddr='$remoteAddr' session_username='".($sessionUser ?? '')."'"); $sql = 'SELECT * FROM Users WHERE Enabled = 1'; @@ -257,7 +248,7 @@ function getAuthUser($auth) { } // end foreach user } // end if - ZM\Info("Unable to authenticate user from auth hash '$auth' (filterUser='".($filterUser ?? '')."' xff='$xff' directAddr='$directAddr' rowsTried=$rowsTried ttl=".ZM_AUTH_HASH_TTL.'h)'); + ZM\Info("Unable to authenticate user from auth hash '$auth' (filterUser='".($filterUser ?? '')."' sessionUser='".($sessionUser ?? '')."' xff='$xff' directAddr='$directAddr' rowsTried=$rowsTried ttl=".ZM_AUTH_HASH_TTL.'h)'); return null; } // end if using auth hash From 61c4c6606b575a2eecb68d40f08c9f09bd2c3d30 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 15 May 2026 23:01:00 -0400 Subject: [PATCH 19/32] perf: scope Event neighbor queries to Id only The view() action sets recursive=1 on the Event model, which the subsequent find('neighbors') calls inherited. That made each of the four neighbor lookups (prev/next, prevOfMonitor/nextOfMonitor) SELECT every column from Events plus LEFT JOIN Monitor and Storage, then fire a separate Frames hasMany query per neighbor row. Only Event.Id is used downstream. Pass fields=Event.Id and recursive=-1 on each neighbor call so the generated SQL is just: SELECT Event.Id FROM Events AS Event WHERE Event.Id < ? ORDER BY Event.Id DESC LIMIT 1 The per-monitor variant uses Events_MonitorId_idx which already covers (MonitorId, Id) via InnoDB's implicit PK suffix, so no schema change is needed. --- web/api/app/Controller/EventsController.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/web/api/app/Controller/EventsController.php b/web/api/app/Controller/EventsController.php index cf81de629..de8265d3f 100644 --- a/web/api/app/Controller/EventsController.php +++ b/web/api/app/Controller/EventsController.php @@ -262,9 +262,14 @@ class EventsController extends AppController { return; } - # Get the previous and next events for any monitor + # Get the previous and next events for any monitor. + # Only Id is used below, so skip the wide SELECT + Monitor/Storage joins + Frames hasMany expansion + # that recursive=1 from above would otherwise pull in for each neighbor row. $this->Event->id = $id; - $event_neighbors = $this->Event->find('neighbors'); + $event_neighbors = $this->Event->find('neighbors', array( + 'fields' => array('Event.Id'), + 'recursive' => -1, + )); $event['Event']['Next'] = isset($event_neighbors['next']) ? $event_neighbors['next']['Event']['Id'] : 0; $event['Event']['Prev'] = isset($event_neighbors['prev']) ? $event_neighbors['prev']['Event']['Id'] : 0; @@ -274,7 +279,9 @@ class EventsController extends AppController { # Also get the previous and next events for the same monitor $event_monitor_neighbors = $this->Event->find('neighbors', array( - 'conditions'=>array('Event.MonitorId'=>$event['Event']['MonitorId']) + 'fields' => array('Event.Id'), + 'recursive' => -1, + 'conditions' => array('Event.MonitorId' => $event['Event']['MonitorId']), )); $event['Event']['NextOfMonitor'] = isset($event_monitor_neighbors['next']) ? $event_monitor_neighbors['next']['Event']['Id'] : 0; $event['Event']['PrevOfMonitor'] = isset($event_monitor_neighbors['prev']) ? $event_monitor_neighbors['prev']['Event']['Id'] : 0; From 47d3af70c73f5570f6769d17a2073f657da07ffc Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 15 May 2026 23:54:32 -0400 Subject: [PATCH 20/32] fix: keep DefaultVideo='index.m3u8' for events with an HLS manifest refs #4757 Event::~Event() rewrote DefaultVideo to the renamed mp4 filename on every close, which made event.php's HLS detection (str_ends_with DefaultVideo, '.m3u8') return false for every closed event. The HLS player only kicked in for in-progress events or when MP4HLS was manually picked from the codec dropdown. Detect index.m3u8 in the same dirent walk that already computes video_size, and write 'index.m3u8' to DefaultVideo when it's present. mp4-only events keep using the renamed mp4 as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_event.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/zm_event.cpp b/src/zm_event.cpp index 4bb68eb78..cc50f1b35 100644 --- a/src/zm_event.cpp +++ b/src/zm_event.cpp @@ -258,18 +258,27 @@ Event::~Event() { if (frame_data.size()) WriteDbFrames(); uint64_t video_size = 0; + bool has_m3u8 = false; DIR *video_dir; if ((video_dir = opendir(path.c_str())) != NULL) { struct dirent *dir_entry; while ((dir_entry = readdir(video_dir)) != NULL) { struct stat vf_stat; if (stat((path + "/" + dir_entry->d_name).c_str(), &vf_stat) == 0 && - S_ISREG(vf_stat.st_mode)) + S_ISREG(vf_stat.st_mode)) { video_size += vf_stat.st_size; + if (!strcmp(dir_entry->d_name, "index.m3u8")) has_m3u8 = true; + } } closedir(video_dir); } + // Prefer index.m3u8 as DefaultVideo when an HLS manifest was written, so + // event.php picks the HLS player on closed events too — otherwise the player + // selection logic falls back to direct mp4 playback for everything except a + // manual MP4HLS codec choice. + std::string default_video = has_m3u8 ? "index.m3u8" : video_file; + // Use async dbQueue instead of synchronous zmDbDoUpdate to avoid blocking // the close_event_thread (which blocks the analysis thread on the next closeEvent). // Conditionally update Name only if it hasn't been changed by the user during recording. @@ -286,7 +295,7 @@ Event::~Event() { frames, alarm_frames, tot_score, static_cast(alarm_frames ? (tot_score / alarm_frames) : 0), max_score, max_score_frame_id, - video_file.c_str(), // defaults to "" + default_video.c_str(), video_size, id); dbQueue.push(std::move(sql)); From 10a4f9af136063c90a0ad5f7627fa426dd3642e0 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 15 May 2026 23:54:40 -0400 Subject: [PATCH 21/32] fix: pick HLS player when index.m3u8 exists, not from DefaultVideo refs #4757 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closed events that have already had their DefaultVideo column rewritten to the renamed mp4 (every event recorded before the C++ fix landed) need a client-side fallback so the HLS player still gets picked up. Treat the on-disk index.m3u8 as the source of truth for HLS availability and gate on the codec choice — auto / MP4 / MP4HLS all use HLS when the manifest is there; MJPEG and any other explicit non-video choice keep the old behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/skins/classic/views/event.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/skins/classic/views/event.php b/web/skins/classic/views/event.php index 3b753fff4..fe7928549 100644 --- a/web/skins/classic/views/event.php +++ b/web/skins/classic/views/event.php @@ -354,9 +354,13 @@ if (file_exists($Event->Path().'/objdetect.jpg')) {
DefaultVideo(), '.m3u8')) - && file_exists($Event->Path() . '/index.m3u8'); + // Prefer HLS byte-range playback whenever the manifest exists on disk and the + // user hasn't explicitly opted into a non-HLS playback mode. Closed events + // have DefaultVideo rewritten to the renamed mp4, so checking the file system + // is what catches them. + $has_hls = file_exists($Event->Path() . '/index.m3u8') + && (($codec == 'MP4HLS') || ($codec == 'MP4') || ($codec == 'auto') + || str_ends_with($Event->DefaultVideo(), '.m3u8')); if ($has_hls) { $Server = $Event->Server(); $hlsSrc = $Server->PathToIndex() . '?view=view_hls&eid=' . $Event->Id(); From 275b675ac857d1d8e58103535cb2a5c453f5303f Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 11:21:21 -0400 Subject: [PATCH 22/32] =?UTF-8?q?fix:=20stop=20view=5Fhls.php=20emitting?= =?UTF-8?q?=20/zm/index.php=3Findex.php=3Fview=3D=E2=80=A6=20refs=20#4757?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The segment-URL rewrite captured "index.php?…" in $1 and then prepended $base_url . '?', producing a double-prefixed URL that drops the view= routing parameter. Players fetched the manifest itself instead of the mp4 byte range, so Firefox showed a spinner and Chrome silently failed. Capture only the query string (everything after "index.php?") for both the bare segment URLs and the EXT-X-MAP URI rewrite. Same fix that landed (unmerged) in PR #4803 and PR #4806; pulled in here so the full HLS path can be reviewed and merged together. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/views/view_hls.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/views/view_hls.php b/web/views/view_hls.php index 9502b2f24..8b5c63a59 100644 --- a/web/views/view_hls.php +++ b/web/views/view_hls.php @@ -57,16 +57,19 @@ $content = file_get_contents($m3u8_path); $Server = $Event->Server(); $base_url = $Server->PathToIndex(); -// Replace bare URLs with full paths including auth +// Replace bare relative segment URLs with full paths including auth. +// The m3u8 has lines like "index.php?view=view_video&eid=N&file=F" — capture +// only the query string (after "index.php?") so the replacement doesn't emit +// "/zm/index.php?index.php?view=…". $content = preg_replace( - '/^(index\.php\?.+)$/m', + '/^index\.php\?(.+)$/m', $base_url . '?$1' . $auth_query, $content ); -// Also fix the EXT-X-MAP URI +// Also fix the EXT-X-MAP URI (initialization segment) the same way. $content = preg_replace( - '/URI="(index\.php\?[^"]+)"/m', + '/URI="index\.php\?([^"]+)"/m', 'URI="' . $base_url . '?$1' . $auth_query . '"', $content ); From 20da5e5f1204f1e4ae37db039cfd02947dc5ac78 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 11:21:21 -0400 Subject: [PATCH 23/32] fix: report served mp4 filename in view_video.php Content-Disposition refs #4757 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When DefaultVideo is 'index.m3u8' but the view_video.php fallback resolves $path to the actual mp4 (so byte-range playback works), the Content-Disposition header still advertised filename='index.m3u8'. The download button then saved a playlist instead of video. Derive $filename from $path unconditionally — after the fallback runs, $path is always the file we're streaming, regardless of mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/views/view_video.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/views/view_video.php b/web/views/view_video.php index dff454f44..ea825e2a3 100644 --- a/web/views/view_video.php +++ b/web/views/view_video.php @@ -87,7 +87,10 @@ if ( ! ($fh = @fopen($path, 'rb') ) ) { header('HTTP/1.0 404 Not Found'); die(); } -$filename = ($mode == 'mp4') ? basename($path) : (($Event) ? $Event->DefaultVideo() : ''); +// Always derive the filename from the resolved $path: after the m3u8 fallback +// above, $path can point at an mp4 even when DefaultVideo is 'index.m3u8', so +// reporting DefaultVideo would advertise a manifest while serving mp4 bytes. +$filename = basename($path); $size = filesize($path); $begin = 0; From e378da3d1b0487017566a1cb1138c5245f81590d Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 11:21:21 -0400 Subject: [PATCH 24/32] fix: disable video.js liveui so HLS scrubbing works on in-progress events refs #4757 liveui:true replaces the seekbar with a live-edge-only control, which prevents the user from scrubbing back through the already-recorded portion of an ongoing event. Always render the standard seekbar. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/skins/classic/views/event.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/skins/classic/views/event.php b/web/skins/classic/views/event.php index fe7928549..80c537055 100644 --- a/web/skins/classic/views/event.php +++ b/web/skins/classic/views/event.php @@ -406,7 +406,11 @@ if ($video_tag) { autoplay: true, preload: 'auto', playbackRates: rates, - liveui: EndDateTime() ? 'true' : 'false' ?>, + // liveui replaces the seekbar with a live-edge-only control, + // which makes it impossible to scrub back through the already- + // recorded portion of an in-progress event. Always false so the + // standard seekbar is rendered. + liveui: false, liveTracker: { trackingThreshold: 0 } From 375e82fd88fd69662eb4937906a9fcf70ffca920 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 11:47:42 -0400 Subject: [PATCH 25/32] fix: don't force HLS when user explicitly picked MP4 codec refs #4757 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10a4f9af1 included $codec == 'MP4' in the HLS-eligibility check, which broke native mp4 playback whenever an index.m3u8 was present — picking MP4 from the dropdown silently went through HLS instead. MP4 means "play the mp4 file directly"; honor that. The MP4HLS / auto / DefaultVideo-ends-in-.m3u8 conditions still cover closed events whose stored DefaultVideo predates the C++ fix that preserves 'index.m3u8'. Reported by @IgorA100 on commit 10a4f9af1. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/skins/classic/views/event.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web/skins/classic/views/event.php b/web/skins/classic/views/event.php index 80c537055..891442acc 100644 --- a/web/skins/classic/views/event.php +++ b/web/skins/classic/views/event.php @@ -354,12 +354,13 @@ if (file_exists($Event->Path().'/objdetect.jpg')) {
Path() . '/index.m3u8') - && (($codec == 'MP4HLS') || ($codec == 'MP4') || ($codec == 'auto') + && (($codec == 'MP4HLS') || ($codec == 'auto') || str_ends_with($Event->DefaultVideo(), '.m3u8')); if ($has_hls) { $Server = $Event->Server(); From 4182a829db6c9d801903f23f0a335fbaaa42de5e Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 12:01:19 -0400 Subject: [PATCH 26/32] fix: drain VideoStore reorder_queues inside finalize() refs #4757 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The destructor drained reorder_queues by calling writeVideoFramePacket / writeAudioFramePacket, which call av_interleaved_write_frame on oc. finalize() closes oc->pb, so when Event::~Event() calls finalize() and then deletes the VideoStore, the destructor's queue drain would write into a closed output — undefined behavior at best, silently dropping the trailing reordered frames at worst. Move the drain into finalize() ahead of the trailer write, and gate the destructor's drain on !finalized_ so it still runs in the legacy path where nobody called finalize(). Reported by Copilot on PR #4835. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_videostore.cpp | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/zm_videostore.cpp b/src/zm_videostore.cpp index 617662359..c8732bdba 100644 --- a/src/zm_videostore.cpp +++ b/src/zm_videostore.cpp @@ -692,18 +692,21 @@ Debug(1, "Done flushing"); VideoStore::~VideoStore() { - for (auto &n : reorder_queues) { - auto &queue = n.second; - Debug(1, "Queue for %d length is %zu", n.first, queue.size()); - while (!queue.empty()) { - auto pkt = queue.front(); - queue.pop_front(); - if (pkt->codec_type == AVMEDIA_TYPE_VIDEO) { - writeVideoFramePacket(pkt); - } else if (pkt->codec_type == AVMEDIA_TYPE_AUDIO) { - writeAudioFramePacket(pkt); + // When finalize() has run it already drained these queues; doing it again + // would push packets into a closed AVFormatContext. + if (!finalized_) { + for (auto &n : reorder_queues) { + auto &queue = n.second; + Debug(1, "Queue for %d length is %zu", n.first, queue.size()); + while (!queue.empty()) { + auto pkt = queue.front(); + queue.pop_front(); + if (pkt->codec_type == AVMEDIA_TYPE_VIDEO) { + writeVideoFramePacket(pkt); + } else if (pkt->codec_type == AVMEDIA_TYPE_AUDIO) { + writeAudioFramePacket(pkt); + } } - //delete pkt; } } @@ -1599,6 +1602,23 @@ void VideoStore::finalize() { if (!oc || !oc->pb) return; + // Drain reorder queues before writing the trailer — the destructor would + // otherwise try to run these packets through av_interleaved_write_frame() + // after we've already closed oc->pb here. + for (auto &n : reorder_queues) { + auto &queue = n.second; + Debug(1, "Queue for %d length is %zu", n.first, queue.size()); + while (!queue.empty()) { + auto pkt = queue.front(); + queue.pop_front(); + if (pkt->codec_type == AVMEDIA_TYPE_VIDEO) { + writeVideoFramePacket(pkt); + } else if (pkt->codec_type == AVMEDIA_TYPE_AUDIO) { + writeAudioFramePacket(pkt); + } + } + } + flush_codecs(); Debug(4, "Flushing interleaved queues"); From 2e83b8b0741dbe313e66faa2cef8588ef62e9e30 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 12:01:19 -0400 Subject: [PATCH 27/32] revert: stop forcing DefaultVideo='index.m3u8' for HLS events refs #4757 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 47d3af70c rewrote DefaultVideo to 'index.m3u8' on close so event.php would pick HLS for closed events. But several PHP call sites use DefaultVideo as a local file path passed to ffmpeg or to filesize() — Event.php's getImageSrc(), Length(), Filesize(), and Video() all rely on it being a real video filename. ffmpeg can't resolve our manifest's relative "index.php?…" segment URLs, so thumbnail/frame generation would break for events with SaveJPEGs disabled. The PHP-side fallback (10a4f9af1, 375e82fd8) keys HLS selection off the on-disk index.m3u8 plus the codec choice, which is enough on its own to route closed events through HLS without touching DefaultVideo. Reported by Copilot on PR #4835. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_event.cpp | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/zm_event.cpp b/src/zm_event.cpp index cc50f1b35..4bb68eb78 100644 --- a/src/zm_event.cpp +++ b/src/zm_event.cpp @@ -258,27 +258,18 @@ Event::~Event() { if (frame_data.size()) WriteDbFrames(); uint64_t video_size = 0; - bool has_m3u8 = false; DIR *video_dir; if ((video_dir = opendir(path.c_str())) != NULL) { struct dirent *dir_entry; while ((dir_entry = readdir(video_dir)) != NULL) { struct stat vf_stat; if (stat((path + "/" + dir_entry->d_name).c_str(), &vf_stat) == 0 && - S_ISREG(vf_stat.st_mode)) { + S_ISREG(vf_stat.st_mode)) video_size += vf_stat.st_size; - if (!strcmp(dir_entry->d_name, "index.m3u8")) has_m3u8 = true; - } } closedir(video_dir); } - // Prefer index.m3u8 as DefaultVideo when an HLS manifest was written, so - // event.php picks the HLS player on closed events too — otherwise the player - // selection logic falls back to direct mp4 playback for everything except a - // manual MP4HLS codec choice. - std::string default_video = has_m3u8 ? "index.m3u8" : video_file; - // Use async dbQueue instead of synchronous zmDbDoUpdate to avoid blocking // the close_event_thread (which blocks the analysis thread on the next closeEvent). // Conditionally update Name only if it hasn't been changed by the user during recording. @@ -295,7 +286,7 @@ Event::~Event() { frames, alarm_frames, tot_score, static_cast(alarm_frames ? (tot_score / alarm_frames) : 0), max_score, max_score_frame_id, - default_video.c_str(), + video_file.c_str(), // defaults to "" video_size, id); dbQueue.push(std::move(sql)); From 5c0532fe3bebe30e563e34fd0bf4f771fa6a3f2e Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 18:15:19 -0400 Subject: [PATCH 28/32] fix: drop DefaultVideo extension check from \$has_hls refs #4757 The str_ends_with(\$Event->DefaultVideo(), '.m3u8') clause overrode the user's codec choice for in-progress events: DefaultVideo is set to 'index.m3u8' by Event's constructor, so picking MP4 from the dropdown still routed through HLS. Base \$has_hls solely on on-disk manifest presence + codec being MP4HLS or auto. MJPEG already short-circuits earlier via \$video_tag. Reported by Copilot on PR #4835. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/skins/classic/views/event.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/skins/classic/views/event.php b/web/skins/classic/views/event.php index 891442acc..38563a802 100644 --- a/web/skins/classic/views/event.php +++ b/web/skins/classic/views/event.php @@ -355,13 +355,13 @@ if (file_exists($Event->Path().'/objdetect.jpg')) { Path() . '/index.m3u8') - && (($codec == 'MP4HLS') || ($codec == 'auto') - || str_ends_with($Event->DefaultVideo(), '.m3u8')); + && (($codec == 'MP4HLS') || ($codec == 'auto')); if ($has_hls) { $Server = $Event->Server(); $hlsSrc = $Server->PathToIndex() . '?view=view_hls&eid=' . $Event->Id(); From 4aa62a4d82efc0bbb27637dd84f87b0140d96791 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 18:15:20 -0400 Subject: [PATCH 29/32] fix: VideoStore filename use-after-free + null-oc destructor guard refs #4757 Two crash-class bugs reported by Copilot on PR #4835: 1. filename was held as const char* pointing into a caller-owned std::string. Event::AddPacket_() can rename the underlying file and reassign video_incomplete_path, invalidating that pointer; finalize() then fopens through the dangling pointer to parse the mfro trailer. Store filename as std::string inside VideoStore so the storage lives as long as the object does. 2. VideoStore::~VideoStore() dereferenced oc->pb and ran the reorder- queue drain without checking oc. If open() bailed before allocating oc, both paths would crash. Guard on oc being non-null. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_videostore.cpp | 30 ++++++++++++++++-------------- src/zm_videostore.h | 5 ++++- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/zm_videostore.cpp b/src/zm_videostore.cpp index c8732bdba..a3b2e97dd 100644 --- a/src/zm_videostore.cpp +++ b/src/zm_videostore.cpp @@ -62,7 +62,7 @@ VideoStore::VideoStore( resample_ctx(nullptr), fifo(nullptr), converted_in_samples(nullptr), - filename(filename_in), + filename(filename_in ? filename_in : ""), format(format_in), video_first_pts(AV_NOPTS_VALUE), video_first_dts(AV_NOPTS_VALUE), @@ -85,24 +85,24 @@ VideoStore::VideoStore( /* Failure to open audio will not be a total failure. */ bool VideoStore::open() { - Debug(1, "Opening video storage stream %s format: %s", filename, format); + Debug(1, "Opening video storage stream %s format: %s", filename.c_str(), format); - int ret = avformat_alloc_output_context2(&oc, nullptr, nullptr, filename); + int ret = avformat_alloc_output_context2(&oc, nullptr, nullptr, filename.c_str()); if (ret < 0) { Warning( "Could not create video storage stream %s as no out ctx" " could be assigned based on filename: %s", - filename, av_make_error_string(ret).c_str()); + filename.c_str(), av_make_error_string(ret).c_str()); } // Couldn't deduce format from filename, trying from format name if (!oc) { - avformat_alloc_output_context2(&oc, nullptr, format, filename); + avformat_alloc_output_context2(&oc, nullptr, format, filename.c_str()); if (!oc) { Error( "Could not create video storage stream %s as no out ctx" " could not be assigned based on filename or format %s", - filename, format); + filename.c_str(), format); return false; } } // end if ! oc @@ -522,9 +522,9 @@ bool VideoStore::open() { /* open the out file, if needed */ if (!(out_format->flags & AVFMT_NOFILE)) { - ret = avio_open2(&oc->pb, filename, AVIO_FLAG_WRITE, nullptr, nullptr); + ret = avio_open2(&oc->pb, filename.c_str(), AVIO_FLAG_WRITE, nullptr, nullptr); if (ret < 0) { - Error("Could not open out file '%s': %s", filename, av_make_error_string(ret).c_str()); + Error("Could not open out file '%s': %s", filename.c_str(), av_make_error_string(ret).c_str()); return false; } } @@ -563,7 +563,7 @@ bool VideoStore::open() { av_dict_free(&opts); if (ret < 0) { Error("Error occurred when writing out file header to %s: %s", - filename, av_make_error_string(ret).c_str()); + filename.c_str(), av_make_error_string(ret).c_str()); avio_closep(&oc->pb); return false; } @@ -693,8 +693,10 @@ Debug(1, "Done flushing"); VideoStore::~VideoStore() { // When finalize() has run it already drained these queues; doing it again - // would push packets into a closed AVFormatContext. - if (!finalized_) { + // would push packets into a closed AVFormatContext. Also skip when oc was + // never allocated (open() failed before assigning oc) — the write helpers + // dereference oc. + if (!finalized_ && oc) { for (auto &n : reorder_queues) { auto &queue = n.second; Debug(1, "Queue for %d length is %zu", n.first, queue.size()); @@ -710,7 +712,7 @@ VideoStore::~VideoStore() { } } - if (!finalized_ && oc->pb) { + if (!finalized_ && oc && oc->pb) { flush_codecs(); // Flush Queues @@ -1651,8 +1653,8 @@ void VideoStore::finalize() { // bytes and contains the mfra size, so we can subtract that to find where // the final fragment's mdat actually ends. int64_t fragment_n_end = file_size; - if (filename && file_size >= 16) { - FILE *fp = fopen(filename, "rb"); + if (!filename.empty() && file_size >= 16) { + FILE *fp = fopen(filename.c_str(), "rb"); if (fp) { if (fseeko(fp, file_size - 16, SEEK_SET) == 0) { uint8_t mfro[16]; diff --git a/src/zm_videostore.h b/src/zm_videostore.h index 852e05f38..d6e3a7749 100644 --- a/src/zm_videostore.h +++ b/src/zm_videostore.h @@ -71,7 +71,10 @@ class VideoStore { AVAudioFifo *fifo; uint8_t *converted_in_samples; - const char *filename; + // filename is owned (std::string) so it stays valid for the lifetime of + // VideoStore even if the caller later renames/reassigns the source path + // it was constructed from. A bare const char* would dangle in that case. + std::string filename; const char *format; // These are for in From 3b03ec5f12d136560075b4c7e6c536e0a27301d0 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sat, 16 May 2026 19:30:19 -0400 Subject: [PATCH 30/32] refactor: route VideoStore destructor through finalize() refs #4757 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The destructor previously duplicated the queue drain, trailer write, and avio close from finalize(), gated on !finalized_. That drifts. Have the destructor just call finalize() — it is idempotent (early returns when finalized_ is already set or when oc was never allocated) — so the shutdown logic lives in one place and resource deallocation stays in the destructor. Reported by Copilot on PR #4835. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/zm_videostore.cpp | 53 ++++--------------------------------------- 1 file changed, 5 insertions(+), 48 deletions(-) diff --git a/src/zm_videostore.cpp b/src/zm_videostore.cpp index a3b2e97dd..4b4c239af 100644 --- a/src/zm_videostore.cpp +++ b/src/zm_videostore.cpp @@ -692,54 +692,11 @@ Debug(1, "Done flushing"); VideoStore::~VideoStore() { - // When finalize() has run it already drained these queues; doing it again - // would push packets into a closed AVFormatContext. Also skip when oc was - // never allocated (open() failed before assigning oc) — the write helpers - // dereference oc. - if (!finalized_ && oc) { - for (auto &n : reorder_queues) { - auto &queue = n.second; - Debug(1, "Queue for %d length is %zu", n.first, queue.size()); - while (!queue.empty()) { - auto pkt = queue.front(); - queue.pop_front(); - if (pkt->codec_type == AVMEDIA_TYPE_VIDEO) { - writeVideoFramePacket(pkt); - } else if (pkt->codec_type == AVMEDIA_TYPE_AUDIO) { - writeAudioFramePacket(pkt); - } - } - } - } - - if (!finalized_ && oc && oc->pb) { - flush_codecs(); - - // Flush Queues - Debug(4, "Flushing interleaved queues"); - av_interleaved_write_frame(oc, nullptr); - - Debug(1, "Writing trailer"); - /* Write the trailer before close */ - int rc; - if ((rc = av_write_trailer(oc)) < 0) { - Error("Error writing trailer %s", av_err2str(rc)); - } else { - Debug(3, "Success Writing trailer"); - } - - // When will we not be using a file ? - if (!(out_format->flags & AVFMT_NOFILE)) { - /* Close the out file. */ - Debug(4, "Closing"); - if ((rc = avio_close(oc->pb)) < 0) { - Error("Error closing avio %s", av_err2str(rc)); - } - } else { - Debug(3, "Not closing avio because we are not writing to a file."); - } - oc->pb = nullptr; - } // end if oc->pb + // Run the shutdown path through finalize() so the queue-drain / trailer / + // close logic lives in one place. finalize() is idempotent and bails early + // if oc was never allocated, so the legacy "caller didn't call finalize" + // path and the open()-failed-before-allocating-oc path both work. + finalize(); // I wonder if we should be closing the file first. // I also wonder if we really need to be doing all the ctx From 1bce09f4e84615e03e083480ec92ed87fe44fff7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 03:29:41 +0000 Subject: [PATCH 31/32] fix: address review feedback - endian decode, send_twice init, setLastViewed, curr_frame_id gating Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/b457dcf6-616f-4df9-bc28-76640bf5dd39 Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- src/zm_eventstream.cpp | 8 +++++--- src/zm_monitorstream.cpp | 12 ++++++------ src/zm_stream.h | 14 +++++++++----- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/zm_eventstream.cpp b/src/zm_eventstream.cpp index cf08b81d4..ea7d5ace0 100644 --- a/src/zm_eventstream.cpp +++ b/src/zm_eventstream.cpp @@ -480,7 +480,7 @@ void EventStream::processCommand(const CmdMsg *msg) { Debug(1, "Got VARPLAY command"); stopped = false; paused = false; - replay_rate = ntohs(((unsigned char)msg->msg_data[2]<<8)|(unsigned char)msg->msg_data[1])-32768; + replay_rate = (((unsigned char)msg->msg_data[1]<<8)|(unsigned char)msg->msg_data[2])-VARPLAY_RATE_OFFSET; if (replay_rate > 50 * ZM_RATE_BASE) { Warning("requested replay rate (%d) is too high. We only support up to 50x", replay_rate); replay_rate = 50 * ZM_RATE_BASE; @@ -494,6 +494,8 @@ void EventStream::processCommand(const CmdMsg *msg) { Debug(1, "Got STOP command"); stopped = true; paused = false; + step = 0; + send_twice = false; break; case CMD_FASTFWD : { Debug(1, "Got FAST FWD command"); @@ -1198,10 +1200,10 @@ void EventStream::runStream() { // Paused or stopped delta = MAX_SLEEP; - // We are paused, so might be stepping + // We are paused, so might be stepping (not when fully stopped) //if ( step != 0 )// Adding 0 is cheaper than an if 0 // curr_frame_id starts at 1 though, so we might skip the first frame? - curr_frame_id += step; + if (!stopped) curr_frame_id += step; } // end if !paused && !stopped } // end scope for mutex lock diff --git a/src/zm_monitorstream.cpp b/src/zm_monitorstream.cpp index 6cb7f625e..4dd4f6a91 100644 --- a/src/zm_monitorstream.cpp +++ b/src/zm_monitorstream.cpp @@ -113,7 +113,7 @@ void MonitorStream::processCommand(const CmdMsg *msg) { paused = false; delayed = true; } - replay_rate = ntohs(((unsigned char)msg->msg_data[2]<<8)|(unsigned char)msg->msg_data[1])-32768; + replay_rate = (((unsigned char)msg->msg_data[1]<<8)|(unsigned char)msg->msg_data[2])-VARPLAY_RATE_OFFSET; break; case CMD_STOP : Debug(1, "Got STOP command"); @@ -646,15 +646,15 @@ void MonitorStream::runStream() { std::this_thread::sleep_for(MAX_SLEEP); continue; } - monitor->setLastViewed(); - if (frame_type == FRAME_ANALYSIS) - monitor->setLastAnalysisViewed(); - if (stopped) { - // In stopped state, do nothing except wait for a new command + // In stopped state, do nothing except wait for a new command. + // Don't call setLastViewed() so we don't keep capture/decoding active unnecessarily. std::this_thread::sleep_for(MAX_SLEEP); continue; } + monitor->setLastViewed(); + if (frame_type == FRAME_ANALYSIS) + monitor->setLastAnalysisViewed(); if (paused) { if (!was_paused) { diff --git a/src/zm_stream.h b/src/zm_stream.h index f729cc504..8c794ca00 100644 --- a/src/zm_stream.h +++ b/src/zm_stream.h @@ -54,6 +54,9 @@ class StreamBase { enum { DEFAULT_ZOOM=ZM_SCALE_BASE }; enum { DEFAULT_MAXFPS=10 }; enum { DEFAULT_BITRATE=100000 }; + // Offset applied when encoding a signed replay rate as a uint16 for CMD_VARPLAY. + // On the wire: uint16 = rate + VARPLAY_RATE_OFFSET. Receiver subtracts the same offset. + static const int VARPLAY_RATE_OFFSET = 32768; protected: typedef struct { @@ -93,12 +96,12 @@ class StreamBase { CMD_NEXT, CMD_SEEK, // CMD_VARPLAY resumes or starts playback at a caller-specified rate. - // The desired rate is packed as a big-endian uint16 offset by +32768 so that the range - // [-32768, +32767] maps to [0, 65535]. ZM_RATE_BASE (100) represents 1x speed, so: - // 32868 (= 32768 + 100) encodes 1x forward playback, - // 32668 (= 32768 - 100) encodes 1x reverse playback. + // The desired rate is packed as a big-endian uint16 offset by +VARPLAY_RATE_OFFSET so that + // the range [-32768, +32767] maps to [0, 65535]. ZM_RATE_BASE (100) represents 1x speed, so: + // 32868 (= VARPLAY_RATE_OFFSET + 100) encodes 1x forward playback, + // 32668 (= VARPLAY_RATE_OFFSET - 100) encodes 1x reverse playback. // Negative rates play in reverse; rates > ZM_RATE_BASE play faster than real-time. - // MSG payload: msg_data[1..2] = (rate + 32768) as network-byte-order uint16. + // MSG payload: msg_data[1..2] = (rate + VARPLAY_RATE_OFFSET) as network-byte-order uint16. CMD_VARPLAY, CMD_GET_IMAGE, CMD_QUIT, @@ -202,6 +205,7 @@ class StreamBase { paused(false), stopped(false), step(0), + send_twice(false), maxfps(DEFAULT_MAXFPS), base_fps(0.0), effective_fps(0.0), From 03801a9e238dddd47111fbc85232d7988f5f4b6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 12:37:39 +0000 Subject: [PATCH 32/32] fix: reset step/send_twice in MonitorStream CMD_STOP; reset stopped in EventStream.js lifecycle Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/8c14d453-7815-44fd-956a-0cda13c218f9 Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com> --- src/zm_monitorstream.cpp | 2 ++ web/js/EventStream.js | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/zm_monitorstream.cpp b/src/zm_monitorstream.cpp index 4dd4f6a91..48ba5420f 100644 --- a/src/zm_monitorstream.cpp +++ b/src/zm_monitorstream.cpp @@ -120,6 +120,8 @@ void MonitorStream::processCommand(const CmdMsg *msg) { stopped = true; paused = false; delayed = false; + step = 0; + send_twice = false; break; case CMD_FASTFWD : Debug(1, "Got FAST FWD command"); diff --git a/web/js/EventStream.js b/web/js/EventStream.js index 52eb8ee99..c9072ea24 100644 --- a/web/js/EventStream.js +++ b/web/js/EventStream.js @@ -91,6 +91,7 @@ function EventStream(config) { this.currentEventId = eventId; this.rate = (options.rate !== undefined) ? options.rate : 100; this.paused = false; + this.stopped = false; this.lastOptions = Object.assign({}, options); // Fresh connkey for this stream @@ -203,6 +204,7 @@ function EventStream(config) { this.started = false; this.paused = false; + this.stopped = false; this.connKey = null; this.streamCmdParms.connkey = null; this.consecutiveErrors = 0; @@ -248,6 +250,7 @@ function EventStream(config) { } this.started = false; this.connKey = null; + this.stopped = false; this.streamCmdParms.connkey = null; // Delay before restarting — exponential backoff