mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
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:
1 parent
fa4ce4b2a5
commit
db8afbae81
43 files changed
+1327
-1110
No files matched your search
@@ -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'
|
||||
: '',
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user