mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
- A background catalog refresh that rejects now keeps the old catalog for another TTL, as an empty refresh already did, so a refresh that keeps failing is retried once per TTL instead of on every request. - Trim notes are keyed by the source list they describe, and /sources asks for the list it served, so a request answered during a refresh never pairs the old cameras with the new list's notes. - Tests: a failed background refresh (one warning, no unhandled rejection, no retry before the TTL, trim notes stay with their list) and an empty background refresh (old catalog kept, no upstream fetch before the TTL). The race assertion's message now names the failure; its timer is cleared. - The Tarktee test no longer imports the new constant, so on main it fails on its own assertion instead of taking the whole file down. - precompute-cctv-heights waits 60 s for a cold catalog, past Tarktee's 30 s. - CURRENT-STATE notes the 30 s bound and the retry spacing. Co-Authored-By: Claude Opus 5.5 <[email protected]>
587 lines
21 KiB
JavaScript
587 lines
21 KiB
JavaScript
import { createCctvCatalog } from './cctv/catalog.js';
|
|
import {
|
|
normalizeFeedType,
|
|
isVideoFeedType,
|
|
toFiniteNumber,
|
|
} from './cctv/normalize.js';
|
|
import {
|
|
buildSyntheticCctvSvg,
|
|
proxyMediaResponse,
|
|
fetchCctvImageFromUpstream,
|
|
fetchTxdotSnapshot,
|
|
fetchCctvMediaUpstream,
|
|
watchDownstreamClose,
|
|
} from './cctv/media.js';
|
|
import {
|
|
CCTV_FRAME_FETCH_TIMEOUT_MS,
|
|
CCTV_MAX_SOURCES_CEILING,
|
|
} from './cctv/constants.js';
|
|
import { sanitizeCctvRangeHeader } from './cctv/range.js';
|
|
import { createHlsPuller } from './cctv/stream.js';
|
|
import { googleServerApiKey } from './places/google-key.js';
|
|
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
|
/**
|
|
* Vite plugin: CCTV camera proxy with source registry, frame/media serving,
|
|
* fallback chain (upstream -> Street View -> synthetic SVG), and health tracking.
|
|
*
|
|
* Endpoints:
|
|
* GET /api/cctv/sources — list all registered camera sources
|
|
* GET /api/cctv/health — per-camera health/status report
|
|
* GET /api/cctv/stream/:id — stream info (feedType, URLs) for a camera
|
|
* GET /api/cctv/media/:id — proxy live video media from upstream
|
|
* GET /api/cctv/frame/:id — single frame with fallback chain
|
|
*
|
|
* @returns {import('vite').Plugin}
|
|
*/
|
|
export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|
const getCctvSources = createCctvCatalog({ sourceRoot });
|
|
/** @type {Map<string,{id:string,status:string,sourceKind:string,label:string,message:string,updatedAt:number}>} */
|
|
const health = new Map();
|
|
/** Cap on health map entries to prevent unbounded growth. Sized to the
|
|
* CCTV_MAX_SOURCES ceiling so health/status observability is never evicted
|
|
* for any catalog the proxy can actually serve. */
|
|
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
|
|
/** Live HLS strategies (see ./cctv/stream.js). Shared across dev and preview. */
|
|
const puller = createHlsPuller();
|
|
|
|
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
|
|
const setHealth = (cameraId, patch) => {
|
|
// Evict oldest entries if the health map grows beyond the cap
|
|
if (!health.has(cameraId) && health.size >= HEALTH_MAX_ENTRIES) {
|
|
const oldest = health.keys().next().value;
|
|
health.delete(oldest);
|
|
}
|
|
const prev = health.get(cameraId) || {};
|
|
health.set(cameraId, {
|
|
id: cameraId,
|
|
status: patch.status || prev.status || 'unknown',
|
|
sourceKind: patch.sourceKind || prev.sourceKind || 'unknown',
|
|
label: patch.label || prev.label || '',
|
|
message: patch.message || prev.message || '',
|
|
updatedAt: Date.now(),
|
|
});
|
|
};
|
|
|
|
/** Snapshot all camera health entries as an array. */
|
|
const listHealth = () => Array.from(health.values());
|
|
|
|
/** Build a JSON payload describing stream info (feedType, URLs) for a camera. */
|
|
const buildStreamPayload = (source, cameraId) => {
|
|
const feedType = normalizeFeedType(source?.feedType || 'image');
|
|
return {
|
|
id: cameraId,
|
|
feedType,
|
|
mediaUrl: isVideoFeedType(feedType)
|
|
? `/api/cctv/media/${encodeURIComponent(cameraId)}`
|
|
: null,
|
|
frameUrl: `/api/cctv/frame/${encodeURIComponent(cameraId)}`,
|
|
provider: source?.provider || '',
|
|
sourceKind:
|
|
source?.sourceKind || (source?.url ? 'configured' : 'fallback'),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Fetch a Google Street View static image as a fallback frame. Server-side
|
|
* call, never reaches the browser — prefers GOOGLE_MAPS_SERVER_API_KEY
|
|
* (#33: a key scoped to Street View Static/Places, restricted by server IP
|
|
* rather than HTTP referrer) and falls back to the browser-exposed
|
|
* GOOGLE_MAPS_API_KEY for setups that haven't split the two yet.
|
|
*/
|
|
const streetViewFallback = async ({ lat, lon, heading, fov, pitch }) => {
|
|
const streetViewKey = googleServerApiKey();
|
|
if (!streetViewKey || !Number.isFinite(lat) || !Number.isFinite(lon))
|
|
return null;
|
|
try {
|
|
const sv = new URL('https://maps.googleapis.com/maps/api/streetview');
|
|
sv.searchParams.set('size', '960x540');
|
|
sv.searchParams.set('location', `${lat},${lon}`);
|
|
sv.searchParams.set(
|
|
'heading',
|
|
String(Number.isFinite(heading) ? heading : 0),
|
|
);
|
|
sv.searchParams.set(
|
|
'fov',
|
|
String(Number.isFinite(fov) ? Math.max(20, Math.min(120, fov)) : 80),
|
|
);
|
|
sv.searchParams.set(
|
|
'pitch',
|
|
String(Number.isFinite(pitch) ? Math.max(-40, Math.min(20, pitch)) : 0),
|
|
);
|
|
sv.searchParams.set('source', 'outdoor');
|
|
sv.searchParams.set('return_error_code', 'true');
|
|
sv.searchParams.set('key', streetViewKey);
|
|
|
|
const svResp = await fetch(sv.toString(), {
|
|
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' },
|
|
signal: AbortSignal.timeout(CCTV_FRAME_FETCH_TIMEOUT_MS),
|
|
});
|
|
const svType = svResp.headers.get('content-type') || '';
|
|
if (!svResp.ok || !svType.startsWith('image/')) return null;
|
|
|
|
return {
|
|
ok: true,
|
|
body: Buffer.from(await svResp.arrayBuffer()),
|
|
contentType: svType,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const installMiddleware = (server) => {
|
|
server.httpServer?.on('close', () => {
|
|
puller.shutdown();
|
|
});
|
|
server.middlewares.use('/api/cctv', async (req, res) => {
|
|
try {
|
|
const sources = await getCctvSources();
|
|
const sourceById = new Map(
|
|
sources.map((source) => [source.id, source]),
|
|
);
|
|
const url = new URL(req.url || '/', 'http://localhost');
|
|
|
|
if (url.pathname === '/sources') {
|
|
const body = {
|
|
trimmedPacks: getCctvSources.trimmedPacks?.(sources) ?? [],
|
|
sources: sources.map((source) => ({
|
|
id: source.id,
|
|
pack: source.pack,
|
|
name: source.name,
|
|
city: source.city,
|
|
cityId: source.cityId,
|
|
provider: source.provider,
|
|
lat: source.lat,
|
|
lon: source.lon,
|
|
headingDeg: source.headingDeg,
|
|
headingConfidence: source.headingConfidence || '',
|
|
pitchDeg: source.pitchDeg,
|
|
fovDeg: source.fovDeg,
|
|
rangeM: source.rangeM,
|
|
mountHeightM: source.mountHeightM,
|
|
groundElevationM: source.groundElevationM,
|
|
feedType: normalizeFeedType(source.feedType),
|
|
sourceKind:
|
|
source.sourceKind || (source.url ? 'configured' : 'fallback'),
|
|
poseSource: source.poseSource,
|
|
model: source.model || '',
|
|
manufacturer: source.manufacturer || '',
|
|
license: source.license,
|
|
credit: source.credit || '',
|
|
code: source.code || '',
|
|
groundHeights: source.groundHeights || null,
|
|
})),
|
|
};
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(JSON.stringify(body));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/health') {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(JSON.stringify({ cameras: listHealth() }));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/stream/')) {
|
|
const cameraId =
|
|
decodeURIComponent(url.pathname.replace('/stream/', '').trim()) ||
|
|
'camera';
|
|
const source = sourceById.get(cameraId);
|
|
const payload = buildStreamPayload(source, cameraId);
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(JSON.stringify(payload));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/media/')) {
|
|
const match = /^\/media\/([^/]+)(?:\/(seg_(\d+)\.ts))?$/.exec(
|
|
url.pathname,
|
|
);
|
|
if (!match) {
|
|
res.writeHead(404);
|
|
res.end();
|
|
return;
|
|
}
|
|
const cameraId = decodeURIComponent(match[1]);
|
|
const source = sourceById.get(cameraId);
|
|
const mediaUrl = source?.url || '';
|
|
const feedType = normalizeFeedType(source?.feedType || 'image');
|
|
// Live feeds only. A still camera is served by /frame, whose fetch
|
|
// keeps redirects on the registered origin, accepts only image/* and
|
|
// caps the body; relaying it here would bypass all three.
|
|
if (!isVideoFeedType(feedType) && feedType !== 'mjpeg') {
|
|
res.writeHead(404, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(
|
|
JSON.stringify({
|
|
error: 'No live media for this camera; use the frame endpoint',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
const leaseId = url.searchParams.get('lease');
|
|
if (feedType === 'hls' && !/^[a-f0-9-]{36}$/i.test(leaseId || '')) {
|
|
res.writeHead(400);
|
|
res.end();
|
|
return;
|
|
}
|
|
if (req.method === 'DELETE') {
|
|
if (leaseId) puller.release(cameraId, leaseId);
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
if (req.method !== 'GET') {
|
|
res.writeHead(405);
|
|
res.end();
|
|
return;
|
|
}
|
|
if (feedType === 'hls') {
|
|
if (
|
|
!/^https?:\/\//i.test(mediaUrl) ||
|
|
!/\.m3u8(?:\?|$)/i.test(mediaUrl)
|
|
) {
|
|
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
res.end(
|
|
JSON.stringify({
|
|
error:
|
|
'This stream requires an unsupported transport; use the frame fallback',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (match[2]) {
|
|
const body = puller.getSegment(
|
|
cameraId,
|
|
url.searchParams.get('session'),
|
|
Number(match[3]),
|
|
leaseId,
|
|
);
|
|
res.writeHead(body ? 200 : 404, {
|
|
'Content-Type': 'video/mp2t',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(body || undefined);
|
|
return;
|
|
}
|
|
const downstream = watchDownstreamClose(res);
|
|
let entry;
|
|
const cancelPending = () => {
|
|
if (entry) puller.release(cameraId, leaseId);
|
|
};
|
|
try {
|
|
entry = await puller.ensure(cameraId, mediaUrl, leaseId);
|
|
if (downstream.closed) {
|
|
cancelPending();
|
|
return;
|
|
}
|
|
downstream.signal.addEventListener('abort', cancelPending, {
|
|
once: true,
|
|
});
|
|
if (!(await puller.waitReady(entry, downstream.signal)))
|
|
throw new Error('Stream unavailable');
|
|
const playlist = await puller.buildPlaylist(
|
|
entry,
|
|
cameraId,
|
|
leaseId,
|
|
);
|
|
if (downstream.closed) return;
|
|
if (!playlist) throw new Error('Stream unavailable');
|
|
setHealth(cameraId, {
|
|
status: 'ok',
|
|
sourceKind: 'live',
|
|
label: source?.provider || 'Configured source',
|
|
message: 'Live HLS connected',
|
|
});
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/vnd.apple.mpegurl',
|
|
'Cache-Control': 'no-store',
|
|
'X-CCTV-Source': 'hls-pull',
|
|
'X-CCTV-Session': entry.token,
|
|
});
|
|
res.end(playlist);
|
|
} catch {
|
|
setHealth(cameraId, {
|
|
status: 'degraded',
|
|
sourceKind: 'fallback',
|
|
label: source?.provider || 'Configured source',
|
|
message: 'Live HLS unavailable',
|
|
});
|
|
if (!downstream.closed) {
|
|
res.writeHead(503, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
'Retry-After': '2',
|
|
});
|
|
res.end(JSON.stringify({ error: 'Live stream unavailable' }));
|
|
}
|
|
} finally {
|
|
downstream.signal.removeEventListener('abort', cancelPending);
|
|
}
|
|
return;
|
|
}
|
|
if (match[2] || req.method !== 'GET') {
|
|
res.writeHead(404);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
if (!mediaUrl || !/^https?:\/\//i.test(mediaUrl)) {
|
|
setHealth(cameraId, {
|
|
status: 'degraded',
|
|
sourceKind: 'fallback',
|
|
label: source?.provider || 'No upstream URL',
|
|
message: 'No stream URL configured',
|
|
});
|
|
res.writeHead(404, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(
|
|
JSON.stringify({
|
|
error: 'No media URL configured for this camera',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Bound before the request goes out: most of the wait is before any
|
|
// header arrives, and a viewer who leaves during it must take the
|
|
// upstream request with them.
|
|
const downstream = watchDownstreamClose(res);
|
|
try {
|
|
const upstreamHeaders = {
|
|
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
|
|
};
|
|
// Never forward the client's own string: a Range this proxy does
|
|
// not accept is dropped and the request proceeds without one.
|
|
const requestRange = sanitizeCctvRangeHeader(req.headers?.range);
|
|
if (requestRange) upstreamHeaders.Range = requestRange;
|
|
const upstream = await fetchCctvMediaUpstream(mediaUrl, {
|
|
headers: upstreamHeaders,
|
|
signal: downstream.signal,
|
|
});
|
|
if (downstream.closed) {
|
|
// The headers arrived for a viewer who is no longer there.
|
|
try {
|
|
await upstream.body?.cancel();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
return;
|
|
}
|
|
const contentType = upstream.headers.get('content-type') || '';
|
|
if (!upstream.ok) {
|
|
try {
|
|
await upstream.body?.cancel();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
setHealth(cameraId, {
|
|
status: 'degraded',
|
|
sourceKind: 'upstream',
|
|
label: source?.provider || 'Configured source',
|
|
message: `Upstream HTTP ${upstream.status}`,
|
|
});
|
|
res.writeHead(upstream.status, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(
|
|
JSON.stringify({
|
|
error: `Upstream returned ${upstream.status}`,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
isVideoFeedType(feedType) &&
|
|
!(
|
|
contentType.startsWith('video/') ||
|
|
contentType.includes('mpegurl')
|
|
)
|
|
) {
|
|
setHealth(cameraId, {
|
|
status: 'degraded',
|
|
sourceKind: 'upstream',
|
|
label: source?.provider || 'Configured source',
|
|
message: `Unexpected media type ${contentType || 'unknown'}`,
|
|
});
|
|
} else {
|
|
setHealth(cameraId, {
|
|
status: 'ok',
|
|
sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot',
|
|
label: source?.provider || 'Configured source',
|
|
message: isVideoFeedType(feedType)
|
|
? 'Live stream connected'
|
|
: 'Snapshot feed connected',
|
|
});
|
|
}
|
|
|
|
await proxyMediaResponse(res, upstream, {
|
|
sourceHeader: isVideoFeedType(feedType)
|
|
? 'live-media'
|
|
: 'upstream-image',
|
|
});
|
|
return;
|
|
} catch (error) {
|
|
if (downstream.closed) {
|
|
// The viewer left mid-request. That is not a camera fault and
|
|
// there is nobody to answer.
|
|
return;
|
|
}
|
|
const timedOut =
|
|
error?.name === 'AbortError' || error?.name === 'TimeoutError';
|
|
// GET /api/cctv/health serializes `message`, and the CCTV panel
|
|
// renders it as a status label, so the raw error would leave the
|
|
// server by a different door than the sanitized body below and
|
|
// land on screen.
|
|
//
|
|
// The log line drops the text too, unlike the catch at the bottom
|
|
// of this file: a media-fetch failure names the camera's upstream
|
|
// host, and these lines get pasted into issues. The status codes
|
|
// below carry the diagnosis — 504 for a timeout, 502 otherwise.
|
|
console.warn('[CCTV Proxy] media fetch failed');
|
|
setHealth(cameraId, {
|
|
status: 'degraded',
|
|
sourceKind: 'upstream',
|
|
label: source?.provider || 'Configured source',
|
|
message: 'Media fetch failed',
|
|
});
|
|
res.writeHead(timedOut ? 504 : 502, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(
|
|
JSON.stringify({
|
|
error: timedOut
|
|
? 'Upstream media timeout'
|
|
: 'Media proxy failed',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!url.pathname.startsWith('/frame/')) {
|
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'not found' }));
|
|
return;
|
|
}
|
|
|
|
const cameraId =
|
|
decodeURIComponent(url.pathname.replace('/frame/', '').trim()) ||
|
|
'camera';
|
|
const source = sourceById.get(cameraId);
|
|
const label = url.searchParams.get('label') || source?.name || cameraId;
|
|
const city = url.searchParams.get('city') || source?.city || '';
|
|
const lat = Number(url.searchParams.get('lat') || source?.lat);
|
|
const lon = Number(url.searchParams.get('lon') || source?.lon);
|
|
const heading = Number(
|
|
url.searchParams.get('heading') || source?.headingDeg,
|
|
);
|
|
const fov = Number(url.searchParams.get('fov') || source?.fovDeg);
|
|
const pitch = Number(url.searchParams.get('pitch') || source?.pitchDeg);
|
|
|
|
// Only use server-registered upstream URLs — never accept client-supplied URLs
|
|
// (prevents SSRF via ?upstream= query parameter)
|
|
const upstreamCandidate =
|
|
source?.snapshotUrl ||
|
|
(!isVideoFeedType(normalizeFeedType(source?.feedType))
|
|
? source?.url
|
|
: '');
|
|
|
|
const upstreamImage =
|
|
source?.sourceKind === 'txdot-its'
|
|
? await fetchTxdotSnapshot(upstreamCandidate)
|
|
: await fetchCctvImageFromUpstream(upstreamCandidate);
|
|
if (upstreamImage?.ok) {
|
|
setHealth(cameraId, {
|
|
status: 'ok',
|
|
sourceKind: 'snapshot',
|
|
label: source?.provider || 'Configured source',
|
|
message: 'Upstream snapshot active',
|
|
});
|
|
res.writeHead(200, {
|
|
'Content-Type': upstreamImage.contentType,
|
|
'Cache-Control': 'no-store',
|
|
'X-CCTV-Source': 'upstream-image',
|
|
});
|
|
res.end(upstreamImage.body);
|
|
return;
|
|
}
|
|
|
|
const sv = await streetViewFallback({
|
|
lat,
|
|
lon,
|
|
heading,
|
|
fov,
|
|
pitch,
|
|
});
|
|
if (sv?.ok) {
|
|
setHealth(cameraId, {
|
|
status: 'degraded',
|
|
sourceKind: 'streetview',
|
|
label: 'Google Street View',
|
|
message: 'Fallback Street View frame',
|
|
});
|
|
res.writeHead(200, {
|
|
'Content-Type': sv.contentType,
|
|
'Cache-Control': 'no-store',
|
|
'X-CCTV-Source': 'streetview',
|
|
});
|
|
res.end(sv.body);
|
|
return;
|
|
}
|
|
|
|
const svg = buildSyntheticCctvSvg({
|
|
cameraId,
|
|
label,
|
|
city,
|
|
status: source?.url
|
|
? 'UPSTREAM UNAVAILABLE'
|
|
: 'NO UPSTREAM CONFIGURED',
|
|
});
|
|
|
|
setHealth(cameraId, {
|
|
status: 'degraded',
|
|
sourceKind: 'synthetic',
|
|
label: source?.provider || 'Synthetic fallback',
|
|
message: source?.url
|
|
? 'Upstream unavailable'
|
|
: 'No source configured',
|
|
});
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'image/svg+xml',
|
|
'Cache-Control': 'no-store',
|
|
'X-CCTV-Source': 'synthetic',
|
|
});
|
|
res.end(svg);
|
|
} catch (error) {
|
|
console.error('[CCTV Proxy]', error?.message || String(error));
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'CCTV proxy error' }));
|
|
}
|
|
});
|
|
};
|
|
return {
|
|
name: 'cctv-proxy',
|
|
configureServer: installMiddleware,
|
|
configurePreviewServer: installMiddleware,
|
|
};
|
|
}
|