// Low-level stream probe. // Icecast/SHOUTcast servers commonly answer with `ICY 200 OK` instead of // `HTTP/1.1 200 OK`, which Node's built-in fetch refuses to parse. We open // a raw TCP/TLS socket, send a minimal HTTP/1.0 GET, and inspect the first // status line ourselves. import net from 'node:net'; import tls from 'node:tls'; const TIMEOUT = 8000; const UA = 'Mozilla/5.0 OnlineRadioExplorer/0.1'; export function probeStream(rawUrl) { return new Promise((resolve) => { let url; try { url = new URL(rawUrl); } catch { return resolve('err-badurl'); } const isTls = url.protocol === 'https:'; const port = Number(url.port) || (isTls ? 443 : 80); const path = (url.pathname || '/') + (url.search || ''); const host = url.hostname; const opts = { host, port, servername: host }; const connect = isTls ? tls.connect : net.connect; const sock = connect(opts); let settled = false; const finish = (status) => { if (settled) return; settled = true; try { sock.destroy(); } catch { } resolve(status); }; sock.setTimeout(TIMEOUT); sock.on('timeout', () => finish('err-timeout')); sock.on('error', () => finish('err-fetch')); sock.on('connect', () => { const req = `GET ${path} HTTP/1.0\r\n` + `Host: ${host}\r\n` + `User-Agent: ${UA}\r\n` + `Icy-MetaData: 1\r\n` + `Accept: */*\r\n` + `Connection: close\r\n\r\n`; sock.write(req); }); let buf = ''; sock.on('data', (chunk) => { buf += chunk.toString('latin1'); const eol = buf.indexOf('\n'); if (eol < 0) return; const statusLine = buf.slice(0, eol).trim(); // Accept: HTTP/1.x 2xx, ICY 2xx, SOURCE 2xx const m = statusLine.match(/^(?:HTTP\/\d\.\d|ICY|SOURCE)\s+(\d{3})/i); if (!m) return finish(`bad-${statusLine.slice(0, 16)}`); const code = Number(m[1]); if (code >= 200 && code < 400) finish('up'); else finish(`http-${code}`); }); sock.on('end', () => { if (!settled) finish(buf ? 'err-empty' : 'err-fetch'); }); }); }