Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
168 lines
18 KiB
JavaScript
168 lines
18 KiB
JavaScript
// Internet radio. The station list comes from the community Radio Browser directory (free, no key), with The Journey FM (Liberty University, Lynchburg VA)
|
|
// always on top. The hub fetches each stream itself and passes it on: many stations only offer plain http, which a browser refuses to play inside an https page,
|
|
// and the hub also reads the "now playing" text out of the stream so the player can show the song. Each person keeps their own list of saved stations.
|
|
const fs = require('fs'), path = require('path'), http = require('http'), https = require('https'), dns = require('dns'), net = require('net');
|
|
const users = require('./users');
|
|
|
|
const FEATURED = [{
|
|
id: 'journeyfm', name: 'The Journey FM', url: process.env.JOURNEYFM_STREAM_URL || 'http://ic.liberty.edu:8000/WRVL', homepage: 'https://www.myjourneyfm.com/', favicon: 'https://www.myjourneyfm.com/wp-content/uploads/sites/2/2025/12/cropped-Version-4-192x192.png', tags: 'christian,contemporary christian,worship',
|
|
country: 'United States', state: 'Virginia', codec: 'MP3', bitrate: 192, featured: true,
|
|
}];
|
|
const JOURNEY_NOW = () => process.env.JOURNEYFM_NOW_URL || 'https://www.myjourneyfm.com/wp-json/custom/v2/recentlyPlayed';
|
|
const DIRECTORY = () => process.env.RADIO_BROWSER_URL ? [process.env.RADIO_BROWSER_URL.replace(/\/+$/, '')] : ['https://de1.api.radio-browser.info', 'https://at1.api.radio-browser.info', 'https://nl1.api.radio-browser.info'];
|
|
const UA = 'NE-Hub/1.0 (self-hosted)';
|
|
const PLAYABLE = /^(MP3|AAC|AAC\+|OGG|VORBIS|OPUS)$/i;
|
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
|
|
// ---- the hub never connects to a private or local address on behalf of a station (the address is checked after it is looked up, so a name that points inside cannot slip through)
|
|
const isPrivate = a => {
|
|
if (process.env.RADIO_ALLOW_PRIVATE === '1') return false;
|
|
if (net.isIPv4(a)) { const [x, y] = a.split('.').map(Number); return x === 0 || x === 10 || x === 127 || (x === 169 && y === 254) || (x === 172 && y >= 16 && y <= 31) || (x === 192 && y === 168) || (x === 100 && y >= 64 && y <= 127) || x >= 224; }
|
|
if (net.isIPv6(a)) { const l = a.toLowerCase(); return l === '::1' || l === '::' || l.startsWith('fe80') || l.startsWith('fc') || l.startsWith('fd') || (l.startsWith('::ffff:') && isPrivate(l.slice(7))); }
|
|
return true;
|
|
};
|
|
const safeLookup = (host, opts, cb) => { if (typeof opts === 'function') { cb = opts; opts = {}; }
|
|
dns.lookup(host, { ...opts, all: true }, (err, list) => { if (err) return cb(err); const ok = list.filter(x => !isPrivate(x.address)); if (!ok.length) return cb(new Error('That address is not allowed'));
|
|
if (opts.all) return cb(null, ok); cb(null, ok[0].address, ok[0].family); }); };
|
|
function open(url, { headers = {}, timeout = 12000, hops = 4 } = {}) { // GET with redirects; resolves with the response (not yet read)
|
|
return new Promise((ok, no) => {
|
|
let u; try { u = new URL(url); } catch { return no(fail(400, 'Bad address')); }
|
|
if (!/^https?:$/.test(u.protocol)) return no(fail(400, 'Bad address'));
|
|
const lit = u.hostname.replace(/^\[|\]$/g, ''); if (net.isIP(lit) && isPrivate(lit)) return no(fail(502, 'That address is not allowed')); // (an address written as numbers never goes through the lookup below)
|
|
const req = (u.protocol === 'https:' ? https : http).get(u, { headers: { 'user-agent': UA, ...headers }, lookup: safeLookup, timeout }, res => {
|
|
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) { res.resume(); if (!hops) return no(fail(502, 'Too many redirects')); return open(new URL(res.headers.location, u).href, { headers, timeout, hops: hops - 1 }).then(ok, no); }
|
|
ok(res);
|
|
});
|
|
req.on('timeout', () => req.destroy(fail(504, 'The station did not answer'))); req.on('error', e => no(e.code && e.code >= 400 ? e : fail(502, 'Could not reach that address')));
|
|
});
|
|
}
|
|
async function readText(url, max = 2e6, opts) { const res = await open(url, opts); if (res.statusCode >= 400) { res.resume(); throw fail(502, 'Answered ' + res.statusCode); } const c = []; let n = 0; for await (const d of res) { n += d.length; if (n > max) { res.destroy(); throw fail(502, 'Too large'); } c.push(d); } return Buffer.concat(c).toString('utf8'); }
|
|
|
|
// ---- Radio Browser
|
|
async function directory(p) { let last; for (const base of DIRECTORY()) { try { return JSON.parse(await readText(base + p, 2e6, { timeout: 8000 })); } catch (e) { last = e; } } throw fail(502, 'The radio directory is not answering' + (last && last.message ? ' (' + last.message + ')' : '')); }
|
|
const tidy = s => ({
|
|
id: s.stationuuid, name: String(s.name || '').replace(/\s+/g, ' ').trim().slice(0, 80), url: s.url_resolved || s.url, homepage: /^https?:/.test(s.homepage || '') ? s.homepage : '', favicon: /^https?:/.test(s.favicon || '') ? s.favicon : '',
|
|
tags: String(s.tags || '').split(',').map(x => x.trim()).filter(Boolean).slice(0, 4).join(','), country: s.country || '', state: s.state || '', codec: s.codec || '', bitrate: +s.bitrate || 0, votes: +s.votes || 0,
|
|
});
|
|
const usable = s => s && s.stationuuid && /^https?:\/\//.test(s.url_resolved || s.url || '') && !s.hls && PLAYABLE.test(s.codec || '') && String(s.name || '').trim();
|
|
const known = new Map(); // station id -> { t, st }
|
|
async function station(id) {
|
|
const f = FEATURED.find(x => x.id === id) || mine().find(x => x.id === id); if (f) return f;
|
|
if (!/^[\w-]{8,64}$/.test(id)) throw fail(400, 'Bad station');
|
|
const c = known.get(id); if (c && Date.now() - c.t < 3600e3) return c.st;
|
|
const [s] = await directory('/json/stations/byuuid/' + id); if (!usable(s)) throw fail(404, 'Station not found');
|
|
const st = tidy(s); if (known.size > 500) known.clear(); known.set(id, { t: Date.now(), st }); return st;
|
|
}
|
|
const favFile = () => path.join(users.dir(), 'radio.json');
|
|
const mine = () => { try { const l = JSON.parse(fs.readFileSync(favFile(), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; } };
|
|
const saveMine = l => { const f = favFile(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(l), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
|
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 1e5) throw fail(413, 'Too large'); c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { throw fail(400, 'Bad request'); } };
|
|
|
|
// ---- now playing: the Journey's own feed, or the song title carried inside the stream
|
|
const nowMap = new Map(); // person:station -> { title, t }
|
|
const clean = s => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 160);
|
|
const titleCase = s => s && s === s.toUpperCase() ? s.toLowerCase().replace(/(^|[\s(&\/-])([a-z])/g, (m, a, b) => a + b.toUpperCase()) : s;
|
|
/** "10/24/2026 22:01:37" on the station's clock (Lynchburg, Virginia: US Eastern time) as a moment in time. */
|
|
function easternEpoch(s) {
|
|
const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4}) (\d{1,2}):(\d{2}):(\d{2})$/.exec(String(s || '').trim()); if (!m) return 0;
|
|
const [mo, d, y, h, mi, se] = m.slice(1).map(Number), wall = Date.UTC(y, mo - 1, d, h, mi, se);
|
|
const at = ms => { const p = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' }).formatToParts(new Date(ms)).map(x => [x.type, +x.value])); return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - ms; };
|
|
let t = wall - at(wall); t = wall - at(t); return t; // (twice, so the hour the clocks change is right too)
|
|
}
|
|
const journeyCache = new Map();
|
|
async function journeyNow(recent) {
|
|
const k = recent ? 'r' : 'n', c = journeyCache.get(k); if (c && Date.now() - c.t < 8000) return c.v; // the station's feed is asked at most every 8 seconds, however many people are listening
|
|
const rows = JSON.parse(await readText(JOURNEY_NOW() + (recent ? '/getRecentlyPlayed' : '/getNowPlaying'), 1e6, { timeout: 8000 }));
|
|
const v = (Array.isArray(rows) ? rows : []).filter(r => r && r.TITLE && r.ARTIST).map(r => ({ title: titleCase(clean(r.TITLE)), artist: titleCase(clean(r.ARTIST)), at: r.ACTIVITY_DATE || '', startedAt: easternEpoch(r.ACTIVITY_DATE) })); // rows with no artist are the station's own sound bites
|
|
journeyCache.set(k, { t: Date.now(), v }); return v;
|
|
}
|
|
/** Pull the song title out of a stream: every `metaint` bytes of audio are followed by a small text block (StreamTitle='...';), which must not reach the player. */
|
|
function icyFilter(metaint, onTitle) {
|
|
let toMeta = metaint, need = -1, buf = Buffer.alloc(0);
|
|
return chunk => {
|
|
const out = []; let i = 0;
|
|
while (i < chunk.length) {
|
|
if (need >= 0) { const take = Math.min(need - buf.length, chunk.length - i); buf = Buffer.concat([buf, chunk.subarray(i, i + take)]); i += take; if (buf.length === need) { const m = /StreamTitle='([^]*?)';/.exec(buf.toString('utf8')); if (m) onTitle(clean(m[1])); need = -1; buf = Buffer.alloc(0); toMeta = metaint; } }
|
|
else if (toMeta === 0) { need = chunk[i] * 16; i++; if (need === 0) { need = -1; toMeta = metaint; } }
|
|
else { const take = Math.min(toMeta, chunk.length - i); out.push(chunk.subarray(i, i + take)); i += take; toMeta -= take; }
|
|
}
|
|
return Buffer.concat(out);
|
|
};
|
|
}
|
|
|
|
// ---- cover art for the song that is on: iTunes' free search (no key), else the station's logo, else a plain coloured tile with the station's initials
|
|
const ITUNES = () => (process.env.ITUNES_URL || 'https://itunes.apple.com').replace(/\/+$/, '');
|
|
const artCache = new Map(); // "artist|title" -> { t, url }
|
|
const nrm = s => String(s || '').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/\(.*?\)|\[.*?\]/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim();
|
|
async function songArtUrl(artist, title) {
|
|
const key = nrm(artist) + '|' + nrm(title), c = artCache.get(key); if (c && Date.now() - c.t < 6 * 3600e3) return c.url;
|
|
let url = '';
|
|
try {
|
|
const d = JSON.parse(await readText(ITUNES() + '/search?' + new URLSearchParams({ term: artist + ' ' + title, entity: 'song', media: 'music', limit: '8' }), 1e6, { timeout: 8000 })), na = nrm(artist).split(' ')[0], nt = nrm(title);
|
|
const hit = (d.results || []).find(x => { const t = nrm(x.trackName), a = nrm(x.artistName); return (t === nt || t.includes(nt) || nt.includes(t)) && (a.includes(na) || nrm(artist).includes(a.split(' ')[0])); });
|
|
if (hit && hit.artworkUrl100) url = hit.artworkUrl100.replace(/\/\d+x\d+bb\./, '/600x600bb.');
|
|
} catch {}
|
|
if (artCache.size > 500) artCache.clear(); artCache.set(key, { t: Date.now(), url }); return url;
|
|
}
|
|
const initials = n => String(n || 'Radio').split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase().replace(/[<>&"']/g, '');
|
|
const tile = n => { const hue = [...String(n)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0); return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 60% 45%)"/><stop offset="1" stop-color="hsl(${(hue + 40) % 360} 60% 28%)"/></linearGradient></defs><rect width="400" height="400" fill="url(#g)"/><text x="200" y="240" font-family="Georgia,serif" font-size="150" text-anchor="middle" fill="#fff" fill-opacity=".92">${initials(n)}</text></svg>`; };
|
|
async function imageFrom(url, max = 1.5e6) { // a picture, or null
|
|
try { const r = await open(url, { timeout: 8000 }), type = String(r.headers['content-type'] || '').split(';')[0].toLowerCase(); if (r.statusCode !== 200 || !/^image\/(jpeg|png|gif|webp)$/.test(type)) { r.resume(); return null; }
|
|
const c = []; let n = 0; for await (const d of r) { n += d.length; if (n > max) { r.destroy(); return null; } c.push(d); } return { type, data: Buffer.concat(c) }; } catch { return null; }
|
|
}
|
|
|
|
async function handle(req, res) {
|
|
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/radio'.length) || '/', me = users.current();
|
|
try {
|
|
let m;
|
|
if (p === '/search' && req.method === 'GET') {
|
|
const q = clean(u.searchParams.get('q')).slice(0, 60), tag = clean(u.searchParams.get('tag')).slice(0, 30), off = Math.min(500, Math.max(0, +u.searchParams.get('offset') || 0));
|
|
const qs = new URLSearchParams({ hidebroken: 'true', order: 'clickcount', reverse: 'true', limit: '30', offset: String(off), is_https: u.searchParams.get('https') === '1' ? 'true' : '' }); if (!qs.get('is_https')) qs.delete('is_https');
|
|
if (q) qs.set('name', q); if (tag) qs.set('tag', tag); if (!q && !tag) qs.set('tag', 'christian');
|
|
const rows = await directory('/json/stations/search?' + qs), items = rows.filter(usable).map(tidy);
|
|
for (const st of items) known.set(st.id, { t: Date.now(), st }); if (known.size > 500) known.clear(); // saving or playing one of these needs no second look-up
|
|
return json(res, 200, { items, more: rows.length >= 30 });
|
|
}
|
|
if (p === '/stations' && req.method === 'GET') return json(res, 200, { featured: FEATURED, mine: mine() });
|
|
if (p === '/stations' && req.method === 'POST') {
|
|
const b = await readBody(req), s = await station(String(b.id || '')); if (s.featured) return json(res, 200, { saved: true });
|
|
const l = mine().filter(x => x.id !== s.id); l.push(s); saveMine(l.slice(-200)); return json(res, 200, { saved: true });
|
|
}
|
|
if ((m = /^\/stations\/([\w-]+)$/.exec(p)) && req.method === 'DELETE') { saveMine(mine().filter(x => x.id !== m[1])); return json(res, 200, { removed: true }); }
|
|
if ((m = /^\/now\/([\w-]+)$/.exec(p)) && req.method === 'GET') {
|
|
if (m[1] === 'journeyfm') { let now = null, recent = []; try { recent = await journeyNow(true); now = recent[0] || null; } catch {} return json(res, 200, { title: now ? now.title : '', artist: now ? now.artist : '', startedAt: now ? now.startedAt : 0, recent: recent.slice(0, 12) }); }
|
|
const n = nowMap.get(me.id + ':' + m[1]); const fresh = n && Date.now() - n.t < 600e3, sp = fresh ? /^(.+?)\s+-\s+(.+)$/.exec(n.title) : null; // many stations send "Artist - Title"
|
|
return json(res, 200, { title: fresh ? (sp ? sp[2] : n.title) : '', artist: sp ? sp[1] : '', startedAt: 0, recent: [] });
|
|
}
|
|
if (p === '/art' && req.method === 'GET') { // never a broken picture: the song's cover, else the station's logo, else a tile
|
|
const artist = clean(u.searchParams.get('artist')), title = clean(u.searchParams.get('title')); let img = null, name = 'Radio';
|
|
const sid = u.searchParams.get('s') || ''; let st = null; try { st = sid ? await station(sid) : null; } catch {} if (st) name = st.name;
|
|
if (artist && title) { const url = await songArtUrl(artist, title); if (url && (/(^|\.)mzstatic\.com$/.test(new URL(url).hostname) || process.env.ITUNES_URL)) img = await imageFrom(url); }
|
|
if (!img && st && st.favicon) img = await imageFrom(st.favicon);
|
|
if (img) { res.writeHead(200, { 'content-type': img.type, 'cache-control': 'private, max-age=86400', 'x-content-type-options': 'nosniff' }); return res.end(img.data); }
|
|
res.writeHead(200, { 'content-type': 'image/svg+xml', 'cache-control': 'private, max-age=3600', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'", 'x-content-type-options': 'nosniff' }); return res.end(tile(name));
|
|
}
|
|
if (p === '/icon' && req.method === 'GET') { // station logos come through the hub too (they are often plain http, and fetching them directly would tell their servers who is listening)
|
|
const res2 = await open(u.searchParams.get('u') || '', { timeout: 8000 }), type = String(res2.headers['content-type'] || '').split(';')[0].toLowerCase();
|
|
if (res2.statusCode !== 200 || !/^image\/(png|jpeg|gif|webp|x-icon|vnd\.microsoft\.icon|svg\+xml)$/.test(type)) { res2.resume(); throw fail(404, 'No icon'); }
|
|
const c = []; let n = 0; for await (const d of res2) { n += d.length; if (n > 5e5) { res2.destroy(); throw fail(404, 'No icon'); } c.push(d); }
|
|
res.writeHead(200, { 'content-type': type === 'image/svg+xml' ? 'image/svg+xml' : type, 'cache-control': 'private, max-age=604800', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'", 'x-content-type-options': 'nosniff' }); return res.end(Buffer.concat(c));
|
|
}
|
|
if ((m = /^\/play\/([\w-]+)$/.exec(p)) && req.method === 'GET') {
|
|
const s = await station(m[1]), up = await open(s.url, { headers: { 'icy-metadata': '1' }, timeout: 15000 });
|
|
if (up.statusCode >= 400) { up.resume(); throw fail(502, s.name + ' answered ' + up.statusCode); }
|
|
const type = String(up.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
|
if (/mpegurl|x-scpls|text\/|html/.test(type)) { up.resume(); throw fail(502, s.name + ' sent a playlist, not a stream'); }
|
|
const metaint = +up.headers['icy-metaint'] || 0, key = me.id + ':' + s.id, filter = metaint ? icyFilter(metaint, t => nowMap.set(key, { title: t, t: Date.now() })) : null;
|
|
res.writeHead(200, { 'content-type': type.startsWith('audio/') || type === 'application/ogg' ? type : 'audio/mpeg', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'accept-ranges': 'none' });
|
|
const close = () => { try { up.destroy(); } catch {} }; res.on('close', close); up.on('error', () => res.destroy());
|
|
up.on('data', d => { if (!res.write(filter ? filter(d) : d)) { up.pause(); res.once('drain', () => up.resume()); } }); up.on('end', () => res.end());
|
|
return;
|
|
}
|
|
return json(res, 404, { error: 'Not found' });
|
|
} catch (e) { if (res.headersSent) return res.destroy(); return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.message }); }
|
|
}
|
|
|
|
module.exports = { handle, icyFilter, isPrivate, easternEpoch };
|