mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +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;
|
||||
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" } });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue