diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..278c4e0bd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +Radio tags are lowercased and whitespace-normalized before the final 80-character +cap, including requested-tag coverage matching. Malformed station rows are skipped +individually; valid rows retain their accepted catalog generation. The UI snapshot +reports a bounded `rejectedStationCount` and degraded coverage when rows were +skipped. Malformed envelope/freshness/generation metadata and all-invalid responses +still preserve the previous usable catalog. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/server/providers/radio/catalog.js b/server/providers/radio/catalog.js index 36a3eb730..7eed9f01a 100644 --- a/server/providers/radio/catalog.js +++ b/server/providers/radio/catalog.js @@ -5,7 +5,7 @@ import { readResponseTextCapped } from '../common/http.js'; import { normalizeRadioBrowserStation, publicRadioStation, - cleanRadioText, + normalizeRadioTag, } from './stations.js'; import { radioMirrorOrigin, @@ -177,8 +177,8 @@ export function createRadioProxyMiddleware({ 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( + const stations = rows + .filter( (row) => row && typeof row === 'object' && @@ -188,18 +188,9 @@ export function createRadioProxyMiddleware({ (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 requestedTag = normalizeRadioTag(tag); const requestedTagCovered = !requestedTag || stations.some((station) => diff --git a/server/providers/radio/stations.js b/server/providers/radio/stations.js index b97b2d3c6..49279e2d2 100644 --- a/server/providers/radio/stations.js +++ b/server/providers/radio/stations.js @@ -1,5 +1,6 @@ export { cleanRadioText, + normalizeRadioTag, isNonGlobalIpv4, publicRadioHttpsUrl, normalizeRadioBrowserStation, diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index 58a4101a1..6ab8561c6 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -712,6 +712,24 @@ test('unusable directory responses preserve warm client state atomically', async assert.equal(getRadioUIState().stale, true); assert.equal(getRadioUIState().degraded, true); } + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [replacement, { id: 'malformed' }], stale: false, degraded: false, + acceptedGeneration: 2, catalogInstance: 'qa-instance-a', updatedAt: now, + })); + await radioLayer.update(); + assert.equal(getRadioUIState().stationCount, 1); + assert.equal(getRadioUIState().rejectedStationCount, 1); + assert.equal(getRadioUIState().degraded, true); + assert.equal(getRadioUIState().stale, false); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2); + assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id); + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [{ id: 'malformed' }], stale: false, degraded: false, + acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now, + })); + await radioLayer.update(); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2); + assert.equal(getRadioUIState().stale, true); } finally { globalThis.fetch = originalFetch; radioLayer.destroy(); diff --git a/src/data/radioProxy.test.mjs b/src/data/radioProxy.test.mjs index 60463205b..d1c4d2660 100644 --- a/src/data/radioProxy.test.mjs +++ b/src/data/radioProxy.test.mjs @@ -533,3 +533,11 @@ test('a mirror answering an invalid reason phrase rejects instead of crashing', const crashed = mockMirrorStatus(t, 200, 'OK\x7f'); await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError); }); + + +test('case expansion is capped after tag normalization in the portable and server station path', () => { + const row = normalizeRadioBrowserStation(station({tags: 'İ'.repeat(80) + ', NEWS__RADIO ,news-radio'})); + assert.equal(row.tags[0].length, 80); + assert.equal(row.tags[0], 'i\u0307'.repeat(40)); + assert.deepEqual(row.tags.slice(1), ['news radio']); +}); diff --git a/src/layers/radio/ingestion.js b/src/layers/radio/ingestion.js index 869b8d403..5a0ccfa1d 100644 --- a/src/layers/radio/ingestion.js +++ b/src/layers/radio/ingestion.js @@ -70,11 +70,6 @@ export function createIngestion({ ); if (!acceptedRows.length) throw new Error('Radio directory returned no usable stations'); - if (acceptedRows.length !== rows.length) { - throw new Error( - 'Radio directory response contained malformed stations', - ); - } const acceptedGeneration = body.acceptedGeneration; if ( acceptedGeneration !== null && @@ -147,7 +142,12 @@ export function createIngestion({ } if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt; } - layerState._degraded = body.degraded; + layerState._rejectedStationCount = Math.min( + 1000000, + rows.length - acceptedRows.length, + ); + layerState._degraded = + body.degraded || layerState._rejectedStationCount > 0; layerState._stale = body.stale; layerState._error = preservingWarmCatalog ? ERROR_REFRESH_DEGRADED diff --git a/src/layers/radio/presentation.js b/src/layers/radio/presentation.js index 5d6763ca4..c30889018 100644 --- a/src/layers/radio/presentation.js +++ b/src/layers/radio/presentation.js @@ -20,6 +20,7 @@ export function createPresentation({ loading: layerState._loading, stale: layerState._stale, degraded: layerState._degraded, + rejectedStationCount: layerState._rejectedStationCount, error: layerState._error, updatedAt: layerState._updatedAt, filter: layerState._filter, diff --git a/src/layers/radio/state.js b/src/layers/radio/state.js index dca461e03..918ebb989 100644 --- a/src/layers/radio/state.js +++ b/src/layers/radio/state.js @@ -25,6 +25,7 @@ export function createState({ services }) { layerState._stale = false; layerState._degraded = false; + layerState._rejectedStationCount = 0; layerState._error = null; diff --git a/src/sources/radioBrowser.js b/src/sources/radioBrowser.js index 8511b6beb..8329f6257 100644 --- a/src/sources/radioBrowser.js +++ b/src/sources/radioBrowser.js @@ -11,6 +11,16 @@ export function cleanRadioText(value, maxLength) { .trim(); } +/** Normalize a station/filter tag and cap its final, case-folded representation. */ +export function normalizeRadioTag(value) { + return cleanRadioText( + String(value ?? '') + .toLowerCase() + .replace(/[_-]+/g, ' '), + 80, + ); +} + export function isNonGlobalIpv4(hostname) { const pieces = hostname.split('.'); if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece))) @@ -99,13 +109,7 @@ export function normalizeRadioBrowserStation( if (!name) return null; const tags = String(raw?.tags ?? '') .split(',') - .map((tag) => - cleanRadioText(tag, 80) - .toLocaleLowerCase() - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(), - ) + .map(normalizeRadioTag) .filter(Boolean) .filter((tag, index, all) => all.indexOf(tag) === index) .slice(0, 24);