// This file is part of Moonfire NVR, a security camera network video recorder. // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception /** * @file Convenience wrapper around the Moonfire NVR API layer. * * See design/api.md for a description of the API. Some of the * documentation is copied into the docstrings here for convenience, but * that doc is authoritative. * * The functions here return a Typescript discriminating union of status. * This seems convenient for ensuring the caller handles all possibilities. */ import { Camera, Session } from "./types"; export type StreamType = "main" | "sub"; export interface FetchSuccess { status: "success"; response: T; } export interface FetchAborted { status: "aborted"; } export interface FetchError { status: "error"; message: string; httpStatus?: number; } export type FetchResult = FetchSuccess | FetchAborted | FetchError; async function myfetch( url: string, init: RequestInit ): Promise> { let response; try { response = await fetch(url, init); } catch (e) { if (e.name === "AbortError") { return { status: "aborted" }; } else { return { status: "error", message: `network error: ${e.message}`, }; } } if (!response.ok) { let text; try { text = await response.text(); } catch (e) { console.warn( `${url}: ${response.status}: unable to read body: ${e.message}` ); return { status: "error", httpStatus: response.status, message: `unable to read body: ${e.message}`, }; } return { status: "error", httpStatus: response.status, message: text, }; } console.debug(`${url}: ${response.status}`); return { status: "success", response, }; } /** Fetches an initialization segment. */ export async function init( videoSampleEntryId: number, init: RequestInit ): Promise> { const url = `/api/init/${videoSampleEntryId}.mp4`; const fetchRes = await myfetch(url, init); if (fetchRes.status !== "success") { return fetchRes; } let body; try { body = await fetchRes.response.arrayBuffer(); } catch (e) { console.warn(`${url}: unable to read body: ${e.message}`); return { status: "error", message: `unable to read body: ${e.message}`, }; } return { status: "success", response: body, }; } async function json( url: string, init: RequestInit ): Promise> { const fetchRes = await myfetch(url, init); if (fetchRes.status !== "success") { return fetchRes; } let body; try { body = await fetchRes.response.json(); } catch (e) { console.warn(`${url}: unable to read body: ${e.message}`); return { status: "error", message: `unable to read body: ${e.message}`, }; } return { status: "success", response: body, }; } export interface ToplevelResponse { timeZoneName: string; cameras: Camera[]; session: Session | undefined; } /** Fetches the top-level API data. */ export async function toplevel(init: RequestInit) { const resp = await json("/api/?days=true", init); if (resp.status === "success") { resp.response.cameras.forEach((c) => { for (const key in c.streams) { const s = c.streams[key as StreamType]!; s.camera = c; s.streamType = key as StreamType; } }); } return resp; } export interface LoginRequest { username: string; password: string; } /** Logs in. */ export async function login(req: LoginRequest, init: RequestInit) { return await myfetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(req), ...init, }); } export interface LogoutRequest { csrf: string; } /** Logs out. */ export async function logout(req: LogoutRequest, init: RequestInit) { return await myfetch("/api/logout", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(req), ...init, }); } /** * Represents a range of one or more recordings as in a single array entry of * GET /api/cameras/<uuid>/<stream>/<recordings>. */ export interface Recording { /** id of the first recording in this range. */ startId: number; /** * If present, indicates that recordings startId, endId (inclusive) * are described here. */ endId?: number; /** * If this range is not fully committed to the database, the first id that is * uncommitted. This is significant because it's possible that after a crash * and restart, this id will refer to a completely different recording. That * recording will have a different openId. */ firstUncommitted?: number; /** * If this boolean is true, the recording endId is still being written to. * Accesses to this id (such as view.mp4) may retrieve more data than * described here if not bounded by duration. Additionally, if startId == * endId, the start time of the recording is "unanchored" and may change in * subsequent accesses. */ growing?: boolean; /** * Each time Moonfire NVR starts in read-write mode, it is assigned an * increasing "open id". This field is the open id as of when these * recordings were written. This can be used to disambiguate ids referring to * uncommitted recordings. */ openId: number; /** * start time of the given recording, in the wall time scale. Note this * may be less than the requested startTime90k if this recording was ongoing * at the requested time. */ startTime90k: number; /** * end time of the given recording, in the wall time scale. Note this may be * greater than the requested endTime90k if this recording was ongoing at the * requested time. */ endTime90k: number; /** * a reference to an entry in the videoSampleEntries object. */ videoSampleEntryId: number; /** * the number of samples (aka frames) of video in this recording. */ videoSamples: number; /** * the number of bytes of video in this recording. */ sampleFileBytes: number; } export interface VideoSampleEntry { width: number; height: number; pixelHSpacing?: number; pixelVSpacing?: number; } export interface RecordingsRequest { cameraUuid: string; stream: StreamType; startTime90k?: number; endTime90k?: number; split90k?: number; } export interface RecordingsResponse { recordings: Recording[]; videoSampleEntries: { [id: number]: VideoSampleEntry }; } function withQuery(baseUrl: string, params: { [key: string]: any }): string { const p = new URLSearchParams(); for (const k in params) { const v = params[k]; if (v !== undefined) { p.append(k, v.toString()); } } const ps = p.toString(); return ps !== "" ? `${baseUrl}?${ps}` : baseUrl; } export async function recordings(req: RecordingsRequest, init: RequestInit) { const p = new URLSearchParams(); if (req.startTime90k !== undefined) { p.append("startTime90k", req.startTime90k.toString()); } if (req.endTime90k !== undefined) { p.append("endTime90k", req.endTime90k.toString()); } if (req.split90k !== undefined) { p.append("split90k", req.split90k.toString()); } const url = withQuery( `/api/cameras/${req.cameraUuid}/${req.stream}/recordings`, { startTime90k: req.startTime90k, endTime90k: req.endTime90k, split90k: req.split90k, } ); return await json(url, init); } /** * Returns a URL to a .mp4 of the given recording. * If trimToRange90k is supplied, the .mp4 will include * only the portion of the recording which overlaps with the given half-open * interval. */ export function recordingUrl( cameraUuid: string, stream: StreamType, r: Recording, timestampTrack: boolean, trimToRange90k?: [number, number] ): string { let s = `${r.startId}`; if (r.endId !== undefined) { s += `-${r.endId}`; } if (r.firstUncommitted !== undefined) { s += `@${r.openId}`; // disambiguate. } let rel = ""; if (trimToRange90k !== undefined && r.startTime90k < trimToRange90k[0]) { rel += trimToRange90k[0] - r.startTime90k; } rel += "-"; if (trimToRange90k !== undefined && r.endTime90k > trimToRange90k[1]) { rel += trimToRange90k[1] - r.startTime90k; } else if (r.growing) { // View just the portion described by recording, not anything added later. rel += r.endTime90k - r.startTime90k; } if (rel !== "-") { s += "." + rel; } return withQuery(`/api/cameras/${cameraUuid}/${stream}/view.mp4`, { s, ts: timestampTrack, }); }