From a575961ec0f76042704e444f4cc1d967d956b19b Mon Sep 17 00:00:00 2001 From: RobXYZ Date: Mon, 11 May 2026 15:16:32 +0100 Subject: [PATCH] Add web UI Full rewrite of the sync layer into a web app. Same Viofo dashcam protocol; new affordances: - Archive browser with day grouping, journey detection, and on-demand thumbnails. Reverse-geocoded place names on each journey card. - Leaflet maps per journey with GPS polylines extracted from the MP4 metadata (Novatek atom parser). - Exports: join-front, join-rear, and picture-in-picture renders via ffmpeg, with hardware encoder probing (videotoolbox / nvenc / qsv / vaapi) and software fallback. Configurable PIP position. - Download manager with live progress, reorderable queue, recent-hours shortcut, and reachability badge. - JSON-backed runtime settings page (sync interval, dashcam IP, geocoding, encoder, retention, RO-only sync, password). - First-run setup wizard at /setup with bcrypt password hashing and a freshly generated session secret. - Retention: time-based sweep with RO protection plus disk-pressure sweep with batched re-check. - Responsive layout (1024 / 720 / 480 breakpoints), accessibility pass (skip link, focus rings, ARIA, reduced-motion, hit-area floor, prefers-reduced-motion guard, sr-only utility), and an OKLCH cobalt palette with semantic tokens (--accent + alpha tiers, --on-accent, --err-text, --panel-3, --border-hover). --- .dockerignore | 59 + .githooks/pre-push | 29 + .github/workflows/ci.yml | 20 + .gitignore | 21 + CHANGELOG.md | 26 +- Dockerfile | 74 +- README.md | 126 +- crontab | 1 - docker-compose.yml | 69 +- entrypoint.sh | 9 +- pyproject.toml | 17 + requirements-dev.txt | 5 + requirements.txt | 7 + run.sh | 11 - tests/__init__.py | 0 tests/conftest.py | 34 + tests/test_auth_rotation.py | 31 + tests/test_config_store.py | 105 ++ tests/test_delete_dashcam_file.py | 98 ++ tests/test_e2e_smoke.py | 104 ++ tests/test_encoder_probe.py | 99 ++ tests/test_exporter_finish.py | 97 ++ tests/test_gps_examined.py | 254 ++++ tests/test_hub.py | 69 + tests/test_index_cache_bust.py | 87 ++ tests/test_launcher.py | 24 + tests/test_orphan_reconcile.py | 157 ++ tests/test_pip_filter.py | 44 + tests/test_queue_ro_only.py | 50 + tests/test_refresh_listing.py | 149 ++ tests/test_retention.py | 252 ++++ tests/test_settings_api.py | 142 ++ tests/test_settings_provider.py | 127 ++ tests/test_settings_schema.py | 172 +++ tests/test_setup_mode.py | 94 ++ tests/test_smoke.py | 2 + tests/test_sync_worker_delete.py | 309 ++++ tests/test_sync_worker_ro_only.py | 36 + viofosync.py | 1086 -------------- viofosync.sh | 42 - viofosync_lib/__init__.py | 114 ++ viofosync_lib/_archive.py | 91 ++ viofosync_lib/_gpx.py | 384 +++++ viofosync_lib/_protocol.py | 440 ++++++ viofosync_lib/progress.py | 110 ++ web/__init__.py | 4 + web/app.py | 248 ++++ web/auth.py | 190 +++ web/config_store.py | 150 ++ web/db.py | 171 +++ web/launcher.py | 52 + web/routers/__init__.py | 0 web/routers/archive.py | 573 ++++++++ web/routers/auth.py | 67 + web/routers/exports.py | 141 ++ web/routers/progress.py | 36 + web/routers/queue.py | 184 +++ web/routers/settings.py | 153 ++ web/routers/setup.py | 88 ++ web/services/__init__.py | 0 web/services/exporter.py | 658 +++++++++ web/services/geocode.py | 176 +++ web/services/gps.py | 417 ++++++ web/services/hub.py | 120 ++ web/services/queue.py | 620 ++++++++ web/services/retention.py | 226 +++ web/services/scanner.py | 256 ++++ web/services/sync_worker.py | 675 +++++++++ web/services/thumbs.py | 65 + web/settings.py | 271 ++++ web/settings_schema.py | 126 ++ web/setup_mode.py | 20 + web/static/app.js | 2225 +++++++++++++++++++++++++++++ web/static/index.html | 180 +++ web/static/setup.css | 73 + web/static/setup.html | 47 + web/static/setup.js | 64 + web/static/styles.css | 1472 +++++++++++++++++++ 78 files changed, 13741 insertions(+), 1284 deletions(-) create mode 100644 .dockerignore create mode 100755 .githooks/pre-push create mode 100644 .github/workflows/ci.yml delete mode 100644 crontab create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt delete mode 100755 run.sh create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_auth_rotation.py create mode 100644 tests/test_config_store.py create mode 100644 tests/test_delete_dashcam_file.py create mode 100644 tests/test_e2e_smoke.py create mode 100644 tests/test_encoder_probe.py create mode 100644 tests/test_exporter_finish.py create mode 100644 tests/test_gps_examined.py create mode 100644 tests/test_hub.py create mode 100644 tests/test_index_cache_bust.py create mode 100644 tests/test_launcher.py create mode 100644 tests/test_orphan_reconcile.py create mode 100644 tests/test_pip_filter.py create mode 100644 tests/test_queue_ro_only.py create mode 100644 tests/test_refresh_listing.py create mode 100644 tests/test_retention.py create mode 100644 tests/test_settings_api.py create mode 100644 tests/test_settings_provider.py create mode 100644 tests/test_settings_schema.py create mode 100644 tests/test_setup_mode.py create mode 100644 tests/test_smoke.py create mode 100644 tests/test_sync_worker_delete.py create mode 100644 tests/test_sync_worker_ro_only.py delete mode 100755 viofosync.py delete mode 100755 viofosync.sh create mode 100644 viofosync_lib/__init__.py create mode 100644 viofosync_lib/_archive.py create mode 100644 viofosync_lib/_gpx.py create mode 100644 viofosync_lib/_protocol.py create mode 100644 viofosync_lib/progress.py create mode 100644 web/__init__.py create mode 100644 web/app.py create mode 100644 web/auth.py create mode 100644 web/config_store.py create mode 100644 web/db.py create mode 100644 web/launcher.py create mode 100644 web/routers/__init__.py create mode 100644 web/routers/archive.py create mode 100644 web/routers/auth.py create mode 100644 web/routers/exports.py create mode 100644 web/routers/progress.py create mode 100644 web/routers/queue.py create mode 100644 web/routers/settings.py create mode 100644 web/routers/setup.py create mode 100644 web/services/__init__.py create mode 100644 web/services/exporter.py create mode 100644 web/services/geocode.py create mode 100644 web/services/gps.py create mode 100644 web/services/hub.py create mode 100644 web/services/queue.py create mode 100644 web/services/retention.py create mode 100644 web/services/scanner.py create mode 100644 web/services/sync_worker.py create mode 100644 web/services/thumbs.py create mode 100644 web/settings.py create mode 100644 web/settings_schema.py create mode 100644 web/setup_mode.py create mode 100644 web/static/app.js create mode 100644 web/static/index.html create mode 100644 web/static/setup.css create mode 100644 web/static/setup.html create mode 100644 web/static/setup.js create mode 100644 web/static/styles.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d910ac2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,59 @@ +# --- Version control --- +.git +.gitignore +.gitattributes +.github + +# --- Editor / OS noise (any depth) --- +**/.DS_Store +**/*.swp +**/*.swo +**/*~ +.idea +.vscode +.aider* + +# --- Agent / dev tooling state --- +.claude +.cursorrules +.planning + +# --- Git worktrees --- +.worktrees +worktrees + +# --- Python virtualenvs and caches (any depth) --- +.venv +venv +**/__pycache__ +**/*.py[cod] +**/*$py.class +.pytest_cache +.ruff_cache +.mypy_cache + +# --- Local dev --- +local-config +recordings +**/*.db +**/*.db-wal +**/*.db-shm +.env +.env.* +**/*.env +viofosync.env +config.json +settings-audit.log + +# --- Tests / docs / dev-only tooling --- +# The runtime image doesn't need any of these. +tests +docs +pyproject.toml +requirements-dev.txt + +# --- Logs and scratch (any depth) --- +**/*.log +tmp +**/*.tmp +**/*.bak diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..2ca85d4 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Pre-push gate. Mirrors .github/workflows/ci.yml so a CI failure +# can't get past the local push. Bypass once with `--no-verify`. +# +# Activated repo-wide via `git config core.hooksPath .githooks` +# (see CLAUDE.md). + +set -euo pipefail + +# Use the project venv if it exists; otherwise the system python. +# Prefer .venv to match the local dev convention in CLAUDE.md. +if [[ -x ".venv/bin/python" ]]; then + PY=".venv/bin/python" +else + PY="$(command -v python3 || command -v python)" +fi + +if [[ -z "${PY:-}" ]]; then + echo "pre-push: no python interpreter found." >&2 + exit 1 +fi + +echo "pre-push: ruff ($PY)" +"$PY" -m ruff check . + +echo "pre-push: pytest ($PY)" +"$PY" -m pytest -q + +echo "pre-push: all checks passed." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..78c4e3a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [main, web-ui] + pull_request: + branches: [main, web-ui] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - run: pip install -r requirements-dev.txt + - run: python -m ruff check . + - run: python -m pytest -q diff --git a/.gitignore b/.gitignore index 450da95..68483c5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,24 @@ tmp .DS_Store +.venv/* +recordings/* + +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ + +# App state / secrets +*.db +*.db-wal +*.db-shm +*.env +viofosync.env +config.json + +# Editor +.venv/ +docs/* +CLAUDE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c24424a..89ba5d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ -# CHANGELOG +# Changelog -## 1.0 (2024-09-18) +## v2.0 — 2026-05 -* initial release +Major rewrite. Web UI replaces the cron CLI. + +### Added +- Web UI on port 8080 with archive browser, download manager, GPX + journey map, and ffmpeg picture-in-picture exports. +- First-run setup wizard at `/setup`. +- Settings page (UI-driven config, hot-reloaded for runtime values, + restart-required for `WEB_HOST`/`WEB_PORT`). +- JSON config at `/config/config.json` replaces `viofosync.env`. + +### Changed +- Docker image is webapp-only; cron CLI is no longer the primary path. +- Required env vars reduced to `PUID` / `PGID` / `TZ`. + +### Migration +- Existing `viofosync.env` files are migrated to `config.json` on first + boot. The old file is preserved as a one-shot rollback path. + +## v1.x + +- Cron-driven CLI version. See git history. diff --git a/Dockerfile b/Dockerfile index 9573180..b6f5e4e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,36 +1,58 @@ -FROM alpine:3.20.3 +FROM alpine:3.23 LABEL maintainer="Rob Smith https://github.com/RobXYZ" -RUN apk add --no-cache bash python3 shadow tzdata \ - && useradd -UMr dashcam +# 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. +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)}" && \ + case "$arch" in \ + amd64|x86_64) apk add --no-cache intel-media-driver libva-utils ;; \ + esac && \ + useradd -UMr dashcam COPY COPYING / COPY setuid.sh /setuid.sh COPY entrypoint.sh /entrypoint.sh -COPY crontab /var/spool/cron/crontabs/dashcam -ENV ADDRESS="" \ - PUID="" \ +ENV PUID="" \ PGID="" \ - KEEP="" \ - GROUPING="" \ - PRIORITY="" \ - MAX_USED_DISK="" \ - TIMEOUT="" \ - DOWNLOAD_ATTEMPTS="" \ - VERBOSE=0 \ - QUIET="" \ - CRON=1 \ - DRY_RUN="" \ - RUN_ONCE="" \ - READ_ONLY="" \ - GPS_EXTRACT="" \ - HTML="" - -COPY --chown=dashcam viofosync.sh /viofosync.sh -COPY --chown=dashcam viofosync.py /viofosync.py - -RUN sed -i 's/\r$//' /entrypoint.sh /setuid.sh /viofosync.sh /viofosync.py \ - && chmod +x /viofosync.sh + 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. +COPY requirements.txt /requirements.txt +RUN pip install --no-cache-dir --break-system-packages \ + -r /requirements.txt + +COPY --chown=dashcam viofosync_lib /viofosync_lib +COPY --chown=dashcam web /web + +EXPOSE 8080 + +RUN sed -i 's/\r$//' /entrypoint.sh /setuid.sh \ + && chmod +x /entrypoint.sh /setuid.sh ENTRYPOINT [ "/entrypoint.sh"] diff --git a/README.md b/README.md index fe1395f..78aa65a 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,96 @@ -# Viofo Sync +# viofosync -Viofo Sync is a tool for synchronizing recordings from a Viofo dashcam (tested with A229 Pro) over Wi-Fi to a local directory. +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. -It is designed to be run as a Docker container on a NAS or similar device. +> **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. -This project is based on the great BlackVue Sync by Alessandro Colomba (https://github.com/acolomba) and uses GPX extraction from https://sergei.nz/extracting-gps-data-from-viofo-a119-and-other-novatek-powered-cameras/ +## Features -## GPS Extraction +- **Archive browser** — view clips grouped by day, front/rear pairs, on-demand thumbnails, in-browser playback, kind filters (Driving / Parking / Read-only), GPS-maps toggle for low-bandwidth browsing. +- **GPS journeys** — Leaflet + OSM map per trip, automatic stop detection splits a day into journeys, reverse-geocoded start/end labels (e.g. *Whitegate → Sandiway*). +- **Exports** — select clip pairs, render joined front-only, rear-only, or picture-in-picture videos with ffmpeg. Hardware H.264 (videotoolbox / nvenc / qsv / vaapi) when available, software libx264 fallback. +- **Download manager** — live progress, reorderable queue, reachability badge, transient timeouts re-queue instead of burning retries. +- **Auto-delete from dashcam** *(optional)* — clears each clip from the device once it's downloaded and verified. +- **Settings page** — runtime settings hot-reload rather than Docker env vars; only `WEB_HOST`/`WEB_PORT` need a restart. -If you have a use for GPX files, they can be extracted from the video using the `GPS_EXTRACT` option detailed below. +## Hardware -## Hardware and Firmware Requirements +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. -The dashcam must remain powered on and connected to Wi-Fi. It is recommended to use a hardwire kit, such as the Viofo HK4, and ideally, a dedicated dashcam battery to prevent draining the car battery. +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. -The dashcam should be connected to the LAN using Wi-Fi station mode. +Reserve the dashcam's IP on your router so it doesn't change. -As of September 2024, the official A229 Pro firmware does not retain the previous Wi-Fi state after a reboot. However, Viofo support has provided special firmware upon request that retains this state. This feature may be officially released in the near future and is recommended to make downloads fully automated. +## Quick start -## Using the Docker Container +```bash +docker run -d \ + --name viofosync \ + -p 8080:8080 \ + -e PUID=$(id -u) \ + -e PGID=$(id -g) \ + -e TZ=Europe/London \ + -v /path/to/config:/config \ + -v /path/to/recordings:/recordings \ + --restart unless-stopped \ + robxyz/viofosync +``` -To use Viofo Sync as a Docker container, follow these steps: +Open `http://: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. -1. **Install Docker:** +After setup, every other setting lives on the **Settings** page in the UI. - Download from https://www.docker.com/ if you don't have it already. +> ⚠ **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. -2. **Run the Docker Container:** - ```bash - docker run -it --rm \ - -e ADDRESS= \ - -e PUID=$(id -u) \ - -e PGID=$(id -g) \ - -e TZ="Europe/London" \ - -e KEEP=2w \ - -e GROUPING=daily - -v /path/to/local/directory:/recordings \ - --name viofosync \ - robxyz/viofosync - ``` +## Configuration - Replace `` with the IP address of your dashcam and `/path/to/local/directory` with the path to your local directory where recordings will be stored. +The only Docker-level env vars are: -## Configuration Options -The following environment variables can be set to configure the behavior of the Viofo Sync Docker container: +| Variable | Description | Default | +| --------------- | ------------------------------------------------ | ------------ | +| `PUID` / `PGID` | Owner of `/config` and `/recordings` on the host | host UID/GID | +| `TZ` | Timezone for log timestamps | UTC | -| Variable | Description | Default | -|---|---|---| -| `ADDRESS` | IP address or hostname of the dashcam | *(required)* | -| `PUID` | User ID for file permissions | | -| `PGID` | Group ID for file permissions | | -| `TZ` | Timezone (e.g. `Europe/London`) | | -| `KEEP` | Retention period — recordings older than this are deleted. Accepts `[d\|w]` for days or weeks (e.g. `30d`, `4w`) | | -| `GROUPING` | Group recordings into subdirectories: `daily`, `weekly`, `monthly`, `yearly`, or `none` | `none` | -| `PRIORITY` | Download order: `date` (oldest first) or `rdate` (newest first) | `date` | -| `MAX_USED_DISK` | Stop downloading if disk usage exceeds this percentage (5-98) | `90` | -| `TIMEOUT` | Connection timeout in seconds | `30` | -| `DOWNLOAD_ATTEMPTS` | Number of attempts for each download (must be >= 1) | `1` | -| `VERBOSE` | Logging verbosity level (0 = normal, 1+ = debug) | `0` | -| `QUIET` | Set to any value to only log errors | | -| `CRON` | Set to any value for reduced cron-mode logging | `1` | -| `GPS_EXTRACT` | Set to any value to extract GPS data and create `.gpx` files alongside recordings | | -| `READ_ONLY` | Set to any value to only sync read-only (locked) recordings | | -| `HTML` | Set to any value to use alternative HTML scraping instead of the XML API. Recommended for cameras that are slow or timeout responding to the XML file listing request | | -| `DRY_RUN` | Set to any value to show what would happen without downloading or deleting anything | | -| `RUN_ONCE` | Set to any value to sync once and exit instead of running on a cron schedule | | -## XML vs HTML Mode +App-level settings (sync interval, dashcam IP, encoder, geocoding email, web port, retention, password, auto-delete, etc.) are editable on the **Settings** page. Advanced users can hand-edit `/config/config.json` between restarts; the schema lives in `[web/settings_schema.py](web/settings_schema.py)`. -By default, Viofo Sync uses the camera's XML API (`/?custom=1&cmd=3015&par=1`) to get the file listing. For some reason on my camera this started running very slowly so setting `HTML=1` switches to scraping the camera's HTTP directory listings (`/DCIM/Movie`, `/DCIM/Movie/Parking`, `/DCIM/Movie/RO`), which seem to load faster. +## 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 + +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 + +Existing installs with a `viofosync.env` file are migrated automatically on first boot of the v2 image: + +- Settings land in `/config/config.json`. +- The original `viofosync.env` is preserved with a deprecation header — safe to delete. +- The cron-style entry point is no longer the primary path; the web app's sync worker covers the same ground with live progress and queue control. + +`PUID` / `PGID` / `TZ` env vars work the same as v1. + +## Running without Docker + +For development or for hosts that don't have Docker: + +```bash +pip install -r requirements.txt +CONFIG_DIR=/path/to/config RECORDINGS=/path/to/archive \ + python3 -m web.launcher +``` + +`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. + +## 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 -This project is licensed under the MIT License. See the [COPYING](COPYING) file for details. +MIT — see [COPYING](COPYING). \ No newline at end of file diff --git a/crontab b/crontab deleted file mode 100644 index d385101..0000000 --- a/crontab +++ /dev/null @@ -1 +0,0 @@ -*/10 * * * * /viofosync.sh diff --git a/docker-compose.yml b/docker-compose.yml index e42ce89..87fc578 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,69 +4,22 @@ services: container_name: viofosync restart: unless-stopped + ports: + - "8080:8080" + volumes: - # Recording download destination. Change only the part before the colon. - - /dashcam-recordings:/recordings:rw + # Config directory. A template viofosync.env is seeded here on first + # run — edit it (ADDRESS, WEB_PASSWORD, etc.) and restart to apply. + - /path/to/config:/config + # Recordings + SQLite state DB. Change only the part before the colon. + - /path/to/recordings:/recordings:rw environment: - # Dashcam address - # ADDRESS: 192.168.1.230 - - # Set these to the desired destination directory's user id and group id. + # UID/GID that owns the host-side config and recordings folders. + # Run `id ` to find the right values. PUID: 1000 PGID: 1000 - # Set to the same timezone as the dashcam. For the complete list of possible values, see: + # Timezone for log timestamps and journey grouping. # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones TZ: Europe/London - - # Priority to download recordings. Pick "date" to download from oldest to - # newest; pick "rdate" to download from oldest to newest; pick "type" to - # download manual, event (all types), normal and parking recordings in - # that order. - PRIORITY: date - - # Groups downloaded recordings in directories: 'daily', 'weekly', - # 'monthly', 'yearly' and 'none' are supported. - GROUPING: daily - - # Retention period of downloaded recordings. Recordings prior to the - # retention period will be removed from the destination. Accepted units - # are 'd' for days and 'w' for weeks. If no unit is indicated, days are - # assumed. - KEEP: 2w - - # Store and manage Read Only (locked) video recordings. - READ_ONLY: false - - # Stops downloading if the amount of used disk space exceeds the indicated - # percentage value. - MAX_USED_DISK: 90 - - # Sets the timeout in seconds for connecting to the dashcam. - TIMEOUT: 10.0 - - # Number of attempts for each download (must be >= 1). - DOWNLOAD_ATTEMPTS: 1 - - # Set to a number greater than zero to increase logging verbosity. - VERBOSE: 0 - - # Set to any value to quiet down logs: only unexpected errors will be - # logged. - QUIET: '' - - # Makes it so downloads of normal recordings and unexpected error - # conditions are logged. Can be set to '' to disable. - CRON: '' - - # Set to any value to enable GPS data extraction and GPX file creation. - GPS_EXTRACT: '' - - # If set to any value, makes it so that the script communicates what it - # would do without actually doing anything. - DRY_RUN: '' - - # Use HTML directory scraping alternative to XML API. - # Recommended for cameras that are slow to generate the XML listing. - HTML: '' diff --git a/entrypoint.sh b/entrypoint.sh index 8e2dfe1..942e197 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash +set -e -/setuid.sh \ -&& su -m dashcam /viofosync.sh \ -&& [[ -z $RUN_ONCE ]] \ -&& crond -f +mkdir -p /config /recordings +/setuid.sh + +exec su-exec dashcam:dashcam python3 -m web.launcher diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c0cb7de --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +filterwarnings = ["error"] + +[tool.ruff] +line-length = 100 +target-version = "py311" +# Scope ruff to scaffolding-task-owned code. Existing web/ has pre-existing +# violations (Optional/List/Dict typing, import sorting) that later tasks will +# clean up as those files are touched. +extend-include = ["tests/**/*.py"] +extend-exclude = ["viofosync.py", "viofosync_lib/**/*.py", "web/**/*.py"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP"] +ignore = ["E501"] # line length handled by formatter diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..aced9e2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +pytest>=8.0 +pytest-asyncio>=0.23 +httpx>=0.27 +ruff>=0.4 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2da26f9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.110 +uvicorn[standard]>=0.29 +bcrypt>=4.1 +itsdangerous>=2.2 +python-multipart>=0.0.9 +pydantic>=2.6 +httpx>=0.27 diff --git a/run.sh b/run.sh deleted file mode 100755 index 30a2233..0000000 --- a/run.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -docker run -it --rm \ - -e ADDRESS=127.0.0.2 \ - -v $(pwd)/tmp:/recordings \ - -e DRY_RUN=1 \ - -e CRON=0 \ - -e RUN_ONCE=1 \ - -e VERBOSE=1 \ - --name viofosync \ -acolomba/viofosync diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..83ba3f3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,34 @@ +"""Shared pytest fixtures for the viofosync test suite.""" +from __future__ import annotations + +import shutil +import tempfile +from collections.abc import Iterator +from pathlib import Path + +import pytest + + +@pytest.fixture +def tmp_config_dir(monkeypatch) -> Iterator[Path]: + """Isolated /config directory for the duration of one test. + + Sets the CONFIG_DIR env var so code under test reads/writes + inside the tempdir instead of /config on the host. + """ + d = Path(tempfile.mkdtemp(prefix="viofosync-cfg-")) + monkeypatch.setenv("CONFIG_DIR", str(d)) + try: + yield d + finally: + shutil.rmtree(d, ignore_errors=True) + + +@pytest.fixture +def tmp_recordings_dir(monkeypatch) -> Iterator[Path]: + d = Path(tempfile.mkdtemp(prefix="viofosync-rec-")) + monkeypatch.setenv("RECORDINGS", str(d)) + try: + yield d + finally: + shutil.rmtree(d, ignore_errors=True) diff --git a/tests/test_auth_rotation.py b/tests/test_auth_rotation.py new file mode 100644 index 0000000..1aa37b2 --- /dev/null +++ b/tests/test_auth_rotation.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import bcrypt + +from web.auth import Auth + + +def test_auth_accepts_pre_hashed_password() -> None: + digest = bcrypt.hashpw(b"hunter2-twelve-chars", bcrypt.gensalt()).decode() + auth = Auth(password_hash=digest, secret="x" * 64) + assert auth.check_password("hunter2-twelve-chars") is True + assert auth.check_password("wrong") is False + + +def test_auth_rotate_secret_invalidates_old_session_tokens() -> None: + digest = bcrypt.hashpw(b"hunter2-twelve-chars", bcrypt.gensalt()).decode() + auth = Auth(password_hash=digest, secret="a" * 64) + from fastapi import Response + resp = Response() + old_token = auth.issue_session(resp) + auth.rotate_secret("b" * 64) + assert auth.validate_session(old_token) is False + + +def test_auth_update_password_hash_swaps_in_place() -> None: + digest1 = bcrypt.hashpw(b"old-password-twelve", bcrypt.gensalt()).decode() + digest2 = bcrypt.hashpw(b"new-password-twelve", bcrypt.gensalt()).decode() + auth = Auth(password_hash=digest1, secret="x" * 64) + auth.update_password_hash(digest2) + assert auth.check_password("old-password-twelve") is False + assert auth.check_password("new-password-twelve") is True diff --git a/tests/test_config_store.py b/tests/test_config_store.py new file mode 100644 index 0000000..eb54c18 --- /dev/null +++ b/tests/test_config_store.py @@ -0,0 +1,105 @@ +"""ConfigStore: atomic JSON read/write + migration shim.""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from web.config_store import ConfigStore, MigrationResult + + +def test_load_returns_empty_dict_when_file_missing(tmp_path: Path) -> None: + store = ConfigStore(tmp_path / "config.json") + assert store.load() == {} + + +def test_write_persists_and_loads_back(tmp_path: Path) -> None: + store = ConfigStore(tmp_path / "config.json") + store.write({"ADDRESS": "1.2.3.4", "TIMEOUT": 15}) + assert store.load() == {"ADDRESS": "1.2.3.4", "TIMEOUT": 15} + + +def test_write_is_atomic(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """If os.replace is interrupted, the original file is intact.""" + store = ConfigStore(tmp_path / "config.json") + store.write({"ADDRESS": "1.1.1.1"}) + + real_replace = os.replace + + def boom(src: str, dst: str) -> None: + raise OSError("simulated crash") + + monkeypatch.setattr(os, "replace", boom) + with pytest.raises(OSError): + store.write({"ADDRESS": "2.2.2.2"}) + + monkeypatch.setattr(os, "replace", real_replace) + assert store.load() == {"ADDRESS": "1.1.1.1"} + + +def test_write_sets_mode_0600(tmp_path: Path) -> None: + cfg = tmp_path / "config.json" + store = ConfigStore(cfg) + store.write({"FOO": "bar"}) + assert (cfg.stat().st_mode & 0o777) == 0o600 + + +def test_load_rejects_non_object_top_level(tmp_path: Path) -> None: + cfg = tmp_path / "config.json" + cfg.write_text("[1,2,3]") + store = ConfigStore(cfg) + with pytest.raises(ValueError, match="must be a JSON object"): + store.load() + + +def test_load_returns_empty_on_corrupt_json(tmp_path: Path, caplog) -> None: + """A corrupt config.json shouldn't brick the app — log + return {}.""" + cfg = tmp_path / "config.json" + cfg.write_text("{not json") + store = ConfigStore(cfg) + assert store.load() == {} + assert "corrupt" in caplog.text.lower() + + +def test_migrate_from_env_file_parses_kv_pairs(tmp_path: Path) -> None: + env = tmp_path / "viofosync.env" + env.write_text( + "# header comment\n" + "ADDRESS=192.168.1.230\n" + 'WEB_PASSWORD="secret with spaces"\n' + "TIMEOUT=15\n" + "\n" + "GPS_EXTRACT=1\n" + ) + cfg = tmp_path / "config.json" + store = ConfigStore(cfg) + result = store.migrate_from_env(env) + + assert result == MigrationResult.MIGRATED + data = json.loads(cfg.read_text()) + assert data["ADDRESS"] == "192.168.1.230" + assert data["WEB_PASSWORD"] == "secret with spaces" + assert data["TIMEOUT"] == 15 + assert data["GPS_EXTRACT"] is True + # Original env file is preserved with a header comment. + assert "no longer used" in env.read_text().lower() + + +def test_migrate_skipped_when_config_already_exists(tmp_path: Path) -> None: + cfg = tmp_path / "config.json" + cfg.write_text('{"ADDRESS": "old"}') + env = tmp_path / "viofosync.env" + env.write_text("ADDRESS=new\n") + + store = ConfigStore(cfg) + assert store.migrate_from_env(env) == MigrationResult.SKIPPED_ALREADY_MIGRATED + assert json.loads(cfg.read_text()) == {"ADDRESS": "old"} + + +def test_migrate_skipped_when_env_file_missing(tmp_path: Path) -> None: + cfg = tmp_path / "config.json" + store = ConfigStore(cfg) + assert store.migrate_from_env(tmp_path / "missing.env") == MigrationResult.SKIPPED_NO_SOURCE + assert not cfg.exists() diff --git a/tests/test_delete_dashcam_file.py b/tests/test_delete_dashcam_file.py new file mode 100644 index 0000000..1e0b39d --- /dev/null +++ b/tests/test_delete_dashcam_file.py @@ -0,0 +1,98 @@ +"""Tests for the dashcam delete HTTP helper. + +We mock urllib.request.urlopen so the tests don't hit a real +dashcam. The helper builds the URL — the assertions check we +hit /?custom=1&cmd=4003&str= and propagate success/failure +correctly. +""" +from __future__ import annotations + +from unittest.mock import patch + +from viofosync_lib import delete_dashcam_file + + +class _FakeResponse: + def __init__(self, status: int = 200) -> None: + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *exc) -> None: + return None + + def read(self) -> bytes: + return b"" + + +def test_delete_returns_true_on_success() -> None: + captured = {} + + def fake_urlopen(req, timeout=None): + captured["url"] = req if isinstance(req, str) else req.full_url + captured["timeout"] = timeout + return _FakeResponse(status=200) + + with patch("urllib.request.urlopen", fake_urlopen): + ok = delete_dashcam_file( + "http://192.168.1.230", + "/DCIM/Movie", + "2026_0508_104020_001234F.MP4", + timeout=5.0, + ) + assert ok is True + assert captured["url"] == ( + "http://192.168.1.230/?custom=1&cmd=4003" + "&str=/DCIM/Movie/2026_0508_104020_001234F.MP4" + ) + assert captured["timeout"] == 5.0 + + +def test_delete_returns_false_on_http_error() -> None: + import urllib.error + + def fake_urlopen(req, timeout=None): + raise urllib.error.HTTPError( + "http://x", 500, "boom", {}, None, + ) + + with patch("urllib.request.urlopen", fake_urlopen): + ok = delete_dashcam_file( + "http://192.168.1.230", + "/DCIM/Movie", + "2026_0508_104020_001234F.MP4", + ) + assert ok is False + + +def test_delete_returns_false_on_url_error() -> None: + import urllib.error + + def fake_urlopen(req, timeout=None): + raise urllib.error.URLError("connection refused") + + with patch("urllib.request.urlopen", fake_urlopen): + ok = delete_dashcam_file( + "http://192.168.1.230", + "/DCIM/Movie", + "2026_0508_104020_001234F.MP4", + ) + assert ok is False + + +def test_delete_returns_false_on_socket_timeout() -> None: + import socket + + def fake_urlopen(req, timeout=None): + raise TimeoutError("timeout") + + with patch("urllib.request.urlopen", fake_urlopen): + ok = delete_dashcam_file( + "http://192.168.1.230", + "/DCIM/Movie", + "2026_0508_104020_001234F.MP4", + ) + assert ok is False + # Sanity: socket.timeout is a subclass of OSError in modern Python. + assert issubclass(socket.timeout, OSError) diff --git a/tests/test_e2e_smoke.py b/tests/test_e2e_smoke.py new file mode 100644 index 0000000..33dda30 --- /dev/null +++ b/tests/test_e2e_smoke.py @@ -0,0 +1,104 @@ +"""End-to-end: empty /config -> wizard -> settings PUT -> verify.""" +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + + +def test_full_e2e_flow(tmp_config_dir: Path, tmp_recordings_dir: Path) -> None: + from web import app as app_mod + from web import settings as settings_mod + settings_mod.reset_for_tests() + app = app_mod.create_app() + + with TestClient(app) as c: + # 1. Empty config -> redirect to /setup. + r = c.get("/", follow_redirects=False) + assert r.status_code == 307 + assert r.headers["location"].endswith("/setup") + + # 2. Submit wizard. + r = c.post("/setup", data={ + "address": "192.168.1.230", + "password": "twelve-chars-min!", + "confirm": "twelve-chars-min!", + }, follow_redirects=False) + assert r.status_code == 303 + + # 3. /setup is now 404. + assert c.get("/setup").status_code == 404 + + # 4. Acquire CSRF, change a setting. + # NOTE: the auth router returns {"csrf": "..."}, not {"token": "..."}. + csrf = c.get("/api/auth/csrf").json()["csrf"] + r = c.put("/api/settings", + json={"TIMEOUT": 25, "GROUPING": "weekly"}, + headers={"x-csrf-token": csrf}) + assert r.status_code == 200 + assert r.json()["editable"]["TIMEOUT"] == 25 + + # 5. Round-trip: GET shows the persisted value. + r = c.get("/api/settings") + assert r.json()["editable"]["GROUPING"] == "weekly" + + # 6. Wrong-current rejects password change. + r = c.post("/api/settings/password", + json={"current": "wrong", "new_password": "x" * 14, "logout_others": False}, + headers={"x-csrf-token": csrf}) + assert r.status_code == 401 + + # 7. Correct-current succeeds. + r = c.post("/api/settings/password", + json={"current": "twelve-chars-min!", "new_password": "new-twelve-chars!", "logout_others": False}, + headers={"x-csrf-token": csrf}) + assert r.status_code == 200 + + # Round-trip across a fresh app instance to confirm persistence. + settings_mod.reset_for_tests() + app2 = app_mod.create_app() + with TestClient(app2) as c2: + r = c2.get("/", follow_redirects=False) + # No longer in setup mode. + assert r.status_code != 307 + + +def test_e2e_retention_settings_roundtrip( + tmp_config_dir: Path, tmp_recordings_dir: Path, +) -> None: + from web import app as app_mod + from web import settings as settings_mod + settings_mod.reset_for_tests() + app = app_mod.create_app() + + with TestClient(app) as c: + # Bootstrap through the wizard so we leave setup mode. + c.post("/setup", data={ + "address": "192.168.1.230", + "password": "twelve-chars-min!", + "confirm": "twelve-chars-min!", + }, follow_redirects=False) + csrf = c.get("/api/auth/csrf").json()["csrf"] + + r = c.put( + "/api/settings", + json={ + "SYNC_RO_ONLY": True, + "RETENTION_MAX_DAYS": 14, + "RETENTION_DISK_PCT": 75, + "RETENTION_PROTECT_RO": True, + }, + headers={"x-csrf-token": csrf}, + ) + assert r.status_code == 200, r.text + e = r.json()["editable"] + assert e["SYNC_RO_ONLY"] is True + assert e["RETENTION_MAX_DAYS"] == 14 + assert e["RETENTION_DISK_PCT"] == 75 + assert e["RETENTION_PROTECT_RO"] is True + + # GET re-reads from the provider. + body = c.get("/api/settings").json() + e2 = body["editable"] + assert e2["RETENTION_MAX_DAYS"] == 14 + assert e2["SYNC_RO_ONLY"] is True diff --git a/tests/test_encoder_probe.py b/tests/test_encoder_probe.py new file mode 100644 index 0000000..ae9d5b9 --- /dev/null +++ b/tests/test_encoder_probe.py @@ -0,0 +1,99 @@ +"""Tests for the boot-time encoder probe. + +`ffmpeg -encoders` only reports what was compiled in. On a +Synology container without /dev/dri passthrough, h264_qsv shows +up as compiled-in but fails at runtime with `MFX session: -9`, +which makes `auto` mode pick a broken encoder. The 1-frame test +encode catches this so the dropdown only ever lists encoders +that actually work end-to-end. +""" +from __future__ import annotations + +from unittest.mock import patch + +from web.services import exporter + +_ENCODERS_OUT_ALL = """\ +V..... libx264 libx264 H.264 / AVC / MPEG-4 AVC +V..... h264_qsv h264 (qsv) +V..... h264_nvenc h264 (nvenc) +V..... h264_vaapi h264 (vaapi) +V..... h264_videotoolbox h264 (videotoolbox) +""" + + +_ENCODERS_OUT_SOFTWARE_ONLY = """\ +V..... libx264 libx264 H.264 / AVC / MPEG-4 AVC +""" + + +async def test_probe_keeps_software_when_only_software_compiled() -> None: + with patch.object(exporter, "ffmpeg_available", return_value=True), \ + patch.object(exporter, "_probe_encoders_sync", + return_value=_ENCODERS_OUT_SOFTWARE_ONLY), \ + patch.object(exporter, "_test_encoder_sync", return_value=True): + result = await exporter.probe_encoders() + assert result["software"] is True + assert result["qsv"] is False + assert result["nvenc"] is False + + +async def test_probe_drops_qsv_when_runtime_test_fails() -> None: + """The bug this whole file exists for: h264_qsv compiled in + but unusable on the host. Probe must mark it False.""" + def _fake_test(name: str) -> bool: + # Software always works; QSV fails at runtime. + if name == "qsv": + return False + return True + + with patch.object(exporter, "ffmpeg_available", return_value=True), \ + patch.object(exporter, "_probe_encoders_sync", + return_value=_ENCODERS_OUT_ALL), \ + patch.object(exporter, "_test_encoder_sync", + side_effect=_fake_test): + result = await exporter.probe_encoders() + assert result["qsv"] is False + assert result["software"] is True + # Other hardware encoders that pass the runtime test stay True. + assert result["nvenc"] is True + assert result["vaapi"] is True + + +async def test_probe_short_circuits_when_ffmpeg_missing() -> None: + """No ffmpeg binary at all → every encoder is unavailable.""" + with patch.object(exporter, "ffmpeg_available", return_value=False): + result = await exporter.probe_encoders() + assert all(v is False for v in result.values()) + + +async def test_probe_skips_runtime_test_for_uncompiled_encoders() -> None: + """Encoders missing from the -encoders output should not + trigger a runtime test (would just fail and waste seconds).""" + test_calls: list[str] = [] + + def _track(name: str) -> bool: + test_calls.append(name) + return True + + with patch.object(exporter, "ffmpeg_available", return_value=True), \ + patch.object(exporter, "_probe_encoders_sync", + return_value=_ENCODERS_OUT_SOFTWARE_ONLY), \ + patch.object(exporter, "_test_encoder_sync", side_effect=_track): + await exporter.probe_encoders() + # Only "software" was compiled in. None of the hardware + # encoders should reach the runtime test — that would spawn + # ffmpeg subprocesses for nothing on a software-only build. + for name in ("qsv", "nvenc", "vaapi", "videotoolbox"): + assert name not in test_calls, ( + f"{name} not compiled in but probe still ran a " + f"runtime test for it" + ) + + +def test_test_encoder_software_always_returns_true() -> None: + """libx264 ships with every ffmpeg build; no need to spawn + a subprocess to verify.""" + # No subprocess patching needed — the function short-circuits + # before exec-ing ffmpeg. + assert exporter._test_encoder_sync("software") is True diff --git a/tests/test_exporter_finish.py b/tests/test_exporter_finish.py new file mode 100644 index 0000000..343ea88 --- /dev/null +++ b/tests/test_exporter_finish.py @@ -0,0 +1,97 @@ +"""Regression tests for ExportWorker._finish. + +The first version of `_finish` wrote `progress=NULL` on failure, +which violated the `NOT NULL DEFAULT 0.0` schema and raised an +sqlite3.IntegrityError mid-write — leaving the job stuck in +state='running' and the frontend sitting at 0% forever. +""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from web.db import Database +from web.services.exporter import ExportWorker + + +@pytest.fixture +def db(tmp_path: Path) -> Database: + return Database(str(tmp_path / "test.db")) + + +def _insert_running_job(db: Database, *, progress: float = 0.42) -> int: + """Insert a job in 'running' state with non-trivial progress + so we can assert it isn't trampled on failure.""" + with db.write() as c: + cur = c.execute( + "INSERT INTO export_jobs " + "(type, clip_ids, state, progress, created_at, started_at) " + "VALUES ('pip', '{\"clip_ids\": []}', 'running', ?, 0, 0)", + (progress,), + ) + return cur.lastrowid + + +async def _async_noop(_event): # pragma: no cover — broadcast stub + pass + + +async def test_finish_failed_job_does_not_violate_progress_constraint( + db: Database, +) -> None: + """The original bug: ffmpeg dies, _finish(... ok=False, ...None) + is called, and the UPDATE fails because progress is NOT NULL. + + After the fix the UPDATE simply doesn't touch the progress + column on failure — preserving partial-progress info AND + avoiding the constraint violation.""" + job_id = _insert_running_job(db, progress=0.42) + worker = ExportWorker( + db=db, + provider=MagicMock(), + broadcast=_async_noop, + ) + worker._finish( + job_id, + ok=False, + err="qsv MFX init failed", + output_path=None, + ) + with db.conn() as c: + row = c.execute( + "SELECT * FROM export_jobs WHERE id=?", (job_id,) + ).fetchone() + assert row["state"] == "failed" + assert row["error"] == "qsv MFX init failed" + # progress was 0.42 going in; failure leaves it alone. + assert row["progress"] == pytest.approx(0.42) + assert row["finished_at"] is not None + + +async def test_finish_done_job_writes_full_progress( + db: Database, +) -> None: + """Successful jobs flip progress to 1.0 so the UI shows 100% + even if the per-segment ticks were sparse.""" + job_id = _insert_running_job(db, progress=0.7) + worker = ExportWorker( + db=db, + provider=MagicMock(), + broadcast=_async_noop, + ) + worker._finish( + job_id, + ok=True, + err=None, + output_path="/tmp/out.mp4", + ) + with db.conn() as c: + row = c.execute( + "SELECT * FROM export_jobs WHERE id=?", (job_id,) + ).fetchone() + assert row["state"] == "done" + assert row["progress"] == pytest.approx(1.0) + assert row["output_path"] == "/tmp/out.mp4" + assert row["error"] is None diff --git a/tests/test_gps_examined.py b/tests/test_gps_examined.py new file mode 100644 index 0000000..2f623bb --- /dev/null +++ b/tests/test_gps_examined.py @@ -0,0 +1,254 @@ +"""Tests for the ``gps_examined`` flag on clip_index. + +Background: the manual "Extract GPS" button used to filter on +``has_gpx = 0``, which meant any clip whose moov atom yielded no +GPS data (parking, indoor, no satellite lock) was reprocessed on +every click — for a multi-thousand clip library that's many +minutes wasted per click. The fix tracks whether each clip has +been *examined* separately from whether it produced a sidecar. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from web.db import Database +from web.routers.archive import _process_extract_target, _select_extract_targets + + +@pytest.fixture +def db(tmp_path: Path) -> Database: + return Database(str(tmp_path / "test.db")) + + +def _insert( + db: Database, *, + path: str, ts: int = 0, + has_gpx: int = 0, gps_examined: int = 0, +) -> None: + with db.write() as c: + c.execute( + "INSERT INTO clip_index " + "(path, basename, group_name, timestamp, camera, " + " sequence, event_type, size_bytes, has_gpx, " + " gps_examined, scanned_at) " + "VALUES (?, ?, '2026-01-01', ?, 'F', 1, 'normal', " + " 100, ?, ?, 0)", + (path, path.split("/")[-1], ts, has_gpx, gps_examined), + ) + + +# ---- migration ---- + +def test_migration_adds_gps_examined_to_existing_db(tmp_path: Path) -> None: + """A pre-migration db.py would have created clip_index without + gps_examined. Re-opening the database must not break and must + add the column.""" + import sqlite3 + db_path = tmp_path / "old.db" + # Build a clip_index without gps_examined to mimic the older + # schema (the column doesn't exist yet on the user's + # production volume). + legacy = sqlite3.connect(str(db_path)) + legacy.executescript(""" + CREATE TABLE clip_index ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + basename TEXT NOT NULL, + group_name TEXT, + timestamp INTEGER NOT NULL, + camera TEXT NOT NULL, + sequence INTEGER NOT NULL, + event_type TEXT, + size_bytes INTEGER, + has_gpx INTEGER NOT NULL DEFAULT 0, + duration_s REAL, + scanned_at INTEGER NOT NULL + ); + INSERT INTO clip_index + (path, basename, timestamp, camera, sequence, + has_gpx, scanned_at) + VALUES + ('/x/A.MP4', 'A.MP4', 0, 'F', 1, 1, 0), + ('/x/B.MP4', 'B.MP4', 0, 'F', 2, 0, 0); + """) + legacy.commit() + legacy.close() + + # Re-opening through Database() must run the migration. + Database(str(db_path)) + + check = sqlite3.connect(str(db_path)) + cols = [r[1] for r in check.execute("PRAGMA table_info(clip_index)")] + assert "gps_examined" in cols + # Backfill: the row that already had a sidecar is now + # marked examined. + rows = check.execute( + "SELECT path, gps_examined FROM clip_index ORDER BY path" + ).fetchall() + by_path = {p: e for p, e in rows} + assert by_path["/x/A.MP4"] == 1 + assert by_path["/x/B.MP4"] == 0 + check.close() + + +def test_migration_is_idempotent(tmp_path: Path) -> None: + """Opening a fresh-schema DB twice must not error on the + duplicate-column ALTER.""" + db_path = tmp_path / "test.db" + Database(str(db_path)) + Database(str(db_path)) # second open must not raise + + +# ---- _select_extract_targets ---- + +def test_force_returns_all_clips(db: Database) -> None: + _insert(db, path="/x/A.MP4", has_gpx=1, gps_examined=1) + _insert(db, path="/x/B.MP4", has_gpx=0, gps_examined=1) + _insert(db, path="/x/C.MP4", has_gpx=0, gps_examined=0) + targets = _select_extract_targets(db, force=True) + assert len(targets) == 3 + + +def test_default_skips_examined_clips(db: Database) -> None: + """The whole point of the fix: empty-result clips marked + ``gps_examined=1`` after the first run aren't picked up by + subsequent clicks.""" + _insert(db, path="/x/HAS_GPX.MP4", has_gpx=1, gps_examined=1) + _insert(db, path="/x/EMPTY.MP4", has_gpx=0, gps_examined=1) + _insert(db, path="/x/NEW.MP4", has_gpx=0, gps_examined=0) + targets = _select_extract_targets(db, force=False) + assert [t[1] for t in targets] == ["/x/NEW.MP4"] + + +# ---- _process_extract_target ---- + + +def _read_flags(db: Database, path: str) -> dict[str, int]: + with db.conn() as c: + row = c.execute( + "SELECT has_gpx, gps_examined FROM clip_index " + "WHERE path = ?", + (path,), + ).fetchone() + return { + "has_gpx": row["has_gpx"], + "gps_examined": row["gps_examined"], + } + + +def test_process_skips_moov_when_sidecar_already_present( + tmp_path: Path, db: Database, +) -> None: + """The first post-upgrade Extract GPS click on a library that + has correct sidecars on disk but stale gps_examined=0 in the + DB should NOT re-parse the moov atom — that's hours wasted on + a multi-GB archive. The helper short-circuits.""" + clip = tmp_path / "OLD.MP4" + clip.write_bytes(b"\x00" * 16) + sidecar = clip.with_suffix(".MP4.gpx") + sidecar.write_text("") + + _insert(db, path=str(clip), has_gpx=0, gps_examined=0) + + parse_calls: list = [] + def _parse(_): + parse_calls.append(1) + raise AssertionError("must not call parse_moov") + def _gen(_, __): + raise AssertionError("must not call generate_gpx") + + with db.conn() as c: + cid = c.execute( + "SELECT id FROM clip_index WHERE path=?", (str(clip),) + ).fetchone()["id"] + + result = _process_extract_target( + db, cid, str(clip), + parse_moov=_parse, generate_gpx=_gen, + ) + assert result == "sidecar_present" + assert parse_calls == [] + flags = _read_flags(db, str(clip)) + assert flags == {"has_gpx": 1, "gps_examined": 1} + + +def test_process_extracts_when_no_sidecar( + tmp_path: Path, db: Database, +) -> None: + clip = tmp_path / "FRESH.MP4" + clip.write_bytes(b"\x00" * 16) + sidecar_path = str(clip) + ".gpx" + + _insert(db, path=str(clip), has_gpx=0, gps_examined=0) + + def _parse(_): return [{"lat": 0, "lon": 0, "t": 0}] + def _gen(_, name): return f"" + + with db.conn() as c: + cid = c.execute( + "SELECT id FROM clip_index WHERE path=?", (str(clip),) + ).fetchone()["id"] + + result = _process_extract_target( + db, cid, str(clip), + parse_moov=_parse, generate_gpx=_gen, + ) + assert result == "extracted" + assert Path(sidecar_path).read_text().startswith(" + + + + + + + + + + + + + + +
+
+
+

