From 41021a62add2c3e1712bcde15b1202f9d7edbc50 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sat, 19 Sep 2026 16:40:21 -0500 Subject: [PATCH] Add live video: WHEP relay route and WebRTC player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST/DELETE /api/cameras/[id]/live/whep check the session, resolve the camera from the registry, and relay WebRTC signaling to MediaMTX on localhost. The browser never sees MediaMTX's address, its error text or a camera login; video flows browser↔MediaMTX, not through Next. - LivePlayer negotiates with the browser's own RTCPeerConnection and shows connecting, reconnecting and failed states with Retry. - The camera page gains a Live section; the pop-out plays live video and falls back to snapshot polling if it can't. Both offer Main/Sub. - Stop a leftover MediaMTX from a server that didn't exit cleanly: record its pid and, on startup, stop it only if that pid is still our binary with our config. - Export vrek log: both cameras watched live on main and sub (4 of the goal's 6 checks). Co-Authored-By: Claude Opus 5 (1M context) --- .vrek/log.ndjson | 20 ++ .../api/cameras/[id]/live/whep/route.test.ts | 174 +++++++++++++++ src/app/api/cameras/[id]/live/whep/route.ts | 105 ++++++++++ src/app/cameras/[id]/live-panel.test.tsx | 23 ++ src/app/cameras/[id]/live-panel.tsx | 23 ++ .../cameras/[id]/live/live-player.test.tsx | 198 ++++++++++++++++++ src/app/cameras/[id]/live/live-player.tsx | 85 ++++++++ src/app/cameras/[id]/live/live-view.test.tsx | 55 ++++- src/app/cameras/[id]/live/live-view.tsx | 89 ++++++-- src/app/cameras/[id]/live/stream-select.tsx | 28 +++ src/app/cameras/[id]/live/use-whep.ts | 116 ++++++++++ src/app/cameras/[id]/live/webrtc.ts | 8 + src/app/cameras/[id]/page.test.tsx | 5 + src/app/cameras/[id]/page.tsx | 5 + src/lib/mediamtx-leftover.test.ts | 126 +++++++++++ src/lib/mediamtx-leftover.ts | 87 ++++++++ src/lib/mediamtx-supervisor.test.ts | 13 ++ src/lib/mediamtx-supervisor.ts | 5 + src/lib/video.test.ts | 38 ++++ src/lib/video.ts | 24 ++- 20 files changed, 1193 insertions(+), 34 deletions(-) create mode 100644 src/app/api/cameras/[id]/live/whep/route.test.ts create mode 100644 src/app/api/cameras/[id]/live/whep/route.ts create mode 100644 src/app/cameras/[id]/live-panel.test.tsx create mode 100644 src/app/cameras/[id]/live-panel.tsx create mode 100644 src/app/cameras/[id]/live/live-player.test.tsx create mode 100644 src/app/cameras/[id]/live/live-player.tsx create mode 100644 src/app/cameras/[id]/live/stream-select.tsx create mode 100644 src/app/cameras/[id]/live/use-whep.ts create mode 100644 src/app/cameras/[id]/live/webrtc.ts create mode 100644 src/lib/mediamtx-leftover.test.ts create mode 100644 src/lib/mediamtx-leftover.ts diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index 8d2e0e0..63a932b 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -801,3 +801,23 @@ {"id":"evt-r8ad4s4jhgpw","type":"edge.added","subject":"iss-4atmz13","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-4atmz13","to":"iss-yd2sq2q"},"at":"2026-09-19T20:33:23.229Z","parents":["evt-t3hv9z68czv1"],"hash":"ef606c14c0ef29a9538119652ef1482d7ed8b9ec4af4966afc80cdf614ea0173"} {"id":"evt-88zs5h5gg17f","type":"edge.added","subject":"iss-yd2sq2q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-yd2sq2q","to":"area:video"},"at":"2026-09-19T20:33:23.230Z","parents":["evt-r8ad4s4jhgpw"],"hash":"f04eaa848b5e077d22a68283a76b3d1be03ac60ff6fd3d0a720b234d509cf572"} {"id":"evt-69m4zgpr4mzg","type":"edge.added","subject":"iss-yd2sq2q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"discovered_from","from":"iss-yd2sq2q","to":"iss-nk6zrzv"},"at":"2026-09-19T20:33:23.231Z","parents":["evt-88zs5h5gg17f"],"hash":"c4b77e1ae675f302e51d418c5433781a0e4300741a9dd346ddf78bec9b826e40"} +{"id":"evt-qehb93vm0ycn","type":"node.created","subject":"ver-mmjvk5b","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"On startup the bridge stops a leftover MediaMTX recorded in .data/mediamtx/mediamtx.pid (SIGTERM, then SIGKILL after about 3 s), but only when that pid still runs our binary with our config; the pid file is written on spawn and removed on exit","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T20:35:44.472Z","parents":["evt-69m4zgpr4mzg"],"hash":"cb195ffff80a349f25e0976b83c0269220a9643eee71ad0711b7d1cc52b357cd"} +{"id":"evt-k1jkcdnpvpfj","type":"edge.added","subject":"ver-mmjvk5b","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-mmjvk5b","to":"iss-yd2sq2q"},"at":"2026-09-19T20:35:44.473Z","parents":["evt-qehb93vm0ycn"],"hash":"a4325f9e3429ed46171791ff9fedafb98cf249f188410985b79cd4ffa04d930e"} +{"id":"evt-egns6xcvsxz0","type":"verification.recorded","subject":"ver-mmjvk5b","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/mediamtx-leftover.test.ts (fake process table; never signals a reused pid), mediamtx-supervisor.test.ts (onSpawn/onExit), video.test.ts (\"leftover MediaMTX and pid file\"). Full suite 738 tests pass, 99.86% lines; tsc and eslint clean; next build compiles."},"at":"2026-09-19T20:35:44.474Z","parents":["evt-k1jkcdnpvpfj"],"hash":"3879a5cf742d0ef6bb36b7ccb4deae76e9972d197f1b71e9080cdad86e99c372"} +{"id":"evt-4rqs4x7276j6","type":"node.created","subject":"ver-r06y7ck","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Real check by the user: after force-killing the server (kill -9 on next-server), the next start logs \"[video] stopped a MediaMTX left running by an earlier server\" and MediaMTX starts normally","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T20:35:46.362Z","parents":["evt-egns6xcvsxz0"],"hash":"2162360435f0db9b49fe200ef289849460a1d1a92317974e6930f77f933fec7c"} +{"id":"evt-4ycqh2c3rdqp","type":"edge.added","subject":"ver-r06y7ck","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-r06y7ck","to":"iss-yd2sq2q"},"at":"2026-09-19T20:35:46.364Z","parents":["evt-4rqs4x7276j6"],"hash":"1bbff09fcdc57d01100e0ea486995230a0b7b97090c29133d69ecaeca5b9f6f6"} +{"id":"evt-e0y77cepccqd","type":"verification.recorded","subject":"ver-r06y7ck","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pending","evidence":"User test with the new build"},"at":"2026-09-19T20:35:46.365Z","parents":["evt-4ycqh2c3rdqp"],"hash":"18780a1e5853ebbe44fdbe416b1ef1dc602361687279dfd17154df7c517479bf"} +{"id":"evt-zjaa2vr6m15t","type":"node.status_changed","subject":"iss-yd2sq2q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-19T20:35:48.277Z","parents":["evt-e0y77cepccqd"],"hash":"d1fc6f039f2c5c2bbbc8ef4b38676bbbe2919e0a18d74b50abc7b99d1f901a35"} +{"id":"evt-zc7rkzaets70","type":"node.created","subject":"ver-qsqa18n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"POST/DELETE /api/cameras/[id]/live/whep check the session, validate the id and stream, relay signaling to MediaMTX on localhost with the bridge login, and never return MediaMTX's address, its error text or a camera credential; the player negotiates WebRTC in the browser, shows connecting/reconnecting/failed with Retry, and the pop-out falls back to snapshots; the camera page and pop-out both offer Main/Sub","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T21:35:04.892Z","parents":["evt-zjaa2vr6m15t"],"hash":"0a12104cdbccd44c7db496b321ed67e47f5038c3fe1851573b914cea58a33aaa"} +{"id":"evt-dqw4sny9j4j0","type":"edge.added","subject":"ver-qsqa18n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-qsqa18n","to":"iss-cbx21zy"},"at":"2026-09-19T21:35:04.894Z","parents":["evt-zc7rkzaets70"],"hash":"53827af283759ae1b215bad43a3dd797e5f3b07e57d286f4626fb70ac0a9bf42"} +{"id":"evt-r3afr6svxe26","type":"verification.recorded","subject":"ver-qsqa18n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/api/cameras/[id]/live/whep/route.test.ts (18 tests), live-player.test.tsx (fake RTCPeerConnection), live-view.test.tsx, live-panel.test.tsx, page.test.tsx. Full suite 769 tests pass, 99.81% lines; tsc and eslint clean; next build lists ƒ /api/cameras/[id]/live/whep. WHEP behaviour checked against MediaMTX v1.21.0 internal/servers/webrtc/http_server.go."},"at":"2026-09-19T21:35:04.895Z","parents":["evt-dqw4sny9j4j0"],"hash":"4fcd50e634ab3957602b8fd5e4399e045338f26d7acee865c08db969fef007ec"} +{"id":"evt-t8adbjc357z1","type":"node.created","subject":"ver-pgf465d","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Real check by the user: both cameras play live in the pop-out and on the camera page, on main and sub, with the session required and no camera credentials or MediaMTX address visible in the browser","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T21:35:07.527Z","parents":["evt-r3afr6svxe26"],"hash":"5e823af787b849d1b4b3715c9cd639ff971bde25046f099dd2543d8e8437ef19"} +{"id":"evt-vcksn6nsjyhr","type":"edge.added","subject":"ver-pgf465d","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-pgf465d","to":"iss-cbx21zy"},"at":"2026-09-19T21:35:07.530Z","parents":["evt-t8adbjc357z1"],"hash":"e68a14bd5ee9d09fde7ce89631455a5d647fa53c39ff167934438ab2884c8703"} +{"id":"evt-r1qj2e2yyrwe","type":"verification.recorded","subject":"ver-pgf465d","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pending","evidence":"User test after npm run build && npm start (or npm run dev): watch each camera, switch Main/Sub, and check the browser's network tab"},"at":"2026-09-19T21:35:07.531Z","parents":["evt-vcksn6nsjyhr"],"hash":"204c81adcc35d0725e4ecf38bc659526e87a2e20297238b039c03225573a455d"} +{"id":"evt-kmm00xbmnw9k","type":"node.status_changed","subject":"iss-cbx21zy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-19T21:35:09.464Z","parents":["evt-r1qj2e2yyrwe"],"hash":"5b8d3b666a7b0807cafaae7f631bb591c504a29213af8ac479153072fa81ef28"} +{"id":"evt-j1n23nmpk1yd","type":"node.created","subject":"mea-95yr6gy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"User watched both cameras live, on main and sub, in the browser","body":"Four of the goal's six checks, observed by the user on 2026-09-19 in their own browser after the live player shipped: cameras …af2e and …af54, each on the main (4096×1860) and sub (1200×536) streams, played live over WebRTC through the app's session-checked WHEP route (\"looks good\"). No firewall prompt appeared, which fits watching from the same Mac, where the loopback candidate is used. The two remaining checks are motion recording, one per camera, which isn't designed yet (iss-g456j72).","status":"recorded","owner":null,"attrs":{"value":4,"applies_at":"2026-09-19"}},"at":"2026-09-19T21:39:58.273Z","parents":["evt-kmm00xbmnw9k"],"hash":"7b614246944c81a570fc5469993b0b4117da1c1d02aa0defe736024e8aff700b"} +{"id":"evt-3tvvjzpx0fpx","type":"edge.added","subject":"mea-95yr6gy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-95yr6gy","to":"gol-sxakryh"},"at":"2026-09-19T21:39:58.275Z","parents":["evt-j1n23nmpk1yd"],"hash":"85b9d4aabec09896e90b5696e1ba0f3ae5d23251bcef503d259a7823241692fe"} +{"id":"evt-f58yed61x6g9","type":"node.created","subject":"ver-jra5mms","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Real check by the user: both cameras play live in the pop-out and on the camera page, on main and sub","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T21:40:00.026Z","parents":["evt-3tvvjzpx0fpx"],"hash":"b8b09e4b2ac8ebac9d16b7909fb23ccc5d561362c2f740aca515bb9fe2ee601f"} +{"id":"evt-2mf7gbv44fmb","type":"edge.added","subject":"ver-jra5mms","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-jra5mms","to":"iss-cbx21zy"},"at":"2026-09-19T21:40:00.027Z","parents":["evt-f58yed61x6g9"],"hash":"d162456933fd05a2e6e019e5106a569ef7635cea9018d32972b1c3b143df207f"} +{"id":"evt-w404t95c5wxk","type":"verification.recorded","subject":"ver-jra5mms","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"User on 2026-09-19: \"looks good\", both cameras, main and sub (measurement mea for gol-sxakryh, value 4 of 6)."},"at":"2026-09-19T21:40:00.028Z","parents":["evt-2mf7gbv44fmb"],"hash":"f54aa94f712f9045480d6c6a0e29d17c3c2791405805ecb3aafab5b8bd36f9d3"} +{"id":"evt-ew0n4dq70n0y","type":"node.status_changed","subject":"iss-cbx21zy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"in_progress","to":"done"},"at":"2026-09-19T21:40:01.764Z","parents":["evt-w404t95c5wxk"],"hash":"44defac6820b215f010e288c5dd6d875b0d6a0608439728ce7231e10999f0a6f"} diff --git a/src/app/api/cameras/[id]/live/whep/route.test.ts b/src/app/api/cameras/[id]/live/whep/route.test.ts new file mode 100644 index 0000000..c17d20f --- /dev/null +++ b/src/app/api/cameras/[id]/live/whep/route.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ctx, ID, record, target, url } from "../../../../../../../test/camera-routes"; + +const getCameraRecord = vi.fn(); +const ensureStreamPath = vi.fn(); + +// Access control is tested in src/app/api/access.test.ts; here requests are allowed. +vi.mock("@/lib/access", () => ({ apiAccessDenied: async () => null })); +vi.mock("@/lib/camera-registry", async (importOriginal) => ({ + ...(await importOriginal()), + getCameraRecord, +})); +vi.mock("@/lib/video", async (importOriginal) => ({ + ...(await importOriginal()), + ensureStreamPath, + mediamtxAuthHeader: () => "Basic YXBwOnNlY3JldA==", +})); + +const { POST, DELETE } = await import("./route"); +const { VideoBridgeError } = await import("@/lib/video"); + +const OFFER = "v=0\r\no=- 1 1 IN IP4 0.0.0.0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n"; +const ANSWER = "v=0\r\no=- 2 2 IN IP4 127.0.0.1\r\n"; +const SESSION = "123e4567-e89b-12d3-a456-426614174000"; +const PATH = `cam-${ID}-main`; + +const post = (query = "?stream=main", body = OFFER, type = "application/sdp", id?: string) => + POST(new Request(url(`live/whep${query}`), { method: "POST", headers: { "Content-Type": type }, body }), ctx(id)); + +const del = (query: string, id?: string) => + DELETE(new Request(url(`live/whep${query}`), { method: "DELETE" }), ctx(id)); + +/** A stand-in MediaMTX on localhost. */ +function stubMediamtx(handler: (url: string, init: RequestInit) => Response) { + const mock = vi.fn(async (u: string | URL, init?: RequestInit) => handler(String(u), init ?? {})); + vi.stubGlobal("fetch", mock); + return mock; +} + +const created = () => + new Response(ANSWER, { status: 201, headers: { Location: `/${PATH}/whep/${SESSION}` } }); + +beforeEach(() => { + getCameraRecord.mockReset().mockResolvedValue(record); + ensureStreamPath.mockReset().mockResolvedValue(PATH); +}); + +describe("POST /api/cameras/[id]/live/whep", () => { + it("relays the offer to MediaMTX with the bridge login and returns the answer", async () => { + const fetchMock = stubMediamtx(() => created()); + const res = await post(); + + expect(res.status).toBe(201); + expect(res.headers.get("Content-Type")).toBe("application/sdp"); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + expect(res.headers.get("X-Whep-Session")).toBe(SESSION); + expect(await res.text()).toBe(ANSWER); + + expect(ensureStreamPath).toHaveBeenCalledWith(target, "main"); + const [calledUrl, init] = fetchMock.mock.calls[0]; + expect(calledUrl).toBe(`http://127.0.0.1:8889/${PATH}/whep`); + expect(init!.body).toBe(OFFER); + expect((init!.headers as Record).Authorization).toBe("Basic YXBwOnNlY3JldA=="); + }); + + it("asks for the sub stream when requested, and defaults to main", async () => { + stubMediamtx(() => created()); + await post("?stream=sub"); + expect(ensureStreamPath).toHaveBeenLastCalledWith(target, "sub"); + await post("?stream=nonsense"); + expect(ensureStreamPath).toHaveBeenLastCalledWith(target, "main"); + await post(""); + expect(ensureStreamPath).toHaveBeenLastCalledWith(target, "main"); + }); + + it("never returns a MediaMTX address or its error text", async () => { + const fetchMock = stubMediamtx(() => new Response(`path 'x' not found: rtsp://camera:hunter2@1.2.3.4`, { status: 404 })); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const res = await post(); + expect(res.status).toBe(503); + const body = await res.text(); + expect(body).toBe(JSON.stringify({ error: "The camera's live stream could not be started", code: "video" })); + expect(body).not.toContain("hunter2"); + expect(body).not.toContain("127.0.0.1"); + expect(fetchMock).toHaveBeenCalled(); + }); + + it("says live video is unavailable when the bridge isn't running, without calling MediaMTX", async () => { + const fetchMock = stubMediamtx(() => created()); + ensureStreamPath.mockRejectedValue(new VideoBridgeError("Video bridge is not running")); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const res = await post(); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: "Live video is not available right now", code: "video" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("reports camera problems with the shared error codes", async () => { + const { CameraAuthError } = await import("@/lib/camera"); + ensureStreamPath.mockRejectedValue(new CameraAuthError("rejected")); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const res = await post(); + expect(res.status).toBe(401); + expect(await res.json()).toMatchObject({ code: "auth" }); + }); + + it("survives MediaMTX being unreachable", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); + vi.spyOn(console, "warn").mockImplementation(() => {}); + expect((await post()).status).toBe(503); + }); + + it("leaves the session header empty when MediaMTX sends no usable Location", async () => { + stubMediamtx(() => new Response(ANSWER, { status: 201 })); + expect((await post()).headers.get("X-Whep-Session")).toBe(""); + }); + + it.each([ + ["a non-SDP content type", "?stream=main", OFFER, "application/json", 415], + ["an empty offer", "?stream=main", "", "application/sdp", 400], + ["an oversized offer", "?stream=main", "v".repeat(65_537), "application/sdp", 400], + ])("rejects %s", async (_l, query, body, type, status) => { + const fetchMock = stubMediamtx(() => created()); + expect((await post(query, body, type)).status).toBe(status); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects a malformed id and an unknown camera before touching the bridge", async () => { + expect((await post("?stream=main", OFFER, "application/sdp", "../x")).status).toBe(400); + getCameraRecord.mockResolvedValue(null); + expect((await post()).status).toBe(404); + getCameraRecord.mockResolvedValue({ ...record, host: "8.8.8.8" }); + expect((await post()).status).toBe(400); + expect(ensureStreamPath).not.toHaveBeenCalled(); + }); +}); + +describe("DELETE /api/cameras/[id]/live/whep", () => { + it("ends the session on MediaMTX using the path built from the registry", async () => { + const fetchMock = stubMediamtx(() => new Response(null, { status: 200 })); + const res = await del(`?stream=main&session=${SESSION}`); + expect(res.status).toBe(204); + const [calledUrl, init] = fetchMock.mock.calls[0]; + expect(calledUrl).toBe(`http://127.0.0.1:8889/${PATH}/whep/${SESSION}`); + expect(init!.method).toBe("DELETE"); + }); + + it.each(["", "?stream=main", "?stream=main&session=not-a-uuid", "?session=../../etc"])( + "rejects %j without calling MediaMTX", + async (query) => { + const fetchMock = stubMediamtx(() => new Response(null, { status: 200 })); + expect((await del(query)).status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("still answers when the bridge is stopped or MediaMTX is unreachable", async () => { + const video = await import("@/lib/video"); + const auth = vi.spyOn(video, "mediamtxAuthHeader").mockImplementation(() => { + throw new video.VideoBridgeError("Video bridge not started"); + }); + expect((await del(`?stream=sub&session=${SESSION}`)).status).toBe(503); + + auth.mockRestore(); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); + vi.spyOn(console, "warn").mockImplementation(() => {}); + expect((await del(`?stream=sub&session=${SESSION}`)).status).toBe(204); + }); + + it("checks the camera id like every other route", async () => { + getCameraRecord.mockResolvedValue(null); + expect((await del(`?stream=main&session=${SESSION}`)).status).toBe(404); + }); +}); diff --git a/src/app/api/cameras/[id]/live/whep/route.ts b/src/app/api/cameras/[id]/live/whep/route.ts new file mode 100644 index 0000000..ef6feb0 --- /dev/null +++ b/src/app/api/cameras/[id]/live/whep/route.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; +import { apiAccessDenied } from "@/lib/access"; +import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route"; +import { mediamtxWebrtcUrl, pathName } from "@/lib/mediamtx-config"; +import { ensureStreamPath, mediamtxAuthHeader, VideoBridgeError } from "@/lib/video"; + +/** + * WebRTC signaling (WHEP) for one camera stream, relayed to MediaMTX on localhost (vrek + * iss-cbx21zy). The browser never learns MediaMTX's address or a camera login: it posts + * its SDP offer here, and gets the answer back. Like every entry point, this checks the + * session and resolves the camera from the registry (pri-m1csgrm). + */ + +const streamSchema = z.enum(["main", "sub"]).catch("main"); +/** MediaMTX's session id: the uuid at the end of the Location it returns. */ +const sessionSchema = z.string().uuid(); +/** An SDP offer is a few KB; anything much larger isn't one. */ +const MAX_SDP_BYTES = 64 * 1024; + +const SDP = "application/sdp"; + +function unavailable(detail: string) { + return Response.json({ error: detail, code: "video" }, { status: 503 }); +} + +export async function POST(request: Request, ctx: RouteContext<"/api/cameras/[id]/live/whep">) { + const denied = await apiAccessDenied(); + if (denied) return denied; + const target = await cameraTarget(ctx.params); + if (target instanceof Response) return target; + + if (!request.headers.get("content-type")?.startsWith(SDP)) { + return Response.json({ error: "Expected an SDP offer" }, { status: 415 }); + } + const offer = await request.text(); + if (!offer || offer.length > MAX_SDP_BYTES) { + return Response.json({ error: "Invalid SDP offer" }, { status: 400 }); + } + const stream = streamSchema.parse(new URL(request.url).searchParams.get("stream")); + + let name: string; + try { + name = await ensureStreamPath(target, stream); + } catch (err) { + if (err instanceof VideoBridgeError) { + console.warn("[video]", err.message); + return unavailable("Live video is not available right now"); + } + return cameraErrorResponse(err); + } + + const res = await fetch(mediamtxWebrtcUrl(`/${name}/whep`), { + method: "POST", + headers: { "Content-Type": SDP, Authorization: mediamtxAuthHeader() }, + body: offer, + signal: AbortSignal.timeout(15_000), + }).catch((err: unknown) => { + console.warn("[video] WHEP relay failed:", err); + return null; + }); + + if (!res || res.status !== 201) { + // MediaMTX's own error text can name paths and sources; keep it in the log. + if (res) console.warn(`[video] MediaMTX refused the WHEP offer (HTTP ${res.status})`); + return unavailable("The camera's live stream could not be started"); + } + + const session = res.headers.get("location")?.split("/").pop() ?? ""; + return new Response(await res.text(), { + status: 201, + headers: { + "Content-Type": SDP, + "Cache-Control": "no-store", + // The browser sends this back to hang up; it only controls its own session. + "X-Whep-Session": sessionSchema.safeParse(session).success ? session : "", + }, + }); +} + +/** Ends a session the browser started, so MediaMTX stops pulling the camera right away. */ +export async function DELETE(request: Request, ctx: RouteContext<"/api/cameras/[id]/live/whep">) { + const denied = await apiAccessDenied(); + if (denied) return denied; + const target = await cameraTarget(ctx.params); + if (target instanceof Response) return target; + + const params = new URL(request.url).searchParams; + const session = sessionSchema.safeParse(params.get("session")); + if (!session.success) return Response.json({ error: "Invalid session" }, { status: 400 }); + const stream = streamSchema.parse(params.get("stream")); + + let auth: string; + try { + auth = mediamtxAuthHeader(); + } catch { + return unavailable("Live video is not available right now"); + } + // The path is built from the camera in the registry, never from the request. + await fetch(mediamtxWebrtcUrl(`/${pathName(target.id, stream)}/whep/${session.data}`), { + method: "DELETE", + headers: { Authorization: auth }, + signal: AbortSignal.timeout(5_000), + }).catch((err: unknown) => console.warn("[video] WHEP teardown failed:", err)); + return new Response(null, { status: 204 }); +} diff --git a/src/app/cameras/[id]/live-panel.test.tsx b/src/app/cameras/[id]/live-panel.test.tsx new file mode 100644 index 0000000..6a19307 --- /dev/null +++ b/src/app/cameras/[id]/live-panel.test.tsx @@ -0,0 +1,23 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import LivePanel from "./live-panel"; + +vi.mock("./live/live-player", () => ({ + default: ({ cameraId, name, stream }: { cameraId: string; name: string; stream: string }) => ( +
{`${name} ${cameraId} ${stream}`}
+ ), +})); + +const ID = "11111111-2222-3333-4444-555555555555"; + +describe("LivePanel", () => { + it("plays the main stream for this camera, and can switch to the sub stream", () => { + render(); + expect(screen.getByRole("heading", { name: "Live" })).toBeTruthy(); + expect(screen.getByTestId("player").textContent).toBe(`Porch ${ID} main`); + + fireEvent.change(screen.getByLabelText(/Stream/), { target: { value: "sub" } }); + expect(screen.getByTestId("player").textContent).toBe(`Porch ${ID} sub`); + }); +}); diff --git a/src/app/cameras/[id]/live-panel.tsx b/src/app/cameras/[id]/live-panel.tsx new file mode 100644 index 0000000..13698e8 --- /dev/null +++ b/src/app/cameras/[id]/live-panel.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useState } from "react"; +import LivePlayer from "./live/live-player"; +import StreamSelect from "./live/stream-select"; +import type { StreamKind } from "./live/use-whep"; + +/** Live video on the camera's own page (vrek iss-cbx21zy). */ +export default function LivePanel({ cameraId, name }: { cameraId: string; name: string }) { + const [stream, setStream] = useState("main"); + + return ( +
+
+

Live

+ +
+
+ +
+
+ ); +} diff --git a/src/app/cameras/[id]/live/live-player.test.tsx b/src/app/cameras/[id]/live/live-player.test.tsx new file mode 100644 index 0000000..d839dcf --- /dev/null +++ b/src/app/cameras/[id]/live/live-player.test.tsx @@ -0,0 +1,198 @@ +// @vitest-environment jsdom +// The browser's RTCPeerConnection is replaced by a scripted fake and fetch is stubbed, so +// no real peer connection or network is used (vrek pri-e14bahk). +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { json, renderWithQuery, stubFetch } from "../../../../../test/dom"; +import LivePlayer from "./live-player"; + +const m = vi.hoisted(() => ({ createPeerConnection: vi.fn() })); +vi.mock("./webrtc", () => ({ createPeerConnection: m.createPeerConnection })); +const createPeerConnection = m.createPeerConnection; + +const ID = "11111111-2222-3333-4444-555555555555"; +const SESSION = "123e4567-e89b-12d3-a456-426614174000"; +const whep = () => `POST /api/cameras/${ID}/live/whep`; + +class FakePeer extends EventTarget { + static last: FakePeer; + transceivers: string[] = []; + localDescription: { type: string; sdp: string } | null = null; + remoteDescription: RTCSessionDescriptionInit | null = null; + iceGatheringState: RTCIceGatheringState = "complete"; + connectionState: RTCPeerConnectionState = "new"; + closed = false; + + constructor() { + super(); + FakePeer.last = this; + } + addTransceiver(kind: string) { + this.transceivers.push(kind); + } + async createOffer() { + return { type: "offer", sdp: "offer-sdp" }; + } + async setLocalDescription(desc: { type: string; sdp: string }) { + this.localDescription = desc; + } + async setRemoteDescription(desc: RTCSessionDescriptionInit) { + this.remoteDescription = desc; + } + close() { + this.closed = true; + } + /** Test helpers. */ + connect(state: RTCPeerConnectionState) { + this.connectionState = state; + this.dispatchEvent(new Event("connectionstatechange")); + } + emitTrack(stream: unknown) { + const event = new Event("track") as Event & { streams: MediaStream[]; track: unknown }; + Object.assign(event, { streams: [stream], track: {} }); + this.dispatchEvent(event); + } +} + +const answer = (headers: Record = { "X-Whep-Session": SESSION }) => + new Response("answer-sdp", { status: 201, headers }); + +beforeEach(() => { + createPeerConnection.mockReset().mockImplementation(() => new FakePeer()); +}); + +const render = (props: Partial[0]> = {}) => + renderWithQuery(); + +describe("LivePlayer", () => { + it("offers to our own route, plays the answer, and shows the picture", async () => { + const fetchMock = stubFetch({ [whep()]: () => answer() }); + render(); + expect(screen.getByRole("status").textContent).toContain("Connecting…"); + + await waitFor(() => expect(FakePeer.last.remoteDescription).toEqual({ type: "answer", sdp: "answer-sdp" })); + expect(FakePeer.last.transceivers).toEqual(["video", "audio"]); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe(`/api/cameras/${ID}/live/whep?stream=main`); + expect((init!.headers as Record)["Content-Type"]).toBe("application/sdp"); + expect(init!.body).toBe("offer-sdp"); + + // jsdom has no MediaStream; the player only passes it to