/** * Synology Surveillance Station helper. * * Mirrors the working Python tool we already use to pull commissioning * footage off the NAS. One call per request: log in → list/download → log out. * * const { sid, did } = await synologyLogin() * const recs = await synologyListRecordings(sid, did, [2], from, to) * await synologyDownloadRecording(sid, did, recs[0].id, '/tmp/foo.mp4') * await synologyLogout(sid, did) */ import { createWriteStream } from 'node:fs' import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' type SynoConfig = { url: string user: string password: string } function getConfig(): SynoConfig { const config = useRuntimeConfig() const url = (config.synology?.url || '').toString().replace(/\/+$/, '') const user = (config.synology?.user || '').toString() const password = (config.synology?.password || '').toString() if (!url || !user || !password) { throw createError({ statusCode: 500, statusMessage: 'Synology Surveillance Station is not configured (SYNOLOGY_URL / SYNOLOGY_USER / SYNOLOGY_PASSWORD).' }) } return { url, user, password } } /** Build `${base}/webapi/entry.cgi?…` with the given params. */ function buildEntryUrl(base: string, params: Record): string { const qs = new URLSearchParams() for (const [k, v] of Object.entries(params)) { qs.set(k, String(v)) } return `${base}/webapi/entry.cgi?${qs.toString()}` } export type SynoSession = { sid: string did?: string } export async function synologyLogin(): Promise { const { url, user, password } = getConfig() const target = buildEntryUrl(url, { api: 'SYNO.API.Auth', method: 'login', version: 7, account: user, passwd: password, session: 'SurveillanceStation', format: 'sid', }) const res = await fetch(target) const json: any = await res.json().catch(() => null) if (!res.ok || !json?.success || !json?.data?.sid) { const code = json?.error?.code ?? res.status throw createError({ statusCode: 502, statusMessage: `Synology login failed (code ${code}).` }) } return { sid: json.data.sid, did: json.data.did } } export async function synologyLogout(session: SynoSession): Promise { try { const { url } = getConfig() const target = buildEntryUrl(url, { api: 'SYNO.API.Auth', method: 'logout', version: 7, _sid: session.sid, session: 'SurveillanceStation', }) await fetch(target).catch(() => null) } catch { // Best-effort logout; ignore failures. } } export type SynoRecording = { id: number | string cameraId: number | string | null startTime: number | null filePath: string | null raw: any } /** * On some DSM/Surveillance Station builds the List response does NOT * include a startTime field at all — instead the recording's start UTC * timestamp is embedded as a 13-digit millisecond number in `filePath`, * e.g. `Kommissionierung-1-20260503-002000-1777760400027-7.mp4`. * * Returns the start time in UTC seconds, or null if it can't be parsed. */ function parseStartFromFilePath(filePath?: string | null): number | null { if (!filePath) return null const m = /([0-9]{13})-[0-9]+\.mp4$/.exec(filePath) if (!m) return null const ms = Number(m[1]) if (!Number.isFinite(ms) || ms <= 0) return null return Math.floor(ms / 1000) } /** * List recordings overlapping the [from, to] UNIX-timestamp window for the * given camera ids. Tries the API parameter formats most SS builds accept, * falling back to an unfiltered query + client-side filter. */ export async function synologyListRecordings( session: SynoSession, cameraIds: number[], fromTs: number, toTs: number, apiVersion: number = 6 ): Promise { const { url } = getConfig() const camCsv = cameraIds.join(',') const camBracket = `[${camCsv}]` const attempts: Array> = [ { cameraIds: camCsv }, { cameraIds: camBracket }, {}, // last resort: unfiltered ] let lastError: any = null for (const extra of attempts) { const params: Record = { api: 'SYNO.SurveillanceStation.Recording', method: 'List', version: apiVersion, fromTime: fromTs, toTime: toTs, _sid: session.sid, ...extra, } const target = buildEntryUrl(url, params) let res: Response try { res = await fetch(target) } catch (err: any) { lastError = err continue } if (!res.ok) { lastError = new Error(`HTTP ${res.status}`) continue } const json: any = await res.json().catch(() => null) if (!json?.success) { lastError = json?.error continue } const items: any[] = json?.data?.recordings || json?.data?.events || [] const camSet = new Set(cameraIds.map((c) => Number(c))) const filtered = items .filter((r) => { // If we used the unfiltered fallback, filter client-side. if (Object.keys(extra).length === 0) { const cid = Number(r?.camera_id ?? r?.cameraId) return camSet.has(cid) } return true }) .map((r) => { const filePath = r?.filePath ?? r?.file_path ?? null const startTime = r?.startTime ?? r?.start_time ?? r?.recordingTime ?? r?.recordTime ?? parseStartFromFilePath(filePath) return { id: r?.id ?? r?.recordId, cameraId: r?.camera_id ?? r?.cameraId ?? null, startTime: startTime ?? null, filePath, raw: r, } }) return filtered } throw createError({ statusCode: 502, statusMessage: `Synology Recording.List failed: ${lastError?.message ?? lastError?.code ?? 'unknown error'}` }) } /** * Stream a recording to disk. Returns the number of bytes written. * Used by the init endpoint to cache the file before serving it. * * `offsetTimeMs` / `playTimeMs` slice a sub-window out of the underlying * (typically ~30-min) recording so we don't download the whole file when * we only need a few minutes around the commissioning timestamp. Both * are optional — passing neither gives the full recording. */ export async function synologyDownloadRecording( session: SynoSession, recordingId: number | string, destPath: string, apiVersion: number = 6, offsetTimeMs?: number, playTimeMs?: number ): Promise { const { url } = getConfig() const params: Record = { api: 'SYNO.SurveillanceStation.Recording', method: 'Download', version: apiVersion, id: recordingId, mountId: 0, _sid: session.sid, } if (Number.isFinite(offsetTimeMs) && (offsetTimeMs as number) >= 0) { params.offsetTimeMs = Math.floor(offsetTimeMs as number) } if (Number.isFinite(playTimeMs) && (playTimeMs as number) > 0) { params.playTimeMs = Math.floor(playTimeMs as number) } const target = buildEntryUrl(url, params) const res = await fetch(target) if (!res.ok || !res.body) { throw createError({ statusCode: 502, statusMessage: `Synology Recording.Download failed (HTTP ${res.status}).` }) } // Some SS builds return a JSON error with HTTP 200 if the recording is gone. const ct = res.headers.get('content-type') || '' if (ct.includes('application/json')) { const json: any = await res.json().catch(() => null) throw createError({ statusCode: 502, statusMessage: `Synology Recording.Download returned an error (code ${json?.error?.code ?? '?'}).` }) } const out = createWriteStream(destPath) // Node 18+ converts a WHATWG ReadableStream into a Readable via fromWeb. const nodeStream = Readable.fromWeb(res.body as any) let written = 0 nodeStream.on('data', (chunk: Buffer) => { written += chunk.length }) await pipeline(nodeStream, out) return written } /** * Server-side time-range export. Documented in the Surveillance Station * Web API as a 3-step async flow: * * 1. RangeExport(camId, fromTime, toTime, fileName) → returns dlid * 2. GetRangeExportProgress(dlid) → poll until progress=100 * (must be called within every 20 s to keep the task alive) * 3. OnRangeExportDone(dlid, fileName) → stream the merged MP4 * (must be called within 1 min of progress=100, else NAS cleans up) * * On a continuously-recording camera (one ~30-min file per period) this * returns a single MP4 covering the exact window asked for — no local * stitching needed. On a motion-triggered camera the result is a merged * file with motion gaps elided (same content as our previous ffmpeg * concat path, just produced server-side); set the camera back to * continuous recording for full timeline alignment. * * Throws if the NAS reports `progress=-1`, returns a `zip` (which means * codec/resolution mismatch in the range and the API gave us a bundle * of pieces, not a merged file), or if any step times out. */ export async function synologyRangeExport( session: SynoSession, cameraId: number, fromTs: number, toTs: number, destPath: string, fileName: string = 'video', apiVersion: number = 6, ): Promise<{ bytes: number; fileExt: 'mp4' | 'zip' }> { const { url } = getConfig() // Step 1: kick off the export task. const startUrl = buildEntryUrl(url, { api: 'SYNO.SurveillanceStation.Recording', method: 'RangeExport', version: apiVersion, camId: cameraId, fromTime: fromTs, toTime: toTs, fileName, _sid: session.sid, }) const startRes = await fetch(startUrl) const startJson: any = await startRes.json().catch(() => null) if (!startJson?.success || !Number.isFinite(startJson?.data?.dlid)) { throw createError({ statusCode: 502, statusMessage: `RangeExport start failed (code ${startJson?.error?.code ?? 'unknown'}).` }) } const dlid = Number(startJson.data.dlid) // Step 2: poll progress every 1.5 s until done. Hard cap at 5 min so a // stuck task can't pin the request indefinitely. The doc says we MUST // poll at least every 20 s to keep the task alive; 1.5 s is fine. let fileExt = '' const t0 = Date.now() const MAX_MS = 5 * 60 * 1000 while (Date.now() - t0 < MAX_MS) { await new Promise((r) => setTimeout(r, 1500)) const progressUrl = buildEntryUrl(url, { api: 'SYNO.SurveillanceStation.Recording', method: 'GetRangeExportProgress', version: apiVersion, dlid, _sid: session.sid, }) const progressJson: any = await fetch(progressUrl).then(r => r.json()).catch(() => null) if (!progressJson?.success) { throw createError({ statusCode: 502, statusMessage: `RangeExport progress failed (code ${progressJson?.error?.code ?? 'unknown'}).` }) } const progress = Number(progressJson?.data?.progress) fileExt = String(progressJson?.data?.fileExt ?? '') if (progress === -1) { throw createError({ statusCode: 502, statusMessage: 'RangeExport task failed (progress=-1).' }) } if (progress === 100) break } if (fileExt !== 'mp4' && fileExt !== 'zip') { throw createError({ statusCode: 504, statusMessage: `RangeExport timed out after ${Math.round((Date.now() - t0) / 1000)}s (last fileExt='${fileExt}').` }) } if (fileExt === 'zip') { throw createError({ statusCode: 502, statusMessage: 'RangeExport produced a zip (codec/resolution mismatch in the time range). Set the camera to continuous recording with consistent encoding.' }) } // Step 3: stream the merged file to disk. const downloadUrl = buildEntryUrl(url, { api: 'SYNO.SurveillanceStation.Recording', method: 'OnRangeExportDone', version: apiVersion, dlid, fileName, _sid: session.sid, }) const dlRes = await fetch(downloadUrl) if (!dlRes.ok || !dlRes.body) { throw createError({ statusCode: 502, statusMessage: `RangeExport download failed (HTTP ${dlRes.status}).` }) } const ct = dlRes.headers.get('content-type') || '' if (ct.includes('application/json')) { const json: any = await dlRes.json().catch(() => null) throw createError({ statusCode: 502, statusMessage: `RangeExport download returned error (code ${json?.error?.code ?? '?'}).` }) } const out = createWriteStream(destPath) const nodeStream = Readable.fromWeb(dlRes.body as any) let written = 0 nodeStream.on('data', (chunk: Buffer) => { written += chunk.length }) await pipeline(nodeStream, out) return { bytes: written, fileExt: 'mp4' } } export function getDefaultCameraIds(): number[] { const config = useRuntimeConfig() const raw = (config.synology?.cameraIds || '').toString().trim() if (!raw) return [2] return raw .split(/[,\s]+/) .map((s) => Number(s)) .filter((n) => Number.isFinite(n) && n > 0) } export function getWindowSeconds(): { pre: number; post: number } { const config = useRuntimeConfig() const pre = Number(config.synology?.preSeconds ?? 10) const post = Number(config.synology?.postSeconds ?? 180) return { pre: Number.isFinite(pre) && pre >= 0 ? pre : 10, post: Number.isFinite(post) && post >= 0 ? post : 180, } }