Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ WEBRTC_CANDIDATE=

# --- Recording ---
SEGMENT_SECONDS=4 # 2-6s; shorter = snappier seek. Starts on a keyframe.
# SEGMENT_RECEIPT_TIMEOUT_SECS=90 # stall watchdog before a worker reconnects; clamped to [20, 3600]

# --- Recorder supervision loops (optional; defaults are right for almost everyone) ---
# CONFIG_POLL_SECONDS=30 # how often the recorder re-diffs the DB camera list
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ services:
ARCHIVE_STORAGE_PATH: ${ARCHIVE_STORAGE_PATH:-/data/archive}
ARCHIVE_STORAGE_NAME: "${ARCHIVE_STORAGE_NAME:-Archive}"
SEGMENT_SECONDS: ${SEGMENT_SECONDS:-4}
SEGMENT_RECEIPT_TIMEOUT_SECS: ${SEGMENT_RECEIPT_TIMEOUT_SECS:-} # stall watchdog before a worker reconnects; empty = default 90s, clamped to [20, 3600]
MOTION_HWACCEL: ${MOTION_HWACCEL:-cpu} # 'cpu' works on any host (software motion decode); set vaapi/cuda/auto for a GPU (overlays) or via the admin decode-backend setting
MAX_GPU_DECODE_SESSIONS: ${MAX_GPU_DECODE_SESSIONS:-4}
CONFIG_POLL_SECONDS: ${CONFIG_POLL_SECONDS:-30}
Expand Down
12 changes: 8 additions & 4 deletions docs-site/docs/configuration/environment-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ key in `.env`, restart the affected container, done. No
the admin console, the console value (stored in the database) wins over the
env default; that's flagged in the notes.

Every secret-bearing key also answers to a `_FILE` twin (`JWT_SECRET_FILE`,
`GO2RTC_PASS_FILE`, and the rest) holding a path to read the value from, for
Docker secrets. Only `HA_TOKEN_FILE` gets its own row below, because the others
are mechanical; see [Secrets](/configuration/secrets) for the list.
Most secret-bearing keys also answer to a `_FILE` twin (`DATABASE_URL_FILE`,
`JWT_SECRET_FILE`, `SEED_ADMIN_PASSWORD_FILE`, `HA_TOKEN_FILE`) holding a path
to read the value from, for Docker secrets. `GO2RTC_USER`/`GO2RTC_PASS` are the
exception: the embedded go2rtc restreamer expands them straight from the
process environment and compose requires the plain vars, so those two don't
support `_FILE`. Only `HA_TOKEN_FILE` gets its own row below, because the
others are mechanical; see [Secrets](/configuration/secrets) for the list.

## Time zone

Expand Down Expand Up @@ -69,6 +72,7 @@ console's Server & streaming settings, that value wins.
| Key | Default | Notes |
|---|---|---|
| `SEGMENT_SECONDS` | `4` | 2 to 6 seconds; short segments mean near-instant seek |
| `SEGMENT_RECEIPT_TIMEOUT_SECS` | `90` | stall watchdog: how long a worker waits for the next segment before it reconnects. Raise it for a long-GOP camera whose keyframe interval exceeds the default. Clamped to `[20, 3600]`; unset or unparseable falls back to the default rather than erroring. |

## Recorder internals

Expand Down
10 changes: 6 additions & 4 deletions docs-site/docs/configuration/secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,12 @@ provision a fresh database (you lose the segment index, so restore a
old one immediately, so update any running worker with the new value.
- Secrets that support it can come from a file instead of the environment
via the `_FILE` convention (`DATABASE_URL_FILE`, `JWT_SECRET_FILE`,
`GO2RTC_USER_FILE` / `GO2RTC_PASS_FILE`, `SEED_ADMIN_PASSWORD_FILE`,
`HA_TOKEN_FILE`). Point one at a Docker-secret path and Crumb reads the
file, keeping the plaintext value out of the process environment and
`.env`. See `scripts/setup-secrets.sh` and
`SEED_ADMIN_PASSWORD_FILE`, `HA_TOKEN_FILE`). Point one at a Docker-secret
path and Crumb reads the file, keeping the plaintext value out of the
process environment and `.env`. `GO2RTC_USER`/`GO2RTC_PASS` don't support
this convention: the embedded go2rtc restreamer expands them straight from
the process environment, and compose requires the plain vars, so those two
stay as regular `.env` values. See `scripts/setup-secrets.sh` and
`docker-compose.secrets.example.yml`.

