Commit Graph
29448 Commits
Author SHA1 Message Date
Isaac Connor 279e467b95 fix: run the event video encoder without a shell
Event::GenerateVideo built an ffmpeg command line as a string and ran it
through qx(), and two of the values in it are event fields an operator with
Events=Edit can set through the API. DefaultVideo was interpolated with no
quoting at all. The name behind the output filename only has its whitespace
replaced, so a single quote in it closed the quoting that was there. Either one
gave arbitrary command execution as the account the daemons run as.

Both confirmed against the module before the change, driving GenerateVideo
directly:

  DefaultVideo = "x.mp4; touch <marker>; echo"     -> marker created
  Name         = "evt';touch${IFS}<marker>;echo'"  -> marker created

The second needs ${IFS} rather than spaces because the name has whitespace
substituted before it is used, which is the whole of the sanitising that was
being relied on.

The command is now a list passed to exec, so there is no shell to escape from
whatever those fields hold; ffmpeg's own output still lands in the event's
ffmpeg.log. The two configured option strings are admin-set and hold several
options each, so they are split on whitespace to become separate arguments;
shell quoting inside them is no longer honoured.

After the change both payloads run ffmpeg with the injection as a literal
argument and create no marker.

See GHSA-pfph-4j9j-7cv7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBHBB95RBX7D9p8ge2WDZb
(cherry picked from commit 5fc491728c679bc7a8ff4b51183983e7fe184200)
2026-09-24 19:44:16 -04:00
Isaac Connor 3ea9618033 fix: issue the API token to the account that authenticated
/api/host/login took the token's subject from the `user` request parameter and
authenticated the request from a different one. beforeFilter() validates
user=/pass=, and zm_authenticate_request() logs in from username=/password=,
but nothing ever checked that the two named the same account. A caller could
authenticate with their own credentials and ask for a token issued to someone
else.

Reproduced end to end against a live instance. A System=None, Events=View
account posting

  username=lowpriv&password=testpass123&user=admin

received an access and a refresh token whose claims read {"user":"admin"}, and
that token was accepted by a System-gated endpoint. Any enabled API account
escalated to administrator without knowing the administrator's password or
ZM_AUTH_HASH_SECRET.

The subject now comes from the authenticated user rather than from the request,
and a request that reached this point without authenticating is refused instead
of being handed a token for whoever it named.

Verified after the change on the same instance: the request above mints a token
for lowpriv, an ordinary user=/pass= login still returns tokens for the caller,
and the refresh-token path still issues a new access token.

See GHSA-m77q-66v7-j3fq.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBHBB95RBX7D9p8ge2WDZb
(cherry picked from commit 72cb485655c53babfcfecaed572e0ef24e96db06)
2026-09-24 19:44:16 -04:00
Isaac Connor e924bfb999 fix: treat a request parameter that is not a string as absent in auth
Saving a user from the web ui took the whole auth path down:

  PHP Fatal error: Uncaught TypeError: strcasecmp(): Argument #1 ($string1)
  must be of type string, array given in includes/auth.php:197
  #0 auth.php(197): strcasecmp()
  #1 auth.php(528): getAuthUser()
  #2 auth.php(687): userFromSession()

reached from ?view=user&uid=2. That page's form posts user[Username],
user[Password], user[Name] and the rest, so $_REQUEST['user'] is an array on
every save from it, and getAuthUser() read that parameter as the username to
filter on and handed it to strcasecmp(). Under PHP 8 a string function given an
array is a TypeError rather than a warning, so the request died with a 500.

The same shape arrives from anyone who cares to send it, and not only on a page
that needs a session. userFromSession() reads user, pass, username, password
and auth straight out of the request, and the credential branches run before
anyone is logged in, so ?username[]=x&password[]=y reaches validateUser() with
arrays on an install that has never seen the caller before.

requestString() returns a parameter only when it is a string and null
otherwise, which is what the callers already do with a parameter that was not
sent. An array is not a username, a password or an auth hash.

master no longer has the strcasecmp line the report names, so it does not fatal
in that exact spot, but it reads the same unvalidated values: $filterUser is
bound as a query parameter and the credentials still reach validateUser(). This
fixes the class rather than the one line, and backports to 1.38 where the
reported line lives.

The test lifts requestString() out of auth.php and evaluates it alone, because
including auth.php needs a database; test_auth_no_include_side_effects.php
sidesteps the same dependency the same way. 8 cases, covering the form's array,
the login parameters, a nested array and the strings that must still pass
through. 5 of them fail without this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 1f4aa1a16c1b63fdf34a6f2c6aef6590dbd45acd)
2026-09-24 19:44:16 -04:00
Isaac Connor 6442f404dc Merge branch 'openbsd-support' 2026-09-24 19:34:18 -04:00
Isaac ConnorandClaude Opus 5.5 0e0f4afb1d fix: generate the OpenBSD rc.d script from the configured paths refs #5152
The rc.d script and Apache config hard-coded the /usr/local paths the
FreeBSD/OpenBSD distro target uses, so any build with other paths got
files pointing at the wrong place.

