Files
gods-eye-view/server/providers/cctv/catalog.js
T
Cliff Flood 51b89d3e4a feat(cctv): add the City of Toronto RESCU camera pack
The province's 511 pack covers Ontario's highways; this adds the municipal
network underneath it — roughly 336 cameras across downtown arterials plus
the Gardiner, DVP, Allen and Lake Shore, from the City's own open data.

The list is keyless and carries no frame URL, so frame URLs are built from
the convention the dataset documents (`CameraImages/loc####.jpg`) and a
camera number validated as plain digits. The frame proxy is therefore
pinned to the portal origin by construction rather than by trusting a
field. The list fetch refuses redirects, bounds the body it buffers, and
releases the transport on every rejection path.

The dataset publishes no camera facing. `D1`-`D4` look like a compass and
map trivially onto one, but they describe the *comparison* images — static
reference stills captured in 2016 and served from a different path — not
the live pan-tilt-zoom frame. A heading derived from them would be
confidently wrong for every camera in the city, so every camera takes the
shared id-hash fallback at low confidence, as headingless Austin, TfL,
Fintraffic and Calgary cameras do. A test pins that down so it is not
"fixed" later.

Names stay verbatim: the feed's labels are upper-case, and title-casing
would mangle MCCOWAN RD and ST HELEN'S AVE more often than it helped.

Carries the Open Government Licence – Toronto attribution the licence
requires. Disable with `CCTV_TORONTO_ENABLED=0`.
2026-10-13 18:54:59 -04:00

