dev #19

Merged
elirtf merged 60 commits from dev into master 2026-07-27 17:58:38 +00:00
Owner
No description provided.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Discovery's bulk-add endpoint now accepts a per-device `groups` payload
(name/display_name/ip/cameras) instead of a single virtual group name,
creating NVR rows with real ip_address/username/encrypted password so
Sync and the native-events hub can use them. Existing hollow rows
(NULL ip/username/password from the old virtual-group flow) are
backfilled from scan creds, never overwritten if already populated.

tests/test_native_detection.py's discovery/add test used the old
group_name/cameras shape (the endpoint's only caller besides
Discovery.jsx, updated in Task 2) — rewritten to the groups payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Discovery's buildDefaultCameras named streams `<group>-cam1-stream1` when
guessStreamType() failed to classify typical Hikvision ONVIF profile labels,
producing camera names that never end in `-main` — invisible on the
Dashboard (which filters on `-main`) and skipped by motion/detection
workers. Now: parse the RTSP path for /streaming/channels/N (matching
app/processing/native_events.py::channel_from_rtsp) to derive a channel +
main/sub role, mirroring the NVRs-tab import naming scheme
(`<nvr>-ch1-main`) so both add flows are interchangeable. When the URL
doesn't carry a channel, fall back to guessStreamType(), then assume
ONVIF main/sub profile order for 2-stream devices, then stream index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Standalone ISAPI search client for NVR-owned recording playback:
parses CMSearchResult XML into (status, spans) with Z-stripped naive
wall-clock timestamps, and paginates search_recordings() by items
actually returned (numOfMatches is the total match count, not page size).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add tests for search_recordings pagination (accumulates spans across
pages and advances by items returned, not by numOfMatches — the
firmware reports the day's TOTAL there), and for the 401/403 auth
failure and generic HTTP error paths. Also drop the unused
logger/logging import in app/isapi.py — nothing in the module logs.
Adds app/nvr_playback.py with resolve_nvr_target, build_playback_source,
mint_stream_name, and max_streams helpers for NVR-owned recording playback
(task 3 of the NVR playback spec).
Adds GET /api/nvr-playback/<id>/timeline, POST /api/nvr-playback/<id>/play,
and DELETE /api/nvr-playback/stream/<name> to the existing playback blueprint,
following the same auth + camera-access-check pattern as /segments. Play
registers a per-viewer go2rtc stream via patch_stream_only (never PUT) after
a lazy sweep_idle_streams cleanup, and rejects with 503 once at capacity
(max_streams()). Registers the playback blueprint in the test app fixture
and adds an an_nvr_camera fixture + auth_headers fixture for the new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The NVR playback teardown route only checked the stream-name prefix,
letting any authenticated user tear down another camera's playback
stream by guessing its enumerable camera_id. Parse the camera_id out
of nvrpb-<id>-<token> and enforce _user_can_access_camera before
deleting; orphaned streams (camera already deleted) still tear down
as a cleanup backstop.

Also add missing test coverage: DELETE bad-prefix/authorized/forbidden
paths, a sweep-before-count assertion on the over-cap play test, and a
403 test on timeline that proves access is checked before any NVR work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cv2.imwrite() returns a bool and never raises on failure (disk full,
bad permissions, etc). _snapshot_event previously ignored the return
value and always created a RecordingEvent row, even when the JPEG
write silently failed, leaving a phantom event pointing at a missing
or zero-byte file.

Capture the imwrite result and check it alongside the file size; on
failure, log a warning, remove any zero-byte file, and return without
creating a RecordingEvent — mirroring the existing CAPTURE_MIN_SIZE
safeguard in _stop_recording.
Add nvrTimeline, nvrPlay, nvrStop bindings to playbackApi, wrapping
the Task 5 backend routes (GET timeline, POST play, DELETE stream).
PlaybackPage branches on recording_policy=nvr: fetches the NVR timeline,
renders discrete spans on the existing strip, and on span click calls /play
then mounts Go2rtcMsePlayer keyed on the returned stream (remount = seek).
Best-effort nvrStop on stream change/unmount. CameraPolicySelect gains an
'NVR owns recordings' option gated on the camera having an NVR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generation counter discards a superseded /play response and tears down its
minted stream, so rapid seeks can't let an older click win. CameraPolicySelect
falls back to 'off' if a camera is on nvr policy but has lost its NVR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- _sync_policy_and_enabled and the camera CREATE handler now treat
  recording_policy="nvr" like "off" for recording_enabled, so the recorder
  never writes clip files for NVR-owned cameras.
- native_events._reconcile widens its subscribe-set filter to include
  recording_policy="nvr" cameras (independent of recording_enabled),
  mirroring engine.py's eligibility query, so native motion events are
  still picked up for them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
resolve_nvr_target now mirrors native_events.endpoint_for_camera: if the stored
NVR password can't be decrypted (Fernet key mismatch) or isn't set, use the
credentials embedded in the camera's own RTSP URL instead of hard-failing.
This unblocks setting recording_policy=nvr on deployments where the NVR-row
password predates the current encryption key.

Also adds an 'NVR' bulk-apply button to each NVR site group in Recordings →
Settings, so all cameras behind an NVR can be switched at once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The timeline fetch previously swallowed all errors (setNvrSpans([])), so a 502
from the route or a genuinely empty day both showed as a blank timeline with no
explanation. Now shows a loading state, the actual error reason, or an explicit
'no footage for <date>' message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 8 added the NVR timeline/player to PlaybackPage.jsx, but /playback
redirects to /recordings which renders RecordingsPage — so the NVR branch was
never reachable. Add it to RecordingsPage: nvr cameras load the NVR timeline
(discrete spans mapped to the Timeline segment shape), clicking a span mints a
playback stream and plays it through the MSE player (remount = seek), with
stream teardown on replace/unmount and a visible empty/error state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
go2rtc's /api/streams returns HTTP 400 for any src whose fragment contains a
literal space, so the tuned '#raw=-preset:v veryfast -crf:v 23 -maxrate:v 6M
-bufsize:v 12M' flags (which worked only as a config-file value in the
prototype) can't ride in an API-registered source — this was the 'Failed to
register playback stream with go2rtc' error. Use '#video=h264#width=1920':
downscaling to 1080p keeps the bitrate reasonable (~8-9 Mbit on 4MP HEVC) and
the source is space-free so the API accepts it. Verified end-to-end against the
real NVR (register 200, first frame 2.7s). Tighter rate control needs a named
ffmpeg template in go2rtc config — deferred.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NVR-owned cameras record JPEG snapshot events, not mp4 clips, but the event
file route served everything through serve_mp4_file which rejects non-.mp4
('Invalid file type.'). Serve .jpg/.jpeg snapshots as image/jpeg (same path/
access guards), and render them as <img> in the Events tab instead of <video>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Events tab: clicking a JPEG snapshot no longer loads it into the video player
  (kept backend jpg serving for thumbnails / other uses).
