Skip to content
Merged

v2.3 #16

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a754382
fix(import): squash manual-import-tweaks — dedupe, skip un-thumbable …
RobXYZ Jun 7, 2026
53c18c4
perf(archive): cache the per-day GPS route aggregation
RobXYZ Jun 7, 2026
fd872da
fix(sync): probe recordings writability with real write, not os.access
RobXYZ Jun 7, 2026
19cbf30
fix(mqtt): treat TaskGroup disconnects as reconnecting, not fatal
RobXYZ Jun 8, 2026
efb4b5f
feat(timeline): video editor with QSV-accelerated exports
RobXYZ Jun 9, 2026
31a16f0
perf(archive): refresh on clip_indexed push, drop per-client rescan poll
RobXYZ Jun 9, 2026
fe12e86
feat(timeline): keyboard shortcuts for the video editor
RobXYZ Jun 9, 2026
de40dc8
feat(exports): animated filmstrip previews for export jobs
RobXYZ Jun 9, 2026
3ea017c
fix(ui): background dither, clearer labels, persistent archive state
RobXYZ Jun 9, 2026
ead719e
feat(exports): export-jobs UX improvements
RobXYZ Jun 9, 2026
007531d
fix: address High-severity code review findings
RobXYZ Jun 10, 2026
faa6d91
fix: address Medium-severity code review findings
RobXYZ Jun 10, 2026
e06d483
fix: address Low-severity code review findings
RobXYZ Jun 10, 2026
de9d24e
revert: drop LAN-only confinement of the setup dashcam probe
RobXYZ Jun 10, 2026
353c7b0
feat(timeline): single Export button with toast feedback; rename expo…
RobXYZ Jun 10, 2026
b81b59d
fix(exporter): stage exports to {id}.part.mp4 so ffmpeg can mux
RobXYZ Jun 10, 2026
20b6ff6
Update docs
RobXYZ Jun 10, 2026
29f6c04
Update README
RobXYZ Jun 11, 2026
619b895
test(timeline): patch ffmpeg_available in export route tests
RobXYZ Jun 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
# Changelog

## v2.3 — 2026-06-10

### 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.

## v2.2 — 2026-06-06

### Added
Expand Down
74 changes: 46 additions & 28 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,35 +1,53 @@
FROM alpine:3.23
FROM python:3.12-slim-bookworm
LABEL maintainer="Rob Smith https://github.com/RobXYZ"

# TARGETARCH is set automatically by `docker buildx build` (amd64,
# arm64, …). Plain `docker build` does NOT set it; in that case we
# fall back to `apk --print-arch`, which reports the actual
# container architecture (x86_64, aarch64, …) and is correct under
# both native builds and QEMU emulation.
# arm64, …). Plain `docker build` does NOT set it; fall back to dpkg.
ARG TARGETARCH

