fix(cctv): add Taiwan Highway Bureau fallback

This commit is contained in:
InoukT committed 2026-11-02 09:58:57 -05:00
1 parent 151ce3dee7
commit ed2aaa2c50
8 files changed
+704 -39

No files matched your search

+156
View File
@@ -313,3 +313,159 @@ test('CCTV upstream frame fetch accepts only raster image types and sends their
assert.equal(state.cancelled, true, `${contentType}: body cancelled`);
}
});
function multipartResponse(contentType, chunks, { onCancel = () => {} } = {}) {
let nextChunk = 0;
const body = new ReadableStream(
{
pull(controller) {
if (nextChunk >= chunks.length) {
controller.close();
return;
}
controller.enqueue(chunks[nextChunk++]);
},
cancel() {
onCancel();
},
},
{ highWaterMark: 0 },
);
return new Response(body, {
status: 200,
headers: { 'Content-Type': contentType },
});
}
function jpegPart(boundary, body, contentType = 'image/jpeg', length = body.length) {
return Buffer.concat([
Buffer.from(
'--' +
boundary +
'\r\nContent-Type: ' +
contentType +
'\r\nContent-Length: ' +
length +
'\r\n\r\n',
),
body,
Buffer.from('\r\n'),
]);
}
test('CCTV upstream frame fetch extracts only the first JPEG from MJPEG', async () => {
const boundary = 'DIGIEVER';
const first = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
const second = Buffer.from([0xff, 0xd8, 0x33, 0x44, 0xff, 0xd9]);
for (const type of [
'multipart/x-mixed-replace; boundary=DIGIEVER',
'multipart/x-mixed-replace; boundary="DIGIEVER"',
]) {
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
maxBytes: 1024,
fetchImpl: async () =>
multipartResponse(
type,
[
jpegPart(boundary, first),
jpegPart(boundary, second),
Buffer.from('--' + boundary + '--\r\n'),
],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result?.contentType, 'image/jpeg', type);
assert.deepEqual(result?.body, first, type);
assert.equal(cancelled, true, type);
}
});
test('CCTV upstream frame fetch rejects malformed and non-JPEG MJPEG parts', async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0x01, 0x02, 0xff, 0xd9]);
for (const [type, chunks] of [
['multipart/x-mixed-replace', []],
[
'multipart/x-mixed-replace; boundary=DIGIEVER',
[jpegPart('DIGIEVER', jpeg, 'image/png')],
],
]) {
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
fetchImpl: async () =>
multipartResponse(type, chunks, { onCancel: () => { cancelled = true; } }),
},
);
assert.equal(result, null, type);
assert.equal(cancelled, true, type);
}
});
test('CCTV upstream frame fetch rejects truncated or oversized MJPEG parts', async () => {
const boundary = 'DIGIEVER';
const full = Buffer.from([0xff, 0xd8, 0x01, 0x02, 0xff, 0xd9]);
const truncated = Buffer.from([0xff, 0xd8, 0x01, 0x02]);
for (const [chunks, maxBytes] of [
[jpegPart(boundary, truncated, 'image/jpeg', full.length), 1024],
[jpegPart(boundary, full), 5],
]) {
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
maxBytes,
fetchImpl: async () =>
multipartResponse(
'multipart/x-mixed-replace; boundary=' + boundary,
[chunks],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result, null);
assert.equal(cancelled, true);
}
});
test('CCTV upstream frame fetch does not cross into a later part when the first JPEG is truncated', async () => {
const boundary = 'DIGIEVER';
const truncated = Buffer.from([0xff, 0xd8, 0x01, 0x02]);
const valid = Buffer.from([0xff, 0xd8, 0x03, 0x04, 0xff, 0xd9]);
const wire = Buffer.concat([
Buffer.from(
'--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n',
),
truncated,
Buffer.from(
'\r\n--' +
boundary +
'\r\nContent-Type: image/jpeg\r\nContent-Length: ' +
valid.length +
'\r\n\r\n',
),
valid,
Buffer.from('\r\n--' + boundary + '--\r\n'),
]);
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
maxBytes: 1024,
fetchImpl: async () =>
multipartResponse(
'multipart/x-mixed-replace; boundary=' + boundary,
[wire],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result, null);
assert.equal(cancelled, true);
});
+87 -3
View File
@@ -182,6 +182,86 @@ test('loader deduplicates cameras that share a camera number', async (t) => {
]);
});
test('falls back to Highway Bureau MJPEG cameras when the Freeway catalog fails', async (t) => {
const highwayUrl = 'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
const requests = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url, options) => {
const href = String(url);
requests.push(href);
assert.equal(options.redirect, 'manual');
if (href === TAIWAN_FREEWAY_CCTV_URL)
return new Response('', { status: 503 });
if (href === highwayUrl) {
return new Response(
cctvXml(
block({
id: 'CCTV-14-0620-009-002',
stream: 'https://cctv-ss02.thb.gov.tw:443/T62-9K+020',
lat: '24.9967',
lon: '121.4521',
}),
),
{ headers: { 'content-type': 'application/xml' } },
);
}
throw new Error('Unexpected Taiwan feed URL: ' + href);
});
const sources = await loadTaiwanSources();
assert.deepEqual(requests, [TAIWAN_FREEWAY_CCTV_URL, highwayUrl]);
assert.equal(sources.length, 1);
assert.equal(sources[0].id, 'tw-thb-cctv-14-0620-009-002');
assert.equal(sources[0].cityId, 'taiwan');
assert.equal(sources[0].provider, 'Highway Bureau, MOTC');
assert.equal(sources[0].feedType, 'mjpeg');
assert.equal(sources[0].sourceKind, 'taiwan-highway-motc');
assert.equal(new URL(sources[0].url).hostname, 'cctv-ss02.thb.gov.tw');
assert.equal(new URL(sources[0].url).pathname, '/T62-9K+020');
});
test('the Highway Bureau fallback registers only approved HTTPS camera servers', async (t) => {
const highwayUrl = 'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
if (String(url) === TAIWAN_FREEWAY_CCTV_URL)
return new Response('', { status: 503 });
if (String(url) === highwayUrl) {
return new Response(
cctvXml(
block({
id: 'CCTV-14-0620-009-001',
stream: 'https://cctv-ss09.thb.gov.tw/T62-9K+019',
}),
block({
id: 'CCTV-14-0620-009-003',
stream: 'http://cctv-ss02.thb.gov.tw/T62-9K+021',
}),
block({
id: 'CCTV-14-0620-009-004',
stream: 'https://cctv-ss02.thb.gov.tw:8443/T62-9K+022',
}),
block({
id: 'CCTV-14-0620-009-002',
stream: 'https://cctv-ss02.thb.gov.tw:443/T62-9K+020',
}),
),
{ headers: { 'content-type': 'application/xml' } },
);
}
throw new Error('Unexpected Taiwan feed URL: ' + String(url));
});
const sources = await loadTaiwanSources();
assert.deepEqual(
sources.map((source) => source.id),
['tw-thb-cctv-14-0620-009-002'],
);
});
test('a redirected or failed catalog contributes an empty pack', async (t) => {
t.mock.method(console, 'warn', () => {});
for (const status of [302, 503]) {
@@ -214,13 +294,17 @@ test('the Taiwan pack is in the catalog by default', async (t) => {
);
});
test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
test('CCTV_TAIWAN_ENABLED=0 makes no Taiwan catalog request', async (t) => {
const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0');
assert.ok(requested.length > 0, 'the other packs still refreshed');
assert.equal(
requested.some((href) => href.includes('freeway.gov.tw')),
false,
);
assert.equal(
requested.some((href) => href.includes('cctv-maintain.thb.gov.tw')),
false,
);
assert.ok(!sources.some((c) => c.cityId === 'taiwan'));
});
@@ -250,12 +334,12 @@ test('a failed download is not retried until the backoff ends', async (t) => {
t.mock.method(Date, 'now', () => 1_000_000);
assert.deepEqual(await loadTaiwanSources(), []);
assert.deepEqual(await loadTaiwanSources(), []);
assert.equal(calls, 1, 'the second refresh made no request');
assert.equal(calls, 2, 'the second refresh made no request');
Date.now.mock.mockImplementation(
() => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1,
);
await loadTaiwanSources();
assert.equal(calls, 2, 'the backoff ends after an hour');
assert.equal(calls, 4, 'the backoff ends after an hour');
});
test('a failed download serves the last good table', async (t) => {