Make the rc.d script a template filled with CMAKE_INSTALL_FULL_BINDIR and
PERL_EXECUTABLE, generated into misc/ when building on OpenBSD. Drop the
OpenBSD Apache config: misc/apache.conf is already generated from the
configured web, cgi and cache paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:29:02 -04:00
Charlie RootandClaude Opus 5.5 b18924e2db fix: serve the jpeg unscaled when GD lacks jpeg support refs #5152
Without imagecreatefromjpeg the scaling path fatals, so fall through to
passing the file through as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:01:21 -04:00
ovi-vansidandClaude Opus 5.5 276eba92e6 fix: use su -s /bin/sh USER -c for running as the web user refs #5152
The long options --shell/--command are util-linux only. OpenBSD su takes
-s for the shell and passes arguments after the login name to it, and
util-linux su accepts the same form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:01:21 -04:00
ovi-vansidandClaude Opus 5.5 a90d2efdfc feat: add OpenBSD rc.d script and Apache config refs #5152
Point the Apache aliases at the web directory cmake installs to, and drop
the unused pexp line that named python.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:01:21 -04:00
ovi-vansidandClaude Opus 5.5 e30c416599 build: accept OpenBSD as a target distro refs #5152
ZM_TARGET_DISTRO=OpenBSD takes the same /usr/local paths as FreeBSD.
OpenBSD has no /dev/shm, so default ZM_PATH_MAP to /tmp there. This goes
in the platform defaults rather than the distro ladder because ZM_PATH_MAP
is already a cache entry by the time the ladder runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:01:20 -04:00
Isaac Connor bbb1fec97b Merge pull request #5151 from ZoneMinder/dependabot/github_actions/github/codeql-action-4.38.1
build(deps): bump github/codeql-action from 4.38.0 to 4.38.1
2026-09-23 20:23:32 -04:00
Isaac Connor 8f5a0af862 Merge pull request #5143 from SteveGilvarry/feature/stream-socket-events
Replace the per-monitor media FIFOs with a unix stream socket
2026-09-23 18:17:50 -04:00
Steve GilvarryandClaude Fable 5.1 f023032488 test: keep the stream socket benchmark's pacing deadline signed
kPacketInterval * i with a size_t i gives the deadline an unsigned
duration; once the producer falls behind, libc++'s sleep_until turns the
negative remaining time into a near-infinite sleep and the benchmark
hangs. Multiply by a signed index.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 15:07:15 +10:00
Steve GilvarryandClaude Fable 5.1 0ac5a2cc83 fix: refuse over-long stream socket paths and media for unknown stream ids
zm::UnixSocket copies the path with a truncating strncpy, so a
PATH_SOCKS long enough to overflow sun_path made the server bind one
file while chmod, chown and unlink acted on another, and the client
connect to a truncated, different path. Both now refuse such a path with
an error. Start() also closes the listener on its own failure paths.

SendMedia indexed the two-entry sequence array with the stream id; a
caller passing StreamId::Monitor would have written past it. Ignore
anything that is not video or audio.

Tests: server and client refuse a 200 character path; a Monitor stream
id is ignored and does not disturb the video sequence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 15:07:15 +10:00
Steve GilvarryandClaude Fable 5.1 e917315f12 fix: gate rtsp media on a session confirmed against the latest HELLO set
on_media admitted a packet whenever its generation matched the one the
session was built for. A restarted zmc starts again at generation 0, so
if the camera came back with different parameters its replayed keyframe
passed that check and went into the old codec's packer before the main
thread rebuilt the session.

Move the HELLO and generation bookkeeping into RtspSessionTracker, a
small class with no locking or RTSP types. Every HELLO and every
disconnect clears the confirmed flag; Update() asks the tracker for a
plan (none, adopt, rebuild), acts on it and confirms; on_media feeds the
packers only for a confirmed session at the confirmed generation. A set
that failed to build (unsupported codec) is not retried until a new
HELLO arrives, so a bad camera does not rebuild on every pass.

Tests: a new suite for the tracker covers the complete-set rule, the
HELLO-to-rebuild window, generation reuse after a producer restart with
changed and with unchanged parameters, audio appearing, disappearing and
changing, a generation without video, a failed build, and HELLOs for
streams the server does not serve.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 15:07:15 +10:00
Steve GilvarryandClaude Fable 5.1 2f1112cd14 fix: build the stream snapshot from one synchronized view of state and health
RefreshStreamSnapshot read the analysis thread's state member from the
capture thread (through SendStreamHealthEvent) while SetState could be
writing it, and only the health fields were under the mutex. Two threads
could also each build a snapshot and publish them in the wrong order.
SetState now mirrors the state into a field guarded by
stream_event_mutex, and the snapshot is built and published under that
one lock from the mirror and the health fields, so every snapshot is a
consistent view and the last one published is the newest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 15:07:15 +10:00
Steve GilvarryandClaude Fable 5.1 3d98b216ca fix: frame the stream socket snapshot when a consumer connects
The cached snapshot was framed when the status last changed, so after a
generation bump or further events a new consumer received it stamped
with a stale generation and an old event-sequence baseline. Keep only
the body and frame it in AcceptClient, so the header carries the
generation and sequence in effect at the moment of connection.

