Merge pull request #607 from Reloded/feat/lithuania-cctv

feat(cctv): add Lithuania Eismoinfo road cameras
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-15 16:10:15 -07:00
commit 584a95fbec
11 files changed
+402 -5

No files matched your search

+2
View File
@@ -231,6 +231,8 @@ AISSTREAM_API_KEY=
# CCTV_TORONTO_ENABLED=1 # set 0 to disable the City of Toronto RESCU cameras # CCTV_TORONTO_ENABLED=1 # set 0 to disable the City of Toronto RESCU cameras
# CCTV_TORONTO_MAX_SOURCES=336 # cap cameras loaded from Toronto RESCU (clamped to 8–500) # CCTV_TORONTO_MAX_SOURCES=336 # cap cameras loaded from Toronto RESCU (clamped to 8–500)
# 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_MAX_SOURCES=400 # cap Lithuanian cameras; 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
+2
View File
@@ -1,5 +1,7 @@
# Changelog # Changelog
- Add Lithuania's public Via Lietuva / Eismoinfo road cameras to CCTV, with published coordinates, on-demand snapshots, attribution and bounded catalog requests. No API key is required. The pack is opt-in (`CCTV_LITHUANIA_ENABLED=1`) because its camera endpoints are the eismoinfo.lt website backend, not a service listed in Via Lietuva's open-data statement, so the licence for the photos is unconfirmed.
- Add King County road cameras (Washington State) as a keyless CCTV source - Add King County road cameras (Washington State) as a keyless CCTV source
pack: one public King County DOT ArcGIS layer, fetched with an explicit field pack: one public King County DOT ArcGIS layer, fetched with an explicit field
list so its staff-name columns are never requested, and frames pinned to the list so its staff-name columns are never requested, and frames pinned to the
+2
View File
@@ -93,6 +93,8 @@ 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.
- **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.
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
+1 -1
View File
@@ -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), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement). 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), 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 | 🟢 |
+16
View File
@@ -997,6 +997,22 @@ By default all ~336 cameras load, nearest downtown first.
`CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the 4,000 `CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the 4,000
catalog-wide ceiling round-robin with the other packs. catalog-wide ceiling round-robin with the other packs.
## Lithuania CCTV source pack
The CCTV catalog can include Via Lietuva / Eismoinfo road cameras. The pack is
off unless `CCTV_LITHUANIA_ENABLED=1`, because its camera endpoints are the
eismoinfo.lt website backend rather than a service listed in Via Lietuva's
open-data statement, so the licence for the photos is unconfirmed.
Two keyless public endpoints provide current snapshot URLs and WGS84 camera
locations; records are joined by ID, with invalid coordinates and off-origin
image URLs rejected. Catalog requests refuse redirects, have a 15-second
deadline and a 4 MiB response cap, and use the existing shared 15-minute cache
and fair catalog allocation. An unavailable source contributes an empty pack.
Frames use the registered-image proxy and retain Via Lietuva attribution.
The images are periodically updated road-condition photographs, not continuous
video; camera poses use the existing low-confidence heading prior.
`CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000).
## 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
+2
View File
@@ -112,6 +112,8 @@
"server/providers/cctv/cap.js", "server/providers/cctv/cap.js",
"server/providers/cctv/groundHeights.js", "server/providers/cctv/groundHeights.js",
"server/providers/cctv/catalog.js", "server/providers/cctv/catalog.js",
"server/providers/cctv/lithuania.js",
"src/data/cctvLithuania.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",
+1
View File
@@ -176,6 +176,7 @@
"server/providers/cctv/cap.js", "server/providers/cctv/cap.js",
"server/providers/cctv/groundHeights.js", "server/providers/cctv/groundHeights.js",
"server/providers/cctv/catalog.js", "server/providers/cctv/catalog.js",
"server/providers/cctv/lithuania.js",
"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",
+15 -4
View File
@@ -4,6 +4,7 @@ import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
import { allocateSourceCap, resolveCatalogCap } from './cap.js'; 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 { import {
loadAustinSourcesFromOpenData, loadAustinSourcesFromOpenData,
loadCaltransSourcesFromOpenData, loadCaltransSourcesFromOpenData,
@@ -27,15 +28,18 @@ import {
/** Env kill switch: unset or anything but "0" means enabled. */ /** Env kill switch: unset or anything but "0" means enabled. */
const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0'; const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0';
/** Env opt-in: only "1" enables. For packs whose feed access needs an /** Env opt-in: only "1" enables. For packs whose feed access needs an
* operator agreement, so an unset flag never reaches the upstream. */ * operator agreement or whose terms are unconfirmed, so an unset flag never
* reaches the upstream. */
const envOptIn = (name) => String(process.env[name] || '').trim() === '1'; const envOptIn = (name) => String(process.env[name] || '').trim() === '1';
/** /**
* Live open-data packs, in merge order. Adding a region is one entry here * Live open-data packs, in merge order. Adding a region is one entry here
* plus its loader in sources.js; the catalog cap is shared across entries * plus its loader (in sources.js or its own module); the catalog cap is
* round-robin (cap.js), so a new pack never silently evicts an older one. * shared across entries round-robin (cap.js), so a new pack never silently
* evicts an older one.
* Each pack fails independently (allSettled) and is gated by its own env * Each pack fails independently (allSettled) and is gated by its own env
* kill switch, or by an opt-in flag where access needs an agreement. * kill switch, or by an opt-in flag where access needs an agreement or the
* terms are unconfirmed.
*/ */
const LIVE_PACKS = [ const LIVE_PACKS = [
{ name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData }, { name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData },
@@ -121,6 +125,13 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_TORONTO_ENABLED'), enabled: () => envEnabled('CCTV_TORONTO_ENABLED'),
load: loadTorontoSourcesFromOpenData, load: loadTorontoSourcesFromOpenData,
}, },
{
// Opt-in until the Via Lietuva open-data terms are confirmed to cover the
// camera photos (see DATA_SOURCES.md).
name: 'lithuania',
enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'),
load: loadLithuaniaSources,
},
]; ];
/** /**
* 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).
+154
View File
@@ -0,0 +1,154 @@
import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
import {
cameraDisplayCode,
fallbackHeadingFromId,
prioritizeSources,
} from './normalize.js';
import { readResponseJsonCapped } from '../common/http.js';
const BASE = 'https://eismoinfo.lt/eismoinfo-backend';
export const LITHUANIA_CAMERAS_URL = `${BASE}/camera-info-table`;
// Without lks=true this endpoint returns [latitude, longitude]. The camera
// table's x/y fields use LKS-94 metres and must never be treated as degrees.
export const LITHUANIA_LOCATIONS_URL = `${BASE}/layer-static-features/VKR`;
export const LITHUANIA_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
const FRAME_PATH = '/eismoinfo-backend/image-provider/camera/last';
const ANCHORS = [
{ lat: 54.6872, lon: 25.2797 }, // Vilnius
{ lat: 54.8985, lon: 23.9036 }, // Kaunas
{ lat: 55.7033, lon: 21.1443 }, // Klaipeda
{ lat: 55.9349, lon: 23.3137 }, // Siauliai
{ lat: 55.7348, lon: 24.3575 }, // Panevezys
];
/** Join public catalogs by ID; register only the official still endpoint. */
export function lithuaniaCameraToSource(row, feature) {
const id = String(row?.id ?? '');
if (!/^[1-9]\d{0,8}$/.test(id) || String(feature?.id) !== id) return null;
const point = feature?.points?.[0]?.point;
if (!Array.isArray(point) || point.length !== 2) return null;
const [lat, lon] = point;
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
lat < 53.8 ||
lat > 56.5 ||
lon < 20.8 ||
lon > 26.9
)
return null;
try {
const url = new URL(row.image);
if (
url.origin !== 'https://eismoinfo.lt' ||
url.username ||
url.password ||
url.pathname !== FRAME_PATH ||
url.searchParams.get('id') !== id
)
return null;
} catch {
return null;
}
// Reconstruct so extra query parameters cannot alter the request.
const imageUrl = `https://eismoinfo.lt${FRAME_PATH}?id=${id}`;
const cameraId = `lithuania-${id}`;
const name = String(row.name || feature.name || `Lietuva ${id}`)
.trim()
.slice(0, 160);
return {
id: cameraId,
name,
city: 'Lithuania / Lietuva',
cityId: 'lithuania',
provider: 'Via Lietuva / Eismoinfo',
lat,
lon,
// No calibrated pose is published; use the app's low-confidence prior.
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'eismoinfo-public',
credit:
'Via Lietuva / eismoinfo.lt — periodically updated road-camera stills',
code: cameraDisplayCode(name.toUpperCase()),
};
}
async function readCatalog(url, sibling) {
const signal = AbortSignal.any([
AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
sibling.signal,
]);
try {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
redirect: 'manual',
signal,
});
if (!response.ok) {
await response.body?.cancel().catch(() => {});
throw new Error(
response.status >= 300 && response.status < 400
? 'catalog redirected; redirects are not followed'
: `Catalog HTTP ${response.status}`,
);
}
return await readResponseJsonCapped(
response,
LITHUANIA_MAX_CATALOG_BYTES,
signal,
);
} catch (error) {
// The join needs both catalogs; release the other request at once
// rather than leaving its body open until the timeout.
sibling.abort();
throw error;
}
}
/** Two bounded requests per shared catalog refresh, no API key required. */
export async function loadLithuaniaSources() {
try {
const sibling = new AbortController();
const [rows, layers] = await Promise.all([
readCatalog(LITHUANIA_CAMERAS_URL, sibling),
readCatalog(LITHUANIA_LOCATIONS_URL, sibling),
]);
if (!Array.isArray(rows) || !Array.isArray(layers)) return [];
const features = layers.find((layer) => layer?.layer === 'VKR')?.features;
if (!Array.isArray(features)) return [];
const locations = new Map(
features.filter(Boolean).map((feature) => [String(feature.id), feature]),
);
const cameras = new Map();
for (const row of rows) {
const camera = lithuaniaCameraToSource(
row,
locations.get(String(row?.id)),
);
if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera);
}
const rawCap = Number(process.env.CCTV_LITHUANIA_MAX_SOURCES || 400);
const cap = Number.isFinite(rawCap)
? Math.max(1, Math.min(1000, Math.floor(rawCap)))
: 400;
const sources = prioritizeSources([...cameras.values()], cap, ANCHORS);
console.log(
`[CCTV] Loaded Lithuania camera sources: ${cameras.size} (using ${sources.length})`,
);
return sources;
} catch (error) {
console.warn(
'[CCTV] Lithuania camera download error:',
error?.message || error,
);
return [];
}
}
+198
View File
@@ -0,0 +1,198 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
lithuaniaCameraToSource,
loadLithuaniaSources,
LITHUANIA_CAMERAS_URL,
LITHUANIA_LOCATIONS_URL,
LITHUANIA_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/lithuania.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
const row = {
id: 72,
name: 'Vilnius A1 10,04',
x: 576154,
y: 6056867,
image:
'https://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72',
};
const feature = {
id: '72',
points: [{ point: [54.642550940833566, 25.17981587877114] }],
};
const layers = [{ layer: 'VKR', features: [feature] }];
test('Lithuania joins by ID and uses WGS84 latitude/longitude, never LKS-94 metres', () => {
const camera = lithuaniaCameraToSource(row, feature);
assert.equal(camera.id, 'lithuania-72');
assert.equal(camera.lat, 54.642550940833566);
assert.equal(camera.lon, 25.17981587877114);
assert.equal(camera.name, 'Vilnius A1 10,04');
assert.equal(camera.feedType, 'image');
assert.equal(camera.headingConfidence, 'low');
assert.equal(camera.url, row.image);
assert.match(camera.credit, /Via Lietuva/);
});
test('invalid joins, coordinates and image destinations cannot enter the proxy registry', () => {
for (const invalid of [
null,
{ ...feature, id: '8' },
{ ...feature, points: [] },
{ ...feature, points: [{ point: [25.18, 54.64] }] },
{ ...feature, points: [{ point: [576154, 6056867] }] },
{ ...feature, points: [{ point: [null, 25] }] },
]) {
assert.equal(lithuaniaCameraToSource(row, invalid), null);
}
for (const image of [
'http://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72',
row.image.replace('eismoinfo.lt', 'eismoinfo.lt.evil.test'),
row.image.replace('https://', 'https://user:pass@'),
row.image.replace('id=72', 'id=8'),
'http://127.0.0.1/private',
'',
null,
]) {
assert.equal(lithuaniaCameraToSource({ ...row, image }, feature), null);
}
assert.equal(
lithuaniaCameraToSource(
{ ...row, image: row.image + '&extra=ignored' },
feature,
).url,
row.image,
);
assert.equal(lithuaniaCameraToSource(null, feature), null);
});
function mockCatalogs(t) {
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 (url === LITHUANIA_CAMERAS_URL) {
assert.equal(options.redirect, 'manual');
return Response.json([row, row, { ...row, id: 999 }]);
}
if (url === LITHUANIA_LOCATIONS_URL) return Response.json(layers);
return Response.json([]);
});
return requested;
}
test('loader deduplicates and omits cameras without matching published coordinates', async (t) => {
const requested = mockCatalogs(t);
assert.deepEqual(
(await loadLithuaniaSources()).map((c) => c.id),
['lithuania-72'],
);
assert.deepEqual(
requested.sort(),
[LITHUANIA_CAMERAS_URL, LITHUANIA_LOCATIONS_URL].sort(),
);
});
/** Run one catalog refresh with CCTV_LITHUANIA_ENABLED set to `flag`
* (undefined leaves it unset), restoring the environment afterwards. */
async function runCatalogWithLithuaniaFlag(t, flag) {
const requested = mockCatalogs(t);
const saved = process.env.CCTV_LITHUANIA_ENABLED;
try {
if (flag === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
else process.env.CCTV_LITHUANIA_ENABLED = flag;
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
} finally {
if (saved === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
else process.env.CCTV_LITHUANIA_ENABLED = saved;
}
}
test('CCTV_LITHUANIA_ENABLED=1 wires the Lithuania pack into the catalog', async (t) => {
const { sources } = await runCatalogWithLithuaniaFlag(t, '1');
assert.equal(
sources.find((c) => c.id === 'lithuania-72')?.cityId,
'lithuania',
);
});
// The camera endpoints are the eismoinfo.lt website backend, not a service the
// open-data statement lists, so an install that has not opted in must never
// contact them.
for (const [label, flag] of [
['an unset CCTV_LITHUANIA_ENABLED', undefined],
['an empty CCTV_LITHUANIA_ENABLED', ''],
['CCTV_LITHUANIA_ENABLED=0', '0'],
['CCTV_LITHUANIA_ENABLED=true', 'true'],
]) {
test(`${label} makes no Lithuania request`, async (t) => {
const { requested, sources } = await runCatalogWithLithuaniaFlag(t, flag);
assert.ok(requested.length > 0, 'the other packs still refreshed');
assert.equal(
requested.some((href) => href.includes('eismoinfo.lt')),
false,
'the pack never reaches its upstream',
);
assert.ok(!sources.some((c) => c.cityId === 'lithuania'));
});
}
test('a failed catalog cancels the sibling request instead of waiting for the timeout', async (t) => {
t.mock.method(console, 'warn', () => {});
let siblingSignal;
t.mock.method(globalThis, 'fetch', async (url, options) => {
if (url === LITHUANIA_CAMERAS_URL) {
return new Response('', { status: 503 });
}
siblingSignal = options.signal;
// Never settles on its own; only an abort ends it.
return new Promise((_, reject) => {
options.signal.addEventListener('abort', () =>
reject(options.signal.reason),
);
});
});
assert.deepEqual(await loadLithuaniaSources(), []);
assert.equal(siblingSignal?.aborted, true);
assert.notEqual(siblingSignal.reason?.name, 'TimeoutError');
});
test('a redirected catalog is reported as not followed', async (t) => {
const warnings = [];
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
t.mock.method(
globalThis,
'fetch',
async () =>
new Response('', {
status: 302,
headers: { Location: 'http://127.0.0.1/' },
}),
);
assert.deepEqual(await loadLithuaniaSources(), []);
assert.ok(warnings.some((line) => /redirects are not followed/.test(line)));
});
test('failed, redirected, malformed and oversized catalogs degrade to an empty pack', async (t) => {
t.mock.method(console, 'warn', () => {});
for (const makeResponse of [
() => new Response('', { status: 503 }),
() =>
new Response('', {
status: 302,
headers: { Location: 'http://127.0.0.1/' },
}),
() => new Response('invalid json'),
() => Response.json({ unexpected: true }),
() =>
new Response('[]', {
headers: { 'Content-Length': String(LITHUANIA_MAX_CATALOG_BYTES + 1) },
}),
]) {
t.mock.method(globalThis, 'fetch', async () => makeResponse());
assert.deepEqual(await loadLithuaniaSources(), []);
}
});
+9
View File
@@ -248,6 +248,15 @@ export const DATA_CREDITS = [
'<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' + '<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' +
'license CC BY 4.0', 'license CC BY 4.0',
}, },
{
key: 'lithuania-cctv',
html:
// Opt-in: the camera endpoints are the eismoinfo.lt website backend, not
// among the services Via Lietuva's open-data statement lists (its
// conditions: attribution, reuse on the same terms).
'Road-camera stills (Lithuania, when enabled): ' +
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
},
{ {
key: 'calgary-cctv', key: 'calgary-cctv',
html: html: