Skip to content

v2.3#16

Merged
RobXYZ merged 19 commits into
mainfrom
dev
Jun 11, 2026
Merged

v2.3#16
RobXYZ merged 19 commits into
mainfrom
dev

Conversation

@RobXYZ

@RobXYZ RobXYZ commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Added

Timeline Video Editor

A multi-camera timeline editor for the archive: scrub the front/rear channels on a shared playhead with per-clip filmstrips, set in/out trim points, and cut between cameras to export a single switched-angle video. Includes frame-accurate keyboard shortcuts. Join, picture-in-picture, and switched exports are now hardware-accelerated via Intel QuickSync where available, with VAAPI and software fallbacks.

Export Jobs

  • Animated filmstrip preview on each job — hover to scrub through the finished video.
  • Click a job's thumbnail to play the export in the viewer.
  • Output Length and Size columns in the jobs table.
  • Switched-camera exports now carry one continuous front-camera audio track, removing the audible jump at each camera switch.

Changed

  • Base image moved to Debian + jellyfin-ffmpeg to unlock QuickSync on Intel iGPUs; VAAPI and software remain as fallbacks, so a host without a working iGPU degrades transparently.
  • Archive updates live on a clip-indexed push instead of per-client polling, and the per-day GPS route aggregation is cached.
  • UI polish: background dither, clearer labels, and archive view state that persists across navigation.

Fixed

A broad reliability and security hardening pass (full per-item detail in CLAUDE.md):

  • Worker lifecycle — sync and export workers now shut down within a bounded timeout, and an in-flight or paused ffmpeg export is no longer left running after stop. Changing the dashcam address or toggling scheduled sync starts and stops the worker at runtime, without a restart.
  • Responsiveness — NAS directory walks, SQLite transactions, and the quota disk-usage scan run off the event loop, so a slow or busy recordings volume no longer freezes the UI and live updates.
  • Data safety — manual-import staging recovers completed clips after a crash instead of deleting them; downloads that fail their size check are rejected rather than archived truncated; corrupt or truncated MP4s no longer spin a worker at 100% CPU; and partial thumbnails, filmstrips, or exports can't be served from cache or left to count against the quota.
  • Disk full — a full recordings volume raises a sticky "disk full" sync error and pauses the queue, instead of marking every clip failed.
  • MQTT — reconnect backoff resets after a stable connection, retained discovery configs are cleaned up on the first node-id/prefix change, and timed-out probes are reaped.
  • Security — clip filenames and geocoded place names are HTML-escaped before display; the live-events WebSocket rejects cross-origin handshakes; the MQTT broker password is no longer returned by the settings API; and retention will not delete a clip while an export is reading it.
  • Queue counters update immediately after the Prioritise and Retry actions.

RobXYZ and others added 19 commits June 7, 2026 19:27
…clips

Squashes three minor fixes:
- Don't upload clips already in the archive but do import incomplete files
- Mark already-on-disk clips done so sync stops re-downloading
- Stop re-running ffmpeg on un-thumbable clips every sweep

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Opening a day (or the day route) re-parsed every GPX sidecar for that
date on each request — ~19s for 536 files on a busy day, and the day
view's "Loading…" blocks on it. The result only changes when the day's
GPX files change, so cache the aggregated payload keyed by a signature
of those files (path + mtime + size), persisted as a JSON sidecar under
$RECORDINGS/.route_cache so it survives restarts. Labels are applied on
read (not cached) so they stay current as the geocode cache fills.