Tests: a snapshot cached at generation 0 with no events is delivered to
a later consumer with the current generation and sequence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 15:07:15 +10:00
Steve GilvarryandClaude Fable 5.1 7d9d97f387 fix: apply both stream parameter sets to the stream socket in one generation
Two problems with announcing streams one at a time. SetAudioParams only
bumped the generation when audio had been announced before, so audio
joining a video-only stream was appended to the current generation after
its video HELLO, which the protocol says is the last HELLO of a
generation. And a re-prime that changed both streams bumped twice:
consumers saw, and a connecting consumer was handed, an intermediate
generation pairing the new audio with the old video, which
zm_rtsp_server built a session for and tore down again.

Add StreamSocket::SetStreams(video, audio), which applies the whole set
under one lock: unchanged is a no-op, the first announcement stays in
generation 0, and any change once a video HELLO has gone out - new
parameters, a stream appearing, a stream disappearing - is exactly one
bump with every remaining stream re-announced, audio first. The single
stream setters and the new ClearVideoParams are wrappers over the same
logic, and a null or codec-less parameter set means "no such stream".
PrimeCapture passes both streams in one call.

Tests: audio joining an announced video stream bumps the generation;
SetStreams keeps the initial announcement in generation 0, changes both
streams in one generation with consistent pairing, and drops a video
stream the source no longer has.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 15:07:14 +10:00
Isaac ConnorandClaude Opus 5 bdf688eecb fix: carry the zone's alarm colour on the alarmed and filtered pixel methods
The alarmed/filtered pixel check methods handed Overlay() the raw GRAY8
scoring mask. Overlay keys on a non-zero source pixel and copies that byte, so
the highlight took whatever shape one byte has in the destination format: the
red channel on an RGB32 monitor, which is the whole reason alarms have always
come out red; all three channels on RGB24, giving white; luma alone on YUV420.
The zone's configured Alarm Colour was honoured only on the blob path, which
goes through HighlightEdges.

Generalise HighlightEdges into BuildHighlight, which takes an edges_only flag
and otherwise fills every marked pixel, and build the highlight for the pixel
methods the same way the blob path already builds its outline: in the
capture's own pixel format, carrying alarm_rgb, once scoring is finished with
the GRAY8 mask. HighlightEdges stays as a thin wrapper so the blob path and
its callers are unchanged.

This is a behaviour change: monitors left on the default red see no
difference, but a zone configured with any other Alarm Colour now paints that
colour instead of red or white.

Reverting the zone hunk fails the new test in all three of its sections.
Full suite: 148 cases, 12535 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBHBB95RBX7D9p8ge2WDZb
2026-09-22 09:57:19 -04:00
Isaac ConnorandClaude Opus 5 5b2dd2abbe fix: do not fault in the shm time accessors before connect() has mapped
Monitor::connect() returns false with shared_data still null on every one of
its failure paths: the mmap file cannot be opened (wrong ownership, e.g.
after a package upgrade), fstat fails, ftruncate cannot grow it (/dev/shm out
of space -- a container with the default 64MB tmpfs hits this quickly, since
one 720x480 monitor with 10 buffers already asks for ~20MB and a 1080x720 one
asks for ~62MB), or mmap itself fails.

zmc's startup loop reacts by retrying:

    while (!monitor->connect() and !zm_terminate) {
      Warning("Couldn't connect to monitor %d", monitor->Id());
      monitor->SetHeartbeatTime(std::chrono::system_clock::now());
      sleep(1);
    }

so the first thing it does after a failed connect is write through the null
pointer. zmc dies with SIGSEGV at address 0x80 instead of retrying, which
presents as a monitor that will not start and a capture daemon that keeps
crashing. The accessors on either side of these already guard with
`if (shared_data && shared_data->valid)`.

Reverting the guard fails the new test with SIGSEGV at zm_monitor_shm.cpp:66,
and restoring it passes. Same fix as release-1.38's a550545e4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBHBB95RBX7D9p8ge2WDZb
2026-09-22 09:57:19 -04:00
dependabot[bot] 752c7ce5ee build(deps): bump github/codeql-action from 4.38.0 to 4.38.1
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.38.0 to 4.38.1.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4.38.0...v4.38.1)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.38.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 17:43:47 +00:00
Isaac ConnorandClaude Opus 5 81a36d5b42 chore: ignore the cmake and perl build artifacts that land in the source tree
Running cmake in the tree, and the perl module build, leave 22 files and
directories behind that nothing ignored: CTestTestfile.cmake at the root and
under tests/, DartConfiguration.tcl, _deps/, a tests_include-<hash>.cmake
whose name changes with the test list, __pycache__/, and the
ExtUtils::MakeMaker leftovers (MYMETA.json, MYMETA.yml, MakefilePerl, blib/,
pm_to_blib, output/) under each of scripts/ZoneMinder, onvif/modules and
onvif/proxy.

They were untracked and unignored, which makes git add -A a trap: it sweeps
all of them into the commit. That is how 98k lines of generated perl nearly
went in with the Importance logging change, and how the AGPL licensed
audioMotion-analyzer.js and two cmake files did go into a commit on the
audio-level-graph branch before being taken back out.

The MakeMaker names are matched without a path because they appear under
three different directories; output/ is matched by full path instead, since a
bare output/ would claim any directory of that name anywhere in the tree.
Verified that no tracked file is shadowed by any of these, and that git add -A
now stages nothing generated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:44 -05:00
Isaac ConnorandClaude Opus 5 428c048bac feat: measure the audio level on demand, with a meter in the editor
Reverts the previous commit's always-on measurement. Decoding audio for every
monitor that has it spends CPU on a number almost nothing reads, which is the
wrong trade even though it did solve the chicken and egg of picking a
threshold without ever seeing a level.

Measure when something is actually going to use the reading instead:

 - AudioDetection is on, as before, so nothing changes for a monitor that
   scores on audio; or
 - somebody asked. SharedData gains audio_level_until, a wall clock second
   the capture thread keeps measuring up to. The monitor editor's new level
   meter pushes it forward while it is on screen and the measurement lapses a
   few seconds after the page is left, so nothing has to send a stop and a
   crashed browser cannot leave a monitor decoding forever.

When the reading stops being wanted the decoder is released and the published
level and peak are cleared, so a stale number is not left looking current and
an old peak does not land on the next frame row written.

audio_level_until is carved out of analysis_pad rather than appended, so
SharedData stays 888 bytes and no existing offset moves; the static_asserts,
Memory.pm and Monitor.php are updated together and all three now agree the
field is at +880.

The meter itself is on the audio settings, shown whether or not
AudioDetection is checked, because the level is what you need in order to
choose a threshold. It draws the threshold currently in the input as a mark on
the bar so a reading can be judged against it before saving, and a monitor
whose zmc is not running reads "no reading" rather than a confident 0, which
would be indistinguishable from silence.

Frames.AudioLevel is therefore 0 again on monitors that do not score on audio.
That is what the graph already treats as "no audio data", so it draws no line
rather than a flat one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:44 -05:00
Isaac ConnorandClaude Opus 5 4619258293 feat: measure the audio level whatever AudioDetection is set to
Gating the measurement on AudioDetection made the graph useless for the job
it is most wanted for. AudioThreshold is a per-device number -- the floor on
one camera's mic is nothing like another's -- so it has to be measured before
it can be set, but nothing was measured until it was already set. Enabling
detection with a guessed threshold to find out what the real one should be is
backwards.

The level is now read for every monitor with decodable audio.  AudioDetection
governs only whether crossing the threshold contributes a score, which is
what the setting is named for. shared_data->audio_alarm stays 0 when it is
off, so nothing downstream changes for a monitor that does not want audio
alarms.

Nothing here depends on Analysing either. The measurement is in
Monitor::Capture, which runs on whatever Analysing is set to, and frame rows
come from Event::AddFrame, which a continuously recording monitor reaches
through the RECORDING_ALWAYS path with motion detection off. So a monitor
that only records continuously still gets levels on its rows.

Since Monitor::Capture retries Open on every audio packet until it succeeds,
and that now happens for every monitor with audio rather than the handful with
detection on, AudioDetector remembers a codec it has already failed to find a
decoder for. Without it a stream ZoneMinder cannot decode logs a warning at
the audio packet rate for as long as the monitor runs. A reconnect bringing a
different codec is still tried.

The cost is one audio decode per monitor with audio, where before it was one
per monitor with detection enabled. That is small next to the video path, but
it is not nothing on a box with many cameras.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:44 -05:00
Isaac ConnorandClaude Opus 5 4ea307a1cf feat: graph motion score and audio level under the event video
The cue strip under the video showed one flat red band per alarm period. It
tried to encode the score as a bar height, 'height: '+frame.Score+'px', but
never could: the frames ajax did not return Score, so every bar got
'height: undefinedpx' and fell back to the stylesheet's height: 100%. So the
motion level it looks like it is drawing has never actually been drawn.

Replace it with a line graph of both series over the length of the event. The
alarm periods stay, as a pale wash behind the lines, so nothing that was
readable before is lost.

The two series do not share a vertical scale. Audio level is 0-100 by
construction, but a motion score is a sum over zones with no upper bound, so
pinning both to 0-100 would flatten the audio line against the floor on any
event scoring above 100. Each is scaled to its own maximum, and hovering reads
out the exact values, which is what the numbers are wanted for. An event whose
rows are all zero -- recorded before the column existed, or by a monitor with
AudioDetection off -- draws no audio line at all, rather than a flat line
claiming silence was measured.

The readout goes into the existing #indicator, which already tracks the mouse
across the whole progress bar, instead of a second tooltip competing for the
same pixels.

