diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index c38667e..1d9875b 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -846,3 +846,36 @@ {"id":"evt-90ck7pe92yzb","type":"edge.added","subject":"dec-d50j69a","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"answers","from":"dec-d50j69a","to":"iss-hdt480y"},"at":"2026-09-19T21:50:12.327Z","parents":["evt-v6w9rsx8erzq"],"hash":"86675848af2322f42bf9c4453467e1633bb947ebc64f7b5e70a63ad8c842b9a6"} {"id":"evt-hq3cwd9sq2kb","type":"edge.added","subject":"dec-d50j69a","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-d50j69a","to":"gol-sxakryh"},"at":"2026-09-19T21:50:12.328Z","parents":["evt-90ck7pe92yzb"],"hash":"75118130559649fcbe4d30f7eab54e5734dad48b2954fb7be7b4898204899bcd"} {"id":"evt-3w4qpcc7n1qv","type":"node.status_changed","subject":"iss-hdt480y","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T21:50:16.490Z","parents":["evt-hq3cwd9sq2kb"],"hash":"0930689ca703fb569f1f4a20b5e04888fde27ca44db69abd4f9234fe5fb18218"} +{"id":"evt-y6y3j2xmc9sn","type":"node.created","subject":"iss-ws9nb88","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Record motion clips: ONVIF motion watcher driving MediaMTX recording, with retention","body":"Per dec-9z8vdmj and dec-d50j69a. A server-only motion module subscribes to each registry camera's ONVIF events through the existing cached connection and acts on changes to tns1:VideoSource/MotionAlarm (the camera repeats its state about once a second). On motion: configure that camera's recording path in MediaMTX (source pulled continuously, record on, fMP4) so a clip is written. After motion ends plus a post-roll of a few seconds: recording off and the source back to on-demand, so the camera isn't pulled while nothing happens. Re-subscribes by itself after dropped pulls (fnd-3kp0rwy). Configuration: RECORDINGS_DIR (default .data/recordings), the stream to record (default sub), retention days (default 7), and an off switch. Retention is our own hourly sweep of the recordings folder, not MediaMTX's, so it runs whether or not a path is currently recording. Started from instrumentation after the video bridge. Tests use a fake camera event source and a stubbed MediaMTX API (pri-e14bahk).","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T21:51:50.582Z","parents":["evt-3w4qpcc7n1qv"],"hash":"1c9ffab6dbf55b0e7378668efb2796895e0e178dba6268713c1b9d6caaa7d332"} +{"id":"evt-szpfkcnf9pfh","type":"edge.added","subject":"iss-ws9nb88","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-ws9nb88","to":"gol-sxakryh"},"at":"2026-09-19T21:51:50.586Z","parents":["evt-y6y3j2xmc9sn"],"hash":"b6e87de7222ea3ecf49e852c05e8e2e522eb11e7dfae5524b9daa6ee853b8811"} +{"id":"evt-25cqdt058rtc","type":"edge.added","subject":"iss-ws9nb88","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-ws9nb88","to":"src/lib/motion.ts"},"at":"2026-09-19T21:51:50.587Z","parents":["evt-szpfkcnf9pfh"],"hash":"543d1203161566d5c92a6f813b8e9dfcf0aee0e2e10aa9b11c48cc3448b69e7a"} +{"id":"evt-q7xfcmjd2xae","type":"edge.added","subject":"iss-ws9nb88","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-ws9nb88","to":"src/lib/recordings.ts"},"at":"2026-09-19T21:51:50.588Z","parents":["evt-25cqdt058rtc"],"hash":"2b4efbd480aee8da4bb7f9f93458a07de072f77e8d58d0535874f44837c6da29"} +{"id":"evt-d8tmqkk62qav","type":"edge.added","subject":"iss-ws9nb88","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-ws9nb88","to":"src/instrumentation.ts"},"at":"2026-09-19T21:51:50.589Z","parents":["evt-q7xfcmjd2xae"],"hash":"3c999d385f7d772f281eb527909b6f8d2e4c48dc5cbb09fc9fa3fa647e136805"} +{"id":"evt-gr4z8fx1vtc4","type":"edge.added","subject":"iss-g456j72","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-g456j72","to":"iss-ws9nb88"},"at":"2026-09-19T21:51:50.590Z","parents":["evt-d8tmqkk62qav"],"hash":"89ad14104b481e6113e02c52c8c44af16f8b9e43372eccda4def83b9e72b2e03"} +{"id":"evt-jhr3base57e1","type":"edge.added","subject":"iss-ws9nb88","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-ws9nb88","to":"area:video"},"at":"2026-09-19T21:51:50.591Z","parents":["evt-gr4z8fx1vtc4"],"hash":"d9f9a94c108d33d2c18a42ff99a1c37260c9ff1a15f4f0e51e4722f915fc1678"} +{"id":"evt-m054t5j3mmg3","type":"node.created","subject":"iss-v677bbd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Browse, play and delete motion clips behind the admin session","body":"Per dec-9z8vdmj, after the recorder. A Recordings page lists clips per camera, newest first, with the time, length and size, and plays them in the browser (fMP4 over a route handler that supports Range requests, so seeking works). Deleting a clip asks first. Every route checks the session and resolves the camera from the registry; file paths are never taken from the request (pri-m1csgrm), so a request can only name a clip that the recordings folder actually holds. The camera page links to that camera's clips, and the dashboard card shows when a camera is recording.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T21:51:54.615Z","parents":["evt-jhr3base57e1"],"hash":"0022d4f96766890597bdcc76de7bfd26ded16f68a257cfcb41db06739f8c8037"} +{"id":"evt-jq2phdnh4mwe","type":"edge.added","subject":"iss-v677bbd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-v677bbd","to":"gol-sxakryh"},"at":"2026-09-19T21:51:54.616Z","parents":["evt-m054t5j3mmg3"],"hash":"3d24bbd7d7ff768d8b58404b72bb46ece9fe7580d97acd5e16daf2688f6b99b5"} +{"id":"evt-xdybt29kct1m","type":"edge.added","subject":"iss-v677bbd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-v677bbd","to":"src/app/recordings/"},"at":"2026-09-19T21:51:54.617Z","parents":["evt-jq2phdnh4mwe"],"hash":"e9600b1b50a90c4de03d2b33d3a039df88021686a78195d60b616f8c4e703b7f"} +{"id":"evt-6nhmt9n3mndx","type":"edge.added","subject":"iss-v677bbd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-v677bbd","to":"src/app/api/recordings/"},"at":"2026-09-19T21:51:54.618Z","parents":["evt-xdybt29kct1m"],"hash":"e8c58ba4bd94c8adf5fc0dbb39d8b15ceadc4dae4d9027f68b1eebe0ac8a26f3"} +{"id":"evt-3y54bg2z49vh","type":"edge.added","subject":"iss-g456j72","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-g456j72","to":"iss-v677bbd"},"at":"2026-09-19T21:51:54.619Z","parents":["evt-6nhmt9n3mndx"],"hash":"fb9eb814fc5d2be18133483339475228409f8956277189fff635e0f3cecc5c9a"} +{"id":"evt-hftcfaxfxzjx","type":"edge.added","subject":"iss-v677bbd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-v677bbd","to":"area:video"},"at":"2026-09-19T21:51:54.620Z","parents":["evt-3y54bg2z49vh"],"hash":"fd1a704c07e6be61533dbf1b6146a34309999f6897ea08b46fda86e801269b31"} +{"id":"evt-wgae0f19mnfz","type":"edge.added","subject":"iss-v677bbd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-v677bbd","to":"area:ui"},"at":"2026-09-19T21:51:54.621Z","parents":["evt-hftcfaxfxzjx"],"hash":"f8b0be12bbc80e39d47b3a2c9e9731104f58770103ba7586718d7d0489efea35"} +{"id":"evt-dy4wxfhczrsa","type":"node.created","subject":"ver-5gs8ng9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Motion from ONVIF VideoSource/MotionAlarm starts recording through a per-camera MediaMTX path and stops after a 10 s post-roll; only changes act, changes are applied one at a time, failures are logged and retried; clips land in RECORDINGS_DIR (default .data/recordings) per camera, the configured stream is used, and an hourly sweep deletes clips older than the retention window","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T21:56:20.284Z","parents":["evt-wgae0f19mnfz"],"hash":"ddfa12b57430e0d6f3697bc2c646f00a5f02d5e18a72a96a4451b2075aedc448"} +{"id":"evt-x78nqh6vvynk","type":"edge.added","subject":"ver-5gs8ng9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-5gs8ng9","to":"iss-ws9nb88"},"at":"2026-09-19T21:56:20.288Z","parents":["evt-dy4wxfhczrsa"],"hash":"bed3d140e09f5299788b86929ac335388b989d0d16dd8c1f4f9db43e2f108e4a"} +{"id":"evt-9en2g7vhe8x5","type":"verification.recorded","subject":"ver-5gs8ng9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/motion.test.ts, motion-recorder.test.ts, recordings.test.ts, mediamtx-config.test.ts (\"recording paths\"), video.test.ts (\"setRecording\"), instrumentation.test.ts. Full suite 813 tests pass, 99.66% lines; tsc and eslint clean; next build compiles. Nothing was recorded from a real camera in these tests."},"at":"2026-09-19T21:56:20.289Z","parents":["evt-x78nqh6vvynk"],"hash":"82b5cdf4d205ae6979fb7b63009f6cb490417551f4c1e63936b507720643cc4f"} +{"id":"evt-tb8r7cvwjxbj","type":"node.created","subject":"ver-4yj6bh2","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Real check by the user: walking in front of a camera produces a clip in .data/recordings/<camera id>/ that plays, and recording stops afterwards","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T21:56:22.369Z","parents":["evt-9en2g7vhe8x5"],"hash":"02b3d4562dc1b8035ec7948138169e979ce5c75457bdef5591f7102edf3fae27"} +{"id":"evt-bmf0a3nw9n8t","type":"edge.added","subject":"ver-4yj6bh2","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-4yj6bh2","to":"iss-ws9nb88"},"at":"2026-09-19T21:56:22.371Z","parents":["evt-tb8r7cvwjxbj"],"hash":"19854dc60349872fc2a05b84f01f53b70128da14f4c058c2aaecd0fc0451d36d"} +{"id":"evt-9ykcspgn1s0h","type":"verification.recorded","subject":"ver-4yj6bh2","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pending","evidence":"User test after restarting the server: watch the log for \"[motion] recording …\" and \"[motion] stopped recording …\", then open the newest file under .data/recordings"},"at":"2026-09-19T21:56:22.372Z","parents":["evt-bmf0a3nw9n8t"],"hash":"14fa7604ce2b8a79e1c62160c4bb33a62b5961533130cf9e3e8d31ae41f6b908"} +{"id":"evt-tjemq34879n9","type":"node.status_changed","subject":"iss-ws9nb88","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-19T21:56:24.082Z","parents":["evt-9ykcspgn1s0h"],"hash":"21858a059abeb41c7cb30f9c8e71c30eebf0d533e3b78fd2a4e86bebe67bcd20"} +{"id":"evt-4dvvscpkf0wy","type":"node.created","subject":"fnd-vbq0qrp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Next 16's connection() never resolves outside a request, so startup code must not call registry functions that use it","body":"Found on 2026-09-19 while testing motion recording. src/lib/camera-registry.ts listCameras() awaits connection() from next/server to opt out of prerendering. Called from instrumentation.ts at server start, that await simply never settles: no error, no log, and the recorder silently never started (no \"[motion] watching …\" line and no recordings folder), while the dev server and MediaMTX ran normally. Fix: allCameraRecords() reads the same registry without connection(), for code that runs outside a request; listCameras() stays for pages and routes. Also, register() in instrumentation holds up the server until it returns, so background work like watching cameras is started without awaiting it. Applies to any future startup or background code in this app.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"iss-ws9nb88","note":"Observed on the user's dev server: MediaMTX started, pages served, no [motion] logs, .data/recordings missing"},{"url":"https://nextjs.org/docs/app/api-reference/functions/connection","note":"connection() is documented for request-time opt-out of prerendering"}],"as_of":"2026-09-19"}},"at":"2026-09-19T22:02:07.530Z","parents":["evt-tjemq34879n9"],"hash":"c34b08ba03f7b70419c6d6de4feaacf08c9a8d98d3617a22ea004b6ae65e1ec1"} +{"id":"evt-fpkhbrq3j0xe","type":"edge.added","subject":"fnd-vbq0qrp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-vbq0qrp","to":"iss-ws9nb88"},"at":"2026-09-19T22:02:07.532Z","parents":["evt-4dvvscpkf0wy"],"hash":"82217109b924ad2d39f077c1614a070f9c3e4df686df182523be96810bf222e8"} +{"id":"evt-e45rzbqc5085","type":"edge.added","subject":"fnd-vbq0qrp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-vbq0qrp","to":"pri-e0qjyy0"},"at":"2026-09-19T22:02:07.533Z","parents":["evt-fpkhbrq3j0xe"],"hash":"0c612ba5aaaa78fae0ec12039910c3455f4757625d53026ba91c676320e5192d"} +{"id":"evt-7h76c4xfe8fv","type":"edge.added","subject":"fnd-vbq0qrp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"discovered_from","from":"fnd-vbq0qrp","to":"iss-ws9nb88"},"at":"2026-09-19T22:02:07.534Z","parents":["evt-e45rzbqc5085"],"hash":"b60ff41e988497367ee4872418e7f77db2aa2ed45ea0103a7de359679558dd4e"} +{"id":"evt-fn09kn6wekz6","type":"node.created","subject":"fnd-y7h6pyf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Camera …af54's sub stream carries G711 audio; camera …af2e's does not","body":"Observed on 2026-09-19 in MediaMTX's own log while recording motion clips from both cameras' sub streams: \"[path cam-…af54-sub-rec] [recorder] recording 2 tracks (H264, G711)\" and \"[path cam-…af2e-sub-rec] [recorder] recording 1 track (H264)\". So at least one I91ET has a working microphone and publishes audio over RTSP; the other does not (no microphone, audio disabled, or stream type set to video only in its web page). Consequences: recorded clips from …af54 contain a G711 audio track, which browsers generally cannot decode inside MP4, so playback may be silent even though the video plays; and live WebRTC can carry G711 (Opus and G711 are both allowed), which is worth checking when audio in the live player is wanted. The main streams were not checked, nor was the other camera's configuration.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"iss-ws9nb88","note":"MediaMTX v1.21.0 recorder log lines during the user's motion-recording test"}],"as_of":"2026-09-19"}},"at":"2026-09-19T22:07:36.606Z","parents":["evt-7h76c4xfe8fv"],"hash":"3cc4250476e4d9153f098cf67ec1ee94e32ff42c94f895c689dd487d5f7bd7f9"} +{"id":"evt-w6mdsh4dhs11","type":"edge.added","subject":"fnd-y7h6pyf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-y7h6pyf","to":"que-ph8eak5"},"at":"2026-09-19T22:07:36.609Z","parents":["evt-fn09kn6wekz6"],"hash":"ca45632818099a645ed3442a1a7be7dac2b502862646a87c2d87bd76c4fa7e1d"} +{"id":"evt-hkzghm8k50gr","type":"edge.added","subject":"fnd-y7h6pyf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-y7h6pyf","to":"iss-td8y1cc"},"at":"2026-09-19T22:07:36.610Z","parents":["evt-w6mdsh4dhs11"],"hash":"b710073f65c4c80bffeb993fa99b126aee10304a58910ece5a7318e3c7738b62"} +{"id":"evt-ek2ad9wj3s41","type":"edge.added","subject":"fnd-y7h6pyf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-y7h6pyf","to":"gol-sxakryh"},"at":"2026-09-19T22:07:36.614Z","parents":["evt-hkzghm8k50gr"],"hash":"1ab59d4494576780c1dda07f1271c0ea4cb39e6013e5684603cad42e9b6a1b42"} +{"id":"evt-pts2yq477k34","type":"node.created","subject":"ver-wbk0v4t","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Real check by the user: walking in front of the cameras starts and stops recording, and clips land on disk","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T22:07:39.759Z","parents":["evt-ek2ad9wj3s41"],"hash":"b560ba8edba8e4065da74b4704ab2d841f6218e939285114eb778cbc7e5d1f46"} +{"id":"evt-fqxcxhh97c1x","type":"edge.added","subject":"ver-wbk0v4t","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-wbk0v4t","to":"iss-ws9nb88"},"at":"2026-09-19T22:07:39.760Z","parents":["evt-pts2yq477k34"],"hash":"7f61fcd22e5c71adf48faee46fab3f7f23f551e669c17a95472868216792ea7b"} +{"id":"evt-gr6gmbpx3d72","type":"verification.recorded","subject":"ver-wbk0v4t","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"User's dev server log on 2026-09-19 17:06: \"[motion] watching 2 camera(s), recording the sub stream\", then for both cameras \"[motion] recording \", MediaMTX \"[recorder] recording … tracks\", and \"[motion] stopped recording \" after the post-roll. Files on disk: .data/recordings/cam--sub-rec/2026-09-19_17-06-*.mp4, 1.2–2.5 MB each; box inspection shows valid fMP4 (ftyp, moov, moof/mdat fragments, avc1)."},"at":"2026-09-19T22:07:39.761Z","parents":["evt-fqxcxhh97c1x"],"hash":"82786785d155aa9499945f9306118813533d1c9bb409d50dc905c034faf65869"} +{"id":"evt-gsf60sb8qp1v","type":"node.status_changed","subject":"iss-ws9nb88","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"in_progress","to":"done"},"at":"2026-09-19T22:07:43.955Z","parents":["evt-gr6gmbpx3d72"],"hash":"5f7bb9c68f09bb12cc86d905793efc99f10974c05845cdbfb112429ff33e1094"} diff --git a/README.md b/README.md index 1e8f378..717ff50 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,21 @@ To upgrade, change `version` in `MEDIAMTX_RELEASE` and replace every `sha256` wi values from that release's `checksums.sha256` on GitHub (cross-check them against the per-file digests on the release page), then run `npm run video:install` again. +## Motion recording + +When a camera reports motion over ONVIF, the app records a clip and stops a few seconds +after the motion ends. Clips are fragmented MP4 and play in the browser. + +| Variable | Default | Meaning | +| --- | --- | --- | +| `RECORDINGS_DIR` | `.data/recordings` | Where clips are written; point it at an external drive or NAS if you like. One folder per camera. | +| `RECORD_STREAM` | `sub` | Which stream is recorded. `sub` is about 5 MB per minute of motion, `main` about 38 MB. | +| `RECORD_KEEP_DAYS` | `7` | Clips older than this are deleted by an hourly sweep. | +| `MOTION_RECORDING` | on | Set to `off` to record nothing. | + +There is no pre-roll: a clip starts when the camera reports motion, which is a second or +two after movement begins. Recording pulls the camera only while motion lasts. + --- This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). diff --git a/src/instrumentation.test.ts b/src/instrumentation.test.ts index bcf4c58..9633667 100644 --- a/src/instrumentation.test.ts +++ b/src/instrumentation.test.ts @@ -8,6 +8,8 @@ vi.mock("./lib/setup-code", () => ({ ensureSetupCode })); // Never start a real MediaMTX from tests (pri-e14bahk). const startVideoBridge = vi.fn(); vi.mock("./lib/video", () => ({ startVideoBridge })); +const startMotionRecording = vi.fn(); +vi.mock("./lib/motion-recorder", () => ({ startMotionRecording })); let dir: string; beforeEach(async () => { @@ -16,6 +18,7 @@ beforeEach(async () => { vi.stubEnv("NEXT_RUNTIME", "nodejs"); ensureSetupCode.mockReset(); startVideoBridge.mockReset().mockResolvedValue({ state: "starting", restarts: 0 }); + startMotionRecording.mockReset().mockResolvedValue([]); }); afterEach(() => rm(dir, { recursive: true, force: true })); @@ -64,11 +67,31 @@ describe("instrumentation register", () => { expect(startVideoBridge).not.toHaveBeenCalled(); }); - it("keeps the server up if it can't start", async () => { + it("keeps the server up if it can't start, and skips recording", async () => { startVideoBridge.mockRejectedValue(new Error("EACCES .data")); const error = vi.spyOn(console, "error").mockImplementation(() => {}); await expect(register()).resolves.toBeUndefined(); expect(error).toHaveBeenCalledWith("[video] could not start the video bridge: EACCES .data"); + expect(startMotionRecording).not.toHaveBeenCalled(); + }); + + it("starts motion recording after the bridge, without waiting for it", async () => { + let started: (v: string[]) => void = () => {}; + startMotionRecording.mockReturnValue(new Promise((resolve) => (started = resolve))); + await register(); // resolves even though watching hasn't finished + expect(startMotionRecording).toHaveBeenCalledTimes(1); + started([]); + expect(startMotionRecording.mock.invocationCallOrder[0]).toBeGreaterThan( + startVideoBridge.mock.invocationCallOrder[0], + ); + }); + + it("keeps the server up if motion recording can't start", async () => { + startMotionRecording.mockRejectedValue(new Error("no cameras file")); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + await expect(register()).resolves.toBeUndefined(); + await new Promise((r) => setTimeout(r, 0)); + expect(error).toHaveBeenCalledWith("[motion] could not start motion recording: no cameras file"); }); }); }); diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 74d4b50..3ca9392 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,7 +1,8 @@ /** * Runs once when the server starts. If no admin login exists yet, prints the one-time * setup code (vrek dec-nw2hvff) so it's in the console before anyone opens /setup. Also - * starts the MediaMTX video bridge (vrek dec-xkn4z0e) unless VIDEO_BRIDGE=off. + * starts the MediaMTX video bridge (vrek dec-xkn4z0e) unless VIDEO_BRIDGE=off, and + * motion recording (vrek iss-ws9nb88) unless MOTION_RECORDING=off. */ export async function register() { if (process.env.NEXT_RUNTIME !== "nodejs") return; @@ -20,5 +21,12 @@ export async function register() { } catch (err) { // Video is optional: the dashboard keeps working on snapshots without it. console.error(`[video] could not start the video bridge: ${(err as Error).message}`); + return; } + const { startMotionRecording } = await import("./lib/motion-recorder"); + // Not awaited: register() holds up the server until it returns, and watching cameras + // can take a while or stall on an unreachable one. + void startMotionRecording().catch((err: Error) => { + console.error(`[motion] could not start motion recording: ${err.message}`); + }); } diff --git a/src/lib/camera-registry.test.ts b/src/lib/camera-registry.test.ts index 58f15fa..f24a189 100644 --- a/src/lib/camera-registry.test.ts +++ b/src/lib/camera-registry.test.ts @@ -48,6 +48,21 @@ describe("isValidCameraId", () => { }); describe("registry", () => { + it("lists every camera for startup code, without the request-only connection() (iss-ws9nb88)", async () => { + const { recordDiscovered, allCameraRecords, listCameras } = await load(); + await recordDiscovered([found({ name: "Porch" })]); + const { connection } = await import("next/server"); + vi.mocked(connection).mockClear(); + + const records = await allCameraRecords(); + expect(records.map((c) => c.id)).toEqual([found().id]); + expect(records[0]).toMatchObject({ host: "192.168.1.10", port: 80, name: "Porch" }); + expect(connection).not.toHaveBeenCalled(); + + await listCameras(); + expect(connection).toHaveBeenCalled(); + }); + it("is empty when no file exists yet", async () => { const { listCameras, getCameraRecord } = await load(); expect(await listCameras()).toEqual([]); diff --git a/src/lib/camera-registry.ts b/src/lib/camera-registry.ts index bf8ae1a..7cd4ddd 100644 --- a/src/lib/camera-registry.ts +++ b/src/lib/camera-registry.ts @@ -78,6 +78,14 @@ export async function getCameraRecord(id: string): Promise return (await load())[id] ?? null; } +/** + * Every known camera, for server code that runs outside a request (startup, background + * work). Page and route code uses listCameras, which also opts out of prerendering. + */ +export async function allCameraRecords(): Promise { + return Object.values(await load()); +} + /** Every known camera, ordered by name then address. Read per request, never prerendered. */ export async function listCameras(): Promise { await connection(); diff --git a/src/lib/mediamtx-config.test.ts b/src/lib/mediamtx-config.test.ts index 381a92c..43fcbf2 100644 --- a/src/lib/mediamtx-config.test.ts +++ b/src/lib/mediamtx-config.test.ts @@ -7,6 +7,8 @@ import { mediamtxWebrtcUrl, pathConfig, pathName, + recordingPathConfig, + recordPathName, } from "./mediamtx-config"; const login = { user: "app", pass: "0123456789abcdef" }; @@ -84,3 +86,23 @@ describe("pathConfig", () => { }); }); }); + +describe("recording paths (iss-ws9nb88)", () => { + it("records through a path of its own, so a viewer is never disturbed", () => { + const id = "22ec0000-8b90-11b5-845d-d03bf404af54"; + expect(recordPathName(id, "sub")).toBe(`${pathName(id, "sub")}-rec`); + expect(() => recordPathName("../x", "sub")).toThrow("Invalid camera id"); + }); + + it("pulls continuously and writes fMP4 under %path, which MediaMTX requires, leaving deletion to us", () => { + expect(recordingPathConfig("rtsp://u:p@10.0.0.2/ch2", "/data/recordings")).toEqual({ + ...pathConfig("rtsp://u:p@10.0.0.2/ch2"), + sourceOnDemand: false, + record: true, + recordPath: "/data/recordings/%path/%Y-%m-%d_%H-%M-%S-%f", + recordFormat: "fmp4", + recordSegmentDuration: "1h", + recordDeleteAfter: "0s", + }); + }); +}); diff --git a/src/lib/mediamtx-config.ts b/src/lib/mediamtx-config.ts index b32c151..24239ff 100644 --- a/src/lib/mediamtx-config.ts +++ b/src/lib/mediamtx-config.ts @@ -86,6 +86,11 @@ export function pathName(cameraId: string, stream: StreamKind): string { return `cam-${cameraId}-${stream}`; } +/** The separate path a camera records through, so recording never disturbs a viewer. */ +export function recordPathName(cameraId: string, stream: StreamKind): string { + return `${pathName(cameraId, stream)}-rec`; +} + /** * The runtime path configuration: pull the camera's RTSP stream only while someone is * watching, over TCP (no RTP ports to open towards the camera). @@ -100,3 +105,22 @@ export function pathConfig(source: string) { record: false, }; } + +/** + * Recording a motion clip: the source is pulled continuously while this is set, and + * MediaMTX writes fMP4 segments. `root` is the recordings folder; MediaMTX insists that + * recordPath contains %path (the path's name), which gives each camera stream its own + * subfolder. Deleting old clips is ours to do (dec-9z8vdmj), so MediaMTX's own deletion + * stays off. + */ +export function recordingPathConfig(source: string, root: string) { + return { + ...pathConfig(source), + sourceOnDemand: false, + record: true, + recordPath: `${root}/%path/%Y-%m-%d_%H-%M-%S-%f`, + recordFormat: "fmp4", + recordSegmentDuration: "1h", + recordDeleteAfter: "0s", + }; +} diff --git a/src/lib/motion-recorder.test.ts b/src/lib/motion-recorder.test.ts new file mode 100644 index 0000000..736dae7 --- /dev/null +++ b/src/lib/motion-recorder.test.ts @@ -0,0 +1,167 @@ +// Cameras, MediaMTX and timers are all fakes (vrek pri-e14bahk). +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RecorderDeps } from "./motion-recorder"; + +const recorder = await import("./motion-recorder"); + +const A = "22ec0000-8b90-11b5-845d-d03bf404af54"; +const B = "11111111-2222-3333-4444-555555555555"; +const cameras = [ + { id: A, host: "192.168.1.10", port: 80, lastSeen: "" }, + { id: B, host: "192.168.1.11", port: 80, lastSeen: "" }, +]; + +let timers: { fn: () => void; ms: number }[]; +let motion: Map void>; +let stopped: string[]; +let deps: RecorderDeps & { setRecording: ReturnType; sweep: ReturnType }; +let logs: string[]; + +beforeEach(() => { + recorder.stopMotionRecording(); + timers = []; + motion = new Map(); + stopped = []; + logs = []; + vi.stubEnv("MOTION_RECORDING", ""); + vi.stubEnv("RECORD_STREAM", ""); + deps = { + listCameras: (async () => cameras) as unknown as RecorderDeps["listCameras"], + watchMotion: (async (target: { id: string }, onChange: (m: boolean) => void) => { + motion.set(target.id, onChange); + return { stop: () => stopped.push(target.id) }; + }) as unknown as RecorderDeps["watchMotion"], + setRecording: vi.fn(async () => {}), + sweep: vi.fn(async () => 0), + setTimer: (fn, ms) => { + timers.push({ fn, ms }); + return timers.length - 1; + }, + clearTimer: (handle) => { + timers[handle as number] = { fn: () => {}, ms: -1 }; + }, + log: (line) => logs.push(line), + }; +}); + +/** Runs the pending post-roll timer, which is always the last one queued. */ +const runLastTimer = async () => { + timers.at(-1)!.fn(); + await new Promise((r) => setTimeout(r, 0)); +}; + +describe("startMotionRecording", () => { + it("watches every known camera and sweeps old clips at startup", async () => { + expect(await recorder.startMotionRecording(deps)).toEqual([A, B]); + expect(deps.sweep).toHaveBeenCalledTimes(1); + expect(logs).toContain("[motion] watching 2 camera(s), recording the sub stream"); + expect(timers.at(-1)!.ms).toBe(60 * 60 * 1000); + }); + + it("does nothing when recording is switched off", async () => { + vi.stubEnv("MOTION_RECORDING", "off"); + expect(await recorder.startMotionRecording(deps)).toEqual([]); + expect(deps.sweep).not.toHaveBeenCalled(); + expect(recorder.recordingCameras()).toEqual([]); + }); + + it("starts only once", async () => { + await recorder.startMotionRecording(deps); + await recorder.startMotionRecording(deps); + expect(deps.sweep).toHaveBeenCalledTimes(1); + }); + + it("keeps watching the other cameras when one can't be watched", async () => { + deps.watchMotion = (async (target: { id: string }) => { + if (target.id === A) throw new Error("no login saved"); + return { stop: () => {} }; + }) as unknown as RecorderDeps["watchMotion"]; + expect(await recorder.startMotionRecording(deps)).toEqual([B]); + expect(logs).toContain("[motion] could not watch " + A + ": no login saved"); + }); +}); + +describe("recording on motion", () => { + beforeEach(async () => { + await recorder.startMotionRecording(deps); + deps.setRecording.mockClear(); + }); + + it("records while a camera reports movement and stops after the post-roll", async () => { + motion.get(A)!(true); + await new Promise((r) => setTimeout(r, 0)); + expect(deps.setRecording).toHaveBeenCalledWith({ id: A, host: "192.168.1.10", port: 80 }, "sub", true); + expect(recorder.recordingCameras()).toEqual([A]); + + motion.get(A)!(false); + expect(deps.setRecording).toHaveBeenCalledTimes(1); // still recording during the post-roll + expect(timers.at(-1)!.ms).toBe(recorder.POST_ROLL_MS); + + await runLastTimer(); + expect(deps.setRecording).toHaveBeenLastCalledWith(expect.objectContaining({ id: A }), "sub", false); + expect(recorder.recordingCameras()).toEqual([]); + }); + + it("keeps one clip going when motion returns during the post-roll", async () => { + motion.get(A)!(true); + motion.get(A)!(false); + motion.get(A)!(true); + await runLastTimer(); // the cancelled post-roll + expect(deps.setRecording).toHaveBeenCalledTimes(1); + expect(recorder.recordingCameras()).toEqual([A]); + }); + + it("records the main stream when configured to", async () => { + recorder.stopMotionRecording(); + vi.stubEnv("RECORD_STREAM", "main"); + await recorder.startMotionRecording(deps); + motion.get(B)!(true); + await new Promise((r) => setTimeout(r, 0)); + expect(deps.setRecording).toHaveBeenLastCalledWith(expect.objectContaining({ id: B }), "main", true); + }); + + it("records each camera on its own", async () => { + motion.get(A)!(true); + motion.get(B)!(true); + await new Promise((r) => setTimeout(r, 0)); + expect(recorder.recordingCameras().sort()).toEqual([A, B].sort()); + }); + + it("logs a failure to start recording and tries again on the next motion", async () => { + deps.setRecording.mockRejectedValueOnce(new Error("Video bridge is not running")); + motion.get(A)!(true); + await new Promise((r) => setTimeout(r, 0)); + expect(logs).toContain(`[motion] could not start recording ${A}: Video bridge is not running`); + expect(recorder.recordingCameras()).toEqual([]); + + motion.get(A)!(false); + await runLastTimer(); + motion.get(A)!(true); + await new Promise((r) => setTimeout(r, 0)); + expect(recorder.recordingCameras()).toEqual([A]); + }); +}); + +describe("sweeping", () => { + it("reports deletions and schedules the next sweep, even after a failure", async () => { + deps.sweep.mockResolvedValueOnce(3); + await recorder.startMotionRecording(deps); + expect(logs).toContain("[motion] deleted 3 clip(s) older than 7 days"); + + deps.sweep.mockRejectedValueOnce(new Error("EACCES")); + await runLastTimer(); + expect(logs).toContain("[motion] could not sweep old clips: EACCES"); + expect(timers.at(-1)!.ms).toBe(60 * 60 * 1000); + }); +}); + +describe("stopMotionRecording", () => { + it("stops every watcher and forgets the recorder", async () => { + await recorder.startMotionRecording(deps); + motion.get(A)!(true); + recorder.stopMotionRecording(); + expect(stopped.sort()).toEqual([A, B].sort()); + expect(recorder.recordingCameras()).toEqual([]); + recorder.stopMotionRecording(); // already stopped: fine + }); +}); diff --git a/src/lib/motion-recorder.ts b/src/lib/motion-recorder.ts new file mode 100644 index 0000000..64cc8f2 --- /dev/null +++ b/src/lib/motion-recorder.ts @@ -0,0 +1,157 @@ +import "server-only"; +import type { CameraTarget } from "./camera"; +import { allCameraRecords } from "./camera-registry"; +import { watchMotion, type MotionWatcher } from "./motion"; +import { keepDays, motionRecordingEnabled, recordStream, sweepOldRecordings } from "./recordings"; +import { setRecording } from "./video"; + +/** + * Turns motion into clips (vrek iss-ws9nb88): watch every known camera, record while it + * reports movement, and stop a few seconds after it stops. Old clips are swept hourly. + * Kept on globalThis, like the video bridge, because instrumentation and the app are + * bundled separately. + */ + +/** Keep recording this long after motion ends, so the clip doesn't cut off too early. */ +export const POST_ROLL_MS = 10_000; +const SWEEP_EVERY_MS = 60 * 60 * 1000; + +export interface RecorderDeps { + /** Not listCameras: that one calls connection(), which never resolves outside a request. */ + listCameras: typeof allCameraRecords; + watchMotion: typeof watchMotion; + setRecording: typeof setRecording; + sweep: typeof sweepOldRecordings; + setTimer: (fn: () => void, ms: number) => unknown; + clearTimer: (handle: unknown) => void; + log: (line: string) => void; +} + +const realDeps: RecorderDeps = { + listCameras: allCameraRecords, + watchMotion, + setRecording, + sweep: sweepOldRecordings, + setTimer: (fn, ms) => setTimeout(fn, ms).unref(), + clearTimer: (handle) => clearTimeout(handle as NodeJS.Timeout), + log: (line) => console.log(line), +}; + +interface CameraState { + watcher: MotionWatcher; + /** Pending stop, so a burst of motion doesn't end the clip early. */ + stopTimer: unknown; + recording: boolean; + /** What the camera's motion says it should be; applied one change at a time. */ + desired: boolean; + applying: boolean; +} + +interface Recorder { + cameras: Map; + sweepTimer: unknown; + deps: RecorderDeps; +} + +const KEY = Symbol.for("cameras.motionRecorder"); +const g = globalThis as { [KEY]?: Recorder }; + +/** Which cameras are recording right now, for the UI. */ +export function recordingCameras(): string[] { + const recorder = g[KEY]; + if (!recorder) return []; + return [...recorder.cameras].filter(([, state]) => state.recording).map(([id]) => id); +} + +/** + * Brings MediaMTX in line with what the camera is reporting. Only one change is sent at a + * time, so motion that flickers can't start two recordings; a failure is logged and + * retried when motion next changes, rather than looped over. + */ +async function apply(recorder: Recorder, target: CameraTarget, state: CameraState, on: boolean) { + state.desired = on; + if (state.applying) return; + state.applying = true; + try { + while (state.desired !== state.recording) { + const want = state.desired; + try { + await recorder.deps.setRecording(target, recordStream(), want); + state.recording = want; + recorder.deps.log(`[motion] ${want ? "recording" : "stopped recording"} ${target.id}`); + } catch (err) { + // A camera or bridge problem shouldn't stop the others from recording. + recorder.deps.log( + `[motion] could not ${want ? "start" : "stop"} recording ${target.id}: ${(err as Error).message}`, + ); + break; + } + } + } finally { + state.applying = false; + } +} + +/** + * Starts watching every known camera. Safe to call once per server; a second call returns + * the running recorder. Cameras discovered later are picked up by the next start. + */ +export async function startMotionRecording(deps: RecorderDeps = realDeps): Promise { + if (g[KEY]) return [...g[KEY].cameras.keys()]; + if (!motionRecordingEnabled()) { + deps.log("[motion] motion recording is off (MOTION_RECORDING=off)"); + return []; + } + + const recorder: Recorder = { cameras: new Map(), sweepTimer: null, deps }; + g[KEY] = recorder; + + const sweep = async () => { + try { + const deleted = await deps.sweep(); + if (deleted > 0) deps.log(`[motion] deleted ${deleted} clip(s) older than ${keepDays()} days`); + } catch (err) { + deps.log(`[motion] could not sweep old clips: ${(err as Error).message}`); + } + recorder.sweepTimer = deps.setTimer(() => void sweep(), SWEEP_EVERY_MS); + }; + await sweep(); + + for (const cam of await deps.listCameras()) { + const target: CameraTarget = { id: cam.id, host: cam.host, port: cam.port }; + try { + const state: CameraState = { + watcher: undefined as unknown as MotionWatcher, + stopTimer: null, + recording: false, + desired: false, + applying: false, + }; + state.watcher = await deps.watchMotion(target, (moving) => { + if (state.stopTimer !== null) { + deps.clearTimer(state.stopTimer); + state.stopTimer = null; + } + if (moving) void apply(recorder, target, state, true); + else state.stopTimer = deps.setTimer(() => void apply(recorder, target, state, false), POST_ROLL_MS); + }); + recorder.cameras.set(cam.id, state); + } catch (err) { + deps.log(`[motion] could not watch ${cam.id}: ${(err as Error).message}`); + } + } + deps.log(`[motion] watching ${recorder.cameras.size} camera(s), recording the ${recordStream()} stream`); + return [...recorder.cameras.keys()]; +} + +/** Stops watching; does not stop MediaMTX, which the video bridge owns. */ +export function stopMotionRecording(): void { + const recorder = g[KEY]; + if (!recorder) return; + for (const state of recorder.cameras.values()) { + state.watcher.stop(); + if (state.stopTimer !== null) recorder.deps.clearTimer(state.stopTimer); + } + if (recorder.sweepTimer !== null) recorder.deps.clearTimer(recorder.sweepTimer); + delete g[KEY]; +} diff --git a/src/lib/motion.test.ts b/src/lib/motion.test.ts new file mode 100644 index 0000000..db9edf6 --- /dev/null +++ b/src/lib/motion.test.ts @@ -0,0 +1,92 @@ +// The camera connection is replaced by a scripted event emitter; no camera is contacted +// (vrek pri-e14bahk). +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { isMotionEvent, watchMotion, type MotionDeps, type OnvifEvent } from "./motion"; + +const target = { id: "22ec0000-8b90-11b5-845d-d03bf404af54", host: "192.168.1.10", port: 80 }; + +const event = (topic: string, value: unknown): OnvifEvent => ({ + topic: { _: topic }, + message: { message: { data: { simpleItem: { $: { Name: "State", Value: value } } } } }, +}); + +const MOTION = "tns1:VideoSource/MotionAlarm"; +const CELL = "tns1:RuleEngine/CellMotionDetector/Motion"; + +function fakeCamera() { + const cam = new EventEmitter(); + const deps: MotionDeps = { connect: (async () => cam) as unknown as MotionDeps["connect"], log: vi.fn() }; + return { cam, deps }; +} + +describe("isMotionEvent", () => { + it.each([ + [event(MOTION, true), true], + [event(MOTION, "true"), true], + [event(MOTION, false), false], + [event(MOTION, "false"), false], + [event(MOTION, "unknown"), null], + [event(CELL, true), null], + [event("tns1:Monitoring/ProcessorUsage", 7), null], + [{}, null], + ])("reads %j as %s", (e, expected) => { + expect(isMotionEvent(e as OnvifEvent)).toBe(expected); + }); +}); + +describe("watchMotion", () => { + it("reports only changes, not the state the camera repeats every second", async () => { + const { cam, deps } = fakeCamera(); + const changes: boolean[] = []; + await watchMotion(target, (moving) => changes.push(moving), deps); + + cam.emit("event", event(MOTION, false)); + cam.emit("event", event(MOTION, true)); + cam.emit("event", event(MOTION, true)); + cam.emit("event", event(MOTION, true)); + cam.emit("event", event(MOTION, false)); + expect(changes).toEqual([false, true, false]); + }); + + it("ignores the slow cell motion detector and other topics", async () => { + const { cam, deps } = fakeCamera(); + const onChange = vi.fn(); + await watchMotion(target, onChange, deps); + cam.emit("event", event(CELL, true)); + cam.emit("event", event("tns1:RuleEngine/TamperDetector/Tamper", true)); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("logs event errors and keeps watching", async () => { + const { cam, deps } = fakeCamera(); + const changes: boolean[] = []; + await watchMotion(target, (m) => changes.push(m), deps); + + cam.emit("eventsError", new Error("Parse Error: Data after `Connection: close`")); + expect(deps.log).toHaveBeenCalledWith(expect.stringContaining("Parse Error")); + cam.emit("event", event(MOTION, true)); + expect(changes).toEqual([true]); + }); + + it("stops listening when asked, leaving the shared connection alone", async () => { + const { cam, deps } = fakeCamera(); + const onChange = vi.fn(); + const watcher = await watchMotion(target, onChange, deps); + watcher.stop(); + cam.emit("event", event(MOTION, true)); + expect(onChange).not.toHaveBeenCalled(); + expect(cam.listenerCount("event")).toBe(0); + expect(cam.listenerCount("eventsError")).toBe(0); + }); + + it("passes a connection failure to the caller", async () => { + const deps: MotionDeps = { + connect: (async () => { + throw new Error("no login saved"); + }) as unknown as MotionDeps["connect"], + log: vi.fn(), + }; + await expect(watchMotion(target, vi.fn(), deps)).rejects.toThrow("no login saved"); + }); +}); diff --git a/src/lib/motion.ts b/src/lib/motion.ts new file mode 100644 index 0000000..6d3c62d --- /dev/null +++ b/src/lib/motion.ts @@ -0,0 +1,68 @@ +import "server-only"; +import { connect, type CameraTarget } from "./camera"; + +/** + * Motion from the camera's own ONVIF events (vrek dec-d50j69a). These cameras repeat + * their whole state about once a second, so only changes are reported onward. The topic + * is tns1:VideoSource/MotionAlarm, which follows movement closely; the cell motion + * detector stays on for a minute after movement ends and is ignored. + */ + +const MOTION_TOPIC = /VideoSource\/MotionAlarm/i; + +/** What the onvif library hands us; only these fields are read. */ +export interface OnvifEvent { + topic?: { _?: string }; + message?: { message?: { data?: { simpleItem?: { $?: { Name?: string; Value?: unknown } } } } }; +} + +export function isMotionEvent(event: OnvifEvent): boolean | null { + const topic = event.topic?._; + if (!topic || !MOTION_TOPIC.test(topic)) return null; + const value = event.message?.message?.data?.simpleItem?.$?.Value; + if (value === true || value === "true") return true; + if (value === false || value === "false") return false; + return null; +} + +export interface MotionWatcher { + stop(): void; +} + +export interface MotionDeps { + connect: typeof connect; + log: (line: string) => void; +} + +const defaultDeps: MotionDeps = { connect, log: (line) => console.warn(line) }; + +/** + * Watches one camera and calls `onChange` when motion starts or stops. Event errors are + * logged; the onvif library keeps pulling, and a failed connection is retried on the next + * call (fnd-3kp0rwy). + */ +export async function watchMotion( + target: CameraTarget, + onChange: (moving: boolean) => void, + deps: MotionDeps = defaultDeps, +): Promise { + const cam = await deps.connect(target); + let moving: boolean | null = null; + + const onEvent = (event: OnvifEvent) => { + const now = isMotionEvent(event); + if (now === null || now === moving) return; + moving = now; + onChange(now); + }; + const onError = (err: Error) => deps.log(`[motion] ${target.id}: ${err.message}`); + + cam.on("event", onEvent); + cam.on("eventsError", onError); + return { + stop() { + cam.removeListener("event", onEvent); + cam.removeListener("eventsError", onError); + }, + }; +} diff --git a/src/lib/recordings.test.ts b/src/lib/recordings.test.ts new file mode 100644 index 0000000..8523ce0 --- /dev/null +++ b/src/lib/recordings.test.ts @@ -0,0 +1,114 @@ +import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cameraIdFromFolder, + clipFolder, + DEFAULT_KEEP_DAYS, + keepDays, + motionRecordingEnabled, + recordingsDir, + recordStream, + sweepOldRecordings, +} from "./recordings"; + +const ID = "22ec0000-8b90-11b5-845d-d03bf404af54"; +const DAY = 24 * 60 * 60 * 1000; +const NOW = Date.UTC(2026, 8, 19, 12); + +let dir: string; +beforeEach(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "recordings-")); + vi.stubEnv("RECORDINGS_DIR", dir); +}); +afterEach(() => rm(dir, { recursive: true, force: true })); + +/** Writes a clip whose modification time is `ageDays` old. */ +async function clip(cameraId: string, name: string, ageDays: number) { + const folder = path.join(dir, `cam-${cameraId}-sub-rec`); + await mkdir(folder, { recursive: true }); + const file = path.join(folder, name); + await writeFile(file, "video"); + const when = new Date(NOW - ageDays * DAY); + await utimes(file, when, when); + return file; +} + +describe("configuration", () => { + it("defaults to .data/recordings, the sub stream, 7 days and on", () => { + vi.stubEnv("RECORDINGS_DIR", ""); + vi.spyOn(process, "cwd").mockReturnValue("/app"); + expect(recordingsDir()).toBe(path.join("/app", ".data", "recordings")); + expect(recordStream()).toBe("sub"); + expect(keepDays()).toBe(DEFAULT_KEEP_DAYS); + expect(motionRecordingEnabled()).toBe(true); + }); + + it("takes the folder, stream, retention and off switch from the environment", () => { + vi.stubEnv("RECORDINGS_DIR", "/mnt/nas/clips"); + vi.stubEnv("RECORD_STREAM", "main"); + vi.stubEnv("RECORD_KEEP_DAYS", "30"); + vi.stubEnv("MOTION_RECORDING", "off"); + expect(recordingsDir()).toBe("/mnt/nas/clips"); + expect(recordStream()).toBe("main"); + expect(keepDays()).toBe(30); + expect(motionRecordingEnabled()).toBe(false); + }); + + it.each(["", "0", "-3", "nonsense"])("ignores a nonsensical retention %j", (value) => { + vi.stubEnv("RECORD_KEEP_DAYS", value); + expect(keepDays()).toBe(DEFAULT_KEEP_DAYS); + }); + + it("gives each recorded stream its own folder, named after the MediaMTX path", () => { + expect(clipFolder(ID, "sub")).toBe(path.join(dir, `cam-${ID}-sub-rec`)); + expect(clipFolder(ID, "main")).toBe(path.join(dir, `cam-${ID}-main-rec`)); + expect(() => clipFolder("../../etc", "sub")).toThrow("Invalid camera id"); + }); + + it("reads the camera back out of a folder name, and ignores folders that aren't ours", () => { + expect(cameraIdFromFolder(`cam-${ID}-sub-rec`)).toBe(ID); + expect(cameraIdFromFolder(`cam-${ID}-main-rec`)).toBe(ID); + for (const folder of ["notes", "cam-../etc-sub-rec", `cam-${ID}-sub`, `cam-${ID}-other-rec`]) { + expect(cameraIdFromFolder(folder)).toBeNull(); + } + }); +}); + +describe("sweepOldRecordings", () => { + it("deletes clips past the retention window and keeps newer ones", async () => { + const old = await clip(ID, "2026-09-10_08-00-00-000000.mp4", 9); + const recent = await clip(ID, "2026-09-19_08-00-00-000000.mp4", 0.2); + expect(await sweepOldRecordings(NOW)).toBe(1); + expect(await readdir(path.join(dir, `cam-${ID}-sub-rec`))).toEqual([path.basename(recent)]); + expect(old).toBeTruthy(); + }); + + it("removes a camera folder once all its clips are gone", async () => { + await clip(ID, "old.mp4", 30); + expect(await sweepOldRecordings(NOW)).toBe(1); + expect(await readdir(dir)).toEqual([]); + }); + + it("honours a different retention", async () => { + await clip(ID, "three-days.mp4", 3); + expect(await sweepOldRecordings(NOW, 30)).toBe(0); + expect(await sweepOldRecordings(NOW, 1)).toBe(1); + }); + + it("sweeps every camera and ignores stray files at the top level", async () => { + const other = "11111111-2222-3333-4444-555555555555"; + await clip(ID, "old.mp4", 9); + await clip(other, "old.mp4", 9); + await clip(other, "new.mp4", 1); + await writeFile(path.join(dir, "notes.txt"), "stray"); + expect(await sweepOldRecordings(NOW)).toBe(2); + expect((await readdir(dir)).sort()).toEqual([`cam-${other}-sub-rec`, "notes.txt"].sort()); + }); + + it("does nothing when there is no recordings folder yet", async () => { + vi.stubEnv("RECORDINGS_DIR", path.join(dir, "missing")); + expect(await sweepOldRecordings(NOW)).toBe(0); + }); +}); diff --git a/src/lib/recordings.ts b/src/lib/recordings.ts new file mode 100644 index 0000000..4aadb69 --- /dev/null +++ b/src/lib/recordings.ts @@ -0,0 +1,81 @@ +import "server-only"; +import { readdir, rm, stat } from "node:fs/promises"; +import path from "node:path"; +import { recordPathName, type StreamKind } from "./mediamtx-config"; + +/** + * Where motion clips live and how long they are kept (vrek dec-9z8vdmj). Retention is our + * own sweep rather than MediaMTX's, so old clips go whether or not a camera is currently + * recording. + */ + +export const DEFAULT_KEEP_DAYS = 7; +export const CLIP_EXTENSION = ".mp4"; + +export function recordingsDir(): string { + return ( + process.env.RECORDINGS_DIR || + path.join(/* turbopackIgnore: true */ process.cwd(), ".data", "recordings") + ); +} + +/** Which stream is recorded; the sub stream is about an eighth the size of the main one. */ +export function recordStream(): StreamKind { + return process.env.RECORD_STREAM === "main" ? "main" : "sub"; +} + +export function keepDays(): number { + const days = Number(process.env.RECORD_KEEP_DAYS); + return Number.isFinite(days) && days > 0 ? days : DEFAULT_KEEP_DAYS; +} + +/** Motion recording runs unless it is switched off. */ +export function motionRecordingEnabled(): boolean { + return process.env.MOTION_RECORDING !== "off"; +} + +/** + * Clips live one folder per recorded stream, named after the MediaMTX path, because + * MediaMTX requires %path in the recording path: "cam---rec". + */ +const FOLDER = /^cam-([0-9a-f-]{32,36})-(main|sub)-rec$/; + +export function clipFolder(cameraId: string, stream: StreamKind): string { + return path.join(recordingsDir(), recordPathName(cameraId, stream)); +} + +/** The camera a clip folder belongs to, or null if the folder isn't one of ours. */ +export function cameraIdFromFolder(folder: string): string | null { + return FOLDER.exec(folder)?.[1] ?? null; +} + +/** + * Deletes clips older than the retention window, and any camera folder left empty. + * Returns how many files were deleted. + */ +export async function sweepOldRecordings(now = Date.now(), days = keepDays()): Promise { + const root = recordingsDir(); + const cutoff = now - days * 24 * 60 * 60 * 1000; + const cameras = await readdir(root, { withFileTypes: true }).catch(() => []); + let deleted = 0; + + for (const camera of cameras) { + if (!camera.isDirectory()) continue; + const dir = path.join(root, camera.name); + const clips = await readdir(dir).catch(() => []); + let left = 0; + for (const clip of clips) { + const file = path.join(dir, clip); + const info = await stat(file).catch(() => null); + if (!info?.isFile()) continue; + if (info.mtimeMs < cutoff) { + await rm(file, { force: true }); + deleted++; + } else { + left++; + } + } + if (left === 0) await rm(dir, { recursive: true, force: true }); + } + return deleted; +} diff --git a/src/lib/video.test.ts b/src/lib/video.test.ts index 73c5085..dbf18cf 100644 --- a/src/lib/video.test.ts +++ b/src/lib/video.test.ts @@ -154,6 +154,47 @@ describe("videoBridgeStatus / mediamtxAuthHeader before start", () => { }); }); +describe("setRecording (iss-ws9nb88)", () => { + it("turns recording on through the camera's own recording path", async () => { + const fetchMock = stubApi(() => new Response(null, { status: 200 })); + await video.startVideoBridge(factory); + await video.setRecording(target, "sub", true); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`http://127.0.0.1:9997/v3/config/paths/replace/cam-${ID}-sub-rec`); + const body = JSON.parse(init!.body as string); + expect(body).toMatchObject({ source: SOURCE, record: true, sourceOnDemand: false, recordFormat: "fmp4" }); + // MediaMTX requires %path, which becomes the per-stream folder name. + expect(body.recordPath).toBe(path.join(dir, ".data", "recordings") + "/%path/%Y-%m-%d_%H-%M-%S-%f"); + }); + + it("turns recording off by putting the path back to on-demand", async () => { + const fetchMock = stubApi(() => new Response(null, { status: 200 })); + await video.startVideoBridge(factory); + await video.setRecording(target, "sub", false); + expect(JSON.parse(fetchMock.mock.calls[0][1]!.body as string)).toMatchObject({ + record: false, + sourceOnDemand: true, + }); + }); + + it("creates the recording path if MediaMTX doesn't have it yet", async () => { + const fetchMock = stubApi((url) => new Response(null, { status: url.includes("/replace/") ? 404 : 200 })); + await video.startVideoBridge(factory); + await video.setRecording(target, "main", true); + expect(fetchMock.mock.calls[1][0]).toBe(`http://127.0.0.1:9997/v3/config/paths/add/cam-${ID}-main-rec`); + }); + + it("refuses while the bridge isn't running, and hides MediaMTX's error text", async () => { + await expect(video.setRecording(target, "sub", true)).rejects.toThrow("Video bridge is not running"); + stubApi(() => new Response(`bad source ${SOURCE}`, { status: 400 })); + await video.startVideoBridge(factory); + const err = await video.setRecording(target, "sub", true).catch((e: Error) => e); + expect((err as Error).message).toBe("MediaMTX refused the stream configuration (HTTP 400)"); + expect((err as Error).message).not.toContain("s3cret"); + }); +}); + describe("ensureStreamPath", () => { it("refuses while the bridge isn't running", async () => { await expect(video.ensureStreamPath(target, "main")).rejects.toThrow("Video bridge is not running"); diff --git a/src/lib/video.ts b/src/lib/video.ts index f4fb338..ecdd909 100644 --- a/src/lib/video.ts +++ b/src/lib/video.ts @@ -9,9 +9,12 @@ import { mediamtxConfig, pathConfig, pathName, + recordingPathConfig, + recordPathName, type ApiLogin, type StreamKind, } from "./mediamtx-config"; +import { recordingsDir } from "./recordings"; import { forgetPid, PID_FILE, recordPid, stopLeftoverMediamtx } from "./mediamtx-leftover"; import { MediamtxSupervisor, type BridgeStatus } from "./mediamtx-supervisor"; @@ -135,12 +138,20 @@ export function resetVideoBridgeForTests() { delete g[KEY]; } -/** - * Makes sure MediaMTX has a path for this camera stream, pointing at the camera with its - * current login, and returns the path name. The camera is only pulled while someone - * watches (sourceOnDemand). - */ -export async function ensureStreamPath(target: CameraTarget, stream: StreamKind): Promise { +/** Sends one path configuration to MediaMTX, creating the path if it doesn't exist yet. */ +async function putPath(bridge: Bridge, name: string, config: object): Promise { + const init = { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }; + let res = await api(bridge.login, `/v3/config/paths/replace/${name}`, init); + if (res.status === 404) res = await api(bridge.login, `/v3/config/paths/add/${name}`, init); + // MediaMTX's error text can echo the source URL, which holds the camera password. + if (!res.ok) throw new VideoBridgeError(`MediaMTX refused the stream configuration (HTTP ${res.status})`); +} + +function runningBridge(): Bridge { const bridge = g[KEY]; const status = bridge?.supervisor.status(); if (!bridge || status?.state !== "running") throw new VideoBridgeError("Video bridge is not running"); @@ -148,16 +159,34 @@ export async function ensureStreamPath(target: CameraTarget, stream: StreamKind) bridge.paths.clear(); bridge.pathsEpoch = status.restarts; } + return bridge; +} + +/** + * Starts or stops recording a camera's stream (vrek iss-ws9nb88). While recording, the + * camera is pulled continuously through a path of its own, so a viewer is never + * interrupted; stopping puts that path back to on-demand, which drops the connection. + */ +export async function setRecording(target: CameraTarget, stream: StreamKind, on: boolean): Promise { + const bridge = runningBridge(); + const name = recordPathName(target.id, stream); + const source = await rtspSourceWithLogin(target, stream); + await putPath(bridge, name, on ? recordingPathConfig(source, recordingsDir()) : pathConfig(source)); + // The recording path's source is re-sent each time; don't let the cache skip a change. + bridge.paths.delete(name); +} + +/** + * Makes sure MediaMTX has a path for this camera stream, pointing at the camera with its + * current login, and returns the path name. The camera is only pulled while someone + * watches (sourceOnDemand). + */ +export async function ensureStreamPath(target: CameraTarget, stream: StreamKind): Promise { + const bridge = runningBridge(); const name = pathName(target.id, stream); const source = await rtspSourceWithLogin(target, stream); if (bridge.paths.get(name) === source) return name; - - const body = JSON.stringify(pathConfig(source)); - const init = { method: "POST", headers: { "Content-Type": "application/json" }, body }; - let res = await api(bridge.login, `/v3/config/paths/replace/${name}`, init); - if (res.status === 404) res = await api(bridge.login, `/v3/config/paths/add/${name}`, init); - // MediaMTX's error text can echo the source URL, which holds the camera password. - if (!res.ok) throw new VideoBridgeError(`MediaMTX refused the stream configuration (HTTP ${res.status})`); + await putPath(bridge, name, pathConfig(source)); bridge.paths.set(name, source); return name; }