#!/usr/bin/env node /** * Standalone shell harness for the surveillance-recording lookup. * * Mirrors what server/api/surveillance/shipment-recording/init.post.ts does * but talks to Synology directly so we can verify the timezone shift and * the listing window without going through the Nuxt request stack. * * Usage: * node scripts/test-synology-recording.mjs --ts 2026-05-03T08:04:42Z * node scripts/test-synology-recording.mjs --ts 2026-05-03T08:04:42Z --pre 120 --post 60 * node scripts/test-synology-recording.mjs --ts 2026-05-03T08:04:42Z --no-shift # skip Berlin offset * node scripts/test-synology-recording.mjs --ts 2026-05-03T08:04:42Z --download /tmp/clip.mp4 * * Env (reads .env from repo root automatically): * SYNOLOGY_URL, SYNOLOGY_USER, SYNOLOGY_PASSWORD, SYNOLOGY_CAMERA_IDS */ import { readFileSync, createWriteStream } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, resolve } from 'node:path' import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const repoRoot = resolve(__dirname, '..') // ---------- tiny .env loader (no dependency) ---------------------------- function loadEnv() { for (const name of ['.env', '.env-prod', '.env.local']) { try { const text = readFileSync(resolve(repoRoot, name), 'utf8') for (const raw of text.split('\n')) { const line = raw.trim() if (!line || line.startsWith('#')) continue const eq = line.indexOf('=') if (eq < 0) continue const k = line.slice(0, eq).trim() let v = line.slice(eq + 1).trim() if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { v = v.slice(1, -1) } if (!(k in process.env)) process.env[k] = v } // Use the first one we find — .env wins over .env-prod by listing // order above. Stop after one to mirror the live runtime. return } catch { /* try next */ } } } loadEnv() // ---------- arg parsing ------------------------------------------------- const args = Object.fromEntries( process.argv.slice(2).reduce((acc, cur, i, arr) => { if (cur.startsWith('--')) { const key = cur.slice(2) const next = arr[i + 1] if (!next || next.startsWith('--')) acc.push([key, true]) else acc.push([key, next]) } return acc }, []) ) const tsArg = args.ts || args.anchor if (!tsArg) { console.error('Required: --ts ') process.exit(2) } const pre = Number(args.pre ?? process.env.SYNOLOGY_PRE_SECONDS ?? 120) const post = Number(args.post ?? process.env.SYNOLOGY_POST_SECONDS ?? 60) const noShift = !!args['no-shift'] const downloadPath = typeof args.download === 'string' ? args.download : null const cameraIdsRaw = (args.cams || process.env.SYNOLOGY_CAMERA_IDS || '2').toString() const cameraIds = cameraIdsRaw.split(/[,\s]+/).map(Number).filter(n => Number.isFinite(n) && n > 0) // ---------- timezone helpers (must match init.post.ts) ------------------ function berlinOffsetSeconds(unixSeconds) { const ms = unixSeconds * 1000 const date = new Date(ms) const fmt = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', }) const parts = {} for (const p of fmt.formatToParts(date)) if (p.type !== 'literal') parts[p.type] = p.value const berlinAsUtcMs = Date.UTC( Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second) ) return Math.round((berlinAsUtcMs - ms) / 1000) } function parseTs(s) { const t = Math.floor(new Date(s).getTime() / 1000) if (!Number.isFinite(t) || t <= 0) return null return noShift ? t : t + berlinOffsetSeconds(t) } const fmtUtc = ts => new Date(ts * 1000).toISOString().replace('.000', '') const fmtBerlin = ts => new Date(ts * 1000).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', hour12: false }) // ---------- Synology calls ---------------------------------------------- const url = (process.env.SYNOLOGY_URL || '').replace(/\/+$/, '') const user = process.env.SYNOLOGY_USER || '' const password = process.env.SYNOLOGY_PASSWORD || '' if (!url || !user || !password) { console.error('Missing SYNOLOGY_URL / SYNOLOGY_USER / SYNOLOGY_PASSWORD in env.') process.exit(2) } function entryUrl(params) { const qs = new URLSearchParams() for (const [k, v] of Object.entries(params)) qs.set(k, String(v)) return `${url}/webapi/entry.cgi?${qs.toString()}` } async function login() { const target = entryUrl({ api: 'SYNO.API.Auth', method: 'login', version: 7, account: user, passwd: password, session: 'SurveillanceStation', format: 'sid' }) const res = await fetch(target) const json = await res.json().catch(() => null) if (!json?.success || !json?.data?.sid) { throw new Error(`Login failed: ${JSON.stringify(json?.error || { http: res.status })}`) } return json.data.sid } async function logout(sid) { const target = entryUrl({ api: 'SYNO.API.Auth', method: 'logout', version: 7, _sid: sid, session: 'SurveillanceStation' }) await fetch(target).catch(() => null) } function parseStartFromFilePath(filePath) { if (!filePath) return null const m = /([0-9]{13})-[0-9]+\.mp4$/.exec(filePath) if (!m) return null const ms = Number(m[1]) return Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : null } async function listRecordings(sid, fromTs, toTs, label) { const camCsv = cameraIds.join(',') const attempts = [{ cameraIds: camCsv }, { cameraIds: `[${camCsv}]` }, {}] for (const extra of attempts) { const target = entryUrl({ api: 'SYNO.SurveillanceStation.Recording', method: 'List', version: 6, fromTime: fromTs, toTime: toTs, _sid: sid, ...extra }) const res = await fetch(target) if (!res.ok) continue const json = await res.json().catch(() => null) if (!json?.success) continue const items = json?.data?.recordings || json?.data?.events || [] const camSet = new Set(cameraIds) return items .filter(r => Object.keys(extra).length || camSet.has(Number(r?.camera_id ?? r?.cameraId))) .map(r => ({ id: r?.id ?? r?.recordId, cameraId: r?.camera_id ?? r?.cameraId ?? null, startTime: r?.startTime ?? r?.start_time ?? r?.recordingTime ?? r?.recordTime ?? parseStartFromFilePath(r?.filePath ?? r?.file_path), filePath: r?.filePath ?? r?.file_path ?? null, })) } return [] } async function downloadClip(sid, recordingId, outPath, offsetMs, playMs) { const params = { api: 'SYNO.SurveillanceStation.Recording', method: 'Download', version: 6, id: recordingId, mountId: 0, _sid: sid, } if (Number.isFinite(offsetMs)) params.offsetTimeMs = offsetMs if (Number.isFinite(playMs)) params.playTimeMs = playMs const target = entryUrl(params) const res = await fetch(target) if (!res.ok || !res.body) throw new Error(`Download failed (HTTP ${res.status})`) await pipeline(Readable.fromWeb(res.body), createWriteStream(outPath)) } // ---------- main -------------------------------------------------------- const rawAnchor = Math.floor(new Date(tsArg).getTime() / 1000) const offset = berlinOffsetSeconds(rawAnchor) const anchor = parseTs(tsArg) const fromTs = anchor - pre const toTs = anchor + post console.log('=== Input ===') console.log(` --ts ${tsArg}`) console.log(` pre/post ${pre}s / ${post}s`) console.log(` cameras ${cameraIds.join(',')}`) console.log(` shift ${noShift ? 'OFF (raw UTC)' : `ON (+${offset}s = ${(offset / 3600).toFixed(0)}h Berlin)`}`) console.log() console.log('=== Anchors ===') console.log(` iDempiere "Z" ${tsArg} (parsed as UTC: ${rawAnchor})`) console.log(` Berlin clock ${fmtBerlin(rawAnchor)} (real Berlin local for that UTC moment)`) console.log(` Query anchor ${anchor} (${fmtUtc(anchor)} interpreted as UTC)`) console.log(` Query window [${fromTs}, ${toTs}] → ${fmtUtc(fromTs)} → ${fmtUtc(toTs)}`) console.log() const sid = await login() console.log(`✓ Logged in (sid ${sid.slice(0, 8)}…)`) try { console.log() console.log('=== Narrow listing (fromTs/toTs as-is) ===') const narrow = await listRecordings(sid, fromTs, toTs) console.log(` ${narrow.length} hit(s)`) for (const r of narrow) { console.log(` id=${r.id} cam=${r.cameraId} start=${r.startTime} (${r.startTime ? fmtUtc(r.startTime) : '?'}) file=${r.filePath}`) } // Cameras are motion-triggered → chunks are short and start at irregular // times (not on 30-min boundaries). The chunk that actually contains // the anchor is the one with the LATEST start ≤ anchor — closest by // absolute distance can prefer a chunk that started a few seconds AFTER // the anchor and miss the moment. const before = narrow.filter(r => Number.isFinite(r.startTime) && r.startTime <= anchor) let chosen = null if (before.length) { chosen = before.reduce((best, r) => (r.startTime > best.startTime ? r : best), before[0]) console.log() console.log('=== Chosen chunk (latest start ≤ anchor) ===') } else if (narrow.length) { chosen = narrow.reduce((best, r) => ((r.startTime ?? Infinity) < (best.startTime ?? Infinity) ? r : best), narrow[0]) console.log() console.log('=== Chosen chunk (no chunk starts before anchor — taking earliest forward) ===') } let sliceStart, offsetMs, playMs if (chosen) { const st = Number(chosen.startTime) console.log(` id=${chosen.id} start=${fmtUtc(st)} anchor is ${anchor - st}s into chunk`) sliceStart = Math.max(fromTs, st) offsetMs = Math.max(0, (sliceStart - st) * 1000) playMs = Math.max(1000, (toTs - sliceStart) * 1000) console.log(` sliceStart=${fmtUtc(sliceStart)} offsetMs=${offsetMs} playMs=${playMs}`) } if (downloadPath && chosen) { console.log() console.log(`=== Downloading id=${chosen.id} → ${downloadPath} ===`) await downloadClip(sid, chosen.id, downloadPath, offsetMs, playMs) console.log(` ✓ saved`) } } finally { await logout(sid) console.log() console.log('✓ Logged out') }