Add a Recordings page to browse, play and delete motion clips
- /recordings lists clips newest first with camera, time and size, plays them in the browser and deletes after a confirmation. - /api/recordings lists clips; /api/recordings/clip serves one with Range support so seeking works, and deletes it. A clip is named by folder and file name, both matched against fixed patterns and resolved inside the recordings folder, so no request can reach another file. - Each camera's page links to its own clips. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4a0790565f
commit
4fc68cacf1
@ -879,3 +879,20 @@
|
||||
{"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 <id>\", MediaMTX \"[recorder] recording … tracks\", and \"[motion] stopped recording <id>\" after the post-roll. Files on disk: .data/recordings/cam-<id>-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"}
|
||||
{"id":"evt-s5abwkxw1wm1","type":"node.created","subject":"ver-ncyy24g","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"A session-checked Recordings page lists clips per camera newest first with time and size, plays them with seeking (Range requests), and deletes after a confirmation; clip ids are matched against fixed patterns and resolved inside the recordings folder, so no request can reach another file; the camera page links to its own clips","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T22:13:30.531Z","parents":["evt-gsf60sb8qp1v"],"hash":"cabdfc8bd5463c8acb6c382e14e8e1a7d26f15a68a13504cc9069374ce55cd0c"}
|
||||
{"id":"evt-n01s9wvxbbp7","type":"edge.added","subject":"ver-ncyy24g","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-ncyy24g","to":"iss-v677bbd"},"at":"2026-09-19T22:13:30.533Z","parents":["evt-s5abwkxw1wm1"],"hash":"58a3ffe785a9456e3d9773a387a4cb9f1855e601c43608c790c3cbb9e12898b6"}
|
||||
{"id":"evt-1hgpgz7w83rh","type":"verification.recorded","subject":"ver-ncyy24g","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/recordings.test.ts (listClips, clipFile traversal refusals, deleteClip), src/app/api/recordings/route.test.ts, src/app/api/recordings/clip/route.test.ts (full, range, suffix, 416, traversal), src/app/recordings/clip-list.test.tsx, src/app/recordings/page.test.tsx. Full suite 861 tests pass, 99.67% lines; tsc and eslint clean; next build lists ƒ /recordings, /api/recordings and /api/recordings/clip."},"at":"2026-09-19T22:13:30.534Z","parents":["evt-n01s9wvxbbp7"],"hash":"fbc9a2e411cb1de6b54f186f1a27a332ef6d651adf4eef1f72f7df69ff93a51e"}
|
||||
{"id":"evt-m27ze50m7ew2","type":"node.created","subject":"ver-aexbfgn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Real check by the user: a motion clip from each camera plays in the browser on /recordings, and deleting one works","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T22:13:32.598Z","parents":["evt-1hgpgz7w83rh"],"hash":"c5ef657ba791dd92636ecf742dd6248d77f79c49d20ebb65e8a8715f2432f861"}
|
||||
{"id":"evt-rgavza6239gj","type":"edge.added","subject":"ver-aexbfgn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-aexbfgn","to":"iss-v677bbd"},"at":"2026-09-19T22:13:32.599Z","parents":["evt-m27ze50m7ew2"],"hash":"9a6583fb04bbebc781101bc5a5d3ac538e45048ea2d0c39234968880eddd51ff"}
|
||||
{"id":"evt-8jf435p6dswr","type":"verification.recorded","subject":"ver-aexbfgn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pending","evidence":"User test after restarting the server: open /recordings, play a clip from each camera, delete one"},"at":"2026-09-19T22:13:32.600Z","parents":["evt-rgavza6239gj"],"hash":"4abc418fc138f427492f611f43968dcbbfa9a74f93bc4148c7aacb4abcfca654"}
|
||||
{"id":"evt-bmcdhjathg9p","type":"node.status_changed","subject":"iss-v677bbd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-19T22:13:34.671Z","parents":["evt-8jf435p6dswr"],"hash":"035e51380b988013c77356b04ed50ea7bf55555138e45d969fccaca0e15ca6df"}
|
||||
{"id":"evt-cyh6g3kt46hq","type":"node.created","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Recorded G711 audio from camera …af54 does play in the browser, alongside H.264 video","body":"Corrects the caveat in fnd-y7h6pyf, which assumed browsers could not decode G711 inside MP4. On 2026-09-19 the user played motion clips on /recordings and reported that audio worked. So MediaMTX's fMP4 recordings of a G711 (A-law) track are playable in the user's browser as written, with no transcoding, and no audio conversion is needed for playback. Camera …af54 publishes H264 plus G711 on its sub stream; …af2e publishes video only. Untested: whether other browsers behave the same, and whether audio also arrives over the live WebRTC player.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"iss-v677bbd","note":"User's own playback on /recordings: \"this works well and audio seems to work too\""}],"as_of":"2026-09-19"}},"at":"2026-09-19T22:15:57.638Z","parents":["evt-bmcdhjathg9p"],"hash":"2334e4b4fe34f75664847d726b4a3c702108b6ca6d1fdcc9f5ae51c32e5167c4"}
|
||||
{"id":"evt-pvzz20apprz9","type":"edge.added","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-0qyrzm9","to":"que-ph8eak5"},"at":"2026-09-19T22:15:57.639Z","parents":["evt-cyh6g3kt46hq"],"hash":"6624e96f367c3a826ba21949c49f3814cc8ae61e22b7110fe04f5efc055d4971"}
|
||||
{"id":"evt-8nwppxpaj613","type":"edge.added","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-0qyrzm9","to":"iss-td8y1cc"},"at":"2026-09-19T22:15:57.640Z","parents":["evt-pvzz20apprz9"],"hash":"c4741584dd29cd005f2c0ecf7f3cdcfe61426abf482ef3664d29d70657b68877"}
|
||||
{"id":"evt-k5wqxg4b8t29","type":"edge.added","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-0qyrzm9","to":"gol-sxakryh"},"at":"2026-09-19T22:15:57.641Z","parents":["evt-8nwppxpaj613"],"hash":"3165e4ebf290ddec762097f256959ee4b0b5b6393808d69d2e532d86925b9e54"}
|
||||
{"id":"evt-kgedg91yjbht","type":"edge.added","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"supersedes","from":"fnd-0qyrzm9","to":"fnd-y7h6pyf"},"at":"2026-09-19T22:15:57.642Z","parents":["evt-k5wqxg4b8t29"],"hash":"f9a55af6f43f68b49eac03d7b09fe302480032fcd3f66ab9429da2204b9d95d5"}
|
||||
{"id":"evt-kxff6phn2yet","type":"node.status_changed","subject":"fnd-y7h6pyf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"current","to":"superseded"},"at":"2026-09-19T22:15:57.643Z","parents":["evt-kgedg91yjbht"],"hash":"bba33d5a64fcf42c2a472d188960e9fc659f97d2a1cae7b53ba1748632607756"}
|
||||
{"id":"evt-q6y6kzp0x3rt","type":"node.created","subject":"ver-60m8ttp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Real check by the user: motion clips play in the browser on /recordings","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T22:16:00.004Z","parents":["evt-kxff6phn2yet"],"hash":"bacbde6fdba262183500c21401a8349287c68fb122a0ed4e2b075b4f0142bb47"}
|
||||
{"id":"evt-xah5c4gbg51b","type":"edge.added","subject":"ver-60m8ttp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-60m8ttp","to":"iss-v677bbd"},"at":"2026-09-19T22:16:00.005Z","parents":["evt-q6y6kzp0x3rt"],"hash":"5fc6c47a5ee1452b99e37a9e03b0535035c0b7d7501c54a831a3abc321bdeab5"}
|
||||
{"id":"evt-tvjs688dbkvd","type":"verification.recorded","subject":"ver-60m8ttp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"User on 2026-09-19: \"this works well and audio seems to work too\" after opening /recordings and playing clips."},"at":"2026-09-19T22:16:00.006Z","parents":["evt-xah5c4gbg51b"],"hash":"3d1b87655feb999a73e54d75a528a625c905420e09c088f434a4034416ccfd63"}
|
||||
{"id":"evt-rd8yca1xcevk","type":"node.status_changed","subject":"iss-v677bbd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"in_progress","to":"done"},"at":"2026-09-19T22:16:01.993Z","parents":["evt-tvjs688dbkvd"],"hash":"7a448702e6738eb96202a1e82f57a913a64dfb18b154a98b2fcd0246879c0f68"}
|
||||
|
||||
83
src/app/api/recordings/clip/route.test.ts
Normal file
83
src/app/api/recordings/clip/route.test.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Access control is tested in src/app/api/access.test.ts; here requests are allowed.
|
||||
vi.mock("@/lib/access", () => ({ apiAccessDenied: async () => null }));
|
||||
|
||||
const { GET, DELETE } = await import("./route");
|
||||
|
||||
const ID = "11111111-2222-3333-4444-555555555555";
|
||||
const NAME = "2026-09-19_17-06-18-101298.mp4";
|
||||
const CLIP = `cam-${ID}-sub-rec/${NAME}`;
|
||||
const BODY = "0123456789";
|
||||
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(os.tmpdir(), "clip-route-"));
|
||||
vi.stubEnv("RECORDINGS_DIR", dir);
|
||||
const folder = path.join(dir, `cam-${ID}-sub-rec`);
|
||||
await (await import("node:fs/promises")).mkdir(folder, { recursive: true });
|
||||
await writeFile(path.join(folder, NAME), BODY);
|
||||
});
|
||||
afterEach(() => rm(dir, { recursive: true, force: true }));
|
||||
|
||||
const url = (clip = CLIP) => `http://localhost/api/recordings/clip?clip=${encodeURIComponent(clip)}`;
|
||||
const get = (clip?: string, headers?: HeadersInit) => GET(new Request(url(clip), { headers }));
|
||||
const text = async (res: Response) => Buffer.from(await res.arrayBuffer()).toString();
|
||||
|
||||
describe("GET /api/recordings/clip", () => {
|
||||
it("serves the whole clip as seekable video", async () => {
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("video/mp4");
|
||||
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
expect(res.headers.get("Content-Length")).toBe(String(BODY.length));
|
||||
expect(res.headers.get("Cache-Control")).toBe("no-store");
|
||||
expect(await text(res)).toBe(BODY);
|
||||
});
|
||||
|
||||
it("serves a byte range so the player can seek", async () => {
|
||||
const res = await get(CLIP, { range: "bytes=2-5" });
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers.get("Content-Range")).toBe(`bytes 2-5/${BODY.length}`);
|
||||
expect(res.headers.get("Content-Length")).toBe("4");
|
||||
expect(await text(res)).toBe("2345");
|
||||
});
|
||||
|
||||
it("serves an open-ended range and a suffix range", async () => {
|
||||
expect(await text(await get(CLIP, { range: "bytes=7-" }))).toBe("789");
|
||||
expect(await text(await get(CLIP, { range: "bytes=-3" }))).toBe("789");
|
||||
});
|
||||
|
||||
it("refuses a range past the end", async () => {
|
||||
const res = await get(CLIP, { range: "bytes=50-60" });
|
||||
expect(res.status).toBe(416);
|
||||
expect(res.headers.get("Content-Range")).toBe(`bytes */${BODY.length}`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a traversal", "../../etc/passwd"],
|
||||
["a folder that isn't ours", `secrets/${NAME}`],
|
||||
["a name that isn't a clip", `cam-${ID}-sub-rec/passwd`],
|
||||
["a clip that doesn't exist", `cam-${ID}-main-rec/${NAME}`],
|
||||
["nothing", ""],
|
||||
])("is not found for %s", async (_l, clip) => {
|
||||
expect((await get(clip)).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/recordings/clip", () => {
|
||||
it("deletes the clip", async () => {
|
||||
expect((await DELETE(new Request(url(), { method: "DELETE" }))).status).toBe(204);
|
||||
expect((await get()).status).toBe(404);
|
||||
});
|
||||
|
||||
it.each([["../../etc/passwd"], [""], [`cam-${ID}-sub-rec/2026-01-01_00-00-00-000000.mp4`]])(
|
||||
"is not found for %j",
|
||||
async (clip) => {
|
||||
expect((await DELETE(new Request(url(clip), { method: "DELETE" }))).status).toBe(404);
|
||||
},
|
||||
);
|
||||
});
|
||||
64
src/app/api/recordings/clip/route.ts
Normal file
64
src/app/api/recordings/clip/route.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import type { ReadableOptions } from "node:stream";
|
||||
import { apiAccessDenied } from "@/lib/access";
|
||||
import { clipFile, deleteClip } from "@/lib/recordings";
|
||||
|
||||
/**
|
||||
* Plays or deletes one motion clip (vrek iss-v677bbd). The ?clip= id is matched against
|
||||
* fixed patterns and resolved inside the recordings folder, so a request can only name a
|
||||
* clip that folder actually holds (pri-m1csgrm).
|
||||
*/
|
||||
|
||||
function fileFor(request: Request): string | null {
|
||||
return clipFile(new URL(request.url).searchParams.get("clip") ?? "");
|
||||
}
|
||||
|
||||
/** Node's read stream as a web stream, for one byte range. */
|
||||
function bodyFor(file: string, options: ReadableOptions & { start?: number; end?: number }) {
|
||||
return createReadStream(file, options) as unknown as ReadableStream;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const denied = await apiAccessDenied();
|
||||
if (denied) return denied;
|
||||
const file = fileFor(request);
|
||||
const info = file ? await stat(file).catch(() => null) : null;
|
||||
if (!info?.isFile()) return Response.json({ error: "No such clip" }, { status: 404 });
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "video/mp4",
|
||||
"Cache-Control": "no-store",
|
||||
"Accept-Ranges": "bytes",
|
||||
};
|
||||
|
||||
// Seeking in a <video> needs range requests.
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get("range") ?? "");
|
||||
if (range) {
|
||||
const start = range[1] ? Number(range[1]) : Math.max(0, info.size - Number(range[2] || 0));
|
||||
const end = range[1] && range[2] ? Math.min(Number(range[2]), info.size - 1) : info.size - 1;
|
||||
if (start >= info.size || start > end) {
|
||||
return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${info.size}` } });
|
||||
}
|
||||
return new Response(bodyFor(file!, { start, end }), {
|
||||
status: 206,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Range": `bytes ${start}-${end}/${info.size}`,
|
||||
"Content-Length": String(end - start + 1),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(bodyFor(file!, {}), {
|
||||
headers: { ...headers, "Content-Length": String(info.size) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const denied = await apiAccessDenied();
|
||||
if (denied) return denied;
|
||||
const clip = new URL(request.url).searchParams.get("clip") ?? "";
|
||||
if (!(await deleteClip(clip))) return Response.json({ error: "No such clip" }, { status: 404 });
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
34
src/app/api/recordings/route.test.ts
Normal file
34
src/app/api/recordings/route.test.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Clip } from "@/lib/recordings";
|
||||
|
||||
const listClips = vi.fn<(cameraId?: string) => Promise<Clip[]>>();
|
||||
// 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/recordings", () => ({ listClips }));
|
||||
|
||||
const { GET } = await import("./route");
|
||||
|
||||
const ID = "11111111-2222-3333-4444-555555555555";
|
||||
const clip: Clip = { id: `cam-${ID}-sub-rec/2026-09-19_17-06-18-101298.mp4`, cameraId: ID, startedAt: "2026-09-19T22:06:18.000Z", bytes: 2582503 };
|
||||
const get = (query = "") => GET(new Request(`http://localhost/api/recordings${query}`));
|
||||
|
||||
beforeEach(() => listClips.mockReset().mockResolvedValue([clip]));
|
||||
|
||||
describe("GET /api/recordings", () => {
|
||||
it("lists every clip, uncached", async () => {
|
||||
const res = await get();
|
||||
expect(res.headers.get("Cache-Control")).toBe("no-store");
|
||||
expect(await res.json()).toEqual({ clips: [clip] });
|
||||
expect(listClips).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("narrows to one camera", async () => {
|
||||
await get(`?camera=${ID}`);
|
||||
expect(listClips).toHaveBeenCalledWith(ID);
|
||||
});
|
||||
|
||||
it.each(["?camera=../etc", "?camera=", "?camera=nonsense"])("ignores a bad %s", async (query) => {
|
||||
await get(query);
|
||||
expect(listClips).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
12
src/app/api/recordings/route.ts
Normal file
12
src/app/api/recordings/route.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { apiAccessDenied } from "@/lib/access";
|
||||
import { cameraIdSchema } from "@/lib/camera-registry";
|
||||
import { listClips } from "@/lib/recordings";
|
||||
|
||||
/** Motion clips on disk (vrek iss-v677bbd); ?camera=<id> narrows it to one camera. */
|
||||
export async function GET(request: Request) {
|
||||
const denied = await apiAccessDenied();
|
||||
if (denied) return denied;
|
||||
const camera = z.union([cameraIdSchema, z.null()]).catch(null).parse(new URL(request.url).searchParams.get("camera"));
|
||||
return Response.json({ clips: await listClips(camera ?? undefined) }, { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { ArrowLeft, Cctv, LoaderCircle } from "lucide-react";
|
||||
import { ArrowLeft, Cctv, Film, LoaderCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
@ -39,7 +39,16 @@ export default async function CameraPage({ params }: PageProps<"/cameras/[id]">)
|
||||
{record.location ? <span className="font-sans"> · {record.location}</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
<RefreshButton />
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/recordings?camera=${record.id}`}
|
||||
className="inline-flex items-center gap-1 text-sm underline"
|
||||
>
|
||||
<Film aria-hidden className="size-4" />
|
||||
Recordings
|
||||
</Link>
|
||||
<RefreshButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
|
||||
101
src/app/recordings/clip-list.test.tsx
Normal file
101
src/app/recordings/clip-list.test.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Clip } from "@/lib/recordings";
|
||||
import { stubFetch } from "../../../test/dom";
|
||||
import ClipList from "./clip-list";
|
||||
|
||||
const refresh = vi.fn();
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh }) }));
|
||||
|
||||
const A = "11111111-2222-3333-4444-555555555555";
|
||||
const B = "22ec0000-8b90-11b5-845d-d03bf404af54";
|
||||
const clips: Clip[] = [
|
||||
{ id: `cam-${A}-sub-rec/2026-09-19_17-06-18-101298.mp4`, cameraId: A, startedAt: "2026-09-19T22:06:18.000Z", bytes: 2_582_503 },
|
||||
{ id: `cam-${B}-sub-rec/2026-09-19_08-00-00-000000.mp4`, cameraId: B, startedAt: "2026-09-19T13:00:00.000Z", bytes: 51_200 },
|
||||
];
|
||||
const names = { [A]: "Porch", [B]: "Garage" };
|
||||
const list = (some = clips) => render(<ClipList clips={some} names={names} />);
|
||||
const clipUrl = (clip: Clip) => `/api/recordings/clip?clip=${encodeURIComponent(clip.id)}`;
|
||||
|
||||
beforeEach(() => refresh.mockReset());
|
||||
|
||||
describe("ClipList", () => {
|
||||
it("lists each clip with its camera, time and size", () => {
|
||||
list();
|
||||
expect(screen.getByText("Porch")).toBeTruthy();
|
||||
expect(screen.getByText("Garage")).toBeTruthy();
|
||||
expect(screen.getByText("2.5 MB")).toBeTruthy();
|
||||
expect(screen.getByText("50 KB")).toBeTruthy();
|
||||
expect(document.querySelectorAll("time")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("says so when there is nothing recorded", () => {
|
||||
list([]);
|
||||
expect(screen.getByText(/No clips yet/)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Play" })).toBeNull();
|
||||
});
|
||||
|
||||
it("names a camera that is no longer in the registry", () => {
|
||||
render(<ClipList clips={clips} names={{}} />);
|
||||
expect(screen.getAllByText("Unknown camera")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("plays a clip from our own route and closes the player again", () => {
|
||||
list();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Play" })[0]);
|
||||
const video = document.querySelector("video")!;
|
||||
expect(video.getAttribute("src")).toBe(clipUrl(clips[0]));
|
||||
expect(screen.getByRole("heading").textContent).toContain("Porch");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(document.querySelector("video")).toBeNull();
|
||||
});
|
||||
|
||||
it("asks before deleting, and can be cancelled", async () => {
|
||||
const fetchMock = stubFetch({});
|
||||
list();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]);
|
||||
expect(screen.getByRole("alertdialog", { name: "Delete this clip?" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes a clip, closes it if it was playing, and reloads the list", async () => {
|
||||
const fetchMock = stubFetch({
|
||||
[`DELETE ${clipUrl(clips[0]).split("?")[0]}`]: () => new Response(null, { status: 204 }),
|
||||
});
|
||||
list();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Play" })[0]);
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]);
|
||||
fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalled());
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(clipUrl(clips[0]));
|
||||
expect(document.querySelector("video")).toBeNull();
|
||||
});
|
||||
|
||||
it("says when a delete fails, and keeps the clip listed", async () => {
|
||||
stubFetch({
|
||||
[`DELETE ${clipUrl(clips[0]).split("?")[0]}`]: () => new Response(null, { status: 500 }),
|
||||
});
|
||||
list();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]);
|
||||
fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("could not be deleted");
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Porch")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("treats a clip that is already gone as deleted", async () => {
|
||||
stubFetch({
|
||||
[`DELETE ${clipUrl(clips[0]).split("?")[0]}`]: () => new Response(null, { status: 404 }),
|
||||
});
|
||||
list();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]);
|
||||
fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }));
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
111
src/app/recordings/clip-list.tsx
Normal file
111
src/app/recordings/clip-list.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { CircleAlert, Film, Play, Trash2, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import type { Clip } from "@/lib/recordings";
|
||||
|
||||
/** "2.4 MB" */
|
||||
function sizeLabel(bytes: number): string {
|
||||
return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
|
||||
}
|
||||
|
||||
const clipUrl = (id: string) => `/api/recordings/clip?clip=${encodeURIComponent(id)}`;
|
||||
|
||||
export default function ClipList({ clips, names }: { clips: Clip[]; names: Record<string, string> }) {
|
||||
const router = useRouter();
|
||||
const [playing, setPlaying] = useState<Clip | null>(null);
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function remove(clip: Clip) {
|
||||
setConfirming(null);
|
||||
setError(null);
|
||||
const res = await fetch(clipUrl(clip.id), { method: "DELETE" }).catch(() => null);
|
||||
if (!res || (!res.ok && res.status !== 404)) {
|
||||
setError("That clip could not be deleted.");
|
||||
return;
|
||||
}
|
||||
if (playing?.id === clip.id) setPlaying(null);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (clips.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
No clips yet. One is recorded whenever a camera reports motion.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{playing && (
|
||||
<div className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-medium">
|
||||
{names[playing.cameraId] ?? "Unknown camera"} · {new Date(playing.startedAt).toLocaleString()}
|
||||
</h2>
|
||||
<button onClick={() => setPlaying(null)} className="inline-flex items-center gap-1 text-sm underline">
|
||||
<X aria-hidden className="size-4" />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<video key={playing.id} src={clipUrl(playing.id)} controls autoPlay className="mt-2 w-full rounded bg-black" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="inline-flex items-center gap-1.5 text-sm text-red-600">
|
||||
<CircleAlert aria-hidden className="size-4 shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="flex flex-col gap-2">
|
||||
{clips.map((clip) => (
|
||||
<li
|
||||
key={clip.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded border border-zinc-200 px-3 py-2 text-sm dark:border-zinc-800"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Film aria-hidden className="size-4 shrink-0 text-zinc-500" />
|
||||
<span>
|
||||
<span className="font-medium">{names[clip.cameraId] ?? "Unknown camera"}</span>{" "}
|
||||
<time dateTime={clip.startedAt}>{new Date(clip.startedAt).toLocaleString()}</time>
|
||||
</span>
|
||||
<span className="text-zinc-500">{sizeLabel(clip.bytes)}</span>
|
||||
</span>
|
||||
|
||||
{confirming === clip.id ? (
|
||||
<span role="alertdialog" aria-label="Delete this clip?" className="flex items-center gap-2">
|
||||
<span>Delete this clip?</span>
|
||||
<button
|
||||
onClick={() => remove(clip)}
|
||||
className="inline-flex items-center gap-1 rounded bg-red-600 px-2 py-1 text-white"
|
||||
>
|
||||
<Trash2 aria-hidden className="size-4" />
|
||||
Delete
|
||||
</button>
|
||||
<button onClick={() => setConfirming(null)} className="underline">
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-3">
|
||||
<button onClick={() => setPlaying(clip)} className="inline-flex items-center gap-1 underline">
|
||||
<Play aria-hidden className="size-4" />
|
||||
Play
|
||||
</button>
|
||||
<button onClick={() => setConfirming(clip.id)} className="inline-flex items-center gap-1 underline">
|
||||
<Trash2 aria-hidden className="size-4" />
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/app/recordings/page.test.tsx
Normal file
68
src/app/recordings/page.test.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Clip } from "@/lib/recordings";
|
||||
|
||||
const requirePageAccess = vi.fn();
|
||||
vi.mock("@/lib/access", () => ({ requirePageAccess }));
|
||||
|
||||
const listClips = vi.fn<(id?: string) => Promise<Clip[]>>();
|
||||
const allCameraRecords = vi.fn();
|
||||
vi.mock("@/lib/recordings", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/recordings")>()),
|
||||
listClips,
|
||||
}));
|
||||
vi.mock("@/lib/camera-registry", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/camera-registry")>()),
|
||||
allCameraRecords,
|
||||
}));
|
||||
vi.mock("./clip-list", () => ({
|
||||
default: (props: object) => <div data-testid="clips">{JSON.stringify(props)}</div>,
|
||||
}));
|
||||
|
||||
const { default: RecordingsPage } = await import("./page");
|
||||
|
||||
const ID = "22ec0000-8b90-11b5-845d-d03bf404af54";
|
||||
const clip: Clip = { id: `cam-${ID}-sub-rec/2026-09-19_17-06-18-101298.mp4`, cameraId: ID, startedAt: "2026-09-19T22:06:18.000Z", bytes: 1000 };
|
||||
const record = { id: ID, urn: `urn:uuid:${ID}`, host: "192.168.17.129", port: 80, name: "I91ET", lastSeen: "" };
|
||||
const page = (camera?: string) =>
|
||||
RecordingsPage({ params: Promise.resolve({}), searchParams: Promise.resolve(camera === undefined ? {} : { camera }) });
|
||||
const markup = async (camera?: string) => renderToStaticMarkup(await page(camera)).replace(/"/g, '"');
|
||||
|
||||
beforeEach(() => {
|
||||
requirePageAccess.mockReset().mockResolvedValue(undefined);
|
||||
listClips.mockReset().mockResolvedValue([clip]);
|
||||
allCameraRecords.mockReset().mockResolvedValue([record]);
|
||||
});
|
||||
|
||||
describe("/recordings", () => {
|
||||
it("checks access before reading anything", async () => {
|
||||
requirePageAccess.mockRejectedValue(new Error("redirect /login"));
|
||||
await expect(page()).rejects.toThrow("redirect /login");
|
||||
expect(listClips).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes every clip and the camera names to the list", async () => {
|
||||
const html = await markup();
|
||||
expect(listClips).toHaveBeenCalledWith(undefined);
|
||||
expect(html).toContain(JSON.stringify({ clips: [clip], names: { [ID]: "I91ET" } }));
|
||||
expect(html).toContain("sub stream");
|
||||
expect(html).toContain("7 days");
|
||||
});
|
||||
|
||||
it("narrows to one camera and offers a way back to all of them", async () => {
|
||||
const html = await markup(ID);
|
||||
expect(listClips).toHaveBeenCalledWith(ID);
|
||||
expect(html).toContain("Recordings: I91ET");
|
||||
expect(html).toContain('href="/recordings"');
|
||||
});
|
||||
|
||||
it("ignores a camera parameter that isn't an id", async () => {
|
||||
await markup("../etc");
|
||||
expect(listClips).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("falls back to the address for an unnamed camera", async () => {
|
||||
allCameraRecords.mockResolvedValue([{ ...record, name: undefined }]);
|
||||
expect(await markup(ID)).toContain("Recordings: 192.168.17.129:80");
|
||||
});
|
||||
});
|
||||
45
src/app/recordings/page.tsx
Normal file
45
src/app/recordings/page.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { ArrowLeft, Film } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { requirePageAccess } from "@/lib/access";
|
||||
import { allCameraRecords } from "@/lib/camera-registry";
|
||||
import { cameraIdSchema } from "@/lib/camera-registry";
|
||||
import { keepDays, listClips, recordStream } from "@/lib/recordings";
|
||||
import ClipList from "./clip-list";
|
||||
|
||||
/** Motion clips, newest first (vrek iss-v677bbd). */
|
||||
export default async function RecordingsPage({ searchParams }: PageProps<"/recordings">) {
|
||||
await requirePageAccess();
|
||||
const { camera } = await searchParams;
|
||||
const only = cameraIdSchema.safeParse(camera).data;
|
||||
const [clips, cameras] = await Promise.all([listClips(only), allCameraRecords()]);
|
||||
const names = Object.fromEntries(cameras.map((c) => [c.id, c.name ?? `${c.host}:${c.port}`]));
|
||||
const shown = only ? names[only] : null;
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-4xl flex-1 px-4 py-8 font-sans">
|
||||
<Link href="/" className="inline-flex items-center gap-1 text-sm text-zinc-600 underline dark:text-zinc-400">
|
||||
<ArrowLeft aria-hidden className="size-4" />
|
||||
All cameras
|
||||
</Link>
|
||||
<h1 className="mt-4 flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<Film aria-hidden className="size-6" />
|
||||
Recordings{shown ? `: ${shown}` : ""}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Clips are recorded from each camera's {recordStream()} stream while it reports motion, and
|
||||
deleted after {keepDays()} days.
|
||||
</p>
|
||||
{only && (
|
||||
<p className="mt-1 text-sm">
|
||||
<Link href="/recordings" className="underline">
|
||||
Show every camera
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<ClipList clips={clips} names={names} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,10 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cameraIdFromFolder,
|
||||
clipFile,
|
||||
clipFolder,
|
||||
deleteClip,
|
||||
listClips,
|
||||
DEFAULT_KEEP_DAYS,
|
||||
keepDays,
|
||||
motionRecordingEnabled,
|
||||
@ -112,3 +115,74 @@ describe("sweepOldRecordings", () => {
|
||||
expect(await sweepOldRecordings(NOW)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listClips", () => {
|
||||
const other = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
it("lists every camera's clips newest first, with size and start time", async () => {
|
||||
await clip(ID, "2026-09-19_17-06-18-101298.mp4", 0.1);
|
||||
await clip(other, "2026-09-18_08-00-00-000000.mp4", 1.4);
|
||||
const clips = await listClips();
|
||||
|
||||
expect(clips.map((c) => c.cameraId)).toEqual([ID, other]);
|
||||
expect(clips[0]).toMatchObject({ id: `cam-${ID}-sub-rec/2026-09-19_17-06-18-101298.mp4`, bytes: 5 });
|
||||
const started = new Date(clips[0].startedAt);
|
||||
expect([started.getFullYear(), started.getMonth() + 1, started.getDate(), started.getHours()]).toEqual([
|
||||
2026, 9, 19, 17,
|
||||
]);
|
||||
});
|
||||
|
||||
it("can list one camera only", async () => {
|
||||
await clip(ID, "2026-09-19_17-06-18-101298.mp4", 0.1);
|
||||
await clip(other, "2026-09-19_17-00-00-000000.mp4", 0.1);
|
||||
expect((await listClips(ID)).map((c) => c.cameraId)).toEqual([ID]);
|
||||
});
|
||||
|
||||
it("ignores folders and files that aren't clips of ours", async () => {
|
||||
await mkdir(path.join(dir, "not-ours"), { recursive: true });
|
||||
await writeFile(path.join(dir, "not-ours", "2026-09-19_17-06-18-101298.mp4"), "x");
|
||||
await clip(ID, "notes.txt", 0.1);
|
||||
await clip(ID, "2026-09-19_17-06-18-10129.mp4", 0.1);
|
||||
expect(await listClips()).toEqual([]);
|
||||
});
|
||||
|
||||
it("is empty when nothing has been recorded", async () => {
|
||||
expect(await listClips()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipFile", () => {
|
||||
it("resolves a clip inside the recordings folder", () => {
|
||||
const id = `cam-${ID}-sub-rec/2026-09-19_17-06-18-101298.mp4`;
|
||||
expect(clipFile(id)).toBe(path.join(dir, id));
|
||||
expect(clipFile(id, ID)).toBe(path.join(dir, id));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["another camera's clip", `cam-${ID}-sub-rec/2026-09-19_17-06-18-101298.mp4`, "11111111-2222-3333-4444-555555555555"],
|
||||
["a folder that isn't ours", "etc/2026-09-19_17-06-18-101298.mp4", undefined],
|
||||
["a traversal in the folder", `../../etc/2026-09-19_17-06-18-101298.mp4`, undefined],
|
||||
["a traversal in the name", `cam-${ID}-sub-rec/../../../etc/passwd`, undefined],
|
||||
["a name that isn't a clip", `cam-${ID}-sub-rec/passwd`, undefined],
|
||||
["extra path segments", `cam-${ID}-sub-rec/x/2026-09-19_17-06-18-101298.mp4`, undefined],
|
||||
["nothing", "", undefined],
|
||||
])("refuses %s", (_l, id, cameraId) => {
|
||||
expect(clipFile(id, cameraId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteClip", () => {
|
||||
it("deletes one clip and leaves the rest", async () => {
|
||||
await clip(ID, "2026-09-19_17-06-18-101298.mp4", 0.1);
|
||||
await clip(ID, "2026-09-19_17-07-00-000000.mp4", 0.1);
|
||||
expect(await deleteClip(`cam-${ID}-sub-rec/2026-09-19_17-06-18-101298.mp4`)).toBe(true);
|
||||
expect((await listClips()).map((c) => c.id)).toEqual([`cam-${ID}-sub-rec/2026-09-19_17-07-00-000000.mp4`]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an id that isn't ours", "../../etc/passwd"],
|
||||
["a clip that is already gone", `cam-${ID}-sub-rec/2026-09-19_17-06-18-101298.mp4`],
|
||||
])("says no for %s", async (_l, id) => {
|
||||
expect(await deleteClip(id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -79,3 +79,67 @@ export async function sweepOldRecordings(now = Date.now(), days = keepDays()): P
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/** A clip's file name, as MediaMTX writes it: 2026-09-19_17-06-18-101298.mp4 */
|
||||
const CLIP_NAME = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})-(\d{6})\.mp4$/;
|
||||
|
||||
export interface Clip {
|
||||
/** Folder plus file name, the only handle the browser gets. */
|
||||
id: string;
|
||||
cameraId: string;
|
||||
/** When the recording started, from the file name (local time), else the file's time. */
|
||||
startedAt: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/** Parses MediaMTX's file name; the parts are local time, as the recorder writes them. */
|
||||
function startedAt(name: string, fallbackMs: number): string {
|
||||
const parts = CLIP_NAME.exec(name);
|
||||
if (!parts) return new Date(fallbackMs).toISOString();
|
||||
const [, y, mo, d, h, mi, sec] = parts;
|
||||
return new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(sec)).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every clip on disk, newest first, optionally for one camera. Anything that isn't one of
|
||||
* our folders or a clip file is ignored.
|
||||
*/
|
||||
export async function listClips(cameraId?: string): Promise<Clip[]> {
|
||||
const root = recordingsDir();
|
||||
const clips: Clip[] = [];
|
||||
for (const folder of await readdir(root, { withFileTypes: true }).catch(() => [])) {
|
||||
if (!folder.isDirectory()) continue;
|
||||
const owner = cameraIdFromFolder(folder.name);
|
||||
if (!owner || (cameraId && owner !== cameraId)) continue;
|
||||
for (const name of await readdir(path.join(root, folder.name)).catch(() => [])) {
|
||||
if (!CLIP_NAME.test(name)) continue;
|
||||
const info = await stat(path.join(root, folder.name, name)).catch(() => null);
|
||||
if (!info?.isFile()) continue;
|
||||
clips.push({ id: `${folder.name}/${name}`, cameraId: owner, startedAt: startedAt(name, info.mtimeMs), bytes: info.size });
|
||||
}
|
||||
}
|
||||
return clips.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* The file a clip id names, or null if the id isn't a clip of ours. Both halves are
|
||||
* checked against fixed patterns, so a request can never reach outside the folder
|
||||
* (pri-m1csgrm).
|
||||
*/
|
||||
export function clipFile(id: string, cameraId?: string): string | null {
|
||||
const [folder, name, ...rest] = id.split("/");
|
||||
if (rest.length || !folder || !name || !CLIP_NAME.test(name)) return null;
|
||||
const owner = cameraIdFromFolder(folder);
|
||||
if (!owner || (cameraId && owner !== cameraId)) return null;
|
||||
return path.join(recordingsDir(), folder, name);
|
||||
}
|
||||
|
||||
/** Deletes a clip. Returns false if the id isn't one of ours or the file is already gone. */
|
||||
export async function deleteClip(id: string): Promise<boolean> {
|
||||
const file = clipFile(id);
|
||||
if (!file) return false;
|
||||
const info = await stat(/* turbopackIgnore: true */ file).catch(() => null);
|
||||
if (!info?.isFile()) return false;
|
||||
await rm(/* turbopackIgnore: true */ file, { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user