From 2e1c97b10070ecb4d91393301d7d4c5ca901c288 Mon Sep 17 00:00:00 2001 From: Nic Boet Date: Mon, 12 Oct 2026 09:55:40 -0500 Subject: [PATCH] fix: write the multipart boundary for stored JPEGs sent by nph-zms MonitorStream::sendFrame(filepath) sends a swap file as it is when the stream is JPEG at scale 100 without zoom, which is what buffered live playback (rewind, fast reverse, step) uses. That path wrote only Content-Length and X-Timestamp, so the part had no --ZoneMinderFrame boundary and no Content-Type, and the multipart/x-mixed-replace stream broke. The boundary and Content-Type were written here until the merge in dbb2535a6 dropped them. Write them as sendFrame(Image *) does. Test: the raw path's output starts with the boundary and Content-Type. fixes #5226 Co-Authored-By: Claude Opus 5.5 --- src/zm_monitorstream.cpp | 4 +++ tests/zm_monitorstream.cpp | 57 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/zm_monitorstream.cpp b/src/zm_monitorstream.cpp index 3ed4c1de2..77a09d6a4 100644 --- a/src/zm_monitorstream.cpp +++ b/src/zm_monitorstream.cpp @@ -371,7 +371,11 @@ bool MonitorStream::sendFrame(const std::string &filepath, SystemTimePoint times // Calculate how long it takes to actually send the frame TimePoint send_start_time = std::chrono::steady_clock::now(); + // Same part headers as sendFrame(Image *): send_raw is only set for + // STREAM_JPEG, which is multipart. if ( + (0 > fputs("--" BOUNDARY "\r\nContent-Type: image/jpeg\r\n", stdout)) + || (0 > fprintf(stdout, "Content-Length: %d\r\nX-Timestamp: %.6f\r\n\r\n", img_buffer_size, std::chrono::duration_cast(timestamp.time_since_epoch()).count())) || diff --git a/tests/zm_monitorstream.cpp b/tests/zm_monitorstream.cpp index 403c7b8b8..a1340e166 100644 --- a/tests/zm_monitorstream.cpp +++ b/tests/zm_monitorstream.cpp @@ -23,9 +23,15 @@ #include "zm_monitor.h" #include "zm_zone.h" +#include +#include +#include #include +#include #include +#include + TEST_CASE("MonitorStreamBufferLevel") { SECTION("zero buffer count does not divide by zero") { // Regression for zoneminder/zoneminder#4936: processCommand() could run on @@ -107,11 +113,32 @@ class TestMonitor : public Monitor { // which needs the database. Hand one in directly instead. class TestMonitorStream : public MonitorStream { public: + using MonitorStream::sendFrame; void setMonitor(const std::shared_ptr &p_monitor) { monitor = p_monitor; } }; +// What a call writes to stdout, through a temporary file. +template +std::string CaptureStdout(F f) { + char path[] = "/tmp/zm_test_stdout_XXXXXX"; + int fd = mkstemp(path); + REQUIRE(fd >= 0); + fflush(stdout); + int saved = dup(fileno(stdout)); + dup2(fd, fileno(stdout)); + f(); + fflush(stdout); + dup2(saved, fileno(stdout)); + close(saved); + close(fd); + std::ifstream in(path, std::ios::binary); + std::string out((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + unlink(path); + return out; +} + } // namespace TEST_CASE("MonitorStream releases its monitor when destroyed", "[Stream]") { @@ -135,3 +162,33 @@ TEST_CASE("MonitorStream releases its monitor when destroyed", "[Stream]") { REQUIRE(watched.expired()); } + +TEST_CASE("MonitorStream sends stored JPEGs as multipart parts", "[Stream]") { + // Buffered playback at scale 100 without zoom sends the swap file as it is + // (sendFrame(filepath)'s raw path). Each part needs the boundary and its + // Content-Type like the frames sendFrame(Image *) writes, or the + // multipart/x-mixed-replace stream breaks during rewind or step. + EnsureConfig(); + config.timestamp_on_capture = true; + + char jpeg_path[] = "/tmp/zm_test_swap_XXXXXX"; + int fd = mkstemp(jpeg_path); + REQUIRE(fd >= 0); + const std::string jpeg = "\xff\xd8not really a jpeg\xff\xd9"; + REQUIRE(write(fd, jpeg.data(), jpeg.size()) == static_cast(jpeg.size())); + close(fd); + + TestMonitorStream stream; + stream.setStreamType(StreamBase::STREAM_JPEG); + bool sent = false; + const std::string out = CaptureStdout([&] { + sent = stream.sendFrame(std::string(jpeg_path), SystemTimePoint(Seconds(1700000000))); + }); + unlink(jpeg_path); + + REQUIRE(sent); + const std::string head = "--" BOUNDARY "\r\nContent-Type: image/jpeg\r\nContent-Length: " + + std::to_string(jpeg.size()) + "\r\n"; + REQUIRE(out.compare(0, head.size(), head) == 0); + REQUIRE(out.find(jpeg) != std::string::npos); +}