mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
refactor: extract CCTV media and radio directory providers
This commit is contained in:
1 parent
fada0c661f
commit
79b4929b88
17 files changed
+1977
-1831
No files matched your search
@@ -0,0 +1,180 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { hashSeed, escapeXml } from './normalize.js';
|
||||
import { CCTV_FRAME_FETCH_TIMEOUT_MS } from './constants.js';
|
||||
/**
|
||||
* Generate a synthetic SVG billboard image for a CCTV camera placeholder.
|
||||
*
|
||||
* Produces a 960x540 SVG with a deterministic gradient (hue derived from
|
||||
* camera ID hash), scanline overlay, HUD-style grid, and text labels
|
||||
* showing camera name, city, ID, status, and current timestamp. Used
|
||||
* when no upstream image or Street View fallback is available.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.cameraId
|
||||
* @param {string} opts.label
|
||||
* @param {string} [opts.city]
|
||||
* @param {string} [opts.status]
|
||||
* @returns {string} SVG markup string.
|
||||
*/
|
||||
export function buildSyntheticCctvSvg({ cameraId, label, city, status }) {
|
||||
const seed = hashSeed(`${cameraId}:${label}:${city}`);
|
||||
const hue = seed % 360;
|
||||
const hue2 = (hue + 46) % 360;
|
||||
const now = new Date();
|
||||
const ts = now.toISOString().replace('T', ' ').replace('Z', 'Z').slice(0, 20);
|
||||
const safeLabel = escapeXml(label);
|
||||
const safeCity = escapeXml(city || 'GLOBAL GRID');
|
||||
const safeId = escapeXml(cameraId);
|
||||
const safeStatus = escapeXml(status || 'SYNTHETIC');
|
||||
|
||||
return `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="hsl(${hue}, 35%, 10%)" />
|
||||
<stop offset="60%" stop-color="hsl(${hue2}, 42%, 6%)" />
|
||||
<stop offset="100%" stop-color="#020509" />
|
||||
</linearGradient>
|
||||
<radialGradient id="flare" cx="0.22" cy="0.24" r="0.78">
|
||||
<stop offset="0%" stop-color="hsla(${hue2}, 100%, 65%, 0.35)" />
|
||||
<stop offset="100%" stop-color="hsla(${hue2}, 100%, 40%, 0)" />
|
||||
</radialGradient>
|
||||
<pattern id="scan" width="8" height="8" patternUnits="userSpaceOnUse">
|
||||
<rect width="8" height="8" fill="transparent" />
|
||||
<rect y="0" width="8" height="1" fill="rgba(255,255,255,0.08)" />
|
||||
<rect y="4" width="8" height="1" fill="rgba(255,255,255,0.05)" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="960" height="540" fill="url(#bg)" />
|
||||
<rect width="960" height="540" fill="url(#flare)" />
|
||||
<rect width="960" height="540" fill="url(#scan)" />
|
||||
<g stroke="rgba(123,233,255,0.25)" stroke-width="1" fill="none">
|
||||
<path d="M60 460 Q300 300 520 420 T900 320" />
|
||||
<path d="M100 160 Q340 40 620 130 T920 90" />
|
||||
<path d="M20 280 Q220 230 390 270 T760 250" />
|
||||
</g>
|
||||
<g fill="none" stroke="rgba(180,248,255,0.2)" stroke-width="1">
|
||||
<rect x="70" y="80" width="820" height="380" rx="8" />
|
||||
<line x1="70" y1="270" x2="890" y2="270" />
|
||||
<line x1="480" y1="80" x2="480" y2="460" />
|
||||
</g>
|
||||
<g fill="#9cefff" font-family="JetBrains Mono, monospace" text-transform="uppercase">
|
||||
<text x="74" y="54" font-size="16" letter-spacing="2">CCTV FEED PLACEHOLDER</text>
|
||||
<text x="74" y="512" font-size="14" letter-spacing="1.5">${safeLabel} · ${safeCity}</text>
|
||||
<text x="646" y="512" font-size="13" letter-spacing="1.2">${safeId}</text>
|
||||
<text x="704" y="54" font-size="15" letter-spacing="2">${escapeXml(ts)}</text>
|
||||
<text x="74" y="486" font-size="13" letter-spacing="1.3">${safeStatus}</text>
|
||||
</g>
|
||||
</svg>`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a fetch() response body to a Node.js Readable stream.
|
||||
*
|
||||
* Handles both Node-native streams (.pipe) and web ReadableStreams (.getReader).
|
||||
*
|
||||
* @param {ReadableStream|NodeJS.ReadableStream|null} body
|
||||
* @returns {import('stream').Readable|null}
|
||||
*/
|
||||
export function toReadable(body) {
|
||||
if (!body) return null;
|
||||
if (typeof body.pipe === 'function') return body;
|
||||
if (typeof body.getReader === 'function') {
|
||||
return Readable.fromWeb(body);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipe an upstream fetch Response (image or video) to the client HTTP response.
|
||||
*
|
||||
* Forwards Content-Type, Content-Length, Content-Range, Accept-Ranges, and
|
||||
* Cache-Control headers from the upstream. Falls back to buffered arrayBuffer
|
||||
* if the body is not streamable.
|
||||
*
|
||||
* @param {import('http').ServerResponse} res
|
||||
* @param {Response} upstream - fetch() Response object.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.sourceHeader='upstream'] - Value for X-CCTV-Source header.
|
||||
*/
|
||||
export async function proxyMediaResponse(res, upstream, { sourceHeader = 'upstream' } = {}) {
|
||||
const contentType = upstream.headers.get('content-type') || 'application/octet-stream';
|
||||
const cacheControl = upstream.headers.get('cache-control') || 'no-store';
|
||||
const contentLength = upstream.headers.get('content-length');
|
||||
const contentRange = upstream.headers.get('content-range');
|
||||
const acceptRanges = upstream.headers.get('accept-ranges');
|
||||
const headers = {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': cacheControl,
|
||||
'X-CCTV-Source': sourceHeader,
|
||||
};
|
||||
if (contentLength) headers['Content-Length'] = contentLength;
|
||||
if (contentRange) headers['Content-Range'] = contentRange;
|
||||
if (acceptRanges) headers['Accept-Ranges'] = acceptRanges;
|
||||
|
||||
// Cheap defense: reject an upstream that DECLARES an oversized fixed body.
|
||||
// Live MJPEG/HLS streams are unbounded by design and send no content-length,
|
||||
// so they pipe normally (piping streams to the client, never buffering).
|
||||
const MEDIA_DECLARED_CAP_BYTES = 64 * 1024 * 1024;
|
||||
if (Number.isFinite(Number(contentLength)) && Number(contentLength) > MEDIA_DECLARED_CAP_BYTES) {
|
||||
res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: 'Upstream media exceeds size cap' }));
|
||||
try { await upstream.body?.cancel(); } catch { /* no-op */ }
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(upstream.status, headers);
|
||||
|
||||
const stream = toReadable(upstream.body);
|
||||
if (!stream) {
|
||||
const buf = Buffer.from(await upstream.arrayBuffer());
|
||||
res.end(buf);
|
||||
return;
|
||||
}
|
||||
|
||||
stream.on('error', () => {
|
||||
if (!res.writableEnded) res.end();
|
||||
});
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one upstream CCTV image within the frame-refresh budget.
|
||||
*
|
||||
* A timeout is treated like every other upstream miss so the caller can
|
||||
* continue through the Street View and synthetic fallback chain. `fetchImpl`
|
||||
* and `timeoutMs` are injectable only to keep the timeout contract unit-testable.
|
||||
*
|
||||
* @param {string} url - Server-registered upstream image URL.
|
||||
* @param {object} [options]
|
||||
* @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation.
|
||||
* @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS] - Abort timeout.
|
||||
* @returns {Promise<{ok:true,body:Buffer,contentType:string}|null>}
|
||||
*/
|
||||
export async function fetchCctvImageFromUpstream(url, {
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
if (!url || !/^https?:\/\//i.test(url)) return null;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort(new DOMException('CCTV upstream frame fetch timed out', 'TimeoutError'));
|
||||
}, timeoutMs);
|
||||
try {
|
||||
const upstream = await fetchImpl(url, {
|
||||
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const contentType = upstream.headers.get('content-type') || '';
|
||||
if (!upstream.ok || !contentType.startsWith('image/')) return null;
|
||||
return {
|
||||
ok: true,
|
||||
body: Buffer.from(await upstream.arrayBuffer()),
|
||||
contentType,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user