import * as http from 'http'; import * as https from 'https'; export interface IcyReadOptions { /** Таймаут сокета, мс (по умолчанию 8000). */ timeoutMs?: number; /** * Декодирование StreamTitle: * - 'utf8' — как есть (по умолчанию); * - 'auto-1251' — при битом UTF-8 (символ �) перечитать байты как windows-1251 * (нужно потокам с кириллицей в cp1251, напр. «Новое Радио BY»). */ decode?: 'utf8' | 'auto-1251'; /** Доп. заголовки запроса (User-Agent и т.п.). */ headers?: Record; } /** * Единая реализация чтения первого StreamTitle из ICY-метаданных потока. * Раньше один и тот же state-machine разбора icy-metaint был скопирован в трёх * сервисах (icy/novoeby/love) — теперь источник один. * * Возвращает очищенный заголовок (без \x00, trim) либо null. Никогда не реджектит * (ошибки сети/таймаут → null), чтобы вызов был безопасен в Promise.allSettled. */ export function readIcyStreamTitle( url: string, opts: IcyReadOptions = {}, ): Promise { const timeoutMs = opts.timeoutMs ?? 8000; const decode = opts.decode ?? 'utf8'; return new Promise((resolve) => { let done = false; const finish = (v: string | null) => { if (!done) { done = true; resolve(v); } }; try { const lib = url.startsWith('https') ? https : http; const req = lib.get( url, { headers: { 'Icy-MetaData': '1', ...(opts.headers ?? {}) }, timeout: timeoutMs, }, (res) => { const metaint = parseInt( (res.headers['icy-metaint'] as string) || '0', ); if (!metaint) { req.destroy(); finish(null); return; } let audio = 0; let metaLen = 0; let metaBuf = Buffer.alloc(0); let state: 'audio' | 'len' | 'meta' = 'audio'; res.on('data', (chunk: Buffer) => { let off = 0; while (off < chunk.length) { if (state === 'audio') { const take = Math.min(metaint - audio, chunk.length - off); audio += take; off += take; if (audio >= metaint) state = 'len'; } else if (state === 'len') { metaLen = chunk[off] * 16; off++; if (metaLen === 0) { audio = 0; state = 'audio'; } else { metaBuf = Buffer.alloc(0); state = 'meta'; } } else { const take = Math.min( metaLen - metaBuf.length, chunk.length - off, ); metaBuf = Buffer.concat([ metaBuf, chunk.slice(off, off + take), ]); off += take; if (metaBuf.length >= metaLen) { req.destroy(); finish(extractTitle(metaBuf, decode)); return; } } } }); res.on('error', () => finish(null)); res.on('end', () => finish(null)); }, ); req.on('error', () => finish(null)); req.on('timeout', () => { req.destroy(); finish(null); }); } catch { finish(null); } }); } /** * Достаёт StreamTitle из блока ICY-метаданных. Границы ищем побайтово (latin1), * чтобы мультибайтовая кириллица не сбила смещения; терминатор — `';` (а не первый * апостроф — иначе названия с апострофом, напр. «Song's Name», обрезались бы). */ function extractTitle(buf: Buffer, decode: 'utf8' | 'auto-1251'): string | null { const latin = buf.toString('latin1'); const start = latin.indexOf("StreamTitle='"); if (start < 0) return null; const from = start + "StreamTitle='".length; const end = latin.indexOf("';", from); if (end < 0) return null; const titleBytes = buf.slice(from, end); const utf8 = titleBytes.toString('utf8'); // � (�) — признак невалидного UTF-8 → перечитываем как windows-1251. const decoded = decode === 'auto-1251' && utf8.includes('�') ? new TextDecoder('windows-1251').decode(titleBytes) : utf8; const clean = decoded.replace(/\x00/g, '').trim(); return clean || null; }