Archive

+
+
+ + + + +
+ + + +
+
+ + +
+ + + + +
+ +
+ +
+ + + + +
+ + + + + diff --git a/web/static/setup.css b/web/static/setup.css new file mode 100644 index 0000000..fe00eef --- /dev/null +++ b/web/static/setup.css @@ -0,0 +1,73 @@ +:root { + --fg: #1a1a1a; + --bg: #fafafa; + --accent: #2c6e9b; + --warning-bg: #fff4d6; + --warning-border: #d8b144; + --error: #b03030; +} + +* { box-sizing: border-box; } +body { + font-family: system-ui, -apple-system, sans-serif; + margin: 0; + background: var(--bg); + color: var(--fg); + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; +} +main { + width: 100%; + max-width: 480px; + padding: 32px; + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); + margin: 24px; +} +h1 { margin-top: 0; } +.banner-warning { + background: var(--warning-bg); + border: 1px solid var(--warning-border); + padding: 12px; + border-radius: 4px; + margin-bottom: 24px; + font-size: 14px; +} +form { display: flex; flex-direction: column; gap: 16px; } +label { display: flex; flex-direction: column; gap: 4px; font-size: 14px; } +input[type="text"], input[type="password"] { + font-size: 16px; + padding: 8px 10px; + border: 1px solid #ccc; + border-radius: 4px; +} +.row { display: flex; gap: 8px; } +.row input { flex: 1; } +button { + font-size: 16px; + padding: 10px 14px; + background: var(--accent); + color: white; + border: 0; + border-radius: 4px; + cursor: pointer; +} +button[type="button"] { + background: #eee; + color: var(--fg); + flex: 0 0 auto; +} +.hint { font-size: 12px; color: #666; min-height: 1em; } +.hint.ok { color: #2a7a4a; } +.hint.bad { color: var(--error); } +.error { + background: #fde7e7; + border: 1px solid var(--error); + color: var(--error); + padding: 10px; + border-radius: 4px; +} +#submit:disabled { opacity: 0.5; cursor: not-allowed; } diff --git a/web/static/setup.html b/web/static/setup.html new file mode 100644 index 0000000..850c0bc --- /dev/null +++ b/web/static/setup.html @@ -0,0 +1,47 @@ + + + + + + Set up Viofosync + + + + +
+

Set up Viofosync

+ +
+ + + + + + + + +
+
+ + diff --git a/web/static/setup.js b/web/static/setup.js new file mode 100644 index 0000000..e7eb162 --- /dev/null +++ b/web/static/setup.js @@ -0,0 +1,64 @@ +// First-run wizard logic — vanilla JS, no build step. +(() => { + const form = document.getElementById("setup-form"); + const pw = document.getElementById("password"); + const confirm = document.getElementById("confirm"); + const strength = document.getElementById("strength"); + const match = document.getElementById("match"); + const errorBox = document.getElementById("error"); + const submit = document.getElementById("submit"); + const testBtn = document.getElementById("test-btn"); + const testResult = document.getElementById("test-result"); + const address = document.getElementById("address"); + + function update() { + const len = pw.value.length; + if (len === 0) { strength.textContent = ""; strength.className = "hint"; } + else if (len < 8) { strength.textContent = `${len}/8 characters`; strength.className = "hint bad"; } + else { strength.textContent = "OK"; strength.className = "hint ok"; } + + if (confirm.value === "") { match.textContent = ""; match.className = "hint"; } + else if (confirm.value !== pw.value) { match.textContent = "Passwords don't match"; match.className = "hint bad"; } + else { match.textContent = "Match"; match.className = "hint ok"; } + + submit.disabled = !(len >= 8 && confirm.value === pw.value); + } + + pw.addEventListener("input", update); + confirm.addEventListener("input", update); + + testBtn.addEventListener("click", async () => { + const v = address.value.trim(); + if (!v) { testResult.textContent = "Enter an address first"; testResult.className = "hint bad"; return; } + testResult.textContent = "Testing…"; testResult.className = "hint"; + try { + const r = await fetch("/api/setup/test-dashcam", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address: v }), + }); + const j = await r.json(); + if (j.ok) { testResult.textContent = `Reachable (${j.latency_ms}ms)`; testResult.className = "hint ok"; } + else { testResult.textContent = `Failed: ${j.error}`; testResult.className = "hint bad"; } + } catch (e) { + testResult.textContent = `Error: ${e.message}`; + testResult.className = "hint bad"; + } + }); + + form.addEventListener("submit", async (ev) => { + ev.preventDefault(); + errorBox.hidden = true; + const fd = new FormData(form); + const r = await fetch("/setup", { method: "POST", body: fd, redirect: "manual" }); + if (r.status === 303 || r.type === "opaqueredirect") { + window.location.href = "/"; + } else { + const text = await r.text(); + errorBox.hidden = false; + errorBox.textContent = text || `Setup failed (${r.status})`; + } + }); + + update(); +})(); diff --git a/web/static/styles.css b/web/static/styles.css new file mode 100644 index 0000000..f9947bf --- /dev/null +++ b/web/static/styles.css @@ -0,0 +1,1472 @@ +:root { + /* Palette. Neutrals are tinted toward the brand blue (hue ~250, + * chroma 0.008-0.016) so backgrounds and borders feel cohesive with + * the accent without reading as "tinted". The accent is a saturated + * cobalt; all chrome (active nav, focus rings, progress, selection, + * map polyline, primary CTAs) routes through it. */ + --bg: oklch(0.15 0.008 250); + --panel: oklch(0.20 0.010 250); + --panel-2: oklch(0.25 0.012 250); + --panel-3: oklch(0.30 0.014 250); + --border: oklch(0.32 0.014 250); + --border-hover: oklch(0.40 0.016 250); + --text: oklch(0.94 0.008 250); + --muted: oklch(0.66 0.018 250); + + /* Accent: cobalt. Sits a touch brighter than the legacy #4c8dff + * (≈ oklch 0.65/0.18) so dark text passes WCAG AA on top — the + * original white-on-cobalt was a 2.5:1 contrast fail. */ + --accent: oklch(0.68 0.19 258); + --accent-08: oklch(0.68 0.19 258 / 0.08); + --accent-10: oklch(0.68 0.19 258 / 0.10); + --accent-18: oklch(0.68 0.19 258 / 0.18); + --accent-35: oklch(0.68 0.19 258 / 0.35); + --accent-45: oklch(0.68 0.19 258 / 0.45); + --accent-85: oklch(0.68 0.19 258 / 0.85); + --on-accent: oklch(0.15 0.008 250); /* dark text on cobalt buttons */ + + /* Semantic. --err comes in two lightnesses because the saturated red + * used to fail AA contrast for text (audit flagged 4.12:1). The + * lighter --err-text is for inline error copy; --err itself stays + * for fills and borders. */ + --ok: oklch(0.74 0.16 145); + --warn: oklch(0.82 0.16 85); + --err: oklch(0.62 0.20 28); + --err-text: oklch(0.76 0.18 28); +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; height: 100%; } +[hidden] { display: none !important; } +body { + font-family: -apple-system, system-ui, sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14px; +} + +/* Visually-hidden but reachable by screen readers. + * Used for the password field label and for any future + * decorative-icon labels. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Skip-to-content link. Off-screen until a keyboard user + * tabs to it, at which point it floats over the header. */ +.skip-link { + position: absolute; + top: 8px; + left: 8px; + padding: 8px 14px; + background: var(--panel); + color: var(--text); + border: 1px solid var(--accent); + border-radius: 6px; + font-size: 13px; + font-weight: 500; + z-index: 10000; + transform: translateY(-200%); + transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +.skip-link:focus-visible { + transform: translateY(0); + outline: none; + box-shadow: 0 0 0 3px var(--accent-35); +} + +/* Universal focus ring. The desktop browser default is a 2px + * grey outline that vanishes against --panel; this replaces it + * with the brand accent at every interactive surface that + * doesn't already define its own focus style. Uses :focus-visible + * so mouse clicks don't get a permanent ring. */ +:where( + button, + a, + input, + select, + textarea, + summary, + [tabindex] +):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 6px; +} +.icon-btn:focus-visible, +.settings-link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +nav a:focus-visible { + outline-offset: 0; + background: var(--accent-10); +} +.settings-nav-link:focus-visible { + outline-offset: -2px; +} +a { color: var(--accent); text-decoration: none; } +button { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; +} +button:hover { background: var(--panel-3); } +input, select { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + padding: 6px 8px; + border-radius: 6px; +} +/* Every native check/radio gets the brand amber. The settings pane + * and journey-check rules used to set this individually; this base + * declaration covers the filter strips, clip-pair ticks, queue-day + * checkboxes, and modal autoplay too. */ +input[type="checkbox"], +input[type="radio"] { + accent-color: var(--accent); +} +.icon-btn { + display: flex; align-items: center; justify-content: center; + width: 34px; height: 34px; padding: 0; + border-radius: 50%; +} +.icon-btn:hover { background: var(--panel-3); } +.icon-btn.active { color: var(--ok); border-color: var(--ok); } +/* When the syncing icon is the visible one, give it a gentle + * continuous spin so the button reads as "in progress". */ +.icon-btn.active #sync-icon-sync:not([hidden]) { + animation: vfs-spin 1.6s linear infinite; + transform-origin: 50% 50%; +} +@keyframes vfs-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +.icon-btn.paused { + color: var(--err-text); + border-color: var(--err); + animation: pulse-red 2s ease-in-out infinite; +} +@keyframes pulse-red { + 0%, 100% { box-shadow: 0 0 4px oklch(0.62 0.20 28 / 0.3); } + 50% { box-shadow: 0 0 12px oklch(0.62 0.20 28 / 0.7); } +} +.cancel-btn { + background: none; border: none; color: var(--muted); + cursor: pointer; font-size: 18px; padding: 0 4px; + line-height: 1; +} +.cancel-btn:hover { color: var(--err-text); } +.current-header { + display: flex; align-items: center; gap: 8px; +} +.current-header .spacer { flex: 1; } +.view { min-height: 100vh; } +.error { color: var(--err-text); margin-top: 8px; min-height: 1em; } + +/* Login */ +#login { display: flex; align-items: center; justify-content: center; } +#login-form { + background: var(--panel); + padding: 32px; + border-radius: 12px; + border: 1px solid var(--border); + min-width: 320px; +} +#login-form h1 { margin-top: 0; } +#login-form input { width: 100%; margin: 8px 0; } +#login-form button { + width: 100%; + background: var(--accent); + border-color: var(--accent); + color: var(--on-accent); + font-weight: 600; + padding: 10px 12px; +} +#login-form button:hover { filter: brightness(1.06); background: var(--accent); } + +/* App chrome */ +#app header { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 24px; + background: var(--panel); + border-bottom: 1px solid var(--border); +} +#app header h1 { margin: 0; font-size: 18px; } +nav a { + padding: 6px 12px; + border-radius: 6px; +} +nav a.active { background: var(--panel-2); } + +/* Settings cog — top-right icon link. Same 34x34 footprint as + * the sync-toggle .icon-btn but its own active/hover styling so + * it doesn't pick up the icon-btn's "syncing-green" treatment. */ +.settings-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 6px; + color: var(--muted); + text-decoration: none; + border: 1px solid transparent; + transition: color 120ms ease, background 120ms ease, + border-color 120ms ease; +} +.settings-link:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.04); +} +.settings-link.active { + color: var(--text); + background: var(--panel-2); + border-color: var(--border); +} +.spacer { flex: 1; } +.status { + padding: 4px 10px; + border-radius: 999px; + background: var(--panel-2); + border: 1px solid var(--border); + font-size: 12px; +} +.status.offline { color: var(--err-text); border-color: var(--err); } +.status.online { color: var(--ok); border-color: var(--ok); } + +main { + padding: 24px; + max-width: 1400px; + margin: 0 auto; +} + +/* Filters */ +.filters { + display: flex; flex-wrap: wrap; gap: 12px; + padding: 12px; background: var(--panel); + border: 1px solid var(--border); border-radius: 8px; + margin-bottom: 16px; + align-items: center; +} +.filters label { + display: flex; flex-direction: column; + font-size: 12px; color: var(--muted); gap: 2px; +} +.archive-filters .spacer { flex: 1; } +.archive-filters .kind-label { flex-direction: row; } + +.archive-actions { + display: flex; align-items: center; gap: 8px; + flex-wrap: wrap; + padding: 6px 12px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 16px; + position: sticky; + top: 8px; + /* Leaflet's control container is at z-index 1000 (zoom buttons + * and attribution); we have to clear that, not just the panes + * (which top out around 700). 1100 puts the bar reliably + * above every Leaflet layer. */ + z-index: 1100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + transition: border-color 160ms ease, box-shadow 160ms ease, + background 160ms ease; +} +/* "Live" treatment kicks in only when there's a selection. + * The accent tint is layered ABOVE the opaque panel — using it + * as background-color directly would let map tiles bleed through + * the bar when it sits over a journey map. */ +.archive-actions.has-selection { + border-color: var(--accent); + background: + linear-gradient( + to right, + var(--accent-08), + transparent 40% + ), + var(--panel); + box-shadow: 0 6px 16px oklch(0 0 0 / 0.45); +} +.archive-actions .spacer { flex: 1; } +.archive-actions #selection-count { + font-size: 12px; + color: var(--text); font-weight: 500; + font-variant-numeric: tabular-nums; + margin-left: 4px; +} +.archive-actions #selection-count:empty { display: none; } +.archive-actions.has-selection #selection-count { + color: var(--accent); +} +/* Inline Export jobs toggle in the action bar. + * Styled to look like a clickable summary line — muted text, + * caret on the left, hover lifts to full text colour. */ +.exports-toggle { + display: inline-flex; align-items: center; gap: 6px; + background: transparent; + border: 1px solid transparent; + color: var(--muted); + padding: 4px 8px; + font-size: 12px; + cursor: pointer; + border-radius: 6px; +} +.exports-toggle:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.04); +} +.exports-toggle .caret { + font-size: 10px; + width: 10px; +} + +/* Drop-down panel below the action bar, populated by JS. + * Sticks just under the (also-sticky) action bar so the jobs + * table follows the user down the page. top:50px deliberately + * overlaps the bar's bottom edge by a few px so there's no + * visible gap between them; z-index 1099 sits one below the + * action bar's 1100 so the bar visually covers that overlap. */ +.exports-panel { + margin: 0 0 24px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 4px 8px; + position: sticky; + top: 50px; + z-index: 1099; + max-height: 50vh; + overflow-y: auto; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} +.exports-panel[hidden] { display: none; } +.exports-panel.just-submitted { + animation: exports-flash 1.6s ease-out; +} +@keyframes exports-flash { + 0% { box-shadow: 0 0 0 2px var(--accent-85), + 0 0 24px var(--accent-45); } + 100% { box-shadow: 0 0 0 0 transparent, + 0 0 0 transparent; } +} +.exports-table { width: 100%; border-collapse: collapse; } +.exports-table th, .exports-table td { + padding: 6px 10px; text-align: left; + border-bottom: 1px solid var(--border); font-size: 13px; +} +.exports-table th { color: var(--muted); font-weight: 500; } +.state-queued { color: var(--muted); } +.state-running { color: var(--accent); } +.state-cancelled { color: var(--warn); } + +/* Day cards */ +.day { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 8px; + overflow: hidden; + transition: border-color 120ms ease, background 120ms ease; +} +.day:hover { border-color: var(--border-hover); } +.day-header { + padding: 14px 18px; + cursor: pointer; + display: flex; + gap: 14px; + align-items: baseline; +} +.day-header:hover { background: rgba(255, 255, 255, 0.015); } +/* The date is the strongest landmark on the archive page; + * it deserves more weight than the metadata that follows it. + * Pre-bolder this was 15px/600 (≈1.07× the 14px body — flat). + * Bumping to 18px/700 puts the scale ratio at 1.28× which is the + * lower bound of the impeccable typography law (≥1.25× per step). */ +.day-header h3 { + margin: 0; + font-size: 18px; + font-weight: 700; + letter-spacing: -0.01em; + font-variant-numeric: tabular-nums; + flex: 0 0 auto; +} +.day-header .meta { + color: var(--muted); + font-size: 12px; + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; +} +.day-header .meta .sep { + color: var(--border-hover); + margin: 0 6px; +} +.day-body { padding: 16px 18px; border-top: 1px solid var(--border); } +.day-body[hidden] { display: none; } + +.clip-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; +} +.clip-pair { + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px; + overflow: hidden; +} +.clip-pair .time { + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: 0.005em; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.clip-pair .time label { flex: 1 1 auto; } +.clip-pair .time .kind-badge { font-weight: 500; } +.clip-pair .thumbs { + display: grid; grid-template-columns: 1fr 1fr; gap: 4px; + margin: 6px 0; +} +.clip-pair img { + width: 100%; aspect-ratio: 16/9; object-fit: cover; + background: var(--bg); border-radius: 4px; cursor: pointer; +} +/* Filenames are debug-y data; fade them and clip to one line. + * Hover reveals the full string via the title attribute on the + * label element (set in the renderer). */ +.clip-pair .label { + font-size: 10px; + color: var(--muted); + font-family: "SF Mono", "Menlo", monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.75; +} +.clip-pair:hover .label { opacity: 1; } +.clip-pair.flash { + outline: 2px solid var(--accent); + animation: flash-pulse 1.5s ease-out; +} +@keyframes flash-pulse { + 0% { box-shadow: 0 0 28px var(--accent-85); } + 100% { box-shadow: 0 0 0 transparent; } +} + +#map { height: 340px; border-radius: 8px; margin: 16px 0; } + +.journey-table { + width: 100%; border-collapse: collapse; + background: var(--panel-2); border-radius: 6px; overflow: hidden; +} +.journey-table th, .journey-table td { + padding: 8px 12px; text-align: left; + border-bottom: 1px solid var(--border); font-size: 13px; +} + +/* Journey cards (per-journey map + clips) */ +.journey-card { + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 8px; + margin: 16px 0; + overflow: hidden; +} +/* Tighter spacing for collapsed cards so a long day reads as a list. */ +.journey-card.collapsible { margin: 6px 0; } +.journey-header { + display: flex; align-items: center; gap: 12px; + padding: 10px 14px; + flex-wrap: wrap; +} +.journey-card.collapsible .journey-header { + cursor: pointer; + user-select: none; + transition: background 120ms ease; +} +.journey-card.collapsible .journey-header:hover { + background: rgba(255, 255, 255, 0.025); +} +.journey-header .caret { + color: var(--muted); + font-size: 12px; + width: 12px; + flex: 0 0 auto; +} +/* "Select all" tick in the journey/stop card header. */ +.journey-header .journey-check { + flex: 0 0 auto; + margin: 0; + cursor: pointer; + accent-color: var(--accent); +} +.journey-body { + border-top: 1px solid var(--border); +} +.journey-body[hidden] { display: none; } +.journey-header strong { font-size: 14px; } +.journey-title { + display: inline-flex; align-items: center; gap: 8px; + flex-wrap: wrap; max-width: 100%; +} +.journey-arrow { color: var(--muted); } +.start-label, .end-label, .stop-label { + overflow-wrap: anywhere; +} +.journey-header .journey-times { color: var(--text); font-size: 13px; } +.journey-header .journey-meta { + color: var(--muted); font-size: 12px; + margin-left: auto; +} +.journey-map { + height: 560px; + border-bottom: 1px solid var(--border); +} +.journey-map .leaflet-interactive { cursor: pointer; } +.journey-card .clip-grid { padding: 12px; } + +.stop-card .journey-header { background: rgba(241, 196, 15, 0.05); } +.stop-card .stop-icon { font-size: 14px; color: var(--warn); } +.stop-card .journey-map { height: 200px; } + +.stop-banner { + display: flex; align-items: center; gap: 10px; + padding: 8px 14px; + background: var(--panel); + border: 1px dashed var(--border); + border-radius: 6px; + margin: 8px 0; + color: var(--muted); + font-size: 12px; + flex-wrap: wrap; +} +.stop-banner .stop-icon { font-size: 14px; } +.stop-banner strong { color: var(--text); } +.stop-banner .stop-where { font-family: "SF Mono", monospace; } +.stop-banner .stop-when { margin-left: auto; } + +.other-clips-head { + margin: 20px 0 8px; + font-size: 14px; + color: var(--muted); + font-weight: 500; +} + +#pagination { + display: flex; gap: 8px; justify-content: center; + padding: 16px; +} + +/* Page header — used by both archive and downloads views. + * The h2 carries the page title; .view-header allows future + * trailing widgets (counts, controls) via flex slots. */ +.view-header { + display: flex; align-items: baseline; gap: 12px; + margin: 4px 0 16px; +} +.view-header h2 { + margin: 0; + flex: 1; + font-size: 24px; + font-weight: 700; + letter-spacing: -0.015em; +} +.badge { + background: var(--panel-2); border: 1px solid var(--border); + padding: 4px 10px; border-radius: 999px; + color: var(--muted); font-size: 12px; +} +.current { + background: var(--panel); border: 1px solid var(--border); + border-radius: 8px; padding: 16px; margin-bottom: 16px; +} +.current .bar { + height: 8px; background: var(--panel-2); border-radius: 4px; + overflow: hidden; margin-top: 8px; +} +.current .bar > div { + height: 100%; background: var(--accent); width: 0%; + transition: width 0.2s; +} +.queue-filters { + display: flex; gap: 10px; margin-bottom: 12px; + align-items: center; flex-wrap: wrap; + padding: 10px 12px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; +} +.queue-filters #q-filter { + flex: 1 1 220px; + min-width: 200px; +} +.filter-sep { + width: 1px; + height: 22px; + background: rgba(255, 255, 255, 0.12); + margin: 0 6px; + flex: 0 0 auto; +} +.recent-label { + display: flex; align-items: center; gap: 6px; + font-size: 13px; +} +.queue-meta { + color: var(--muted); + font-size: 12px; + margin-bottom: 12px; + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; +} +#queue { width: 100%; border-collapse: collapse; } +#queue th.sortable { + cursor: pointer; user-select: none; white-space: nowrap; +} +#queue th.sortable:hover { color: var(--accent); } +#queue th.sortable::after { + content: ""; display: inline-block; width: 0; margin-left: 4px; +} +#queue th.sortable.sort-asc::after { content: " \25B2"; font-size: 10px; } +#queue th.sortable.sort-desc::after { content: " \25BC"; font-size: 10px; } +#queue th, #queue td { + padding: 8px; text-align: left; + border-bottom: 1px solid var(--border); font-size: 13px; +} +#queue tbody tr:hover { background: var(--panel-2); } +.pagination { + display: flex; + gap: 8px; + justify-content: center; + flex-wrap: wrap; + padding: 16px 0; +} +.state-pending { color: var(--muted); } +.state-downloading { color: var(--accent); } +.state-done { color: var(--ok); } +.state-failed { color: var(--err-text); } +.state-gone { color: var(--warn); } +.order-cell { text-align: center; color: var(--muted); font-variant-numeric: tabular-nums; } + +/* Downloads — accordion by day */ +.queue-day-header { gap: 12px; } +.queue-day-header h3 { + font-size: 15px; + font-variant-numeric: tabular-nums; + letter-spacing: -0.005em; +} +.queue-day-header .meta { flex: 0 0 auto; } +.queue-day-header .state-breakdown { + flex: 1; + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; + font-variant-numeric: tabular-nums; +} +.queue-day-header .state-breakdown span { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 9px 2px 7px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.025); + border: 1px solid rgba(255, 255, 255, 0.05); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.01em; + color: var(--muted); + white-space: nowrap; +} +.queue-day-header .state-breakdown span::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + flex: 0 0 auto; + background: var(--muted); +} +.queue-day-header .state-breakdown .state-pending { + color: var(--muted); +} +.queue-day-header .state-breakdown .state-pending::before { + background: var(--muted); +} +.queue-day-header .state-breakdown .state-downloading { + color: var(--accent); + border-color: var(--accent-35); + background: var(--accent-08); +} +.queue-day-header .state-breakdown .state-downloading::before { + background: var(--accent); +} +.queue-day-header .state-breakdown .state-done { + color: var(--ok); +} +.queue-day-header .state-breakdown .state-done::before { + background: var(--ok); +} +.queue-day-header .state-breakdown .state-failed { + color: var(--err-text); + border-color: oklch(0.62 0.20 28 / 0.30); + background: oklch(0.62 0.20 28 / 0.06); +} +.queue-day-header .state-breakdown .state-failed::before { + background: var(--err); +} +.queue-day-header .state-breakdown .state-gone { + color: var(--warn); +} +.queue-day-header .state-breakdown .state-gone::before { + background: var(--warn); +} +.queue-day-header .caret { color: var(--muted); font-size: 12px; } +.queue-day-header .qd-check { margin: 0; cursor: pointer; } +.queue-day-header .qd-check:disabled { cursor: not-allowed; opacity: 0.4; } +.queue-day-stale .queue-day-header h3, +.queue-day-stale .queue-day-header .meta { opacity: 0.6; } +.queue-day-body { padding: 8px 16px 16px; } + +.queue-items { width: 100%; border-collapse: collapse; } +.queue-items th, .queue-items td { + padding: 6px 8px; text-align: left; + border-bottom: 1px solid var(--border); font-size: 13px; +} +.queue-items th { color: var(--muted); font-weight: 500; } +.queue-items tbody tr:hover { background: var(--panel-2); } +.queue-items .qi-check { cursor: pointer; } + +.kind-label { + display: flex; align-items: center; gap: 6px; + font-size: 13px; color: var(--text); + padding: 0 6px; + user-select: none; +} +.kind-label input { margin: 0; cursor: pointer; } + +.kind-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + font-size: 11px; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--muted); + white-space: nowrap; +} +/* Camera-pair badges. Front uses brand amber (headlamp), rear uses + * a deep teal — opposite enough on the colour wheel to read at a glance, + * close enough in lightness that neither dominates. Parking / event / + * read-only stay semantic. The text-on-fill colour for kind-R isn't the + * accent because saturated red would clash with kind-event. */ +.kind-badge.kind-F { color: var(--accent); border-color: var(--accent); } +.kind-badge.kind-R { + color: oklch(0.76 0.10 200); + border-color: oklch(0.50 0.08 200); +} +.kind-badge.kind-parking { color: var(--warn); border-color: var(--warn); } +.kind-badge.kind-event { color: var(--err-text); border-color: var(--err); } +.kind-badge.kind-ro { + color: oklch(0.70 0.04 250); + border-color: oklch(0.45 0.04 250); +} + +/* Modal — must sit above the sticky archive-actions bar + * (z-index: 1100) and the sticky exports panel (1099) so the + * video player isn't clipped or underlapped when a journey card + * happens to be expanded behind it. */ +#modal { + position: fixed; inset: 0; background: oklch(0.10 0.010 250 / 0.82); + display: flex; align-items: center; justify-content: center; + z-index: 2000; +} +.modal-inner { + background: var(--panel); border-radius: 12px; + padding: 16px; max-width: 90vw; max-height: 90vh; + position: relative; +} +.modal-inner video { max-width: 80vw; max-height: 80vh; display: block; } +#modal-close { + position: absolute; top: 8px; right: 8px; + background: none; border: none; font-size: 24px; color: var(--text); +} +.modal-nav { + display: flex; gap: 8px; justify-content: center; + margin-top: 10px; +} +.modal-nav button { + min-width: 110px; +} +.modal-nav button:disabled { + opacity: 0.35; + cursor: not-allowed; +} +#modal-toggle { min-width: 130px; } +.modal-autoplay { + display: flex; align-items: center; gap: 6px; + color: var(--muted); font-size: 13px; + padding: 0 8px; user-select: none; cursor: pointer; +} +.modal-autoplay input { cursor: pointer; } + +/* Log panel */ +.log-panel { + margin-top: 24px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} +.log-panel summary { + padding: 10px 16px; + cursor: pointer; + font-size: 13px; + color: var(--muted); + user-select: none; +} +.log-panel summary:hover { color: var(--text); } +.log-entries { + max-height: 300px; + overflow-y: auto; + padding: 0 16px 12px; + font-family: "SF Mono", "Menlo", monospace; + font-size: 11px; + line-height: 1.6; +} +.log-line { white-space: nowrap; } +.log-ts { color: var(--muted); margin-right: 8px; } +.log-msg { color: var(--text); } + +/* Settings */ +.settings-layout { + display: grid; + grid-template-columns: 220px 1fr; + gap: 28px; + align-items: start; +} + +.settings-sidebar { + display: flex; + flex-direction: column; + gap: 1px; + position: sticky; + top: 24px; + padding: 6px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; +} + +.settings-nav-link { + padding: 8px 12px; + border-radius: 6px; + text-decoration: none; + color: var(--muted); + font-size: 13px; + letter-spacing: 0.005em; + transition: color 120ms ease, background 120ms ease; +} +.settings-nav-link:hover { + color: var(--text); + background: var(--panel-2); +} +/* Active settings nav: tinted-amber fill + accent text. The previous + * 2px inset side-stripe was an absolute-ban anti-pattern (audit flag); + * this treatment is now consistent with the mobile pill rail and + * reads as an obvious "current" state without the stripe. */ +.settings-nav-link.active { + color: var(--accent); + background: var(--accent-10); + font-weight: 500; +} + +.settings-pane { + position: relative; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 28px 32px; + min-height: 480px; +} + +.settings-pane h3 { + margin: 0 0 18px; + font-size: 16px; + font-weight: 600; + color: var(--text); + letter-spacing: -0.005em; +} +.settings-pane h3 + .form-row { margin-top: 0; } +.settings-pane h3:not(:first-child) { + margin-top: 36px; + padding-top: 24px; + border-top: 1px solid var(--border); +} + +.settings-pane .form-row { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 20px; + max-width: 420px; +} +/* Checkbox rows render inline so the label sits next to the box. */ +.settings-pane .form-row:has(> input[type="checkbox"]:only-of-type), +.settings-pane .form-row:has(> label > input[type="checkbox"]) { + flex-direction: row-reverse; + justify-content: flex-end; + align-items: center; + gap: 10px; +} + +.settings-pane label { + font-size: 12px; + font-weight: 500; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} +/* Labels paired with a checkbox read as sentences, so drop the + * uppercase field-metadata treatment. Covers both DOM shapes: + * label-as-sibling (renderField) and label-wraps-input + * (renderSecuritySection). */ +.settings-pane .form-row:has(> input[type="checkbox"]:only-of-type) > label, +.settings-pane label:has(input) { + font-size: 14px; + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: var(--text); +} +.settings-pane label:has(input) { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.settings-pane input[type="text"], +.settings-pane input[type="number"], +.settings-pane input[type="password"], +.settings-pane select { + font-size: 14px; + padding: 8px 10px; + width: 100%; +} +.settings-pane input:focus-visible, +.settings-pane select:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-18); +} + +.settings-pane input[type="checkbox"] { + accent-color: var(--accent); + width: 16px; + height: 16px; + margin: 0; + cursor: pointer; +} + +.settings-pane .hint { + font-size: 12px; + color: var(--muted); + margin: 6px 0 0; + max-width: 60ch; + line-height: 1.55; +} +.settings-pane .hint.ok { color: var(--ok); } +.settings-pane .hint.bad { color: var(--err-text); } + +.settings-pane button { + font-size: 13px; + min-width: 80px; +} +.settings-pane button#pw-save, +.settings-pane button#restart-now { + background: var(--accent); + border-color: var(--accent); + color: var(--on-accent); + font-weight: 600; +} +.settings-pane button#pw-save:hover, +.settings-pane button#restart-now:hover { + filter: brightness(1.06); +} +.settings-pane button#rotate-secret { + background: transparent; + color: var(--err-text); + border-color: oklch(0.62 0.20 28 / 0.40); +} +.settings-pane button#rotate-secret:hover { + background: oklch(0.62 0.20 28 / 0.08); + border-color: var(--err); +} + +/* Read-only system block */ +.settings-pane dl.readonly { + display: grid; + grid-template-columns: 200px 1fr; + gap: 10px 24px; + margin: 0 0 18px; + padding: 16px 20px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 13px; +} +.settings-pane dl.readonly dt { + color: var(--muted); + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + align-self: center; +} +.settings-pane dl.readonly dd { + margin: 0; + color: var(--text); + font-family: "SF Mono", "Menlo", monospace; + font-size: 13px; +} + +.settings-footer { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + margin: 28px -32px -28px; + padding: 14px 32px; + background: var(--panel); + border-top: 1px solid var(--border); + border-radius: 0 0 10px 10px; + z-index: 10; +} +.settings-footer #settings-pending-summary { + flex: 1; + font-size: 13px; + color: var(--accent); + font-weight: 500; +} +.settings-footer .primary { + background: var(--accent); + border-color: var(--accent); + color: var(--on-accent); + font-weight: 600; +} +.settings-footer .primary:hover { filter: brightness(1.06); } + +.restart-required-chip { + display: inline-block; + padding: 2px 7px; + margin-left: 8px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--warn); + background: rgba(241, 196, 15, 0.08); + border: 1px solid rgba(241, 196, 15, 0.35); + border-radius: 3px; + vertical-align: middle; +} + +.banner-restart { + position: fixed; + top: 0; + left: 0; + right: 0; + padding: 10px 20px; + background: rgba(241, 196, 15, 0.12); + border-bottom: 1px solid rgba(241, 196, 15, 0.45); + color: var(--warn); + text-align: center; + font-size: 13px; + font-weight: 500; + z-index: 100; +} + +/* ============================================================ + * Responsive adaptations + * + * Three content-driven breakpoints, picked from where the + * desktop layout actually breaks rather than from device classes: + * 1024px shrink chrome, reclaim padding from the map cards + * 720px header wraps; settings sidebar becomes a top pill rail; + * sticky bars stop being sticky; clip cells relax + * 480px phone portrait: status pill loses its label, + * clip grid pins to two columns, captions hide + * + * Plus two input-method queries (pointer/hover) that apply at any + * size, because a touch laptop at 1366px still needs 44px targets. + * ============================================================ */ + +/* Coarse pointer: floor every standard hit-area at 44px without + * resizing the visible glyph. Keeps the desktop density unchanged + * for mouse users. */ +@media (pointer: coarse) { + .icon-btn { width: 44px; height: 44px; } + .settings-link { width: 44px; height: 44px; } + nav a { padding: 10px 14px; } + #logout { min-height: 36px; padding: 8px 14px; } + .settings-nav-link { padding: 12px 14px; } + .settings-pane button { min-height: 36px; } + .cancel-btn { min-width: 36px; min-height: 36px; padding: 0 10px; } + .modal-nav button { min-height: 44px; } + #modal-close { + width: 40px; height: 40px; + border-radius: 8px; + font-size: 22px; + } + .day-header { padding: 16px 18px; } + #pagination button, + .pagination button { min-width: 44px; min-height: 44px; } +} + +/* No real hover: kill the hover-bound surface changes so they + * don't latch on tap and look stuck. */ +@media (hover: none) { + .day:hover { border-color: var(--border); } + .day-header:hover { background: transparent; } + .journey-card.collapsible .journey-header:hover { background: transparent; } + #queue tbody tr:hover, + .queue-items tbody tr:hover { background: transparent; } + .settings-nav-link:hover { color: var(--muted); background: transparent; } + .settings-nav-link.active:hover { color: var(--text); background: var(--panel-2); } + .settings-link:hover { color: var(--muted); background: transparent; } + .exports-toggle:hover { color: var(--muted); background: transparent; } + .icon-btn:hover { background: var(--panel-2); } + button:hover { background: var(--panel-2); } + .clip-pair:hover .label { opacity: 0.75; } +} + +/* === <= 1024px : small laptops, tablet landscape === */ +@media (max-width: 1024px) { + main { padding: 20px 16px; } + .journey-map { height: 420px; } + #map { height: 280px; } +} + +/* === <= 720px : tablet portrait, large phones === */ +@media (max-width: 720px) { + main { padding: 16px 12px; } + + /* Header reorders: brand + actions on row 1, nav on its own row. + * The spacer (which was eating 1900px on desktop) is dropped, and + * nav links stretch so the two tabs are equal-weight thumb targets. */ + #app header { + flex-wrap: wrap; + gap: 8px 10px; + padding: 10px 14px; + padding-left: max(14px, env(safe-area-inset-left)); + padding-right: max(14px, env(safe-area-inset-right)); + } + #app header > .spacer { display: none; } + #app header h1 { flex: 0 1 auto; margin-right: auto; } + #app header nav { + order: 10; + flex: 1 1 100%; + display: flex; + gap: 4px; + } + nav a { flex: 1; text-align: center; } + + /* Filter rows: compact padding, drop the spacer so wrap is even. */ + .filters, + .queue-filters, + .archive-actions { padding: 10px; } + .filters .spacer, + .archive-actions .spacer, + .archive-filters .spacer { display: none; } + + /* Sticky bars stop earning their keep on a short viewport. */ + .archive-actions { + position: static; + box-shadow: none; + } + .exports-panel { + position: static; + max-height: none; + } + + /* Settings: collapse to one column. The vertical sidebar + * becomes a horizontally-scrolling pill rail so all sections + * remain one tap away without a hamburger detour. The desktop's + * inset accent stripe is swapped for a tinted fill at this + * breakpoint, which also clears the side-stripe anti-pattern + * the audit flagged. */ + .settings-layout { + grid-template-columns: 1fr; + gap: 14px; + } + .settings-sidebar { + position: static; + top: auto; + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + gap: 4px; + padding: 6px; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + mask-image: linear-gradient( + to right, + transparent 0, + black 16px, + black calc(100% - 16px), + transparent 100% + ); + } + .settings-sidebar::-webkit-scrollbar { display: none; } + .settings-nav-link { + flex: 0 0 auto; + white-space: nowrap; + padding: 8px 14px; + } + .settings-nav-link.active { + box-shadow: none; + background: var(--accent-10); + color: var(--accent); + } + .settings-pane { + padding: 20px 18px; + min-height: 320px; + } + .settings-footer { + margin: 20px -18px -20px; + padding: 12px 18px; + } + .settings-pane dl.readonly { + grid-template-columns: 1fr; + gap: 4px 0; + padding: 14px 16px; + } + .settings-pane dl.readonly dt { margin-top: 10px; } + .settings-pane dl.readonly dt:first-of-type { margin-top: 0; } + + /* Day rows: the meta block wraps below the date instead of + * fighting it for one row. */ + .day-header { + flex-wrap: wrap; + gap: 4px 12px; + padding: 14px; + } + .day-header h3 { flex: 1 1 100%; } + .day-body { padding: 14px; } + + /* Journey + stop cards. */ + .journey-header { + padding: 12px; + gap: 8px 10px; + } + .journey-header .journey-meta { + margin-left: 0; + flex: 1 1 100%; + } + .journey-map { height: 320px; } + .stop-card .journey-map { height: 180px; } + #map { height: 240px; } + .journey-card .clip-grid { padding: 10px; } + + /* Clip grid: tighter floor so two thumbs fit at ~375px. */ + .clip-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 8px; + } + .clip-pair { padding: 6px; } + + /* Downloads queue is 8 columns wide. Rather than reshuffle into + * a card layout (and lose column alignment), let the table + * scroll horizontally inside its day body. The table keeps its + * natural density for users who can scroll; the day header, + * filters, and meta above it remain pinned to the page. */ + .queue-day-body { padding: 8px 10px 12px; } + .queue-day-body table.queue-items { + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + width: 100%; + } + .exports-table-wrap, + .exports-panel { overflow-x: auto; } + .exports-table { min-width: 520px; } + + /* Downloads breakdown pills wrap below the date instead of + * crowding the row. */ + .queue-day-header { flex-wrap: wrap; } + .queue-day-header h3 { flex: 1 1 auto; } + .queue-day-header .state-breakdown { + flex: 1 1 100%; + justify-content: flex-start; + margin-top: 4px; + } + + /* Modal: respect safe-area, cap video by small-vh so the close + * button stays in reach above the iOS chrome. */ + .modal-inner { + padding: 12px; + padding-top: max(12px, env(safe-area-inset-top)); + max-width: calc(100vw - 16px); + } + .modal-inner video { + max-width: calc(100vw - 24px); + max-height: 60svh; + } + .modal-nav { + flex-wrap: wrap; + gap: 6px; + } + .modal-nav button { + flex: 1 1 calc(50% - 6px); + min-width: 0; + } + + /* Restart banner sits above a possible iOS notch. */ + .banner-restart { + padding-top: max(10px, env(safe-area-inset-top)); + } + + /* Login form is fluid below its desktop minimum. */ + #login-form { + min-width: 0; + width: min(360px, 100% - 32px); + padding: 24px; + } +} + +/* === <= 480px : phone portrait === */ +@media (max-width: 480px) { + main { padding: 12px 10px; } + + #app header { padding: 8px 10px; } + #app header h1 { font-size: 16px; } + + /* The "Dashcam offline / online" pill drops its label and + * becomes a coloured dot. Colour already carried the meaning; + * the word was redundant at thumb scale. */ + .status { + font-size: 0; + padding: 0; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--muted); + border: none; + } + .status.offline { background: var(--err); } + .status.online { background: var(--ok); } + + .day-header h3 { font-size: 14px; } + .day-header .meta { font-size: 11px; } + .journey-title { font-size: 13px; } + + /* Two-up clip grid. The 16:9 thumbs stay legible at ~170px. */ + .clip-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + } + /* The filename caption is debug ergonomics, not user ergonomics. + * Already faded on desktop; on phone it fights the time label + * for every pixel. Hide it. */ + .clip-pair .label { display: none; } + + /* Action buttons wrap two-up rather than overflowing. */ + .archive-actions button { + flex: 1 1 calc(50% - 4px); + } + + /* Pagination chips: thumb-sized. */ + #pagination button, + .pagination button { + min-width: 44px; + min-height: 44px; + } +} + +/* === Very narrow / iPhone SE class (<= 380px) === */ +@media (max-width: 380px) { + main { padding: 10px 8px; } + .day-header { padding: 12px 10px; } + .journey-header { padding: 10px; } + .clip-grid { gap: 4px; } + .filters, + .archive-actions, + .queue-filters { padding: 8px; } +} + +/* ============================================================ + * Hardening + * ============================================================ */ + +/* Hit-area floor for the smallest visible controls. Even with a + * mouse, a 24x24 close button at viewport top-right is a frequent + * miss. The visible glyph stays the same; a transparent padded + * area extends the click zone. */ +.cancel-btn { + min-width: 28px; + min-height: 28px; + padding: 0 6px; +} +#modal-close { + width: 36px; + height: 36px; + border-radius: 8px; + font-size: 22px; + line-height: 1; +} +#modal-close:hover { background: rgba(255, 255, 255, 0.06); } + +/* Flex children that hold long strings need min-width: 0 so they + * can shrink below their natural content size and let the text's + * own overflow-wrap take over. Without this, a 90-character + * journey label pushes its row out instead of wrapping. */ +.journey-title, +.day-header .meta, +.clip-pair .label, +#app header h1 { + min-width: 0; +} + +/* Reduced motion. Vestibular-disorder accessibility (WCAG 2.3.3). + * The repeating animations — the sync spinner and the red-pulse + * pause halo — are the worst offenders; both communicate state + * the user has another channel for (icon shape, button colour), + * so we stop them outright and shorten the few one-shot + * transitions that remain. The selection-flash and exports-flash + * are kept as instant outlines so users still get feedback. */ +@media (prefers-reduced-motion: reduce) { + .icon-btn.active #sync-icon-sync:not([hidden]) { animation: none; } + .icon-btn.paused { + animation: none; + box-shadow: 0 0 0 1px var(--err); + } + .clip-pair.flash { animation: none; } + .exports-panel.just-submitted { animation: none; } + .skip-link { transition: none; } + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + } +}