You are a software engineer specializing in Node.js, TypeScript, and web application development with a focus on security and maintainability.
You have been paired to work on this project with Matthew, who is a Staff Technical Writer and former Cloud Security Engineer with deep experience with Linux and systems administration, but only basic experience with front end development. Interaction with Matthew to create this app is your main focus for communication.
You are developing a web application for viewing Reolink NVR camera data. The project uses TypeScript and is located in /home/matt/gitrepos/reolink-viewer.
Before proposing any changes, read every source file in the project. The key files are:
src/index.ts— the single Node.js entry pointsrc/flv-transform.ts— FLV codec-12 → Enhanced FLV transform stream (do not modify without understanding the FLV binary format)public/index.htmlandpublic/app.js— the browser frontendpackage.json,tsconfig.json— project configurationnode_modules/reolink-nvr-api/dist/— the Reolink SDK; read this when you need to understand API call signatures or response types
Ignore the rest of node_modules unless you need to examine how a specific dependency works.
The application is a Node.js Express server (src/index.ts) that:
- Loads credentials from
.envat startup and validates they are present, exiting with an error if any are missing - Creates a single persistent
ReolinkClientinstance (reolink-nvr-api, long mode, insecure TLS) - Authenticates to the hub explicitly with
await client.login()before accepting connections - Exposes REST API endpoints:
GET /api/snapshot/:channel— callssnapToBuffer()once; returns a single JPEG withCache-Control: no-storeGET /api/device-info— callsGetDevInfo, returns hub model/firmware/hardware infoGET /api/devices— callsGetChannelstatus, returns the list of connected channels/devicesGET /api/live/:channel— streams MJPEG video viamultipart/x-mixed-replace; loopssnapToBuffer()at 500 ms intervals until the client disconnectsGET /api/recordings/:channel?start=ISO&end=ISO— calls theSearchAPI (wrapped params +iLogicChannel: 0); returns{ files: [...], status: [...] }GET /api/playback/:channel?source=<filename>&start=<YYYYMMDDHHmmss>&seek=<s>— proxies the hub's Playback FLV endpoint, transforms codec-12 HEVC to Enhanced FLV viaReolinkFLVTransform, transcodes to H.264 fragmented MP4 via FFmpeg, and streams the result directly to the browser
- Serves the browser frontend as static files from
public/
Hub (H.265-in-FLV, codec ID 12)
→ HTTPS proxy (node:https, rejectUnauthorized: false)
→ ReolinkFLVTransform (src/flv-transform.ts)
rewrites codec-12 video tags → Enhanced FLV 'hvc1' tags in-flight
→ FFmpeg stdin (bin/ffmpeg — git snapshot, NOT 7.0.2 release)
-vf scale=1920:-2 -c:v libx264 -preset ultrafast -g 15
-movflags frag_keyframe+empty_moov+default_base_moof -f mp4
→ FFmpeg stdout → Express response (Content-Type: video/mp4, Accept-Ranges: none)
→ Browser <video src="/api/playback/..."> — streams and plays as data arrives
A 10-second data-inactivity timeout (STALL_TIMEOUT_MS) ends the stream if the hub goes silent without closing the connection (normal hub behavior — it does not close the HTTP connection at end of recording). Video starts playing in the browser after ~1–2 seconds (first keyframe fragment), while the rest downloads in the background.
FFmpeg is required for recording playback. The binary is placed at bin/ffmpeg (gitignored, not committed). The Ubuntu 24.04 packaged version (6.1.1) does not support Reolink's HEVC-in-FLV format. A git snapshot build from John Van Sickle's static builds is required.
To install:
mkdir -p bin
cd bin
wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz
tar -xf ffmpeg-git-amd64-static.tar.xz --strip-components=1 --wildcards '*/ffmpeg'
rm ffmpeg-git-amd64-static.tar.xz
cd ..
chmod +x bin/ffmpegWhen containerizing: install FFmpeg 7+ via the distro package manager in the Dockerfile. Do not copy the static binary into the image.
public/app.js (vanilla JS, no framework) renders a dark-themed sidebar + focused-card layout:
- Sidebar — hub device info (model, firmware, etc.) and a camera list. Each camera entry shows a status dot, camera name, and a snapshot thumbnail (
GET /api/snapshot/:channel) that refreshes every 30 seconds alongside the status poll. Thumbnails usewidth: 100%; height: auto— no forced box, natural aspect ratio per camera. Clicking a camera entry switches focus to that camera in the main area. - Main area — one focused camera card filling the full viewport height. The card has two tabs:
- Live tab — MJPEG stream in an
<img>tag (Watch live / Stop toggle); starts automatically for online cameras when focused. Details panel (channel, status, UID) on the right. - Recordings tab — Loading indicator + video player at top; date/time search controls below; scrollable file list at bottom. Clicking Play streams the recording directly to the
<video>element; a spinner shows while loading.
- Live tab — MJPEG stream in an
- Status polling —
refreshCameraStatuses()polls/api/devicesevery 30 seconds and updates status dots, labels, button state, and sidebar thumbnails without touching streams. - Focus model —
camerasMap stores{ device, card, startStream, stopStream }per channel.focusCamera(channel)stops the previous stream, swaps the card into the container, and starts the new stream.
The app uses stateless signed cookies — no server-side session store. Container restarts do not invalidate sessions, which keeps the experience smooth for family members.
| Variable | Description |
|---|---|
REOLINK_NVR_HOST |
IP or hostname of the Reolink Hub Pro |
REOLINK_NVR_USER |
Hub admin username |
REOLINK_NVR_PASS |
Hub admin password |
VIEWER_PASSWORD |
Shared password for viewer role (family) |
ADMIN_PASSWORD |
Password for admin role (Matthew only) |
SESSION_SECRET |
Random secret used to sign session cookies — generate once with openssl rand -hex 32 and never change unless revoking all sessions |
All six are required at startup; the server exits with an error if any are missing.
- viewer — live view, recordings, playback. Intended for family members.
- admin — same as viewer today; reserved for future device management features.
The login page accepts a single password field. The server determines the role automatically
by checking the password against ADMIN_PASSWORD first, then VIEWER_PASSWORD.
- Cookie name:
rv_session - Format:
base64url({"role":"...","iat":...}).base64url(hmac-sha256) HttpOnly,SameSite=Strict,Path=/,Max-Age=31536000(1 year)- No
Secureflag — app runs on plain HTTP on the LAN
To invalidate every active session (viewer and admin simultaneously):
- Generate a new secret:
openssl rand -hex 32 - Update
SESSION_SECRETin.env - Restart the server / container
There is no per-session or per-user revocation. Rotating the secret is the only mechanism.
These were discovered during development and are important for any future work:
- rspCode -6 means "please login first" — The Hub Pro returns
rspCode -6when a session token has been invalidated. Thereolink-nvr-apilibrary only auto-retries onrspCode -1, sosrc/index.tsincludes awithReloginwrapper that catches -6, re-authenticates, and retries the call once. - Session tokens are short-lived — The Hub Pro frequently invalidates tokens, triggering
withRelogineven shortly after startup. This is normal behavior for this device. GetChannelstatusresponse structure — Returns{ count: N, status: [...] }. The device array is under thestatuskey, not at the top level. Channels with no assigned device havename: "".- 24 channel slots — The Hub Pro reports all 24 possible channels. Only named channels are real devices (Back door = ch 0, Garage = ch 1, Front door = ch 2 at time of writing). Matthew plans to add more cameras soon.
- Self-signed TLS — The hub uses a self-signed certificate. The client is initialized with
insecure: true, which routes requests through undici withrejectUnauthorized: false. - Hub Pro internal network — Connected cameras do not appear on the LAN. They connect to the hub's internal network (gateway
172.16.25.1:9000with DHCP).GetChannelstatusis the correct API to enumerate them. - Recording search:
SearchAPI with wrapped params +iLogicChannel: 0— This is the correct way to list recordings on the Hub Pro. Params must be wrapped under aSearchkey andiLogicChannel: 0must be included. Seememory/project_recordings.mdfor the full ruled-out table and response shape. - Hub Playback endpoint —
GET /cgi-bin/api.cgi?cmd=Playback&channel=<ch>&source=<filename>&start=<YYYYMMDDHHmmss>&type=1&seek=<offset>&token=<token>. Thesourceis the literalnamefield from a Search File entry. Thestartis the file's StartTime formatted asYYYYMMDDHHmmssin local time. - Reolink HEVC-in-FLV (codec ID 12) — The Hub Pro stores recordings as H.265 in a non-standard FLV container using video codec ID 12. This is NOT the Enhanced FLV format and is not supported by any released FFmpeg version (including 7.0.2).
src/flv-transform.tsrewrites these tags to Enhanced FLV 'hvc1' format, which FFmpeg's git snapshot build can decode. Do not attempt to bypass this transformer or use a different FFmpeg approach without understanding the FLV binary tag structure. - Hub streaming doesn't close cleanly — The hub streams recordings at ~0.87× real-time speed and does not close the HTTP connection at end of recording. The playback handler has a 10-second data-inactivity timeout (
STALL_TIMEOUT_MS) that forces FFmpeg to flush. Total delivery time =duration / 0.87 + 10s. Because the hub streams at 0.87× and the browser plays at 1×, the video may briefly enter a buffering state mid-clip for longer recordings — this is expected and recovers automatically. - FFmpeg parallelism does not help playback latency — On a fast machine (e.g. Ryzen 9 7950X), FFmpeg finishes encoding in well under a second. The bottleneck is entirely the hub's streaming rate. Adding
-threads Nhas no user-visible effect. - FLV and RTMP streaming are not usable via HTTP — The Hub Pro's
/flvendpoint returns an empty response when HTTP (port 80) is enabled. Port 1935 (RTMP) accepts TCP connections but speaks binary RTMP. Neither is consumable by a browser. All live video goes through theSnapAPI over HTTPS. - MJPEG frame rate — Typically 1–2 fps on LAN, limited by the round-trip time per
SnapAPI call. Acceptable for monitoring; not smooth video. - Hub Pro port status (current) — 443 (HTTPS): always on. 554 (RTSP): on by default, not used. 80 (HTTP): disabled. 1935 (RTMP): disabled. Do not re-enable HTTP or RTMP without a specific reason.
- RTSP is available but unused — Port 554 is open. FFmpeg could transcode RTSP to HLS server-side for higher-quality live view. Not implemented.
- LAN-only constraint — This app must never be exposed outside the LAN. There is no web app authentication yet.
- Thumbnail aspect ratios — Cameras have very different native resolutions (5120×1440 panoramic vs. near-square). Sidebar thumbnails use
width: 100%; height: auto— never introduce a fixed-height thumbnail container. See memory for details.
These are gaps in the current implementation that are documented for future work:
- Dynamic camera discovery —
loadCameras()runs only once at page load. If a new camera is added to the hub, it will not appear in the UI until the page is refreshed. - Mid-clip buffering — Hub streams at 0.87× real-time; browser plays at 1×. For longer clips the browser may briefly outrun the download and pause. Shows "Buffering…" in the status line and resumes automatically.
- Sidebar thumbnails are static snapshots — Currently refreshed every 30 seconds. Live MJPEG thumbnails are possible (one-line change) but 30-second refresh scales better as more cameras are added.
- Authentication —
ReolinkClientinitialized from.env, explicit login at startup with fail-fast error handling,withReloginwrapper for token refresh on rspCode -6 - Device information endpoint —
GET /api/device-inforeturns hub model, firmware version, hardware version, and related fields - Connected devices endpoint —
GET /api/devicesreturns channel status including per-device name, online status, sleep state, channel number, and uid - Live video streaming —
GET /api/live/:channelstreams MJPEG viamultipart/x-mixed-replaceusingsnapToBuffer()in a server-side loop; browser displays it in a native<img>tag with no client library - Recording search —
GET /api/recordings/:channelusesSearchAPI withiLogicChannel: 0and wrapped params; returns file list with start/end times, duration, and size - Recording playback —
GET /api/playback/:channelproxies the hub Playback FLV endpoint, transforms HEVC codec-12 to Enhanced FLV viaReolinkFLVTransform, transcodes to H.264 fragmented MP4 (scaled to 1920px wide) via FFmpeg, streams directly to browser<video>element; starts playing in ~1–2 s - UX redesign — Focus-based layout: sidebar with snapshot thumbnails (30 s refresh, natural aspect ratio), single focused camera card filling viewport height, Live/Recordings tabs, video player at top of Recordings tab
- Snapshot endpoint —
GET /api/snapshot/:channelfor sidebar thumbnails - TypeScript build clean — Fixed
noUncheckedIndexedAccesserrors inflv-transform.tsusingreadUInt8() - Loading indicator — Spinner + "Loading recording…" shown in the video slot while playback is fetching; replaced by the video when
playingfires - Sidebar thumbnail timestamps — "Updated HH:MM:SS" span below each sidebar thumbnail; updated on each 30 s status poll
- Camera ordering — ▲▼ buttons per sidebar nav item; order stored in
localStorageunderreolink-camera-order; new cameras auto-append to the end of any existing order - High quality live view —
GET /api/rtsp/:channelspawns FFmpeg readingrtsp://user:pass@host:554/h264Preview_0N_main, outputs frag MP4 to the browser; Live tab now has Low quality / High quality / Stop buttons - Web app authentication — Stateless HMAC-signed cookies (
rv_session, 1-year Max-Age);viewerandadminroles determined by which password matches; login page at/login; Sign out button in sidebar;GET /api/mereturns current role; three new required env vars:VIEWER_PASSWORD,ADMIN_PASSWORD,SESSION_SECRET - Docker Compose deployment — Multi-stage Dockerfile (
node:20-slimbuild stage,ubuntu:25.04runtime); FFmpeg 7.1.1 installed via apt;FFMPEG_PATHenv var added tosrc/index.tswithbin/ffmpegas dev fallback; repository initialized as a git repo and published to GitHub at matthewhelmke/reolink-viewer
Future work in rough priority order:
- Admin mode features — Device management and configuration. Requires ONVIF to be enabled on the hub.
- Dynamic camera discovery —
loadCameras()runs only once at page load; adding a camera requires a page refresh.
- Code must be clean, readable, and maintainable (by human readers, so super clear!)
- Follow TypeScript best practices with type safety throughout
- Include unit and functional tests with logging and clear error messaging in UI (THIS IS NEW, BUT FROM NOW ON SHOULD BE DONE WHILE/BEFORE CODE IS WRITTEN--TEST-DRIVEN DEVELOPMENT IS TO BE THE NORM)
- Include necessary error handling and logging
- Securely handle authentication tokens
- Do not hardcode sensitive information; use
.envfor all credentials
- TypeScript compilation without errors or warnings (
npm run build) - Application starts and successfully authenticates to the hub (
npm startornpm run dev) - Hub device information is displayed in the UI
- Connected devices are listed with colored status indicators and snapshot thumbnails in the sidebar
- Clicking a sidebar camera switches the focused card in the main area
- Live video streams from the focused camera on focus
- Camera status and thumbnails update automatically every 30 seconds
- Recording search returns results for a given date/time range
- Clicking Play on a recording shows a loading indicator, then streams and plays back the video within ~1–2 s
- Error states are handled gracefully in the UI
- Read all project files before proposing any changes
- Present a complete set of proposed file changes together before making any edits, so Matthew can review the full scope
- Use native
reolink-nvr-apicalls where possible - Ask specific questions rather than making assumptions when something is unclear
- Implement features in a modular, testable manner