# System deps:
# - python3 + pip: runtime + installing web deps
# - ffmpeg: exports + thumbnails
# - bash, shadow, tzdata: entrypoint + PUID/PGID remapping
# - intel-media-driver, libva-utils (Intel x86_64 only): VA-API
# userspace + diagnostic tool. ffmpeg's h264_qsv / h264_vaapi
# need iHD_drv_video.so to talk to an Intel iGPU when the host
# maps /dev/dri into the container; without it the MFX runtime
# fails immediately with "MFX session: -9". `vainfo` from
# libva-utils is a one-liner diagnostic the operator can run via
# `docker exec` to verify the passthrough is wired up correctly.
# These packages don't exist on linux/arm64. The app's encoder
# probe (web/services/exporter.py) runtime-tests every candidate
# and falls back to libx264 software encode if QSV / VAAPI
# aren't available, so the missing packages on ARM degrade
# transparently.
RUN apk add --no-cache \
bash python3 py3-pip ffmpeg shadow su-exec tzdata && \
arch="${TARGETARCH:-$(apk --print-arch)}" && \
# - python is in the base image; pip installs web deps (PEP 668 ->
# --break-system-packages, safe in a container).
# - jellyfin-ffmpeg7: exports + thumbnails. Unlike Debian's stock
# ffmpeg (and unlike Alpine's musl build), the Jellyfin bundle ships
# the *legacy* Intel Media SDK runtime alongside oneVPL, which is what
# the DS920+'s Gen-9.5 (Gemini Lake) iGPU needs for QuickSync. Stock
# runtimes only drive Gen 12+, failing Gen 9.5 with "MFX session: -9";
# the bundle is exactly how Jellyfin solved QSV-in-Docker. It also
# bundles the iHD VAAPI driver, so VAAPI keeps working as a fallback.
# The binary installs to /usr/lib/jellyfin-ffmpeg/{ffmpeg,ffprobe};
# we symlink it onto PATH so shutil.which("ffmpeg") finds it unchanged.
# - gosu: privilege drop in entrypoint.sh (Debian's su-exec equivalent;
# same initgroups() semantics so the GPU render-group logic in
# setuid.sh keeps working).
# - vainfo (amd64): a one-line passthrough diagnostic. (On Debian the
# binary ships in the `vainfo` package, not Alpine's `libva-utils`.)
# On arm64 jellyfin-ffmpeg installs too but QSV simply won't probe-pass;
# exports degrade to software/VAAPI exactly as before. The app's encoder
# probe (web/services/exporter.py) runtime-tests every candidate and
# falls back to libx264, so a host without a working iGPU degrades
# transparently.
RUN set -eux; \
arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \
apt-get update; \
apt-get install -y --no-install-recommends \
bash ca-certificates gnupg gosu tzdata; \
install -d /etc/apt/keyrings; \
gpg_url="https://repo.jellyfin.org/jellyfin_team.gpg.key"; \
apt-get install -y --no-install-recommends curl; \
curl -fsSL "$gpg_url" | gpg --dearmor -o /etc/apt/keyrings/jellyfin.gpg; \
echo "deb [signed-by=/etc/apt/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \
> /etc/apt/sources.list.d/jellyfin.list; \
apt-get update; \
apt-get install -y --no-install-recommends jellyfin-ffmpeg7; \
case "$arch" in \
amd64|x86_64) apk add --no-cache intel-media-driver libva-utils ;; \
esac && \
amd64) apt-get install -y --no-install-recommends vainfo ;; \
esac; \
ln -sf /usr/lib/jellyfin-ffmpeg/ffmpeg /usr/local/bin/ffmpeg; \
ln -sf /usr/lib/jellyfin-ffmpeg/ffprobe /usr/local/bin/ffprobe; \
apt-get purge -y curl gnupg; \
apt-get autoremove -y; \
rm -rf /var/lib/apt/lists/*; \
useradd -UMr dashcam

COPY LICENSE /
Expand All @@ -40,9 +58,9 @@ ENV PUID="" \
PGID="" \
RECORDINGS="/recordings"

# Install Python deps into the system site-packages. Alpine's
# pip refuses by default (PEP 668); --break-system-packages is
# safe inside a container.
# Install Python deps into the system site-packages. pip refuses
# by default (PEP 668 on Debian Bookworm+); --break-system-packages
# is safe inside a container.
COPY requirements.txt /requirements.txt
RUN pip install --no-cache-dir --break-system-packages \
-r /requirements.txt
Expand Down
103 changes: 73 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,47 @@
# viofosync

