Add Radio Browser station importer
This commit is contained in:
12
src/radio/loadRadioStations.ts
Normal file
12
src/radio/loadRadioStations.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { RadioStation } from './radioTypes.js';
|
||||
|
||||
export async function loadRadioStations(): Promise<RadioStation[]> {
|
||||
const response = await fetch('/data/radio-stations.json');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load radio stations: ${response.status}`);
|
||||
}
|
||||
|
||||
const stations = (await response.json()) as RadioStation[];
|
||||
return Array.isArray(stations) ? stations : [];
|
||||
}
|
||||
24
src/radio/radioCountries.ts
Normal file
24
src/radio/radioCountries.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const radioCountries = [
|
||||
{ name: 'Austria', code: 'AT' },
|
||||
{ name: 'Croatia', code: 'HR' },
|
||||
{ name: 'Serbia', code: 'RS' },
|
||||
{ name: 'Montenegro', code: 'ME' },
|
||||
{ name: 'Bosnia & Herzegovina', code: 'BA' },
|
||||
{ name: 'Germany', code: 'DE' },
|
||||
{ name: 'United Kingdom', code: 'GB' },
|
||||
{ name: 'Italy', code: 'IT' },
|
||||
{ name: 'France', code: 'FR' },
|
||||
{ name: 'Spain', code: 'ES' },
|
||||
{ name: 'USA', code: 'US' },
|
||||
{ name: 'Canada', code: 'CA' },
|
||||
{ name: 'Australia', code: 'AU' },
|
||||
{ name: 'Luxembourg', code: 'LU' },
|
||||
{ name: 'Netherlands', code: 'NL' },
|
||||
{ name: 'Sweden', code: 'SE' },
|
||||
{ name: 'Switzerland', code: 'CH' },
|
||||
{ name: 'Hungary', code: 'HU' },
|
||||
{ name: 'Czechia', code: 'CZ' },
|
||||
{ name: 'Poland', code: 'PL' },
|
||||
] as const;
|
||||
|
||||
export type RadioCountry = (typeof radioCountries)[number];
|
||||
96
src/radio/radioStationNormalizer.ts
Normal file
96
src/radio/radioStationNormalizer.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { RadioBrowserStation, RadioStation } from './radioTypes.js';
|
||||
|
||||
const MAX_TAGS = 12;
|
||||
const OBVIOUSLY_UNSUPPORTED_CODECS = new Set([
|
||||
'wma',
|
||||
'wmav2',
|
||||
'asf',
|
||||
'ra',
|
||||
'rm',
|
||||
'ape',
|
||||
'alac',
|
||||
'amr',
|
||||
]);
|
||||
|
||||
function trimToNull(value: string | null | undefined): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function toNumber(value: number | string | null | undefined): number {
|
||||
const parsed = typeof value === 'number' ? value : Number(value ?? 0);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function toNullableNumber(value: number | string | null | undefined): number | null {
|
||||
const parsed = typeof value === 'number' ? value : Number(value ?? NaN);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function parseTags(tags: string | null | undefined): string[] {
|
||||
if (typeof tags !== 'string' || tags.trim().length === 0) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const parsed: string[] = [];
|
||||
|
||||
for (const rawTag of tags.split(',')) {
|
||||
const tag = rawTag.trim();
|
||||
if (!tag) continue;
|
||||
const normalizedTag = tag.toLowerCase();
|
||||
if (seen.has(normalizedTag)) continue;
|
||||
seen.add(normalizedTag);
|
||||
parsed.push(tag);
|
||||
if (parsed.length >= MAX_TAGS) break;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isHttpsUrl(value: string | null): value is string {
|
||||
if (!value) return false;
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeRadioBrowserStation(
|
||||
station: RadioBrowserStation,
|
||||
countryName: string,
|
||||
): RadioStation | null {
|
||||
const stationUuid = trimToNull(station.stationuuid);
|
||||
if (!stationUuid) return null;
|
||||
|
||||
const name = trimToNull(station.name);
|
||||
if (!name) return null;
|
||||
|
||||
const streamUrl = trimToNull(station.url_resolved) ?? trimToNull(station.url);
|
||||
if (!isHttpsUrl(streamUrl)) return null;
|
||||
|
||||
const codec = trimToNull(station.codec);
|
||||
if (codec && OBVIOUSLY_UNSUPPORTED_CODECS.has(codec.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: stationUuid,
|
||||
name,
|
||||
country: trimToNull(station.country) ?? countryName,
|
||||
countryCode: trimToNull(station.countrycode) ?? '',
|
||||
language: trimToNull(station.language),
|
||||
tags: parseTags(station.tags),
|
||||
codec,
|
||||
bitrate: toNullableNumber(station.bitrate),
|
||||
streamUrl,
|
||||
homepage: trimToNull(station.homepage),
|
||||
logoUrl: trimToNull(station.favicon),
|
||||
votes: toNumber(station.votes),
|
||||
clickcount: toNumber(station.clickcount),
|
||||
source: 'radio-browser',
|
||||
sourceStationUuid: stationUuid,
|
||||
};
|
||||
}
|
||||
34
src/radio/radioTypes.ts
Normal file
34
src/radio/radioTypes.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type RadioBrowserStation = {
|
||||
stationuuid?: string | null;
|
||||
name?: string | null;
|
||||
url?: string | null;
|
||||
url_resolved?: string | null;
|
||||
homepage?: string | null;
|
||||
favicon?: string | null;
|
||||
country?: string | null;
|
||||
countrycode?: string | null;
|
||||
language?: string | null;
|
||||
tags?: string | null;
|
||||
codec?: string | null;
|
||||
bitrate?: number | string | null;
|
||||
votes?: number | string | null;
|
||||
clickcount?: number | string | null;
|
||||
};
|
||||
|
||||
export type RadioStation = {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
language: string | null;
|
||||
tags: string[];
|
||||
codec: string | null;
|
||||
bitrate: number | null;
|
||||
streamUrl: string;
|
||||
homepage: string | null;
|
||||
logoUrl: string | null;
|
||||
votes: number;
|
||||
clickcount: number;
|
||||
source: 'radio-browser';
|
||||
sourceStationUuid: string;
|
||||
};
|
||||
Reference in New Issue
Block a user