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(); } export function isNonGlobalIpv4(hostname) { const pieces = hostname.split('.'); 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 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; if ( hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local') || isNonGlobalIpv4(hostname) || hostname.includes(':') ) return null; url.hash = ''; return url.href; } catch { return null; } } /** 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 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; 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(), ) .filter(Boolean) .filter((tag, index, all) => all.indexOf(tag) === index) .slice(0, 24); const languages = String(raw?.language ?? '') .split(',') .map((language) => cleanRadioText(language, 40)) .filter(Boolean) .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 bitrate = Number(raw?.bitrate); return { id, name, lat, lon, streamUrl, homepage: publicRadioHttpsUrl(raw?.homepage), tags, languages, state: cleanRadioText(raw?.state, 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, clickCount: Math.max(0, Math.min(10_000_000, Number(raw?.clickcount) || 0)), }; } export function publicRadioStation(station) { return { id: station.id, name: station.name, lat: station.lat, lon: station.lon, streamUrl: station.streamUrl, homepage: station.homepage, tags: station.tags, languages: station.languages, state: station.state, country: station.country, countryCode: station.countryCode, metadataTrust: station.metadataTrust, codec: station.codec, bitrate: station.bitrate, }; }