- Playback: surface a 'starting/transcoding' hint and, if no frames arrive
  within 12s, a message pointing at the go2rtc container logs — instead of an
  unexplained black frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of black NVR playback: go2rtc implements ffmpeg: sources as
exec:ffmpeg internally, which fails with 'unsupported scheme: exec:' unless the
exec module is enabled. The existing gate only enabled exec for config-time
ffmpeg: sources (_streams_need_exec_module), but NVR playback registers its
ffmpeg: transcode streams dynamically via the API, so they never tripped it —
the module stayed off and every playback produced no frames (black, no error).
Enable exec whenever any camera uses recording_policy=nvr (exec allow_paths
stays restricted to 'ffmpeg'). Verified live: frame grab through the production
go2rtc went from 0 bytes to a real 330KB frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a web-role daemon that every ~20s deletes nvrpb-* go2rtc streams with no
consumers. The frontend best-effort stops its own stream on unmount/replace, but
a closed tab or crashed client can leave one registered; go2rtc drops the ffmpeg
producer when the last consumer disconnects, and this reaper clears the leftover
entry so playback streams (and their transcodes) can't accumulate when no one is
watching. Interval tunable via NVR_PLAYBACK_REAPER_INTERVAL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
go2rtc 1.9.14's stream DELETE keys off the src query param; passing name returns
200 but leaves the stream registered. stream_delete and sweep_idle_streams both
used name, so stream teardown silently no-opped — the NVR reaper counted deletes
that never happened and abandoned nvrpb-* streams never went away. Verified
against the live go2rtc: ?name= leaves the stream, ?src= removes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the live-MSE NVR playback (which lagged and had no controls) with a
video-on-demand clip: the clicked span is downloaded from the NVR via ISAPI
ContentMgmt/download (~40x realtime, verified 0.3s for a 20s span), transcoded
to a seekable H.264 1080p MP4, cached, and played in a new NvrClipPlayer with a
scrub bar, play/pause, frame-step, 0.25-4x speed (real fast-forward, since the
whole clip is local), and scroll-to-zoom / drag-to-pan. Fixes the lag and
delivers backward/forward/speed/zoom that the live stream could not.

- app/isapi.py: download_span() via ISAPI ContentMgmt/download
- app/nvr_clip.py: download + transcode + on-disk cache + sweep
- GET /api/nvr-playback/<id>/clip?start=&end= serves the cached MP4 (range/seek)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clip-based playback (download + transcode) replaced the go2rtc live-MSE
approach, leaving a whole layer unused. Remove it:
- routes: nvr_play / nvr_stop (per-viewer go2rtc streams)
- nvr_playback.py: build_playback_source, mint_stream_name, max_streams
- services/nvr_stream_reaper.py (+ lifecycle wiring) — no nvrpb streams to reap
- go2rtc_config.py: _nvr_playback_in_use — nvr no longer needs the exec module
- frontend: revert PlaybackPage.jsx (NVR edits landed on this unrouted dead page
  by mistake); drop unused nvrPlay/nvrStop API bindings
- prune tests for removed code; add clip-route tests (validate/serve/403)
- gitignore app/instance/ (runtime DB + encryption key)

resolve_nvr_target and the go2rtc DELETE-by-src fix stay (still used / also
fixed live view).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live acceptance replaced the go2rtc live-MSE transport with downloaded seekable
clips. Add an 'As-built' section to the spec and a pointer on the plan so §3/§4
(go2rtc streams, MSE, PlaybackPage) aren't mistaken for the shipped design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Some NVR clips load, some don't. Concrete debug plan for a fresh session:
instrument first, characterize which spans fail (duration/channel/boundary/
concurrency), diagnose a known-failing span server-side, fix, verify. Hold the
branch merge until clips load reliably.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merge-blocker: nvr_timeline/nvr_clip missing @recordings_view_allowed. Plus
non-blocking findings (recording_enabled desync guard, unbounded clip span,
per-key transcode lock, pan clamp). Branch otherwise reviewed clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of 'some clips don't load': software HEVC->H.264 runs ~0.45x realtime,
so a long span (up to 25 min observed) exceeded the 180s transcode timeout and
never loaded. Short spans (p90=66s) were fine. Fix:
- Cap the transcoded window to NVR_CLIP_MAX_SECONDS (default 240s): download +
  transcode only the first N seconds of a long event, so prep is bounded (~110s
  worst case, verified on the 25-min span) and the clip always loads. Returns a
  'truncated' flag → X-Clip-Truncated header → the player notes it.
- NvrClipPlayer: 180s load timeout so a failure shows an error instead of an
  infinite 'Preparing clip…'; truncation banner for long events.
- Security (whole-branch review merge-blocker): add @recordings_view_allowed to
  nvr_timeline + nvr_clip — they were reachable by users with camera access but
  can_view_recordings=False, unlike every other recorded-footage route.

Hardware-accelerated transcode (to lift the cap) remains the deferred follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deeper root cause of clip errors: the NVR's ISAPI ContentMgmt/download endpoint
garbles concurrent downloads (interleaved HTTP → BadStatusLine/IncompleteRead),
so rapid clicks made some clips fail; concurrent ffmpeg transcodes also starved
each other past nginx's 120s upstream timeout (504). Fix:
- Default NVR_CLIP_MAX_CONCURRENT=1 (serialize download+transcode) + per-cache-key
  lock (dedupe identical in-flight spans). Verified: 4 concurrent requests that
  previously corrupted now all succeed, serialized.
- nginx: dedicated /api/nvr-playback/<id>/clip location with a 300s read timeout
  (the general /api/ 120s could 504 a legit slow clip).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7-day-old spans near the NVR retention edge have corrupt packets; ffmpeg's error
recovery (frame duplication) made the transcode slow/variable and it occasionally
hit the 180s timeout -> 502 'could not load clip'. Add -fflags +discardcorrupt
+genpts -err_detect ignore_err to drop bad packets, and raise the transcode
timeout to 240s (still < nginx's 300s clip timeout).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Investigation verdict: hardware transcode is impossible on this box (only GPU is
a 2011 Fermi Quadro 600 — no NVENC, no QuickSync, no VAAPI encode). But the NVR
download is HEVC, and browsers increasingly play HEVC directly. So default to a
stream-copy REMUX (no re-encode): a clip is ready in ~download time with near-zero
CPU — verified a 25-min span in 33s UNCAPPED (vs H.264 capping to 4min + 112s
transcode), short spans ~2-3s. Scales to many concurrent reviewers.

- ensure_clip(fmt): 'hevc' = download+remux (instant, uncapped); 'h264' =
  download+transcode (slow, capped) — the fallback. /clip?format=h264 selects it.
- NvrClipPlayer tries HEVC first; on a decode error (e.g. Firefox) it retries once
  with ?format=h264 automatically. Reviewers on Chrome/Edge/Safari get instant
  full-span playback; others get the transcode fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Browse & play NVR footage from Opus: ISAPI timeline, seekable HEVC clips
(remux, no transcode) with H.264 fallback, labeled snapshot events, and the
credential/concurrency/retention fixes from live acceptance.
elirtf merged commit fd14decfd4 into master 2026-07-27 17:58:38 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
elirtf/opus!19
No description provided.