294 lines
10 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
import { cameraRegion, normalizeSourceItem } from './normalize.js';
import {
loadAustinSourcesFromOpenData,
loadCaltransSourcesFromOpenData,
loadTflSourcesFromOpenData,
loadOntarioSourcesFromOpenData,
loadFintrafficSourcesFromOpenData,
loadDriveBcSourcesFromOpenData,
loadTxdotSourcesFromOpenData,
loadTallinnSourcesFromCatalog,
loadTarkteeSourcesFromDatex,
loadWarendorfSourcesFromCatalog,
loadNswSourcesFromOpenData,
loadCalgarySourcesFromOpenData,
loadDelDOTSourcesFromOpenData,
loadVegvesenSourcesFromOpenData,
loadTorontoSourcesFromOpenData,
} from './sources.js';
/** Env kill switch: unset or anything but "0" means enabled. */
const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0';
/**
* Live open-data packs, in merge order. Adding a region is one entry here
* plus its loader in sources.js; the catalog cap is shared across entries
* round-robin (cap.js), so a new pack never silently evicts an older one.
* Each pack fails independently (allSettled) and is gated by its own env
* kill switch.
*/
const LIVE_PACKS = [
{ name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData },
{
name: 'caltrans',
enabled: () => true,
load: loadCaltransSourcesFromOpenData,
},
{
name: 'tfl',
enabled: () => envEnabled('CCTV_TFL_ENABLED'),
load: loadTflSourcesFromOpenData,
},
{
name: 'ontario',
enabled: () => envEnabled('CCTV_ONTARIO_ENABLED'),
load: loadOntarioSourcesFromOpenData,
},
{
name: 'fintraffic',
enabled: () => envEnabled('CCTV_FINTRAFFIC_ENABLED'),
load: loadFintrafficSourcesFromOpenData,
},
{
name: 'drivebc',
enabled: () => envEnabled('CCTV_DRIVEBC_ENABLED'),
load: loadDriveBcSourcesFromOpenData,
},
{
name: 'txdot',
enabled: () => envEnabled('CCTV_TXDOT_ENABLED'),
load: loadTxdotSourcesFromOpenData,
},
{
name: 'tallinn',
enabled: () => envEnabled('CCTV_TALLINN_ENABLED'),
load: loadTallinnSourcesFromCatalog,
},
{
name: 'tarktee',
enabled: () => envEnabled('CCTV_TARKTEE_ENABLED'),
load: loadTarkteeSourcesFromDatex,
},
{
name: 'warendorf',
enabled: () => envEnabled('CCTV_WARENDORF_ENABLED'),
load: loadWarendorfSourcesFromCatalog,
},
{
name: 'nsw',
enabled: () => envEnabled('CCTV_NSW_ENABLED'),
load: loadNswSourcesFromOpenData,
},
{
name: 'calgary',
enabled: () => envEnabled('CCTV_CALGARY_ENABLED'),
load: loadCalgarySourcesFromOpenData,
},
{
name: 'deldot',
enabled: () => envEnabled('CCTV_DELDOT_ENABLED'),
load: loadDelDOTSourcesFromOpenData,
},
{
name: 'vegvesen',
enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'),
load: loadVegvesenSourcesFromOpenData,
},
{
name: 'toronto',
enabled: () => envEnabled('CCTV_TORONTO_ENABLED'),
load: loadTorontoSourcesFromOpenData,
},
];
/**
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
*
* @returns {Array<object>} Array of raw source objects, or [] on error.
*/
function loadSourcesFromFile(sourceRoot) {
const sourceFile = process.env.CCTV_SOURCES_FILE || DEFAULT_CCTV_SOURCE_FILE;
const resolved = path.isAbsolute(sourceFile)
? sourceFile
: path.resolve(sourceRoot, sourceFile);
try {
if (!fs.existsSync(resolved)) return [];
const raw = fs.readFileSync(resolved, 'utf8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
console.warn(
'[CCTV] failed to read source file:',
resolved,
error?.message || error,
);
return [];
}
}
/**
* Load CCTV sources from the CCTV_SOURCES_JSON env variable (inline JSON).
*
* @returns {Array<object>} Array of raw source objects, or [] if unset/invalid.
*/
function loadSourcesFromEnv() {
const raw = process.env.CCTV_SOURCES_JSON;
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/** Create an independent catalog rooted in the consuming application. */
export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
/** @type {Array<object>} Cached merged + normalized CCTV source list. */
let _cctvSourceCache = [];
/** @type {number} Epoch-ms when the source cache was last refreshed. */
let _cctvSourceCacheAt = 0;
/** @type {Promise<Array<object>>|null} In-flight refresh, shared by concurrent
* callers so a post-TTL burst launches ONE refetch, not one per request. */
let _cctvSourceInflight = null;
/** @type {Array<{pack: string, available: number, served: number}>} Packs
* that serve fewer cameras than they offer. */
let _cctvTrimmedPacks = [];
/**
* Assemble and cache the merged CCTV source list.
*
* Merges every source pack (live open-data packs, local file, env
* variable), deduplicates by ID, shares the catalog cap fairly across
* packs, and caches for CCTV_SOURCE_CACHE_MS.
*
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
*/
async function getCctvSources() {
const now = Date.now();
if (
_cctvSourceCache.length &&
now - _cctvSourceCacheAt <= CCTV_SOURCE_CACHE_MS
) {
return _cctvSourceCache;
}
// Single-flight: a burst of requests arriving past the TTL shares ONE refresh
// instead of each launching the full multi-provider refetch. The `.finally`
// clears the ref so the next post-TTL cycle starts fresh.
if (_cctvSourceInflight) return _cctvSourceInflight;
_cctvSourceInflight = refreshCctvSources().finally(() => {
_cctvSourceInflight = null;
});
return _cctvSourceInflight;
}
/**
* Assemble and cache the merged CCTV source list from file/env + live packs.
* Always resolves (loaders self-catch to []); on a fully-empty refresh with a
* good prior catalog it serves stale rather than blanking the CCTV layer.
*
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
*/
async function refreshCctvSources() {
const fromFile = loadSourcesFromFile(sourceRoot);
const fromEnv = loadSourcesFromEnv();
const forceAustin =
String(process.env.CCTV_FORCE_AUSTIN || '').trim() === '1';
const preferAustin =
String(process.env.CCTV_PREFER_AUSTIN || '1').trim() !== '0';
// Live open-data packs load unless a file/env pack is configured and live
// packs aren't forced — the same gate that governed the Austin-only fetch
// now governs every entry in LIVE_PACKS.
const needsLiveSources =
forceAustin || (fromFile.length + fromEnv.length === 0 && preferAustin);
const liveResults = needsLiveSources
? await Promise.allSettled(
// Invoked inside the promise so a loader that throws synchronously
// (a file-based pack on a malformed row) is isolated like any other
// failed pack instead of rejecting the whole refresh.
LIVE_PACKS.map((pack) =>
Promise.resolve().then(() =>
pack.enabled() ? pack.load({ sourceRoot }) : [],
),
),
)
: [];
// Live packs first so file/env overrides win on duplicate IDs; each pack
// keeps its own priority order and the catalog cap is shared fairly.
const normalizePack = (name, items) => ({
name,
sources: items
.filter((item) => item && typeof item === 'object')
.map((item) => ({ ...normalizeSourceItem(item), pack: name }))
.filter((item) => item.id),
available: Number.isFinite(items.available)
? items.available
: items.length,
region: items.region ?? null,
});
const packs = [
...LIVE_PACKS.map((pack, index) =>
normalizePack(
pack.name,
liveResults[index]?.status === 'fulfilled'
? liveResults[index].value
: [],
),
),
normalizePack('file', fromFile),
normalizePack('env', fromEnv),
];
const maxCount = resolveCatalogCap(process.env.CCTV_MAX_SOURCES);
const allocation = allocateSourceCap(packs, maxCount);
// Shipped ground heights (src/data/local_data/cctv_ground_heights/, produced by
// scripts/precompute-cctv-heights.mjs) ride along on the served source so
// the client can place a camera and its monitor plane with zero sampling.
const capped = joinGroundHeights(
allocation.sources,
loadGroundHeights(sourceRoot),
);
const trimmed = allocation.packs.filter((pack) => pack.kept < pack.offered);
// Each pack's own nearest-first cap and the shared catalog cap both trim.
const trimmedPacks = packs
.map((pack, index) => ({
pack: pack.name,
available: Math.max(pack.available, allocation.packs[index].offered),
served: allocation.packs[index].kept,
// Where the pack's cameras are, including those not served.
region: pack.region ?? cameraRegion(pack.sources),
}))
.filter((pack) => pack.served < pack.available);
if (trimmed.length) {
const detail = trimmed
.map((pack) => `${pack.name} ${pack.kept}/${pack.offered}`)
.join(', ');
console.warn(
`[CCTV] source catalog exceeds cap ${maxCount}; shared round-robin across packs (${detail}). Raise CCTV_MAX_SOURCES or lower a per-pack cap to change the mix.`,
);
}
if (capped.length > 0 || _cctvSourceCache.length === 0) {
_cctvSourceCache = capped;
_cctvTrimmedPacks = trimmedPacks;
} else {
// Every source came back empty (all live packs timed out / upstream outage)
// but a good catalog is already cached — serve it stale rather than blanking
// every CCTV route. Advancing the timestamp waits one TTL before retrying,
// which (with single-flight) bounds load on a persistently-down upstream.
console.warn(
`[CCTV] source refresh returned empty; serving ${_cctvSourceCache.length} stale cameras`,
);
}
_cctvSourceCacheAt = Date.now();
return _cctvSourceCache;
}
/** Packs serving fewer cameras than they offer, as of the last refresh. */
getCctvSources.trimmedPacks = () => _cctvTrimmedPacks;
return getCctvSources;
}