// One-shot importer: resolves a curated list of underground / experimental / // DJ-led stations against Radio-Browser, then writes // data/seed/stations-underground.json. Re-running is safe: the seeder merges // by Radio-Browser UUID. // // Usage: node server/scripts/import-underground.js // // All entries are real DJ-broadcast / community / college / free-form stations // (no playlist bots), spread across punk, house, jazz, eclectic, underground. 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-underground.json'); const RB = 'https://de1.api.radio-browser.info'; const UA = 'OnlineRadioExplorer/0.1 (+import-underground)'; // Each entry: [searchName, countryCodeOrNull, expectedNameSubstring] // expectedNameSubstring is a lowercase substring used to filter out wrong-station // hits when the same name is shared (e.g. two "Skylab Radio"s in different countries). const PICKS = [ // === UK / IE underground & DJ-led === ['Foundation FM', 'GB', 'foundation fm'], ['Aaja Radio | Channel 1', 'GB', 'aaja'], ['Aaja Radio | Channel 2', 'GB', 'aaja'], ['Bloop London Radio', 'GB', 'bloop london'], ['Reform Radio', 'GB', 'reform radio'], ['1BTN', 'GB', '1btn'], ['Sub.fm', 'GB', 'sub.fm'], ['Radio Wigwam', 'GB', 'wigwam'], ['Skylab Radio', 'GB', 'skylab'], // === NL underground === ['Echobox', 'NL', 'echobox'], ['Operator Radio', 'NL', 'operator'], // === DE / AT / CH underground === ['byte.fm', 'DE', 'byte'], ['Radio 80000', 'DE', '80000'], ['FluxFM', 'DE', 'fluxfm'], ['FluxFM - Techno Underground', 'DE', 'techno'], ['Radio Eins', 'DE', 'radio eins'], ['Radio Helsinki 98,5 Mhz', 'FI', 'helsinki'], ['Radio Helsinki', 'AT', 'helsinki'], ['RTS Couleur 3', 'CH', 'couleur 3'], // === GR / HU / FR / EE underground === ['Movement', 'GR', 'movement.radio 1'], ['Movement', 'GR', 'movement.radio 2'], ['Tilos Rádió', 'HU', 'tilos'], ['Radio Campus Paris', 'FR', 'radio campus paris'], // === US college & free-form (the punk-friendly ones) === ['WMBR 88.1', 'US', 'wmbr'], ['WHRB 95.3', 'US', 'whrb'], ['KALX 90.7FM Berkeley', 'US', 'kalx'], ['WREK 91.1', 'US', 'wrek'], ['WXYC 89.3', 'US', 'wxyc'], ['KZSC 88.1', 'US', 'kzsc'], ['KDVS Davis', 'US', 'kdvs'], ['WPRB 103.3 FM', 'US', 'wprb'], ['WZBC', 'US', 'wzbc'], ['KFJC', null, 'kfjc'], ['KXLU 88.9FM', 'US', 'kxlu'], ['KCSB', 'US', 'kcsb'], ['WLUW', 'US', 'wluw'], ['WUSB 90.1', 'US', 'wusb'], ['BFF.fm', 'US', 'bff.fm'], // === Punk / shoegaze / noise leaning === ['DKFM Shoegaze Radio', 'CA', 'dkfm'], ['idobi Anthm', 'US', 'anthm'], ['idobi Howl', 'US', 'howl'], ['Radio Free Brooklyn', 'US', 'radio free brooklyn'], // === AU community indie === ['Triple R 102.7', 'AU', 'triple r'], ['PBS 106.7FM', 'AU', 'pbs 106.7'], ['FBi Radio', 'AU', 'fbi radio'], ['2SER 107.3 FM', 'AU', '2ser'], ['4zzz', 'AU', '4zzz'], ['RTRFM', 'AU', 'rtrfm'] ]; 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, country) { const params = new URLSearchParams({ name, limit: '8', hidebroken: 'true', order: 'clickcount', reverse: 'true' }); if (country) params.set('countrycode', country); const res = await fetch(`${RB}/json/stations/search?${params}`, { headers: { 'User-Agent': UA } }); if (!res.ok) throw new Error(`RB ${res.status}`); return res.json(); } function pickBest(list, target, expectSub) { if (!list.length) return null; const sub = (expectSub || '').toLowerCase(); const t = target.toLowerCase().trim(); // 1. exact name match const exact = list.find((s) => (s.name || '').toLowerCase().trim() === t); if (exact) return exact; // 2. expected substring match if (sub) { const subMatch = list.find((s) => (s.name || '').toLowerCase().includes(sub)); if (subMatch) return subMatch; } // 3. fall back to top hit return list[0]; } function toEntry(s) { 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: 'underground', country: s.countrycode || null, homepage: s.homepage || null, genres: (s.tags || '').split(',').map((t) => t.trim()).filter(Boolean).slice(0, 6), description: null, image_url: s.favicon || null, source: 'radiobrowser', source_ref: s.stationuuid, streams: [stream] }; } async function main() { const out = []; const seenUuids = new Set(); let okCount = 0; let missCount = 0; let dupCount = 0; for (const [name, country, expectSub] of PICKS) { try { const hits = await rbSearch(name, country); const pick = pickBest(hits, name, expectSub); if (!pick) { console.warn(' miss:', name); missCount++; continue; } if (seenUuids.has(pick.stationuuid)) { console.warn(' dup :', name, '->', pick.name); dupCount++; continue; } seenUuids.add(pick.stationuuid); out.push(toEntry(pick)); console.log(' ok :', name.padEnd(32), '->', pick.name, `(${pick.codec || '?'} ${pick.bitrate || ''})`); okCount++; } catch (err) { console.warn(' err :', name, err.message); missCount++; } 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)}`); console.log(` ok=${okCount} miss=${missCount} dup=${dupCount}`); } main().catch((e) => { console.error(e); process.exit(1); });