mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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.
155 lines
4.9 KiB
JavaScript
155 lines
4.9 KiB
JavaScript
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 [];
|
|
}
|
|
}
|