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

+7 -1
View File
@@ -211,6 +211,7 @@ AISSTREAM_API_KEY=
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc
# OTTAWA_CAMERA_CERTIFICATE= # City of Ottawa Traffic Camera Images API certificate (free; request it via the form on traffic.ottawa.ca 'About the data'); without it the Ottawa pack is skipped
# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
@@ -258,7 +259,12 @@ AISSTREAM_API_KEY=
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
# CCTV_OTTAWA_ENABLED=1 # set 0 to skip City of Ottawa cameras (also needs OTTAWA_CAMERA_CERTIFICATE)
# CCTV_OTTAWA_MAX_SOURCES=250 # cap Ottawa cameras (8-500, nearest downtown first)
# CCTV_QUEBEC_ENABLED=1 # set 0 to skip the Québec camera locations (MTQ, Montréal, scenic links)
# CCTV_QUEBEC_VIDEO_ENABLED= # off by default; set 1 to let viewers play Québec 511 clips in their browser after pressing Play (511 terms of use unconfirmed; see DATA_SOURCES.md)
# CCTV_WORLD_CAMERAS_ENABLED=1 # set 0 to skip the Iceland, Oregon and MLIT (Japan) still cameras
# CCTV_MAX_SOURCES=7516 # catalog-wide ceiling: 4,000 slots shared round-robin, the rest reserved for Québec and World
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
+2 -2
View File
@@ -116,7 +116,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400.
- **Freeway Bureau and Highway Bureau, MOTC (Taiwan).** National freeway cameras use the Freeway Bureau’s keyless [MOTC 2.0 CCTV table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml), refreshed daily and fetched at runtime (never bundled). Freeway Bureau data follows the [Open Government Data License, Taiwan v1.0](https://data.gov.tw/license); the app credits the Bureau, names the dataset and links the licence. If that catalog is unreachable and no last-good source list exists, the app falls back to the Highway Bureau’s free [provincial-road CCTV XML feed](https://thbapp.thb.gov.tw/opendata/), with attribution as requested by the Bureau. Its MJPEG streams are accepted only from cctv-ss01 through cctv-ss08.thb.gov.tw over HTTPS; the frame proxy extracts one bounded JPEG per request. This fallback covers provincial roads, not national freeways. Neither feed requires an API key. CCTV_TAIWAN_ENABLED=0 disables both; CCTV_TAIWAN_MAX_SOURCES defaults to 600 (bounded to 1–2000).
- **Québec CCTV (MTQ / Québec 511, Montréal and scenic links).** `public/quebec-cameras.json` contains 680 MTQ positions from the official georeferenced WFS (CC BY 4.0), 426 Montréal camera locations derived from OSM (ODbL 1.0), and 14 scenic records with per-row provenance and accuracy. The 680 MTQ cameras play the operator's five-second MP4 clip directly in the browser from `www.quebec511.info/Carte/Fenetres/camera.ashx?id=<id>&format=mp4`; Québec 511 refreshes those clips every few minutes. A second browser request supplies an approximate map still. The app does not proxy, store, or read video pixels, and no camera images are bundled. Montréal and scenic entries have provider-page links rather than direct frame URLs; their source-page terms apply. The video remains hosted by Québec 511 under its service terms. No API key is required; `CCTV_QUEBEC_ENABLED=0` disables the pack.
- **Québec CCTV (MTQ / Québec 511, Montréal and scenic links).** `public/quebec-cameras.json` contains 680 MTQ positions from the official georeferenced WFS (CC BY 4.0), 426 Montréal camera locations derived from OSM (ODbL 1.0), and 14 scenic records with per-row provenance and accuracy. The MTQ video is **opt-in** (`CCTV_QUEBEC_VIDEO_ENABLED=1`): the CC BY 4.0 licence covers the camera locations only, and Québec 511's terms for embedding its clips on another site are unconfirmed, so by default MTQ cameras link to the Québec 511 viewer page instead. With the flag set, the 680 MTQ cameras play the operator's five-second MP4 clip directly in the viewer's browser from `www.quebec511.info/Carte/Fenetres/camera.ashx?id=<id>&format=mp4`, only after the viewer presses Play, so Québec 511 sees the viewer's IP address; Québec 511 refreshes those clips every few minutes. A second browser request supplies an approximate map still. The app does not proxy, store, or read video pixels, and no camera images are bundled. The selected-camera credit names both sources: locations MTQ under CC BY 4.0, video Québec 511. Montréal and scenic entries have provider-page links rather than direct frame URLs; their source-page terms apply. The video remains hosted by Québec 511 under its service terms. No API key is required; `CCTV_QUEBEC_ENABLED=0` disables the pack. Confirm Québec 511's terms of use before setting `CCTV_QUEBEC_VIDEO_ENABLED=1` on a public deployment.
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
@@ -163,7 +163,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms.
- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch.
- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
- **City of Ottawa traffic cameras.** The keyless `traffic.ottawa.ca/map/camera_list` JSON supplies an id, coordinates, an English/French description and a type; frames are city-served JPEG stills built from the id at `traffic.ottawa.ca/map/camera?id=<id>`, and only that directory is pinned. MTO cameras carried on the city feed keep their owner as a per-camera credit. The feed publishes no bearing, so headings use the shared id-hash fallback at low confidence, and no status field, so a camera the city takes offline still registers. Frames are fetched at request time and never stored. The city publishes no explicit reuse licence for this feed; deployment beyond personal local viewing needs confirmation of applicable terms. `CCTV_OTTAWA_MAX_SOURCES` caps the pack (default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off.
- **City of Ottawa traffic cameras.** Camera images come from the city's [Traffic Camera Images API](https://traffic.ottawa.ca/en/traffic-map-data-lists-and-resources/about-the-data), which requires an access certificate requested through the city's registration form and allows one certificate and user id to fetch a camera at most once every 60 seconds. Set `OTTAWA_CAMERA_CERTIFICATE`; without it the pack is skipped and makes no request. The camera list is the documented keyless `traffic.ottawa.ca/map/service/camera` JSON (camera number, English and French names, coordinates, owner). Frames are `traffic.ottawa.ca/opendata/camera?c=<number>`; the server adds the certificate and the user id `gev` at fetch time, so no catalog response carries the key, and serves each camera from one shared in-memory frame per minute. MTO cameras carried on the city feed keep their owner as a per-camera credit. The feed publishes no bearing, so headings use the shared id-hash fallback at low confidence, and no status field, so a camera the city takes offline still registers. Frames are never stored on disk. The city states no redistribution licence for the images; the certificate request asks how they will be used, so describe the deployment there. `CCTV_OTTAWA_MAX_SOURCES` caps the pack (default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off.
- **World cams image subset.** The normal CCTV catalog adds 166 Icelandic Road and Coastal Administration cameras, 1,176 Oregon TripCheck stills, and 1,054 MLIT river cameras from the curated `config/cctv_sources.world-image-cameras.json` export; disable it with `CCTV_WORLD_CAMERAS_ENABLED=0`. The server validates and pins each image URL to its provider path. [Vegagerðin's data-service licence](https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions) permits reuse with attribution but excludes third-party rights; [ODOT allows TripCheck image use and requests credit](https://www.tripcheck.com/Pages/Frequently-Asked-Questions); MLIT's [site terms](https://www.river.go.jp/kawabou/kwb_apend/html/caution.html) are CC BY-compatible with attribution and warn against high-volume/periodic collection, so those frames refresh at ten minutes per camera. Images are not bundled or persisted; a bounded 32 MB in-memory cache reuses them until the per-source cadence. The 249 NZTA Journey Planner rows are excluded because its [website terms prohibit scraping](https://www.journeys.nzta.govt.nz/about-this-site/terms-of-use); use an authorized API feed or obtain permission before adding those rows.
- **TxDOT ITS cameras.** The catalog comes from the keyless `its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict` endpoint, one call per district. Frames come from the per-camera `GetCctvSnapshotByIcdId` endpoint, which answers JSON carrying a base64 JPEG rather than an image body; the proxy decodes it only for responses from the official TxDOT origin and validates the JPEG header before serving. Only cameras reporting `Device Online` are registered. Districts default to Austin and San Antonio (`CCTV_TXDOT_DISTRICTS`; 25 codes exist statewide); `CCTV_TXDOT_MAX_SOURCES` caps the pack and `CCTV_TXDOT_ENABLED=0` turns it off. Frames are fetched live at request time and are never stored or redistributed.
- **Tallinn ristmikud.** Intersection camera stills are fetched live from `ristmikud.tallinn.ee` (`/last/camNNN.jpg`). The curated catalog (`config/cctv_sources.tallinn.json`) supplies coordinates and heading priors; only server-registered ristmikud HTTPS URLs are proxied. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_TALLINN_ENABLED=0`.
+1 -1
View File
@@ -435,7 +435,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~7,516 camera locations (4,000 shared slots + 1,120 Québec + 2,396 World image feeds) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Québec (MTQ browser MP4, Montréal locations, regional webcams) · Iceland (Vegagerðin) · Oregon (TripCheck) · Japan (MLIT river cameras) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published or attributed; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📹 **CCTV Mesh** | up to 7,516 camera locations (4,000 shared round-robin slots, plus room for 1,120 Québec and 2,396 World image cameras) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Ottawa (City of Ottawa, free access certificate) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Québec (MTQ locations with Québec 511 links, Montréal locations, regional webcams) · Iceland (Vegagerðin) · Oregon (TripCheck) · Japan (MLIT river cameras) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Québec 511 in-browser video (embedding terms unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published or attributed; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
+2 -1
View File
@@ -71,10 +71,11 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists and segment bodies while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
- **Québec 511 video plays in the browser, opt-in and after Play.** With `CCTV_QUEBEC_VIDEO_ENABLED=1`, `/api/cctv/sources` lists, for each MTQ camera, one URL built server-side from its numeric id on `https://www.quebec511.info/Carte/Fenetres/camera.ashx`; the overlay accepts no other host, path or format. The browser loads that clip only after the viewer presses Play for the selected camera, so Québec 511 sees the viewer's IP address, and the panel says so before Play. Selecting a camera by click, auto-hop, a scene or voice never loads it, and switching cameras stops playback. GEV never proxies, caches or reads the video's pixels. With the flag unset (the default) the browser makes no request to Québec 511.
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. The one exception is a source registered as an MJPEG still (the Taiwan Highway Bureau feeds): its `multipart/x-mixed-replace` answer is read only up to its first JPEG part, capped at 2 MB, and sent as `image/jpeg`; a multipart answer from any other camera falls through. A camera whose API needs an operator credential (the City of Ottawa certificate, `OTTAWA_CAMERA_CERTIFICATE`) has a keyless catalog URL; the frame proxy adds the credential only at fetch time, only for a URL inside that camera's pinned path, so `/api/cctv/sources` and `/api/cctv/stream` never carry it. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
- **Response-size caps and timeouts** on proxied responses.
- **Sanitized errors** — internal error details are not echoed back to clients.
@@ -0,0 +1,8 @@
- Expand CCTV coverage with a keyless Taiwan Highway Bureau fallback, 1,120
Québec camera locations, City of Ottawa traffic cameras (needs a free city
access certificate, `OTTAWA_CAMERA_CERTIFICATE`), and 2,396 curated
image-backed cameras from Iceland, Oregon, and Japan (InoukT, #1506).
- Québec 511 in-browser video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) and
loads only after the viewer presses Play; enforce provider-specific frame
refresh limits, and report recognized MLIT maintenance images as degraded
instead of healthy camera snapshots.
@@ -1,6 +0,0 @@
- Expand CCTV coverage with a keyless Taiwan Highway Bureau fallback, Québec
camera catalog and map preview, Ottawa traffic cameras, and 2,396 curated
image-backed cameras from Iceland, Oregon, and Japan (InoukT, #1506).
- Keep Québec 511 video browser-only, enforce provider-specific frame refresh
limits, and report recognized MLIT maintenance images as degraded instead of
healthy camera snapshots.
+11 -4
View File
@@ -15,8 +15,12 @@ media URL permitted by the exception below.
restricted.
- Prefer a public, keyless feed. If credentials are required, keep them in a
server-only environment variable; never put a secret in browser code or a
Vite-exposed variable. If access terms require an agreement or remain
unconfirmed, make the pack opt-in and ensure it makes no request by default.
Vite-exposed variable. A pack that needs a key is opt-in by that key: with
the variable unset it makes no request and logs once (Ontario 511 and
Ottawa). Keep the key out of catalog URLs and add it only at fetch time. Use
the provider's documented API, not a website endpoint that skips its key. If
access terms require an agreement or remain unconfirmed, make the pack
opt-in and ensure it makes no request by default.
- Confirm the feed can be fetched from the app's Node server environment. A URL
opening in a browser does not prove the server can reach it, and browser CORS
behavior does not diagnose a server-side fetch.
@@ -94,8 +98,11 @@ Catalog and media loaders must also:
For live media, prefer the existing proxy and supported feed type (`mjpeg`,
`mp4`, `webm`, or `hls`). A direct browser-only video is a narrow fallback for
a source whose terms allow embedding when the browser can play it but the app
server cannot fetch it. Build its URL from a validated provider ID on the
server; never accept a browser-supplied media URL. Render it in a native DOM
server cannot fetch it. Unconfirmed embedding terms make it opt-in like any
other pack, and the browser loads it only after an explicit play action, never
on selection, because the provider sees the viewer's IP address. Build its URL
from a validated provider ID on the server; never accept a browser-supplied
media URL. Render it in a native DOM
`<video>` without `crossOrigin`, and do not read its pixels into canvas, WebGL,
or a Cesium texture. If a map preview is needed, a second video request may be
paused as a DOM overlay at the camera location; label it as an approximate
+40 -13
View File
@@ -1323,9 +1323,10 @@ id-hash fallback at low confidence.
Once enabled, the 250 cameras nearest the five borough cores load.
`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The default catalog limit is
7,516: 4,000 shared round-robin slots, 1,120 reserved Québec locations, and
2,396 reserved World image sources. Lower explicit `CCTV_MAX_SOURCES` values
are honored.
7,516: 4,000 shared round-robin slots, plus room above them for the 1,120
Québec locations and 2,396 World image sources. Lower explicit
`CCTV_MAX_SOURCES` values are honored; reservations only use the capacity
above the 4,000 shared slots.
## City of Toronto RESCU CCTV source pack
@@ -5022,8 +5023,9 @@ silently demoting every later lookup for the session.
covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
on the main road spine. `CCTV_MAX_SOURCES` defaults to 7,516: 4,000 shared
round-robin slots plus 3,516 reserved Québec and World image slots. Lower
caps fill reservations first; at 3,516 or less, no shared slots remain.
round-robin slots plus 3,516 reserved Québec and World image slots.
Reservations only use capacity above the 4,000 shared slots, so at 4,000 or
less every pack, Québec and World included, shares the cap round-robin.
~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once
it expires, `/api/cctv` keeps answering from it while one background refresh reloads every
pack, so only the very first load waits for the slowest pack (a live first load took about
@@ -6578,13 +6580,22 @@ when `CCTV_QUEBEC_ENABLED` is not `0`. The pack contains 1,120 records: 680
MTQ highway cameras, 426 Montréal OSM camera locations, and 14 scenic webcams.
The default catalog limit is 7,516: it reserves all 1,120 Québec locations and
2,396 World image feeds, and keeps 4,000 slots for the existing round-robin
camera packs. A lower explicit `CCTV_MAX_SOURCES` value is still honored and
can trim the reserved packs if it is below their combined size.
camera packs. Reservations only use capacity above those 4,000 slots: a lower
explicit `CCTV_MAX_SOURCES` thins every pack round-robin, Québec and World
included, instead of taking the shared slots.
Selecting an MTQ camera opens the official Québec 511 MP4 in a floating native
browser player and requests a second video for the paused map preview. The
official feed is a five-second clip refreshed by Québec 511 every 2–4 minutes,
not continuous live video; the browser player reloads every two minutes. The
The Québec 511 video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) because Québec
511's terms for embedding its clips on another site are unconfirmed; the camera
locations themselves are CC BY 4.0. With the flag unset, MTQ cameras show
their location, credit and a link to the Québec 511 viewer page. With it set,
selecting an MTQ camera opens a floating panel with a Play button and a notice
that Québec 511 sees the viewer's IP address; the browser contacts Québec 511
only after Play, never on selection alone (layer enable, auto-hop, a scene or
voice), and a different camera starts unplayed. After Play, the panel plays
the official MP4 in a native browser player and requests a second video for the
paused map preview. The official feed is a five-second clip refreshed by
Québec 511 every 2–4 minutes, not continuous live video; the browser player
reloads every two minutes while the tab is visible. The
map preview is a DOM overlay attached to the camera's coordinates and scales
down with camera distance. Neither video is read into a canvas or Cesium texture:
Québec 511 does not grant localhost pixel access, and its MP4 is not available
@@ -6593,8 +6604,24 @@ locations with per-camera source-page links, but no direct frame URLs; those
show a location label and open the official/operator page. MTQ positions carry
CC BY 4.0 attribution, Montréal positions are attributed to OpenStreetMap
contributors under ODbL, and scenic rows retain their position accuracy and
provenance. The one-camera `/quebec511-camera-preview.html` page remains for
source review; Québec cameras now load through the normal CCTV layer.
provenance. Québec cameras load through the normal CCTV layer.
## City of Ottawa CCTV source pack
The CCTV catalog can add City of Ottawa traffic cameras, including the MTO
cameras the city carries. Camera images come from the city's Traffic Camera
Images API (`traffic.ottawa.ca/opendata/camera`), which needs a certificate
from the city's registration form and lets one certificate and user id fetch a
camera at most once a minute. Without `OTTAWA_CAMERA_CERTIFICATE` the pack is
skipped with no request and one log line. The camera list is the documented
keyless `traffic.ottawa.ca/map/service/camera` JSON. Catalog URLs carry no
key: the frame proxy adds the certificate and the fixed user id `gev` at fetch
time, only for a numeric camera number inside the pinned `/opendata/` path,
and serves each camera from one shared server-side frame per minute. Inside
that minute a camera with no cached frame gets the placeholder, never the
Street View fallback. The feed publishes no bearing, so headings use the
id-hash fallback at low confidence. `CCTV_OTTAWA_MAX_SOURCES` caps the pack
(default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off.
## World camera image feeds
-151
View File
@@ -1,151 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Québec 511 · Camera 3735 preview</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, 'Segoe UI', sans-serif;
background: #060a0e;
color: #e7f5fb;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
padding: clamp(20px, 5vw, 56px);
background:
radial-gradient(
ellipse at 12% 0%,
rgba(0, 181, 216, 0.13),
transparent 45%
),
#060a0e;
}
main {
width: min(100%, 1024px);
margin: 0 auto;
}
.eyebrow,
.meta,
.notice,
.source-link {
font-family: 'Cascadia Code', Consolas, monospace;
}
.eyebrow {
margin: 0 0 12px;
color: #5bd8f2;
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(28px, 5vw, 48px);
line-height: 1.08;
font-weight: 650;
}
.meta {
margin: 14px 0 28px;
color: #92a9b4;
font-size: 13px;
}
.player {
overflow: hidden;
border: 1px solid rgba(91, 216, 242, 0.3);
border-radius: 14px;
background: #020405;
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.45);
}
video {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
background: #020405;
}
.notice {
margin: 18px 0;
padding: 14px 16px;
border-left: 2px solid #e9b95f;
background: rgba(233, 185, 95, 0.08);
color: #d8cba9;
font-size: 12px;
line-height: 1.6;
}
.source-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: #71def5;
font-size: 13px;
text-decoration: none;
}
.source-link:hover {
color: #c2f5ff;
text-decoration: underline;
}
.source-link:focus-visible {
outline: 2px solid #71def5;
outline-offset: 4px;
}
</style>
</head>
<body>
<main>
<p class="eyebrow">God’s Eye View · Single-camera preview</p>
<h1>Route 136 at Rue Panet</h1>
<p class="meta">MTQ camera 3735 · Montréal · 45.51651, −73.55020</p>
<section class="player" aria-label="Québec 511 camera video">
<video controls autoplay muted playsinline preload="metadata">
<source
src="https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4"
type="video/mp4"
/>
Your browser cannot play this Québec 511 camera feed.
</video>
</section>
<p class="notice">
This page plays the official feed in the browser. God’s Eye shows a
separate paused browser video at the camera’s map position; Québec 511
does not grant pixel access, so it is not a Cesium texture. Location
data: Ministère des Transports et de la Mobilité durable,
<a
href="https://www.donneesquebec.ca/recherche/dataset/camera-de-circulation"
>Caméra de circulation (CC BY 4.0)</a
>.
</p>
<a class="source-link" href="/?welcome=0">
Open God’s Eye CCTV cameras <span aria-hidden="true">↗</span>
</a>
<br />
<a
class="source-link"
href="https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735"
target="_blank"
rel="noopener noreferrer"
>
Open official Québec 511 viewer <span aria-hidden="true">↗</span>
</a>
</main>
</body>
</html>
+1 -1
View File
@@ -44,7 +44,7 @@ CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}"
CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-7516}"
# Capture which provider credentials genuinely came from the parent shell
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
+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. */
+3 -3
View File
@@ -12,7 +12,7 @@ import {
CALGARY_IMAGE_ORIGIN,
CALGARY_MAX_CATALOG_BYTES,
DEFAULT_CALGARY_ROWS_URL,
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
} from '../../server/providers/cctv/constants.js';
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
@@ -384,6 +384,6 @@ test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t)
}
});
test('the default catalog preserves shared, Québec, and World image slots', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
test('the shared catalog slots are not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
});
+65
View File
@@ -6,9 +6,12 @@ import {
} from '../../server/providers/cctv/cap.js';
import {
CCTV_MAX_SOURCES_CEILING,
CCTV_SHARED_SOURCE_SLOTS,
CCTV_SOURCE_CACHE_MS,
DEFAULT_CCTV_MAX_SOURCES,
WORLD_CCTV_SOURCE_COUNT,
} from '../../server/providers/cctv/constants.js';
import { QUEBEC_CAMERA_SOURCE_COUNT } from '../../server/providers/cctv/quebec.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
const pack = (name, count, prefix = name) => ({
@@ -99,6 +102,68 @@ test('duplicate ids resolve last-pack-wins and count against the winning pack',
]);
});
/** Shared packs plus two reserved ones, shaped like the default catalog. */
const reservedFixture = () => [
...Array.from({ length: 20 }, (_, i) => pack(`shared${i}`, 250)),
{ ...pack('quebec', 1120), reservedCount: 1120 },
{ ...pack('world', 2396), reservedCount: 2396 },
];
const keptByName = (packs) =>
Object.fromEntries(packs.map(({ name, kept }) => [name, kept]));
test('the default cap is the shared floor plus the reserved Québec and World packs', () => {
assert.equal(
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS +
QUEBEC_CAMERA_SOURCE_COUNT +
WORLD_CCTV_SOURCE_COUNT,
);
});
test('at the default cap reserved packs are whole and shared packs keep the floor', () => {
const { sources, packs } = allocateSourceCap(
reservedFixture(),
DEFAULT_CCTV_MAX_SOURCES,
);
const kept = keptByName(packs);
assert.equal(sources.length, DEFAULT_CCTV_MAX_SOURCES);
assert.equal(kept.quebec, 1120);
assert.equal(kept.world, 2396);
for (let i = 0; i < 20; i++) assert.equal(kept[`shared${i}`], 200);
assert.match(
sources[0].id,
/^shared0-/,
'the first camera stays in a shared pack',
);
});
test('a cap at the shared floor reserves nothing and thins every pack evenly', () => {
const { packs } = allocateSourceCap(reservedFixture(), 4000);
const kept = keptByName(packs);
// 22 lanes share 4,000 round-robin: nobody is wiped out by the reservation.
for (const count of Object.values(kept))
assert.ok(count >= 181 && count <= 182);
});
test('a small cap (dev-secure 48) still reaches the shared packs first', () => {
const { sources, packs } = allocateSourceCap(reservedFixture(), 48);
const kept = keptByName(packs);
assert.equal(sources.length, 48);
for (let i = 0; i < 20; i++) assert.ok(kept[`shared${i}`] >= 2);
assert.match(sources[0].id, /^shared0-/);
});
test('reservations fill only the room above the floor, round-robin between reserved packs', () => {
const { packs } = allocateSourceCap(reservedFixture(), 4100);
const kept = keptByName(packs);
const shared = Object.entries(kept)
.filter(([name]) => name.startsWith('shared'))
.reduce((sum, [, count]) => sum + count, 0);
// 100 reserved slots split between Québec and World, then 4,000 round-robin.
assert.ok(shared >= 4000 - 2 * 182, `shared packs kept ${shared}`);
assert.equal(shared + kept.quebec + kept.world, 4100);
});
test('resolveCatalogCap bounds the env value and falls back to the default', () => {
assert.equal(resolveCatalogCap(undefined), DEFAULT_CCTV_MAX_SOURCES);
assert.equal(resolveCatalogCap('nonsense'), DEFAULT_CCTV_MAX_SOURCES);
+6 -3
View File
@@ -15,6 +15,7 @@ import {
DEFAULT_FINTRAFFIC_MAX_SOURCES,
DEFAULT_TXDOT_MAX_SOURCES,
DEFAULT_OTTAWA_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
DEFAULT_TALLINN_MAX_SOURCES,
DEFAULT_TARKTEE_MAX_SOURCES,
DEFAULT_NSW_MAX_SOURCES,
@@ -223,7 +224,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r
);
});
test('default caps of the original packs fit inside the default catalog cap', () => {
test('default caps of the original packs fit inside the shared catalog slots', () => {
// Every default pack at its own cap (Warendorf ships 1 curated camera).
const packs =
DEFAULT_AUSTIN_MAX_SOURCES +
@@ -239,9 +240,11 @@ test('default caps of the original packs fit inside the default catalog cap', ()
DEFAULT_NSW_MAX_SOURCES +
DEFAULT_CATALONIA_MAX_SOURCES +
1;
// The reserved Québec and World packs sit above the shared floor, so the
// shared packs must fit the floor itself, not the whole default cap.
assert.ok(
packs <= DEFAULT_CCTV_MAX_SOURCES,
`default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`,
packs <= CCTV_SHARED_SOURCE_SLOTS,
`default packs (${packs}) would be thinned by the shared slots (${CCTV_SHARED_SOURCE_SLOTS})`,
);
assert.ok(
DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING,
+4 -4
View File
@@ -10,7 +10,7 @@ import {
parseHkCameraLocationsXml,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
DEFAULT_HK_CAMERAS_URL,
DEFAULT_HK_MAX_SOURCES,
HK_ANCHORS,
@@ -396,8 +396,8 @@ test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
}
});
test('the default catalog preserves shared, Québec, and World image slots', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
test('the shared catalog slots are not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
});
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
@@ -406,7 +406,7 @@ test('the HK pack default sits above the full TD catalog and anchors the New Ter
'default cap should keep nearly all published TD cameras',
);
assert.ok(
DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_HK_MAX_SOURCES <= CCTV_SHARED_SOURCE_SLOTS,
'a single pack must not outgrow the catalog-wide ceiling alone',
);
const places = HK_ANCHORS.map(
+3 -3
View File
@@ -8,7 +8,7 @@ import {
nycCameraToSource,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
DEFAULT_NYC_MAX_SOURCES,
NYC_CAMERAS_URL,
NYC_GROUND_ELEVATION_M,
@@ -375,8 +375,8 @@ for (const [label, flag] of [
});
}
test('the default catalog preserves shared, Québec, and World image slots', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
test('the shared catalog slots are not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
});
+87 -32
View File
@@ -12,7 +12,13 @@ import {
fallbackHeadingFromId,
pinnedCameraUrl,
} from '../../server/providers/cctv/normalize.js';
import { loadOttawaSourcesFromOpenData } from '../../server/providers/cctv/ottawa.js';
import {
_resetOttawaCertificateWarningForTest,
loadOttawaSourcesFromOpenData,
ottawaAuthorizedFrameUrl,
} from '../../server/providers/cctv/ottawa.js';
const CERT = { OTTAWA_CAMERA_CERTIFICATE: 'CERT123' };
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
@@ -33,35 +39,77 @@ function quiet(t) {
t.mock.method(console, 'warn', () => {});
}
/** One `/map/camera_list` row carrying the fields the loader reads. */
function ottawaRow(id, lat, lon, overrides = {}) {
/** One documented `/map/service/camera` row carrying the fields the loader reads. */
function ottawaRow(number, lat, lon, overrides = {}) {
return {
id,
number: 2000 + id,
description: `Camera ${id}`,
descriptionFr: `Caméra ${id}`,
type: 'City',
id: number + 10000,
camera_number: number,
name: `Camera ${number}`,
name_french: `Caméra ${number}`,
cameraOwner: 'CITY',
latitude: lat,
longitude: lon,
...overrides,
};
}
test('Ottawa loader keeps in-city cameras and builds frame URLs from the id', async (t) => {
/** The feed's envelope around its rows. */
const ottawaPayload = (rows) => Response.json({ cameras: rows });
test('without OTTAWA_CAMERA_CERTIFICATE the Ottawa pack is skipped with no request, logged once', async (t) => {
_resetOttawaCertificateWarningForTest();
const lines = [];
t.mock.method(console, 'warn', (line) => lines.push(String(line)));
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
throw new Error('no request expected');
});
assert.deepEqual(await loadOttawaSourcesFromOpenData({}, {}), []);
assert.deepEqual(
await loadOttawaSourcesFromOpenData({}, { OTTAWA_CAMERA_CERTIFICATE: ' ' }),
[],
);
assert.equal(fetchMock.mock.callCount(), 0);
assert.equal(lines.length, 1);
assert.match(lines[0], /OTTAWA_CAMERA_CERTIFICATE/);
});
test('Ottawa frame URLs are signed only inside the opendata camera path', () => {
assert.equal(
ottawaAuthorizedFrameUrl('https://traffic.ottawa.ca/opendata/camera?c=16', CERT),
'https://traffic.ottawa.ca/opendata/camera?c=16&certificate=CERT123&id=gev',
);
assert.equal(
ottawaAuthorizedFrameUrl('https://traffic.ottawa.ca/opendata/camera?c=16', {}),
'',
'no certificate, no signed URL',
);
for (const url of [
'https://traffic.ottawa.ca/opendata/camera?c=16x',
'https://traffic.ottawa.ca/opendata/other?c=16',
'https://traffic.ottawa.ca/map/camera?id=16',
'https://traffic.ottawa.ca.evil.test/opendata/camera?c=16',
'http://traffic.ottawa.ca/opendata/camera?c=16',
])
assert.equal(ottawaAuthorizedFrameUrl(url, CERT), '', url);
});
test('Ottawa loader keeps in-city cameras and builds keyless frame URLs from the camera number', async (t) => {
quiet(t);
withEnv(t, { CCTV_OTTAWA_MAX_SOURCES: undefined });
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json([
ottawaRow(309, 45.341343, -75.81467, {
description: '(MTO) Hwy 416 NB ramp to Hwy 417 East',
type: 'MTO',
return ottawaPayload([
ottawaRow(2025, 45.341343, -75.81467, {
name: '(MTO) Hwy 416 NB ramp to Hwy 417 East',
cameraOwner: 'MTO',
}),
ottawaRow(1, 45.4215, -75.6972, {
description: 'Bank St at Somerset St',
type: 'City',
name: 'Bank St at Somerset St',
camera_owner: 'CITY',
cameraOwner: undefined,
}),
ottawaRow(1, 45.4216, -75.6973, { name: 'duplicate number' }),
ottawaRow(0, 45.4215, -75.6972),
ottawaRow(-3, 45.4215, -75.6972),
ottawaRow(7, null, -75.7),
@@ -71,18 +119,21 @@ test('Ottawa loader keeps in-city cameras and builds frame URLs from the id', as
]);
});
const cameras = await loadOttawaSourcesFromOpenData();
const cameras = await loadOttawaSourcesFromOpenData({}, CERT);
assert.deepEqual(requested, [OTTAWA_CAMERAS_URL]);
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
'ottawa-1',
'ottawa-309',
'ottawa-2025',
]);
const mto = cameras.find((camera) => camera.id === 'ottawa-309');
const mto = cameras.find((camera) => camera.id === 'ottawa-2025');
const city = cameras.find((camera) => camera.id === 'ottawa-1');
assert.equal(mto.url, 'https://traffic.ottawa.ca/map/camera?id=309');
assert.equal(mto.url, 'https://traffic.ottawa.ca/opendata/camera?c=2025');
assert.doesNotMatch(mto.url, /certificate/, 'the catalog URL carries no key');
assert.equal(mto.snapshotUrl, mto.url);
assert.equal(mto.frameRefreshMs, 60000, 'the API allows one fetch a minute');
assert.equal(city.name, 'Bank St at Somerset St');
assert.equal(mto.name, '(MTO) Hwy 416 NB ramp to Hwy 417 East');
assert.equal(mto.city, 'Ottawa');
assert.equal(mto.provider, 'City of Ottawa');
@@ -91,7 +142,7 @@ test('Ottawa loader keeps in-city cameras and builds frame URLs from the id', as
assert.equal(mto.headingConfidence, 'low');
assert.equal(
mto.headingDeg,
fallbackHeadingFromId('ottawa-309'),
fallbackHeadingFromId('ottawa-2025'),
'the feed publishes no bearing, so the id-hash fallback is deterministic',
);
assert.match(mto.license, /City of Ottawa/);
@@ -115,9 +166,9 @@ test('Ottawa loader keeps the cameras nearest downtown under its cap', async (t)
(lat, i) => ottawaRow(200 + i, lat, -75.7),
),
];
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
t.mock.method(globalThis, 'fetch', async () => ottawaPayload(rows));
const cameras = await loadOttawaSourcesFromOpenData();
const cameras = await loadOttawaSourcesFromOpenData({}, CERT);
assert.equal(cameras.length, 8);
assert.deepEqual(
@@ -134,15 +185,16 @@ test('Ottawa loader fails soft on HTTP errors, unexpected payloads and network e
for (respond of [
async () => new Response('unavailable', { status: 503 }),
async () => Response.json({ cameras: [] }),
async () => Response.json([ottawaRow(1, 45.4215, -75.6972)]),
async () => {
throw new TypeError('fetch failed');
},
]) {
assert.deepEqual(await loadOttawaSourcesFromOpenData(), []);
assert.deepEqual(await loadOttawaSourcesFromOpenData({}, CERT), []);
}
});
test('CCTV catalog merges Ottawa cameras and CCTV_OTTAWA_ENABLED=0 skips the request', async (t) => {
test('CCTV catalog merges Ottawa cameras with a certificate and CCTV_OTTAWA_ENABLED=0 skips the request', async (t) => {
quiet(t);
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-ottawa-'));
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
@@ -154,13 +206,16 @@ test('CCTV catalog merges Ottawa cameras and CCTV_OTTAWA_ENABLED=0 skips the req
CCTV_MAX_SOURCES: undefined,
CCTV_OTTAWA_ENABLED: undefined,
CCTV_OTTAWA_MAX_SOURCES: undefined,
CCTV_QUEBEC_ENABLED: '0',
CCTV_WORLD_CAMERAS_ENABLED: '0',
OTTAWA_CAMERA_CERTIFICATE: 'CERT123',
});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
// Every other live pack is down; only Ottawa answers.
return String(url) === OTTAWA_CAMERAS_URL
? Response.json([ottawaRow(1, 45.4215, -75.6972)])
? ottawaPayload([ottawaRow(1, 45.4215, -75.6972)])
: new Response('unavailable', { status: 503 });
});
@@ -169,7 +224,7 @@ test('CCTV catalog merges Ottawa cameras and CCTV_OTTAWA_ENABLED=0 skips the req
sources.map((source) => source.id),
['ottawa-1'],
);
assert.equal(sources[0].url, 'https://traffic.ottawa.ca/map/camera?id=1');
assert.equal(sources[0].url, 'https://traffic.ottawa.ca/opendata/camera?c=1');
process.env.CCTV_OTTAWA_ENABLED = '0';
requested.length = 0;
@@ -185,16 +240,16 @@ test('CCTV catalog merges Ottawa cameras and CCTV_OTTAWA_ENABLED=0 skips the req
test('Ottawa frames stay inside the city image directory', () => {
assert.ok(
pinnedCameraUrl(
'https://traffic.ottawa.ca/map/camera?id=309',
'https://traffic.ottawa.ca/opendata/camera?c=309',
OTTAWA_IMAGE_ORIGIN,
),
);
for (const url of [
'https://traffic.ottawa.ca.evil.test/map/camera?id=309',
'https://traffic.ottawa.ca/other/camera?id=309',
'https://user:[email protected]/map/camera?id=309',
'https://traffic.ottawa.ca:8443/map/camera?id=309',
'http://traffic.ottawa.ca/map/camera?id=309',
'https://traffic.ottawa.ca.evil.test/opendata/camera?c=309',
'https://traffic.ottawa.ca/map/camera?id=309',
'https://user:[email protected]/opendata/camera?c=309',
'https://traffic.ottawa.ca:8443/opendata/camera?c=309',
'http://traffic.ottawa.ca/opendata/camera?c=309',
])
assert.equal(pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN), null, url);
});
+76 -1
View File
@@ -365,6 +365,7 @@ test('CCTV upstream frame fetch extracts only the first JPEG from MJPEG', async
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
maxBytes: 1024,
fetchImpl: async () =>
multipartResponse(
@@ -397,6 +398,7 @@ test('CCTV upstream frame fetch rejects malformed and non-JPEG MJPEG parts', asy
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
fetchImpl: async () =>
multipartResponse(type, chunks, { onCancel: () => { cancelled = true; } }),
},
@@ -418,6 +420,7 @@ test('CCTV upstream frame fetch rejects truncated or oversized MJPEG parts', asy
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
maxBytes,
fetchImpl: async () =>
multipartResponse(
@@ -456,6 +459,7 @@ test('CCTV upstream frame fetch does not cross into a later part when the first
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
maxBytes: 1024,
fetchImpl: async () =>
multipartResponse(
@@ -468,4 +472,75 @@ test('CCTV upstream frame fetch does not cross into a later part when the first
assert.equal(result, null);
assert.equal(cancelled, true);
});
});
test('CCTV upstream frame fetch ignores MJPEG unless the source is registered as MJPEG', async () => {
const boundary = 'DIGIEVER';
const jpeg = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
fetchImpl: async () =>
multipartResponse(
'multipart/x-mixed-replace; boundary=' + boundary,
[jpegPart(boundary, jpeg)],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result, null);
assert.equal(cancelled, true);
});
test('CCTV upstream frame fetch accepts a CRLF preamble and a boundary that already starts with --', async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
for (const [type, wire] of [
[
'multipart/x-mixed-replace; boundary=DIGIEVER',
Buffer.concat([Buffer.from('\r\n'), jpegPart('DIGIEVER', jpeg)]),
],
[
'multipart/x-mixed-replace; boundary=--myboundary',
Buffer.concat([
Buffer.from(
'--myboundary\r\nContent-Type: image/jpeg\r\nContent-Length: ' +
jpeg.length +
'\r\n\r\n',
),
jpeg,
Buffer.from('\r\n--myboundary\r\n'),
]),
],
]) {
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
fetchImpl: async () => multipartResponse(type, [wire]),
},
);
assert.deepEqual(result?.body, jpeg, type);
}
});
test('CCTV MJPEG part without Content-Length ends at the next delimiter, not at an inner JPEG end marker', async () => {
const boundary = 'DIGIEVER';
// An EXIF thumbnail's own FF D9 sits inside the image.
const jpeg = Buffer.from([0xff, 0xd8, 0x01, 0xff, 0xd9, 0x02, 0x03, 0xff, 0xd9]);
const wire = Buffer.concat([
Buffer.from('--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n'),
jpeg,
Buffer.from('\r\n--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n'),
]);
// Delivered one byte at a time, as a trickling upstream would.
const chunks = [...wire].map((byte) => Buffer.from([byte]));
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
fetchImpl: async () =>
multipartResponse('multipart/x-mixed-replace; boundary=' + boundary, chunks),
},
);
assert.deepEqual(result?.body, jpeg);
});
+3 -3
View File
@@ -10,7 +10,7 @@ import {
TORONTO_RESCU_LIST_URL,
TORONTO_RESCU_IMAGE_ORIGIN,
TORONTO_MAX_CATALOG_BYTES,
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
} from '../../server/providers/cctv/constants.js';
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
@@ -418,6 +418,6 @@ test('CCTV_TORONTO_ENABLED=0 keeps the lane from being loaded at all', async (t)
}
});
test('the default catalog preserves shared, Québec, and World image slots', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
test('the shared catalog slots are not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
});
+306
View File
@@ -0,0 +1,306 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
DEFAULT_WORLD_CCTV_SOURCE_FILE,
WORLD_CCTV_SOURCE_COUNT,
} from '../../server/providers/cctv/constants.js';
import {
isMlitRiverAdjustmentImage,
loadWorldCameraSources,
} from '../../server/providers/cctv/worldCameras.js';
import {
loadQuebecSources,
QUEBEC_CAMERA_SOURCE_COUNT,
quebec511VideoUrl,
quebecBrowserVideoEnabled,
} from '../../server/providers/cctv/quebec.js';
import { cctvProxy } from '../../server/providers/cctv.js';
const REPO_ROOT = path.resolve(import.meta.dirname, '../..');
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0xff, 0xd9]);
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** A temporary source root holding the given World and Québec catalogs. */
function sourceRoot(t, { world = [], quebec = null } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-world-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const worldFile = path.join(root, DEFAULT_WORLD_CCTV_SOURCE_FILE);
fs.mkdirSync(path.dirname(worldFile), { recursive: true });
fs.writeFileSync(worldFile, JSON.stringify(world));
if (quebec) {
fs.mkdirSync(path.join(root, 'public'), { recursive: true });
fs.writeFileSync(
path.join(root, 'public/quebec-cameras.json'),
JSON.stringify({ cameras: quebec }),
);
}
return root;
}
const icelandRow = (overrides = {}) => ({
id: 'world-is-7075',
name: 'Almannaskarð - norður',
provider: 'Vegagerðin road cameras',
lat: 64.291877,
lon: -15.028035,
sourceKind: 'vegagerdin-road',
url: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg',
snapshotUrl:
'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg',
...overrides,
});
test('the shipped World catalog loads every curated row', (t) => {
quiet(t);
const sources = loadWorldCameraSources({ sourceRoot: REPO_ROOT });
assert.equal(sources.length, WORLD_CCTV_SOURCE_COUNT);
const byKind = Object.groupBy(sources, (source) => source.sourceKind);
assert.equal(byKind['vegagerdin-road'].length, 166);
assert.equal(byKind['tripcheck-odot'].length, 1176);
assert.equal(byKind['mlit-river'].length, 1054);
for (const source of byKind['mlit-river'])
assert.equal(source.frameRefreshMs, 10 * 60 * 1000);
});
test('the World loader accepts a pinned row and rejects anything outside its policy', (t) => {
quiet(t);
const rejected = [
['unknown kind', { sourceKind: 'nzta-journeys' }],
['wrong id prefix', { id: 'world-or-7075' }],
['outside bounds', { lat: 40, lon: -15 }],
['other host', { url: 'https://evil.test/vgdata/vefmyndavelar/a.jpg' }],
['other directory', { url: 'https://www.vegagerdin.is/other/a.jpg' }],
['query string', { url: icelandRow().url + '?x=1' }],
['not a jpg', { url: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a.png' }],
['plain http', { url: icelandRow().url.replace('https:', 'http:') }],
[
'url and snapshot differ',
{ snapshotUrl: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/b.jpg' },
],
];
const root = sourceRoot(t, {
world: [
icelandRow(),
icelandRow(), // duplicate id
...rejected.map(([, overrides], i) =>
icelandRow({ id: `world-is-${9000 + i}`, ...overrides }),
),
],
});
const sources = loadWorldCameraSources({ sourceRoot: root });
assert.deepEqual(
sources.map((source) => source.id),
['world-is-7075'],
);
assert.equal(sources[0].frameRefreshMs, 5 * 60 * 1000);
assert.equal(sources[0].feedType, 'image');
});
test('the World loader fails soft on a missing or malformed catalog', (t) => {
quiet(t);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-world-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
assert.deepEqual(loadWorldCameraSources({ sourceRoot: root }), []);
const file = path.join(root, DEFAULT_WORLD_CCTV_SOURCE_FILE);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, '{not json');
assert.deepEqual(loadWorldCameraSources({ sourceRoot: root }), []);
});
test('only the exact MLIT adjustment image is recognised', () => {
assert.equal(isMlitRiverAdjustmentImage({ ok: true, body: JPEG }), false);
assert.equal(isMlitRiverAdjustmentImage(null), false);
assert.equal(isMlitRiverAdjustmentImage({ ok: false, body: JPEG }), false);
});
test('the shipped Québec catalog loads every location', (t) => {
quiet(t);
const sources = loadQuebecSources({ sourceRoot: REPO_ROOT });
assert.equal(sources.length, QUEBEC_CAMERA_SOURCE_COUNT);
for (const source of sources) {
assert.equal(source.url, '', 'the server never fetches Québec media');
assert.equal(source.snapshotUrl, '');
}
});
test('the Québec loader maps ids by kind, keeps https viewer links, and splits the MTQ credit', (t) => {
quiet(t);
const at = { lat: 45.51651, lon: -73.5502 };
const root = sourceRoot(t, {
quebec: [
{ id: '3735', kind: 'traffic', name: 'Route 136', viewerUrl: 'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735', ...at },
{ id: 'mtl-465', kind: 'montreal', name: 'Montréal 465', viewerUrl: 'http://ville.montreal.qc.ca/x', ...at },
{ id: 'web-1', kind: 'webcam', name: 'Tremblant', operator: 'Station Mont-Tremblant', viewerUrl: 'https://user:[email protected]/', ...at },
{ id: 'abc', kind: 'traffic', ...at }, // non-numeric traffic id
{ id: '3735', kind: 'traffic', ...at }, // duplicate
{ id: '9', kind: 'traffic', lat: 40, lon: -73.5 }, // outside Québec
],
});
const sources = loadQuebecSources({ sourceRoot: root });
const byId = new Map(sources.map((source) => [source.id, source]));
assert.deepEqual([...byId.keys()], ['qc-511-3735', 'qc-mtl-465', 'qc-web-1']);
const mtq = byId.get('qc-511-3735');
assert.equal(mtq.sourceKind, 'quebec-511-browser');
assert.match(mtq.credit, /Locations: MTQ · CC BY 4\.0/);
assert.match(mtq.credit, /Video: Québec 511/);
assert.doesNotMatch(mtq.license, /^Données Québec \/ MTQ, CC BY 4\.0$/);
assert.equal(byId.get('qc-mtl-465').sourceUrl, '', 'http viewer links are dropped');
assert.equal(byId.get('qc-web-1').sourceUrl, '', 'credentialed links are dropped');
assert.equal(
quebec511VideoUrl('qc-511-3735'),
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4',
);
assert.equal(quebec511VideoUrl('qc-mtl-465'), '');
});
test('Québec 511 browser video is opt-in', () => {
assert.equal(quebecBrowserVideoEnabled({}), false);
assert.equal(quebecBrowserVideoEnabled({ CCTV_QUEBEC_VIDEO_ENABLED: '0' }), false);
assert.equal(quebecBrowserVideoEnabled({ CCTV_QUEBEC_VIDEO_ENABLED: ' 1 ' }), true);
});
/** Mount the real proxy with a fixed fetch, and drive requests through it. */
function mount(t, root, respond) {
withEnv(t, {
CCTV_SOURCES_FILE: 'absent-source-file.json',
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: '0',
CCTV_MAX_SOURCES: undefined,
// A configured key makes any Street View fallback observable.
GOOGLE_MAPS_SERVER_API_KEY: 'test-google-key',
});
const requests = [];
t.mock.method(globalThis, 'fetch', async (url, init = {}) => {
requests.push(String(url));
return respond(String(url), init) || new Response('down', { status: 503 });
});
let handler;
cctvProxy({ sourceRoot: root }).configureServer({
middlewares: {
use(_route, fn) {
handler = fn;
},
},
});
const call = async (url) => {
const res = {
writeHead(status, headers) {
Object.assign(this, { status, headers });
},
end(body) {
this.body = body;
},
on() {},
once() {},
};
await handler({ url, method: 'GET', headers: {}, on() {} }, res);
return res;
};
return { call, requests };
}
test('/sources lists Québec browser media only when the operator opts in', async (t) => {
quiet(t);
const root = sourceRoot(t, {
quebec: [{ id: '3735', kind: 'traffic', name: 'Route 136', lat: 45.5, lon: -73.5 }],
});
withEnv(t, { CCTV_QUEBEC_VIDEO_ENABLED: undefined });
const app = mount(t, root, () => null);
const off = JSON.parse((await app.call('/sources')).body).sources;
assert.equal(off.find((s) => s.id === 'qc-511-3735').browserMediaUrl, '');
process.env.CCTV_QUEBEC_VIDEO_ENABLED = '1';
const on = JSON.parse((await app.call('/sources')).body).sources;
assert.match(on.find((s) => s.id === 'qc-511-3735').browserMediaUrl, /camera\.ashx\?id=3735&format=mp4$/);
});
test('a World frame is fetched once per refresh floor and the cooldown never spends Street View', async (t) => {
quiet(t);
const root = sourceRoot(t, { world: [icelandRow()] });
let answer = () => new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } });
const app = mount(t, root, (url) =>
url === icelandRow().url ? answer() : null,
);
const frame = '/frame/world-is-7075';
const upstreamHits = () =>
app.requests.filter((url) => url === icelandRow().url).length;
const first = await app.call(frame);
assert.equal(first.headers['X-CCTV-Source'], 'upstream-image');
const second = await app.call(frame);
assert.equal(second.headers['X-CCTV-Source'], 'upstream-image');
assert.equal(upstreamHits(), 1, 'the second view reuses the cached frame');
// A camera whose first fetch fails is inside its failure backoff: it gets
// the placeholder, not Google Street View, and no second upstream request.
const failRoot = sourceRoot(t, { world: [icelandRow()] });
answer = () => new Response('down', { status: 503 });
const failing = mount(t, failRoot, (url) =>
url === icelandRow().url ? answer() : null,
);
const failed = await failing.call(frame);
assert.notEqual(failed.headers['X-CCTV-Source'], 'upstream-image');
const retry = await failing.call(frame);
assert.equal(retry.headers['X-CCTV-Source'], 'synthetic');
assert.equal(
failing.requests.filter((url) => url === icelandRow().url).length,
1,
'the backoff holds the provider cadence',
);
assert.equal(
failing.requests.filter((url) => url.includes('maps.googleapis.com/maps/api/streetview')).length,
1,
'only the first miss, outside any cooldown, may try Street View',
);
});
test('Ottawa frames are signed with the certificate at fetch time only', async (t) => {
quiet(t);
const root = sourceRoot(t);
withEnv(t, { OTTAWA_CAMERA_CERTIFICATE: 'CERT123' });
const app = mount(t, root, (url) => {
if (url === 'https://traffic.ottawa.ca/map/service/camera')
return Response.json({
cameras: [
{ camera_number: 16, name: 'Bank & Hunt Club', latitude: 45.35, longitude: -75.65, cameraOwner: 'CITY' },
],
});
if (url.startsWith('https://traffic.ottawa.ca/opendata/camera'))
return new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } });
return null;
});
const sources = JSON.parse((await app.call('/sources')).body).sources;
const ottawa = sources.find((source) => source.id === 'ottawa-16');
assert.ok(ottawa);
assert.doesNotMatch(JSON.stringify(sources), /CERT123/, 'no catalog field carries the certificate');
const frame = await app.call('/frame/ottawa-16');
assert.equal(frame.headers['X-CCTV-Source'], 'upstream-image');
await app.call('/frame/ottawa-16');
const hits = app.requests.filter((url) => url.includes('/opendata/camera'));
assert.deepEqual(hits, [
'https://traffic.ottawa.ca/opendata/camera?c=16&certificate=CERT123&id=gev',
], 'one signed request per camera per minute');
});
+2 -3
View File
@@ -1,6 +1,4 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "إغلاق معاينة الكاميرا",
"Refresh map still": "تحديث صورة الخريطة الثابتة",
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
@@ -3228,5 +3226,6 @@
"Audio": "الصوت",
"UTILITIES": "الأدوات",
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة"
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
"Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك."
}
+2 -3
View File
@@ -1,6 +1,4 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "Kameravorschau schließen",
"Refresh map still": "Kartenstandbild aktualisieren",
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.",
@@ -2948,5 +2946,6 @@
"Audio": "Audio",
"UTILITIES": "WERKZEUGE",
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien"
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien",
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht."
}
+2 -3
View File
@@ -1,6 +1,4 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "Cerrar vista previa de la cámara",
"Refresh map still": "Actualizar imagen fija del mapa",
"The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.",
@@ -3018,5 +3016,6 @@
"Right panels": "Paneles derechos",
"UTILITIES": "UTILIDADES",
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos"
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos",
"Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP."
}
+2 -3
View File
@@ -1,6 +1,4 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "Fermer l’aperçu de la caméra",
"Refresh map still": "Actualiser l’image fixe sur la carte",
"The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
@@ -3018,5 +3016,6 @@
"Audio": "Audio",
"UTILITIES": "OUTILS",
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers"
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP."
}
+2 -3
View File
@@ -1,6 +1,4 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "Chiudi anteprima della telecamera",
"Refresh map still": "Aggiorna immagine fissa sulla mappa",
"The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.",
@@ -3018,5 +3016,6 @@
"Audio": "Audio",
"UTILITIES": "STRUMENTI",
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file"
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file",
"Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP."
}
+2 -3
View File
@@ -1,6 +1,4 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "カメラのプレビューを閉じる",
"Refresh map still": "地図上の静止画を更新",
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
@@ -2878,5 +2876,6 @@
"Audio": "オーディオ",
"UTILITIES": "ユーティリティ",
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル"
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。"
}
+2 -3
View File
@@ -1,6 +1,4 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "Fechar prévia da câmera",
"Refresh map still": "Atualizar imagem fixa no mapa",
"The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.",
@@ -3018,5 +3016,6 @@
"Audio": "Áudio",
"UTILITIES": "UTILITÁRIOS",
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos"
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos",
"Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP."
}
+2 -3
View File
@@ -1,6 +1,4 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "Закрыть просмотр камеры",
"Refresh map still": "Обновить кадр на карте",
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
@@ -3088,5 +3086,6 @@
"Right panels": "Правые панели",
"UTILITIES": "ИНСТРУМЕНТЫ",
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы"
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
"Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес."
}
+2 -3
View File
@@ -1,6 +1,4 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "Закрити перегляд камери",
"Refresh map still": "Оновити кадр на карті",
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.",
@@ -3088,5 +3086,6 @@
"Audio": "Аудіо",
"UTILITIES": "ІНСТРУМЕНТИ",
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли"
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
"Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу."
}
+2 -3
View File
@@ -1,6 +1,4 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "关闭摄像头预览",
"Refresh map still": "刷新地图静止画面",
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
@@ -2878,5 +2876,6 @@
"Right panels": "右侧面板",
"UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件"
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件",
"Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。"
}
+2 -3
View File
@@ -1,6 +1,4 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "關閉攝影機預覽",
"Refresh map still": "重新整理地圖靜態畫面",
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
@@ -2878,5 +2876,6 @@
"Right panels": "右側面板",
"UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件"
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。"
}
+11 -33
View File
@@ -31,9 +31,7 @@
"dB",
"hPa",
"{source} HTTP {status}",
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
"▲ {frp} MW"
],
"de": [
"1090 MHz",
@@ -201,9 +199,7 @@
"{value} KM/S",
"ΔN",
"Ⅱ PAUSE",
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
"▲ {frp} MW"
],
"es": [
"1090 MHz",
@@ -331,9 +327,7 @@
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
"▲ {frp} MW"
],
"fr": [
"1090 MHz",
@@ -514,9 +508,7 @@
"ΔE",
"ΔN",
"Ⅱ PAUSE",
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
"▲ {frp} MW"
],
"it": [
"1090 MHz",
@@ -687,9 +679,7 @@
"{value} conf",
"ΔE",
"ΔN",
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
"▲ {frp} MW"
],
"ja": [
"1090 MHz",
@@ -738,9 +728,7 @@
"{type} SPD: {speed} HDG: {heading}",
"ΔE",
"ΔN",
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
"▲ {frp} MW"
],
"pt": [
"1090 MHz",
@@ -869,9 +857,7 @@
"{value} KM/H",
"{value} KM/S",
"ΔN",
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
"▲ {frp} MW"
],
"ru": [
"AIS: --",
@@ -889,9 +875,7 @@
"S2 GhostMaps",
"S2 · {name}",
"VS",
"WHOT/BHOT",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
"WHOT/BHOT"
],
"uk": [
"AIS: --",
@@ -904,9 +888,7 @@
"NVG",
"REC",
"S2 GhostMaps",
"WHOT/BHOT",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
"WHOT/BHOT"
],
"zh-CN": [
"1090 MHz",
@@ -963,9 +945,7 @@
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
"▲ {frp} MW"
],
"zh-Hant": [
"1090 MHz",
@@ -1022,8 +1002,6 @@
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
"▲ {frp} MW"
]
}
-114
View File
@@ -1,114 +0,0 @@
.qc-preview-panel {
position: fixed;
z-index: 2000;
top: 80px;
right: 24px;
width: min(360px, calc(100vw - 32px));
overflow: hidden;
border: 1px solid #3eabbc;
border-radius: 8px;
background: #071117;
color: #e6f8fb;
box-shadow: 0 12px 36px #000a;
font: 12px/1.4 sans-serif;
}
.qc-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
cursor: move;
touch-action: none;
}
.qc-preview-panel button {
border: 1px solid #43818d;
border-radius: 4px;
padding: 4px 8px;
background: #102830;
color: inherit;
cursor: pointer;
}
.qc-preview-panel button:focus-visible {
outline: 2px solid #82e9f7;
outline-offset: 2px;
}
.qc-preview-live {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
}
.qc-preview-panel .qc-preview-live[hidden],
.qc-preview-marker video[hidden] {
display: none;
}
.qc-preview-panel select {
display: block;
width: calc(100% - 20px);
margin: 0 10px 8px;
border: 1px solid #43818d;
border-radius: 4px;
padding: 6px 8px;
background: #102830;
color: inherit;
}
.qc-preview-coordinates {
margin: 0 10px 8px;
color: #a9c5ca;
font:
10px/1.4 'Cascadia Code',
Consolas,
monospace;
}
.qc-preview-panel .qc-preview-live[hidden],
.qc-preview-marker video[hidden] {
display: none;
}
.qc-preview-actions {
display: flex;
gap: 8px;
padding: 8px 10px 0;
}
.qc-preview-panel p {
margin: 8px 10px 10px;
color: #a9c5ca;
}
.qc-preview-panel a {
display: inline-block;
margin: 0 10px 10px;
color: #82e9f7;
}
.qc-preview-marker {
position: fixed;
z-index: 500;
width: 160px;
transform: translate(-50%, -100%) scale(var(--qc-preview-scale, 1));
transform-origin: 50% 100%;
pointer-events: none;
padding: 3px;
border: 1px solid #65d7e7;
background: #071117;
color: #d8f9ff;
font: 10px/1.3 sans-serif;
}
.qc-preview-marker[hidden] {
display: none;
}
.qc-preview-marker video {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
}
.qc-preview-marker span {
display: block;
padding: 3px;
}
.qc-preview-marker-link-only {
width: max-content;
max-width: 220px;
}
.qc-preview-marker-link-only {
width: max-content;
max-width: 220px;
}
-319
View File
@@ -1,319 +0,0 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../data/iconOrientation.js';
import { msg, t } from '../i18n/index.js';
const CAMERA_ORIGIN = 'https://www.quebec511.info/Carte/Fenetres/camera.ashx';
// i18n-ignore: provider and licence labels are required source attribution.
const MTQ_ATTRIBUTION = msg('MTQ · CC BY 4.0');
// i18n-ignore: OpenStreetMap credit is shown in its standard form.
const OSM_ATTRIBUTION = msg('OpenStreetMap contributors · ODbL');
function directVideoUrl(camera) {
return camera.kind === 'traffic' && /^\d+$/.test(camera.id)
? `${CAMERA_ORIGIN}?id=${camera.id}&format=mp4`
: '';
}
function safeViewerUrl(value) {
try {
const url = new URL(String(value || ''));
return url.protocol === 'https:' && !url.username && !url.password
? url.href
: '';
} catch {
return '';
}
}
/** Install the opt-in Québec camera browser preview and return its cleanup. */
export function installQuebecCameraPreview(
viewer,
{ initialId = '3735', fetchImpl = globalThis.fetch } = {},
) {
const panel = document.createElement('section');
panel.className = 'qc-preview-panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', t('CCTV camera'));
const header = document.createElement('header');
header.className = 'qc-preview-header';
const title = document.createElement('strong');
const close = document.createElement('button');
close.type = 'button';
close.textContent = '×';
close.setAttribute('aria-label', t('Close camera preview'));
header.append(title, close);
const select = document.createElement('select');
select.setAttribute('aria-label', t('CCTV camera'));
const coordinates = document.createElement('p');
coordinates.className = 'qc-preview-coordinates';
const live = document.createElement('video');
live.className = 'qc-preview-live';
live.controls = true;
live.setAttribute('aria-label', t('CCTV camera'));
live.autoplay = true;
live.muted = true;
live.loop = true;
live.playsInline = true;
const actions = document.createElement('div');
actions.className = 'qc-preview-actions';
const fly = document.createElement('button');
fly.type = 'button';
fly.textContent = t('FOCUS');
const refresh = document.createElement('button');
refresh.type = 'button';
refresh.textContent = t('Refresh map still');
actions.append(fly, refresh);
const note = document.createElement('p');
const source = document.createElement('a');
source.target = '_blank';
source.rel = 'noopener noreferrer';
const viewerLink = document.createElement('a');
viewerLink.target = '_blank';
viewerLink.rel = 'noopener noreferrer';
viewerLink.textContent = t("Open this image on the provider's site");
panel.append(
header,
select,
coordinates,
live,
actions,
note,
source,
viewerLink,
);
const marker = document.createElement('div');
marker.className = 'qc-preview-marker';
marker.hidden = true;
const still = document.createElement('video');
still.setAttribute('aria-hidden', 'true');
still.muted = true;
still.playsInline = true;
still.preload = 'auto';
const label = document.createElement('span');
marker.append(still, label);
document.body.append(panel, marker);
const controller = new AbortController();
let disposed = false;
let selected = null;
let cameraPosition = null;
let mapStillUrl = '';
const cameraData = [];
function setMedia(video, url) {
if (video.src === url) return;
video.pause();
video.removeAttribute('src');
if (!url) {
video.load();
return;
}
video.src = url;
video.load();
}
function refreshStill() {
if (!mapStillUrl) return;
still.load();
void still.play().catch(() => {});
}
function useCamera(camera) {
selected = camera;
cameraPosition = Cesium.Cartesian3.fromDegrees(camera.lon, camera.lat, 10);
const mediaUrl = directVideoUrl(camera);
const viewerUrl = safeViewerUrl(camera.viewerUrl);
title.textContent = camera.nameEn || camera.name;
select.value = camera.id;
// i18n-ignore: coordinates, accuracy and provenance are camera data.
coordinates.textContent = `${camera.lat.toFixed(camera.kind === 'traffic' ? 5 : 6)}, ${camera.lon.toFixed(camera.kind === 'traffic' ? 5 : 6)} · ${camera.accuracyM ? `±${camera.accuracyM} m` : camera.positionSource}`;
live.hidden = !mediaUrl;
refresh.hidden = !mediaUrl;
still.hidden = !mediaUrl;
if (mediaUrl) {
note.textContent = t(
'The map still uses a separate request and may show a different moment.',
);
setMedia(live, mediaUrl);
live.play().catch(() => {});
if (mapStillUrl !== mediaUrl) {
mapStillUrl = mediaUrl;
setMedia(still, mediaUrl);
refreshStill();
}
// i18n-ignore: this suffix is provider-provided location provenance.
label.textContent = `${t('PREVIEW')} · ${camera.positionSource}`;
marker.classList.remove('qc-preview-marker-link-only');
} else {
note.textContent = viewerUrl
? t("Open this image on the provider's site")
: t('Source unavailable');
setMedia(live, '');
setMedia(still, '');
mapStillUrl = '';
// i18n-ignore: camera names and accuracy are supplied as catalog data.
label.textContent = camera.accuracyM
? `${camera.name} · ±${camera.accuracyM} m`
: camera.name;
marker.classList.add('qc-preview-marker-link-only');
}
const osmPosition =
camera.kind === 'montreal' ||
/openstreetmap|\bosm\b/i.test(camera.positionSource);
const attribution =
camera.kind === 'traffic'
? t('Source: {label}', { label: MTQ_ATTRIBUTION })
: osmPosition
? t('Source: {label}', { label: OSM_ATTRIBUTION })
: t('Source: {label}', {
label: camera.operator || camera.positionSource || t('Source'),
});
source.textContent = attribution;
const attributionUrl =
camera.kind === 'traffic'
? 'https://www.donneesquebec.ca/recherche/dataset/camera-de-circulation'
: osmPosition
? 'https://www.openstreetmap.org/copyright'
: viewerUrl;
source.href = attributionUrl || '#';
source.hidden = !attributionUrl;
viewerLink.href = viewerUrl || '#';
viewerLink.hidden = !viewerUrl;
updateMarker();
}
function updateMarker() {
if (!selected || !cameraPosition) {
marker.hidden = true;
return;
}
const canvas = viewer.scene.canvas;
const point = viewer.scene.cartesianToCanvasCoordinates(cameraPosition);
const visible =
point &&
horizonOccluder(viewer.camera).isPointVisible(cameraPosition) &&
point.x >= 0 &&
point.y >= 0 &&
point.x <= canvas.clientWidth &&
point.y <= canvas.clientHeight;
marker.hidden = !visible;
if (!visible) return;
const rect = canvas.getBoundingClientRect();
marker.style.left = `${rect.left + point.x}px`;
marker.style.top = `${rect.top + point.y}px`;
const distance = Cesium.Cartesian3.distance(
viewer.camera.positionWC,
cameraPosition,
);
marker.style.setProperty(
'--qc-preview-scale',
String(Math.min(1, 600 / Math.max(1, distance))),
);
}
select.addEventListener('change', () => {
const camera = cameraData.find((item) => item.id === select.value);
if (camera) useCamera(camera);
});
still.addEventListener('loadeddata', () => still.pause());
still.addEventListener('error', () => {
label.textContent = t('Source unavailable');
});
live.addEventListener('error', () => {
note.textContent = t('Source unavailable');
});
refresh.addEventListener('click', refreshStill);
fly.addEventListener('click', () => {
if (!selected) return;
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
selected.lon,
selected.lat,
600,
),
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-90), roll: 0 },
duration: 1.5,
});
});
header.addEventListener('pointerdown', (event) => {
if (event.target.closest('button')) return;
const rect = panel.getBoundingClientRect();
const offsetX = event.clientX - rect.left;
const offsetY = event.clientY - rect.top;
header.setPointerCapture(event.pointerId);
const move = (moveEvent) => {
panel.style.left = `${Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, moveEvent.clientX - offsetX))}px`;
panel.style.top = `${Math.max(0, Math.min(window.innerHeight - panel.offsetHeight, moveEvent.clientY - offsetY))}px`;
panel.style.right = 'auto';
};
const stop = () => {
header.removeEventListener('pointermove', move);
header.removeEventListener('pointerup', stop);
header.removeEventListener('pointercancel', stop);
};
header.addEventListener('pointermove', move);
header.addEventListener('pointerup', stop);
header.addEventListener('pointercancel', stop);
});
const removeRender = viewer.scene.postRender.addEventListener(updateMarker);
const ready = (async () => {
try {
const response = await fetchImpl('/quebec-cameras.json', {
signal: controller.signal,
});
if (!response.ok) throw new Error(`Camera list HTTP ${response.status}`);
const payload = await response.json();
const cameras = Array.isArray(payload?.cameras) ? payload.cameras : [];
if (!cameras.length) throw new Error('Empty Québec camera list');
cameraData.push(...cameras);
// i18n-ignore: these group names are provider/source categories.
for (const kind of ['traffic', 'montreal', 'webcam']) {
const group = document.createElement('optgroup');
group.label =
kind === 'traffic'
? 'MTQ / Québec 511'
: kind === 'montreal'
? 'Montréal / OpenStreetMap'
: 'Scenic webcams';
for (const camera of cameras.filter((item) => item.kind === kind)) {
const option = document.createElement('option');
option.value = camera.id;
// i18n-ignore: camera names and regions are provider catalog data.
option.textContent = `${camera.nameEn || camera.name} · ${camera.region || camera.category}`;
group.append(option);
}
if (group.children.length) select.append(group);
}
const chosen =
cameras.find((camera) => camera.id === initialId) ||
cameras.find((camera) => camera.id === '3735') ||
cameras[0];
useCamera(chosen);
} catch {
if (!disposed) note.textContent = t('Source unavailable');
}
})();
const dispose = () => {
if (disposed) return;
disposed = true;
controller.abort();
removeRender();
for (const video of [live, still]) {
video.pause();
video.removeAttribute('src');
video.load();
}
panel.remove();
marker.remove();
};
close.addEventListener('click', dispose);
return { dispose, ready };
}
-136
View File
@@ -1,136 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Window } from 'happy-dom';
import * as Cesium from 'cesium';
import { installQuebecCameraPreview } from './quebecCameraPreview.js';
test('Québec preview selects direct-feed and viewer-only cameras and scales the map card', async () => {
const window = new Window({ url: 'http://localhost:4173/?qc-preview=all' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
globalThis.window = window;
globalThis.document = window.document;
window.HTMLMediaElement.prototype.load = function () {};
window.HTMLMediaElement.prototype.play = function () {
return Promise.resolve();
};
window.HTMLMediaElement.prototype.pause = function () {
this.dataset.paused = 'yes';
};
let removed = 0;
let destination;
let render;
let point = { x: 200, y: 150 };
const viewer = {
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-73.5502, 45.51651, 1000),
flyTo: (options) => {
destination = options.destination;
},
},
scene: {
canvas: {
clientWidth: 800,
clientHeight: 600,
getBoundingClientRect: () => ({ left: 30, top: 40 }),
},
cartesianToCanvasCoordinates: () => point,
postRender: {
addEventListener: (callback) => {
render = callback;
return () => {
removed++;
};
},
},
},
};
const cameras = [
{
id: '3735',
kind: 'traffic',
name: 'Route 136 at rue Panet',
nameEn: 'Route 136 at rue Panet',
lat: 45.51651,
lon: -73.5502,
viewerUrl:
'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735',
positionSource: 'MTQ WFS',
accuracyM: null,
},
{
id: 'mtl-465',
kind: 'montreal',
name: '1re Avenue / Sortie A-20',
lat: 45.44462,
lon: -73.663552,
viewerUrl: 'https://ville.montreal.qc.ca/circulation/node/465',
positionSource: 'OSM node',
accuracyM: 15,
},
];
try {
const preview = installQuebecCameraPreview(viewer, {
initialId: '3735',
fetchImpl: async () => ({ ok: true, json: async () => ({ cameras }) }),
});
await preview.ready;
const videos = [...window.document.querySelectorAll('video')];
assert.equal(videos.length, 2);
assert.equal(
videos[0].src,
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4',
);
assert.equal(videos[1].src, videos[0].src);
assert.equal(videos[0].hidden, false);
assert.match(
window.document.querySelector('.qc-preview-coordinates').textContent,
/45\.51651, -73\.55020/,
);
videos[1].dispatchEvent(new window.Event('loadeddata'));
assert.equal(videos[1].dataset.paused, 'yes');
const marker = window.document.querySelector('.qc-preview-marker');
assert.equal(marker.hidden, false);
assert.equal(marker.style.left, '230px');
assert.equal(marker.style.top, '190px');
const nearScale = Number(
marker.style.getPropertyValue('--qc-preview-scale'),
);
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
-73.5502,
45.51651,
80000,
);
render();
assert.ok(
Number(marker.style.getPropertyValue('--qc-preview-scale')) < nearScale,
);
const select = window.document.querySelector('select');
select.value = 'mtl-465';
select.dispatchEvent(new window.Event('change'));
assert.equal(videos[0].hidden, true);
assert.equal(videos[1].hidden, true);
assert.match(
window.document.querySelectorAll(
'.qc-preview-panel a[target="_blank"]',
)[1].href,
/ville\.montreal\.qc\.ca/,
);
window.document.querySelector('.qc-preview-actions button').click();
assert.ok(destination);
point = { x: -1, y: 150 };
render();
assert.equal(marker.hidden, true);
window.document.querySelector('.qc-preview-header button').click();
preview.dispose();
assert.equal(removed, 1);
assert.equal(window.document.querySelectorAll('video').length, 0);
} finally {
globalThis.window = previousWindow;
globalThis.document = previousDocument;
window.close();
}
});
+115 -34
View File
@@ -49,13 +49,29 @@ function setMedia(video, url) {
video.load();
}
/** Treat an interrupted play() (a pause or a new source) as no failure. */
function onPlayFailure(mark) {
return (error) => {
if (error?.name !== 'AbortError') mark();
};
}
/**
* Québec 511 serves a new clip at the same URL every few minutes, so a refresh
* adds a timestamp to get past the browser's media cache.
*/
function mediaRefreshUrl(value) {
const url = new URL(value);
url.searchParams.set('_', String(Date.now()));
return url.href;
}
/** Attach the active Québec source to a floating browser player and map preview. */
/**
* Attach the active Québec source to a floating browser player and map
* preview. The browser contacts Québec 511 only after the viewer presses Play
* for that camera; selecting it (by click, auto-hop, a scene or voice) only
* shows its details and the play control.
*/
export function createQuebecCctvOverlay(viewer) {
if (!viewer?.scene || typeof document === 'undefined') return null;
@@ -64,7 +80,7 @@ export function createQuebecCctvOverlay(viewer) {
const panel = document.createElement('section');
panel.className = 'qci-panel';
panel.hidden = true;
panel.setAttribute('role', 'dialog');
panel.setAttribute('role', 'region');
panel.setAttribute('aria-label', t('CCTV camera'));
const header = document.createElement('header');
@@ -91,10 +107,14 @@ export function createQuebecCctvOverlay(viewer) {
const actions = document.createElement('div');
actions.className = 'qci-actions';
const play = document.createElement('button');
play.type = 'button';
play.className = 'qci-play';
play.textContent = t('Play');
const refresh = document.createElement('button');
refresh.type = 'button';
refresh.textContent = t('Refresh map still');
actions.append(refresh);
actions.append(play, refresh);
const note = document.createElement('p');
note.className = 'qci-note';
const sourceLink = document.createElement('a');
@@ -110,7 +130,7 @@ export function createQuebecCctvOverlay(viewer) {
still.className = 'qci-marker-still';
still.muted = true;
still.playsInline = true;
still.preload = 'auto';
still.preload = 'none';
still.setAttribute('aria-hidden', 'true');
const markerLabel = document.createElement('span');
markerLabel.className = 'qci-marker-label';
@@ -121,7 +141,11 @@ export function createQuebecCctvOverlay(viewer) {
let selectedPosition = null;
let closedWindowCameraId = null;
let selectedMediaUrl = '';
/** Camera id the viewer pressed Play for; media loads only for this one. */
let playingCameraId = null;
let mediaRefreshTimer = 0;
let removeRender = null;
let canvasRect = null;
let liveUnavailable = false;
let stillUnavailable = false;
@@ -144,8 +168,18 @@ export function createQuebecCctvOverlay(viewer) {
markerLabel.textContent = t('Source unavailable');
}
function startRender() {
if (!removeRender)
removeRender = viewer.scene.postRender.addEventListener(updateMarker);
}
function stopRender() {
removeRender?.();
removeRender = null;
}
function stopMediaRefresh() {
clearInterval(mediaRefreshTimer);
window.clearInterval(mediaRefreshTimer);
mediaRefreshTimer = 0;
}
@@ -156,7 +190,7 @@ export function createQuebecCctvOverlay(viewer) {
() => {
if (document.hidden || closedWindowCameraId === selected?.id) return;
setMedia(live, mediaRefreshUrl(selectedMediaUrl));
void live.play().catch(markLiveUnavailable);
void live.play().catch(onPlayFailure(markLiveUnavailable));
refreshStill();
},
2 * 60 * 1000,
@@ -164,10 +198,33 @@ export function createQuebecCctvOverlay(viewer) {
}
function refreshStill() {
if (!selectedMediaUrl) return;
if (!selectedMediaUrl || playingCameraId !== selected?.id) return;
setMedia(still, '');
setMedia(still, mediaRefreshUrl(selectedMediaUrl));
void still.play().catch(markStillUnavailable);
void still.play().catch(onPlayFailure(markStillUnavailable));
}
/** Load and play the selected camera's media: only from a Play click. */
function startPlayback() {
if (!selected || !selectedMediaUrl) return;
playingCameraId = selected.id;
liveUnavailable = false;
stillUnavailable = false;
play.hidden = true;
refresh.hidden = false;
live.hidden = false;
still.hidden = false;
marker.classList.remove('qci-marker-link-only');
note.textContent = t(
'The map still uses a separate request and may show a different moment.',
);
note.hidden = false;
markerLabel.textContent = markerLabelText(selected, true);
setMedia(live, selectedMediaUrl);
setMedia(still, selectedMediaUrl);
void live.play().catch(onPlayFailure(markLiveUnavailable));
void still.play().catch(onPlayFailure(markStillUnavailable));
startMediaRefresh();
}
function setCamera(camera, { reselected = false } = {}) {
@@ -176,9 +233,11 @@ export function createQuebecCctvOverlay(viewer) {
selectedPosition = null;
closedWindowCameraId = null;
selectedMediaUrl = '';
playingCameraId = null;
liveUnavailable = false;
stillUnavailable = false;
stopMediaRefresh();
stopRender();
panel.hidden = true;
marker.hidden = true;
setMedia(live, '');
@@ -189,6 +248,7 @@ export function createQuebecCctvOverlay(viewer) {
const changed = selected?.id !== camera.id;
if (changed || reselected) closedWindowCameraId = null;
selected = camera;
startRender();
selectedPosition = Cesium.Cartesian3.fromDegrees(
Number(camera.lon),
Number(camera.lat),
@@ -205,7 +265,9 @@ export function createQuebecCctvOverlay(viewer) {
if (changed || mediaUrl !== selectedMediaUrl) {
liveUnavailable = false;
stillUnavailable = false;
playingCameraId = null;
}
const playing = Boolean(mediaUrl) && playingCameraId === camera.id;
title.textContent = camera.name || t('CCTV camera');
coordinates.textContent = [
`${Number(camera.lat).toFixed(5)}, ${Number(camera.lon).toFixed(5)}`,
@@ -218,16 +280,19 @@ export function createQuebecCctvOverlay(viewer) {
.join(' · ');
credit.textContent = camera.credit || camera.provider || '';
credit.hidden = !credit.textContent;
live.hidden = !mediaUrl;
refresh.hidden = !mediaUrl;
live.hidden = !playing;
play.hidden = !mediaUrl || playing;
refresh.hidden = !playing;
actions.hidden = !mediaUrl;
note.hidden = !mediaUrl;
note.textContent = mediaUrl
? liveUnavailable
? t('Source unavailable')
: t(
'The map still uses a separate request and may show a different moment.',
)
? !playing
? t('Video plays from Québec 511, which sees your IP address.')
: liveUnavailable
? t('Source unavailable')
: t(
'The map still uses a separate request and may show a different moment.',
)
: sourceUrl
? ''
: t('Source unavailable');
@@ -236,27 +301,24 @@ export function createQuebecCctvOverlay(viewer) {
sourceLink.hidden = !sourceUrl;
markerLabel.textContent = stillUnavailable
? t('Source unavailable')
: markerLabelText(camera, mediaUrl);
marker.classList.toggle('qci-marker-link-only', !mediaUrl);
still.hidden = !mediaUrl;
: markerLabelText(camera, playing);
marker.classList.toggle('qci-marker-link-only', !playing);
still.hidden = !playing;
panel.hidden = false;
if (changed || mediaUrl !== selectedMediaUrl) {
selectedMediaUrl = mediaUrl;
setMedia(live, mediaUrl);
setMedia(still, mediaUrl);
if (mediaUrl) {
void live.play().catch(markLiveUnavailable);
void still.play().catch(markStillUnavailable);
startMediaRefresh();
} else {
stopMediaRefresh();
}
} else if (reselected && mediaUrl) {
// A new camera never inherits playback: the browser stays off 511
// until the viewer presses Play for it.
stopMediaRefresh();
setMedia(live, '');
setMedia(still, '');
} else if (reselected && playing) {
if (live.error || live.networkState === 3 || live.readyState < 2) {
setMedia(live, mediaRefreshUrl(mediaUrl));
}
if (live.paused) void live.play().catch(markLiveUnavailable);
if (live.paused)
void live.play().catch(onPlayFailure(markLiveUnavailable));
startMediaRefresh();
if (still.error || still.networkState === 3 || still.readyState < 2)
refreshStill();
@@ -280,7 +342,10 @@ export function createQuebecCctvOverlay(viewer) {
point.y <= canvas.clientHeight;
marker.hidden = !visible;
if (!visible) return;
const rect = canvas.getBoundingClientRect();
// Measured once per resize or scroll, not every frame, so the per-frame
// style writes never force a layout.
canvasRect ||= canvas.getBoundingClientRect();
const rect = canvasRect;
marker.style.left = `${rect.left + point.x}px`;
marker.style.top = `${rect.top + point.y}px`;
const distance = Cesium.Cartesian3.distance(
@@ -320,6 +385,23 @@ export function createQuebecCctvOverlay(viewer) {
{ signal },
);
refresh.addEventListener('click', refreshStill, { signal });
play.addEventListener('click', startPlayback, { signal });
const resetCanvasRect = () => {
canvasRect = null;
};
window.addEventListener('resize', resetCanvasRect, { signal });
window.addEventListener('scroll', resetCanvasRect, {
signal,
capture: true,
passive: true,
});
panel.addEventListener(
'keydown',
(event) => {
if (event.key === 'Escape') close.click();
},
{ signal },
);
close.addEventListener(
'click',
() => {
@@ -359,25 +441,24 @@ export function createQuebecCctvOverlay(viewer) {
document.addEventListener(
'visibilitychange',
() => {
if (!selectedMediaUrl) return;
if (!selectedMediaUrl || playingCameraId !== selected?.id) return;
if (document.hidden) {
live.pause();
still.pause();
} else if (closedWindowCameraId !== selected?.id) {
void live.play().catch(markLiveUnavailable);
void live.play().catch(onPlayFailure(markLiveUnavailable));
refreshStill();
}
},
{ signal },
);
const removeRender = viewer.scene.postRender.addEventListener(updateMarker);
return {
setCamera,
dispose() {
controller.abort();
stopMediaRefresh();
removeRender();
stopRender();
setMedia(live, '');
setMedia(still, '');
panel.remove();
+164 -43
View File
@@ -4,18 +4,48 @@ import { Window } from 'happy-dom';
import * as Cesium from 'cesium';
import { createQuebecCctvOverlay } from './quebecCctvOverlay.js';
test('routine same-camera updates keep Québec feed errors visible until recovery', async () => {
const MEDIA_URL =
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4';
const CAMERA = Object.freeze({
id: 'qc-511-3735',
sourceKind: 'quebec-511-browser',
name: 'Route 136 at rue Panet',
lat: 45.51651,
lon: -73.5502,
positionSource: 'MTQ georeferenced point',
browserMediaUrl: MEDIA_URL,
sourceUrl:
'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735',
});
/**
* Mount the overlay in a happy-dom window with recorded media loads, timers
* and postRender listeners. `play` decides what each play() returns.
*/
function mountOverlay(t, { play = () => Promise.resolve() } = {}) {
const window = new Window({ url: 'http://localhost:4173/' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
globalThis.window = window;
globalThis.document = window.document;
window.HTMLMediaElement.prototype.load = function () {};
const loads = [];
window.HTMLMediaElement.prototype.load = function () {
if (this.getAttribute('src')) loads.push(this.getAttribute('src'));
};
window.HTMLMediaElement.prototype.play = function () {
return Promise.resolve();
return play(this);
};
window.HTMLMediaElement.prototype.pause = function () {};
window.setInterval = () => 0;
const intervals = new Map();
let nextTimer = 1;
window.setInterval = (fn) => {
const id = nextTimer++;
intervals.set(id, fn);
return id;
};
window.clearInterval = (id) => intervals.delete(id);
const renderListeners = new Set();
const viewer = {
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-73.5502, 45.51651, 1000),
@@ -23,49 +53,140 @@ test('routine same-camera updates keep Québec feed errors visible until recover
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
cartesianToCanvasCoordinates: () => null,
postRender: { addEventListener: () => () => {} },
postRender: {
addEventListener(fn) {
renderListeners.add(fn);
return () => renderListeners.delete(fn);
},
},
},
};
let overlay;
try {
overlay = createQuebecCctvOverlay(viewer);
const camera = {
id: 'qc-511-3735',
sourceKind: 'quebec-511-browser',
name: 'Route 136 at rue Panet',
lat: 45.51651,
lon: -73.5502,
positionSource: 'MTQ georeferenced point',
browserMediaUrl:
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4',
sourceUrl:
'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735',
};
overlay.setCamera(camera);
const [live, still] = window.document.querySelectorAll('video');
live.dispatchEvent(new window.Event('error'));
still.dispatchEvent(new window.Event('error'));
overlay.setCamera({ ...camera });
assert.equal(window.document.querySelector('.qci-note').textContent, 'Source unavailable');
assert.equal(window.document.querySelector('.qci-marker-label').textContent, 'Source unavailable');
live.dispatchEvent(new window.Event('loadeddata'));
still.dispatchEvent(new window.Event('loadeddata'));
assert.match(
window.document.querySelector('.qci-note').textContent,
/separate request/,
);
assert.match(
window.document.querySelector('.qci-marker-label').textContent,
/^PREVIEW ·/,
);
} finally {
const overlay = createQuebecCctvOverlay(viewer);
t.after(() => {
overlay?.dispose();
globalThis.window = previousWindow;
globalThis.document = previousDocument;
window.close();
}
});
const [live, still] = window.document.querySelectorAll('video');
return {
window,
overlay,
loads,
intervals,
renderListeners,
live,
still,
note: () => window.document.querySelector('.qci-note').textContent,
label: () => window.document.querySelector('.qci-marker-label').textContent,
playButton: () => window.document.querySelector('.qci-play'),
};
}
test('selecting a Québec camera contacts Québec 511 only after Play', (t) => {
const ui = mountOverlay(t);
assert.equal(ui.renderListeners.size, 0, 'no per-frame work before a camera');
ui.overlay.setCamera(CAMERA);
assert.deepEqual(ui.loads, [], 'selection alone loads no 511 media');
assert.equal(ui.intervals.size, 0, 'and starts no refresh timer');
assert.equal(ui.playButton().hidden, false);
assert.match(ui.note(), /sees your IP address/);
assert.equal(ui.renderListeners.size, 1);
ui.playButton().click();
assert.deepEqual(ui.loads, [MEDIA_URL, MEDIA_URL], 'live player and map still');
assert.equal(ui.playButton().hidden, true);
assert.equal(ui.intervals.size, 1);
// A different camera never inherits playback.
ui.loads.length = 0;
ui.overlay.setCamera({
...CAMERA,
id: 'qc-511-4103',
browserMediaUrl: MEDIA_URL.replace('3735', '4103'),
});
assert.deepEqual(ui.loads, []);
assert.equal(ui.intervals.size, 0);
assert.equal(ui.playButton().hidden, false);
ui.overlay.setCamera(null);
assert.equal(ui.renderListeners.size, 0, 'deselect drops the per-frame listener');
});
test('a camera without browser media shows only its provider link', (t) => {
const ui = mountOverlay(t);
ui.overlay.setCamera({ ...CAMERA, browserMediaUrl: '' });
assert.equal(ui.playButton().hidden, true);
assert.deepEqual(ui.loads, []);
const link = ui.window.document.querySelector('.qci-panel a');
assert.equal(link.hidden, false);
assert.equal(link.href, CAMERA.sourceUrl);
});
test('routine same-camera updates keep Québec feed errors visible until recovery', (t) => {
const ui = mountOverlay(t);
ui.overlay.setCamera(CAMERA);
ui.playButton().click();
ui.live.dispatchEvent(new ui.window.Event('error'));
ui.still.dispatchEvent(new ui.window.Event('error'));
ui.overlay.setCamera({ ...CAMERA });
assert.equal(ui.note(), 'Source unavailable');
assert.equal(ui.label(), 'Source unavailable');
ui.live.dispatchEvent(new ui.window.Event('loadeddata'));
ui.still.dispatchEvent(new ui.window.Event('loadeddata'));
assert.match(ui.note(), /separate request/);
assert.match(ui.label(), /^PREVIEW ·/);
});
test('an interrupted play() is not reported as an unavailable source', async (t) => {
const abort = () =>
Promise.reject(new DOMException('interrupted by pause()', 'AbortError'));
const ui = mountOverlay(t, { play: abort });
ui.overlay.setCamera(CAMERA);
ui.playButton().click();
await new Promise((resolve) => setImmediate(resolve));
assert.match(ui.note(), /separate request/);
assert.match(ui.label(), /^PREVIEW ·/);
});
test('a real play() failure is reported', async (t) => {
const fail = () =>
Promise.reject(new DOMException('no supported source', 'NotSupportedError'));
const ui = mountOverlay(t, { play: fail });
ui.overlay.setCamera(CAMERA);
ui.playButton().click();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(ui.note(), 'Source unavailable');
});
test('the refresh timer reloads with a cache-buster, skips hidden tabs, and stops on dispose', (t) => {
const ui = mountOverlay(t);
ui.overlay.setCamera(CAMERA);
ui.playButton().click();
const [refresh] = ui.intervals.values();
ui.loads.length = 0;
Object.defineProperty(ui.window.document, 'hidden', {
configurable: true,
get: () => true,
});
refresh();
assert.deepEqual(ui.loads, [], 'a hidden tab does not refresh');
Object.defineProperty(ui.window.document, 'hidden', {
configurable: true,
get: () => false,
});
refresh();
assert.equal(ui.loads.length, 2);
for (const src of ui.loads) assert.match(src, /[?&]_=\d+$/);
ui.overlay.dispose();
assert.equal(ui.intervals.size, 0);
assert.equal(ui.renderListeners.size, 0);
assert.equal(ui.window.document.querySelector('.qci-panel'), null);
});