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_eventstream.cpp b/src/zm_eventstream.cpp index 75e24cb96..ea7d5ace0 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,8 +478,9 @@ 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; + 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; @@ -489,10 +492,14 @@ void EventStream::processCommand(const CmdMsg *msg) { } case CMD_STOP : Debug(1, "Got STOP command"); + stopped = true; paused = false; + step = 0; + send_twice = false; break; case CMD_FASTFWD : { Debug(1, "Got FAST FWD command"); + stopped = false; paused = false; // Set play rate switch (replay_rate) { @@ -517,6 +524,7 @@ void EventStream::processCommand(const CmdMsg *msg) { break; } case CMD_SLOWFWD : { + stopped = false; paused = true; replay_rate = ZM_RATE_BASE; step = 1; @@ -526,6 +534,7 @@ void EventStream::processCommand(const CmdMsg *msg) { break; } case CMD_SLOWREV : { + stopped = false; paused = true; replay_rate = ZM_RATE_BASE; step = -1; @@ -535,6 +544,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 +703,7 @@ void EventStream::processCommand(const CmdMsg *msg) { int zoom; int scale; bool paused; + bool stopped; } status_data = {}; { @@ -707,6 +718,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 +731,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 +1050,11 @@ void EventStream::runStream() { send_frame = false; TimePoint::duration time_since_last_send = now - last_frame_sent; - if (!paused) { + if (stopped) { + // 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); // If we are streaming and this frame is due to be sent @@ -1069,7 +1086,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]; @@ -1117,7 +1134,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]; @@ -1180,14 +1197,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 + // 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; - } // end if !paused + if (!stopped) curr_frame_id += step; + } // end if !paused && !stopped } // end scope for mutex lock if (type == STREAM_SINGLE) { diff --git a/src/zm_monitorstream.cpp b/src/zm_monitorstream.cpp index 68532864c..48ba5420f 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,19 +108,24 @@ void MonitorStream::processCommand(const CmdMsg *msg) { break; case CMD_VARPLAY : Debug(1, "Got VARPLAY command"); + stopped = false; if (paused) { 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"); - paused = true; + stopped = true; + paused = false; delayed = false; + step = 0; + send_twice = false; break; case CMD_FASTFWD : Debug(1, "Got FAST FWD command"); + stopped = false; if (paused) { paused = false; delayed = true; @@ -156,6 +163,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 +171,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 +179,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 +266,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 +310,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 +318,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, @@ -635,6 +648,12 @@ void MonitorStream::runStream() { std::this_thread::sleep_for(MAX_SLEEP); continue; } + if (stopped) { + // 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(); diff --git a/src/zm_signal.cpp b/src/zm_signal.cpp index e4586b95b..a2926a260 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__) + ip = (void *)(uc->uc_mcontext.mc_gpregs.gp_elr); +#else ip = (void *)(uc->uc_mcontext.pc); +#endif #elif defined(__arm__) +#if defined(__FreeBSD__) + ip = (void *)(uc->uc_mcontext.__gregs[_REG_PC]); +#else ip = (void *)(uc->uc_mcontext.arm_pc); +#endif #endif // Print the fault address and instruction pointer diff --git a/src/zm_stream.h b/src/zm_stream.h index 0422d8cc1..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 { @@ -75,7 +78,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 +95,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 +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 + VARPLAY_RATE_OFFSET) as network-byte-order uint16. CMD_VARPLAY, CMD_GET_IMAGE, CMD_QUIT, @@ -125,6 +139,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,7 +203,9 @@ class StreamBase { sd(-1), lock_fd(0), paused(false), + stopped(false), step(0), + send_twice(false), maxfps(DEFAULT_MAXFPS), base_fps(0.0), effective_fps(0.0), diff --git a/src/zm_videostore.cpp b/src/zm_videostore.cpp index acdc9d231..4b4c239af 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), @@ -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()}; @@ -84,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 @@ -521,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; } } @@ -562,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; } @@ -691,49 +692,11 @@ 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); - } - //delete pkt; - } - } - - if (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 @@ -1551,26 +1514,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 +1548,118 @@ 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; + + // 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"); + 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.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]; + 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..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 @@ -93,11 +96,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 +133,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) diff --git a/web/ajax/console.php b/web/ajax/console.php index aca7d36e8..e79250cfc 100644 --- a/web/ajax/console.php +++ b/web/ajax/console.php @@ -58,7 +58,9 @@ ajaxError('Unrecognised action '.$_REQUEST['action'].' or insufficient permissio function queryRequest() { global $user, $Servers; require_once('includes/Monitor.php'); + require_once('includes/Group.php'); require_once('includes/Group_Monitor.php'); + require_once getSkinFile('views/_monitor_filters.php'); $data = array( 'total' => 0, @@ -92,34 +94,46 @@ 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. + // getFilterSelection() reads $_REQUEST first, then the zmFilter_* cookie. $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' => 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 request filters to SQL + // Apply GroupId filter using get_group_sql() to include child groups. + // 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']); - $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_values(array_filter(array_map('validCardinal', $groupIds))); + 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]); + // 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); @@ -203,12 +217,15 @@ function queryRequest() { }); } - // Apply MonitorId filter + // 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']); - $filtered_monitors = array_filter($filtered_monitors, function($monitor) use ($monitor_ids) { - return in_array($monitor['Id'], $monitor_ids); - }); + $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); + }); + } } $data['total'] = count($filtered_monitors); 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/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; 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 diff --git a/web/js/EventStream.js b/web/js/EventStream.js index 24dc64ced..c9072ea24 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; @@ -90,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 @@ -202,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; @@ -247,6 +250,7 @@ function EventStream(config) { } this.started = false; this.connKey = null; + this.stopped = false; this.streamCmdParms.connkey = null; // Delay before restarting — exponential backoff @@ -457,10 +461,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); 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 */ 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; */ } 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 (%) 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; diff --git a/web/skins/classic/views/event.php b/web/skins/classic/views/event.php index 3b753fff4..38563a802 100644 --- a/web/skins/classic/views/event.php +++ b/web/skins/classic/views/event.php @@ -354,9 +354,14 @@ if (file_exists($Event->Path().'/objdetect.jpg')) {
DefaultVideo(), '.m3u8')) - && file_exists($Event->Path() . '/index.m3u8'); + // Prefer HLS byte-range playback when the manifest exists on disk and the + // user picked MP4HLS / auto. Explicit MP4 must stay native ("play the mp4 + // file directly"); explicit MJPEG never reaches here because $video_tag is + // false for it. DefaultVideo's extension is deliberately not consulted — + // in-progress events have DefaultVideo='index.m3u8' from the constructor, + // and using that as a signal would override the explicit MP4 choice. + $has_hls = file_exists($Event->Path() . '/index.m3u8') + && (($codec == 'MP4HLS') || ($codec == 'auto')); if ($has_hls) { $Server = $Event->Server(); $hlsSrc = $Server->PathToIndex() . '?view=view_hls&eid=' . $Event->Id(); @@ -402,7 +407,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 } 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 ); 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;