The geometry lives in web/js/LevelGraph.js so it can be tested without a DOM,
and so the polyline and the hover readout cannot disagree about where a given
second sits. The bar grows from 1.25em to fit the graph, which needed two
consequential CSS changes: #indicator now spans the taller bar, and
.progressBox drops from 0.66 opacity to 0.25, because at full strength the
played part of the graph is unreadable behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:44 -05:00
Isaac ConnorandClaude Opus 5 33661907d1 feat: persist the peak audio level on each frame row
zm_update-1.39.31.sql gave monitors audio detection, but the level only ever
existed in shared memory, so it was gone the moment the frame passed and there
was nothing for the event view to plot. Add Frames.AudioLevel next to Score, on
the same 0-100 dBFS-derived scale the threshold uses.

What is stored is the peak since the previous row, not the level at the instant
the row was written. Frames rows are written well below the capture rate --
only alarm, bulk and score-increasing frames get one -- so sampling at write
time would drop exactly the short loud noises worth seeing on a timeline.
AudioDetector accumulates the peak as it decodes and Event::AddFrame takes it
where the row is built, which clears it so each row covers its own interval.
The Event constructor takes and discards it once, otherwise an event's first
row reports the loudest moment since the previous event ended.

This needs no shared memory change: zma is now an offline re-analysis tool and
the live analysis runs in a thread of zmc, alongside the capture thread that
runs the decoder, so the peak can stay in the AudioDetector. SharedData keeps
its documented 888-byte layout and its fixed offsets.

The frames ajax returns the column, and Score with it. elements in
web/ajax/status.php is a whitelist that never listed Score, which is why the
event view's cue strip has been reading an undefined Score off every frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 40c4cd3c5d fix: run reverse playback at the rate the user picked
changeRate's reverse branch did not use the selected rate as the reverse
speed. It looked it up as rates[rates.indexOf(-rate)-1]/100, stepping one
entry further down the shared rate list, so every reverse rate ran a notch
too slow: -1/2x played at 1/4x and -16x at 10x. -1/4x was worse than slow,
because one step below 25 in that list is 0, so revSpeed came out 0 and the
video sat still while the ui claimed it was rewinding.

The rate the user picked is the speed, so use it.

Leaving reverse through the dropdown also leaked the rewind interval, which
only pauseClicked and vjsPlay ever stopped. Picking a forward rate after a
reverse one left it running, so it went on dragging currentTime backwards and
resetting playbackRate to 0 on every tick while the player was supposedly
running forwards. The teardown is now stopRewind(), split out of
stopFastRev() because stopFastRev rewrites the rate select to 1x, which would
undo the choice changeRate is in the middle of applying.

stopFastRev no longer reads the rate back out of the player to decide what to
put in the select and the cookie, for the same reason streamFastFwd stopped
doing it: videojs can defer the set until the tech is ready, so the getter
still answers with the rate we just left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 ef361dc3ab fix: stop the event playback rate buttons stepping off the end of the rate list
Clicking fast forward once too many at 16x threw out of video.js:

  TypeError: HTMLMediaElement.playbackRate setter: Value being assigned is
  not a finite floating-point value.
    playbackRate@video.min.js
    streamFastFwd@..._views_js_event-....js

streamFastFwd stepped the shared rate list by indexing it directly,
rates[rates.indexOf(current)+1]. At the top of the list that is rates[15],
undefined, and undefined/100 is NaN, which Firefox refuses outright.

The guard meant to prevent this ran after the assignment rather than before
it, and read the rate back from the player to decide, so it could only
disable the button once the bad value had already been sent. It was reachable
in normal use because streamPlay() re-enables the button whatever rate we are
at, so play-then-fast-forward at 16x throws every time; picking 16x from the
rate dropdown gets there too, since changeRate does not touch button state.

indexOf also answers -1 for a rate that is not in the list, and -1+1 indexes
rates[0], which is -1600: stepping forwards from an unlisted rate asked for
16x reverse.

streamFastRev had the same fault at the other end. rates[0-1] is undefined, so
revSpeed became NaN and every tick of the rewind interval then handed
currentTime a NaN.

Both now go through stepRate, which snaps an unlisted rate to the nearest
listed one and returns null rather than walking off either end, so the caller
disables the button and leaves the player alone instead of assigning
something it cannot use. streamFastFwd also sets the dropdown and cookie from
the rate it just asked for rather than reading it back, because the stack in
the report shows videojs deferring the set until the tech is ready, at which
point the getter still answers with the old rate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 16a2831fe6 fix: weigh a monitor's Importance when logging that a device is unreachable
A speaker that has dropped off the network fails on every command until
somebody fixes it, so that is the one error in IPSpeaker that repeats forever
rather than once. On a monitor deliberately marked unimportant it is noise,
and it buries the failures worth acting on.

Nothing about that is specific to speakers, so the policy goes in Logger as
importanceLevel, with ErrorImportance and WarningImportance alongside the
plain Error and Warning for callers to use. It takes the importance value
itself rather than a monitor, so Logger needs to know nothing about monitors
and callers that have some other notion of how much something matters can
still use it.

zmwatch.pl has been open coding the same idea as
WARNING+$monitor->ImportanceNumber() in three places; it now calls
WarningImportance instead, which is the same arithmetic and so leaves its
levels exactly as they were, including the Not important case that lands in
DEBUG1. That case is pinned by a test rather than quietly corrected: it is
long standing behaviour and not this change's business. zmwatch no longer
needs the logger object it was keeping for logPrint, so logInit() is called
bare there as it is in every other script.

