cameras/src/lib/video.ts
Michael Mainguy 0556b3913e Prepare to publish: onvif-dashboard under MIT, paths in one module
- Rename the package to onvif-dashboard, drop private, add the MIT
  LICENSE and the metadata npm needs; a files list keeps the tarball to
  473 kB instead of sweeping in the MediaMTX binary.
- src/lib/paths.ts now decides where everything lives, so the app can run
  from any directory. CAMERAS_DATA_DIR moves the data folder;
  CAMERAS_BIN_DIR moves the helper binaries; every existing per-file
  override still wins, and the defaults are exactly what running from the
  repo meant before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 18:55:08 -05:00

194 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import "server-only";
import { randomBytes } from "node:crypto";
import { rmSync } from "node:fs";
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { rtspSourceWithLogin, type CameraTarget } from "./camera";
import {
mediamtxApiUrl,
mediamtxConfig,
pathConfig,
pathName,
recordingPathConfig,
recordPathName,
type ApiLogin,
type StreamKind,
} from "./mediamtx-config";
import { recordingsDir } from "./recordings";
import { mediamtxBinary as mediamtxBinaryPath, videoBridgeDir } from "./paths";
import { forgetPid, PID_FILE, recordPid, stopLeftoverMediamtx } from "./mediamtx-leftover";
import { MediamtxSupervisor, type BridgeStatus } from "./mediamtx-supervisor";
/**
* The local video bridge (vrek gol-sxakryh, dec-xkn4z0e): starts MediaMTX with a
* localhost-only config and a per-boot API password, and adds camera streams to it on
* demand. Kept on globalThis because instrumentation.ts and the app are bundled
* separately; both must see the same bridge.
*/
export class VideoBridgeError extends Error {
name = "VideoBridgeError";
}
interface Bridge {
supervisor: MediamtxSupervisor;
login: ApiLogin;
/** Path name → source it was last configured with, to skip no-op updates. */
paths: Map<string, string>;
/** The restart count those paths belong to; a restarted MediaMTX has none. */
pathsEpoch: number;
}
const KEY = Symbol.for("cameras.videoBridge");
const g = globalThis as { [KEY]?: Bridge };
export function mediamtxBinary(): string {
return mediamtxBinaryPath();
}
function bridgeDir(): string {
return videoBridgeDir();
}
function authHeader(login: ApiLogin): string {
return `Basic ${Buffer.from(`${login.user}:${login.pass}`).toString("base64")}`;
}
/** Basic auth for MediaMTX's localhost endpoints (API and WebRTC signaling). */
export function mediamtxAuthHeader(): string {
const bridge = g[KEY];
if (!bridge) throw new VideoBridgeError("Video bridge not started");
return authHeader(bridge.login);
}
async function api(login: ApiLogin, route: string, init?: RequestInit): Promise<Response> {
return fetch(mediamtxApiUrl(route), {
...init,
headers: { ...init?.headers, Authorization: authHeader(login) },
signal: AbortSignal.timeout(5_000),
});
}
/** Polls MediaMTX's API until it answers, or rejects after about `attempts × intervalMs`. */
export async function waitForApi(login: ApiLogin, attempts = 40, intervalMs = 250): Promise<void> {
for (let i = 0; i < attempts; i++) {
const res = await api(login, "/v3/info").catch(() => null);
if (res?.ok) return;
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new VideoBridgeError("MediaMTX API did not answer");
}
/** Writes the config owner-only, atomically. */
async function writeConfig(dir: string, text: string): Promise<string> {
await mkdir(dir, { recursive: true, mode: 0o700 });
await chmod(dir, 0o700);
const file = path.join(dir, "mediamtx.yml");
const tmp = `${file}.tmp`;
await writeFile(tmp, text, { mode: 0o600 });
await rename(tmp, file);
return file;
}
/**
* Starts the bridge once per server process. Doesn't wait for MediaMTX to be ready, so
* server startup isn't held up; status() reports progress.
*/
export async function startVideoBridge(
makeSupervisor: (opts: ConstructorParameters<typeof MediamtxSupervisor>[0]) => MediamtxSupervisor = (o) =>
new MediamtxSupervisor(o),
stopLeftover: typeof stopLeftoverMediamtx = stopLeftoverMediamtx,
): Promise<BridgeStatus> {
if (g[KEY]) return g[KEY].supervisor.status();
const login: ApiLogin = { user: "app", pass: randomBytes(24).toString("hex") };
const dir = bridgeDir();
const binary = mediamtxBinary();
const configPath = path.join(dir, "mediamtx.yml");
// A MediaMTX from a server that didn't exit cleanly would hold the ports (iss-yd2sq2q).
const leftover = await stopLeftover(dir, binary, configPath);
if (leftover === "stopped" || leftover === "killed") {
console.log(`[video] stopped a MediaMTX left running by an earlier server (${leftover})`);
}
await writeConfig(dir, mediamtxConfig(login));
const supervisor = makeSupervisor({
binary,
configPath,
cwd: dir,
waitReady: () => waitForApi(login),
onSpawn: (pid) => void recordPid(dir, pid).catch(() => {}),
onExit: () => void forgetPid(dir).catch(() => {}),
});
g[KEY] = { supervisor, login, paths: new Map(), pathsEpoch: 0 };
process.once("exit", () => {
supervisor.stop();
// Synchronous: nothing async runs once the process is exiting.
rmSync(path.join(dir, PID_FILE), { force: true });
});
supervisor.start();
return supervisor.status();
}
export function videoBridgeStatus(): BridgeStatus {
return g[KEY]?.supervisor.status() ?? { state: "stopped", restarts: 0 };
}
/** Test hook: forget the bridge (does not stop a real process). */
export function resetVideoBridgeForTests() {
delete g[KEY];
}
/** 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<void> {
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");
if (status.restarts !== bridge.pathsEpoch) {
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<void> {
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<string> {
const bridge = runningBridge();
const name = pathName(target.id, stream);
const source = await rtspSourceWithLogin(target, stream);
if (bridge.paths.get(name) === source) return name;
await putPath(bridge, name, pathConfig(source));
bridge.paths.set(name, source);
return name;
}