feat(onvif): batch import discovered cameras

This commit is contained in:
Mitchell R 2026-05-11 08:40:25 +02:00
parent bd20580f06
commit 0d9451ae95
No known key found for this signature in database
3 changed files with 202 additions and 86 deletions

View file

@ -38,9 +38,12 @@ interface DiscoverAddStream {
height: number | null;
framerate: number | null;
stream_uri: string;
snapshot_uri?: string | null;
role: "main" | "sub" | "other";
}
type FormValue = string | string[] | undefined;
function htmlFragment(markup: unknown): Response {
return new Response(String(markup), {
headers: { "content-type": "text/html; charset=utf-8" },
@ -89,6 +92,60 @@ function rtspWithCredentials(raw: string, username: string, password: string): s
}
}
function formValue(v: FormValue): string {
return Array.isArray(v) ? (v[0] ?? "") : (v ?? "");
}
function formValues(v: FormValue): string[] {
if (Array.isArray(v)) return v;
return v ? [v] : [];
}
function parseDiscoveredStreams(raw: string): DiscoverAddStream[] {
try {
const parsed = JSON.parse(raw) as DiscoverAddStream[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function importDiscoveredCamera(
deps: AdminDeps,
rawName: string,
username: string,
password: string,
streams: DiscoverAddStream[],
): void {
if (streams.length === 0) return;
const main = streams.find((s) => s.role === "main") ?? streams[0]!;
const mainRtspUrl = rtspWithCredentials(main.stream_uri, username, password);
const name = uniqueCameraName(deps, rawName || "ONVIF camera");
const cam = deps.repo.createCamera({
name,
type: "rtsp",
rtsp_url: mainRtspUrl,
});
for (const stream of streams) {
const width = stream.width == null ? null : Number(stream.width);
const height = stream.height == null ? null : Number(stream.height);
const framerate = stream.framerate == null ? null : Number(stream.framerate);
deps.repo.createCameraStream({
camera_id: cam.id,
role: stream.role === "main" || stream.role === "sub" ? stream.role : "other",
name: stream.profile_name || stream.role,
rtsp_uri: rtspWithCredentials(stream.stream_uri, username, password),
profile_token: stream.profile_token || null,
width: Number.isFinite(width) ? width : null,
height: Number.isFinite(height) ? height : null,
encoding: stream.encoding || null,
framerate: Number.isFinite(framerate) ? framerate : null,
is_discovered: true,
});
}
}
function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
return aStart < bEnd && bStart < aEnd;
}
@ -310,48 +367,32 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
app.post("/admin/cameras/discover/add", async (event) => {
const body = await readBody<Record<string, string>>(event);
const rawName = (body?.["name"] ?? "").trim() || "ONVIF camera";
const username = (body?.["username"] ?? "").trim();
const password = body?.["password"] ?? "";
let streams: DiscoverAddStream[] = [];
try {
const parsed = JSON.parse(body?.["streams_json"] ?? "[]") as DiscoverAddStream[];
streams = Array.isArray(parsed) ? parsed : [];
} catch {
streams = [];
const body = await readBody<Record<string, string | string[]>>(event);
const username = formValue(body?.["username"]).trim();
const password = formValue(body?.["password"]);
let imported = 0;
const selected = formValues(body?.["selected"]);
if (selected.length > 0) {
for (const idx of selected) {
const rawName = formValue(body?.[`camera_${idx}_name`]).trim() || "ONVIF camera";
const streams = parseDiscoveredStreams(formValue(body?.[`camera_${idx}_streams_json`]));
if (streams.length === 0) continue;
importDiscoveredCamera(deps, rawName, username, password, streams);
imported += 1;
}
} else {
const rawName = formValue(body?.["name"]).trim() || "ONVIF camera";
const streams = parseDiscoveredStreams(formValue(body?.["streams_json"]));
if (streams.length > 0) {
importDiscoveredCamera(deps, rawName, username, password, streams);
imported += 1;
}
}
if (streams.length === 0) {
if (imported === 0) {
return new Response(null, { status: 302, headers: { location: "/admin/cameras/discover" } });
}
const main = streams.find((s) => s.role === "main") ?? streams[0]!;
const mainRtspUrl = rtspWithCredentials(main.stream_uri, username, password);
const name = uniqueCameraName(deps, rawName);
const cam = deps.repo.createCamera({
name,
type: "rtsp",
rtsp_url: mainRtspUrl,
});
for (const stream of streams) {
const width = stream.width == null ? null : Number(stream.width);
const height = stream.height == null ? null : Number(stream.height);
const framerate = stream.framerate == null ? null : Number(stream.framerate);
deps.repo.createCameraStream({
camera_id: cam.id,
role: stream.role === "main" || stream.role === "sub" ? stream.role : "other",
name: stream.profile_name || stream.role,
rtsp_uri: rtspWithCredentials(stream.stream_uri, username, password),
profile_token: stream.profile_token || null,
width: Number.isFinite(width) ? width : null,
height: Number.isFinite(height) ? height : null,
encoding: stream.encoding || null,
framerate: Number.isFinite(framerate) ? framerate : null,
is_discovered: true,
});
}
notifyKiosks();
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });

View file

@ -20,6 +20,7 @@ export interface DiscoveredProfile {
height: number | null;
framerate: number | null;
stream_uri: string;
snapshot_uri: string | null;
role: "main" | "sub" | "other";
}
@ -335,6 +336,22 @@ export async function discover(input: DiscoverInput): Promise<DiscoveredCamera[]
}
const uri = pickAll(streamXml, "Uri")[0] ?? "";
if (!uri) continue;
const snapshotBody = `<trt:GetSnapshotUri>
<trt:ProfileToken>${escapeXml(token)}</trt:ProfileToken>
</trt:GetSnapshotUri>`;
const snapshotEnv = buildEnvelope(wsseHeader(input.username, input.password), snapshotBody);
let snapshotUri: string | null = null;
try {
const snapshotXml = await soap(
mediaUrl,
"http://www.onvif.org/ver10/media/wsdl/GetSnapshotUri",
snapshotEnv,
timeoutMs,
);
snapshotUri = pickAll(snapshotXml, "Uri")[0] ?? null;
} catch {
snapshotUri = null;
}
out.push({
profile_name: profileName,
@ -345,6 +362,7 @@ export async function discover(input: DiscoverInput): Promise<DiscoveredCamera[]
height,
framerate,
stream_uri: uri,
snapshot_uri: snapshotUri,
role: "other",
});
}

View file

@ -278,6 +278,7 @@ interface DiscoveredProfileRow {
height: number | null;
framerate: number | null;
stream_uri: string;
snapshot_uri: string | null;
role: "main" | "sub" | "other";
}
@ -297,6 +298,31 @@ interface CameraDiscoverResultsProps {
success?: string;
}
function discoverResultsScript(rootId: string): string {
return (
`(function(){` +
`var root=document.getElementById('${rootId}');if(!root)return;` +
`var checks=function(){return Array.prototype.slice.call(root.querySelectorAll('input[name="selected"]'));};` +
`root.querySelector('[data-action="check-all"]')?.addEventListener('click',function(){checks().forEach(function(c){c.checked=true;});});` +
`root.querySelector('[data-action="uncheck-all"]')?.addEventListener('click',function(){checks().forEach(function(c){c.checked=false;});});` +
`root.querySelector('[data-view="list"]')?.addEventListener('click',function(){root.dataset.view='list';});` +
`root.querySelector('[data-view="cards"]')?.addEventListener('click',function(){root.dataset.view='cards';});` +
`})()`
);
}
const DISCOVER_RESULTS_CSS = `
#discover-results-root[data-view="cards"] .discover-results { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
#discover-results-root[data-view="list"] .discover-camera-card { margin-bottom: 1rem; }
#discover-results-root[data-view="list"] .discover-snaps { display: none; }
.discover-camera-card { margin-bottom: 1rem; }
.discover-snaps { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; margin-bottom: 0.75rem; }
.discover-snap { position: relative; min-height: 150px; background: #111827; border-radius: 4px; overflow: hidden; display: flex; align-items: center; justify-content: center; }
.discover-snap img { width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block; }
.discover-snap-label { position: absolute; left: 6px; top: 6px; background: rgba(17, 24, 39, 0.8); color: #fff; border-radius: 3px; padding: 2px 5px; font-size: 0.7rem; text-transform: uppercase; z-index: 1; }
.discover-snap-empty { color: #d1d5db; font-size: 0.8rem; }
`;
function CameraDiscoverResultsPageLegacy(props: {
user: string;
host: string;
@ -380,55 +406,86 @@ export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) {
: undefined
}
>
<p style="color:#666; margin-bottom:1rem">
Video sources reported by <strong>{props.host}</strong>. Each source imports
as one camera with its profiles saved as streams.
</p>
{props.cameras.length === 0 ? (
<div class="card" style="text-align:center; color:#999; padding:2rem">No profiles returned</div>
) : props.cameras.map((cam) => (
<div class="card" style="margin-bottom:1rem">
<div class="section-header" style="margin-bottom:0.75rem">
<div>
<h2 class="section-title" style="font-size:1rem">{cam.name}</h2>
{cam.source_token ? <div style="color:#666; font-size:0.8rem">Source: {cam.source_token}</div> : ""}
<style>{DISCOVER_RESULTS_CSS}</style>
<div id="discover-results-root" data-view="list">
<p style="color:#666; margin-bottom:1rem">
Video sources reported by <strong>{props.host}</strong>. Each source imports
as one camera with its profiles saved as streams.
</p>
{props.cameras.length === 0 ? (
<div class="card" style="text-align:center; color:#999; padding:2rem">No profiles returned</div>
) : (
<form method="post" action="/admin/cameras/discover/add">
<input type="hidden" name="username" value={props.username} />
<input type="hidden" name="password" value={props.password} />
<div style="display:flex; gap:0.5rem; align-items:center; margin-bottom:1rem; flex-wrap:wrap">
<button type="button" class="btn btn-ghost" data-action="check-all">Check all</button>
<button type="button" class="btn btn-ghost" data-action="uncheck-all">Uncheck all</button>
<button type="button" class="btn btn-ghost" data-view="list">List</button>
<button type="button" class="btn btn-ghost" data-view="cards">Cards</button>
<button type="submit" class="btn btn-primary">Add selected</button>
</div>
<form method="post" action="/admin/cameras/discover/add" style="display:inline">
<input type="hidden" name="name" value={cam.name} />
<input type="hidden" name="username" value={props.username} />
<input type="hidden" name="password" value={props.password} />
<input type="hidden" name="streams_json" value={JSON.stringify(cam.profiles)} />
<button type="submit" class="btn btn-sm btn-primary">Add Camera</button>
</form>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Role</th>
<th>Profile</th>
<th>Encoding</th>
<th>Resolution</th>
<th>FPS</th>
<th>Stream URI</th>
</tr>
</thead>
<tbody>
{cam.profiles.map((p) => (
<tr>
<td><span class="badge badge-gray">{p.role}</span></td>
<td><strong>{p.profile_name}</strong></td>
<td>{p.encoding ? <span class="badge badge-blue">{p.encoding}</span> : "-"}</td>
<td>{p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "-"}</td>
<td>{p.framerate != null ? String(p.framerate) : "-"}</td>
<td style="font-size:0.75rem; word-break:break-all; max-width:300px">{p.stream_uri}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)).join("")}
<div class="discover-results">
{props.cameras.map((cam, idx) => {
const main = cam.profiles.find((p) => p.role === "main") ?? cam.profiles[0] ?? null;
const sub = cam.profiles.find((p) => p.role === "sub") ?? null;
return (
<div class="card discover-camera-card">
<input type="hidden" name={`camera_${String(idx)}_name`} value={cam.name} />
<input type="hidden" name={`camera_${String(idx)}_streams_json`} value={JSON.stringify(cam.profiles)} />
<div class="section-header" style="margin-bottom:0.75rem">
<label style="display:flex; gap:0.5rem; align-items:center">
<input type="checkbox" name="selected" value={String(idx)} checked />
<span>
<strong>{cam.name}</strong>
{cam.source_token ? <span style="color:#666; font-size:0.8rem"> Source: {cam.source_token}</span> : ""}
</span>
</label>
</div>
<div class="discover-snaps">
{[main, sub].filter(Boolean).map((p) => (
<div class="discover-snap">
<div class="discover-snap-label">{p!.role}</div>
{p!.snapshot_uri
? <img src={p!.snapshot_uri} loading="lazy" />
: <div class="discover-snap-empty">No snapshot</div>}
</div>
))}
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Role</th>
<th>Profile</th>
<th>Encoding</th>
<th>Resolution</th>
<th>FPS</th>
<th>Stream URI</th>
</tr>
</thead>
<tbody>
{cam.profiles.map((p) => (
<tr>
<td><span class="badge badge-gray">{p.role}</span></td>
<td><strong>{p.profile_name}</strong></td>
<td>{p.encoding ? <span class="badge badge-blue">{p.encoding}</span> : "-"}</td>
<td>{p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "-"}</td>
<td>{p.framerate != null ? String(p.framerate) : "-"}</td>
<td style="font-size:0.75rem; word-break:break-all; max-width:300px">{p.stream_uri}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}).join("")}
</div>
</form>
)}
</div>
<script>{js(discoverResultsScript("discover-results-root"))}</script>
<div style="margin-top:1rem">
<a href="/admin/cameras/discover" class="btn btn-ghost">Discover Another</a>
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Back to Cameras</a>