Anything that is not a number counts as Normal, so a caller with no monitor
to ask still reports in full: not knowing how much a monitor matters is no
reason to hide its faults.

IPSpeaker is then a one line change at the call site. Only the failure to
reach the device is weighed; a refusal or unparseable content means the device
answered and something is really wrong, which is worth an error however
unimportant the monitor is, and does not repeat the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 1ef80abb4f fix: accept the default export when loading audioMotion-analyzer
The loader looked for a named AudioMotionAnalyzer export and otherwise fell
back to window.AudioMotionAnalyzer, which is what the UMD bundle sets. The
src/ ES module that the import path actually expects exports the class both by
name and as its default, and default is the form upstream documents, so take
module.default as well. Without it a build that exports only a default would
silently fall through to an undefined window global and throw on .version.

Throw a named error when none of the three yields a class, so the existing
catch reports LoadFailed instead of a TypeError on undefined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 f36645dfd9 fix: point the audioMotion-analyzer install instructions at the ES module
The library is AGPL-3.0-or-later so it is not shipped, and the admin installs
it at skins/<skin>/assets/audioMotion-analyzer/src/audioMotion-analyzer.js.
The instructions for doing that had drifted from what the code loads:

- help.txt and the OPTIONS_WHATTODISPLAY help gave the bare
  https://cdn.jsdelivr.net/npm/audiomotion-analyzer@X.X.X URL, which resolves
  to the package's "main" entry, the minified UMD bundle dist/index.js. The
  install path is the package's src/ ES module, so the URL needs the explicit
  /src/audioMotion-analyzer.js suffix. Same for the download links in the
  AudioMotionVersionNotInstalled and AudioMotionVersionWrongVersion messages.
- The install path was written as /skins/MySkin/..., a placeholder that does
  not correspond to any skin.
- RequiresAudioMotionEnabled named only the file, not where it goes.
- assets/version documented every other asset in that directory but not this
  one, leaving no explanation for the otherwise empty directory.

help.txt no longer restates the required version, so 4.5.4 stays declared only
by SUPPORTED_AUDIO_MOTION_ANALYZER_VERSION as intended, and assets/version
points at that constant rather than duplicating it.

Add tests/js/audiomotion-paths.test.js to hold the PHP feature probe, the
dynamic import, help.txt and both lang catalogues to the same path, the same
download URL and the same version.

Also gitignore the installed library so a local install is not committed back
into a GPL-2.0 tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 8b14fba2db fix: bound the IPSpeaker http timeout instead of leaving it at LWP's default
LWP::UserAgent defaults to 180 seconds. Actions for a monitor are serialised
through one control daemon, so a speaker that has dropped off the network holds
that daemon for three minutes per attempt and every command queued behind it
waits. AMLink already sets a timeout; this did not.

Ten seconds, matching AMLink. The speaker answers in milliseconds when it is
reachable at all, so this only ever bounds the case where it is not - which is
the case right now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 70836e7c83 fix: recognise the AMLink plain-text reply that means the session expired
The camera does not report an expired session as a JSON error. It answers with
a bare printable string, "Invalid session in request", where a masked base64
payload belongs. Unmasking that and base64 decoding it produces noise, so a
routine timeout was logged as "malformed JSON string ... at character offset
0" - which named neither the session nor the camera's own explanation.

Detect it before unmasking, log what the camera actually said, and treat it as
a lost session so the existing recovery re-establishes it. session_error is
pure so the wording seen on the wire is pinned by a test rather than by a
camera happening to be in the right state.

refs #4423

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 b4599c16f2 fix: keep the control session alive while a monitor is busy, not only while idle
zmcontrol sent keepAlive only from the branch that runs when select() times
out. A monitor taking a steady stream of commands never reaches that branch,
so it never pinged at all, and cameras that expire a session on a timer
refresh it on the keepAlive rather than on ordinary requests. The monitors
with the most traffic were therefore the ones that lost their session.

Measured on monitor 1: it pinged twice while idle, then took a command every
three seconds for three minutes and the camera rejected the next one 181
seconds after the last ping. Its light commands were being dropped for as long
as the motion kept coming, which is exactly when they matter.

Move the decision into Control::keepAliveDue, timed from the last ping, and
check it at the top of the loop so the 'next' paths in the command handling
cannot skip it. A clock step backwards resets the baseline rather than holding
the ping off until real time catches up.

refs #4423

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 78ed0fee3c fix: re-establish the AMLink session when a reply cannot be decoded
An undecodable reply means the camera and this module no longer agree about
the session, but nothing put that right. Dahua_RPC re-logs-in only when it gets
a parseable error back, so rpc_call returning undef left the session poisoned:
in the logs a CoaxialControlIO.control failure is followed by every later
keepAlive and logout on that session failing the same way, until something else
forces a login. A light switched on by an alarm stays on for that whole period.

Split the single attempt out as rpc_once, recording why it failed, and have
rpc_call re-login and retry once when the failure was a decode failure on a
masked Request.

