feat: add Sydney public traffic cameras

This commit is contained in:
NovaSoft Australia 2026-09-09 22:05:30 +10:00
parent 759652207f
commit 0986310ddb
5 changed files with 114 additions and 9 deletions

View File

@ -31,6 +31,7 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
'austin transportation & public works': 5 * 60 * 1000,
'transport for london': 3 * 60 * 1000,
caltrans: 3 * 60 * 1000,
'transport for nsw': 15 * 1000,
});
/**

View File

@ -500,6 +500,7 @@ test('staticFrameRefreshMs follows known pack cadences and bounds explicit value
assert.equal(staticFrameRefreshMs({ provider: 'Austin Transportation & Public Works' }), 300_000);
assert.equal(staticFrameRefreshMs({ provider: 'Transport for London' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'Caltrans' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'Transport for NSW' }), 15_000);
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 5_000 }), 60_000);
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 3_000_000 }), 1_200_000);
assert.equal(staticFrameRefreshMs({ provider: 'Unknown Provider' }), 300_000);

View File

@ -24,15 +24,21 @@ test('CCTV upstream frame fetch supplies a bounded abort signal', async () => {
});
test('CCTV upstream frame fetch returns a valid image response', async () => {
let observedHeaders = null;
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
timeoutMs: 100,
fetchImpl: async () => new Response(Uint8Array.from([1, 2, 3]), {
fetchImpl: async (_url, options) => {
observedHeaders = options.headers;
return new Response(Uint8Array.from([1, 2, 3]), {
status: 200,
headers: { 'Content-Type': 'image/jpeg' },
}),
});
},
});
assert.equal(result?.ok, true);
assert.equal(result?.contentType, 'image/jpeg');
assert.deepEqual(result?.body, Buffer.from([1, 2, 3]));
assert.match(observedHeaders['User-Agent'], /Chrome/);
assert.match(observedHeaders.Accept, /^image\//);
});

View File

@ -127,6 +127,13 @@ export const DATA_CREDITS = [
'<a href="https://tfl.gov.uk/info-for/open-data-users/" target="_blank" rel="noopener">Powered by TfL Open Data</a>. ' +
'Contains OS data © Crown copyright and database rights.',
},
{
key: 'tfnsw-cctv',
html:
'CCTV cameras &amp; frames (Sydney): ' +
'<a href="https://opendata.transport.nsw.gov.au/dataset/live-traffic-cameras" target="_blank" rel="noopener">Transport for NSW Live Traffic</a> ' +
'(CC BY 3.0 AU).',
},
{
key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',

View File

@ -3543,7 +3543,12 @@ const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam';
const TFL_IMAGE_ORIGIN = 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
const DEFAULT_TFL_MAX_SOURCES = 250;
const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 };
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL) infrequent. Frames are fetched per-request and are unaffected. */
/** Transport for NSW publishes a keyless GeoJSON catalogue with live still URLs. */
const SYDNEY_CCTV_URL = 'https://data.livetraffic.com/cameras/traffic-cam.json';
const SYDNEY_CCTV_IMAGE_ORIGIN = 'https://webcams.transport.nsw.gov.au/livetraffic-webcams/cameras/';
const DEFAULT_SYDNEY_MAX_SOURCES = 250;
const SYDNEY_CENTER = { lat: -33.8688, lon: 151.2093 };
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + TfNSW) infrequent. Frames are fetched per-request and are unaffected. */
const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
/** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one
* stalled upstream can't leave getCctvSources (and thus every CCTV route)
@ -3554,6 +3559,17 @@ const CCTV_SOURCE_FETCH_TIMEOUT_MS = 15 * 1000;
* client refresh cadence. A bounded miss can fall through to Street View or
* the synthetic frame instead of leaving the browser preview pending. */
export const CCTV_FRAME_FETCH_TIMEOUT_MS = 8 * 1000;
/**
* TfNSW's public image CDN returns its maintenance page to bot-style clients
* but serves the same public camera JPEGs to standard browser image requests.
* Keep this limited to camera image fetches; catalog/API requests retain their
* descriptive service user agents.
*/
const CCTV_IMAGE_REQUEST_HEADERS = Object.freeze({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
Referer: 'https://www.livetraffic.com/',
});
/** @type {Array<object>} Cached merged + normalized CCTV source list. */
let _cctvSourceCache = [];
/** @type {number} Epoch-ms when the source cache was last refreshed. */
@ -4168,6 +4184,75 @@ async function loadTflSourcesFromOpenData() {
}
}
/**
* Fetch Transport for NSW's public Live Traffic camera catalogue. The feed is
* GeoJSON and includes a still-image URL for each camera, so it needs no API
* key and frames remain served through the existing CCTV proxy.
*
* @returns {Promise<Array<object>>} Normalized Sydney camera source objects.
*/
async function loadSydneySourcesFromOpenData() {
try {
const resp = await fetch(SYDNEY_CCTV_URL, {
headers: { Accept: 'application/geo+json, application/json' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (!resp.ok) {
console.warn('[CCTV] TfNSW source download failed:', resp.status);
return [];
}
const features = (await resp.json())?.features;
if (!Array.isArray(features)) return [];
const cameras = [];
for (const feature of features) {
const coords = feature?.geometry?.type === 'Point' ? feature.geometry.coordinates : null;
const lon = toFiniteNumber(coords?.[0]);
const lat = toFiniteNumber(coords?.[1]);
const props = feature?.properties || {};
const imageUrl = String(props.href || '').trim();
const rawId = String(feature?.id || '').trim();
if (!rawId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue;
// The source catalog is trusted only for TfNSW's public webcam origin.
if (!imageUrl.startsWith(SYDNEY_CCTV_IMAGE_ORIGIN)) continue;
const heading = directionToHeading(props.direction, true);
const hasHeading = Number.isFinite(heading);
const cameraId = `nsw-${rawId}`;
cameras.push({
id: cameraId,
name: String(props.title || props.view || `TfNSW camera ${rawId}`),
city: 'Sydney',
cityId: 'sydney',
provider: 'Transport for NSW',
lat,
lon,
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
headingConfidence: hasHeading ? 'high' : 'low',
pitchDeg: hasHeading ? -24 : -18,
fovDeg: hasHeading ? 56 : 44,
rangeM: hasHeading ? 210 : 145,
mountHeightM: hasHeading ? 10 : 8,
groundElevationM: 30,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'tfnsw-open-data',
license: 'Transport for NSW Live Traffic data (CC BY 3.0 AU)',
});
}
const maxRaw = Number(process.env.CCTV_SYDNEY_MAX_SOURCES || DEFAULT_SYDNEY_MAX_SOURCES);
const maxCount = Number.isFinite(maxRaw) ? Math.max(8, Math.min(400, Math.floor(maxRaw))) : DEFAULT_SYDNEY_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, [SYDNEY_CENTER]);
console.log(`[CCTV] Loaded Transport for NSW camera sources: ${cameras.length} available (using nearest ${prioritized.length})`);
return prioritized;
} catch (error) {
console.warn('[CCTV] TfNSW source download error:', error?.message || error);
return [];
}
}
/**
* Normalize a raw CCTV source item into a canonical shape with safe defaults.
*
@ -4207,7 +4292,7 @@ function normalizeSourceItem(item) {
/**
* Assemble and cache the merged CCTV source list.
*
* Merges sources from three origins (Austin Open Data, local file,
* Merges sources from live open-data packs plus local file and
* env variable), deduplicates by ID, applies the global max cap, and
* caches for CCTV_SOURCE_CACHE_MS.
*
@ -4244,22 +4329,27 @@ async function refreshCctvSources() {
// Austin-only fetch, now governing all three. Each pack fails independently.
const needsLiveSources = forceAustin || ((fromFile.length + fromEnv.length) === 0 && preferAustin);
const tflEnabled = String(process.env.CCTV_TFL_ENABLED || '1').trim() !== '0';
const sydneyEnabled = String(process.env.CCTV_SYDNEY_ENABLED || '1').trim() !== '0';
let fromAustin = [];
let fromCaltrans = [];
let fromTfl = [];
let fromSydney = [];
if (needsLiveSources) {
const [austinResult, caltransResult, tflResult] = await Promise.allSettled([
const [austinResult, caltransResult, tflResult, sydneyResult] = await Promise.allSettled([
loadAustinSourcesFromOpenData(),
loadCaltransSourcesFromOpenData(),
tflEnabled ? loadTflSourcesFromOpenData() : Promise.resolve([]),
sydneyEnabled ? loadSydneySourcesFromOpenData() : Promise.resolve([]),
]);
fromAustin = austinResult.status === 'fulfilled' ? austinResult.value : [];
fromCaltrans = caltransResult.status === 'fulfilled' ? caltransResult.value : [];
fromTfl = tflResult.status === 'fulfilled' ? tflResult.value : [];
fromSydney = sydneyResult.status === 'fulfilled' ? sydneyResult.value : [];
}
// Live sources first so file/env overrides win on duplicate IDs (Map last-write).
const merged = [...fromAustin, ...fromCaltrans, ...fromTfl, ...fromFile, ...fromEnv];
// Sydney comes first so its public cameras survive the global source cap;
// file/env entries still win on duplicate IDs (Map last-write).
const merged = [...fromSydney, ...fromAustin, ...fromCaltrans, ...fromTfl, ...fromFile, ...fromEnv];
// Deduplicate by camera ID (last-write wins because of Map.set)
const byId = new Map();
@ -4485,7 +4575,7 @@ export async function fetchCctvImageFromUpstream(url, {
}, timeoutMs);
try {
const upstream = await fetchImpl(url, {
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' },
headers: CCTV_IMAGE_REQUEST_HEADERS,
signal: controller.signal,
});
const contentType = upstream.headers.get('content-type') || '';
@ -4662,7 +4752,7 @@ function cctvProxy() {
}
try {
const upstreamHeaders = { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' };
const upstreamHeaders = { ...CCTV_IMAGE_REQUEST_HEADERS };
const requestRange = req.headers?.range;
if (requestRange) upstreamHeaders.Range = requestRange;
const upstream = await fetch(mediaUrl, {