Add Radio Browser station importer

This commit is contained in:
2026-04-26 14:33:55 +02:00
parent 7e256a669e
commit 972164bba7
14 changed files with 36414 additions and 112 deletions

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env node
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { radioCountries } from '../src/radio/radioCountries.js';
import { normalizeRadioBrowserStation } from '../src/radio/radioStationNormalizer.js';
import type { RadioBrowserStation, RadioStation } from '../src/radio/radioTypes.js';
const API_ENDPOINT = 'https://de1.api.radio-browser.info/json/stations/search';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const outputPath = path.join(repoRoot, 'public', 'data', 'radio-stations.json');
const collectedStations: RadioStation[] = [];
const seenStationIds = new Set<string>();
const seenStreamUrls = new Set<string>();
const failedCountries: string[] = [];
let successfulCountries = 0;
for (const country of radioCountries) {
try {
const fetchedStations = await fetchCountryStations(country.code);
const normalizedStations = fetchedStations
.map((station) => normalizeRadioBrowserStation(station, country.name))
.filter((station): station is RadioStation => station !== null);
for (const station of normalizedStations) {
if (seenStationIds.has(station.id)) continue;
if (seenStreamUrls.has(station.streamUrl)) continue;
seenStationIds.add(station.id);
seenStreamUrls.add(station.streamUrl);
collectedStations.push(station);
}
successfulCountries += 1;
} catch (error) {
failedCountries.push(country.code);
const message = error instanceof Error ? error.message : String(error);
console.error(`[radio-import] Failed to import ${country.name} (${country.code}): ${message}`);
}
}
collectedStations.sort((left, right) => {
const countryOrder = left.country.localeCompare(right.country, undefined, { sensitivity: 'base' });
if (countryOrder !== 0) return countryOrder;
if (right.clickcount !== left.clickcount) return right.clickcount - left.clickcount;
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
});
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(collectedStations, null, 2)}\n`, 'utf8');
console.log(`[radio-import] Imported ${collectedStations.length} stations from ${successfulCountries}/${radioCountries.length} countries.`);
console.log(`[radio-import] Failed countries: ${failedCountries.length > 0 ? failedCountries.join(', ') : 'None'}`);
console.log('[radio-import] Output: public/data/radio-stations.json');
async function fetchCountryStations(countryCode: string): Promise<RadioBrowserStation[]> {
const url = new URL(API_ENDPOINT);
url.search = new URLSearchParams({
countrycode: countryCode,
hidebroken: 'true',
is_https: 'true',
order: 'clickcount',
reverse: 'true',
limit: '100',
}).toString();
const response = await fetch(url, {
headers: {
'user-agent': 'RadioPlayerImporter/1.0',
},
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
const payload = (await response.json()) as unknown;
if (!Array.isArray(payload)) {
throw new Error('Expected an array response from Radio Browser.');
}
return payload as RadioBrowserStation[];
}

141
scripts/update-stations.mjs Normal file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env node
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const DEFAULT_SOURCE_URL = 'https://data.radio.si/api/radiostations?857df78efd094abcb98c7bbb53303c3d';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const sourceUrlArg = process.argv[2];
const stationsPathArg = process.argv[3];
const sourceUrl = sourceUrlArg && !sourceUrlArg.startsWith('--') ? sourceUrlArg : DEFAULT_SOURCE_URL;
const stationsPath = stationsPathArg && !stationsPathArg.startsWith('--')
? path.resolve(process.cwd(), stationsPathArg)
: path.join(repoRoot, 'public', 'stations.json');
const [remoteStations, existingStations] = await Promise.all([
fetchJson(sourceUrl),
readJsonFile(stationsPath).catch(() => []),
]);
if (!Array.isArray(remoteStations)) {
throw new Error('Expected the radio stations API to return an array.');
}
const remoteStationsById = new Set(remoteStations.map((station) => station.id));
const existingStationsById = new Map(
existingStations
.filter((station) => station && typeof station === 'object' && typeof station.id === 'string')
.map((station) => [station.id, station]),
);
const updatedStations = remoteStations.map((remoteStation) => {
const existingStation = existingStationsById.get(remoteStation.id);
return mergeStation(existingStation, remoteStation);
}).concat(
existingStations
.filter((station) => station && typeof station === 'object' && typeof station.id === 'string' && !remoteStationsById.has(station.id))
.map(normalizeStation),
);
await writeFile(stationsPath, `${JSON.stringify(updatedStations, null, 2)}\n`, 'utf8');
console.log(`Updated ${updatedStations.length} stations in ${stationsPath}`);
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
return response.json();
}
async function readJsonFile(filePath) {
const contents = await readFile(filePath, 'utf8');
return JSON.parse(contents);
}
function mergeStation(existingStation = {}, remoteStation) {
const assets = { ...(existingStation.assets ?? {}) };
assets.logo = remoteStation.logo;
if (remoteStation.poster) {
assets.poster = remoteStation.poster;
} else {
delete assets.poster;
}
const streams = { ...(existingStation.streams ?? {}) };
streams.audio = remoteStation.liveAudio;
if (remoteStation.liveVideo) {
streams.video = remoteStation.liveVideo;
} else {
delete streams.video;
}
const metadata = { ...(existingStation.metadata ?? {}) };
if (remoteStation.lastSongs) {
metadata.lastSongs = remoteStation.lastSongs;
}
return {
id: remoteStation.id,
name: remoteStation.title ?? existingStation.name ?? remoteStation.id,
slogan: remoteStation.slogan ?? existingStation.slogan ?? '',
category: existingStation.category ?? '',
country: existingStation.country ?? 'SI',
language: existingStation.language ?? 'sl',
region: existingStation.region ?? 'National',
tags: Array.isArray(existingStation.tags) ? existingStation.tags : [],
website: remoteStation.www ?? existingStation.website ?? null,
enabled: typeof remoteStation.enabled === 'boolean' ? remoteStation.enabled : Boolean(existingStation.enabled),
assets,
streams,
metadata,
};
}
function normalizeStation(station) {
const assets = { ...(station.assets ?? {}) };
const streams = { ...(station.streams ?? {}) };
const metadata = { ...(station.metadata ?? {}) };
if (!assets.logo) {
assets.logo = station.logo ?? '';
}
if (station.poster && !assets.poster) {
assets.poster = station.poster;
}
if (!streams.audio) {
streams.audio = station.streams?.audio ?? station.url ?? station.liveAudio ?? station.streamUrl ?? '';
}
if (!streams.video && station.liveVideo) {
streams.video = station.liveVideo;
}
return {
id: station.id,
name: station.name ?? station.title ?? station.id,
slogan: station.slogan ?? '',
category: station.category ?? '',
country: station.country ?? '',
language: station.language ?? '',
region: station.region ?? '',
tags: Array.isArray(station.tags) ? station.tags : [],
website: station.website ?? station.homepage ?? station.www ?? null,
enabled: typeof station.enabled === 'boolean' ? station.enabled : true,
assets,
streams,
metadata,
};
}