A purpose-built Home Assistant sidebar panel for browsing the home-grown NVR
motion clips in /config/nvr — a flat, newest-first thumbnail gallery with
camera and object filters, replacing the painful Media-browser folder drill-down.
It is read-only and additive: it never touches the recording automations or
any file under /config/nvr. Thumbnails are cached to a separate dir
(/config/nvr_thumbs).
- Flat newest-first feed of every clip across all cameras, infinite-scroll.
- Filter dropdowns for camera and detected object (person, cat, …), both derived from the existing folder layout (one entry per camera/object folder).
- Date controls — a Day dropdown for jumping to a single day, plus a range-picker calendar popup that dots the days with clips and filters to an inclusive date range.
- Thumbnails generated on demand by Home Assistant's bundled
ffmpeg(seeks ~10s in, scaled to 320px), cached to disk, throttled to 3 concurrent grabs. - Click to play inline in a lightbox, with a download link.
The recording automations produce two shapes per hour folder:
<date>/<hour>/<camera>/HH:MM:SS.mp4 # the canonical clip
<date>/<hour>/<object>/HH:MM:SS-<camera>.mp4 # hard link tagging that clip
The integration joins them on (time, camera): a folder whose files match
HH:MM:SS.mp4 is a camera; a folder whose files match HH:MM:SS-<camera>.mp4
is an object label. Playback always uses the canonical clip, streamed via the
authed /api/nvr_browser/clip endpoint (clips live outside www/, so they are
not exposed by HA's unauthenticated /local/ route).
GET /api/nvr_browser/events?offset=&limit=&camera=&object=&start=&end=— authed JSON list (start/endare inclusiveYYYY-MM-DDbounds).GET /api/nvr_browser/days— authed JSON{days: [...]}, the availableYYYY-MM-DDfolders newest-first; powers the Day dropdown and the calendar.GET /api/nvr_browser/thumb?path=<rel>— JPEG thumbnail (authed; the events view hands the frontend short-lived signed URLs so a plain<img src>still works only for the logged-in user; path is sanitised against traversal).GET /api/nvr_browser/clip?path=<rel>— original clip stream (authed, signed the same way; supports HTTP range requests for<video>seeking). Replaces the old public/local/nvr/...route.GET /api/nvr_browser/clip_proxy?path=<rel>— authed, a Roku-playable transcode of the clip (≤1080p H.264 + faststart), generated on demand and cached under/config/nvr_proxies. The web panel uses the originalclip; the Roku app uses this. See Pairing a TV.GET /api/nvr_browser/cameras— authed JSON list of live-capable cameras (from thelive_camerasmap); powers the Roku app's live-view picker. Each available camera also carries a signedthumb(a current snapshot via HA'scamera_proxy) so the app can show a preview tile, like clip thumbnails.GET /api/nvr_browser/live?camera=<name>— authed JSON{camera, url, streamFormat:"hls"}; returns Home Assistant's own same-origin HLS URL for that camera's live stream (no transcode). See Live camera streaming.POST /api/nvr_browser/pair/new,GET /api/nvr_browser/pair/claim?secret=,POST /api/nvr_browser/pair/approve— TV-pairing flow (see below).
The companion Roku app nvr-roku shows the
same gallery on a TV. Because a TV can't comfortably type a ~250-character
long-lived token, it authenticates with a short pairing code instead:
- Open the NVR app on the Roku — it displays a 6-character code.
- In the NVR panel, click Pair TV and enter that code.
- The TV signs in automatically and stays signed in.
Approving a code mints a Home Assistant long-lived access token bound to your
account and hands it to that TV. The token has full HA scope (HA has no
per-integration scoping), so only pair TVs you trust, and only ever approve a
code you can see on your own screen. Revoke a TV anytime under HA → Profile →
Security → Long-lived access tokens (named NVR Roku (…)).
The Roku app can also show a live camera view, not just recorded clips. This
is opt-in via a live_cameras map in configuration.yaml that points each NVR
camera name at a Home Assistant camera entity:
nvr_browser:
live_cameras:
backyard: camera.backyard_sub # MUST be a <=1080p H.264 entity
porch: camera.porch_subThe integration returns Home Assistant's own HLS stream URL for that entity
(served by the built-in stream: integration). It does not transcode. The
URL is a root-relative, self-tokenized path (e.g.
/api/hls/<token>/master_playlist.m3u8), which the app resolves against its own
base URL — that's why live works remotely with no extra host config.
The mapped entity must be ≤1080p H.264. Roku's decoder caps H.264 at 1080p — the same limit that makes recorded clips need a transcoded proxy. Home Assistant remuxes the live stream without transcoding, so pointing
live_camerasat a full ~5 MP main stream produces a URL that loads but fails on the TV with a decoder error. Point it at a low-resolution substream (most IP cameras expose one). Requires thestream:integration (enabled by default) and a stream-capable camera entity.
The Roku live-camera picker shows a current snapshot per available camera (a
signed camera_proxy still), the same way clip thumbnails work.
Leaving live_cameras out (or empty) disables live entirely. This is a
Python-side feature, so restart Home Assistant after adding it. The web
sidebar panel is unchanged — live is a Roku-app feature.
- A Home Assistant install whose config directory is
/config— HAOS, Supervised, or the official Container image. Paths are hardcoded to/config/nvrand/config/nvr_thumbs, so a Core/venv install with a different config path won't work as-is. ffmpegavailable to Home Assistant (bundled on HAOS / Supervised / Container).- Live view (optional) additionally needs the
stream:integration (enabled by default) and a stream-capable, ≤1080p H.264 camera entity — see Live camera streaming. - Your motion clips already under
/config/nvr/in the layout above (your recording automations must write there, not underwww/).
- In HACS, open the ⋮ menu → Custom repositories.
- Add this repository's URL with category Integration, then download NVR Browser.
- Add
nvr_browser:toconfiguration.yaml(see Configuration) — the integration won't load until you do. - Restart Home Assistant.
Copy custom_components/nvr_browser/ into your Home Assistant
config/custom_components/ directory and restart Home Assistant.
Enable the integration with one line in configuration.yaml:
nvr_browser:Restart Home Assistant. An NVR item (cctv icon) then appears in the sidebar.
To also stream live cameras to the Roku app, add the optional live_cameras
map — see Live camera streaming.
The panel reads its filters from the page URL, so you can open it pre-filtered from anywhere — a dashboard button, a live-camera card's tap action, a Markdown link, etc. The supported query params match the event API:
| Param | Meaning | Example |
|---|---|---|
camera |
only this camera | camera=front_door |
object |
only clips tagged this object | object=person |
start |
inclusive lower date bound | start=2026-06-01 |
end |
inclusive upper date bound | end=2026-06-08 |
Combine freely, e.g. /nvr-browser?camera=front_door&object=person. From a
Lovelace card, point a navigation at that URL:
tap_action:
action: navigate
navigation_path: /nvr-browser?camera=front_doorThe URL also stays in sync as you change filters in the panel, so the address bar always holds a shareable, bookmarkable link to the current view.
Remove the nvr_browser: line from configuration.yaml, uninstall via HACS (or
delete custom_components/nvr_browser), and restart Home Assistant. The cache
dirs /config/nvr_thumbs (thumbnails) and /config/nvr_proxies (Roku transcodes)
are safe to delete anytime.
Thumbnails are cached under /config/nvr_thumbs, keyed by a hash of the clip
path. Because clips rotate out as the recording retention deletes old days, the
integration prunes orphaned thumbnails (those whose source clip no longer
exists) once at startup and every PRUNE_INTERVAL (default 24h). It also clears
stale .part.jpg temp files from any crashed grab. This tracks your retention
automatically — no manual cleanup needed. The whole cache is still safe to
rm -rf anytime; it just regenerates on next view.
_THUMB_SEM = asyncio.Semaphore(3)— concurrent ffmpeg frame-grabs.scale=320:-1in_generate_thumb— thumbnail width.- seek list in
_generate_thumb(00:00:10, then fallbacks) — frame offset. PRUNE_INTERVAL— how often orphaned thumbnails are swept.THUMB_DIR— cache location.