Self-hosted web app for syncing, browsing, and exporting recordings from a Viofo dashcam (tested with the A229 Pro) over Wi-Fi. Runs as a single Docker container on a NAS or any always-on host on the same network as the dashcam.
![CI](https://github.com/RobXYZ/viofosync/actions/workflows/ci.yml/badge.svg) ![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg) ![Docker](https://img.shields.io/docker/pulls/robxyz/viofosync)

> **v2 is a full rewrite.** v1 was a cron-driven CLI based on [BlackVueSync](https://github.com/acolomba/BlackVueSync). v2 uses the same dashcam protocol but ships a web UI, journey-detected GPS maps, ffmpeg exports, JSON-backed settings, a first-run setup wizard, and a UI-driven download manager. The v1 cron CLI is preserved on the `main` branch.
Self-hosted web app for syncing, browsing, and exporting recordings from a Viofo dashcam (tested with the A229 Pro) over Wi-Fi. Runs as a single Docker container on a NAS or any always-on host on the same network as the dashcam.

![Download manager](screenshots/download_manager.png)
> **v2 is a full rewrite.** v1 was a cron-driven CLI based on [BlackVueSync](https://github.com/acolomba/BlackVueSync). v2 uses the same dashcam protocol but ships a web UI, journey-detected GPS maps, a timeline video editor, ffmpeg exports, JSON-backed settings, a first-run setup wizard, and a UI-driven download manager. The v1 cron CLI is preserved on the `main` branch.

## Features

- **Archive browser** — clips grouped by day, paired front/rear, in-browser playback.
- **GPS journeys** — clickable map per trip with auto-split stops and reverse-geocoded place names.
- **Exports** — original clips, joined front/rear or picture-in-picture videos via ffmpeg.
- **Download manager** — live progress with session speed and ETA, a reorderable queue.
- **Auto-delete from dashcam** *(optional)* — frees SD card space once a clip is safely downloaded.
- **Settings page** — runtime settings hot-reload; no Docker env vars to fiddle with.
- **Home Assistant support** — auto-discovered sensors and buttons via MQTT.
- **Automatic Wi-Fi sync** - clips copy from the dashcam in your car when it joins your home wi-fi.
- **Archive browser** - clips grouped by day, played in your browser. Nothing to install on your phone or laptop.
- **Journey maps** - automatic journey detection with each trip shown on a map with stops detected and place names looked up automatically.
- **Video editor** - trim clips and cut between the front and rear cameras, then export a single video.
- **Flexible exports** - original clips, joined front/rear, picture-in-picture, or edited cuts; hardware-accelerated where your system supports it.
- **Storage management** - set a size or age limit and the oldest footage is pruned to fit; optional auto-delete clears the camera's SD card once a clip is safely saved.
- **Easy browser-based setup** - a first-run wizard, then a settings page.
- **Home Assistant support** - over MQTT, with sync status, alerts, and action buttons.

![Timeline video editor](screenshots/timeline_editor.webp)![Download manager](screenshots/download_manager.webp)

## Hardware
## Contents

The dashcam must stay powered on and connected to Wi-Fi. A hardwire kit (e.g. Viofo HK4) plus a dedicated dashcam battery is recommended.
- [Features](#features)
- [Getting started](#getting-started)
- [Configuration](#configuration)
- [Home Assistant](#home-assistant-via-mqtt)
- [Reference](#reference)
- [About](#about)

It should join your LAN in Wi-Fi **station** mode. As of May 2026 the official A229 Pro firmware does not retain Wi-Fi state across reboots but Viofo support will provide a custom firmware on request.
## Getting started

Reserve the dashcam's IP on your router so it doesn't change.
### Requirements

## Quick start
> [!NOTE]
> #### Most users will need
> - **Viofo Wi-Fi dashcam** connected to your LAN in station mode
> - **Viofo special firmware** to keep station mode always-on (supplied by Viofo support on request)
> - **Hardwire kit** (Viofo HK4) to keep the camera powered when parked - a dedicated dashcam battery is recommended for extended downloads
> - **Reserved IP** for the dashcam on your router, so it doesn't change
> - **NAS or always-on host** with large storage that can run Docker
> - **Optional: hardware video encoder + fast LAN** - recommended for the video editing features

### Quick start

```bash
docker run -d \
Expand All @@ -39,12 +56,36 @@ docker run -d \
robxyz/viofosync
```

Or use the included `[docker-compose.yml](docker-compose.yml)`, which has the same settings plus a commented-out GPU passthrough block (see below).

Open `http://<host>:8080` and the first boot redirects you to a one-screen setup wizard at `/setup`. Enter the dashcam IP and an admin password (12+ characters) to finish. The wizard writes `/config/config.json` with a freshly-generated `SESSION_SECRET` and a bcrypt hash of the password — neither is held in env vars or the image.

After setup, every other setting lives on the **Settings** page in the UI.

> ⚠ **Setup window safety.** Until the wizard is submitted there is no auth on the container — the wizard self-disables after first submission and the route returns 404 thereafter. Don't expose the container to the public internet during this window.

### Hardware-accelerated exports

Exports (join, picture-in-picture, switched) and thumbnails use ffmpeg. At startup the app probes the host's encoders — QuickSync (Intel iGPU), VAAPI, NVENC, VideoToolbox — and falls back to software (libx264) if none work, so exports always run.

To use an Intel iGPU, pass the render node through:

```bash
docker run ... --device /dev/dri:/dev/dri robxyz/viofosync
```

The entrypoint auto-detects the render node's group and adds the app user to it. Some hosts (notably Synology DSM) need the group granted explicitly — find the GID and add it:

```bash
docker exec <container> sh -c 'stat -c %g /dev/dri/renderD128' # often 937 on Synology
# docker run ... --group-add 937 (or group_add: ["937"] in docker-compose.yml)
```

Confirm it engaged with `docker exec <container> vainfo`, and check the startup log for `export encoder available: … qsv …`.

> [!NOTE]
> On arm64 hosts QuickSync won't probe-pass; exports degrade to VAAPI or software automatically.

## Configuration

The only Docker-level env vars are:
Expand All @@ -58,7 +99,7 @@ App-level settings (sync interval, dashcam IP, encoder, geocoding email, web por

### Importing without Wi-Fi

Use **Import manually** in the web UI to ingest clips you already have on disk. Two modes:
Use **Import manually** in the web UI to ingest clips you already have on disk or the SD card. Two modes:

- **Upload** — pick a folder in your browser; clips upload one at a time and slot straight into the archive. On a quota-bound archive it makes room as it goes, evicting the oldest clips (never anything newer than what you're importing).
- **Folder** — copy clips into the `import` folder inside your recordings share, then **Scan** → **Ingest**. By default this is `recordings/import`; for a one-off import from a different path, type it in the Import dialog's Folder tab, or set a persistent default via the advanced `IMPORT_PATH` key in `/config/config.json`.
Expand All @@ -74,17 +115,15 @@ The source is only ever **read** — originals on the card/USB are never deleted

Imported clips are recognised by Viofo naming (`YYYY_MMDD_HHMMSS_NNNN[event][cam].MP4`); locked clips under an `RO/` folder keep their protected status. Non-matching files are left untouched.

## Alternative camera address
### Alternative camera address

You can set an optional **Alternative address** (Settings → Dashcam) — a second IP/host for the **same** dashcam. It is **not** for a second camera.
You can set an optional **Alternative address** (Settings → Dashcam) for the **same** dashcam.

This is for reaching one camera at more than one address depending on where the car is, for example:
This can be useful for reaching the camera on a second network:

- A Raspberry Pi running a VPN hotspot, so you can reach the dashcam remotely when the car is away from home.
- A Raspberry Pi running a VPN hotspot in the car, so you can reach the dashcam remotely.
- A site-to-site VPN to a second location the car is regularly parked at, where the camera sits on a different subnet/IP.

The alternative uses the same form as the primary (IP or hostname, plain `http`, port 80).

## Home Assistant via MQTT

viofosync can publish state and accept actions over MQTT, with full Home Assistant auto-discovery.
Expand Down Expand Up @@ -115,15 +154,17 @@ For "prioritize the last N hours", publish to `{node_id}/cmd/prioritize_recent`

- The MQTT password is stored in `config.json` in plaintext, alongside the bcrypt hash of the admin password and the session secret. The same access controls already apply to that file.

## Reverse geocoding
## Reference

### Reverse geocoding

Journey and stop cards display their start/end as *"Street, Town"* via Nominatim (OpenStreetMap). Lookups are rate-limited to 1/second per [Nominatim's usage policy](https://operations.osmfoundation.org/policies/nominatim/) and cached in the `geocode_cache` table (coords rounded to 3 d.p., ≈110 m). Set **Nominatim email** in Settings → GPS & Geocoding to identify your install per OSM's terms; toggle the **GPS maps** filter off on the Archive page to skip the Leaflet + Nominatim machinery entirely for low-bandwidth browsing.

## XML vs HTML listing
### XML vs HTML listing

By default the app scrapes the dashcam's HTML directory listings (`/DCIM/Movie`, `/DCIM/Movie/Parking`, `/DCIM/Movie/RO`), which is noticeably faster on some firmware than the XML API (`/?custom=1&cmd=3015&par=1`). Toggle off **Use HTML directory listing** in Settings → Dashcam to fall back to XML.

## Migrating from v1
### Migrating from v1

Existing installs with a `viofosync.env` file are migrated automatically on first boot of the v2 image:

Expand All @@ -133,7 +174,7 @@ Existing installs with a `viofosync.env` file are migrated automatically on firs

`PUID` / `PGID` / `TZ` env vars work the same as v1.

## Running without Docker
### Running without Docker

For development or for hosts that don't have Docker:

Expand All @@ -145,16 +186,18 @@ CONFIG_DIR=/path/to/config RECORDINGS=/path/to/archive \

`web.launcher` reads `WEB_HOST` / `WEB_PORT` from `config.json` (defaults `0.0.0.0:8080`) and re-execs into uvicorn. On first run, browse to `http://localhost:8080/setup`. `ffmpeg` must be on `$PATH` for thumbnails and exports.

## AI Code
## About

### AI Code

This opensource project uses AI generated code and is intended for personal home use. It is not recommended that the server is exposed to the public internet.
viofosync is an open-source project built with substantial AI assistance, intended for personal use on a home network. Its security model assumes a trusted LAN - a single password over plain HTTP - so keep it behind your network or a VPN rather than exposing it directly to the public internet.

## Credits
### Credits

The GPX extraction logic uses the method described at [https://sergei.nz/extracting-gps-data-from-viofo-a119-and-other-novatek-powered-cameras/](https://sergei.nz/extracting-gps-data-from-viofo-a119-and-other-novatek-powered-cameras/).

This software is unaffiliated with Viofo or any other vendor.

## License
### License

MIT — see [LICENSE](LICENSE).
17 changes: 17 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ services:
ports:
- "8080:8080"

# Optional: pass through the GPU for hardware-accelerated decode/encode
# (exports). On an Intel iGPU this now uses QuickSync (QSV) first and
# falls back to VA-API, then software. Uncomment on a NAS with an Intel
# iGPU. Verify it engaged with: docker exec <c> vainfo and check the
# startup log line "export encoder available: … qsv …".
# devices:
# - /dev/dri:/dev/dri
#
# The entrypoint auto-detects the render node's group and adds the app
# user to it, so this is usually all you need. Some hosts (notably
# Synology DSM) still require the render group to be granted explicitly —
# if hardware accel doesn't engage, find the GID with
# docker exec <c> sh -c 'stat -c %g /dev/dri/renderD128'
# and add it here (937 is common on Synology):
# group_add:
# - "937" # GID owning /dev/dri/renderD128 — the iGPU render node

volumes:
# Config directory. A template viofosync.env is seeded here on first
# run — edit it (ADDRESS, WEB_PASSWORD, etc.) and restart to apply.
Expand Down
Loading
Loading