mysql_insert_id() returns my_ulonglong, but zmDbDoInsert() returned int.
Once Events.Id passed INT32_MAX the id was truncated, then sign-extended
when stored in Event::id (uint64_t), so 3502167073 became
18446744072916751373. The Events_Hour/Day/Week/Month inserts were given
the negative value and rejected, and every later UPDATE of the event
matched no row.
Also widen the callers that re-truncated the result: the re-analysis
event id in zma, Tag::Id() when building an Event_Tag, and
Event_Tag::save(). The Events_* insert format specifiers become PRIu64
to match the unsigned id.
fixes#4974
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BV1gDc9T6D6H8gtTcxwLvk
On event close ~Event() renamed the video file on disk synchronously but
queued the DefaultVideo=final DB update on the async dbQueue. The monitor's
close_event_thread forks EventEndCommand as soon as ~Event returns, so an
external consumer (e.g. AI analysis via the CakePHP API) could read the
stale DefaultVideo='incomplete.<codec>.<container>' after the file had
already been renamed, producing "File does not exist" errors.
Hard-link the file to its final <Id>-video.* name instead of renaming, so
it resolves under both names for the whole window the DB may still advertise
the old name. Write the finalized Events row synchronously (close runs in
its own thread, so this no longer blocks the analysis thread) and only then
unlink the incomplete.* name, once DefaultVideo=final is committed.
Dedupe by inode in the DiskSpace sum so the doubly-named file is not counted
twice, and guard the m3u8 URL rewrite against the link-failure fallback
where video_file == video_incomplete_file, which would loop forever.
During recording the incomplete video file is renamed to embed the codec
in its name (incomplete.<codec>.<container>) so canPlayCodec() works. The
VideoStore had opened the file under the old name, so FFmpeg's faststart
trailer re-open (oc->url) and the mfra read in finalize() could fail with
ENOENT after the rename.
Add VideoStore::set_filename() to update filename and oc->url, and call it
after the rename. Also warn when movflags contains faststart, which is
incompatible with the fragmented MP4 / HLS output this class produces.
zm_terminate is written from signal handlers and read from daemon loops.
A plain bool is undefined to access from a signal handler and may be hoisted
out of a loop by the optimizer. std::atomic<bool> is lock-free on supported
platforms and matches the already-atomic per-object terminate_ flags. Update
the three Debug() vararg sites to call .load() since atomics can't pass
through ...
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DefaultVideo was being set to 'index.m3u8' at INSERT when the video
writer was enabled, then overwritten with the codec-bearing mp4 name
at event close. That left a window during recording where consumers
saw 'index.m3u8' instead of a real mp4 filename, and forced every
consumer (view_video.php, image.php, event.php) to special-case the
'index.m3u8' string.
Now:
- INSERT leaves DefaultVideo empty.
- After videoStore->open() succeeds, UPDATE DefaultVideo to the
codec-bearing incomplete.{codec}.{container} name. If the rename
to add the codec failed, fall back to the plain incomplete name
so DefaultVideo always matches what is actually on disk.
- Close-time UPDATE (existing) sets the final {id}-video.{codec}.
{container}.
Invariant for consumers: non-empty DefaultVideo => a playable mp4 is
being written or has been written. The codec is encoded in the name
for the entire recording lifetime.
event.php: drop the str_ends_with(DefaultVideo, '.m3u8') branch from
$video_tag (codec detection on the next line already covers in-progress
events now that DefaultVideo carries the codec). Drop the
DefaultVideo === 'index.m3u8' download-tooltip special case. Remove
the stale comment about the constructor-time 'index.m3u8' value.
The === 'index.m3u8' checks in view_video.php and image.php are left
in place as belt-and-braces for any legacy DB rows recorded under the
old behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
47d3af70c rewrote DefaultVideo to 'index.m3u8' on close so event.php
would pick HLS for closed events. But several PHP call sites use
DefaultVideo as a local file path passed to ffmpeg or to filesize() —
Event.php's getImageSrc(), Length(), Filesize(), and Video() all rely
on it being a real video filename. ffmpeg can't resolve our manifest's
relative "index.php?…" segment URLs, so thumbnail/frame generation
would break for events with SaveJPEGs disabled.
The PHP-side fallback (10a4f9af1, 375e82fd8) keys HLS selection off the
on-disk index.m3u8 plus the codec choice, which is enough on its own to
route closed events through HLS without touching DefaultVideo.
Reported by Copilot on PR #4835.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Event::~Event() rewrote DefaultVideo to the renamed mp4 filename on every
close, which made event.php's HLS detection (str_ends_with DefaultVideo,
'.m3u8') return false for every closed event. The HLS player only kicked
in for in-progress events or when MP4HLS was manually picked from the
codec dropdown.
Detect index.m3u8 in the same dirent walk that already computes video_size,
and write 'index.m3u8' to DefaultVideo when it's present. mp4-only events
keep using the renamed mp4 as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The m3u8 manifest written by VideoStore had two interlocking bugs that
produced duplicate entries with bogus 0.040s durations and a missing or
mis-pointed final fragment, which made video.js seek backwards.
Root cause: with movflags=frag_keyframe the mov muxer flushes fragment N
to disk *inside* av_interleaved_write_frame() when keyframe N+1 arrives.
The previous code snapshotted avio_tell() *before* that call, so its
last_fragment_offset_ described the next fragment's start while the
fragment at that offset hadn't actually been flushed yet. writeM3U8
then push_back'd a tentative entry off that stale state on every live
update, and the next keyframe push pushed a second entry at the same
offset+size with the correct duration.
Now we snapshot avio_tell() *after* av_interleaved_write_frame(), which
gives the actual end of the just-flushed fragment, and record fragment
N-1 there. writeM3U8 no longer mutates fragments_ — it just emits the
list.
The final fragment (no later keyframe to close it) is recorded by a new
finalize() method on VideoStore that runs av_interleaved_write_frame
flush + av_write_trailer, then parses the trailing mfro box to subtract
the mfra trailer size from the file length. Event::~Event() calls
finalize() before writeM3U8(true); the VideoStore destructor skips its
own trailer write when finalize() has already run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fragment tracking:
- Rewrite detection to use avio_flush()+avio_tell() before each video
keyframe write, giving exact fragment boundaries. Fixes duplicate
entries and missing first fragment in the m3u8 manifest.
- Remove unused pending_fragment_dts_ and last_flush_pos_ members.
Live event playback:
- Set DefaultVideo to index.m3u8 at event INSERT time (no DB update
needed after videoStore opens)
- Rename incomplete file to include codec (incomplete.h264.mp4) so
canPlayCodec() works during recording
- Rewrite final m3u8 after video file rename to reference final name
- Add live retry handler in event.php: retries all error codes with
backoff (3s then 5s), max 30 retries, resets on successful playback
- Update progress bar duration from video element for live events
Fallback handling:
- view_video.php: when DefaultVideo is m3u8 and no file param given,
search event dir for actual mp4 (final name then incomplete)
- view_hls.php: reject m3u8 with no EXTINF entries (event just started)
- event.php: require m3u8 file to exist on disk before offering HLS,
fall back to direct MP4 otherwise
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Write a single continuous fragmented MP4 per event and generate an HLS
m3u8 manifest with byte-range references into that file. This enables
seamless browser playback via video.js's built-in http-streaming (VHS)
without needing separate segment files.
C++ changes:
- VideoStore tracks fragment boundaries (moof+mdat byte offsets and
durations) as packets are written, by monitoring avio_tell() around
keyframe writes in write_packet()
- Add writeM3U8() method that generates EXT-X-VERSION:7 byte-range
manifests with EXT-X-MAP for the init segment
- Event writes a live m3u8 (no EXT-X-ENDLIST) on each new fragment
for in-progress viewing, and a final VOD manifest at event close
- Change movflags to frag_keyframe+empty_moov+default_base_moof
(default_base_moof required by HLS fMP4 spec, faststart removed
as it's meaningless with empty_moov)
PHP/web changes:
- New view_hls.php endpoint serves pre-built m3u8 with auth tokens
- event.php detects index.m3u8 and uses HLS as primary source with
direct MP4 as fallback
- CSRF exemption for view_hls in index.php
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Event::Run could block indefinitely in PacketQueue methods during normal
event closing (closeEvent from analysis thread), because their wait
predicates only check deleting/zm_terminate, not Event's terminate_ flag.
Three changes fix this:
- get_packet_no_wait: return immediately when iterator at end instead of
blocking on condition variable (makes it truly non-blocking)
- Event::Run: use increment_it(wait=false) since deletePacket can advance
the iterator to end() during AddPacket_ without the queue lock
- Event::Stop: call packetqueue->notify_all() to wake timed waits so
Run() checks terminate_ promptly
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three changes to prevent the analysis thread from stalling and the
packet queue from filling up:
1. Replace blind sleep_for/usleep in Event::Run() with
packetqueue->wait_for() condition variable waits. The event thread
now wakes immediately when decoder/analysis completes or new packets
are queued, instead of always sleeping the full 33ms/10ms.
2. Add missing packetqueue.notify_all() calls after setting
packet->analyzed (Monitor::Analyse) and packet->decoded
(DECODING_NONE path in Monitor::Capture) so the event thread's
condition waits actually get signaled.
3. Replace synchronous zmDbDoUpdate() calls in Event::~Event() with
async dbQueue.push(). The two Events UPDATE queries (with Name
fallback logic) are combined into a single query using MySQL IF().
This eliminates blocking DB I/O from the close_event_thread, which
the analysis thread joins on the next closeEvent() call.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
rename() returns -1 on error, not an errno value. The actual error
code is stored in errno. Using strerror(result) with result=-1 gives
incorrect error messages.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>