mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge remote-tracking branch 'upstream/main' into fix/houston-cctv-pack
# Conflicts: # .env.example # README.md # docs/CURRENT-STATE.md # server/providers/cctv/catalog.js # server/providers/cctv/normalize.js # server/providers/cctv/sources.js # src/data/dataCredits.js
This commit is contained in:
913 files changed
+81152
-21200
No files matched your search
@@ -5,6 +5,7 @@ import { allocateSourceCap, resolveCatalogCap } from './cap.js';
|
||||
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
|
||||
import { cameraRegion, normalizeSourceItem } from './normalize.js';
|
||||
import { loadLithuaniaSources } from './lithuania.js';
|
||||
import { loadTaiwanSources } from './taiwan.js';
|
||||
import {
|
||||
loadAustinSourcesFromOpenData,
|
||||
loadCaltransSourcesFromOpenData,
|
||||
@@ -19,12 +20,14 @@ import {
|
||||
loadNswSourcesFromOpenData,
|
||||
loadCalgarySourcesFromOpenData,
|
||||
loadCataloniaSourcesFromOpenData,
|
||||
loadHkSourcesFromOpenData,
|
||||
loadDelDOTSourcesFromOpenData,
|
||||
loadKingCountySourcesFromOpenData,
|
||||
loadWsdotSourcesFromOpenData,
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
loadNycSourcesFromOpenData,
|
||||
loadTorontoSourcesFromOpenData,
|
||||
loadFloridaSourcesFromOpenData,
|
||||
loadHoustonSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
@@ -106,6 +109,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
|
||||
load: loadCataloniaSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'hk',
|
||||
enabled: () => envEnabled('CCTV_HK_ENABLED'),
|
||||
load: loadHkSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'deldot',
|
||||
enabled: () => envEnabled('CCTV_DELDOT_ENABLED'),
|
||||
@@ -145,6 +153,19 @@ const LIVE_PACKS = [
|
||||
enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'),
|
||||
load: loadLithuaniaSources,
|
||||
},
|
||||
{
|
||||
// FL511's terms limit its content to individual, personal use (other use
|
||||
// needs FDOT's written consent), so the pack is off unless the operator
|
||||
// opts in (see DATA_SOURCES.md).
|
||||
name: 'florida',
|
||||
enabled: () => envOptIn('CCTV_FLORIDA_ENABLED'),
|
||||
load: loadFloridaSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'taiwan',
|
||||
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
|
||||
load: loadTaiwanSources,
|
||||
},
|
||||
{
|
||||
name: 'houston',
|
||||
enabled: () => envOptIn('CCTV_HOUSTON_ENABLED'),
|
||||
@@ -201,16 +222,19 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
/** @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 = [];
|
||||
/** @type {WeakMap<Array<object>, Array<{pack: string, available: number, served: number}>>}
|
||||
* Packs that serve fewer cameras than they offer, keyed by the source list
|
||||
* they describe, so a request served the old list during a background
|
||||
* refresh never pairs it with the new list's trim notes. */
|
||||
const _cctvTrimmedPacks = new WeakMap();
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* packs, and caches for CCTV_SOURCE_CACHE_MS. Past that, the cached list is
|
||||
* returned while a background refresh replaces it; only a first load waits.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
|
||||
*/
|
||||
@@ -225,11 +249,31 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
// 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;
|
||||
if (!_cctvSourceInflight) {
|
||||
_cctvSourceInflight = refreshCctvSources().finally(() => {
|
||||
_cctvSourceInflight = null;
|
||||
});
|
||||
// A background refresh has no caller waiting on it: report its failure
|
||||
// here, once per refresh rather than once per request it serves. Keep
|
||||
// the old catalog for another TTL, as an empty refresh does, so a refresh
|
||||
// that keeps failing is retried once per TTL, not back to back.
|
||||
if (_cctvSourceCache.length) {
|
||||
_cctvSourceInflight.catch((error) => {
|
||||
_cctvSourceCacheAt = Date.now();
|
||||
console.warn(
|
||||
'[CCTV] background catalog refresh failed; serving',
|
||||
_cctvSourceCache.length,
|
||||
'cameras from the last catalog:',
|
||||
error?.message || error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
// With a catalog in hand, answer with it while the refresh runs: the
|
||||
// refresh lasts as long as the slowest pack (Tarktee's DATEX feeds take up
|
||||
// to its 30 s timeout), and every /api/cctv request would otherwise wait
|
||||
// for it. Only the first load, with nothing to serve yet, waits.
|
||||
return _cctvSourceCache.length ? _cctvSourceCache : _cctvSourceInflight;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -319,7 +363,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
}
|
||||
if (capped.length > 0 || _cctvSourceCache.length === 0) {
|
||||
_cctvSourceCache = capped;
|
||||
_cctvTrimmedPacks = trimmedPacks;
|
||||
_cctvTrimmedPacks.set(capped, 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
|
||||
@@ -333,7 +377,10 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
return _cctvSourceCache;
|
||||
}
|
||||
|
||||
/** Packs serving fewer cameras than they offer, as of the last refresh. */
|
||||
getCctvSources.trimmedPacks = () => _cctvTrimmedPacks;
|
||||
/** Packs serving fewer cameras than they offer in `sources` (by default the
|
||||
* current catalog). Pass the list a request was served to keep the two in
|
||||
* step while a background refresh swaps the catalog. */
|
||||
getCctvSources.trimmedPacks = (sources = _cctvSourceCache) =>
|
||||
_cctvTrimmedPacks.get(sources) ?? [];
|
||||
return getCctvSources;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export const CCTV_MAX_SOURCES_CEILING = 5000;
|
||||
export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
|
||||
/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */
|
||||
/** TxDOT ITS: one keyless JSON catalog per district (25 districts statewide). */
|
||||
export const TXDOT_ORIGIN = 'https://its.txdot.gov';
|
||||
const TXDOT_ORIGIN = 'https://its.txdot.gov';
|
||||
export const TXDOT_CCTV_STATUS_URL = (district) =>
|
||||
`${TXDOT_ORIGIN}/its/DistrictIts/GetCctvStatusListByDistrict?districtCode=${encodeURIComponent(district)}`;
|
||||
/** Per-camera frame. Returns JSON `{snippet:<base64 jpeg>}`, not an image body;
|
||||
@@ -113,13 +113,34 @@ export const CALTRANS_ANCHORS = [
|
||||
{ lat: 32.7157, lon: -117.1611 }, // San Diego
|
||||
{ lat: 38.5816, lon: -121.4944 }, // Sacramento
|
||||
];
|
||||
export const FLORIDA_CCTV_URL =
|
||||
'https://services.arcgis.com/3wFbqsFPLeKqOlIK/arcgis/rest/services/FL511_Traffic_Cameras/FeatureServer/0/query';
|
||||
export const FLORIDA_IMAGE_HOSTS = [
|
||||
'images-dis.divas.cloud',
|
||||
'snapshots.divas.cloud',
|
||||
];
|
||||
export const DEFAULT_FLORIDA_MAX_SOURCES = 500;
|
||||
/** Byte cap per catalog page (2,000 rows, about 650 KiB measured). */
|
||||
export const FLORIDA_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
/** Catalog page ceiling: 40 pages covers 4,000+ cameras even at 100 rows a page. */
|
||||
export const FLORIDA_MAX_CATALOG_PAGES = 40;
|
||||
export const FLORIDA_ANCHORS = [
|
||||
{ lat: 25.7617, lon: -80.1918 }, // Miami
|
||||
{ lat: 28.5384, lon: -81.3789 }, // Orlando
|
||||
{ lat: 27.9506, lon: -82.4572 }, // Tampa
|
||||
{ lat: 30.3322, lon: -81.6557 }, // Jacksonville
|
||||
{ lat: 30.4383, lon: -84.2807 }, // Tallahassee
|
||||
{ lat: 26.1224, lon: -80.1373 }, // Fort Lauderdale
|
||||
{ lat: 26.7153, lon: -80.0534 }, // West Palm Beach
|
||||
{ lat: 30.4213, lon: -87.2169 }, // Pensacola (panhandle)
|
||||
];
|
||||
/** TfL JamCams: one keyless list endpoint; frames live on a public S3 bucket. */
|
||||
export const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam';
|
||||
export const TFL_IMAGE_ORIGIN =
|
||||
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
|
||||
export const DEFAULT_TFL_MAX_SOURCES = 250;
|
||||
export const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 };
|
||||
/** Ontario 511: keyless CARS/511 camera catalog; frame URLs are still images. */
|
||||
/** Ontario 511: CARS/511 camera catalog, which needs a developer key (ONTARIO_511_API_KEY); frame URLs are still images. */
|
||||
export const ONTARIO_511_CAMERAS_URL =
|
||||
'https://511on.ca/api/v2/get/cameras?format=json&lang=en';
|
||||
export const ONTARIO_511_IMAGE_ORIGIN = 'https://511on.ca/map/Cctv/';
|
||||
@@ -188,6 +209,8 @@ export const TARKTEE_IMAGES_URL =
|
||||
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages';
|
||||
export const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/';
|
||||
export const DEFAULT_TARKTEE_MAX_SOURCES = 179;
|
||||
/** Hard ceiling per Tarktee DATEX XML document (~120–165 KB live). */
|
||||
export const TARKTEE_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
|
||||
export const TARKTEE_ANCHORS = [
|
||||
{ lat: 59.437, lon: 24.753 }, // Tallinn
|
||||
{ lat: 58.378, lon: 26.729 }, // Tartu
|
||||
@@ -286,6 +309,36 @@ export const CATALONIA_ANCHORS = [
|
||||
{ lat: 41.5636, lon: 2.0111 }, // Terrassa
|
||||
];
|
||||
|
||||
/** Hong Kong Transport Department traffic snapshot cameras: keyless English
|
||||
* locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */
|
||||
export const DEFAULT_HK_CAMERAS_URL =
|
||||
'https://static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml';
|
||||
/** The only origin HK camera frames may come from. Verified 2026-10-11: still
|
||||
* URLs answer 200 on this host with no redirect hop. */
|
||||
export const HK_IMAGE_ORIGIN = 'https://tdcctv.data.one.gov.hk/';
|
||||
/** Above the full TD English catalog (~1,013 cameras) so a default install
|
||||
* keeps nearly all of them. Catalog-wide round-robin still applies when the
|
||||
* sum of packs exceeds DEFAULT_CCTV_MAX_SOURCES (Norway-style). */
|
||||
export const DEFAULT_HK_MAX_SOURCES = 1100;
|
||||
/** Prioritization anchors across Hong Kong Island, Kowloon and the New
|
||||
* Territories so a lowered cap keeps territory-wide coverage rather than
|
||||
* only the harbour corridor. */
|
||||
export const HK_ANCHORS = [
|
||||
{ lat: 22.2819, lon: 114.158 }, // Central
|
||||
{ lat: 22.2976, lon: 114.1722 }, // Tsim Sha Tsui
|
||||
{ lat: 22.3827, lon: 114.19 }, // Sha Tin
|
||||
{ lat: 22.3701, lon: 114.114 }, // Tsuen Wan
|
||||
{ lat: 22.4503, lon: 114.1688 }, // Tai Po
|
||||
{ lat: 22.493, lon: 114.139 }, // Fanling
|
||||
{ lat: 22.391, lon: 113.977 }, // Tuen Mun
|
||||
{ lat: 22.4445, lon: 114.022 }, // Yuen Long
|
||||
{ lat: 22.307, lon: 114.26 }, // Tseung Kwan O
|
||||
];
|
||||
/** Hard ceiling on the locations XML body. The live English list is ~400 KB
|
||||
* for ~1,000 cameras; this only exists so an unbounded upstream cannot be
|
||||
* buffered without limit. */
|
||||
export const HK_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/** Statens vegvesen (Norway) road cameras: the keyless OGC API Features view
|
||||
* of the official DATEX 3.1 CCTV site table, one GeoJSON request for the whole
|
||||
* country (~900 cameras). The raw DATEX node needs an account; this view does
|
||||
@@ -317,6 +370,49 @@ export const NORWAY_ANCHORS = [
|
||||
{ lat: 69.6492, lon: 18.9553 }, // Tromsø
|
||||
];
|
||||
|
||||
/** Kartverket's keyless point-height API (CC BY 4.0): terrain height above
|
||||
* sea level (NN2000), used as the Vegvesen cameras' ground prior because the
|
||||
* camera feed's points are 2D. */
|
||||
export const KARTVERKET_HEIGHT_URL =
|
||||
'https://ws.geonorge.no/hoydedata/v1/punkt';
|
||||
/** Points per request. The API takes up to 50, but above ~20 it answers the
|
||||
* extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not
|
||||
* at all; at 20 nearly every camera resolves from the 1 m terrain model. */
|
||||
export const KARTVERKET_MAX_POINTS = 20;
|
||||
/** Requests in flight at once. The whole country is ~36 requests of
|
||||
* 0.6-9.5 s each, so a cold lookup takes ~6-13 s. */
|
||||
export const KARTVERKET_CONCURRENCY = 8;
|
||||
/** Deadline for one complete lookup, shared by every request. A lookup cut
|
||||
* short by it is resumed on the next refresh, without a backoff. */
|
||||
export const KARTVERKET_LOOKUP_TIMEOUT_MS = 30 * 1000;
|
||||
/** One request that takes longer than this counts as a failing service. */
|
||||
export const KARTVERKET_REQUEST_TIMEOUT_MS = 10 * 1000;
|
||||
/** The Vegvesen lane's budget, measured from the start of the loader (catalog
|
||||
* fetch included), for waiting on heights. Under the 15 s per-provider fetch
|
||||
* timeout so this pack is never the catalog's slowest lane. A lookup still
|
||||
* running after it keeps filling the cache for the next refresh. */
|
||||
export const KARTVERKET_CATALOG_WAIT_MS = 12 * 1000;
|
||||
/** The lane only waits when more than this share of its cameras has no
|
||||
* answer yet (a cold cache). A few new cameras or expired misses are looked
|
||||
* up in the background instead of holding the refresh. */
|
||||
export const KARTVERKET_WAIT_MISSING_SHARE = 0.1;
|
||||
/** A point Kartverket has no height for (some ferry quays and bridges) is not
|
||||
* asked about again for this long. */
|
||||
export const KARTVERKET_NULL_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
/** After a failing request (408, 429, 5xx, a network error, a request
|
||||
* timeout or an unreadable body), no lookup starts for this long, or for the
|
||||
* Retry-After the service sent, bounded to [1 min, 1 h]. Longer than the
|
||||
* 15-minute catalog refresh (CCTV_SOURCE_CACHE_MS), so a failing service
|
||||
* costs one attempt per backoff, not one per refresh. */
|
||||
export const KARTVERKET_FAILURE_BACKOFF_MS = 30 * 60 * 1000;
|
||||
export const KARTVERKET_MAX_BACKOFF_MS = 60 * 60 * 1000;
|
||||
export const KARTVERKET_MAX_RESPONSE_BYTES = 256 * 1024;
|
||||
/** Plausible Norwegian terrain: below sea level only at the shore, and no road
|
||||
* camera sits above ~1,500 m. Anything outside is treated as no height. */
|
||||
export const KARTVERKET_HEIGHT_RANGE_M = Object.freeze({ min: -20, max: 2500 });
|
||||
/** Same identity the other CCTV upstream requests send. */
|
||||
export const KARTVERKET_USER_AGENT = 'gods-eye-view-cctv-proxy/1.0';
|
||||
|
||||
/** NYC DOT Traffic Management Center cameras: one keyless JSON list for the
|
||||
* five boroughs (~980 cameras). Frames are stills synthesized from each
|
||||
* camera UUID on the city's webcam host — never taken from the payload. */
|
||||
@@ -470,6 +566,10 @@ export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
|
||||
* pending forever — a hung fetch aborts, the loader returns [], and
|
||||
* serve-stale/other packs take over. */
|
||||
export const CCTV_SOURCE_FETCH_TIMEOUT_MS = 15 * 1000;
|
||||
/** Tarktee's two DATEX feeds (120 and 160 KB) answered in 2 to 16.6 s across
|
||||
* ten requests on 2026-10-18, so the shared 15 s timeout dropped the Estonian
|
||||
* pack on about a third of loads. Both must arrive for any camera to load. */
|
||||
export const TARKTEE_FETCH_TIMEOUT_MS = 30 * 1000;
|
||||
/** Individual CCTV image fetches must settle before the active 10-second
|
||||
* client refresh cadence. A bounded miss can fall through to Street View or
|
||||
* the synthetic frame instead of leaving the browser preview pending. */
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'node:path';
|
||||
import { poseHash } from '../../../src/data/cctvFootprint.js';
|
||||
|
||||
/** Sidecar written by scripts/precompute-cctv-heights.mjs. */
|
||||
export const DEFAULT_GROUND_HEIGHTS_FILE =
|
||||
const DEFAULT_GROUND_HEIGHTS_FILE =
|
||||
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json';
|
||||
|
||||
/** @type {{path:string, mtimeMs:number, cameras:object}|null} */
|
||||
|
||||
+131
-10
@@ -1,13 +1,21 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { hashSeed, escapeXml } from './normalize.js';
|
||||
import { hashSeed, escapeXml, pinnedCameraUrl } from './normalize.js';
|
||||
import {
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
CCTV_FRAME_MAX_BODY_BYTES,
|
||||
CCTV_MEDIA_FETCH_TIMEOUT_MS,
|
||||
CCTV_MEDIA_IDLE_TIMEOUT_MS,
|
||||
CCTV_MEDIA_MAX_BODY_BYTES,
|
||||
HK_IMAGE_ORIGIN,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
TALLINN_IMAGE_ORIGIN,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
TFL_IMAGE_ORIGIN,
|
||||
WARENDORF_IMAGE_ORIGINS,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
} from './constants.js';
|
||||
/**
|
||||
* Generate a synthetic SVG billboard image for a CCTV camera placeholder.
|
||||
@@ -84,7 +92,7 @@ export function buildSyntheticCctvSvg({ cameraId, label, city, status }) {
|
||||
* @param {ReadableStream|NodeJS.ReadableStream|null} body
|
||||
* @returns {import('stream').Readable|null}
|
||||
*/
|
||||
export function toReadable(body) {
|
||||
function toReadable(body) {
|
||||
if (!body) return null;
|
||||
if (typeof body.pipe === 'function') return body;
|
||||
if (typeof body.getReader === 'function') {
|
||||
@@ -93,6 +101,73 @@ export function toReadable(body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a Content-Type value into its lower-case essence and parameters.
|
||||
*
|
||||
* @param {string|null|undefined} value
|
||||
* @returns {{essence:string,params:Map<string,string>}|null} Null when the
|
||||
* value is missing or is not a `type/subtype` media type.
|
||||
*/
|
||||
function parseMediaType(value) {
|
||||
const [head, ...rest] = String(value ?? '').split(';');
|
||||
const essence = head.trim().toLowerCase();
|
||||
if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(essence)) return null;
|
||||
const params = new Map();
|
||||
for (const part of rest) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const name = part.slice(0, eq).trim().toLowerCase();
|
||||
let paramValue = part.slice(eq + 1).trim();
|
||||
if (/^".*"$/.test(paramValue)) paramValue = paramValue.slice(1, -1);
|
||||
if (name && !params.has(name)) params.set(name, paramValue);
|
||||
}
|
||||
return { essence, params };
|
||||
}
|
||||
|
||||
/** Still-image types a camera frame may be served as. */
|
||||
const CCTV_RASTER_IMAGE_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The canonical type of a raster camera frame, or null for any other type.
|
||||
*
|
||||
* @param {string|null|undefined} contentType
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function cctvRasterImageType(contentType) {
|
||||
const essence = parseMediaType(contentType)?.essence;
|
||||
return CCTV_RASTER_IMAGE_TYPES.has(essence) ? essence : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type to relay a live feed as, or null when the upstream type does not
|
||||
* match the feed kind. MJPEG keeps its part boundary, without which the body
|
||||
* cannot be split into frames.
|
||||
*
|
||||
* @param {string} feedType - Normalized feed type (mp4, webm or mjpeg).
|
||||
* @param {string|null|undefined} contentType
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function cctvLiveMediaType(feedType, contentType) {
|
||||
const parsed = parseMediaType(contentType);
|
||||
if (!parsed) return null;
|
||||
if (feedType === 'mp4' || feedType === 'webm')
|
||||
return parsed.essence === `video/${feedType}` ? parsed.essence : null;
|
||||
if (feedType !== 'mjpeg' || parsed.essence !== 'multipart/x-mixed-replace')
|
||||
return null;
|
||||
const boundary = parsed.params.get('boundary') || '';
|
||||
if (!/^[0-9A-Za-z'()+_,./:=? -]{0,69}[0-9A-Za-z'()+_,./:=?-]$/.test(boundary))
|
||||
return null;
|
||||
return /^[0-9A-Za-z'+_.-]+$/.test(boundary)
|
||||
? `multipart/x-mixed-replace; boundary=${boundary}`
|
||||
: `multipart/x-mixed-replace; boundary="${boundary}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipe an upstream fetch Response (image or video) to the client HTTP response.
|
||||
*
|
||||
@@ -104,6 +179,8 @@ export function toReadable(body) {
|
||||
* @param {Response} upstream - fetch() Response object.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.sourceHeader='upstream'] - Value for X-CCTV-Source header.
|
||||
* @param {string} [opts.contentType] - Validated type to send instead of the
|
||||
* upstream's own.
|
||||
* @param {number} [opts.idleTimeoutMs=CCTV_MEDIA_IDLE_TIMEOUT_MS] - Silence
|
||||
* allowed between upstream chunks before the stream is released. Injectable
|
||||
* only to keep the deadline unit-testable.
|
||||
@@ -114,10 +191,10 @@ export async function proxyMediaResponse(
|
||||
{
|
||||
sourceHeader = 'upstream',
|
||||
idleTimeoutMs = CCTV_MEDIA_IDLE_TIMEOUT_MS,
|
||||
contentType = upstream.headers.get('content-type') ||
|
||||
'application/octet-stream',
|
||||
} = {},
|
||||
) {
|
||||
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');
|
||||
@@ -340,7 +417,14 @@ export async function fetchCctvMediaUpstream(
|
||||
if (downstream?.aborted) controller.abort();
|
||||
else downstream?.addEventListener?.('abort', onDownstreamAbort);
|
||||
try {
|
||||
return await fetchImpl(url, { headers, signal: controller.signal });
|
||||
// Redirects stay on the registered origin, as for frames.
|
||||
const upstream = await fetchWithinHost(
|
||||
url,
|
||||
{ headers, signal: controller.signal },
|
||||
fetchImpl,
|
||||
);
|
||||
if (!upstream) throw new Error('Media redirect refused');
|
||||
return upstream;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
downstream?.removeEventListener?.('abort', onDownstreamAbort);
|
||||
@@ -428,9 +512,40 @@ export async function fetchTxdotSnapshot(
|
||||
/** Redirect hops the frame path will follow, and only within the same host. */
|
||||
const MAX_SAME_HOST_REDIRECTS = 2;
|
||||
|
||||
/**
|
||||
* Directories camera catalogs are pinned to, by host. A redirect on one of
|
||||
* these hosts must stay inside the pinned directory too, so a same-origin
|
||||
* redirect cannot leave it (another bucket on a shared storage host).
|
||||
*/
|
||||
const CCTV_PINNED_PREFIXES_BY_HOST = new Map();
|
||||
for (const prefix of [
|
||||
TFL_IMAGE_ORIGIN,
|
||||
TALLINN_IMAGE_ORIGIN,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
...WARENDORF_IMAGE_ORIGINS,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
HK_IMAGE_ORIGIN,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
]) {
|
||||
const host = new URL(prefix).hostname;
|
||||
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
|
||||
...(CCTV_PINNED_PREFIXES_BY_HOST.get(host) || []),
|
||||
prefix,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Whether a redirect target stays inside its host's pinned directory. */
|
||||
function withinPinnedPrefix(url) {
|
||||
const prefixes = CCTV_PINNED_PREFIXES_BY_HOST.get(url.hostname);
|
||||
return !prefixes || prefixes.some((p) => pinnedCameraUrl(url.href, p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a registered frame URL following redirects ONLY within the original
|
||||
* origin (scheme, host and port; at most MAX_SAME_HOST_REDIRECTS hops).
|
||||
* origin (scheme, host and port; at most MAX_SAME_HOST_REDIRECTS hops) and,
|
||||
* on a pinned camera host, within its pinned directory.
|
||||
* Default redirect-following would let an upstream steer a host-pinned
|
||||
* request, and its host-specific headers, to any origin, another port, or a
|
||||
* plaintext downgrade.
|
||||
@@ -471,7 +586,7 @@ export async function fetchWithinHost(url, init, fetchImpl = fetch) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (next.origin !== origin) return null;
|
||||
if (next.origin !== origin || !withinPinnedPrefix(next)) return null;
|
||||
current = next;
|
||||
}
|
||||
return null;
|
||||
@@ -543,9 +658,15 @@ export async function fetchCctvImageFromUpstream(
|
||||
fetchImpl,
|
||||
);
|
||||
if (!upstream) return null;
|
||||
const contentType = upstream.headers.get('content-type') || '';
|
||||
if (!upstream.ok || !contentType.startsWith('image/')) {
|
||||
controller.abort();
|
||||
const contentType = cctvRasterImageType(
|
||||
upstream.headers.get('content-type'),
|
||||
);
|
||||
if (!upstream.ok || !contentType) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const body = await readCappedResponseBytes(upstream, maxBytes);
|
||||
|
||||
@@ -64,7 +64,7 @@ export function toFiniteNumber(value, fallback = NaN) {
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeKey(text) {
|
||||
function normalizeKey(text) {
|
||||
return String(text || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
@@ -80,7 +80,7 @@ export function normalizeKey(text) {
|
||||
* @param {string} value
|
||||
* @returns {{lat:number, lon:number}}
|
||||
*/
|
||||
export function parsePointString(value) {
|
||||
function parsePointString(value) {
|
||||
const match = String(value || '').match(
|
||||
/POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i,
|
||||
);
|
||||
@@ -100,7 +100,7 @@ export function parsePointString(value) {
|
||||
* @param {string|object|null} value
|
||||
* @returns {{lat:number, lon:number}}
|
||||
*/
|
||||
export function coerceLatLon(value) {
|
||||
function coerceLatLon(value) {
|
||||
if (!value) return { lat: NaN, lon: NaN };
|
||||
|
||||
if (typeof value === 'string') {
|
||||
@@ -377,6 +377,17 @@ export function isLikelyCalgaryCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Florida statewide, Key West to Georgia state line, with slack at each edge */
|
||||
export function isLikelyFloridaCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 24.3 &&
|
||||
lat <= 31.1 &&
|
||||
lon >= -87.7 &&
|
||||
lon <= -79.8
|
||||
);
|
||||
}
|
||||
|
||||
/** New York City's five boroughs, with slack for the harbor and city line. */
|
||||
export function isLikelyNycCoordinate(lat, lon) {
|
||||
return (
|
||||
@@ -388,6 +399,17 @@ export function isLikelyNycCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Hong Kong SAR extent, with slack for the outlying islands. */
|
||||
export function isLikelyHongKongCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 22.15 &&
|
||||
lat <= 22.6 &&
|
||||
lon >= 113.8 &&
|
||||
lon <= 114.45
|
||||
);
|
||||
}
|
||||
|
||||
/** Greater Houston (the Woodlands to Galveston, Katy to Baytown). Farther
|
||||
* TranStar partners (Dallas, Columbus) are out of this city pack. */
|
||||
export function isLikelyHoustonCoordinate(lat, lon) {
|
||||
@@ -649,3 +671,38 @@ export function isLikelyTorontoCoordinate(lat, lon) {
|
||||
lon <= -79.1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A camera URL under a pinned directory, by parsed URL rather than string
|
||||
* prefix: the same scheme and exact host as `prefix`, no credentials, the
|
||||
* default port, and a normalized path inside the prefix's directory. Dot
|
||||
* segments (including encoded ones) and backslashes are resolved before the
|
||||
* path is compared, and an encoded slash or backslash is refused.
|
||||
*
|
||||
* @param {string} raw - Camera URL from a catalog or redirect.
|
||||
* @param {string} prefix - Pinned origin and directory, ending in `/`.
|
||||
* @returns {string|null} The normalized URL, or null when it is not pinned.
|
||||
*/
|
||||
export function pinnedCameraUrl(raw, prefix) {
|
||||
let url;
|
||||
let pin;
|
||||
try {
|
||||
url = new URL(String(raw ?? '').trim());
|
||||
pin = new URL(prefix);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
url.protocol !== pin.protocol ||
|
||||
url.hostname !== pin.hostname ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.port ||
|
||||
!url.pathname.startsWith(
|
||||
pin.pathname.endsWith('/') ? pin.pathname : `${pin.pathname}/`,
|
||||
) ||
|
||||
/%2f|%5c/i.test(url.pathname)
|
||||
)
|
||||
return null;
|
||||
return url.href;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
_resetOntarioKeyWarningForTest,
|
||||
loadOntarioSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
const ROW = {
|
||||
Id: '42',
|
||||
Latitude: 43.6532,
|
||||
Longitude: -79.3832,
|
||||
Location: 'Hwy 401 near Yonge St',
|
||||
Direction: 'Eastbound',
|
||||
Views: [
|
||||
{
|
||||
Status: 'Enabled',
|
||||
Url: 'https://511on.ca/map/Cctv/4201',
|
||||
Description: 'Eastbound',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function captureConsole(t) {
|
||||
const lines = [];
|
||||
const capture = (...args) => lines.push(args.join(' '));
|
||||
t.mock.method(console, 'warn', capture);
|
||||
t.mock.method(console, 'log', capture);
|
||||
return lines;
|
||||
}
|
||||
|
||||
test.beforeEach(() => _resetOntarioKeyWarningForTest());
|
||||
|
||||
test('without ONTARIO_511_API_KEY the Ontario pack is skipped, logged once', async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
fetches += 1;
|
||||
return Response.json([ROW]);
|
||||
});
|
||||
assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []);
|
||||
assert.deepEqual(
|
||||
await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: ' ' }),
|
||||
[],
|
||||
);
|
||||
assert.equal(fetches, 0);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /ONTARIO_511_API_KEY/);
|
||||
});
|
||||
|
||||
test('with a key the camera list is requested with it and parsed', async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json([ROW]);
|
||||
});
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{ sourceRoot: '/ignored' },
|
||||
{ ONTARIO_511_API_KEY: ' test-key ' },
|
||||
);
|
||||
assert.equal(requested.length, 1);
|
||||
const url = new URL(requested[0]);
|
||||
assert.equal(url.host, '511on.ca');
|
||||
assert.equal(url.searchParams.get('key'), 'test-key');
|
||||
assert.equal(url.searchParams.get('format'), 'json');
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.equal(cameras[0].id, 'on-42');
|
||||
assert.equal(cameras[0].url, 'https://511on.ca/map/Cctv/4201');
|
||||
// The records go to the browser through /api/cctv; the key must stay on
|
||||
// the server-side catalog request.
|
||||
assert.doesNotMatch(JSON.stringify(cameras), /test-key/);
|
||||
assert.doesNotMatch(lines.join('\n'), /test-key/);
|
||||
});
|
||||
|
||||
test('the source cap is read from the same environment as the key', async (t) => {
|
||||
captureConsole(t);
|
||||
const rows = Array.from({ length: 12 }, (_, i) => ({
|
||||
...ROW,
|
||||
Id: String(100 + i),
|
||||
Latitude: ROW.Latitude + i * 0.01,
|
||||
Views: [{ ...ROW.Views[0], Url: `https://511on.ca/map/Cctv/${100 + i}` }],
|
||||
}));
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: 'test-key', CCTV_ONTARIO_MAX_SOURCES: '8' },
|
||||
);
|
||||
assert.equal(cameras.length, 8);
|
||||
});
|
||||
|
||||
for (const status of [400, 401, 403]) {
|
||||
test(`a ${status} points at the key by name, never by value`, async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ Message: 'Invalid Key' }, { status }),
|
||||
);
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: 'secret-key-value' },
|
||||
);
|
||||
assert.deepEqual(cameras, []);
|
||||
assert.match(
|
||||
lines.join('\n'),
|
||||
new RegExp(`failed: ${status} \\(check ONTARIO_511_API_KEY\\)`),
|
||||
);
|
||||
assert.doesNotMatch(lines.join('\n'), /secret-key-value/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [429, 500, 503]) {
|
||||
test(`a ${status} is reported without blaming the key`, async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({}, { status }),
|
||||
);
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: 'secret-key-value' },
|
||||
);
|
||||
assert.deepEqual(cameras, []);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.equal(
|
||||
lines[0],
|
||||
`[CCTV] Ontario 511 camera download failed: ${status}`,
|
||||
);
|
||||
assert.doesNotMatch(lines[0], /secret-key-value/);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -70,7 +70,7 @@ export async function fetchHlsBytes(
|
||||
}
|
||||
}
|
||||
|
||||
export function sameOriginHlsUrl(value, base) {
|
||||
function sameOriginHlsUrl(value, base) {
|
||||
const url = new URL(value, base);
|
||||
if (
|
||||
!['https:', 'http:'].includes(url.protocol) ||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
|
||||
import {
|
||||
cameraDisplayCode,
|
||||
fallbackHeadingFromId,
|
||||
prioritizeSources,
|
||||
} from './normalize.js';
|
||||
import { readResponseTextCapped } from '../common/http.js';
|
||||
|
||||
/** Freeway Bureau (MOTC) CCTV static table in the national MOTC 2.0 XML
|
||||
* standard, published keyless and refreshed daily (~1,800 cameras). */
|
||||
export const TAIWAN_FREEWAY_CCTV_URL =
|
||||
'https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml';
|
||||
/** The whole network is ~1.2 MB. */
|
||||
export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024;
|
||||
const DEFAULT_TAIWAN_MAX_SOURCES = 600;
|
||||
/** After a failed download, wait this long before asking again. The catalog
|
||||
* host does not answer from some networks outside Taiwan, and every attempt
|
||||
* there holds the shared catalog refresh until the connect timeout. */
|
||||
export const TAIWAN_RETRY_AFTER_FAILURE_MS = 60 * 60 * 1000;
|
||||
/** The table is republished daily; a good copy stands in for a failed
|
||||
* download for up to this long. */
|
||||
const TAIWAN_LAST_GOOD_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// The table publishes each camera's MJPEG stream. The same camera servers
|
||||
// answer a single JPEG for the same camera ID on a sibling path, which is what
|
||||
// the frame proxy can relay; only these exact pairs are ever registered.
|
||||
const STREAM_TO_FRAME = new Map([
|
||||
['cctvn.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']],
|
||||
['cctvn5.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']],
|
||||
['cctvc.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']],
|
||||
[
|
||||
'cctvs.freeway.gov.tw',
|
||||
['/live-view/mjpg/video.cgi', '/live-view/jpg/image.cgi'],
|
||||
],
|
||||
]);
|
||||
|
||||
const DIRECTIONS = { N: 'N', S: 'S', E: 'E', W: 'W' };
|
||||
|
||||
/** Prioritization anchors, north to south, so a cap keeps the whole island
|
||||
* rather than only greater Taipei. */
|
||||
const ANCHORS = [
|
||||
{ lat: 25.033, lon: 121.5654 }, // Taipei
|
||||
{ lat: 24.9936, lon: 121.301 }, // Taoyuan
|
||||
{ lat: 24.8138, lon: 120.9675 }, // Hsinchu
|
||||
{ lat: 24.7021, lon: 121.7378 }, // Yilan
|
||||
{ lat: 24.1477, lon: 120.6736 }, // Taichung
|
||||
{ lat: 22.9999, lon: 120.227 }, // Tainan
|
||||
{ lat: 22.6273, lon: 120.3014 }, // Kaohsiung
|
||||
];
|
||||
|
||||
/** Taiwan main island with slack for the coast; the freeway network has no
|
||||
* offshore-island cameras. */
|
||||
function isLikelyTaiwanCoordinate(lat, lon) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
lat >= 21.8 &&
|
||||
lat <= 25.4 &&
|
||||
lon >= 119.9 &&
|
||||
lon <= 122.1
|
||||
);
|
||||
}
|
||||
|
||||
const XML_ENTITIES = {
|
||||
amp: '&',
|
||||
lt: '<',
|
||||
gt: '>',
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
};
|
||||
|
||||
function codePoint(value, radix) {
|
||||
const code = parseInt(value, radix);
|
||||
return Number.isInteger(code) && code > 0 && code <= 0x10ffff
|
||||
? String.fromCodePoint(code)
|
||||
: '';
|
||||
}
|
||||
|
||||
function decodeXml(text) {
|
||||
return String(text ?? '')
|
||||
.replace(
|
||||
/&(?:(amp|lt|gt|quot|apos)|#(\d{1,7})|#x([0-9a-fA-F]{1,6}));/g,
|
||||
(_, name, dec, hex) =>
|
||||
name ? XML_ENTITIES[name] : codePoint(dec ?? hex, dec ? 10 : 16),
|
||||
)
|
||||
.trim();
|
||||
}
|
||||
|
||||
function tag(block, name) {
|
||||
const match = new RegExp(`<${name}>([^<]*)</${name}>`).exec(block);
|
||||
return match ? decodeXml(match[1]) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the MOTC 2.0 `CCTVList` document into flat rows. Regex-based like the
|
||||
* Catalonia and Tarktee parsers: the schema is flat, and only the fields used
|
||||
* below are read.
|
||||
*
|
||||
* @param {string} xml
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function parseTaiwanCctvXml(xml) {
|
||||
const rows = [];
|
||||
const blockRe = /<CCTV>([\s\S]*?)<\/CCTV>/g;
|
||||
let match;
|
||||
while ((match = blockRe.exec(String(xml || ''))) !== null) {
|
||||
const block = match[1];
|
||||
rows.push({
|
||||
cctvId: tag(block, 'CCTVID'),
|
||||
streamUrl: tag(block, 'VideoStreamURL'),
|
||||
lat: Number(tag(block, 'PositionLat')),
|
||||
lon: Number(tag(block, 'PositionLon')),
|
||||
roadName: tag(block, 'RoadName'),
|
||||
direction: tag(block, 'RoadDirection'),
|
||||
sectionStart: tag(block, 'Start'),
|
||||
sectionEnd: tag(block, 'End'),
|
||||
mile: tag(block, 'LocationMile'),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Published MJPEG stream URL -> the same camera's still, plus the host and
|
||||
* camera number that identify it, or null.
|
||||
*
|
||||
* @param {string} streamUrl
|
||||
* @returns {?{frameUrl: string, host: string, camera: string}}
|
||||
*/
|
||||
export function taiwanFrame(streamUrl) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(streamUrl ?? ''));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.port)
|
||||
return null;
|
||||
const paths = STREAM_TO_FRAME.get(url.hostname);
|
||||
if (!paths || url.pathname !== paths[0]) return null;
|
||||
const camera = url.searchParams.get('camera') || '';
|
||||
if (!/^[A-Za-z0-9-]{1,40}$/.test(camera)) return null;
|
||||
return {
|
||||
// Reconstruct so extra query parameters cannot alter the request.
|
||||
frameUrl: `https://${url.hostname}${paths[1]}?camera=${camera}`,
|
||||
host: url.hostname.split('.')[0],
|
||||
camera,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One parsed Freeway Bureau row -> one catalog source, or null.
|
||||
*
|
||||
* @param {object} row - Output of parseTaiwanCctvXml.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function taiwanCameraToSource(row) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const { lat, lon } = row;
|
||||
if (!isLikelyTaiwanCoordinate(lat, lon)) return null;
|
||||
const frame = taiwanFrame(row.streamUrl);
|
||||
if (!frame) return null;
|
||||
const imageUrl = frame.frameUrl;
|
||||
// CCTVID carries Chinese place names and a few IDs repeat across distinct
|
||||
// cameras, so the stable key is the camera server plus its camera number.
|
||||
const rawId = String(row.cctvId ?? '')
|
||||
.trim()
|
||||
.slice(0, 80);
|
||||
const cameraId = `tw-freeway-${frame.host}-${frame.camera.toLowerCase()}`;
|
||||
const road = String(row.roadName ?? '').trim();
|
||||
const direction = DIRECTIONS[String(row.direction ?? '').trim()] || '';
|
||||
const mile = String(row.mile ?? '').trim();
|
||||
const section = [row.sectionStart, row.sectionEnd]
|
||||
.map((part) => String(part ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.join('→');
|
||||
// A bare carriageway letter is not a name; fall back to the published ID.
|
||||
const name =
|
||||
(road || mile || section
|
||||
? [road, direction, mile, section].filter(Boolean).join(' ')
|
||||
: '') ||
|
||||
rawId ||
|
||||
`Freeway camera ${frame.camera}`;
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name: name.slice(0, 160),
|
||||
// One island-wide category in the camera picker, like Finland and Norway.
|
||||
city: 'Taiwan / 臺灣',
|
||||
cityId: 'taiwan',
|
||||
provider: 'Freeway Bureau, MOTC',
|
||||
lat,
|
||||
lon,
|
||||
// The table gives the carriageway direction, not the camera's bearing,
|
||||
// so the pose uses the shared low-confidence prior.
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'tw-freeway-motc',
|
||||
license:
|
||||
'Freeway Bureau, MOTC open data, Open Government Data License, Taiwan (Version 1.0)',
|
||||
code: cameraDisplayCode(
|
||||
`${road} ${mile}`.trim().toUpperCase() || frame.camera,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let lastGood = null;
|
||||
let lastGoodAt = 0;
|
||||
let retryAfter = 0;
|
||||
|
||||
/** Forget the last good table and any failure backoff (tests only). */
|
||||
export function resetTaiwanSourcesCache() {
|
||||
lastGood = null;
|
||||
lastGoodAt = 0;
|
||||
retryAfter = 0;
|
||||
}
|
||||
|
||||
function fallbackSources() {
|
||||
return lastGood && Date.now() - lastGoodAt <= TAIWAN_LAST_GOOD_MAX_AGE_MS
|
||||
? lastGood
|
||||
: [];
|
||||
}
|
||||
|
||||
function taiwanSourceCap() {
|
||||
const rawCap = Number(
|
||||
process.env.CCTV_TAIWAN_MAX_SOURCES || DEFAULT_TAIWAN_MAX_SOURCES,
|
||||
);
|
||||
return Number.isFinite(rawCap)
|
||||
? Math.max(1, Math.min(2000, Math.floor(rawCap)))
|
||||
: DEFAULT_TAIWAN_MAX_SOURCES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Taiwan Freeway Bureau cameras from the keyless MOTC 2.0 XML table.
|
||||
* One bounded request per shared catalog refresh. A failed download serves the
|
||||
* last good table (up to a day old) and is not retried for an hour, so a host
|
||||
* that cannot reach the Freeway Bureau does not wait on it every refresh.
|
||||
*
|
||||
* @returns {Promise<Array<object>>}
|
||||
*/
|
||||
export async function loadTaiwanSources() {
|
||||
if (Date.now() < retryAfter) return fallbackSources();
|
||||
try {
|
||||
const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS);
|
||||
const response = await fetch(
|
||||
process.env.CCTV_TAIWAN_URL || TAIWAN_FREEWAY_CCTV_URL,
|
||||
{
|
||||
headers: { Accept: 'application/xml,text/xml' },
|
||||
redirect: 'manual',
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw new Error(
|
||||
response.status >= 300 && response.status < 400
|
||||
? 'catalog redirected; redirects are not followed'
|
||||
: `HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
const xml = await readResponseTextCapped(
|
||||
response,
|
||||
TAIWAN_MAX_CATALOG_BYTES,
|
||||
signal,
|
||||
);
|
||||
const cameras = new Map();
|
||||
for (const row of parseTaiwanCctvXml(xml)) {
|
||||
const camera = taiwanCameraToSource(row);
|
||||
if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera);
|
||||
}
|
||||
if (!cameras.size) throw new Error('no usable cameras in the table');
|
||||
const sources = prioritizeSources(
|
||||
[...cameras.values()],
|
||||
taiwanSourceCap(),
|
||||
ANCHORS,
|
||||
);
|
||||
console.log(
|
||||
`[CCTV] Loaded Taiwan camera sources: ${cameras.size} (using ${sources.length})`,
|
||||
);
|
||||
lastGood = sources;
|
||||
lastGoodAt = Date.now();
|
||||
retryAfter = 0;
|
||||
return sources;
|
||||
} catch (error) {
|
||||
retryAfter = Date.now() + TAIWAN_RETRY_AFTER_FAILURE_MS;
|
||||
const fallback = fallbackSources();
|
||||
console.warn(
|
||||
`[CCTV] Taiwan camera download failed (${error?.message || error}); ` +
|
||||
(fallback.length
|
||||
? `serving the last good ${fallback.length} cameras`
|
||||
: 'no Taiwan cameras') +
|
||||
', next attempt in 1 h',
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user