mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(cctv): add Taiwan Freeway Bureau camera pack
Read ~1,800 national freeway cameras at runtime from the Freeway Bureau's keyless MOTC 2.0 CCTV.xml table. Each published MJPEG stream maps to the same camera server's single-JPEG path; only those exact HTTPS pairs on the four freeway.gov.tw camera servers are registered. On by default, CCTV_TAIWAN_ENABLED=0 skips it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
93ea088f45
commit
73336b297e
10 files changed
+488
-1
No files matched your search
@@ -237,6 +237,8 @@ AISSTREAM_API_KEY=
|
|||||||
# CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL
|
# CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL
|
||||||
# CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md)
|
# CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md)
|
||||||
# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required
|
# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required
|
||||||
|
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
|
||||||
|
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
|
||||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
|||||||
**Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent.
|
**Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent.
|
||||||
|
|
||||||
- **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.
|
- **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, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse with attribution; the app credits the Freeway Bureau, MOTC. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
|
||||||
|
|
||||||
- **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)).
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
|||||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
| 🌍 **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 | 🟢 |
|
| ◍ **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 |
|
| 🚗 **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** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · 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), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · 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) · Taiwan (Freeway Bureau, MOTC), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; 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) | 🟢 |
|
| 📷 **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 and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. 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 | 🟢 |
|
| 📻 **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 | 🟢 |
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV:
|
||||||
|
~1,800 cameras read at runtime from the official MOTC 2.0 open-data table,
|
||||||
|
with on-demand snapshots and attribution. No API key is required; set
|
||||||
|
`CCTV_TAIWAN_ENABLED=0` to skip it.
|
||||||
@@ -1075,6 +1075,22 @@ The images are periodically updated road-condition photographs, not continuous
|
|||||||
video; camera poses use the existing low-confidence heading prior.
|
video; camera poses use the existing low-confidence heading prior.
|
||||||
`CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000).
|
`CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000).
|
||||||
|
|
||||||
|
## Taiwan CCTV source pack
|
||||||
|
|
||||||
|
The CCTV catalog includes Taiwan national freeway cameras from the Freeway
|
||||||
|
Bureau, MOTC. One keyless request reads the daily MOTC 2.0 `CCTV.xml` table
|
||||||
|
(~1,800 cameras); the request refuses redirects, has a 15-second deadline and a
|
||||||
|
6 MiB response cap, and uses the shared 15-minute cache and fair catalog
|
||||||
|
allocation. An unavailable source contributes an empty pack. The table
|
||||||
|
publishes MJPEG stream URLs; each is mapped to the same camera server's
|
||||||
|
single-JPEG path for the same camera number, and only those exact HTTPS pairs
|
||||||
|
on the four `freeway.gov.tw` camera servers are registered with the frame
|
||||||
|
proxy. Camera keys come from the server and camera number, because published
|
||||||
|
CCTV IDs contain Chinese place names and a few repeat across cameras. Poses use
|
||||||
|
the low-confidence heading prior. `CCTV_TAIWAN_ENABLED=0` disables the pack;
|
||||||
|
`CCTV_TAIWAN_MAX_SOURCES` sets its cap (default 600, bounded to 1–2000).
|
||||||
|
Gate: `src/data/cctvTaiwan.test.mjs`.
|
||||||
|
|
||||||
## DriveBC CCTV source pack
|
## DriveBC CCTV source pack
|
||||||
|
|
||||||
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
|
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
|
||||||
|
|||||||
@@ -114,6 +114,8 @@
|
|||||||
"server/providers/cctv/catalog.js",
|
"server/providers/cctv/catalog.js",
|
||||||
"server/providers/cctv/lithuania.js",
|
"server/providers/cctv/lithuania.js",
|
||||||
"src/data/cctvLithuania.test.mjs",
|
"src/data/cctvLithuania.test.mjs",
|
||||||
|
"server/providers/cctv/taiwan.js",
|
||||||
|
"src/data/cctvTaiwan.test.mjs",
|
||||||
"server/providers/cctv/constants.js",
|
"server/providers/cctv/constants.js",
|
||||||
"server/providers/cctv/media.js",
|
"server/providers/cctv/media.js",
|
||||||
"server/providers/cctv/normalize.js",
|
"server/providers/cctv/normalize.js",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { allocateSourceCap, resolveCatalogCap } from './cap.js';
|
|||||||
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
|
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
|
||||||
import { cameraRegion, normalizeSourceItem } from './normalize.js';
|
import { cameraRegion, normalizeSourceItem } from './normalize.js';
|
||||||
import { loadLithuaniaSources } from './lithuania.js';
|
import { loadLithuaniaSources } from './lithuania.js';
|
||||||
|
import { loadTaiwanSources } from './taiwan.js';
|
||||||
import {
|
import {
|
||||||
loadAustinSourcesFromOpenData,
|
loadAustinSourcesFromOpenData,
|
||||||
loadCaltransSourcesFromOpenData,
|
loadCaltransSourcesFromOpenData,
|
||||||
@@ -144,6 +145,11 @@ const LIVE_PACKS = [
|
|||||||
enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'),
|
enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'),
|
||||||
load: loadLithuaniaSources,
|
load: loadLithuaniaSources,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'taiwan',
|
||||||
|
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
|
||||||
|
load: loadTaiwanSources,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
/**
|
/**
|
||||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
|
||||||
|
import {
|
||||||
|
cameraDisplayCode,
|
||||||
|
fallbackHeadingFromId,
|
||||||
|
prioritizeSources,
|
||||||
|
} from './normalize.js';
|
||||||
|
import { readResponseTextCapped } from '../common/http.js';
|
||||||
|
|
||||||
|
/** Freeway Bureau (MOTC) CCTV static table in the national MOTC 2.0 XML
|
||||||
|
* standard, published keyless and refreshed daily (~1,800 cameras). */
|
||||||
|
export const TAIWAN_FREEWAY_CCTV_URL =
|
||||||
|
'https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml';
|
||||||
|
/** The whole network is ~1.2 MB. */
|
||||||
|
export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024;
|
||||||
|
export const DEFAULT_TAIWAN_MAX_SOURCES = 600;
|
||||||
|
|
||||||
|
// The table publishes each camera's MJPEG stream. The same camera servers
|
||||||
|
// answer a single JPEG for the same camera ID on a sibling path, which is what
|
||||||
|
// the frame proxy can relay; only these exact pairs are ever registered.
|
||||||
|
const STREAM_TO_FRAME = new Map([
|
||||||
|
['cctvn.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']],
|
||||||
|
['cctvn5.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']],
|
||||||
|
['cctvc.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']],
|
||||||
|
[
|
||||||
|
'cctvs.freeway.gov.tw',
|
||||||
|
['/live-view/mjpg/video.cgi', '/live-view/jpg/image.cgi'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DIRECTIONS = { N: 'N', S: 'S', E: 'E', W: 'W' };
|
||||||
|
|
||||||
|
/** Prioritization anchors, north to south, so a cap keeps the whole island
|
||||||
|
* rather than only greater Taipei. */
|
||||||
|
const ANCHORS = [
|
||||||
|
{ lat: 25.033, lon: 121.5654 }, // Taipei
|
||||||
|
{ lat: 24.9936, lon: 121.301 }, // Taoyuan
|
||||||
|
{ lat: 24.8138, lon: 120.9675 }, // Hsinchu
|
||||||
|
{ lat: 24.7021, lon: 121.7378 }, // Yilan
|
||||||
|
{ lat: 24.1477, lon: 120.6736 }, // Taichung
|
||||||
|
{ lat: 22.9999, lon: 120.227 }, // Tainan
|
||||||
|
{ lat: 22.6273, lon: 120.3014 }, // Kaohsiung
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Taiwan main island with slack for the coast; the freeway network has no
|
||||||
|
* offshore-island cameras. */
|
||||||
|
export function isLikelyTaiwanCoordinate(lat, lon) {
|
||||||
|
return (
|
||||||
|
Number.isFinite(lat) &&
|
||||||
|
Number.isFinite(lon) &&
|
||||||
|
lat >= 21.8 &&
|
||||||
|
lat <= 25.4 &&
|
||||||
|
lon >= 119.9 &&
|
||||||
|
lon <= 122.1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const XML_ENTITIES = {
|
||||||
|
amp: '&',
|
||||||
|
lt: '<',
|
||||||
|
gt: '>',
|
||||||
|
quot: '"',
|
||||||
|
apos: "'",
|
||||||
|
};
|
||||||
|
|
||||||
|
function decodeXml(text) {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replace(/&(amp|lt|gt|quot|apos);/g, (_, name) => XML_ENTITIES[name])
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tag(block, name) {
|
||||||
|
const match = new RegExp(`<${name}>([^<]*)</${name}>`).exec(block);
|
||||||
|
return match ? decodeXml(match[1]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the MOTC 2.0 `CCTVList` document into flat rows. Regex-based like the
|
||||||
|
* Catalonia and Tarktee parsers: the schema is flat, and only the fields used
|
||||||
|
* below are read.
|
||||||
|
*
|
||||||
|
* @param {string} xml
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
export function parseTaiwanCctvXml(xml) {
|
||||||
|
const rows = [];
|
||||||
|
const blockRe = /<CCTV>([\s\S]*?)<\/CCTV>/g;
|
||||||
|
let match;
|
||||||
|
while ((match = blockRe.exec(String(xml || ''))) !== null) {
|
||||||
|
const block = match[1];
|
||||||
|
rows.push({
|
||||||
|
cctvId: tag(block, 'CCTVID'),
|
||||||
|
streamUrl: tag(block, 'VideoStreamURL'),
|
||||||
|
lat: Number(tag(block, 'PositionLat')),
|
||||||
|
lon: Number(tag(block, 'PositionLon')),
|
||||||
|
roadName: tag(block, 'RoadName'),
|
||||||
|
direction: tag(block, 'RoadDirection'),
|
||||||
|
sectionStart: tag(block, 'Start'),
|
||||||
|
sectionEnd: tag(block, 'End'),
|
||||||
|
mile: tag(block, 'LocationMile'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Published MJPEG stream URL -> the same camera's still, plus the host and
|
||||||
|
* camera number that identify it, or null.
|
||||||
|
*
|
||||||
|
* @param {string} streamUrl
|
||||||
|
* @returns {?{frameUrl: string, host: string, camera: string}}
|
||||||
|
*/
|
||||||
|
export function taiwanFrame(streamUrl) {
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(String(streamUrl ?? ''));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'https:' || url.username || url.password || url.port)
|
||||||
|
return null;
|
||||||
|
const paths = STREAM_TO_FRAME.get(url.hostname);
|
||||||
|
if (!paths || url.pathname !== paths[0]) return null;
|
||||||
|
const camera = url.searchParams.get('camera') || '';
|
||||||
|
if (!/^[A-Za-z0-9-]{1,40}$/.test(camera)) return null;
|
||||||
|
return {
|
||||||
|
// Reconstruct so extra query parameters cannot alter the request.
|
||||||
|
frameUrl: `https://${url.hostname}${paths[1]}?camera=${camera}`,
|
||||||
|
host: url.hostname.split('.')[0],
|
||||||
|
camera,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One parsed Freeway Bureau row -> one catalog source, or null.
|
||||||
|
*
|
||||||
|
* @param {object} row - Output of parseTaiwanCctvXml.
|
||||||
|
* @returns {?object}
|
||||||
|
*/
|
||||||
|
export function taiwanCameraToSource(row) {
|
||||||
|
if (!row || typeof row !== 'object') return null;
|
||||||
|
const { lat, lon } = row;
|
||||||
|
if (!isLikelyTaiwanCoordinate(lat, lon)) return null;
|
||||||
|
const frame = taiwanFrame(row.streamUrl);
|
||||||
|
if (!frame) return null;
|
||||||
|
const imageUrl = frame.frameUrl;
|
||||||
|
// CCTVID carries Chinese place names and a few IDs repeat across distinct
|
||||||
|
// cameras, so the stable key is the camera server plus its camera number.
|
||||||
|
const rawId = String(row.cctvId ?? '')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 80);
|
||||||
|
const cameraId = `tw-freeway-${frame.host}-${frame.camera.toLowerCase()}`;
|
||||||
|
const road = String(row.roadName ?? '').trim();
|
||||||
|
const direction = DIRECTIONS[String(row.direction ?? '').trim()] || '';
|
||||||
|
const mile = String(row.mile ?? '').trim();
|
||||||
|
const section = [row.sectionStart, row.sectionEnd]
|
||||||
|
.map((part) => String(part ?? '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('→');
|
||||||
|
const name =
|
||||||
|
[road, direction, mile].filter(Boolean).join(' ') +
|
||||||
|
(section ? ` ${section}` : '') ||
|
||||||
|
rawId ||
|
||||||
|
`Freeway camera ${frame.camera}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: cameraId,
|
||||||
|
name: name.slice(0, 160),
|
||||||
|
// One island-wide category in the camera picker, like Finland and Norway.
|
||||||
|
city: 'Taiwan / 臺灣',
|
||||||
|
cityId: 'taiwan',
|
||||||
|
provider: 'Freeway Bureau, MOTC',
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
// The table gives the carriageway direction, not the camera's bearing,
|
||||||
|
// so the pose uses the shared low-confidence prior.
|
||||||
|
headingDeg: fallbackHeadingFromId(cameraId),
|
||||||
|
headingConfidence: 'low',
|
||||||
|
pitchDeg: -18,
|
||||||
|
fovDeg: 44,
|
||||||
|
rangeM: 145,
|
||||||
|
mountHeightM: 8,
|
||||||
|
feedType: 'image',
|
||||||
|
url: imageUrl,
|
||||||
|
snapshotUrl: imageUrl,
|
||||||
|
sourceKind: 'tw-freeway-motc',
|
||||||
|
license:
|
||||||
|
'Freeway Bureau, MOTC open data, Open Government Data License, Taiwan (Version 1.0)',
|
||||||
|
code: cameraDisplayCode(
|
||||||
|
`${road} ${mile}`.trim().toUpperCase() || frame.camera,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Taiwan Freeway Bureau cameras from the keyless MOTC 2.0 XML table.
|
||||||
|
* One bounded request per shared catalog refresh.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<object>>}
|
||||||
|
*/
|
||||||
|
export async function loadTaiwanSources() {
|
||||||
|
try {
|
||||||
|
const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS);
|
||||||
|
const response = await fetch(
|
||||||
|
process.env.CCTV_TAIWAN_URL || TAIWAN_FREEWAY_CCTV_URL,
|
||||||
|
{
|
||||||
|
headers: { Accept: 'application/xml,text/xml' },
|
||||||
|
redirect: 'manual',
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
await response.body?.cancel().catch(() => {});
|
||||||
|
console.warn(
|
||||||
|
'[CCTV] Taiwan camera download failed:',
|
||||||
|
response.status >= 300 && response.status < 400
|
||||||
|
? 'catalog redirected; redirects are not followed'
|
||||||
|
: response.status,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const xml = await readResponseTextCapped(
|
||||||
|
response,
|
||||||
|
TAIWAN_MAX_CATALOG_BYTES,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
const cameras = new Map();
|
||||||
|
for (const row of parseTaiwanCctvXml(xml)) {
|
||||||
|
const camera = taiwanCameraToSource(row);
|
||||||
|
if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera);
|
||||||
|
}
|
||||||
|
const rawCap = Number(
|
||||||
|
process.env.CCTV_TAIWAN_MAX_SOURCES || DEFAULT_TAIWAN_MAX_SOURCES,
|
||||||
|
);
|
||||||
|
const cap = Number.isFinite(rawCap)
|
||||||
|
? Math.max(1, Math.min(2000, Math.floor(rawCap)))
|
||||||
|
: DEFAULT_TAIWAN_MAX_SOURCES;
|
||||||
|
const sources = prioritizeSources([...cameras.values()], cap, ANCHORS);
|
||||||
|
console.log(
|
||||||
|
`[CCTV] Loaded Taiwan camera sources: ${cameras.size} (using ${sources.length})`,
|
||||||
|
);
|
||||||
|
return sources;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'[CCTV] Taiwan camera download error:',
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
loadTaiwanSources,
|
||||||
|
parseTaiwanCctvXml,
|
||||||
|
taiwanCameraToSource,
|
||||||
|
taiwanFrame,
|
||||||
|
TAIWAN_FREEWAY_CCTV_URL,
|
||||||
|
} from '../../server/providers/cctv/taiwan.js';
|
||||||
|
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||||
|
|
||||||
|
function cctvXml(...blocks) {
|
||||||
|
return `<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<CCTVList xmlns="http://traffic.transportdata.tw/standard/traffic/schema/">
|
||||||
|
<AuthorityCode>NFB</AuthorityCode>
|
||||||
|
<CCTVs>${blocks.join('')}</CCTVs>
|
||||||
|
</CCTVList>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function block({
|
||||||
|
id = 'CCTV-N1-S-0.000-M',
|
||||||
|
stream = 'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
|
||||||
|
lat = '25.1229931',
|
||||||
|
lon = '121.735695',
|
||||||
|
} = {}) {
|
||||||
|
return `<CCTV>
|
||||||
|
<CCTVID>${id}</CCTVID>
|
||||||
|
<VideoStreamURL>${stream}</VideoStreamURL>
|
||||||
|
<PositionLon>${lon}</PositionLon>
|
||||||
|
<PositionLat>${lat}</PositionLat>
|
||||||
|
<RoadName>國道1號</RoadName>
|
||||||
|
<RoadDirection>S</RoadDirection>
|
||||||
|
<RoadSection><Start>基隆端</Start><End>基隆交流道</End></RoadSection>
|
||||||
|
<LocationMile>0K+000</LocationMile>
|
||||||
|
</CCTV>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('parses the MOTC 2.0 table and registers the same camera still', () => {
|
||||||
|
const [row] = parseTaiwanCctvXml(cctvXml(block()));
|
||||||
|
const camera = taiwanCameraToSource(row);
|
||||||
|
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
|
||||||
|
assert.equal(camera.lat, 25.1229931);
|
||||||
|
assert.equal(camera.lon, 121.735695);
|
||||||
|
assert.equal(camera.name, '國道1號 S 0K+000 基隆端→基隆交流道');
|
||||||
|
assert.equal(camera.cityId, 'taiwan');
|
||||||
|
assert.equal(camera.feedType, 'image');
|
||||||
|
assert.equal(camera.headingConfidence, 'low');
|
||||||
|
assert.equal(
|
||||||
|
camera.url,
|
||||||
|
'https://cctvn.freeway.gov.tw/abs2mjpg/jpg?camera=10000',
|
||||||
|
);
|
||||||
|
assert.equal(camera.snapshotUrl, camera.url);
|
||||||
|
assert.match(camera.license, /Open Government Data License, Taiwan/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each camera server maps its stream path to its own still path only', () => {
|
||||||
|
assert.equal(
|
||||||
|
taiwanFrame(
|
||||||
|
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?camera=373',
|
||||||
|
).frameUrl,
|
||||||
|
'https://cctvs.freeway.gov.tw/live-view/jpg/image.cgi?camera=373',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
taiwanFrame(
|
||||||
|
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
|
||||||
|
).frameUrl,
|
||||||
|
'https://cctvn5.freeway.gov.tw/abs2mjpg/jpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
taiwanFrame(
|
||||||
|
'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495&extra=1',
|
||||||
|
).frameUrl,
|
||||||
|
'https://cctvc.freeway.gov.tw/abs2mjpg/jpg?camera=495',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('off-host, downgraded or malformed stream URLs never enter the proxy registry', () => {
|
||||||
|
for (const stream of [
|
||||||
|
'http://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
|
||||||
|
'https://cctvn.freeway.gov.tw.evil.test/abs2mjpg/bmjpg?camera=10000',
|
||||||
|
'https://user:[email protected]/abs2mjpg/bmjpg?camera=10000',
|
||||||
|
'https://cctvn.freeway.gov.tw:8443/abs2mjpg/bmjpg?camera=10000',
|
||||||
|
'https://cctvn.freeway.gov.tw/live-view/mjpg/video.cgi?camera=10000',
|
||||||
|
'https://cctvs.freeway.gov.tw/abs2mjpg/bmjpg?camera=373',
|
||||||
|
'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=../../admin',
|
||||||
|
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?cacamera=373',
|
||||||
|
'http://127.0.0.1/private',
|
||||||
|
'',
|
||||||
|
]) {
|
||||||
|
assert.equal(taiwanFrame(stream), null, stream);
|
||||||
|
const [row] = parseTaiwanCctvXml(cctvXml(block({ stream })));
|
||||||
|
assert.equal(taiwanCameraToSource(row), null, stream);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coordinates outside Taiwan, swapped or missing are rejected', () => {
|
||||||
|
for (const [lat, lon] of [
|
||||||
|
['121.735695', '25.1229931'],
|
||||||
|
['35.68', '139.76'],
|
||||||
|
['', '121.7'],
|
||||||
|
['NaN', '121.7'],
|
||||||
|
]) {
|
||||||
|
const [row] = parseTaiwanCctvXml(cctvXml(block({ lat, lon })));
|
||||||
|
assert.equal(taiwanCameraToSource(row), null, `${lat},${lon}`);
|
||||||
|
}
|
||||||
|
assert.equal(taiwanCameraToSource(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Chinese place names in CCTVID stay in the record without shaping the key', () => {
|
||||||
|
const [row] = parseTaiwanCctvXml(
|
||||||
|
cctvXml(block({ id: 'CCTV-N1-N-55-R-中壢服務區&(北)' })),
|
||||||
|
);
|
||||||
|
const camera = taiwanCameraToSource(row);
|
||||||
|
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
|
||||||
|
assert.equal(row.cctvId, 'CCTV-N1-N-55-R-中壢服務區&(北)');
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockCatalog(t, xml) {
|
||||||
|
const requested = [];
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||||
|
requested.push(String(url));
|
||||||
|
if (String(url) === TAIWAN_FREEWAY_CCTV_URL) {
|
||||||
|
assert.equal(options.redirect, 'manual');
|
||||||
|
return new Response(xml, {
|
||||||
|
headers: { 'content-type': 'application/xml' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Response.json([]);
|
||||||
|
});
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('loader deduplicates cameras that share a camera number', async (t) => {
|
||||||
|
mockCatalog(
|
||||||
|
t,
|
||||||
|
cctvXml(
|
||||||
|
block(),
|
||||||
|
block({ id: 'CCTV-N1-S-0.000-M-dup' }),
|
||||||
|
block({
|
||||||
|
stream: 'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495',
|
||||||
|
lat: '24.1',
|
||||||
|
lon: '120.6',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.deepEqual((await loadTaiwanSources()).map((c) => c.id).sort(), [
|
||||||
|
'tw-freeway-cctvc-495',
|
||||||
|
'tw-freeway-cctvn-10000',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a redirected or failed catalog contributes an empty pack', async (t) => {
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
for (const status of [302, 503]) {
|
||||||
|
t.mock.method(
|
||||||
|
globalThis,
|
||||||
|
'fetch',
|
||||||
|
async () =>
|
||||||
|
new Response('', {
|
||||||
|
status,
|
||||||
|
headers: status === 302 ? { location: 'https://evil.test/' } : {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.deepEqual(await loadTaiwanSources(), []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runCatalogWithTaiwanFlag(t, flag) {
|
||||||
|
const requested = mockCatalog(t, cctvXml(block()));
|
||||||
|
const saved = process.env.CCTV_TAIWAN_ENABLED;
|
||||||
|
try {
|
||||||
|
if (flag === undefined) delete process.env.CCTV_TAIWAN_ENABLED;
|
||||||
|
else process.env.CCTV_TAIWAN_ENABLED = flag;
|
||||||
|
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||||
|
return { requested, sources };
|
||||||
|
} finally {
|
||||||
|
if (saved === undefined) delete process.env.CCTV_TAIWAN_ENABLED;
|
||||||
|
else process.env.CCTV_TAIWAN_ENABLED = saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the Taiwan pack is in the catalog by default', async (t) => {
|
||||||
|
const { sources } = await runCatalogWithTaiwanFlag(t, undefined);
|
||||||
|
assert.equal(
|
||||||
|
sources.find((c) => c.id === 'tw-freeway-cctvn-10000')?.cityId,
|
||||||
|
'taiwan',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
|
||||||
|
const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0');
|
||||||
|
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||||
|
assert.equal(
|
||||||
|
requested.some((href) => href.includes('freeway.gov.tw')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.ok(!sources.some((c) => c.cityId === 'taiwan'));
|
||||||
|
});
|
||||||
@@ -257,6 +257,13 @@ export const DATA_CREDITS = [
|
|||||||
'Road-camera stills (Lithuania, when enabled): ' +
|
'Road-camera stills (Lithuania, when enabled): ' +
|
||||||
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
|
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'taiwan-cctv',
|
||||||
|
html:
|
||||||
|
'Freeway camera stills (Taiwan): ' +
|
||||||
|
'<a href="https://www.freeway.gov.tw/" target="_blank" rel="noopener">Freeway Bureau, MOTC</a>, ' +
|
||||||
|
'Open Government Data License, Taiwan (Version 1.0)',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'calgary-cctv',
|
key: 'calgary-cctv',
|
||||||
html:
|
html:
|
||||||
|
|||||||
Reference in new issue
Block a user