diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e822ab5a2..83b114819 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -69,5 +69,17 @@ "src/data/tomtomTiles.test.mjs", "src/data/firmsCsv.js", "src/data/firmsCsv.test.mjs", - "src/tooling/environmentProviders.test.mjs" + "src/tooling/environmentProviders.test.mjs", + "server/providers/cctv.js", + "server/providers/cctv/catalog.js", + "server/providers/cctv/constants.js", + "server/providers/cctv/media.js", + "server/providers/cctv/normalize.js", + "server/providers/cctv/sources.js", + "server/providers/radio.js", + "server/providers/radio/catalog.js", + "server/providers/radio/constants.js", + "server/providers/radio/stations.js", + "server/providers/radio/transport.js", + "src/tooling/mediaProviders.test.mjs" ] diff --git a/server/providers/cctv.js b/server/providers/cctv.js index e884a9d72..17235e97c 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -1,6 +1,14 @@ import { createCctvCatalog } from './cctv/catalog.js'; -import { normalizeFeedType, isVideoFeedType, toFiniteNumber } from './cctv/normalize.js'; -import { buildSyntheticCctvSvg, proxyMediaResponse, fetchCctvImageFromUpstream } from './cctv/media.js'; +import { + normalizeFeedType, + isVideoFeedType, + toFiniteNumber, +} from './cctv/normalize.js'; +import { + buildSyntheticCctvSvg, + proxyMediaResponse, + fetchCctvImageFromUpstream, +} from './cctv/media.js'; import { CCTV_FRAME_FETCH_TIMEOUT_MS } from './cctv/constants.js'; import { googleServerApiKey } from './places/google-key.js'; export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream }; @@ -58,7 +66,8 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { : null, frameUrl: `/api/cctv/frame/${encodeURIComponent(cameraId)}`, provider: source?.provider || '', - sourceKind: source?.sourceKind || (source?.url ? 'configured' : 'fallback'), + sourceKind: + source?.sourceKind || (source?.url ? 'configured' : 'fallback'), }; }; @@ -71,14 +80,24 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { */ const streetViewFallback = async ({ lat, lon, heading, fov, pitch }) => { const streetViewKey = googleServerApiKey(); - if (!streetViewKey || !Number.isFinite(lat) || !Number.isFinite(lon)) return null; + if (!streetViewKey || !Number.isFinite(lat) || !Number.isFinite(lon)) + return null; try { const sv = new URL('https://maps.googleapis.com/maps/api/streetview'); sv.searchParams.set('size', '960x540'); sv.searchParams.set('location', `${lat},${lon}`); - sv.searchParams.set('heading', String(Number.isFinite(heading) ? heading : 0)); - sv.searchParams.set('fov', String(Number.isFinite(fov) ? Math.max(20, Math.min(120, fov)) : 80)); - sv.searchParams.set('pitch', String(Number.isFinite(pitch) ? Math.max(-40, Math.min(20, pitch)) : 0)); + sv.searchParams.set( + 'heading', + String(Number.isFinite(heading) ? heading : 0), + ); + sv.searchParams.set( + 'fov', + String(Number.isFinite(fov) ? Math.max(20, Math.min(120, fov)) : 80), + ); + sv.searchParams.set( + 'pitch', + String(Number.isFinite(pitch) ? Math.max(-40, Math.min(20, pitch)) : 0), + ); sv.searchParams.set('source', 'outdoor'); sv.searchParams.set('return_error_code', 'true'); sv.searchParams.set('key', streetViewKey); @@ -106,7 +125,9 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { server.middlewares.use('/api/cctv', async (req, res) => { try { const sources = await getCctvSources(); - const sourceById = new Map(sources.map((source) => [source.id, source])); + const sourceById = new Map( + sources.map((source) => [source.id, source]), + ); const url = new URL(req.url || '/', 'http://localhost'); if (url.pathname === '/sources') { @@ -127,33 +148,47 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { mountHeightM: source.mountHeightM, groundElevationM: source.groundElevationM, feedType: normalizeFeedType(source.feedType), - sourceKind: source.sourceKind || (source.url ? 'configured' : 'fallback'), + sourceKind: + source.sourceKind || (source.url ? 'configured' : 'fallback'), poseSource: source.poseSource, license: source.license, })), }; - res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify(body)); return; } if (url.pathname === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify({ cameras: listHealth() })); return; } if (url.pathname.startsWith('/stream/')) { - const cameraId = decodeURIComponent(url.pathname.replace('/stream/', '').trim()) || 'camera'; + const cameraId = + decodeURIComponent(url.pathname.replace('/stream/', '').trim()) || + 'camera'; const source = sourceById.get(cameraId); const payload = buildStreamPayload(source, cameraId); - res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify(payload)); return; } if (url.pathname.startsWith('/media/')) { - const cameraId = decodeURIComponent(url.pathname.replace('/media/', '').trim()) || 'camera'; + const cameraId = + decodeURIComponent(url.pathname.replace('/media/', '').trim()) || + 'camera'; const source = sourceById.get(cameraId); const mediaUrl = source?.url || ''; const feedType = normalizeFeedType(source?.feedType || 'image'); @@ -165,13 +200,22 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { label: source?.provider || 'No upstream URL', message: 'No stream URL configured', }); - res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); - res.end(JSON.stringify({ error: 'No media URL configured for this camera' })); + res.writeHead(404, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: 'No media URL configured for this camera', + }), + ); return; } try { - const upstreamHeaders = { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' }; + const upstreamHeaders = { + 'User-Agent': 'gods-eye-view-cctv-proxy/1.0', + }; const requestRange = req.headers?.range; if (requestRange) upstreamHeaders.Range = requestRange; const upstream = await fetch(mediaUrl, { @@ -185,12 +229,25 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { label: source?.provider || 'Configured source', message: `Upstream HTTP ${upstream.status}`, }); - res.writeHead(upstream.status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); - res.end(JSON.stringify({ error: `Upstream returned ${upstream.status}` })); + res.writeHead(upstream.status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: `Upstream returned ${upstream.status}`, + }), + ); return; } - if (isVideoFeedType(feedType) && !(contentType.startsWith('video/') || contentType.includes('mpegurl'))) { + if ( + isVideoFeedType(feedType) && + !( + contentType.startsWith('video/') || + contentType.includes('mpegurl') + ) + ) { setHealth(cameraId, { status: 'degraded', sourceKind: 'upstream', @@ -202,12 +259,16 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { status: 'ok', sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot', label: source?.provider || 'Configured source', - message: isVideoFeedType(feedType) ? 'Live stream connected' : 'Snapshot feed connected', + message: isVideoFeedType(feedType) + ? 'Live stream connected' + : 'Snapshot feed connected', }); } await proxyMediaResponse(res, upstream, { - sourceHeader: isVideoFeedType(feedType) ? 'live-media' : 'upstream-image', + sourceHeader: isVideoFeedType(feedType) + ? 'live-media' + : 'upstream-image', }); return; } catch (error) { @@ -217,7 +278,10 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { label: source?.provider || 'Configured source', message: error?.message || 'Media fetch failed', }); - res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify({ error: 'Media proxy failed' })); return; } @@ -229,23 +293,33 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { return; } - const cameraId = decodeURIComponent(url.pathname.replace('/frame/', '').trim()) || 'camera'; + const cameraId = + decodeURIComponent(url.pathname.replace('/frame/', '').trim()) || + 'camera'; const source = sourceById.get(cameraId); - const label = url.searchParams.get('label') || source?.name || cameraId; + const label = + url.searchParams.get('label') || source?.name || cameraId; const city = url.searchParams.get('city') || source?.city || ''; const lat = Number(url.searchParams.get('lat') || source?.lat); const lon = Number(url.searchParams.get('lon') || source?.lon); - const heading = Number(url.searchParams.get('heading') || source?.headingDeg); + const heading = Number( + url.searchParams.get('heading') || source?.headingDeg, + ); const fov = Number(url.searchParams.get('fov') || source?.fovDeg); - const pitch = Number(url.searchParams.get('pitch') || source?.pitchDeg); + const pitch = Number( + url.searchParams.get('pitch') || source?.pitchDeg, + ); // Only use server-registered upstream URLs — never accept client-supplied URLs // (prevents SSRF via ?upstream= query parameter) const upstreamCandidate = - source?.snapshotUrl - || (!isVideoFeedType(normalizeFeedType(source?.feedType)) ? source?.url : ''); + source?.snapshotUrl || + (!isVideoFeedType(normalizeFeedType(source?.feedType)) + ? source?.url + : ''); - const upstreamImage = await fetchCctvImageFromUpstream(upstreamCandidate); + const upstreamImage = + await fetchCctvImageFromUpstream(upstreamCandidate); if (upstreamImage?.ok) { setHealth(cameraId, { status: 'ok', @@ -262,7 +336,13 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { return; } - const sv = await streetViewFallback({ lat, lon, heading, fov, pitch }); + const sv = await streetViewFallback({ + lat, + lon, + heading, + fov, + pitch, + }); if (sv?.ok) { setHealth(cameraId, { status: 'degraded', @@ -283,14 +363,18 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { cameraId, label, city, - status: source?.url ? 'UPSTREAM UNAVAILABLE' : 'NO UPSTREAM CONFIGURED', + status: source?.url + ? 'UPSTREAM UNAVAILABLE' + : 'NO UPSTREAM CONFIGURED', }); setHealth(cameraId, { status: 'degraded', sourceKind: 'synthetic', label: source?.provider || 'Synthetic fallback', - message: source?.url ? 'Upstream unavailable' : 'No source configured', + message: source?.url + ? 'Upstream unavailable' + : 'No source configured', }); res.writeHead(200, { diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index e27d06528..a883de6a1 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -1,8 +1,16 @@ import fs from 'node:fs'; import path from 'node:path'; -import { DEFAULT_CCTV_SOURCE_FILE, DEFAULT_CCTV_MAX_SOURCES, CCTV_SOURCE_CACHE_MS } from './constants.js'; +import { + DEFAULT_CCTV_SOURCE_FILE, + DEFAULT_CCTV_MAX_SOURCES, + CCTV_SOURCE_CACHE_MS, +} from './constants.js'; import { normalizeSourceItem } from './normalize.js'; -import { loadAustinSourcesFromOpenData, loadCaltransSourcesFromOpenData, loadTflSourcesFromOpenData } from './sources.js'; +import { + loadAustinSourcesFromOpenData, + loadCaltransSourcesFromOpenData, + loadTflSourcesFromOpenData, +} from './sources.js'; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). * @@ -19,7 +27,11 @@ function loadSourcesFromFile(sourceRoot) { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch (error) { - console.warn('[CCTV] failed to read source file:', resolved, error?.message || error); + console.warn( + '[CCTV] failed to read source file:', + resolved, + error?.message || error, + ); return []; } } @@ -42,99 +54,128 @@ function loadSourcesFromEnv() { /** Create an independent catalog rooted in the consuming application. */ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { -/** @type {Array} Cached merged + normalized CCTV source list. */ -let _cctvSourceCache = []; -/** @type {number} Epoch-ms when the source cache was last refreshed. */ -let _cctvSourceCacheAt = 0; -/** @type {Promise>|null} In-flight refresh, shared by concurrent - * callers so a post-TTL burst launches ONE refetch, not one per request. */ -let _cctvSourceInflight = null; + /** @type {Array} Cached merged + normalized CCTV source list. */ + let _cctvSourceCache = []; + /** @type {number} Epoch-ms when the source cache was last refreshed. */ + let _cctvSourceCacheAt = 0; + /** @type {Promise>|null} In-flight refresh, shared by concurrent + * callers so a post-TTL burst launches ONE refetch, not one per request. */ + let _cctvSourceInflight = null; -/** - * Assemble and cache the merged CCTV source list. - * - * Merges sources from three origins (Austin Open Data, local file, - * env variable), deduplicates by ID, applies the global max cap, and - * caches for CCTV_SOURCE_CACHE_MS. - * - * @returns {Promise>} Deduplicated, capped source list. - */ -async function getCctvSources() { - const now = Date.now(); - if (_cctvSourceCache.length && now - _cctvSourceCacheAt <= CCTV_SOURCE_CACHE_MS) { + /** + * Assemble and cache the merged CCTV source list. + * + * Merges sources from three origins (Austin Open Data, local file, + * env variable), deduplicates by ID, applies the global max cap, and + * caches for CCTV_SOURCE_CACHE_MS. + * + * @returns {Promise>} Deduplicated, capped source list. + */ + async function getCctvSources() { + const now = Date.now(); + if ( + _cctvSourceCache.length && + now - _cctvSourceCacheAt <= CCTV_SOURCE_CACHE_MS + ) { + return _cctvSourceCache; + } + // Single-flight: a burst of requests arriving past the TTL shares ONE refresh + // instead of each launching the full multi-provider refetch. The `.finally` + // clears the ref so the next post-TTL cycle starts fresh. + if (_cctvSourceInflight) return _cctvSourceInflight; + _cctvSourceInflight = refreshCctvSources().finally(() => { + _cctvSourceInflight = null; + }); + return _cctvSourceInflight; + } + + /** + * Assemble and cache the merged CCTV source list from file/env + live packs. + * Always resolves (loaders self-catch to []); on a fully-empty refresh with a + * good prior catalog it serves stale rather than blanking the CCTV layer. + * + * @returns {Promise>} Deduplicated, capped source list. + */ + async function refreshCctvSources() { + const fromFile = loadSourcesFromFile(sourceRoot); + const fromEnv = loadSourcesFromEnv(); + + const forceAustin = + String(process.env.CCTV_FORCE_AUSTIN || '').trim() === '1'; + const preferAustin = + String(process.env.CCTV_PREFER_AUSTIN || '1').trim() !== '0'; + // Live open-data packs (Austin + Caltrans + TfL) load unless a file/env pack + // is configured and live packs aren't forced — same gate that governed the + // Austin-only fetch, now governing all three. Each pack fails independently. + const needsLiveSources = + forceAustin || (fromFile.length + fromEnv.length === 0 && preferAustin); + const tflEnabled = + String(process.env.CCTV_TFL_ENABLED || '1').trim() !== '0'; + + let fromAustin = []; + let fromCaltrans = []; + let fromTfl = []; + if (needsLiveSources) { + const [austinResult, caltransResult, tflResult] = + await Promise.allSettled([ + loadAustinSourcesFromOpenData(), + loadCaltransSourcesFromOpenData(), + tflEnabled ? loadTflSourcesFromOpenData() : Promise.resolve([]), + ]); + fromAustin = + austinResult.status === 'fulfilled' ? austinResult.value : []; + fromCaltrans = + caltransResult.status === 'fulfilled' ? caltransResult.value : []; + fromTfl = tflResult.status === 'fulfilled' ? tflResult.value : []; + } + // Live sources first so file/env overrides win on duplicate IDs (Map last-write). + const merged = [ + ...fromAustin, + ...fromCaltrans, + ...fromTfl, + ...fromFile, + ...fromEnv, + ]; + + // Deduplicate by camera ID (last-write wins because of Map.set) + const byId = new Map(); + for (const item of merged) { + if (!item || typeof item !== 'object') continue; + const normalized = normalizeSourceItem(item); + if (!normalized.id) continue; + byId.set(normalized.id, normalized); + } + + const mergedSources = Array.from(byId.values()); + const maxRaw = Number( + process.env.CCTV_MAX_SOURCES || DEFAULT_CCTV_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(1200, Math.floor(maxRaw))) + : DEFAULT_CCTV_MAX_SOURCES; + if (mergedSources.length > maxCount) { + console.warn( + `[CCTV] source catalog ${mergedSources.length} exceeds cap ${maxCount}; keeping the first ${maxCount} (raise CCTV_MAX_SOURCES or lower a per-pack cap to change which).`, + ); + } + const capped = + mergedSources.length > maxCount + ? mergedSources.slice(0, maxCount) + : mergedSources; + if (capped.length > 0 || _cctvSourceCache.length === 0) { + _cctvSourceCache = capped; + } else { + // Every source came back empty (all live packs timed out / upstream outage) + // but a good catalog is already cached — serve it stale rather than blanking + // every CCTV route. Advancing the timestamp waits one TTL before retrying, + // which (with single-flight) bounds load on a persistently-down upstream. + console.warn( + `[CCTV] source refresh returned empty; serving ${_cctvSourceCache.length} stale cameras`, + ); + } + _cctvSourceCacheAt = Date.now(); return _cctvSourceCache; } - // Single-flight: a burst of requests arriving past the TTL shares ONE refresh - // instead of each launching the full multi-provider refetch. The `.finally` - // clears the ref so the next post-TTL cycle starts fresh. - if (_cctvSourceInflight) return _cctvSourceInflight; - _cctvSourceInflight = refreshCctvSources().finally(() => { _cctvSourceInflight = null; }); - return _cctvSourceInflight; -} -/** - * Assemble and cache the merged CCTV source list from file/env + live packs. - * Always resolves (loaders self-catch to []); on a fully-empty refresh with a - * good prior catalog it serves stale rather than blanking the CCTV layer. - * - * @returns {Promise>} Deduplicated, capped source list. - */ -async function refreshCctvSources() { - const fromFile = loadSourcesFromFile(sourceRoot); - const fromEnv = loadSourcesFromEnv(); - - const forceAustin = String(process.env.CCTV_FORCE_AUSTIN || '').trim() === '1'; - const preferAustin = String(process.env.CCTV_PREFER_AUSTIN || '1').trim() !== '0'; - // Live open-data packs (Austin + Caltrans + TfL) load unless a file/env pack - // is configured and live packs aren't forced — same gate that governed the - // Austin-only fetch, now governing all three. Each pack fails independently. - const needsLiveSources = forceAustin || ((fromFile.length + fromEnv.length) === 0 && preferAustin); - const tflEnabled = String(process.env.CCTV_TFL_ENABLED || '1').trim() !== '0'; - - let fromAustin = []; - let fromCaltrans = []; - let fromTfl = []; - if (needsLiveSources) { - const [austinResult, caltransResult, tflResult] = await Promise.allSettled([ - loadAustinSourcesFromOpenData(), - loadCaltransSourcesFromOpenData(), - tflEnabled ? loadTflSourcesFromOpenData() : Promise.resolve([]), - ]); - fromAustin = austinResult.status === 'fulfilled' ? austinResult.value : []; - fromCaltrans = caltransResult.status === 'fulfilled' ? caltransResult.value : []; - fromTfl = tflResult.status === 'fulfilled' ? tflResult.value : []; - } - // Live sources first so file/env overrides win on duplicate IDs (Map last-write). - const merged = [...fromAustin, ...fromCaltrans, ...fromTfl, ...fromFile, ...fromEnv]; - - // Deduplicate by camera ID (last-write wins because of Map.set) - const byId = new Map(); - for (const item of merged) { - if (!item || typeof item !== 'object') continue; - const normalized = normalizeSourceItem(item); - if (!normalized.id) continue; - byId.set(normalized.id, normalized); - } - - const mergedSources = Array.from(byId.values()); - const maxRaw = Number(process.env.CCTV_MAX_SOURCES || DEFAULT_CCTV_MAX_SOURCES); - const maxCount = Number.isFinite(maxRaw) ? Math.max(8, Math.min(1200, Math.floor(maxRaw))) : DEFAULT_CCTV_MAX_SOURCES; - if (mergedSources.length > maxCount) { - console.warn(`[CCTV] source catalog ${mergedSources.length} exceeds cap ${maxCount}; keeping the first ${maxCount} (raise CCTV_MAX_SOURCES or lower a per-pack cap to change which).`); - } - const capped = mergedSources.length > maxCount ? mergedSources.slice(0, maxCount) : mergedSources; - if (capped.length > 0 || _cctvSourceCache.length === 0) { - _cctvSourceCache = capped; - } else { - // Every source came back empty (all live packs timed out / upstream outage) - // but a good catalog is already cached — serve it stale rather than blanking - // every CCTV route. Advancing the timestamp waits one TTL before retrying, - // which (with single-flight) bounds load on a persistently-down upstream. - console.warn(`[CCTV] source refresh returned empty; serving ${_cctvSourceCache.length} stale cameras`); - } - _cctvSourceCacheAt = Date.now(); - return _cctvSourceCache; -} - -return getCctvSources; + return getCctvSources; } diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index b8d733348..2ab5f5715 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -1,6 +1,7 @@ export const DEFAULT_CCTV_SOURCE_FILE = 'config/cctv_sources.austin.json'; /** Austin Open Data portal endpoint for traffic camera records. */ -export const DEFAULT_AUSTIN_ROWS_URL = 'https://data.austintexas.gov/api/views/b4k4-adkb/rows.json?accessType=DOWNLOAD'; +export const DEFAULT_AUSTIN_ROWS_URL = + 'https://data.austintexas.gov/api/views/b4k4-adkb/rows.json?accessType=DOWNLOAD'; /** Default cap on Austin cameras after distance-based prioritization. */ export const DEFAULT_AUSTIN_MAX_SOURCES = 250; /** Global cap on total CCTV sources served by the proxy. */ @@ -22,7 +23,8 @@ export const CALTRANS_ANCHORS = [ ]; /** TfL JamCams: one keyless list endpoint; frames live on a public S3 bucket. */ export const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam'; -export const TFL_IMAGE_ORIGIN = 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/'; +export const TFL_IMAGE_ORIGIN = + 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/'; export const DEFAULT_TFL_MAX_SOURCES = 250; export const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 }; /** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL) infrequent. Frames are fetched per-request and are unaffected. */ diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index e0c298e1a..e7684dacc 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -97,8 +97,13 @@ export function toReadable(body) { * @param {object} [opts] * @param {string} [opts.sourceHeader='upstream'] - Value for X-CCTV-Source header. */ -export async function proxyMediaResponse(res, upstream, { sourceHeader = 'upstream' } = {}) { - const contentType = upstream.headers.get('content-type') || 'application/octet-stream'; +export async function proxyMediaResponse( + res, + upstream, + { sourceHeader = 'upstream' } = {}, +) { + const contentType = + upstream.headers.get('content-type') || 'application/octet-stream'; const cacheControl = upstream.headers.get('cache-control') || 'no-store'; const contentLength = upstream.headers.get('content-length'); const contentRange = upstream.headers.get('content-range'); @@ -116,10 +121,20 @@ export async function proxyMediaResponse(res, upstream, { sourceHeader = 'upstre // Live MJPEG/HLS streams are unbounded by design and send no content-length, // so they pipe normally (piping streams to the client, never buffering). const MEDIA_DECLARED_CAP_BYTES = 64 * 1024 * 1024; - if (Number.isFinite(Number(contentLength)) && Number(contentLength) > MEDIA_DECLARED_CAP_BYTES) { - res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + if ( + Number.isFinite(Number(contentLength)) && + Number(contentLength) > MEDIA_DECLARED_CAP_BYTES + ) { + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify({ error: 'Upstream media exceeds size cap' })); - try { await upstream.body?.cancel(); } catch { /* no-op */ } + try { + await upstream.body?.cancel(); + } catch { + /* no-op */ + } return; } @@ -151,14 +166,16 @@ export async function proxyMediaResponse(res, upstream, { sourceHeader = 'upstre * @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS] - Abort timeout. * @returns {Promise<{ok:true,body:Buffer,contentType:string}|null>} */ -export async function fetchCctvImageFromUpstream(url, { - fetchImpl = fetch, - timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS, -} = {}) { +export async function fetchCctvImageFromUpstream( + url, + { fetchImpl = fetch, timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS } = {}, +) { if (!url || !/^https?:\/\//i.test(url)) return null; const controller = new AbortController(); const timeoutId = setTimeout(() => { - controller.abort(new DOMException('CCTV upstream frame fetch timed out', 'TimeoutError')); + controller.abort( + new DOMException('CCTV upstream frame fetch timed out', 'TimeoutError'), + ); }, timeoutMs); try { const upstream = await fetchImpl(url, { diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 24efbe25a..4d9fde95d 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -39,7 +39,9 @@ export function escapeXml(text) { * @returns {string} Normalized feed type. */ export function normalizeFeedType(value) { - const raw = String(value || '').trim().toLowerCase(); + const raw = String(value || '') + .trim() + .toLowerCase(); if (!raw) return 'image'; if (raw === 'jpeg' || raw === 'jpg' || raw === 'png') return 'image'; if (raw === 'mjpg') return 'mjpeg'; @@ -93,7 +95,9 @@ export function normalizeKey(text) { * @returns {{lat:number, lon:number}} */ export function parsePointString(value) { - const match = String(value || '').match(/POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i); + const match = String(value || '').match( + /POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i, + ); if (!match) return { lat: NaN, lon: NaN }; return { lon: toFiniteNumber(match[1]), @@ -123,11 +127,16 @@ export function coerceLatLon(value) { const lat = toFiniteNumber( value.latitude ?? value.lat ?? value.y ?? value.Latitude ?? value.Lat, - NaN + NaN, ); const lon = toFiniteNumber( - value.longitude ?? value.lon ?? value.lng ?? value.x ?? value.Longitude ?? value.Lon, - NaN + value.longitude ?? + value.lon ?? + value.lng ?? + value.x ?? + value.Longitude ?? + value.Lon, + NaN, ); return { lat, lon }; } @@ -152,16 +161,24 @@ export function extractAustinCoords(record) { ]; for (const candidate of candidates) { const parsed = coerceLatLon(candidate); - if (Number.isFinite(parsed.lat) && Number.isFinite(parsed.lon)) return parsed; + if (Number.isFinite(parsed.lat) && Number.isFinite(parsed.lon)) + return parsed; } const lat = toFiniteNumber( - record.latitude ?? record.lat ?? record.camera_latitude ?? record.location_latitude, - NaN + record.latitude ?? + record.lat ?? + record.camera_latitude ?? + record.location_latitude, + NaN, ); const lon = toFiniteNumber( - record.longitude ?? record.lon ?? record.lng ?? record.camera_longitude ?? record.location_longitude, - NaN + record.longitude ?? + record.lon ?? + record.lng ?? + record.camera_longitude ?? + record.location_longitude, + NaN, ); return { lat, lon }; } @@ -239,11 +256,19 @@ export function extractAustinName(record, cameraId) { * @returns {number} Heading in degrees [0..360), or NaN if unknown. */ export function extractAustinHeading(record) { - const direct = toFiniteNumber(record.heading_deg ?? record.heading ?? record.bearing, NaN); + const direct = toFiniteNumber( + record.heading_deg ?? record.heading ?? record.bearing, + NaN, + ); if (Number.isFinite(direct)) return ((direct % 360) + 360) % 360; // Dedicated direction fields: bare cardinal words ("West") are real facings. - const directionKeys = ['direction', 'travel_direction', 'facing', 'facing_direction']; + const directionKeys = [ + 'direction', + 'travel_direction', + 'facing', + 'facing_direction', + ]; for (const key of directionKeys) { const heading = directionToHeading(record[key], true); if (Number.isFinite(heading)) return heading; @@ -260,7 +285,9 @@ export function extractAustinHeading(record) { record.cross_street, record.description, record.name, - ].filter(Boolean).join(' '); + ] + .filter(Boolean) + .join(' '); const inferred = directionToHeading(nameProbe); if (Number.isFinite(inferred)) return inferred; @@ -276,7 +303,7 @@ export function extractAustinHeading(record) { */ export function isLikelyAustinCoordinate(lat, lon) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; - return lat >= 30.02 && lat <= 30.58 && lon >= -98.12 && lon <= -97.40; + return lat >= 30.02 && lat <= 30.58 && lon >= -98.12 && lon <= -97.4; } /** @@ -324,18 +351,26 @@ export function rowArrayToObject(row, columns) { export function prioritizeSources(cameras, maxCount, anchors) { const list = Array.isArray(cameras) ? cameras : []; const anchorList = (Array.isArray(anchors) ? anchors : []).filter( - (a) => Number.isFinite(a?.lat) && Number.isFinite(a?.lon) + (a) => Number.isFinite(a?.lat) && Number.isFinite(a?.lon), ); - if (!Number.isFinite(maxCount) || maxCount <= 0 || list.length <= maxCount || !anchorList.length) { + if ( + !Number.isFinite(maxCount) || + maxCount <= 0 || + list.length <= maxCount || + !anchorList.length + ) { return list; } const scored = list.map((camera, idx) => { const lat = Number(camera?.lat); const lon = Number(camera?.lon); - const distKm = Number.isFinite(lat) && Number.isFinite(lon) - ? Math.min(...anchorList.map((a) => haversineKm(lat, lon, a.lat, a.lon))) - : Number.POSITIVE_INFINITY; + const distKm = + Number.isFinite(lat) && Number.isFinite(lon) + ? Math.min( + ...anchorList.map((a) => haversineKm(lat, lon, a.lat, a.lon)), + ) + : Number.POSITIVE_INFINITY; return { camera, idx, distKm }; }); @@ -363,7 +398,9 @@ export function normalizeSourceItem(item) { lat: toFiniteNumber(item.lat), lon: toFiniteNumber(item.lon), headingDeg: toFiniteNumber(item.headingDeg), - headingConfidence: String(item.headingConfidence || item.headingSource || '').toLowerCase(), + headingConfidence: String( + item.headingConfidence || item.headingSource || '', + ).toLowerCase(), pitchDeg: toFiniteNumber(item.pitchDeg), fovDeg: toFiniteNumber(item.fovDeg), rangeM: toFiniteNumber(item.rangeM), diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 81da1574a..111029564 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -1,5 +1,28 @@ -import { DEFAULT_AUSTIN_ROWS_URL, DEFAULT_AUSTIN_MAX_SOURCES, AUSTIN_DOWNTOWN, CALTRANS_CCTV_URL, DEFAULT_CALTRANS_DISTRICTS, DEFAULT_CALTRANS_MAX_SOURCES, CALTRANS_ANCHORS, TFL_JAMCAM_URL, TFL_IMAGE_ORIGIN, DEFAULT_TFL_MAX_SOURCES, LONDON_CENTER, CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js'; -import { toFiniteNumber, extractAustinCoords, extractAustinCameraId, extractAustinName, extractAustinHeading, isLikelyAustinCoordinate, fallbackHeadingFromId, rowArrayToObject, prioritizeSources } from './normalize.js'; +import { + DEFAULT_AUSTIN_ROWS_URL, + DEFAULT_AUSTIN_MAX_SOURCES, + AUSTIN_DOWNTOWN, + CALTRANS_CCTV_URL, + DEFAULT_CALTRANS_DISTRICTS, + DEFAULT_CALTRANS_MAX_SOURCES, + CALTRANS_ANCHORS, + TFL_JAMCAM_URL, + TFL_IMAGE_ORIGIN, + DEFAULT_TFL_MAX_SOURCES, + LONDON_CENTER, + CCTV_SOURCE_FETCH_TIMEOUT_MS, +} from './constants.js'; +import { + toFiniteNumber, + extractAustinCoords, + extractAustinCameraId, + extractAustinName, + extractAustinHeading, + isLikelyAustinCoordinate, + fallbackHeadingFromId, + rowArrayToObject, + prioritizeSources, +} from './normalize.js'; import { directionToHeading } from '../../../src/data/directionText.js'; /** * Fetch and parse Austin traffic camera records from the city Open Data portal. @@ -14,13 +37,18 @@ import { directionToHeading } from '../../../src/data/directionText.js'; export async function loadAustinSourcesFromOpenData() { const endpoint = process.env.CCTV_AUSTIN_ROWS_URL || DEFAULT_AUSTIN_ROWS_URL; try { - const resp = await fetch(endpoint, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS) }); + const resp = await fetch(endpoint, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); if (!resp.ok) { console.warn('[CCTV] Austin source download failed:', resp.status); return []; } const payload = await resp.json(); - const columns = Array.isArray(payload?.meta?.view?.columns) ? payload.meta.view.columns : []; + const columns = Array.isArray(payload?.meta?.view?.columns) + ? payload.meta.view.columns + : []; const rows = Array.isArray(payload?.data) ? payload.data : []; if (!columns.length || !rows.length) return []; @@ -35,7 +63,9 @@ export async function loadAustinSourcesFromOpenData() { // REMOVED and VOID rows whose frame URLs never resolve — those cameras // would render as permanent Street View / synthetic fallbacks. Tolerate // a missing column (keep the row) so a schema change fails open. - const status = String(record.camera_status || '').trim().toUpperCase(); + const status = String(record.camera_status || '') + .trim() + .toUpperCase(); if (status && status !== 'TURNED_ON') continue; const { lat, lon } = extractAustinCoords(record); @@ -44,7 +74,9 @@ export async function loadAustinSourcesFromOpenData() { const extractedHeading = extractAustinHeading(record); const hasHeading = Number.isFinite(extractedHeading); - const headingDeg = hasHeading ? extractedHeading : fallbackHeadingFromId(cameraId); + const headingDeg = hasHeading + ? extractedHeading + : fallbackHeadingFromId(cameraId); cameras.push({ id: cameraId, name: extractAustinName(record, cameraId), @@ -68,18 +100,29 @@ export async function loadAustinSourcesFromOpenData() { }); } - const unique = Array.from(new Map(cameras.map((camera) => [camera.id, camera])).values()); - const maxRaw = Number(process.env.CCTV_AUSTIN_MAX_SOURCES || DEFAULT_AUSTIN_MAX_SOURCES); - const maxCount = Number.isFinite(maxRaw) ? Math.max(8, Math.min(300, Math.floor(maxRaw))) : DEFAULT_AUSTIN_MAX_SOURCES; + const unique = Array.from( + new Map(cameras.map((camera) => [camera.id, camera])).values(), + ); + const maxRaw = Number( + process.env.CCTV_AUSTIN_MAX_SOURCES || DEFAULT_AUSTIN_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(300, Math.floor(maxRaw))) + : DEFAULT_AUSTIN_MAX_SOURCES; const prioritized = prioritizeSources(unique, maxCount, [AUSTIN_DOWNTOWN]); if (prioritized.length < unique.length) { - console.log(`[CCTV] Loaded Austin camera sources: ${unique.length} (using nearest ${prioritized.length})`); + console.log( + `[CCTV] Loaded Austin camera sources: ${unique.length} (using nearest ${prioritized.length})`, + ); } else { console.log('[CCTV] Loaded Austin camera sources:', prioritized.length); } return prioritized; } catch (error) { - console.warn('[CCTV] Austin source download error:', error?.message || error); + console.warn( + '[CCTV] Austin source download error:', + error?.message || error, + ); return []; } } @@ -96,7 +139,8 @@ export async function loadAustinSourcesFromOpenData() { * @returns {Promise>} Normalized camera source objects. */ export async function loadCaltransSourcesFromOpenData() { - const districtsRaw = process.env.CCTV_CALTRANS_DISTRICTS ?? DEFAULT_CALTRANS_DISTRICTS; + const districtsRaw = + process.env.CCTV_CALTRANS_DISTRICTS ?? DEFAULT_CALTRANS_DISTRICTS; const districts = String(districtsRaw) .split(',') .map((token) => Number(token.trim())) @@ -105,18 +149,24 @@ export async function loadCaltransSourcesFromOpenData() { const settled = await Promise.allSettled( districts.map(async (district) => { - const resp = await fetch(CALTRANS_CCTV_URL(district), { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS) }); + const resp = await fetch(CALTRANS_CCTV_URL(district), { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); if (!resp.ok) throw new Error(`D${district} HTTP ${resp.status}`); const payload = await resp.json(); const rows = Array.isArray(payload?.data) ? payload.data : []; return { district, rows }; - }) + }), ); const cameras = []; for (const result of settled) { if (result.status !== 'fulfilled') { - console.warn('[CCTV] Caltrans district fetch failed:', result.reason?.message || result.reason); + console.warn( + '[CCTV] Caltrans district fetch failed:', + result.reason?.message || result.reason, + ); continue; } const { district, rows } = result.value; @@ -135,13 +185,17 @@ export async function loadCaltransSourcesFromOpenData() { const locationName = String(loc.locationName || '').trim(); // Leading token of locationName is the stable camera code ("TV102 -- I-580 : …"). const codeMatch = /^([A-Za-z0-9_-]+)\s*--/.exec(locationName); - const code = (codeMatch ? codeMatch[1] : `x${cameras.length}`).toLowerCase(); + const code = ( + codeMatch ? codeMatch[1] : `x${cameras.length}` + ).toLowerCase(); const cameraId = `ca-d${district}-${code}`; // loc.direction is a dedicated field ("West", "South") → allow bare words. const heading = directionToHeading(loc.direction, true); const hasHeading = Number.isFinite(heading); - const label = locationName.replace(/^([A-Za-z0-9_-]+)\s*--\s*/, '') || `Caltrans D${district} ${code}`; + const label = + locationName.replace(/^([A-Za-z0-9_-]+)\s*--\s*/, '') || + `Caltrans D${district} ${code}`; cameras.push({ id: cameraId, name: loc.nearbyPlace ? `${label} (${loc.nearbyPlace})` : label, @@ -168,7 +222,9 @@ export async function loadCaltransSourcesFromOpenData() { // so it must be right-ish on its own. groundElevationM: (() => { const ft = toFiniteNumber(loc.elevation, NaN); - return Number.isFinite(ft) ? Math.max(-100, Math.min(4000, ft * 0.3048)) : 150; + return Number.isFinite(ft) + ? Math.max(-100, Math.min(4000, ft * 0.3048)) + : 150; })(), feedType: 'image', url: imageUrl, @@ -179,10 +235,16 @@ export async function loadCaltransSourcesFromOpenData() { } } - const maxRaw = Number(process.env.CCTV_CALTRANS_MAX_SOURCES || DEFAULT_CALTRANS_MAX_SOURCES); - const maxCount = Number.isFinite(maxRaw) ? Math.max(8, Math.min(600, Math.floor(maxRaw))) : DEFAULT_CALTRANS_MAX_SOURCES; + const maxRaw = Number( + process.env.CCTV_CALTRANS_MAX_SOURCES || DEFAULT_CALTRANS_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(600, Math.floor(maxRaw))) + : DEFAULT_CALTRANS_MAX_SOURCES; const prioritized = prioritizeSources(cameras, maxCount, CALTRANS_ANCHORS); - console.log(`[CCTV] Loaded Caltrans camera sources: ${cameras.length} inService (using nearest ${prioritized.length})`); + console.log( + `[CCTV] Loaded Caltrans camera sources: ${cameras.length} inService (using nearest ${prioritized.length})`, + ); return prioritized; } @@ -199,8 +261,13 @@ export async function loadCaltransSourcesFromOpenData() { export async function loadTflSourcesFromOpenData() { try { const appKey = String(process.env.TFL_APP_KEY || '').trim(); - const url = appKey ? `${TFL_JAMCAM_URL}?app_key=${encodeURIComponent(appKey)}` : TFL_JAMCAM_URL; - const resp = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS) }); + const url = appKey + ? `${TFL_JAMCAM_URL}?app_key=${encodeURIComponent(appKey)}` + : TFL_JAMCAM_URL; + const resp = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); if (!resp.ok) { console.warn('[CCTV] TfL JamCam download failed:', resp.status); return []; @@ -251,10 +318,16 @@ export async function loadTflSourcesFromOpenData() { }); } - const maxRaw = Number(process.env.CCTV_TFL_MAX_SOURCES || DEFAULT_TFL_MAX_SOURCES); - const maxCount = Number.isFinite(maxRaw) ? Math.max(8, Math.min(600, Math.floor(maxRaw))) : DEFAULT_TFL_MAX_SOURCES; + const maxRaw = Number( + process.env.CCTV_TFL_MAX_SOURCES || DEFAULT_TFL_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(600, Math.floor(maxRaw))) + : DEFAULT_TFL_MAX_SOURCES; const prioritized = prioritizeSources(cameras, maxCount, [LONDON_CENTER]); - console.log(`[CCTV] Loaded TfL JamCam sources: ${cameras.length} available (using nearest ${prioritized.length})`); + console.log( + `[CCTV] Loaded TfL JamCam sources: ${cameras.length} available (using nearest ${prioritized.length})`, + ); return prioritized; } catch (error) { console.warn('[CCTV] TfL JamCam download error:', error?.message || error); diff --git a/server/providers/radio.js b/server/providers/radio.js index c36010745..5dd4b196f 100644 --- a/server/providers/radio.js +++ b/server/providers/radio.js @@ -1,7 +1,11 @@ import { createRadioProxyMiddleware } from './radio/catalog.js'; export { createRadioProxyMiddleware }; export { isPublicRadioAddress } from './radio/transport.js'; -export { normalizeRadioBrowserStation, publicRadioStation, publicRadioHttpsUrl } from './radio/stations.js'; +export { + normalizeRadioBrowserStation, + publicRadioStation, + publicRadioHttpsUrl, +} from './radio/stations.js'; export function radioBrowserProxy() { const middleware = createRadioProxyMiddleware(); const install = (server) => { diff --git a/server/providers/radio/catalog.js b/server/providers/radio/catalog.js index 3abf59af2..36a3eb730 100644 --- a/server/providers/radio/catalog.js +++ b/server/providers/radio/catalog.js @@ -2,25 +2,53 @@ import { lookup as lookupDns } from 'node:dns/promises'; import { randomUUID } from 'node:crypto'; import { readResponseTextCapped } from '../common/http.js'; -import { normalizeRadioBrowserStation, publicRadioStation, cleanRadioText } from './stations.js'; -import { radioMirrorOrigin, radioProxyDestination, resolveRadioProxyAddresses, fetchPinnedRadioResponse } from './transport.js'; -import { RADIO_DIRECTORY_CACHE_MS, RADIO_DIRECTORY_STALE_MS, RADIO_MIRROR_CACHE_MS, RADIO_FETCH_TIMEOUT_MS, RADIO_RESPONSE_MAX_BYTES, RADIO_DIRECTORY_LIMIT, RADIO_CATALOG_MIN_SUCCESSFUL_QUERIES, RADIO_CATALOG_HEALTHY_MIN_STATIONS, RADIO_USER_AGENT, RADIO_UUID_RE, RADIO_FALLBACK_MIRRORS } from './constants.js'; +import { + normalizeRadioBrowserStation, + publicRadioStation, + cleanRadioText, +} from './stations.js'; +import { + radioMirrorOrigin, + radioProxyDestination, + resolveRadioProxyAddresses, + fetchPinnedRadioResponse, +} from './transport.js'; +import { + RADIO_DIRECTORY_CACHE_MS, + RADIO_DIRECTORY_STALE_MS, + RADIO_MIRROR_CACHE_MS, + RADIO_FETCH_TIMEOUT_MS, + RADIO_RESPONSE_MAX_BYTES, + RADIO_DIRECTORY_LIMIT, + RADIO_CATALOG_MIN_SUCCESSFUL_QUERIES, + RADIO_CATALOG_HEALTHY_MIN_STATIONS, + RADIO_USER_AGENT, + RADIO_UUID_RE, + RADIO_FALLBACK_MIRRORS, +} from './constants.js'; export async function mapRadioConcurrent(values, concurrency, mapper) { const results = new Array(values.length); let cursor = 0; - const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => { - for (;;) { - const index = cursor++; - if (index >= values.length) return; - results[index] = await mapper(values[index], index); - } - }); + const workers = Array.from( + { length: Math.min(concurrency, values.length) }, + async () => { + for (;;) { + const index = cursor++; + if (index >= values.length) return; + results[index] = await mapper(values[index], index); + } + }, + ); await Promise.all(workers); return results; } /** Create the testable Connect middleware backing `/api/radio`. */ -export function createRadioProxyMiddleware({ fetchImpl = null, lookupImpl = lookupDns, now = Date.now } = {}) { +export function createRadioProxyMiddleware({ + fetchImpl = null, + lookupImpl = lookupDns, + now = Date.now, +} = {}) { let mirrorCache = { origins: [...RADIO_FALLBACK_MIRRORS], cachedAt: 0 }; let mirrorPromise = null; let catalogCache = null; @@ -35,11 +63,15 @@ export function createRadioProxyMiddleware({ fetchImpl = null, lookupImpl = look async function fetchJson(url, maxBytes = RADIO_RESPONSE_MAX_BYTES) { const destination = radioProxyDestination(url); - if (!destination) throw new Error('Radio Browser destination is not permitted'); + if (!destination) + throw new Error('Radio Browser destination is not permitted'); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), RADIO_FETCH_TIMEOUT_MS); try { - const addresses = await resolveRadioProxyAddresses(destination.hostname, lookupImpl); + const addresses = await resolveRadioProxyAddresses( + destination.hostname, + lookupImpl, + ); const options = { headers: { Accept: 'application/json', 'User-Agent': RADIO_USER_AGENT }, signal: controller.signal, @@ -49,10 +81,15 @@ export function createRadioProxyMiddleware({ fetchImpl = null, lookupImpl = look ? await fetchImpl(destination.href, options) : await fetchPinnedRadioResponse(destination, options, addresses); if (response.status >= 300 && response.status < 400) { - try { await response.body?.cancel?.(); } catch { /* no-op */ } + try { + await response.body?.cancel?.(); + } catch { + /* no-op */ + } throw new Error('Radio Browser redirects are refused'); } - if (!response.ok) throw new Error(`Radio Browser returned ${response.status}`); + if (!response.ok) + throw new Error(`Radio Browser returned ${response.status}`); const text = await readResponseTextCapped(response, maxBytes); return JSON.parse(text); } finally { @@ -61,20 +98,40 @@ export function createRadioProxyMiddleware({ fetchImpl = null, lookupImpl = look } async function mirrors() { - if (now() - mirrorCache.cachedAt < RADIO_MIRROR_CACHE_MS) return mirrorCache.origins; + if (now() - mirrorCache.cachedAt < RADIO_MIRROR_CACHE_MS) + return mirrorCache.origins; if (!mirrorPromise) { mirrorPromise = (async () => { try { - const rows = await fetchJson('https://all.api.radio-browser.info/json/servers', 256 * 1024); - const discovered = [...new Set((Array.isArray(rows) ? rows : []).map((row) => radioMirrorOrigin(row?.name)).filter(Boolean))]; + const rows = await fetchJson( + 'https://all.api.radio-browser.info/json/servers', + 256 * 1024, + ); + const discovered = [ + ...new Set( + (Array.isArray(rows) ? rows : []) + .map((row) => radioMirrorOrigin(row?.name)) + .filter(Boolean), + ), + ]; if (discovered.length) { - mirrorCache = { origins: [...discovered, ...RADIO_FALLBACK_MIRRORS.filter((origin) => !discovered.includes(origin))], cachedAt: now() }; + mirrorCache = { + origins: [ + ...discovered, + ...RADIO_FALLBACK_MIRRORS.filter( + (origin) => !discovered.includes(origin), + ), + ], + cachedAt: now(), + }; } } catch { mirrorCache = { ...mirrorCache, cachedAt: now() }; } return mirrorCache.origins; - })().finally(() => { mirrorPromise = null; }); + })().finally(() => { + mirrorPromise = null; + }); } return mirrorPromise; } @@ -92,68 +149,113 @@ export function createRadioProxyMiddleware({ fetchImpl = null, lookupImpl = look } async function refreshCatalog() { - const queries = [null, 'news', 'talk', 'weather', 'emergency', 'scanner', 'aviation', 'marine', 'traffic']; - const outcomes = await mapRadioConcurrent(queries, 3, async (tag, index) => { - const params = new URLSearchParams({ - has_geo_info: 'true', - is_https: 'true', - hidebroken: 'true', - order: 'clickcount', - reverse: 'true', - limit: index === 0 ? '1800' : '220', - }); - if (tag) params.set('tag', tag); - try { - const rows = await fetchPath(`/json/stations/search?${params}`); - if (!Array.isArray(rows)) throw new Error('Radio Browser catalog payload was not an array'); - if (!rows.every((row) => ( - row - && typeof row === 'object' - && !Array.isArray(row) - && typeof row.stationuuid === 'string' - && typeof row.name === 'string' - && (typeof row.url_resolved === 'string' || typeof row.url === 'string') - ))) throw new Error('Radio Browser catalog contained a malformed station row'); - const stations = rows.map(normalizeRadioBrowserStation).filter(Boolean); - const requestedTag = cleanRadioText(tag, 80) - .toLocaleLowerCase() - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - const requestedTagCovered = !requestedTag || stations.some((station) => ( - station.tags.some((stationTag) => stationTag === requestedTag || stationTag.includes(requestedTag)) - )); - return { - // Query coverage is based on accepted rows, not merely a payload that - // happens to match the upstream schema. Specialist responses must - // also contain an accepted station tagged for the requested category. - succeeded: stations.length > 0 && requestedTagCovered, - stations, - }; - } catch { - return { succeeded: false, stations: [] }; - } - }); + const queries = [ + null, + 'news', + 'talk', + 'weather', + 'emergency', + 'scanner', + 'aviation', + 'marine', + 'traffic', + ]; + const outcomes = await mapRadioConcurrent( + queries, + 3, + async (tag, index) => { + const params = new URLSearchParams({ + has_geo_info: 'true', + is_https: 'true', + hidebroken: 'true', + order: 'clickcount', + reverse: 'true', + limit: index === 0 ? '1800' : '220', + }); + if (tag) params.set('tag', tag); + try { + const rows = await fetchPath(`/json/stations/search?${params}`); + if (!Array.isArray(rows)) + throw new Error('Radio Browser catalog payload was not an array'); + if ( + !rows.every( + (row) => + row && + typeof row === 'object' && + !Array.isArray(row) && + typeof row.stationuuid === 'string' && + typeof row.name === 'string' && + (typeof row.url_resolved === 'string' || + typeof row.url === 'string'), + ) + ) + throw new Error( + 'Radio Browser catalog contained a malformed station row', + ); + const stations = rows + .map(normalizeRadioBrowserStation) + .filter(Boolean); + const requestedTag = cleanRadioText(tag, 80) + .toLocaleLowerCase() + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + const requestedTagCovered = + !requestedTag || + stations.some((station) => + station.tags.some( + (stationTag) => + stationTag === requestedTag || + stationTag.includes(requestedTag), + ), + ); + return { + // Query coverage is based on accepted rows, not merely a payload that + // happens to match the upstream schema. Specialist responses must + // also contain an accepted station tagged for the requested category. + succeeded: stations.length > 0 && requestedTagCovered, + stations, + }; + } catch { + return { succeeded: false, stations: [] }; + } + }, + ); const resultSets = outcomes.map((outcome) => outcome.stations); const selected = []; const seen = new Set(); const take = (station) => { - if (!station || seen.has(station.id) || selected.length >= RADIO_DIRECTORY_LIMIT) return; + if ( + !station || + seen.has(station.id) || + selected.length >= RADIO_DIRECTORY_LIMIT + ) + return; seen.add(station.id); selected.push(station); }; // Seed specialist station-tag queries before popularity fill so operational // categories remain represented even when global click charts skew musical. for (const rows of resultSets.slice(1)) rows.slice(0, 45).forEach(take); - resultSets.flat().sort((a, b) => b.clickCount - a.clickCount || a.name.localeCompare(b.name)).forEach(take); + resultSets + .flat() + .sort( + (a, b) => b.clickCount - a.clickCount || a.name.localeCompare(b.name), + ) + .forEach(take); const timestamp = now(); - const successfulQueries = outcomes.filter((outcome) => outcome.succeeded).length; - const broadQueryHealthy = outcomes[0].succeeded && outcomes[0].stations.length > 0; + const successfulQueries = outcomes.filter( + (outcome) => outcome.succeeded, + ).length; + const broadQueryHealthy = + outcomes[0].succeeded && outcomes[0].stations.length > 0; const healthReasons = []; if (!broadQueryHealthy) healthReasons.push('broad-query-unhealthy'); - if (successfulQueries < RADIO_CATALOG_MIN_SUCCESSFUL_QUERIES) healthReasons.push('query-coverage-below-policy'); - if (selected.length < RADIO_CATALOG_HEALTHY_MIN_STATIONS) healthReasons.push('station-coverage-below-policy'); + if (successfulQueries < RADIO_CATALOG_MIN_SUCCESSFUL_QUERIES) + healthReasons.push('query-coverage-below-policy'); + if (selected.length < RADIO_CATALOG_HEALTHY_MIN_STATIONS) + healthReasons.push('station-coverage-below-policy'); const degraded = healthReasons.length > 0; const coverage = { successfulQueries, @@ -171,14 +273,18 @@ export function createRadioProxyMiddleware({ fetchImpl = null, lookupImpl = look coverage, }; if (degraded && catalogCache) { - const error = new Error('Radio Browser catalog refresh did not meet health policy'); + const error = new Error( + 'Radio Browser catalog refresh did not meet health policy', + ); error.radioCatalogDegraded = true; error.radioDegradedReason = nextCatalog.degradedReason; error.radioCoverage = coverage; throw error; } if (degraded && !selected.length) { - const error = new Error('Radio Browser catalog refresh returned no usable stations'); + const error = new Error( + 'Radio Browser catalog refresh returned no usable stations', + ); error.radioCatalogDegraded = true; error.radioDegradedReason = nextCatalog.degradedReason; error.radioCoverage = coverage; @@ -197,16 +303,24 @@ export function createRadioProxyMiddleware({ fetchImpl = null, lookupImpl = look } async function getCatalog() { - if (catalogCache && now() - catalogCache.cachedAt < RADIO_DIRECTORY_CACHE_MS) { + if ( + catalogCache && + now() - catalogCache.cachedAt < RADIO_DIRECTORY_CACHE_MS + ) { return { ...catalogCache, stale: false }; } if (!refreshPromise) { - refreshPromise = refreshCatalog().finally(() => { refreshPromise = null; }); + refreshPromise = refreshCatalog().finally(() => { + refreshPromise = null; + }); } try { - return { ...await refreshPromise, stale: false }; + return { ...(await refreshPromise), stale: false }; } catch (error) { - if (catalogCache && now() - catalogCache.cachedAt <= RADIO_DIRECTORY_STALE_MS) { + if ( + catalogCache && + now() - catalogCache.cachedAt <= RADIO_DIRECTORY_STALE_MS + ) { return { ...catalogCache, stale: true, @@ -220,7 +334,10 @@ export function createRadioProxyMiddleware({ fetchImpl = null, lookupImpl = look } function sendJson(res, status, body) { - res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify(body)); } diff --git a/server/providers/radio/constants.js b/server/providers/radio/constants.js index 2d935d83d..996e2af06 100644 --- a/server/providers/radio/constants.js +++ b/server/providers/radio/constants.js @@ -5,9 +5,13 @@ export const RADIO_FETCH_TIMEOUT_MS = 12_000; export const RADIO_RESPONSE_MAX_BYTES = 4 * 1024 * 1024; export const RADIO_DIRECTORY_LIMIT = 750; export const RADIO_CATALOG_MIN_SUCCESSFUL_QUERIES = 5; -export const RADIO_CATALOG_HEALTHY_MIN_STATIONS = Math.ceil(RADIO_DIRECTORY_LIMIT / 2); -export const RADIO_USER_AGENT = 'GodsEyeView/1.0 (Radio Browser directory client)'; -export const RADIO_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export const RADIO_CATALOG_HEALTHY_MIN_STATIONS = Math.ceil( + RADIO_DIRECTORY_LIMIT / 2, +); +export const RADIO_USER_AGENT = + 'GodsEyeView/1.0 (Radio Browser directory client)'; +export const RADIO_UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export const RADIO_FALLBACK_MIRRORS = Object.freeze([ 'https://de1.api.radio-browser.info', 'https://de2.api.radio-browser.info', diff --git a/server/providers/radio/stations.js b/server/providers/radio/stations.js index fdf69e62c..4d9bcf35c 100644 --- a/server/providers/radio/stations.js +++ b/server/providers/radio/stations.js @@ -1,40 +1,56 @@ import { RADIO_UUID_RE } from './constants.js'; import { normalizeRadioCountryInput } from '../../../src/data/radioCountry.js'; export function cleanRadioText(value, maxLength) { - return String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, maxLength).trim(); + return String(value ?? '') + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLength) + .trim(); } export function isNonGlobalIpv4(hostname) { const pieces = hostname.split('.'); - if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece))) return false; + if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece))) + return false; const values = pieces.map(Number); if (values.some((value) => value > 255)) return true; const [a, b, c] = values; - return a === 0 || a === 10 || a === 127 || a >= 224 - || (a === 100 && b >= 64 && b <= 127) - || (a === 169 && b === 254) - || (a === 172 && b >= 16 && b <= 31) - || (a === 192 && b === 0) - || (a === 192 && b === 88 && c === 99) - || (a === 192 && b === 168) - || (a === 198 && (b === 18 || b === 19)) - || (a === 198 && b === 51 && c === 100) - || (a === 203 && b === 0 && c === 113); + return ( + a === 0 || + a === 10 || + a === 127 || + a >= 224 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0) || + (a === 192 && b === 88 && c === 99) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && c === 100) || + (a === 203 && b === 0 && c === 113) + ); } /** Return a normalized public HTTPS URL, or null for local/private targets. */ export function publicRadioHttpsUrl(value) { try { const url = new URL(String(value ?? '')); - const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); - if (url.protocol !== 'https:' || url.username || url.password || !hostname) return null; + const hostname = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); + if (url.protocol !== 'https:' || url.username || url.password || !hostname) + return null; if ( - hostname === 'localhost' - || hostname.endsWith('.localhost') - || hostname.endsWith('.local') - || isNonGlobalIpv4(hostname) - || hostname.includes(':') - ) return null; + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname.endsWith('.local') || + isNonGlobalIpv4(hostname) || + hostname.includes(':') + ) + return null; url.hash = ''; return url.href; } catch { @@ -45,25 +61,40 @@ export function publicRadioHttpsUrl(value) { /** Normalize one Radio Browser station and omit favicons and unsafe streams. */ export function normalizeRadioBrowserStation(raw) { const id = cleanRadioText(raw?.stationuuid, 40).toLowerCase(); - const lat = raw?.geo_lat === null || raw?.geo_lat === '' ? null : Number(raw?.geo_lat); - const lon = raw?.geo_long === null || raw?.geo_long === '' ? null : Number(raw?.geo_long); + const lat = + raw?.geo_lat === null || raw?.geo_lat === '' ? null : Number(raw?.geo_lat); + const lon = + raw?.geo_long === null || raw?.geo_long === '' + ? null + : Number(raw?.geo_long); const codec = cleanRadioText(raw?.codec, 16).toUpperCase(); const streamUrl = publicRadioHttpsUrl(raw?.url_resolved || raw?.url); if ( - !RADIO_UUID_RE.test(id) - || Number(raw?.lastcheckok) !== 1 - || Number(raw?.hls) === 1 - || !Number.isFinite(lat) || lat < -90 || lat > 90 - || !Number.isFinite(lon) || lon < -180 || lon > 180 - || !/^(?:MP3|AAC(?:\+|-LC|-HE)?|HE-AAC)$/i.test(codec) - || !streamUrl - ) return null; + !RADIO_UUID_RE.test(id) || + Number(raw?.lastcheckok) !== 1 || + Number(raw?.hls) === 1 || + !Number.isFinite(lat) || + lat < -90 || + lat > 90 || + !Number.isFinite(lon) || + lon < -180 || + lon > 180 || + !/^(?:MP3|AAC(?:\+|-LC|-HE)?|HE-AAC)$/i.test(codec) || + !streamUrl + ) + return null; const name = cleanRadioText(raw?.name, 140); if (!name) return null; const tags = String(raw?.tags ?? '') .split(',') - .map((tag) => cleanRadioText(tag, 80).toLocaleLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim()) + .map((tag) => + cleanRadioText(tag, 80) + .toLocaleLowerCase() + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(), + ) .filter(Boolean) .filter((tag, index, all) => all.indexOf(tag) === index) .slice(0, 24); @@ -74,9 +105,10 @@ export function normalizeRadioBrowserStation(raw) { .slice(0, 8); const rawCountryCode = cleanRadioText(raw?.countrycode, 2).toUpperCase(); const normalizedCode = normalizeRadioCountryInput(rawCountryCode); - const normalizedCountry = normalizedCode.valid && !normalizedCode.empty - ? normalizedCode - : normalizeRadioCountryInput(cleanRadioText(raw?.country, 80)); + const normalizedCountry = + normalizedCode.valid && !normalizedCode.empty + ? normalizedCode + : normalizeRadioCountryInput(cleanRadioText(raw?.country, 80)); const bitrate = Number(raw?.bitrate); return { id, @@ -88,13 +120,17 @@ export function normalizeRadioBrowserStation(raw) { tags, languages, state: cleanRadioText(raw?.state, 80), - country: normalizedCountry.valid && !normalizedCountry.empty - ? normalizedCountry.name - : cleanRadioText(raw?.country, 80), + country: + normalizedCountry.valid && !normalizedCountry.empty + ? normalizedCountry.name + : cleanRadioText(raw?.country, 80), countryCode: normalizedCountry.valid ? normalizedCountry.code : '', metadataTrust: 'untrusted-community', codec, - bitrate: Number.isInteger(bitrate) && bitrate >= 8 && bitrate <= 1024 ? bitrate : null, + bitrate: + Number.isInteger(bitrate) && bitrate >= 8 && bitrate <= 1024 + ? bitrate + : null, clickCount: Math.max(0, Math.min(10_000_000, Number(raw?.clickcount) || 0)), }; } diff --git a/server/providers/radio/transport.js b/server/providers/radio/transport.js index d9b220896..7bbe33bcd 100644 --- a/server/providers/radio/transport.js +++ b/server/providers/radio/transport.js @@ -2,44 +2,67 @@ import https from 'node:https'; import { Readable } from 'node:stream'; import { isNonGlobalIpv4 } from './stations.js'; export function radioMirrorOrigin(value) { - const hostname = String(value ?? '').toLowerCase().replace(/\.$/, ''); + const hostname = String(value ?? '') + .toLowerCase() + .replace(/\.$/, ''); if (!/^[a-z0-9-]+\.api\.radio-browser\.info$/.test(hostname)) return null; return `https://${hostname}`; } /** Return whether a resolved Radio Browser address is safe for an outbound request. */ export function isPublicRadioAddress(value) { - const address = String(value ?? '').trim().toLowerCase().replace(/^\[|\]$/g, ''); + const address = String(value ?? '') + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ''); if (!address) return false; if (!address.includes(':')) { const ipv4 = address.split('.'); - return ipv4.length === 4 - && ipv4.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255) - && !isNonGlobalIpv4(address); + return ( + ipv4.length === 4 && + ipv4.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255) && + !isNonGlobalIpv4(address) + ); } const pieces = address.split('::'); if (pieces.length > 2) return false; const left = pieces[0] ? pieces[0].split(':') : []; const right = pieces[1] ? pieces[1].split(':') : []; const missing = 8 - left.length - right.length; - if ((pieces.length === 1 && missing !== 0) || (pieces.length === 2 && missing < 1)) return false; + if ( + (pieces.length === 1 && missing !== 0) || + (pieces.length === 2 && missing < 1) + ) + return false; const groups = [...left, ...Array(Math.max(0, missing)).fill('0'), ...right]; - if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) return false; - const numeric = groups.reduce((total, group) => (total << 16n) | BigInt(`0x${group}`), 0n); - const inCidr = (base, prefix) => { - const shift = 128n - BigInt(prefix); - return (numeric >> shift) === (base >> shift); - }; - const base = (text) => text.split(':').reduce( - (total, group) => (total << 16n) | BigInt(`0x${group || '0'}`), + if ( + groups.length !== 8 || + groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group)) + ) + return false; + const numeric = groups.reduce( + (total, group) => (total << 16n) | BigInt(`0x${group}`), 0n, ); + const inCidr = (base, prefix) => { + const shift = 128n - BigInt(prefix); + return numeric >> shift === base >> shift; + }; + const base = (text) => + text + .split(':') + .reduce( + (total, group) => (total << 16n) | BigInt(`0x${group || '0'}`), + 0n, + ); const cidr = (text, prefix) => inCidr(base(text), prefix); - return cidr('2000:0:0:0:0:0:0:0', 3) - && !cidr('2001:0:0:0:0:0:0:0', 23) - && !cidr('2001:db8:0:0:0:0:0:0', 32) - && !cidr('2002:0:0:0:0:0:0:0', 16) - && !cidr('3fff:0:0:0:0:0:0:0', 20); + return ( + cidr('2000:0:0:0:0:0:0:0', 3) && + !cidr('2001:0:0:0:0:0:0:0', 23) && + !cidr('2001:db8:0:0:0:0:0:0', 32) && + !cidr('2002:0:0:0:0:0:0:0', 16) && + !cidr('3fff:0:0:0:0:0:0:0', 20) + ); } export function radioProxyDestination(value) { @@ -51,16 +74,18 @@ export function radioProxyDestination(value) { } const origin = radioMirrorOrigin(url.hostname); if ( - !origin - || url.origin !== origin - || url.username - || url.password - || url.port - || url.hash - ) return null; - const discovery = url.hostname.toLowerCase() === 'all.api.radio-browser.info' - && url.pathname === '/json/servers' - && !url.search; + !origin || + url.origin !== origin || + url.username || + url.password || + url.port || + url.hash + ) + return null; + const discovery = + url.hostname.toLowerCase() === 'all.api.radio-browser.info' && + url.pathname === '/json/servers' && + !url.search; const directory = url.pathname === '/json/stations/search'; const click = /^\/json\/url\/[0-9a-f-]+$/i.test(url.pathname) && !url.search; return discovery || directory || click ? url : null; @@ -70,9 +95,15 @@ export async function resolveRadioProxyAddresses(hostname, lookupImpl) { const resolved = await lookupImpl(hostname, { all: true, verbatim: true }); const rows = Array.isArray(resolved) ? resolved : [resolved]; const addresses = rows - .map((row) => ({ address: String(row?.address || ''), family: Number(row?.family) || undefined })) + .map((row) => ({ + address: String(row?.address || ''), + family: Number(row?.family) || undefined, + })) .filter((row) => row.address); - if (!addresses.length || addresses.some((row) => !isPublicRadioAddress(row.address))) { + if ( + !addresses.length || + addresses.some((row) => !isPublicRadioAddress(row.address)) + ) { throw new Error('Radio Browser resolved to a forbidden address'); } return addresses; @@ -81,26 +112,33 @@ export async function resolveRadioProxyAddresses(hostname, lookupImpl) { export function fetchPinnedRadioResponse(url, options, addresses) { return new Promise((resolve, reject) => { const address = addresses[0]; - const request = https.request(url, { - method: 'GET', - headers: options.headers, - signal: options.signal, - lookup(_hostname, lookupOptions, callback) { - if (lookupOptions?.all) callback(null, addresses); - else callback(null, address.address, address.family); + const request = https.request( + url, + { + method: 'GET', + headers: options.headers, + signal: options.signal, + lookup(_hostname, lookupOptions, callback) { + if (lookupOptions?.all) callback(null, addresses); + else callback(null, address.address, address.family); + }, }, - }, (response) => { - const headers = new Headers(); - for (const [name, value] of Object.entries(response.headers)) { - if (Array.isArray(value)) value.forEach((item) => headers.append(name, item)); - else if (value !== undefined) headers.set(name, String(value)); - } - resolve(new Response(Readable.toWeb(response), { - status: response.statusCode || 500, - statusText: response.statusMessage || '', - headers, - })); - }); + (response) => { + const headers = new Headers(); + for (const [name, value] of Object.entries(response.headers)) { + if (Array.isArray(value)) + value.forEach((item) => headers.append(name, item)); + else if (value !== undefined) headers.set(name, String(value)); + } + resolve( + new Response(Readable.toWeb(response), { + status: response.statusCode || 500, + statusText: response.statusMessage || '', + headers, + }), + ); + }, + ); request.on('error', reject); request.end(); }); diff --git a/src/tooling/mediaProviders.test.mjs b/src/tooling/mediaProviders.test.mjs index ce713ad0b..a8980fa0c 100644 --- a/src/tooling/mediaProviders.test.mjs +++ b/src/tooling/mediaProviders.test.mjs @@ -9,11 +9,21 @@ import { localProviderPlugins } from '../../server/providers/local.js'; function install(plugin) { let handler; - plugin.configureServer({ middlewares: { use(_route, fn) { handler = fn; } } }); + plugin.configureServer({ + middlewares: { + use(_route, fn) { + handler = fn; + }, + }, + }); return async (url) => { const res = { - writeHead(status, headers) { Object.assign(this, { status, headers }); }, - end(body) { this.body = body; }, + writeHead(status, headers) { + Object.assign(this, { status, headers }); + }, + end(body) { + this.body = body; + }, }; await handler({ url, method: 'GET' }, res); return res; @@ -24,14 +34,29 @@ function fixture(t, id) { const root = mkdtempSync(path.join(tmpdir(), 'gev-cctv-')); t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(path.join(root, 'config')); - writeFileSync(path.join(root, 'config/cctv_sources.austin.json'), JSON.stringify([ - { id, name: '', lat: 30.27, lon: -97.74, feedType: 'video' }, - ])); + writeFileSync( + path.join(root, 'config/cctv_sources.austin.json'), + JSON.stringify([ + { + id, + name: '', + lat: 30.27, + lon: -97.74, + feedType: 'video', + }, + ]), + ); return root; } function isolate(t) { - for (const name of ['CCTV_SOURCES_FILE', 'CCTV_SOURCES_JSON', 'CCTV_FORCE_AUSTIN', 'GOOGLE_MAPS_SERVER_API_KEY', 'GOOGLE_MAPS_API_KEY']) { + for (const name of [ + 'CCTV_SOURCES_FILE', + 'CCTV_SOURCES_JSON', + 'CCTV_FORCE_AUSTIN', + 'GOOGLE_MAPS_SERVER_API_KEY', + 'GOOGLE_MAPS_API_KEY', + ]) { const previous = process.env[name]; delete process.env[name]; t.after(() => { @@ -39,7 +64,9 @@ function isolate(t) { else process.env[name] = previous; }); } - t.mock.method(globalThis, 'fetch', () => { throw Error('fixture must not fetch'); }); + t.mock.method(globalThis, 'fetch', () => { + throw Error('fixture must not fetch'); + }); } test('CCTV instances resolve their own application source root and isolate catalogs and health', async (t) => { @@ -49,8 +76,14 @@ test('CCTV instances resolve their own application source root and isolate catal const [a, b] = await Promise.all([first('/sources'), second('/sources')]); assert.equal(a.status, 200); assert.equal(b.status, 200); - assert.deepEqual(JSON.parse(a.body).sources.map(s => s.id), ['first']); - assert.deepEqual(JSON.parse(b.body).sources.map(s => s.id), ['second']); + assert.deepEqual( + JSON.parse(a.body).sources.map((s) => s.id), + ['first'], + ); + assert.deepEqual( + JSON.parse(b.body).sources.map((s) => s.id), + ['second'], + ); const stream = JSON.parse((await first('/stream/first')).body); assert.equal(stream.feedType, 'mp4'); assert.equal(stream.mediaUrl, '/api/cctv/media/first'); @@ -66,6 +99,6 @@ test('composition creates exactly one CCTV and radio provider without acquisitio isolate(t); const plugins = localProviderPlugins(); for (const factory of [cctvProxy, radioBrowserProxy]) { - assert.equal(plugins.filter(p => p.name === factory().name).length, 1); + assert.equal(plugins.filter((p) => p.name === factory().name).length, 1); } });