mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
fix(cctv): add Taiwan Highway Bureau fallback
This commit is contained in:
1 parent
151ce3dee7
commit
ed2aaa2c50
8 files changed
+704
-39
No files matched your search
@@ -621,6 +621,151 @@ export function cctvUpstreamUserAgent(url) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one bounded JPEG part from an open MJPEG response, then stop the stream. */
|
||||
async function readFirstMjpegFrame(upstream, maxBytes) {
|
||||
const mediaType = cctvLiveMediaType(
|
||||
'mjpeg',
|
||||
upstream.headers.get('content-type'),
|
||||
);
|
||||
const boundary = parseMediaType(mediaType)?.params.get('boundary') || '';
|
||||
if (
|
||||
!boundary ||
|
||||
!upstream.body ||
|
||||
!Number.isSafeInteger(maxBytes) ||
|
||||
maxBytes < 4
|
||||
) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextBoundary = Buffer.from('\r\n--' + boundary, 'latin1');
|
||||
const opening = Buffer.from('--' + boundary + '\r\n', 'latin1');
|
||||
const headerTerminator = Buffer.from('\r\n\r\n', 'ascii');
|
||||
const jpegStart = Buffer.from([0xff, 0xd8]);
|
||||
const jpegEnd = Buffer.from([0xff, 0xd9]);
|
||||
const maxHeaderBytes = 8 * 1024;
|
||||
const maxBufferedBytes =
|
||||
opening.length + headerTerminator.length + maxHeaderBytes + maxBytes;
|
||||
let reader;
|
||||
let buffer = Buffer.alloc(0);
|
||||
let parsedHeaders = false;
|
||||
let bodyStart = -1;
|
||||
let declaredLength = null;
|
||||
|
||||
try {
|
||||
reader = upstream.body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return null;
|
||||
|
||||
const chunk = Buffer.from(value);
|
||||
const room = Math.max(0, maxBufferedBytes - buffer.length);
|
||||
const included = chunk.subarray(0, room);
|
||||
const overflow = included.length < chunk.length;
|
||||
buffer = Buffer.concat(
|
||||
[buffer, included],
|
||||
buffer.length + included.length,
|
||||
);
|
||||
|
||||
if (buffer.length < opening.length) {
|
||||
if (overflow) return null;
|
||||
continue;
|
||||
}
|
||||
if (!buffer.subarray(0, opening.length).equals(opening)) return null;
|
||||
|
||||
if (!parsedHeaders) {
|
||||
const headerEnd = buffer.indexOf(headerTerminator, opening.length);
|
||||
if (headerEnd < 0) {
|
||||
if (buffer.length - opening.length > maxHeaderBytes || overflow)
|
||||
return null;
|
||||
continue;
|
||||
}
|
||||
if (headerEnd - opening.length > maxHeaderBytes) return null;
|
||||
|
||||
const headerText = buffer
|
||||
.subarray(opening.length, headerEnd)
|
||||
.toString('latin1');
|
||||
const headers = new Map();
|
||||
for (const line of headerText.split('\r\n')) {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon <= 0) return null;
|
||||
const name = line.slice(0, colon).trim().toLowerCase();
|
||||
if (!name || headers.has(name)) return null;
|
||||
headers.set(name, line.slice(colon + 1).trim());
|
||||
}
|
||||
if (cctvRasterImageType(headers.get('content-type')) !== 'image/jpeg')
|
||||
return null;
|
||||
|
||||
const length = headers.get('content-length');
|
||||
if (length !== undefined) {
|
||||
if (!/^\d+$/.test(length)) return null;
|
||||
declaredLength = Number(length);
|
||||
if (
|
||||
!Number.isSafeInteger(declaredLength) ||
|
||||
declaredLength < jpegStart.length + jpegEnd.length ||
|
||||
declaredLength > maxBytes
|
||||
)
|
||||
return null;
|
||||
}
|
||||
bodyStart = headerEnd + headerTerminator.length;
|
||||
parsedHeaders = true;
|
||||
}
|
||||
|
||||
const available = buffer.length - bodyStart;
|
||||
if (declaredLength !== null) {
|
||||
const nextPart = buffer.indexOf(nextBoundary, bodyStart);
|
||||
if (nextPart >= bodyStart && nextPart - bodyStart < declaredLength)
|
||||
return null;
|
||||
if (available >= declaredLength) {
|
||||
const frame = buffer.subarray(bodyStart, bodyStart + declaredLength);
|
||||
if (
|
||||
frame.subarray(0, jpegStart.length).equals(jpegStart) &&
|
||||
frame.subarray(-jpegEnd.length).equals(jpegEnd)
|
||||
)
|
||||
return Buffer.from(frame);
|
||||
return null;
|
||||
}
|
||||
} else if (available >= jpegStart.length) {
|
||||
const frameStart = buffer.subarray(
|
||||
bodyStart,
|
||||
bodyStart + jpegStart.length,
|
||||
);
|
||||
if (!frameStart.equals(jpegStart)) return null;
|
||||
const nextPart = buffer.indexOf(nextBoundary, bodyStart);
|
||||
const end = buffer.indexOf(jpegEnd, bodyStart + jpegStart.length);
|
||||
if (
|
||||
nextPart >= bodyStart &&
|
||||
(end < 0 || end + jpegEnd.length > nextPart)
|
||||
)
|
||||
return null;
|
||||
if (end >= 0) {
|
||||
const frameLength = end + jpegEnd.length - bodyStart;
|
||||
if (frameLength > maxBytes) return null;
|
||||
return Buffer.from(buffer.subarray(bodyStart, end + jpegEnd.length));
|
||||
}
|
||||
if (available > maxBytes) return null;
|
||||
}
|
||||
if (overflow) return null;
|
||||
}
|
||||
} finally {
|
||||
if (reader) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
/* already closed or aborted */
|
||||
}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Fetch one upstream CCTV image within the frame-refresh budget.
|
||||
*
|
||||
@@ -660,10 +805,7 @@ export async function fetchCctvImageFromUpstream(
|
||||
fetchImpl,
|
||||
);
|
||||
if (!upstream) return null;
|
||||
const contentType = cctvRasterImageType(
|
||||
upstream.headers.get('content-type'),
|
||||
);
|
||||
if (!upstream.ok || !contentType) {
|
||||
if (!upstream.ok) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
@@ -671,9 +813,24 @@ export async function fetchCctvImageFromUpstream(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const body = await readCappedResponseBytes(upstream, maxBytes);
|
||||
const upstreamType = upstream.headers.get('content-type');
|
||||
const contentType = cctvRasterImageType(upstreamType);
|
||||
if (contentType) {
|
||||
const body = await readCappedResponseBytes(upstream, maxBytes);
|
||||
if (!body) return null;
|
||||
return { ok: true, body, contentType };
|
||||
}
|
||||
if (!cctvLiveMediaType('mjpeg', upstreamType)) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const body = await readFirstMjpegFrame(upstream, maxBytes);
|
||||
if (!body) return null;
|
||||
return { ok: true, body, contentType };
|
||||
return { ok: true, body, contentType: 'image/jpeg' };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
+161
-10
@@ -10,6 +10,8 @@ import { readResponseTextCapped } from '../common/http.js';
|
||||
* 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;
|
||||
@@ -35,6 +37,16 @@ const STREAM_TO_FRAME = new Map([
|
||||
]);
|
||||
|
||||
const DIRECTIONS = { N: 'N', S: 'S', E: 'E', W: 'W' };
|
||||
const TAIWAN_HIGHWAY_CAMERA_HOSTS = new Set([
|
||||
'cctv-ss01.thb.gov.tw',
|
||||
'cctv-ss02.thb.gov.tw',
|
||||
'cctv-ss03.thb.gov.tw',
|
||||
'cctv-ss04.thb.gov.tw',
|
||||
'cctv-ss05.thb.gov.tw',
|
||||
'cctv-ss06.thb.gov.tw',
|
||||
'cctv-ss07.thb.gov.tw',
|
||||
'cctv-ss08.thb.gov.tw',
|
||||
]);
|
||||
|
||||
/** Prioritization anchors, north to south, so a cap keeps the whole island
|
||||
* rather than only greater Taipei. */
|
||||
@@ -211,6 +223,74 @@ export function taiwanCameraToSource(row) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -237,6 +317,49 @@ function taiwanSourceCap() {
|
||||
: 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
|
||||
@@ -262,7 +385,7 @@ export async function loadTaiwanSources() {
|
||||
throw new Error(
|
||||
response.status >= 300 && response.status < 400
|
||||
? 'catalog redirected; redirects are not followed'
|
||||
: `HTTP ${response.status}`,
|
||||
: 'HTTP ' + response.status,
|
||||
);
|
||||
}
|
||||
const xml = await readResponseTextCapped(
|
||||
@@ -282,7 +405,11 @@ export async function loadTaiwanSources() {
|
||||
ANCHORS,
|
||||
);
|
||||
console.log(
|
||||
`[CCTV] Loaded Taiwan camera sources: ${cameras.size} (using ${sources.length})`,
|
||||
'[CCTV] Loaded Taiwan camera sources: ' +
|
||||
cameras.size +
|
||||
' (using ' +
|
||||
sources.length +
|
||||
')',
|
||||
);
|
||||
lastGood = sources;
|
||||
lastGoodAt = Date.now();
|
||||
@@ -291,13 +418,37 @@ export async function loadTaiwanSources() {
|
||||
} 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;
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user