diff --git a/src/zm_monitorstream.cpp b/src/zm_monitorstream.cpp index f35615cd1..40e059e31 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); +}