// One-shot importer: resolves a list of Dutch station names (from allradio.net) // against Radio-Browser, plus adds Vintage Obscura by direct URL, // then writes data/seed/stations-allradio-nl.json. // // Usage: node server/scripts/import-allradio-nl.js // // Re-running is safe: existing entries are matched by RB UUID via the // merge-by-UUID seeder. This script does NOT touch the database directly. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..', '..'); const OUT = path.join(ROOT, 'data', 'seed', 'stations-allradio-nl.json'); const RB = 'https://de1.api.radio-browser.info'; const UA = 'OnlineRadioExplorer/0.1 (+import-allradio-nl)'; // Station names taken from https://www.allradio.net/country/3 (pages 1+2), // minus duplicates and minus ones already seeded under stations-extended.json. const NAMES = [ // public broadcasters not yet seeded ['NPO 3FM Alternative', 'dutch-public'], ['NPO 3FM KX', 'dutch-public'], ['NPO FunX NL', 'dutch-public'], ['NPO FunX Reggae', 'dutch-public'], ['NPO 2', 'dutch-public'], ['Radio Rijnmond', 'dutch-public'], ['Omroep Gelderland', 'dutch-public'], ['Omroep West', 'dutch-public'], // commercials ['Radio 10', 'dutch-commercial'], ['Radio 10 80\'s Hits', 'dutch-commercial'], ['Radio 10 60\'s & 70\'s Hits', 'dutch-commercial'], ['Radio 538 Nonstop', 'dutch-commercial'], ['538 Dance Department', 'dutch-commercial'], ['538 TOP 50', 'dutch-commercial'], ['Sky Radio Hits', 'dutch-commercial'], ['Sky Radio 90\'s Hits', 'dutch-commercial'], ['Sky Radio 101 FM', 'dutch-commercial'], ['SLAM FM', 'dutch-commercial'], ['Veronica Rockradio', 'dutch-commercial'], ['Veronica TOP1000 AllerTijden', 'dutch-commercial'], ['JAMM FM', 'dutch-commercial'], ['RADIONL', 'dutch-commercial'], ['Grand Prix Radio', 'dutch-commercial'], ['XXL Stenders', 'dutch-commercial'], ['Sublime - Live', 'dutch-commercial'], ['Sublime - Soul', 'dutch-commercial'], // rock & alt ['KINK', 'rock'], ['KINK CLASSICS', 'rock'], ['Baars classic Rock', 'rock'], ['ISKC Rock Radio', 'rock'], ['ICE RADIO', 'rock'], // electronic / dance / hard ['Jungletrain.net', 'electronic'], ['Real Hardstyle Radio', 'electronic'], ['Hardstyle Radio NL', 'electronic'], ['Hardcore Power', 'electronic'], ['Freak31', 'electronic'], ['Decibel', 'electronic'], ['Decibel EURODANCE', 'electronic'], ['Intense Radio', 'electronic'], ['Deep Radio', 'electronic'], ['Fantasy Radio - Italo Disco Euro Dance HiNRG', 'electronic'], ['MixPerfect Radio', 'electronic'], ['Dancegroove Radio', 'electronic'], ['DANCEableRADIO', 'electronic'], // jazz / lounge / classical ['Jazz de Ville - Jazz', 'jazz'], ['Jazz de Ville - Chill', 'jazz'], ['Hi On Line Jazz Radio', 'jazz'], ['Hi On Line Classical Radio', 'classical'], ['Hi On Line Lounge Radio', 'ambient'], ['Hi On Line World Radio', 'world'], ['Hi On Line Latin Radio', 'world'], ['Hi On Line Radio - Pop', 'dutch-commercial'], ['ClassicFM - Chillout', 'classical'], ['Classic NL', 'classical'], // niche / community / piraten ['Pinguin Blues', 'jazz'], ['Pinguin Ska World', 'reggae'], ['Lachende Piraat', 'world'], ['Oude Piraten Hits', 'world'], ['Radio Caroline 319 Gold', 'world'], ['Radio Nostalgia', 'world'], ['Slow Radio Gold', 'world'], ['Olympia Classics', 'world'], ['Peaceful Radio', 'ambient'], ['Amsterdam Funk Channel', 'electronic'], ['247Spice', 'world'], ['SH Radio', 'world'], ['Rivierenland Radio', 'world'], ['Grolloo Radio', 'rock'], ['All Oldies Channel', 'world'], ['i-turn radio', 'world'], ['NPO 3FM Serious Radio', 'dutch-public'] ]; function detectFormat(codec, url) { const c = (codec || '').toLowerCase(); if (c.includes('mp3')) return 'mp3'; if (c.includes('aac')) return 'aac'; if (c.includes('ogg') || c.includes('vorbis') || c.includes('opus')) return 'ogg'; if (url?.endsWith('.m3u8')) return 'hls'; if (url?.endsWith('.m3u')) return 'm3u'; if (url?.endsWith('.pls')) return 'pls'; return 'unknown'; } function slugify(name) { return name.toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80); } async function rbSearch(name) { const url = `${RB}/json/stations/search?name=${encodeURIComponent(name)}&countrycode=NL&limit=5&hidebroken=true&order=clickcount&reverse=true`; const res = await fetch(url, { headers: { 'User-Agent': UA } }); if (!res.ok) throw new Error(`RB ${res.status}`); const list = await res.json(); // also try without country filter as fallback (some entries have wrong country) if (!list.length) { const r2 = await fetch(`${RB}/json/stations/search?name=${encodeURIComponent(name)}&limit=5&hidebroken=true&order=clickcount&reverse=true`, { headers: { 'User-Agent': UA } }); if (r2.ok) return r2.json(); } return list; } function pickBest(list, target) { if (!list.length) return null; const t = target.toLowerCase().trim(); const exact = list.find((s) => (s.name || '').toLowerCase().trim() === t); return exact || list[0]; } function toEntry(s, category) { const stream = { url: s.url_resolved || s.url, format: detectFormat(s.codec, s.url_resolved || s.url), bitrate: s.bitrate || null, label: s.codec ? `${s.codec} ${s.bitrate || ''}`.trim() : null, priority: 0 }; return { uuid: s.stationuuid, slug: `rb-${s.stationuuid.slice(0, 8)}-${slugify(s.name).slice(0, 40)}`, name: s.name, category, country: s.countrycode || 'NL', homepage: s.homepage || null, genres: (s.tags || '').split(',').map((t) => t.trim()).filter(Boolean).slice(0, 5), description: null, image_url: s.favicon || null, source: 'radiobrowser', source_ref: s.stationuuid, streams: [stream] }; } async function main() { const out = []; const seenUuids = new Set(); // Vintage Obscura — direct, no RB. out.push({ slug: 'vintage-obscura', name: 'Vintage Obscura Radio', category: 'underground', country: 'US', homepage: 'https://vintageobscura.net/', genres: ['vintage', 'obscure', 'curated', 'reddit'], description: 'Curated rare music discovered daily by /r/vintageobscura. All tracks <30k YouTube views, pre-2000.', image_url: 'https://vintageobscura.net/img/vintage-obscura-logo.png', streams: [ { url: 'https://radio.vintageobscura.net/stream', format: 'mp3', bitrate: 128, label: 'MP3 128', priority: 0 } ] }); for (const [name, category] of NAMES) { try { const hits = await rbSearch(name); const pick = pickBest(hits, name); if (!pick) { console.warn(' miss:', name); continue; } if (seenUuids.has(pick.stationuuid)) { console.warn(' dup:', name, '->', pick.name); continue; } seenUuids.add(pick.stationuuid); out.push(toEntry(pick, category)); console.log(' ok :', name, '->', pick.name, `(${pick.codec || '?'} ${pick.bitrate || ''})`); } catch (err) { console.warn(' err:', name, err.message); } // gentle pacing await new Promise((r) => setTimeout(r, 80)); } fs.writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n', 'utf8'); console.log(`\nwrote ${out.length} entries to ${path.relative(ROOT, OUT)}`); } main().catch((e) => { console.error(e); process.exit(1); });