diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 6b4e62c..2ca72b1 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -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>(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>(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" } }); diff --git a/server/src/shared/onvif.ts b/server/src/shared/onvif.ts index 70b9478..5de4262 100644 --- a/server/src/shared/onvif.ts +++ b/server/src/shared/onvif.ts @@ -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 + ${escapeXml(token)} + `; + 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 -

- Video sources reported by {props.host}. Each source imports - as one camera with its profiles saved as streams. -

- {props.cameras.length === 0 ? ( -
No profiles returned
- ) : props.cameras.map((cam) => ( -
-
-
-

{cam.name}

- {cam.source_token ?
Source: {cam.source_token}
: ""} + +
+

+ Video sources reported by {props.host}. Each source imports + as one camera with its profiles saved as streams. +

+ {props.cameras.length === 0 ? ( +
No profiles returned
+ ) : ( +
+ + +
+ + + + +
- - - - - - -
-
-
- - - - - - - - - - - - - {cam.profiles.map((p) => ( - - - - - - - - - ))} - -
RoleProfileEncodingResolutionFPSStream URI
{p.role}{p.profile_name}{p.encoding ? {p.encoding} : "-"}{p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "-"}{p.framerate != null ? String(p.framerate) : "-"}{p.stream_uri}
-
-
- )).join("")} +
+ {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 ( +
+ + +
+ +
+
+ {[main, sub].filter(Boolean).map((p) => ( +
+
{p!.role}
+ {p!.snapshot_uri + ? + :
No snapshot
} +
+ ))} +
+
+ + + + + + + + + + + + + {cam.profiles.map((p) => ( + + + + + + + + + ))} + +
RoleProfileEncodingResolutionFPSStream URI
{p.role}{p.profile_name}{p.encoding ? {p.encoding} : "-"}{p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "-"}{p.framerate != null ? String(p.framerate) : "-"}{p.stream_uri}
+
+
+ ); + }).join("")} +
+ + )} +
+