From 976efed6dc2fa207524def3c90e5cf9683a2323b Mon Sep 17 00:00:00 2001 From: Reloded <37674025+Reloded@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:14:46 +0300 Subject: [PATCH 1/2] feat(cctv): add Lithuania Eismoinfo road cameras --- .env.example | 2 + CHANGELOG.md | 2 + DATA_SOURCES.md | 2 + docs/CURRENT-STATE.md | 14 +++ scripts/format-scope.json | 2 + scripts/package-boundaries.json | 1 + server/providers/cctv/catalog.js | 6 ++ server/providers/cctv/lithuania.js | 135 ++++++++++++++++++++++++++++ src/data/cctvLithuania.test.mjs | 138 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 6 ++ 10 files changed, 308 insertions(+) create mode 100644 server/providers/cctv/lithuania.js create mode 100644 src/data/cctvLithuania.test.mjs diff --git a/.env.example b/.env.example index 5bc02df9c..02fdd78b4 100644 --- a/.env.example +++ b/.env.example @@ -171,6 +171,8 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras # CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL +# CCTV_LITHUANIA_ENABLED=1 # set 0 to disable Via Lietuva / Eismoinfo road cameras +# 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_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ab7807e..a9a9e8687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # 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. + - Keep parked transit vehicles aligned to their world course during camera orbits, fall back to reported bearing, and keep vehicles with no course consistently screen-up. - Separate Realtime connection, response/tool, Radio, input/audio, cost, viewport diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 850d27e39..a2610887b 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -59,6 +59,8 @@ How to read this: ### Notes on the live sources +- **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. The provider's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44) describes reuse of its public data; no specific Creative Commons licence is asserted here. `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. Disable with `CCTV_LITHUANIA_ENABLED=0`; `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)). - **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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1b524a961..9d66da384 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -391,6 +391,20 @@ owners. Tuner calculations have a pure entry, with existing layer exports preserved. Destruction removes listeners and state subscriptions before ending tuning; a delayed Enable result cannot reveal or refocus removed controls. +## Lithuania CCTV source pack + +The CCTV catalog includes Via Lietuva / Eismoinfo road cameras by default. +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_ENABLED=0` disables the pack, and +`CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000). + ## DriveBC CCTV source pack The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7f26e1e71..1bdb1c157 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -85,6 +85,8 @@ "server/providers/cctv/cap.js", "server/providers/cctv/groundHeights.js", "server/providers/cctv/catalog.js", + "server/providers/cctv/lithuania.js", + "src/data/cctvLithuania.test.mjs", "server/providers/cctv/constants.js", "server/providers/cctv/media.js", "server/providers/cctv/normalize.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a5806db67..f40976374 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -156,6 +156,7 @@ "server/providers/cctv/cap.js", "server/providers/cctv/groundHeights.js", "server/providers/cctv/catalog.js", + "server/providers/cctv/lithuania.js", "server/providers/cctv/constants.js", "server/providers/cctv/media.js", "server/providers/cctv/normalize.js", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index a8519c85c..ca8a849ab 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -4,6 +4,7 @@ import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js'; import { allocateSourceCap, resolveCatalogCap } from './cap.js'; import { loadGroundHeights, joinGroundHeights } from './groundHeights.js'; import { normalizeSourceItem } from './normalize.js'; +import { loadLithuaniaSources } from './lithuania.js'; import { loadAustinSourcesFromOpenData, loadCaltransSourcesFromOpenData, @@ -30,6 +31,11 @@ const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0'; * kill switch. */ const LIVE_PACKS = [ + { + name: 'lithuania', + enabled: () => envEnabled('CCTV_LITHUANIA_ENABLED'), + load: loadLithuaniaSources, + }, { name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData }, { name: 'caltrans', diff --git a/server/providers/cctv/lithuania.js b/server/providers/cctv/lithuania.js new file mode 100644 index 000000000..df4ee154d --- /dev/null +++ b/server/providers/cctv/lithuania.js @@ -0,0 +1,135 @@ +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) { + const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS); + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal, + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error(`Catalog HTTP ${response.status}`); + } + return readResponseJsonCapped(response, LITHUANIA_MAX_CATALOG_BYTES, signal); +} + +/** Two bounded requests per shared catalog refresh, no API key required. */ +export async function loadLithuaniaSources() { + try { + const [rows, layers] = await Promise.all([ + readCatalog(LITHUANIA_CAMERAS_URL), + readCatalog(LITHUANIA_LOCATIONS_URL), + ]); + 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 []; + } +} diff --git a/src/data/cctvLithuania.test.mjs b/src/data/cctvLithuania.test.mjs new file mode 100644 index 000000000..675e8d251 --- /dev/null +++ b/src/data/cctvLithuania.test.mjs @@ -0,0 +1,138 @@ +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(), + ); +}); + +test('catalog includes Lithuania by default and respects the disable switch', async (t) => { + const requested = mockCatalogs(t); + const saved = process.env.CCTV_LITHUANIA_ENABLED; + try { + delete process.env.CCTV_LITHUANIA_ENABLED; + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + assert.equal( + sources.find((c) => c.id === 'lithuania-72')?.cityId, + 'lithuania', + ); + requested.length = 0; + process.env.CCTV_LITHUANIA_ENABLED = '0'; + const disabled = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + assert.ok(!disabled.some((c) => c.cityId === 'lithuania')); + assert.ok(!requested.includes(LITHUANIA_CAMERAS_URL)); + } finally { + if (saved === undefined) delete process.env.CCTV_LITHUANIA_ENABLED; + else process.env.CCTV_LITHUANIA_ENABLED = saved; + } +}); + +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(), []); + } +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 76029ace8..d5ee02c54 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -163,6 +163,12 @@ export const DATA_CREDITS = [ 'digitraffic.fi, ' + 'license CC BY 4.0', }, + { + key: 'lithuania-cctv', + html: + 'Road-camera stills (Lithuania): ' + + 'Via Lietuva / Eismoinfo; periodically updated images', + }, { key: 'calgary-cctv', html: From bcfba7719606c334106e84415458b4f10022e0b0 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 15:17:31 -0700 Subject: [PATCH 2/2] fix(cctv): make the Lithuania pack opt-in until its licence is confirmed The camera endpoints are the eismoinfo.lt website backend, which the Via Lietuva open-data statement does not list, so the pack now follows the NYC opt-in pattern (CCTV_LITHUANIA_ENABLED=1) and the docs say why. A failed catalog request now aborts its sibling instead of leaving it open until the timeout, and a redirect is logged as not followed. --- .env.example | 2 +- CHANGELOG.md | 2 +- DATA_SOURCES.md | 2 +- README.md | 2 +- docs/CURRENT-STATE.md | 6 ++- server/providers/cctv/catalog.js | 15 ++++-- server/providers/cctv/lithuania.js | 45 +++++++++++----- src/data/cctvLithuania.test.mjs | 82 ++++++++++++++++++++++++++---- src/data/dataCredits.js | 5 +- 9 files changed, 125 insertions(+), 36 deletions(-) diff --git a/.env.example b/.env.example index 4c9a747cc..b0b4584b2 100644 --- a/.env.example +++ b/.env.example @@ -226,7 +226,7 @@ AISSTREAM_API_KEY= # CCTV_NYC_ENABLED= # off by default; set 1 only if you hold a NYC DOT data-sharing agreement (tmcdot@dot.nyc.gov) for the camera feed # CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first) # CCTV_NYC_CAMERAS_URL= # override the NYC TMC catalog URL -# CCTV_LITHUANIA_ENABLED=1 # set 0 to disable Via Lietuva / Eismoinfo road cameras +# 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_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf70f0ee..55fa38139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # 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. +- 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. - Stop one retired bikeshare feed from reporting the whole layer as failed. City feeds are fetched independently, but failure was recorded in a single diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 6081134e7..96904f2bf 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -91,7 +91,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. -- **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. The provider's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44) describes reuse of its public data; no specific Creative Commons licence is asserted here. `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. Disable with `CCTV_LITHUANIA_ENABLED=0`; `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. - **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. diff --git a/README.md b/README.md index 578ccb185..1950db38c 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,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** | ~3,900 public cameras 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), 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** | ~3,900 public cameras 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), 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) | 🟢 | | 📷 **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 | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index afac040a7..407d52a48 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -981,7 +981,10 @@ round-robin, so adding this pack does not silently drop another region. ## Lithuania CCTV source pack -The CCTV catalog includes Via Lietuva / Eismoinfo road cameras by default. +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 @@ -990,7 +993,6 @@ 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_ENABLED=0` disables the pack, and `CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000). ## DriveBC CCTV source pack diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index c071b655e..7b2f0e75f 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -26,15 +26,18 @@ import { /** Env kill switch: unset or anything but "0" means enabled. */ const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0'; /** 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'; /** * 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 - * round-robin (cap.js), so a new pack never silently evicts an older one. + * plus its loader (in sources.js or its own module); the catalog cap is + * 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 - * 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 = [ { name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData }, @@ -111,8 +114,10 @@ const LIVE_PACKS = [ load: loadNycSourcesFromOpenData, }, { + // Opt-in until the Via Lietuva open-data terms are confirmed to cover the + // camera photos (see DATA_SOURCES.md). name: 'lithuania', - enabled: () => envEnabled('CCTV_LITHUANIA_ENABLED'), + enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'), load: loadLithuaniaSources, }, ]; diff --git a/server/providers/cctv/lithuania.js b/server/providers/cctv/lithuania.js index df4ee154d..840e68c6c 100644 --- a/server/providers/cctv/lithuania.js +++ b/server/providers/cctv/lithuania.js @@ -81,26 +81,45 @@ export function lithuaniaCameraToSource(row, feature) { }; } -async function readCatalog(url) { - const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS); - const response = await fetch(url, { - headers: { Accept: 'application/json' }, - redirect: 'manual', - signal, - }); - if (!response.ok) { - await response.body?.cancel(); - throw new Error(`Catalog HTTP ${response.status}`); +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; } - return readResponseJsonCapped(response, LITHUANIA_MAX_CATALOG_BYTES, signal); } /** 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), - readCatalog(LITHUANIA_LOCATIONS_URL), + 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; diff --git a/src/data/cctvLithuania.test.mjs b/src/data/cctvLithuania.test.mjs index 675e8d251..af28a51f6 100644 --- a/src/data/cctvLithuania.test.mjs +++ b/src/data/cctvLithuania.test.mjs @@ -95,25 +95,85 @@ test('loader deduplicates and omits cameras without matching published coordinat ); }); -test('catalog includes Lithuania by default and respects the disable switch', async (t) => { +/** 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 { - delete process.env.CCTV_LITHUANIA_ENABLED; + if (flag === undefined) delete process.env.CCTV_LITHUANIA_ENABLED; + else process.env.CCTV_LITHUANIA_ENABLED = flag; const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); - assert.equal( - sources.find((c) => c.id === 'lithuania-72')?.cityId, - 'lithuania', - ); - requested.length = 0; - process.env.CCTV_LITHUANIA_ENABLED = '0'; - const disabled = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); - assert.ok(!disabled.some((c) => c.cityId === 'lithuania')); - assert.ok(!requested.includes(LITHUANIA_CAMERAS_URL)); + 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) => { diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 4ad739601..0dbd33b72 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -251,7 +251,10 @@ export const DATA_CREDITS = [ { key: 'lithuania-cctv', html: - 'Road-camera stills (Lithuania): ' + + // 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): ' + 'Via Lietuva / Eismoinfo; periodically updated images', }, {