mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
feat(onvif): batch import discovered cameras
This commit is contained in:
parent
bd20580f06
commit
0d9451ae95
3 changed files with 202 additions and 86 deletions
|
|
@ -38,9 +38,12 @@ interface DiscoverAddStream {
|
||||||
height: number | null;
|
height: number | null;
|
||||||
framerate: number | null;
|
framerate: number | null;
|
||||||
stream_uri: string;
|
stream_uri: string;
|
||||||
|
snapshot_uri?: string | null;
|
||||||
role: "main" | "sub" | "other";
|
role: "main" | "sub" | "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FormValue = string | string[] | undefined;
|
||||||
|
|
||||||
function htmlFragment(markup: unknown): Response {
|
function htmlFragment(markup: unknown): Response {
|
||||||
return new Response(String(markup), {
|
return new Response(String(markup), {
|
||||||
headers: { "content-type": "text/html; charset=utf-8" },
|
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 {
|
function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
|
||||||
return aStart < bEnd && bStart < aEnd;
|
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) => {
|
app.post("/admin/cameras/discover/add", async (event) => {
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string | string[]>>(event);
|
||||||
const rawName = (body?.["name"] ?? "").trim() || "ONVIF camera";
|
const username = formValue(body?.["username"]).trim();
|
||||||
const username = (body?.["username"] ?? "").trim();
|
const password = formValue(body?.["password"]);
|
||||||
const password = body?.["password"] ?? "";
|
let imported = 0;
|
||||||
let streams: DiscoverAddStream[] = [];
|
|
||||||
try {
|
const selected = formValues(body?.["selected"]);
|
||||||
const parsed = JSON.parse(body?.["streams_json"] ?? "[]") as DiscoverAddStream[];
|
if (selected.length > 0) {
|
||||||
streams = Array.isArray(parsed) ? parsed : [];
|
for (const idx of selected) {
|
||||||
} catch {
|
const rawName = formValue(body?.[`camera_${idx}_name`]).trim() || "ONVIF camera";
|
||||||
streams = [];
|
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" } });
|
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();
|
notifyKiosks();
|
||||||
|
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export interface DiscoveredProfile {
|
||||||
height: number | null;
|
height: number | null;
|
||||||
framerate: number | null;
|
framerate: number | null;
|
||||||
stream_uri: string;
|
stream_uri: string;
|
||||||
|
snapshot_uri: string | null;
|
||||||
role: "main" | "sub" | "other";
|
role: "main" | "sub" | "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,6 +336,22 @@ export async function discover(input: DiscoverInput): Promise<DiscoveredCamera[]
|
||||||
}
|
}
|
||||||
const uri = pickAll(streamXml, "Uri")[0] ?? "";
|
const uri = pickAll(streamXml, "Uri")[0] ?? "";
|
||||||
if (!uri) continue;
|
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({
|
out.push({
|
||||||
profile_name: profileName,
|
profile_name: profileName,
|
||||||
|
|
@ -345,6 +362,7 @@ export async function discover(input: DiscoverInput): Promise<DiscoveredCamera[]
|
||||||
height,
|
height,
|
||||||
framerate,
|
framerate,
|
||||||
stream_uri: uri,
|
stream_uri: uri,
|
||||||
|
snapshot_uri: snapshotUri,
|
||||||
role: "other",
|
role: "other",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -278,6 +278,7 @@ interface DiscoveredProfileRow {
|
||||||
height: number | null;
|
height: number | null;
|
||||||
framerate: number | null;
|
framerate: number | null;
|
||||||
stream_uri: string;
|
stream_uri: string;
|
||||||
|
snapshot_uri: string | null;
|
||||||
role: "main" | "sub" | "other";
|
role: "main" | "sub" | "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,6 +298,31 @@ interface CameraDiscoverResultsProps {
|
||||||
success?: string;
|
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: {
|
function CameraDiscoverResultsPageLegacy(props: {
|
||||||
user: string;
|
user: string;
|
||||||
host: string;
|
host: string;
|
||||||
|
|
@ -380,55 +406,86 @@ export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p style="color:#666; margin-bottom:1rem">
|
<style>{DISCOVER_RESULTS_CSS}</style>
|
||||||
Video sources reported by <strong>{props.host}</strong>. Each source imports
|
<div id="discover-results-root" data-view="list">
|
||||||
as one camera with its profiles saved as streams.
|
<p style="color:#666; margin-bottom:1rem">
|
||||||
</p>
|
Video sources reported by <strong>{props.host}</strong>. Each source imports
|
||||||
{props.cameras.length === 0 ? (
|
as one camera with its profiles saved as streams.
|
||||||
<div class="card" style="text-align:center; color:#999; padding:2rem">No profiles returned</div>
|
</p>
|
||||||
) : props.cameras.map((cam) => (
|
{props.cameras.length === 0 ? (
|
||||||
<div class="card" style="margin-bottom:1rem">
|
<div class="card" style="text-align:center; color:#999; padding:2rem">No profiles returned</div>
|
||||||
<div class="section-header" style="margin-bottom:0.75rem">
|
) : (
|
||||||
<div>
|
<form method="post" action="/admin/cameras/discover/add">
|
||||||
<h2 class="section-title" style="font-size:1rem">{cam.name}</h2>
|
<input type="hidden" name="username" value={props.username} />
|
||||||
{cam.source_token ? <div style="color:#666; font-size:0.8rem">Source: {cam.source_token}</div> : ""}
|
<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>
|
</div>
|
||||||
<form method="post" action="/admin/cameras/discover/add" style="display:inline">
|
<div class="discover-results">
|
||||||
<input type="hidden" name="name" value={cam.name} />
|
{props.cameras.map((cam, idx) => {
|
||||||
<input type="hidden" name="username" value={props.username} />
|
const main = cam.profiles.find((p) => p.role === "main") ?? cam.profiles[0] ?? null;
|
||||||
<input type="hidden" name="password" value={props.password} />
|
const sub = cam.profiles.find((p) => p.role === "sub") ?? null;
|
||||||
<input type="hidden" name="streams_json" value={JSON.stringify(cam.profiles)} />
|
return (
|
||||||
<button type="submit" class="btn btn-sm btn-primary">Add Camera</button>
|
<div class="card discover-camera-card">
|
||||||
</form>
|
<input type="hidden" name={`camera_${String(idx)}_name`} value={cam.name} />
|
||||||
</div>
|
<input type="hidden" name={`camera_${String(idx)}_streams_json`} value={JSON.stringify(cam.profiles)} />
|
||||||
<div class="table-wrap">
|
<div class="section-header" style="margin-bottom:0.75rem">
|
||||||
<table>
|
<label style="display:flex; gap:0.5rem; align-items:center">
|
||||||
<thead>
|
<input type="checkbox" name="selected" value={String(idx)} checked />
|
||||||
<tr>
|
<span>
|
||||||
<th>Role</th>
|
<strong>{cam.name}</strong>
|
||||||
<th>Profile</th>
|
{cam.source_token ? <span style="color:#666; font-size:0.8rem"> Source: {cam.source_token}</span> : ""}
|
||||||
<th>Encoding</th>
|
</span>
|
||||||
<th>Resolution</th>
|
</label>
|
||||||
<th>FPS</th>
|
</div>
|
||||||
<th>Stream URI</th>
|
<div class="discover-snaps">
|
||||||
</tr>
|
{[main, sub].filter(Boolean).map((p) => (
|
||||||
</thead>
|
<div class="discover-snap">
|
||||||
<tbody>
|
<div class="discover-snap-label">{p!.role}</div>
|
||||||
{cam.profiles.map((p) => (
|
{p!.snapshot_uri
|
||||||
<tr>
|
? <img src={p!.snapshot_uri} loading="lazy" />
|
||||||
<td><span class="badge badge-gray">{p.role}</span></td>
|
: <div class="discover-snap-empty">No snapshot</div>}
|
||||||
<td><strong>{p.profile_name}</strong></td>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
<td>{p.framerate != null ? String(p.framerate) : "-"}</td>
|
<div class="table-wrap">
|
||||||
<td style="font-size:0.75rem; word-break:break-all; max-width:300px">{p.stream_uri}</td>
|
<table>
|
||||||
</tr>
|
<thead>
|
||||||
))}
|
<tr>
|
||||||
</tbody>
|
<th>Role</th>
|
||||||
</table>
|
<th>Profile</th>
|
||||||
</div>
|
<th>Encoding</th>
|
||||||
</div>
|
<th>Resolution</th>
|
||||||
)).join("")}
|
<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">
|
<div style="margin-top:1rem">
|
||||||
<a href="/admin/cameras/discover" class="btn btn-ghost">Discover Another</a>
|
<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>
|
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Back to Cameras</a>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue