/** * Synology Surveillance Station helper. * * Mirrors the working Python tool we already use to pull commissioning * footage off the NAS. The login is shared and reused across requests (see * synologyLogin); every call carries a timeout and re-logs-in once when the * NAS reports a dead session (error 105/106/107/119). * * 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' // Every NAS call is bounded. Without a timeout an unreachable/hung Surveillance // Station pins the Nuxt request (and its RangeExport polling loop) for minutes. const API_TIMEOUT_MS = 15_000 const DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000 // DSM error codes that mean "this sid is no longer valid" (not "no data"): // 105 insufficient privilege (returned for a dead/foreign sid too), // 106 session timeout, 107 session interrupted, 119 SID not found. const SESSION_ERROR_CODES = new Set([105, 106, 107, 119]) // One shared login for the whole server instead of login+logout per request: // fewer auth round-trips, no session-limit surprises when two users open // recordings at once, and a transparent re-login when the NAS drops the sid. const SESSION_MAX_AGE_MS = 30 * 60 * 1000 let cachedSession: { session: SynoSession; at: number } | null = null let loginInflight: Promise | null = null /** fetch() with an AbortSignal timeout. */ function synoFetch(url: string, timeoutMs: number = API_TIMEOUT_MS): Promise { return fetch(url, { signal: AbortSignal.timeout(timeoutMs) }) } export class SynoApiError extends Error { code: number | null constructor(message: string, code: number | null) { super(message) this.code = code } } /** * JSON API call with automatic re-login when the NAS says the sid is gone. * Mutates `session.sid` in place so callers holding the object keep working. * Returns the parsed JSON on success, throws SynoApiError on `success:false`. */ async function synoJson(session: SynoSession, params: Record, retried: boolean = false): Promise { const { url } = getConfig() const res = await synoFetch(buildEntryUrl(url, { ...params, _sid: session.sid })) if (!res.ok) throw new SynoApiError(`HTTP ${res.status}`, null) const json: any = await res.json().catch(() => null) if (json?.success) return json const code = Number(json?.error?.code) if (!retried && SESSION_ERROR_CODES.has(code)) { invalidateSynologySession() const fresh = await synologyLogin() session.sid = fresh.sid session.did = fresh.did return synoJson(session, params, true) } throw new SynoApiError(`code ${Number.isFinite(code) ? code : 'unknown'}`, Number.isFinite(code) ? code : null) } export function invalidateSynologySession(): void { cachedSession = null } 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 } async function doLogin(): 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', }) let res: Response try { res = await synoFetch(target) } catch (err: any) { throw createError({ statusCode: 502, statusMessage: `Synology login failed (${err?.name === 'TimeoutError' ? 'timeout' : (err?.message ?? 'network error')}).` }) } 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 } } /** * Returns the shared session (logging in when there is none or it is stale). * Callers receive a *copy* so a re-login inside one request never swaps the * sid under another request's feet; synoJson re-syncs the copy on demand. */ export async function synologyLogin(): Promise { if (cachedSession && Date.now() - cachedSession.at < SESSION_MAX_AGE_MS) { return { ...cachedSession.session } } if (!loginInflight) { loginInflight = doLogin() .then((session) => { cachedSession = { session, at: Date.now() } return session }) .finally(() => { loginInflight = null }) } const session = await loginInflight return { ...session } } /** * Kept for API compatibility with the route handlers' `finally` blocks. The * session is shared and reused, so this is intentionally a no-op; pass * `force = true` to really log the NAS session out (e.g. on shutdown). */ export async function synologyLogout(session: SynoSession, force: boolean = false): Promise { if (!force) return try { const { url } = getConfig() const target = buildEntryUrl(url, { api: 'SYNO.API.Auth', method: 'logout', version: 7, _sid: session.sid, session: 'SurveillanceStation', }) await synoFetch(target).catch(() => null) } catch { // Best-effort logout; ignore failures. } finally { if (cachedSession?.session.sid === session.sid) cachedSession = null } } 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 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) { let json: any try { json = await synoJson(session, { api: 'SYNO.SurveillanceStation.Recording', method: 'List', version: apiVersion, fromTime: fromTs, toTime: toTs, ...extra, }) } catch (err: any) { lastError = err // A timeout / dead NAS won't get better on the next parameter variant. if (err?.name === 'TimeoutError' || err?.name === 'AbortError') break 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: lastError?.name === 'TimeoutError' ? 504 : 502, statusMessage: `Synology Recording.List failed: ${lastError?.name === 'TimeoutError' ? 'timeout' : (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 synoFetch(target, DOWNLOAD_TIMEOUT_MS) 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. let startJson: any try { startJson = await synoJson(session, { api: 'SYNO.SurveillanceStation.Recording', method: 'RangeExport', version: apiVersion, camId: cameraId, fromTime: fromTs, toTime: toTs, fileName, }) } catch (err: any) { throw createError({ statusCode: 502, statusMessage: `RangeExport start failed (${err?.name === 'TimeoutError' ? 'timeout' : (err?.message ?? 'unknown')}).` }) } if (!Number.isFinite(startJson?.data?.dlid)) { throw createError({ statusCode: 502, statusMessage: 'RangeExport start failed (no dlid).' }) } 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)) let progressJson: any try { progressJson = await synoJson(session, { api: 'SYNO.SurveillanceStation.Recording', method: 'GetRangeExportProgress', version: apiVersion, dlid, }) } catch (err: any) { throw createError({ statusCode: 502, statusMessage: `RangeExport progress failed (${err?.name === 'TimeoutError' ? 'timeout' : (err?.message ?? '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 synoFetch(downloadUrl, DOWNLOAD_TIMEOUT_MS) 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 type SynoCamera = { id: number; name: string } /** * List the cameras configured in Surveillance Station, as `{ id, name }`. * The `id` is the value to store in CUST_CommissionTable.synology_camera_id. * Camera.List's version differs across Surveillance Station releases, so we * try newest → oldest and read id/name from the several field shapes SS uses. */ export async function synologyListCameras(session: SynoSession): Promise { const versions = [9, 8, 7, 6] let lastError: any = null for (const version of versions) { let json: any try { json = await synoJson(session, { api: 'SYNO.SurveillanceStation.Camera', method: 'List', version, }) } catch (err: any) { lastError = err if (err?.name === 'TimeoutError' || err?.name === 'AbortError') break continue } const items: any[] = json?.data?.cameras || json?.data?.camera || [] return items .map((c) => ({ id: Number(c?.id ?? c?.cameraId ?? c?.camera_id), name: String(c?.newName ?? c?.name ?? c?.detailInfo?.camName ?? `Camera ${c?.id ?? ''}`).trim(), })) .filter((c) => Number.isFinite(c.id) && c.id > 0) .sort((a, b) => a.id - b.id) } throw createError({ statusCode: 502, statusMessage: `Synology Camera.List failed: ${lastError?.message ?? lastError?.code ?? 'unknown error'}` }) } 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, } }