The guards matter more than the retry:
- bootstrap channels never recover, since login() issues them
- login() calls logout(), a Request on the dead session, so a re-entrancy guard
  stops its own decode failure starting another recovery
- one retry only, and a 5 second backoff, so a camera that always answers
  unreadably is not met with a login per command

refs #4423

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 c27f905470 fix: keep the AMLink decode diagnosis out of the part of the message that is discarded
The decode failure diagnostic appended its fields after $@, which ends in a
newline, so the payload length, alignment, unmasked-decode result and leading
bytes were pushed onto a second line where nothing looked for them.

$@ was also read after the unmasked-decode eval had already reset it, so the
error reported was that eval's outcome rather than the failure being described.

Add log_safe to flatten a message before logging, use it for the three places
that interpolate $@, capture the real error before running another eval, and
report the decoded byte count and the decoded leading bytes - the base64 alone
does not say whether what failed to parse was truncated or simply not ours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-20 18:17:43 -05:00
Claude 7502019ecb fix: copy the stream socket path into shm without a truncating strncpy
GCC at -O2 rejects strncpy with a bound equal to the destination size
(-Werror=stringop-truncation), which failed the Release CI build. The
preceding length check already guarantees the path and its terminator
fit, so copy size()+1 bytes with memcpy instead.

refs #5143

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4UcdJLt1bxwdpcigGxZRD
2026-09-20 01:48:19 +00:00
Claude 21042ba2f7 fix: send audio HELLO first and keep rtsp sessions across a zmc restart
Correct three problems in the stream socket generation tracking added by
the previous review fix-up:

- ClearAudioParams bumped the generation without restarting the video
  sequence or dropping the cached keyframe, so a late joiner received a
  HELLO at generation N+1 followed by a KEYFRAME still stamped N, and
  sequences did not restart as documented. It now does the same full bump
  as SetVideoParams/SetAudioParams.
- zm_rtsp_server rebuilt the xop session whenever the generation changed,
  even with identical codec parameters. Generations restart at 0 when zmc
  restarts, so every zmc restart dropped the RTSP clients; the original
  code kept the session in that case. Unchanged parameters now just adopt
  the new generation without a teardown.
- Deciding whether audio belongs to the current generation by comparing
  per-stream generation numbers raced the two HELLOs of a generation
  (double rebuild when Update() ran between them) and cannot tell a
  producer restart apart. The producer now sends the audio HELLO before
  the video HELLO within a generation (on connect and on every bump, and
  PrimeCapture announces audio before video), so the video HELLO always
  completes a generation's parameter set. The consumer forgets the
  previous generation's HELLOs when a new generation starts and on
  disconnect, and builds only once the video HELLO of the latest
  generation is in. It also records whether audio was announced at build
  time rather than whether a packer was created, so an unsupported audio
  codec no longer triggers a rebuild on every pass.

The ordering guarantee is documented in the protocol header and the
stream socket docs. Tests pin the audio-first order on connect and on a
video reconfigure, and the keyframe drop and sequence restart on audio
removal.

refs #5143

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4UcdJLt1bxwdpcigGxZRD
2026-09-20 00:36:35 +00:00
Claude d5012d5bc0 docs: document the shm field retirement convention for reserved_path2
The audio_fifo_path field was retired alongside video_fifo_path when the
media FIFOs were replaced by the stream socket. video_fifo_path was
repurposed as stream_socket_path, but a single socket carries both
streams so there is no separate audio path to publish; reserved_path2
stays reserved.

Spell out the convention where the field is defined, so a future reader
does not remove or reorder it (which would shift every later field for
out-of-tree shm readers) and knows the slot is free to reuse at the same
64-byte width. Align the PHP Monitor object and ZoneMinder::Memory notes.

refs #5143

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4UcdJLt1bxwdpcigGxZRD
2026-09-19 23:03:14 +00:00
Claude 2cb01222b0 docs: update stream socket protocol notes from PR review
- Describe pts_us as signed microseconds with AV_NOPTS_VALUE for unknown.
- Document that the socket path is published in the shared-memory
  stream_socket_path field, not only derivable from the convention.
- Clarify the snapshot event's sequence: it equals the next EVENT's
  sequence, and the snapshot is a state message a consumer must not treat
  as a duplicate of that following EVENT.

refs #5143

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4UcdJLt1bxwdpcigGxZRD
2026-09-19 23:00:30 +00:00
Claude 1dc0de1058 fix: drop superseded-generation media and join rtsp packers safely
Address rtsp consumer review findings:

- MonitorRtspStream drops media whose generation does not match the one
  the current xop session was built for. After a parameter change the
  reader thread records the new HELLO and generation before the main
  thread rebuilds the session; feeding that media to the old packer
  produced codec-mismatch output. Update() now also rebuilds on a
  generation change and treats audio as current only when its generation
  matches the video's, so an audio stream dropped at a generation bump is
  not rebuilt into the new session.
- Stop and join each packer's write thread (StopAndJoin) before deleting
  the source, while the object is still the most-derived type. The write
  thread calls the virtual PushFrame; joining only in the base destructor
  raced it against a call that had devolved to the pure base.
- Use 8 kHz as the G.711 timebase fallback when the HELLO omits the
  sample rate, rather than the AAC-oriented 44.1 kHz default.

refs #5143

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4UcdJLt1bxwdpcigGxZRD
2026-09-19 23:00:24 +00:00
Claude 89837a6b40 fix: start the monitor stream socket before capture connects
Address monitor-side stream socket review findings:

- zmc starts the stream socket before the first connect attempt, and
  SendStreamHealthEvent records the health state even when the socket is
  not up yet, so connection/prime faults during startup are observable to
  a consumer instead of being lost until the first successful prime.
- PrimeCapture announces audio whenever the camera has a decodable audio
  stream (Capture forwards audio packets unconditionally), and clears a
  previously announced stream when a re-prime no longer sees it.
- Publish the media stream socket path in the monitor shared-memory
  block (reusing the retired video_fifo_path field as stream_socket_path,
  same offset and size) so consumers discover it without hard-coding the
  convention or reading the producer's zm.conf; the PHP Monitor object
  and the ZoneMinder::Memory Perl module expose the renamed field.
- Move the wall-clock microseconds helper out of zm_monitor.cpp into
  zm_time.h as SystemClockMicros(), where time helpers belong.

refs #5143

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4UcdJLt1bxwdpcigGxZRD
2026-09-19 23:00:14 +00:00
Claude 281a7d967e fix: harden stream socket transport and protocol from PR review
Address several stream socket review findings on the transport, its
consumer client and the wire protocol:

- ParseAllowedUids rejects negative, out-of-range and non-round-tripping
  uids instead of wrapping or truncating them (e.g. 2^32 no longer
  becomes uid 0).
- StreamSocketClient backs off after a connection the producer closes
  before any message, so a rejected consumer (uid allow-list, client
  limit) no longer busy-loops; a rejection is not reported as a
  disconnect.
- SendMedia drops packets for a stream that has no announced HELLO, and
  ClearAudioParams forgets a previously announced audio stream (bumping
  the generation and re-issuing the surviving video HELLO), so a stale
  audio HELLO is never replayed and media never precedes its HELLO.
- Header pts_us is encoded as signed (two's-complement) microseconds so
  negative and AV_NOPTS_VALUE timestamps survive the wire; the dump tool
  decodes it as signed and tracks sequence gaps per generation so a
  generation reset is not mistaken for packet loss.

Tests cover the uid rejections, the audio HELLO clearing and media
guard, the connection-rejection backoff, and signed pts round-trips.

refs #5143

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4UcdJLt1bxwdpcigGxZRD
2026-09-19 23:00:03 +00:00
Isaac Connor 17774364bc Merge pull request #5150 from IgorA100/patch-622059
Fix: Avoiding errors when changing the "src" attribute during playback with go2rtc (video-stream.js)
2026-09-19 17:02:56 -04:00
IgorA100 f1b89471e4 Avoiding errors when changing the "src" attribute during playback with go2rtc "video-stream.js"
This can happen, for example, on the Console page when hovering over an image thumbnail, because the `video-stream` element created for go2rtc in `createGo2rtcStream()` is not assigned an ID.
2026-09-19 23:46:24 +03:00
Isaac Connor e0a619d3c9 Merge pull request #4980 from IgorA100/patch-756163
Start loop countdown only if the stream has started playing on the Watch page.
2026-09-19 10:20:10 -04:00
Isaac Connor 181ece7150 Merge pull request #5149 from IgorA100/patch-641284
Fix: PanZoom scaling on mouse click.
2026-09-19 10:17:45 -04:00
IgorA100 d27f4893e5 Merge branch 'master' into patch-641284 2026-09-19 11:39:50 +03:00
Isaac Connor 54694d6bba Merge branch 'master' of github.com:ZoneMinder/zoneminder 2026-09-18 19:42:25 -04:00
Isaac ConnorandClaude Opus 5 4a02f11e63 fix: tidy the filter view's email settings refs #5147
html_radio() emits <div>s, and a <div> closes an open <p>, so the Email
Format radios were parsed out of their row and landed on the line below
their label. Wrap that row in a div instead. The row label's width now
applies to direct children only, so the radios' own labels keep their
natural size.

Separate the email settings from the options above them with a rule,
rather than having Email To run straight on from Lock Rows.

zmfilter.pl falls back to ZM_EMAIL_HOST when a filter names no server, so
show that (or localhost, its default) as the Email Server placeholder
instead of leaving the field looking unset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:04:04 -04:00
Isaac ConnorandClaude Opus 5 86952cc0de fix: give the log panel its own strings refs #5147
The panel's messages came from the global translate object that
views/js/log.js.php defined, which the footer only loads for the Log
view. On any other view Clear Logs threw "translate is not defined" out
of deleteLogs() before it sent anything, and a failed table query would
have thrown the same way.

Carry the five strings on the panel element as data-i18n and read them
in initLogPanel(), so a panel is self-contained wherever it is embedded.
views/js/log.js.php held nothing else, so it goes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:04:04 -04:00