fix(cctv): key-gate Ottawa, opt-in Québec video, keep shared slots

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.
This commit is contained in:
Said Turksever committed 2026-11-03 15:31:08 +00:00
1 parent fa4ce4b2a5
commit db8afbae81
43 files changed
+1327 -1110

No files matched your search

+77 -34
View File
@@ -1,5 +1,5 @@
import { createCctvCatalog } from './cctv/catalog.js';
import { quebec511VideoUrl } from './cctv/quebec.js';
import { quebec511VideoUrl, quebecBrowserVideoEnabled } from './cctv/quebec.js';
import { normalizeFeedType, isVideoFeedType } from './cctv/normalize.js';
import {
buildSyntheticCctvSvg,
@@ -20,6 +20,7 @@ import { admitSameSiteKeyless } from './common/same-site.js';
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
import { createHlsPuller } from './cctv/stream.js';
import { isMlitRiverAdjustmentImage } from './cctv/worldCameras.js';
import { ottawaAuthorizedFrameUrl } from './cctv/ottawa.js';
import { googleServerApiKey } from './places/google-key.js';
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
/**
@@ -45,8 +46,14 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
/** Live HLS strategies (see ./cctv/stream.js). Shared across dev and preview. */
const puller = createHlsPuller();
/** Bounded transient image reuse keeps World providers inside their cadence. */
const WORLD_FRAME_CACHE_MAX_ENTRIES = 128;
/**
* Bounded transient image reuse keeps cadence-limited providers (World
* stills, Ottawa's once-a-minute API) inside their refresh floor. The byte
* cap is the real bound; the entry cap only limits map overhead.
*/
const WORLD_FRAME_CACHE_MAX_ENTRIES = 1024;
/** Retry a failed fetch sooner than a full refresh window, never under a minute. */
const THROTTLED_FRAME_FAILURE_BACKOFF_MS = 60 * 1000;
const WORLD_FRAME_CACHE_MAX_ENTRY_BYTES = 2 * 1024 * 1024;
const WORLD_FRAME_CACHE_MAX_BYTES = 32 * 1024 * 1024;
const worldFrameCache = new Map();
@@ -89,7 +96,25 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
}
}
async function fetchWorldCameraFrame(source, cameraId, frameUrl) {
function setNextAllowedAt(cameraId, at) {
worldFrameNextAllowedAt.delete(cameraId);
worldFrameNextAllowedAt.set(cameraId, at);
while (worldFrameNextAllowedAt.size > 4096) {
worldFrameNextAllowedAt.delete(
worldFrameNextAllowedAt.keys().next().value,
);
}
}
/**
* Fetch a frame from a provider with a per-camera refresh floor. Inside the
* floor the last frame is reused; with no frame to reuse the caller gets
* `{ throttled: true }` and serves a placeholder, so a cadence-limited camera
* never falls through to the key-bearing Street View fallback. A failed
* fetch backs off for a minute (capped at the floor) instead of a full
* window.
*/
async function fetchThrottledFrame(source, cameraId, frameUrl) {
const cached = getWorldFrameCache(cameraId);
const rawRefreshMs = Number(source?.frameRefreshMs);
const refreshMs =
@@ -103,24 +128,18 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
const inflight = worldFrameInflight.get(cameraId);
if (inflight) return inflight;
const stale = cached
? {
ok: true,
body: cached.body,
contentType: cached.contentType,
stale: true,
}
: null;
const nextAllowedAt = worldFrameNextAllowedAt.get(cameraId) || 0;
if (now < nextAllowedAt) {
return cached
? {
ok: true,
body: cached.body,
contentType: cached.contentType,
stale: true,
}
: null;
}
worldFrameNextAllowedAt.delete(cameraId);
worldFrameNextAllowedAt.set(cameraId, now + refreshMs);
while (worldFrameNextAllowedAt.size > 4096) {
worldFrameNextAllowedAt.delete(
worldFrameNextAllowedAt.keys().next().value,
);
}
if (now < nextAllowedAt) return stale || { ok: false, throttled: true };
if (!frameUrl) return stale;
setNextAllowedAt(cameraId, now + refreshMs);
let request;
request = fetchCctvImageFromUpstream(frameUrl)
.then((image) => {
@@ -128,14 +147,11 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
storeWorldFrame(cameraId, image);
return image;
}
return cached
? {
ok: true,
body: cached.body,
contentType: cached.contentType,
stale: true,
}
: image;
setNextAllowedAt(
cameraId,
Date.now() + Math.min(refreshMs, THROTTLED_FRAME_FAILURE_BACKOFF_MS),
);
return stale || image;
})
.finally(() => {
if (worldFrameInflight.get(cameraId) === request)
@@ -254,6 +270,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
);
if (url.pathname === '/sources') {
const quebecVideo = quebecBrowserVideoEnabled();
const body = {
trimmedPacks: getCctvSources.trimmedPacks?.(sources) ?? [],
sources: sources.map((source) => ({
@@ -278,7 +295,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
source.sourceKind || (source.url ? 'configured' : 'fallback'),
sourceUrl: source.sourceUrl || '',
browserMediaUrl:
source.sourceKind === 'quebec-511-browser'
quebecVideo && source.sourceKind === 'quebec-511-browser'
? quebec511VideoUrl(source.id)
: '',
accuracyM: source.accuracyM,
@@ -636,10 +653,18 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
const upstreamImage =
source?.pack === 'world-cameras'
? await fetchWorldCameraFrame(source, cameraId, upstreamCandidate)
: source?.sourceKind === 'txdot-its'
? await fetchTxdotSnapshot(upstreamCandidate)
: await fetchCctvImageFromUpstream(upstreamCandidate);
? await fetchThrottledFrame(source, cameraId, upstreamCandidate)
: source?.sourceKind === 'ottawa-traffic'
? await fetchThrottledFrame(
source,
cameraId,
ottawaAuthorizedFrameUrl(upstreamCandidate),
)
: source?.sourceKind === 'txdot-its'
? await fetchTxdotSnapshot(upstreamCandidate)
: await fetchCctvImageFromUpstream(upstreamCandidate, {
allowMjpeg: source?.sourceKind === 'taiwan-highway-motc',
});
if (
source?.sourceKind === 'mlit-river' &&
isMlitRiverAdjustmentImage(upstreamImage)
@@ -664,6 +689,24 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
res.end(svg);
return;
}
if (upstreamImage?.throttled) {
// Inside the provider's refresh floor with no frame to reuse: a
// placeholder, not Street View, and no health change.
res.writeHead(200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-store',
'X-CCTV-Source': 'synthetic',
});
res.end(
buildSyntheticCctvSvg({
cameraId,
label,
city,
status: 'WAITING FOR NEXT FRAME',
}),
);
return;
}
if (upstreamImage?.ok) {
setHealth(cameraId, {
status: upstreamImage.stale ? 'degraded' : 'ok',
+19 -7
View File
@@ -1,5 +1,6 @@
import {
CCTV_MAX_SOURCES_CEILING,
CCTV_SHARED_SOURCE_SLOTS,
DEFAULT_CCTV_MAX_SOURCES,
} from './constants.js';
@@ -23,16 +24,27 @@ export function resolveCatalogCap(raw) {
* exceed the catalog cap, the cap is filled ROUND-ROBIN — one camera from
* each pack per turn, in each pack's own order — so a lowered
* CCTV_MAX_SOURCES thins every region a little instead of deleting whichever
* pack happened to be appended last. Duplicate ids resolve last-pack-wins so
* pack happened to be appended last. A pack with a `reservedCount` keeps up to
* that many rows only from the capacity ABOVE the shared floor, so a
* reservation adds room and never shrinks the other packs' round-robin
* share; at or below the floor reserved packs are ordinary lanes. Duplicate
* ids resolve last-pack-wins so
* file/env overrides keep replacing live records; the record is then counted
* against the pack that won it.
*
* @param {Array<{name:string, sources:Array<object>}>} packs - Normalized
* packs in merge order (earlier packs lose duplicate ids to later ones).
* @param {number} maxCount - Catalog cap.
* @param {object} [options]
* @param {number} [options.sharedFloor=CCTV_SHARED_SOURCE_SLOTS] - Slots kept
* for the round-robin before reservations apply.
* @returns {{sources:Array<object>, packs:Array<{name:string, offered:number, kept:number}>}}
*/
export function allocateSourceCap(packs, maxCount) {
export function allocateSourceCap(
packs,
maxCount,
{ sharedFloor = CCTV_SHARED_SOURCE_SLOTS } = {},
) {
const owner = new Map();
for (const pack of packs) {
for (const source of pack.sources) {
@@ -69,13 +81,13 @@ export function allocateSourceCap(packs, maxCount) {
lane.kept = lane.queue.length;
}
} else {
// Some complete regional catalogs are intentionally guaranteed. Consume
// their requested slots first, then distribute the remaining catalog
// capacity with the existing round-robin policy.
while (sources.length + reservedSources.length < limit) {
// Reserved packs draw only on the capacity above the shared floor, then
// every lane (reserved ones included) shares the rest round-robin.
const reserveBudget = Math.max(0, limit - Math.max(0, sharedFloor));
while (reservedSources.length < reserveBudget) {
let tookReserved = false;
for (const lane of lanes) {
if (sources.length + reservedSources.length >= limit) break;
if (reservedSources.length >= reserveBudget) break;
if (lane.kept >= lane.reserved) continue;
reservedSources.push(lane.queue[lane.next]);
lane.next += 1;
+30 -9
View File
@@ -5,9 +5,14 @@ export const DEFAULT_AUSTIN_ROWS_URL =
/** Default cap on Austin cameras after distance-based prioritization. */
export const DEFAULT_AUSTIN_MAX_SOURCES = 250;
/**
* Catalog-wide default: preserve 4,000 shared slots and reserve 1,120 Québec
* plus 2,396 World image locations. Shared packs still split their slots
* round-robin (see cap.js).
* Slots every pack shares round-robin before any reservation applies. A
* reserved pack (Québec, World) only takes capacity above this floor, so a
* cap at or below it thins every region evenly (see cap.js).
*/
export const CCTV_SHARED_SOURCE_SLOTS = 4000;
/**
* Catalog-wide default: the 4,000 shared slots plus room reserved for the
* 1,120 Québec and 2,396 World image locations.
*/
export const DEFAULT_CCTV_MAX_SOURCES = 7516;
/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */
@@ -195,16 +200,32 @@ export const DRIVEBC_ANCHORS = [
{ lat: 49.2827, lon: -123.1207 }, // Vancouver
{ lat: 48.4284, lon: -123.3656 }, // Victoria
];
/** City of Ottawa traffic cameras: keyless JSON list, city-served JPEG stills. */
export const OTTAWA_CAMERAS_URL = 'https://traffic.ottawa.ca/map/camera_list';
/** Frame for one camera; the city serves the still on its own map path. */
export const OTTAWA_IMAGE_URL = (id) =>
`https://traffic.ottawa.ca/map/camera?id=${encodeURIComponent(id)}`;
/** City of Ottawa traffic cameras: documented JSON list (keyless). */
export const OTTAWA_CAMERAS_URL =
'https://traffic.ottawa.ca/map/service/camera';
/**
* Keyless canonical frame URL for one camera number. The city's Traffic Camera
* Images API needs a certificate (OTTAWA_CAMERA_CERTIFICATE), which the frame
* proxy adds at request time so it never appears in a catalog response.
*/
export const OTTAWA_IMAGE_URL = (cameraNumber) =>
`https://traffic.ottawa.ca/opendata/camera?c=${encodeURIComponent(cameraNumber)}`;
/** Pinned frame directory, for redirect containment in the media proxy. */
export const OTTAWA_IMAGE_ORIGIN = 'https://traffic.ottawa.ca/map/';
export const OTTAWA_IMAGE_ORIGIN = 'https://traffic.ottawa.ca/opendata/';
/** The API lets one certificate and user id fetch a camera once a minute. */
export const OTTAWA_FRAME_REFRESH_MS = 60 * 1000;
/** Alphanumeric user id this server sends with its certificate. */
export const OTTAWA_API_USER_ID = 'gev';
export const DEFAULT_OTTAWA_MAX_SOURCES = 250;
/** Prioritization anchor: downtown Ottawa (Parliament Hill). */
export const OTTAWA_ANCHORS = [{ lat: 45.4215, lon: -75.6972 }];
/**
* Taiwan Highway Bureau MJPEG servers. Each camera is one root path segment,
* so the pin is the host root: a redirect must stay on the same server.
*/
export const TAIWAN_HIGHWAY_IMAGE_ORIGINS = Object.freeze(
[1, 2, 3, 4, 5, 6, 7, 8].map((n) => `https://cctv-ss0${n}.thb.gov.tw/`),
);
/** Direct still-image directories from the World cams add-on's eligible packs. */
export const VEGAGERDIN_IMAGE_ORIGIN =
'https://www.vegagerdin.is/vgdata/vefmyndavelar/';
+157 -85
View File
@@ -18,6 +18,7 @@ import {
OTTAWA_IMAGE_ORIGIN,
TALLINN_IMAGE_ORIGIN,
TARKTEE_IMAGE_ORIGIN,
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
TFL_IMAGE_ORIGIN,
WARENDORF_IMAGE_ORIGINS,
WSDOT_IMAGE_ORIGIN,
@@ -538,6 +539,7 @@ for (const prefix of [
VEGAGERDIN_IMAGE_ORIGIN,
TRIPCHECK_IMAGE_ORIGIN,
MLIT_RIVER_IMAGE_ORIGIN,
...TAIWAN_HIGHWAY_IMAGE_ORIGINS,
]) {
const host = new URL(prefix).hostname;
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
@@ -629,18 +631,32 @@ export function cctvUpstreamUserAgent(url) {
}
}
/** Read one bounded JPEG part from an open MJPEG response, then stop the stream. */
/** Largest single JPEG the MJPEG first-frame reader will buffer. */
export const CCTV_MJPEG_FRAME_MAX_BYTES = 2 * 1024 * 1024;
/**
* Read one bounded JPEG part from an open MJPEG response, then stop the stream.
*
* The first part may follow a CRLF preamble, and its delimiter may be written
* as `--<boundary>` or, when the declared boundary already starts with `--`,
* as the boundary itself. A part with Content-Length is read to that length;
* a part without one ends at the next delimiter, so a JPEG end marker inside
* the image (an EXIF thumbnail) never truncates it. Bytes are copied once
* into a preallocated buffer and every search resumes where it stopped, so a
* trickling upstream costs linear time.
*/
async function readFirstMjpegFrame(upstream, maxBytes) {
const mediaType = cctvLiveMediaType(
'mjpeg',
upstream.headers.get('content-type'),
);
const boundary = parseMediaType(mediaType)?.params.get('boundary') || '';
const frameMax = Math.min(maxBytes, CCTV_MJPEG_FRAME_MAX_BYTES);
if (
!boundary ||
!upstream.body ||
!Number.isSafeInteger(maxBytes) ||
maxBytes < 4
!Number.isSafeInteger(frameMax) ||
frameMax < 4
) {
try {
await upstream.body?.cancel();
@@ -650,19 +666,91 @@ async function readFirstMjpegFrame(upstream, maxBytes) {
return null;
}
const nextBoundary = Buffer.from('\r\n--' + boundary, 'latin1');
const opening = Buffer.from('--' + boundary + '\r\n', 'latin1');
const delimiters = [Buffer.from('--' + boundary, 'latin1')];
if (boundary.startsWith('--') && boundary.length > 2)
delimiters.push(Buffer.from(boundary, 'latin1'));
const lineEnd = Buffer.from('\r\n', 'ascii');
const headerTerminator = Buffer.from('\r\n\r\n', 'ascii');
const jpegStart = Buffer.from([0xff, 0xd8]);
const jpegEnd = Buffer.from([0xff, 0xd9]);
const maxPreambleBytes = 64;
const maxHeaderBytes = 8 * 1024;
const maxBufferedBytes =
opening.length + headerTerminator.length + maxHeaderBytes + maxBytes;
maxPreambleBytes +
delimiters[0].length +
lineEnd.length +
maxHeaderBytes +
headerTerminator.length +
frameMax +
lineEnd.length +
delimiters[0].length;
const buffer = Buffer.allocUnsafe(maxBufferedBytes);
let length = 0;
let reader;
let buffer = Buffer.alloc(0);
let parsedHeaders = false;
let headerStart = -1;
let delimiter = null;
let headerScanFrom = 0;
let bodyStart = -1;
let declaredLength = null;
let boundaryScanFrom = -1;
/** Match the opening delimiter after an optional CRLF preamble. */
function findOpening() {
let at = 0;
while (at < length && (buffer[at] === 0x0d || buffer[at] === 0x0a)) at++;
if (at > maxPreambleBytes) return { failed: true };
for (const candidate of delimiters) {
const line = Buffer.concat([candidate, lineEnd]);
const need = at + line.length;
if (length < need) {
const seen = buffer.subarray(at, length);
if (line.subarray(0, seen.length).equals(seen))
return { pending: true };
continue;
}
if (buffer.subarray(at, need).equals(line))
return { start: need, delimiter: candidate };
}
return { failed: true };
}
/** Parse the part headers; false rejects the stream. */
function parseHeaders(headerEnd) {
const headerText = buffer
.subarray(headerStart, headerEnd)
.toString('latin1');
const headers = new Map();
for (const line of headerText.split('\r\n')) {
const colon = line.indexOf(':');
if (colon <= 0) return false;
const name = line.slice(0, colon).trim().toLowerCase();
if (!name || headers.has(name)) return false;
headers.set(name, line.slice(colon + 1).trim());
}
if (cctvRasterImageType(headers.get('content-type')) !== 'image/jpeg')
return false;
const declared = headers.get('content-length');
if (declared !== undefined) {
if (!/^\d+$/.test(declared)) return false;
declaredLength = Number(declared);
if (
!Number.isSafeInteger(declaredLength) ||
declaredLength < jpegStart.length + jpegEnd.length ||
declaredLength > frameMax
)
return false;
}
return true;
}
/** A complete JPEG body, or null. */
function asJpeg(frame) {
return frame.length >= jpegStart.length + jpegEnd.length &&
frame.subarray(0, jpegStart.length).equals(jpegStart) &&
frame.subarray(-jpegEnd.length).equals(jpegEnd)
? Buffer.from(frame)
: null;
}
try {
reader = upstream.body.getReader();
@@ -671,92 +759,71 @@ async function readFirstMjpegFrame(upstream, maxBytes) {
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,
);
const room = maxBufferedBytes - length;
const included = Math.min(room, chunk.length);
chunk.copy(buffer, length, 0, included);
length += included;
const overflow = included < chunk.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;
if (headerStart < 0) {
const opening = findOpening();
if (opening.failed) return null;
if (opening.pending) {
if (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;
headerStart = opening.start;
delimiter = opening.delimiter;
headerScanFrom = headerStart;
}
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;
if (bodyStart < 0) {
const headerEnd = buffer
.subarray(0, length)
.indexOf(headerTerminator, headerScanFrom);
if (headerEnd < 0) {
if (length - headerStart > maxHeaderBytes || overflow) return null;
headerScanFrom = Math.max(
headerStart,
length - headerTerminator.length + 1,
);
continue;
}
} 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 (headerEnd - headerStart > maxHeaderBytes) return null;
if (!parseHeaders(headerEnd)) return null;
bodyStart = headerEnd + headerTerminator.length;
boundaryScanFrom = bodyStart;
}
const available = length - bodyStart;
if (available >= jpegStart.length) {
if (
nextPart >= bodyStart &&
(end < 0 || end + jpegEnd.length > nextPart)
!buffer
.subarray(bodyStart, bodyStart + jpegStart.length)
.equals(jpegStart)
)
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;
}
const nextDelimiter = Buffer.concat([lineEnd, delimiter]);
const nextPart = buffer
.subarray(0, length)
.indexOf(nextDelimiter, boundaryScanFrom);
if (declaredLength !== null) {
if (nextPart >= bodyStart && nextPart - bodyStart < declaredLength)
return null;
if (available >= declaredLength)
return asJpeg(buffer.subarray(bodyStart, bodyStart + declaredLength));
} else if (nextPart >= bodyStart) {
const frame = buffer.subarray(bodyStart, nextPart);
return frame.length > frameMax ? null : asJpeg(frame);
} else if (available > frameMax) {
return null;
}
if (nextPart < 0)
boundaryScanFrom = Math.max(
bodyStart,
length - nextDelimiter.length + 1,
);
if (overflow) return null;
}
} finally {
@@ -786,6 +853,10 @@ async function readFirstMjpegFrame(upstream, maxBytes) {
* @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation.
* @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS] - Abort timeout.
* @param {number} [options.maxBytes=CCTV_FRAME_MAX_BODY_BYTES] - Snapshot byte cap.
* @param {boolean} [options.allowMjpeg=false] - Accept a multipart MJPEG
* answer and keep its first JPEG. Only sources registered as MJPEG stills
* (the Taiwan Highway Bureau feeds) set it; any other host answering
* multipart falls through to the next fallback.
* @returns {Promise<{ok:true,body:Buffer,contentType:string}|null>}
*/
export async function fetchCctvImageFromUpstream(
@@ -794,6 +865,7 @@ export async function fetchCctvImageFromUpstream(
fetchImpl = fetch,
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
maxBytes = CCTV_FRAME_MAX_BODY_BYTES,
allowMjpeg = false,
} = {},
) {
if (!url || !/^https?:\/\//i.test(url)) return null;
@@ -828,7 +900,7 @@ export async function fetchCctvImageFromUpstream(
if (!body) return null;
return { ok: true, body, contentType };
}
if (!cctvLiveMediaType('mjpeg', upstreamType)) {
if (!allowMjpeg || !cctvLiveMediaType('mjpeg', upstreamType)) {
try {
await upstream.body?.cancel();
} catch {
+85 -19
View File
@@ -2,15 +2,55 @@ import {
CCTV_SOURCE_FETCH_TIMEOUT_MS,
DEFAULT_OTTAWA_MAX_SOURCES,
OTTAWA_ANCHORS,
OTTAWA_API_USER_ID,
OTTAWA_CAMERAS_URL,
OTTAWA_FRAME_REFRESH_MS,
OTTAWA_IMAGE_ORIGIN,
OTTAWA_IMAGE_URL,
} from './constants.js';
import {
fallbackHeadingFromId,
pinnedCameraUrl,
prioritizeSources,
toFiniteNumber,
} from './normalize.js';
let ottawaCertificateMissingLogged = false;
/** Test hook: let the next missing-certificate skip log again. */
export function _resetOttawaCertificateWarningForTest() {
ottawaCertificateMissingLogged = false;
}
/** The operator's Traffic Camera Images API certificate, trimmed. */
function ottawaCertificate(env = process.env) {
return String(env.OTTAWA_CAMERA_CERTIFICATE ?? '').trim();
}
/**
* Add the operator's certificate to a registered keyless Ottawa frame URL.
* Only a URL inside the pinned `/opendata/` directory with a numeric camera
* number is signed, so the certificate never goes to any other path or host.
*
* @param {string} url - Canonical frame URL from the catalog.
* @param {Record<string,string|undefined>} [env]
* @returns {string} The signed URL, or '' when there is no certificate or the
* URL is not a registered Ottawa frame.
*/
export function ottawaAuthorizedFrameUrl(url, env = process.env) {
const certificate = ottawaCertificate(env);
const pinned = pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN);
if (!certificate || !pinned) return '';
const parsed = new URL(pinned);
const cameraNumber = parsed.searchParams.get('c') || '';
if (parsed.pathname !== '/opendata/camera' || !/^\d+$/.test(cameraNumber))
return '';
const signed = new URL(OTTAWA_IMAGE_URL(cameraNumber));
signed.searchParams.set('certificate', certificate);
signed.searchParams.set('id', OTTAWA_API_USER_ID);
return signed.href;
}
/**
* City of Ottawa envelope: Kanata to Orléans, Barrhaven to the river, with
* slack for the MTO cameras on the 416/417 that the city feed also carries.
@@ -35,19 +75,37 @@ function isOttawaCoordinate(lat, lon) {
}
/**
* Load the City of Ottawa's public traffic camera list.
* Load the City of Ottawa's traffic cameras.
*
* The keyless `camera_list` JSON publishes an id, coordinates, an English and
* French description, and a `type`; frames are JPEG stills built from the id
* on the city's own image path. The feed carries no bearing, no elevation and
* no status field, so headings use the same deterministic low-confidence
* fallback as the other heading-less packs and the client's ground snap
* refines the elevation prior.
* Camera images come from the city's Traffic Camera Images API, which needs a
* certificate from the city's registration form (OTTAWA_CAMERA_CERTIFICATE)
* and allows one fetch per camera per minute for a certificate and user id.
* Without a certificate the pack is skipped, with no request and one log line.
* The camera list is the documented `map/service/camera` JSON: a camera
* number, English and French names, coordinates and the owner (CITY or MTO).
* The feed carries no bearing, no elevation and no status field, so headings
* use the same deterministic low-confidence fallback as the other
* heading-less packs and the client's ground snap refines the elevation
* prior.
*
* @param {object} [_options]
* @param {Record<string,string|undefined>} [env]
* @returns {Promise<Array<object>>} Cameras nearest downtown first, or [] on
* any upstream or payload failure.
* a missing certificate or any upstream or payload failure.
*/
export async function loadOttawaSourcesFromOpenData() {
export async function loadOttawaSourcesFromOpenData(
_options = {},
env = process.env,
) {
if (!ottawaCertificate(env)) {
if (!ottawaCertificateMissingLogged) {
ottawaCertificateMissingLogged = true;
console.warn(
'[CCTV] Ottawa cameras skipped: the city camera image API needs a certificate (set OTTAWA_CAMERA_CERTIFICATE)',
);
}
return [];
}
try {
const resp = await fetch(OTTAWA_CAMERAS_URL, {
headers: { Accept: 'application/json' },
@@ -57,23 +115,30 @@ export async function loadOttawaSourcesFromOpenData() {
console.warn('[CCTV] Ottawa camera download failed:', resp.status);
return [];
}
const rows = await resp.json();
if (!Array.isArray(rows)) return [];
const payload = await resp.json();
const rows = Array.isArray(payload?.cameras) ? payload.cameras : null;
if (!rows) return [];
const cameras = [];
const seen = new Set();
for (const row of rows) {
const id = toFiniteNumber(row?.id);
if (!Number.isSafeInteger(id) || id <= 0) continue;
const number = toFiniteNumber(row?.camera_number);
if (!Number.isSafeInteger(number) || number <= 0 || seen.has(number))
continue;
const lat = toFiniteNumber(row?.latitude);
const lon = toFiniteNumber(row?.longitude);
if (!isOttawaCoordinate(lat, lon)) continue;
seen.add(number);
const cameraId = `ottawa-${id}`;
const imageUrl = OTTAWA_IMAGE_URL(id);
const type = String(row?.type || '').trim();
const cameraId = `ottawa-${number}`;
const imageUrl = OTTAWA_IMAGE_URL(number);
// The docs name the field camera_owner; the live feed sends cameraOwner.
const owner = String(row?.camera_owner || row?.cameraOwner || '')
.trim()
.toUpperCase();
cameras.push({
id: cameraId,
name: String(row?.description || '').trim() || `Ottawa camera ${id}`,
name: String(row?.name || '').trim() || `Ottawa camera ${number}`,
city: 'Ottawa',
cityId: 'ottawa',
provider: 'City of Ottawa',
@@ -89,10 +154,11 @@ export async function loadOttawaSourcesFromOpenData() {
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
frameRefreshMs: OTTAWA_FRAME_REFRESH_MS,
sourceKind: 'ottawa-traffic',
license: 'City of Ottawa public traffic camera feed',
license: 'City of Ottawa Traffic Camera Images API (registered access)',
credit:
type === 'MTO'
owner === 'MTO'
? 'Ministry of Transportation of Ontario (MTO) camera'
: '',
});
+15 -4
View File
@@ -5,6 +5,16 @@ import { cameraDisplayCode, fallbackHeadingFromId } from './normalize.js';
const QUEBEC_CATALOG_PATH = 'public/quebec-cameras.json';
export const QUEBEC_CAMERA_SOURCE_COUNT = 1120;
/**
* Whether the operator opted in to the browser-direct Québec 511 video. Off by
* default: the camera locations are CC BY 4.0, but whether Québec 511's
* service terms allow another site to embed its clips is unconfirmed, so an
* unset flag lists the cameras with a link to the 511 viewer page only.
*/
export function quebecBrowserVideoEnabled(env = process.env) {
return String(env.CCTV_QUEBEC_VIDEO_ENABLED || '').trim() === '1';
}
/** Build the direct browser-only MP4 URL for one namespaced MTQ camera id. */
export function quebec511VideoUrl(cameraId) {
const match = /^qc-511-(\d+)$/.exec(String(cameraId || ''));
@@ -99,8 +109,9 @@ export function loadQuebecSources({ sourceRoot = process.cwd() } = {}) {
mountHeightM: 8,
groundElevationM: 0,
feedType: 'image',
// No frame URL is registered: 511 denies the server-side media proxy,
// while the browser can play its MP4 in the dedicated DOM overlay.
// No frame URL is registered: the server never fetches 511 media. With
// CCTV_QUEBEC_VIDEO_ENABLED=1 the browser plays the 511 MP4 in the DOM
// overlay after a play click; otherwise the camera links to 511.
url: '',
snapshotUrl: '',
sourceKind,
@@ -110,12 +121,12 @@ export function loadQuebecSources({ sourceRoot = process.cwd() } = {}) {
: null,
positionSource: String(camera.positionSource || ''),
license: isTraffic
? 'Données Québec / MTQ, CC BY 4.0'
? 'Locations: Données Québec / MTQ, CC BY 4.0. Video: hosted by Québec 511 under its terms of use'
: isMontreal
? 'OpenStreetMap contributors, ODbL 1.0'
: 'Operator terms apply; location provenance shown per camera',
credit: isTraffic
? 'MTQ · CC BY 4.0'
? 'Locations: MTQ · CC BY 4.0 · Video: Québec 511'
: isMontreal
? 'OpenStreetMap contributors · ODbL'
: provider,
+7 -11
View File
@@ -1,4 +1,7 @@
import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
import {
CCTV_SOURCE_FETCH_TIMEOUT_MS,
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
} from './constants.js';
import {
cameraDisplayCode,
fallbackHeadingFromId,
@@ -37,16 +40,9 @@ 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',
]);
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. */