## If you hand-edit `.env` instead of using the script
Expand Down
2 changes: 1 addition & 1 deletion docs-site/docs/getting-started/upgrade-and-rollback.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ images.)

This one was tested end to end as an in-place upgrade from the published
v0.1.1 images. It is a drop-in: `.env` needs no changes, the new migrations
(0072 through 0078) apply in a single pass on first boot, footage came
(0072 through 0080) apply in a single pass on first boot, footage came
through byte-identical, and logins, roles, policies, and the authenticated
RTSP restream default were all intact afterwards. Four things are worth
knowing before you run it.
Expand Down
18 changes: 18 additions & 0 deletions docs-site/docs/integrations/license-plate-recognition.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,24 @@ draw them in the LPR section's per-camera zone editor over a live snapshot.
OCR flip on one of those can never push a real watchlisted plate over the
budget, which is the behavior I want (I'd rather never miss an alert).

## Naming a plate

A plate is a string of characters until you tell Crumb otherwise. From the
Plates tab (web console, desktop, Android, and iOS) you can give any plate a
human-readable name, "Jason's truck", "Mail carrier", whatever tells you at a
glance what you're looking at. Naming is admin-only and is a separate action
from watchlisting: you can name a plate without watchlisting it, watchlist a
plate without naming it, or do both. Once set, the name shows wherever that
plate appears, reads, watchlist, and the plate detail, with the raw plate
number still legible underneath so you're never guessing what was actually
read. Clearing the name falls back to a watchlist label if one exists, then to
the raw plate.

**Copy a plate number** to the clipboard straight from a reads row, a
watchlist row, or the plate detail, on the web console, desktop, Android, and
iOS, so you can hand it off (a call to the front gate, a note to a neighbor)
without retyping it.

## Searching and retention

Search the read database with `GET /plates` (exact, prefix, contains, or fuzzy
Expand Down
4 changes: 2 additions & 2 deletions docs/COMPONENT-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,8 @@ is not. The web admin console doubles as the desktop's management surface
| LPR A/B engine benchmark (`docs/DECISIONS.md` 2026-07-17 A/B entry; backend: `GET /lpr/ab-report` `view_plates` + `POST /lpr/ab-confirm` admin-only in `plates.rs`, pure pairing in `services/common/src/lpr_ab.rs`, `lpr_pass_truth` migration `0070`; applies only to `lpr_engine = 'both'` cameras) | Deferred (compact read-only stat view is a nice-to-have) | **Benchmark** dialog off the Plates tab (`apps/desktop-flutter/lib/ui/plates/ab_benchmark.dart`), button auto-hidden unless the server reports a `both` camera; confirm-true-plate is admin-only | Deferred | Deferred |
| LPR plate names (issue #363, `docs/DECISIONS.md` 2026-07-31 plate-names entry; backend: `plate_labels` migration `0073`, `PUT /lpr/plate-labels` + `DELETE /lpr/plate-labels/:plate` admin-only in `plates.rs`, display-name resolution `COALESCE(plate_labels.label, lpr_watchlist.label)` on the normalized plate folded into `list_plate_reads` + the watchlist read in `db.rs`, `display_name` added to `PlateRead`/`PlateWatchlistEntry`, alert text in `detection_ingester.rs` uses it; exact-normalized keying, no fuzzy-variant naming in v1) | LPR reads + Watchlist rows show the resolved name; per-row **Name / Rename** (blank clears) affordance (`namePlate`, admin-only server-side) | reads + watchlist rows render `display_name`, plus set/edit/clear from a read row, the read pop-up, and a watchlist row (`plates_screen.dart` + `plate_name_dialog.dart`, admin-gated on `canNamePlates`) | reads + watchlist rows render `display_name` (`feature/plates/PlatesScreen.kt`) | reads + watchlist rows render `display_name`, **plus set / rename / clear** from a read row, a gallery card, or a watchlist row (`Features/Plates/PlatesView.swift` `PlateNameSheet`, pure logic in `PlateNaming.swift`, `CrumbAPI.setPlateLabel`/`clearPlateLabel`), gated on `AppContainer.isAdmin` to match the admin-only endpoints; blank clears, and the sheet states that naming is not alerting. Same surfaces carry a **copy-plate-number** affordance (button + context menu, `CrumbClipboard` in `Platform/Platform.swift`) that always copies the raw plate, never the name. **Deferred:** web PDF report + set/clear from the desktop/Android clients |
| Adaptive live-wall quality (issues #382 desktop / #383 Apple / #384 Android, `docs/DECISIONS.md` 2026-07-20 live-wall entry; two-stage predictive guardrail (75%) + reactive backpressure (85% shed / 60% restore, hysteresis), shed order protects the focused/zoomed tile, "SD" badge; client-local per-machine thresholds, NO server change) | N/A (console has no live wall) | guardrail nudge + `gpuDecUtil` backpressure shed/restore (`apps/desktop-flutter/lib/state/adaptive_wall.dart`, `ui/wall_screen.dart`) | guardrail + ExoPlayer `DecoderCounters` / `PowerManager` thermal backpressure (`feature/live/WallDecodeMonitor.kt`); sheds to snapshot (wall is sub-preferring), count-based guardrail (no per-camera resolution client-side) | guardrail + `ProcessInfo.thermalState` backpressure (`Features/Live/WallLoadController.swift`); main-vs-sub as resolution proxy. **Deferred:** `AVSampleBufferDisplayLayer` frame-health refinement |
| Targeted `_subv` sub-stream repair (issues #483/#485/#501/#526; backend: `services/api/src/go2rtc.rs` `subv_name()` (`<go2rtc_name>_subv`), `subv_src()`, `resolve_needs_subv()` + the per-pass `needs_subv` map with `AppState::set_subv_needed`/`retain_subv_needed`; `rtsp_subv_url` in `services/api/src/dto.rs`. NO migration, the state is in-memory and self-clears on api restart. A video-only copy restream so go2rtc republishes a proper `fmtp` line for subs whose SDP lacks one. Registration rule: only when the sub SDP is POSITIVELY detected as lacking `fmtp` AND the rtpmap encoding is H264/H265/HEVC (MJPEG has no `fmtp` by RFC 2435 and must never be flagged); an "unknown" verdict is sticky, not a re-registration. go2rtc spawns the ffmpeg only while a consumer is attached, so an idle `_subv` costs nothing. Absent field ⇒ the client behaves exactly as before) | N/A (console has no native RTSP live path) | Deliberately NOT consumed: libmpv parses the raw sub fine, so adding `_subv` here would buy a remux for nothing. Decision, not a gap | **The only consumer**: `feature/live/LiveStreamFallback.kt` (wall `subv → sub → mobile`, fullscreen `main → mainv → subv → sub → mobile`), `LiveCameraTile.kt`, `LiveFullscreenScreen.kt`, `data/Models.kt`; tests `SubStreamUrlTest.kt`, `LiveStreamFallbackTest.kt` | Deliberately NOT consumed, same rationale as desktop |
| Targeted `_mainv` MAIN repair, OPT-IN transcode (`docs/DECISIONS.md` 2026-08-08 `_mainv` entry; backend: `services/api/src/go2rtc.rs` `mainv_name()` (`<go2rtc_name>_mainv`), `mainv_src()` (`ffmpeg:<name>#video=h264#audio=aac` — a TRANSCODE, not a copy) + the per-pass `needs_mainv` map with `AppState::set_mainv_needed`/`retain_mainv_needed`; `mainv_url()` + `rtsp_mainv_url` in `services/api/src/playback.rs`/`dto.rs`; config `main_repair_transcode_enabled` (`MAIN_REPAIR_TRANSCODE_ENABLED`, default false) in `services/api/src/config.rs`, forwarded in `docker-compose.yml`. NO migration, state in-memory. Registration rule: only when the operator opts in AND the MAIN SDP is POSITIVELY detected as lacking `fmtp` (reuses `sdp_video_lacks_fmtp` on the main producer, always warm because the recorder consumes the main through go2rtc); sticky-unknown like `_subv`. WHY a transcode not a copy: a copy-remux restores `fmtp` but go2rtc then emits an HEVC parameter-set Aggregation Packet Media3 can't depacketize (verified 2026-08-08), so only a re-encode plays on Android. Absent field ⇒ client behaves exactly as before) | N/A (console has no native RTSP live path) | Deliberately NOT consumed: libmpv plays the raw H.265 main fine (no fmtp/AP sensitivity), so a transcode would buy nothing. Decision, not a gap | **The only consumer**: `feature/live/LiveStreamFallback.kt` (`MAINV` tier, fullscreen `main → mainv → subv → sub → mobile`, wall no-sub `main → mainv → mobile`; HD, no SD badge), `data/Models.kt` `rtspMainvUrl`; tests `LiveStreamFallbackTest.kt`. Also here: `missing attribute fmtp` added to the deterministic step-down signatures so a broken main drops to SD instantly even with the repair off | Deliberately NOT consumed, same rationale as desktop |
| Targeted `_subv` sub-stream repair (issues #483/#485/#501/#526; backend: `services/api/src/go2rtc.rs` `subv_name()` (`<go2rtc_name>_subv`), `subv_src()`, `resolve_needs_subv()` + the per-pass `needs_subv` map with `AppState::set_subv_needed`/`retain_subv_needed`; `rtsp_subv_url` in `services/api/src/dto.rs`. NO migration, the state is in-memory and self-clears on api restart. A video-only copy restream so go2rtc republishes a proper `fmtp` line for subs whose SDP lacks one. Registration rule: only when the sub SDP is POSITIVELY detected as lacking `fmtp` AND the rtpmap encoding is H264/H265/HEVC (MJPEG has no `fmtp` by RFC 2435 and must never be flagged); an "unknown" verdict is sticky, not a re-registration. go2rtc spawns the ffmpeg only while a consumer is attached, so an idle `_subv` costs nothing. Absent field ⇒ the client behaves exactly as before) | N/A (console has no native RTSP live path) | Deliberately NOT consumed: libmpv parses the raw sub fine, so adding `_subv` here would buy a remux for nothing. Decision, not a gap | **The only consumer**: `feature/live/LiveStreamFallback.kt` (wall `subv → sub → mobile`, fullscreen `mainv → main → subv → sub → mobile`), `LiveCameraTile.kt`, `LiveFullscreenScreen.kt`, `data/Models.kt`; tests `SubStreamUrlTest.kt`, `LiveStreamFallbackTest.kt` | Deliberately NOT consumed, same rationale as desktop |
| Targeted `_mainv` MAIN repair, OPT-IN transcode (`docs/DECISIONS.md` 2026-08-08 `_mainv` entry; backend: `services/api/src/go2rtc.rs` `mainv_name()` (`<go2rtc_name>_mainv`), `mainv_src()` (`ffmpeg:<name>#video=h264#audio=aac` — a TRANSCODE, not a copy) + the per-pass `needs_mainv` map with `AppState::set_mainv_needed`/`retain_mainv_needed`; `mainv_url()` + `rtsp_mainv_url` in `services/api/src/playback.rs`/`dto.rs`; config `main_repair_transcode_enabled` (`MAIN_REPAIR_TRANSCODE_ENABLED`, default false) in `services/api/src/config.rs`, forwarded in `docker-compose.yml`. NO migration, state in-memory. Registration rule: only when the operator opts in AND the SERVED SDP is POSITIVELY detected as lacking `fmtp` (`stream_served_video_lacks_fmtp`, reading go2rtc's actual served SDP rather than the main producer's, since a copy-remux can change what ships even when the producer looks healthy); sticky-unknown like `_subv`. WHY a transcode not a copy: a copy-remux restores `fmtp` but go2rtc then emits an HEVC parameter-set Aggregation Packet Media3 can't depacketize (verified 2026-08-08), so only a re-encode plays on Android. Absent field ⇒ client behaves exactly as before) | N/A (console has no native RTSP live path) | Deliberately NOT consumed: libmpv plays the raw H.265 main fine (no fmtp/AP sensitivity), so a transcode would buy nothing. Decision, not a gap | **The only consumer**: `feature/live/LiveStreamFallback.kt` (`MAINV` tier, tried FIRST after #594: fullscreen `mainv → main → subv → sub → mobile`, wall no-sub `mainv → main → mobile`; HD, no SD badge), `data/Models.kt` `rtspMainvUrl`; tests `LiveStreamFallbackTest.kt`. Also here: `missing attribute fmtp` added to the deterministic step-down signatures so a broken main drops to SD instantly even with the repair off | Deliberately NOT consumed, same rationale as desktop |
| Playback timeline "solo selected camera" (client-only UI preference, desktop-only, NO server change; `apps/desktop-flutter/lib/ui/motion_timeline/`) | N/A (console playback has no per-camera stacked motion timeline) | Toggle in the Playback legend bar collapses the stacked per-camera motion/detection strip to just the focused (maximized else selected) camera and follows the focus; pure decision `visibleTimelineCameras` in `motion_timeline_controller.dart` (unit-tested), persisted across sessions via `PlaybackPrefs.getSoloSelectedCamera`/`setSoloSelectedCamera`. Solo falls back to the full stacked view when there is no loaded selection (never a misleading empty strip) | N/A (Android timeline has no cross-camera stacked histogram) | N/A (iOS timeline has no cross-camera stacked histogram) |

Parity walk for a new feature:
Expand Down
9 changes: 6 additions & 3 deletions docs/COMPOSE.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,13 @@ docker compose up -d
If `CRUMB_VERSION` is pinned in `.env`, set it to the new version before you
pull. If you never set it you are on `latest`, and the pull is enough.

- **Migrations run themselves.** First boot applies 0072 through 0078. No manual
- **Migrations run themselves.** First boot applies 0072 through 0080. No manual
step, and no downtime beyond the container restart.
- **No new required settings.** `GO2RTC_AUTH` is the only new key, and leaving it
unset keeps the secure default: the LAN RTSP restream stays authenticated.
- **No new required settings.** Two new keys, both optional. `GO2RTC_AUTH`
leaves the secure default in place when unset: the LAN RTSP restream stays
authenticated. `MAIN_REPAIR_TRANSCODE_ENABLED` is off by default and only
matters if you opt in to the per-camera HD repair for a main stream Android
otherwise steps down to SD.
- **`MOTION_HWACCEL`:** a new install now defaults to `cpu`, but an `.env`
generated by 0.1.1 pins `MOTION_HWACCEL=auto` explicitly, so an upgrade leaves
you on `auto`. That is safe now. `auto` no longer just asks whether cuda was
Expand Down
10 changes: 10 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@ Follow-ups tracked separately: record-from-sub main-repair blindness, and the
sub-side incomplete-fmtp class (an `a=fmtp` present but missing its parameter
sets). The transcode decision itself is unchanged.

**Correction (#594, later).** The Decision above has Android try `mainv` right
after the raw main (`main -> mainv -> subv -> sub -> mobile`). That order cost
every LPR camera a doomed connect to the raw main on each fullscreen open (the
startup lag called out elsewhere in this entry). `LiveStreamFallback.kt` now
tries `mainv` FIRST when the server has published it: fullscreen
`mainv -> main -> subv -> sub -> mobile`, wall no-sub `mainv -> main -> mobile`.
A camera with no repair published has no `mainv` rung and is unaffected,
`main -> ...` exactly as before. `docs/COMPONENT-MAP.md`'s ladder entries are
kept in step with this reorder.

---

## 2026-08-08, Boot storage seeding is PATH-idempotent (skip a name whose directory is already covered) + runtime name lookups fall back to path — supersedes #557's "detect, never fix"
Expand Down
9 changes: 9 additions & 0 deletions docs/OPS-BACKUP-RECOVERY.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ it** (see the pre-deployment checklist at the bottom).
Commands assume the stack lives at `/opt/crumb/app` (the compose project
root, where `docker-compose.yml` and `.env` live). Adjust paths to your install.

> **Automated backups already run out of the box.** The api ships a built-in
> nightly `pg_dump` job, on by default, with its own rotation and failure
> alerting, see `docs/BACKUP.md`. You do not need to set up the manual
> `scripts/backup-db.sh` cron below just to have backups happening; this
> runbook's script and cron are the supplemental, ad-hoc path (custom
> schedule, dumps pushed somewhere the api's bind mount doesn't reach, etc.),
> not the thing that makes backups exist in the first place. Running both is
> harmless, they just produce two sets of dumps.

---

## Why the database is the thing you must back up
Expand Down
Loading
Loading