First open still pays the parse; every later open is near-instant until
the day's GPX actually changes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
os.access(path, W_OK) is unreliable on NFS — it checks cached owner/mode
against the local UID and can report a genuinely writable export as
non-writable, while the real write is accepted by the server's own
permission mapping. This caused a false "recordings path not writable"
error for users on NFS mounts where manual writes succeed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
_connect_and_loop runs its workers in an asyncio.TaskGroup, which
reports a task failure as an ExceptionGroup rather than the bare error.
A routine broker disconnect (aiomqtt.MqttError, e.g. "Disconnected
during message iteration") therefore escaped the `except
aiomqtt.MqttError` clause, fell through to `except Exception`, was
logged as a fatal "unexpected error" with a full traceback, and set the
connection state to ERROR instead of RECONNECTING.

Unwrap the ExceptionGroup: propagate cancellation untouched (so shutdown
is never swallowed), classify a group of pure MqttErrors as a normal
reconnect, and only flag genuinely unexpected errors as ERROR.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Timeline video editor (clip filmstrips, duration probing, scrubbing) plus a move to a Debian/jellyfin-ffmpeg base image to unlock Intel QuickSync, with VAAPI and software fallbacks for join, PiP and switched exports.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Replace the 30s per-tab /api/archive/rescan poll with a clip_indexed WebSocket push, so the archive updates without every open browser tab re-triggering a scan (which was doubling scanner/duration logs).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Play/pause, set start/end, switch camera/view, plus navigation and zoom. Keys are surfaced in button tooltips and a '?' cheat-sheet overlay (role=dialog, aria-modal, raised above the player modal).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
A per-job filmstrip sprite, generated eagerly when the job finishes and served from a cache-only endpoint (no request-time ffmpeg, which previously caused a CPU storm when the jobs table re-rendered on every progress tick). The table shows one frame and scrubs the strip on hover; deleting a job cleans up its cached sprite. Built on a timestamp-driven sprite core (generate_sprite_at) refactored out of the clip filmstrip code.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Lift --bg out of the near-black zone where the GPU dithered visible stripes on the export/login backgrounds; rename the export 'Clear' button to 'Clear selected clips' and the 'GPS maps' filter to 'GPS Journey Splits'; persist open archive day(s) across navigation so returning from the timeline doesn't collapse them and lose your place.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
- Click a job thumbnail to play the export in the player modal.
- Switched exports take one continuous front-camera audio track, muxed at the end, eliminating the audio jump at camera switches.
- Silence benign libva init chatter in export logs (LIBVA_MESSAGING_LEVEL=1).
- Show a shimmer placeholder while a job's filmstrip generates, swapping to the real strip when ready (export_preview_ready event + has_preview).
- Add output Length and Size columns to the jobs table.
- Label the switched export type 'Timeline' in the table.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
- XSS: add escHtml() and escape clip filenames, geocode place labels,
  and the kind-badge class at every innerHTML sink (the filename
  regex allows arbitrary characters in the camera segment, and
  Nominatim labels are external data). escapeExportText now
  delegates to escHtml so attribute contexts get quote escaping.
- parse_moov: stop the moov child walk on any atom smaller than its
  own 8-byte header — a truncated power-loss clip used to spin a
  worker thread forever.
- get_dashcam_filenames: pass socket_timeout to urlopen; a half-open
  dashcam connection used to wedge the sync worker indefinitely.
- Event loop: move blocking I/O off the loop — listing reconcile
  (NAS walk + DB txn), ExportWorker._pop_next, MQTT state_fn/attrs_fn
  (disk_used walks the archive in quota mode), and the upload
  route's quota check + chunk writes.
- ExportWorker.stop(): SIGCONT + kill the in-flight ffmpeg child
  (a paused encoder outlived the server) and await the cancelled
  task so cleanup runs.

Also clears pre-existing ruff errors in tests (E702 semicolons,
unsorted imports) so `ruff check .` passes again.

Co-Authored-By: Claude Fable 5 <[email protected]>
Reliability and correctness batch, each item pinned by regression
tests (34 new tests; suite now 613):

- WebSocket /api/progress rejects cross-origin handshakes (4403) —
  the docstring claimed an Origin check that didn't exist.
- queue_changed events from threadpool route handlers no longer
  vanish: Hub.bind_loop() in lifespan + schedule_broadcast falling
  back to the bound loop (MQTT interceptor mirrors it).
- SyncWorker honours runtime ADDRESS / ENABLE_SCHEDULED_SYNC changes
  (start/stop via settings subscriber) and stop() bounds its waits
  with STOP_TIMEOUT instead of the dead wait_for branch.
- Retention can no longer delete clips a queued/running export will
  read: exporter.export_protect_ids() threaded through sweep and
  make_room_for at every call site.
- Thumbnails and sprite montages write to .part names renamed only on
  verified success — a killed ffmpeg no longer poisons the cache with
  a partial file served forever. ensure_thumb gained a 3-permit
  semaphore (a 100-clip day view used to spawn 100 ffmpegs).
- Download size verification falls back to the listing size when the
  HEAD probe fails, so truncated transfers aren't archived as done.
- Importer staging: recover_staging() re-ingests complete staged
  clips at boot and before folder ingest (a crash between the two
  same-volume renames used to cost the only copy — the next run
  deleted it); uploads stream to .part and are renamed only after
  size verification; cleanup keeps fresh .part files so a concurrent
  upload survives a folder ingest.
- MQTT reconnect backoff resets after a stable connection; discovery
  topology state is owned by MqttService.__init__; timed-out ffprobe
  children are killed and reaped.
- ENOSPC is a sticky disk_full sync error that stops the drain and
  refunds the attempt, instead of burning the whole queue's retry
  budget against a full disk.
- export_preview.preview_path() is pure (no makedirs side effect) —
  also removes the MagicMock/ repo-root dir leak from tests.
- Hub bounds each client send with SEND_TIMEOUT_S so one stalled tab
  can't starve every other client of events.
- Frontend: import modal fetches gained 401-redirect / CSRF-retry
  semantics (ifetch); WS reconnect uses exponential backoff with
  jitter and re-fetches list views after reconnecting; archive
  Leaflet maps are .remove()d before re-render; loadDays uses the
  stale-response token pattern.

Co-Authored-By: Claude Fable 5 <[email protected]>
Security hardening, resource hygiene, and concurrency-safety batch
(25 new tests; suite now 639):

- MQTT_PASSWORD is write-only over the API: GET returns a masking
  sentinel, a PUT carrying the sentinel means "unchanged", and the
  Test-connection endpoint substitutes the stored secret.
- The unauthenticated /api/setup/test-dashcam probe is confined to
  LAN-resolving targets, so it can't be used as a TCP-connect prober
  against arbitrary hosts during the setup window. The authenticated
  settings probe stays unrestricted (remote-VPN dashcams).
- The import scan endpoint returns a skipped count, not the filenames
  of whatever readable directory was scanned.
- The login rate-limiter sweeps stale per-IP buckets so the map can't
  grow unbounded; documents the proxy-IP keying trade-off.
- Exports stage to {id}.mp4.part renamed only on verified success;
  failures/cancels remove the partial and a startup sweep clears
  pre-existing orphans, so .exports no longer accumulates unreferenced
  files that count against the recordings quota.
- download_file takes max_attempts/socket_timeout as parameters
  instead of download_file_with mutating module globals, so concurrent
  downloads can't clobber each other.
- New web/services/tasks.spawn() holds a strong reference to detached
  fire-and-forget tasks until done (and logs their exceptions) —
  applied to the export/scanner/queue broadcasts, rescan sweeps, and
  the app.py settings callbacks.
- Settings subscribe() returns an unsubscribe handle; the lifespan
  detaches all callbacks on shutdown so the singleton provider stops
  leaking callbacks that pin dead app objects.
- Retry backoff sleeps are interruptible: a pause/stop is honoured in
  ~100ms instead of after the full 5-50s ladder.

Co-Authored-By: Claude Fable 5 <[email protected]>
The _is_lan_host restriction on the unauthenticated
/api/setup/test-dashcam probe is removed. It blocked legitimate
remote dashcams — Tailscale uses CGNAT 100.64/10 (not is_private),
and public DDNS to a port-forwarded camera resolves to a public IP —
while providing little real security: during the setup window the
unauthenticated setup form itself allows account takeover, which
dwarfs a TCP-connect/port-scan oracle. The README already warns not
to expose the container during setup.

Kept: the import-scan filename-leak fix (skipped_count, not names),
which is authenticated and has no such downside. Test renamed to
test_import_scan_no_leak.py.

Co-Authored-By: Claude Fable 5 <[email protected]>
…rt type to "timeline"

Collapse the timeline editor's export section (Switched/Front/Rear buttons +
"Export" label + inline status text) into a single primary "Export" button.
Queue feedback now flows through a new app-wide toast() primitive — a success
toast with a "View export jobs" action, an error toast on failure/empty —
replacing the small aria-live status line.

Retire the confusing export-type term "switched" -> "timeline" everywhere on
the wire and in code: API validation regex + dispatch, DB type value, worker
(enqueue_timeline/_run_timeline/_TIMELINE_PROTECT_MARGIN_S), EXPORT_TYPE_LABELS,
and the test suite. The separate switch-point editing concept (+ Switch Camera,
build_switch_pieces, .tl-switch*) is intentionally left unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
ffmpeg picks its output muxer from the filename extension. Staging to
{id}.mp4.part gave it a bare '.part' tail it can't infer, so the final
mux/encode failed with 'Unable to choose an output format' for every export
type (join, pip, timeline). Move the .part marker before the extension
({id}.part.mp4), matching the .part.jpg staging already used by
thumbs/filmstrip; _finish strips the infix on promotion. The orphan sweep
already covers both the new and legacy names.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The two POST /api/exports timeline tests passed locally (ffmpeg
installed) but failed in CI: enqueue_timeline() calls
ffmpeg_available() independently of the encoder check, raising 503
when ffmpeg is absent. Patch it to True like every other test in the
file, making them deterministic and letting requires_segments reach
the actual segment validation (400) instead of the ffmpeg short-circuit.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@RobXYZ RobXYZ merged commit 8e5295e into main Jun 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant