mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
Review fixes for #1506. - Ottawa: use the documented camera list and the city's Traffic Camera Images API. The pack is skipped with no request unless OTTAWA_CAMERA_CERTIFICATE is set; the certificate is added only at fetch time inside the pinned /opendata/ path, and each camera is fetched at most once a minute through the shared frame cache. - Québec 511 video is opt-in (CCTV_QUEBEC_VIDEO_ENABLED=1) and loads only after the viewer presses Play; selection, auto-hop, scenes and voice no longer contact Québec 511. Credit splits locations (MTQ, CC BY 4.0) from video (Québec 511). Interrupted play() is no longer reported as an unavailable source; the per-frame listener runs only while a Québec camera is selected and the canvas rect is cached. - Catalog cap: reserved Québec and World slots come only from capacity above the 4,000 shared slots, so a lower CCTV_MAX_SOURCES thins every pack instead of starving the existing ones. dev-fresh and .env.example default to 7,516. - Throttled frames (World, Ottawa): inside the refresh floor with no cached frame the proxy serves the placeholder, never Street View; a failed fetch backs off for a minute; the cache is bounded by bytes. - MJPEG first-frame extraction runs only for sources registered as MJPEG (Taiwan Highway Bureau), caps a frame at 2 MB, reads in linear time, accepts a CRLF preamble and a boundary that already starts with "--", and ends a part without Content-Length at the next delimiter. THB hosts are registered in the redirect pin list. - Tests: World loader policy, Québec loader and opt-in, proxy cooldown without Street View, Ottawa signing, cap reservations at 7,516, 4,000 and 48, overlay Play gate, refresh, visibility and teardown. Guard tests check the 4,000 shared slots again. - Remove the unused standalone Québec preview module and public page. - Docs: DATA_SOURCES, SECURITY, CURRENT-STATE, CCTV_INTEGRATION, README, .env.example, changelog fragment renamed to its full scope.
451 lines
14 KiB
JavaScript
451 lines
14 KiB
JavaScript
import {
|
|
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
|
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
|
} 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';
|
|
export const TAIWAN_HIGHWAY_CCTV_URL =
|
|
'https://cctv-maintain.thb.gov.tw/opendataCCTVs.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' };
|
|
const TAIWAN_HIGHWAY_CAMERA_HOSTS = new Set(
|
|
TAIWAN_HIGHWAY_IMAGE_ORIGINS.map((origin) => new URL(origin).hostname),
|
|
);
|
|
|
|
/** 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,
|
|
),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* One Highway Bureau row -> a source whose first MJPEG frame is served as a
|
|
* snapshot by the existing camera-frame proxy.
|
|
*
|
|
* @param {object} row - Output of parseTaiwanCctvXml.
|
|
* @returns {?object}
|
|
*/
|
|
export function taiwanHighwayCameraToSource(row) {
|
|
if (!row || typeof row !== 'object') return null;
|
|
const { lat, lon } = row;
|
|
if (!isLikelyTaiwanCoordinate(lat, lon)) return null;
|
|
|
|
let url;
|
|
try {
|
|
url = new URL(String(row.streamUrl ?? ''));
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (
|
|
url.protocol !== 'https:' ||
|
|
url.username ||
|
|
url.password ||
|
|
url.port ||
|
|
url.search ||
|
|
url.hash ||
|
|
!TAIWAN_HIGHWAY_CAMERA_HOSTS.has(url.hostname) ||
|
|
!/^\/[A-Za-z0-9._+~-]{1,100}$/.test(url.pathname) ||
|
|
url.pathname.includes('..')
|
|
)
|
|
return null;
|
|
|
|
const rawId = String(row.cctvId ?? '').trim();
|
|
if (!/^[A-Za-z0-9-]{1,80}$/.test(rawId)) return null;
|
|
const cameraId = 'tw-thb-' + rawId.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('→');
|
|
const name =
|
|
(road || mile || section
|
|
? [road, direction, mile, section].filter(Boolean).join(' ')
|
|
: '') || rawId;
|
|
|
|
return {
|
|
id: cameraId,
|
|
name: name.slice(0, 160),
|
|
city: 'Taiwan / 臺灣',
|
|
cityId: 'taiwan',
|
|
provider: 'Highway Bureau, MOTC',
|
|
lat,
|
|
lon,
|
|
headingDeg: fallbackHeadingFromId(cameraId),
|
|
headingConfidence: 'low',
|
|
pitchDeg: -18,
|
|
fovDeg: 44,
|
|
rangeM: 145,
|
|
mountHeightM: 8,
|
|
feedType: 'mjpeg',
|
|
url: url.href,
|
|
snapshotUrl: url.href,
|
|
sourceKind: 'taiwan-highway-motc',
|
|
license: 'Highway Bureau, MOTC open data',
|
|
code: cameraDisplayCode((road + ' ' + mile).trim().toUpperCase() || rawId),
|
|
};
|
|
}
|
|
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 the public provincial-road CCTV table as a no-key fallback. */
|
|
async function loadTaiwanHighwaySources() {
|
|
const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS);
|
|
const response = await fetch(TAIWAN_HIGHWAY_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
|
|
? 'Highway Bureau catalog redirected; redirects are not followed'
|
|
: 'Highway Bureau catalog 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 = taiwanHighwayCameraToSource(row);
|
|
if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera);
|
|
}
|
|
if (!cameras.size)
|
|
throw new Error('no usable Highway Bureau cameras in the table');
|
|
const sources = prioritizeSources(
|
|
[...cameras.values()],
|
|
taiwanSourceCap(),
|
|
ANCHORS,
|
|
);
|
|
console.log(
|
|
'[CCTV] Loaded Taiwan Highway Bureau fallback: ' +
|
|
cameras.size +
|
|
' (using ' +
|
|
sources.length +
|
|
')',
|
|
);
|
|
return 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();
|
|
if (fallback.length) {
|
|
console.warn(
|
|
'[CCTV] Taiwan Freeway camera download failed (' +
|
|
(error?.message || error) +
|
|
'); serving the last good ' +
|
|
fallback.length +
|
|
' cameras, next attempt in 1 h',
|
|
);
|
|
return fallback;
|
|
}
|
|
try {
|
|
const sources = await loadTaiwanHighwaySources();
|
|
lastGood = sources;
|
|
lastGoodAt = Date.now();
|
|
console.warn(
|
|
'[CCTV] Taiwan Freeway camera download failed (' +
|
|
(error?.message || error) +
|
|
'); using ' +
|
|
sources.length +
|
|
' Highway Bureau cameras as fallback, next attempt in 1 h',
|
|
);
|
|
return sources;
|
|
} catch (fallbackError) {
|
|
console.warn(
|
|
'[CCTV] Taiwan Freeway camera download failed (' +
|
|
(error?.message || error) +
|
|
'); Highway Bureau fallback failed (' +
|
|
(fallbackError?.message || fallbackError) +
|
|
'); no Taiwan cameras, next attempt in 1 h',
|
|
);
|
|
return [];
|
|
}
|
|
}
|
|
}
|