Files
radiola-backend/src/now-playing/icy-reader.ts
nk 944ec63df0 refactor(now-playing): единый IcyReader + реестр dedicated-источников
Убраны 3 копии state-machine разбора icy-metaint (icy/novoeby/love) → один
readIcyStreamTitle в icy-reader.ts (с опцией decode auto-1251 для cp1251-потоков
и корректным терминатором ';' — апострофы в названиях больше не обрезаются).

Ручной genre-notIn список в IcyNowPlayingService заменён центральным реестром
dedicated-sources.ts (host + genre), согласованным с селекторами самих сервисов:
добавил выделенный сервис — впиши host/genre в одно место, ICY его пропустит
автоматически. Исключение по хосту провабельно совпадает с тем, что сервис
реально обрабатывает (раньше genre легко забывали добавить).
2026-06-06 16:54:02 +03:00

136 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as http from 'http';
import * as https from 'https';
export interface IcyReadOptions {
/** Таймаут сокета, мс (по умолчанию 8000). */
timeoutMs?: number;
/**
* Декодирование StreamTitle:
* - 'utf8' — как есть (по умолчанию);
* - 'auto-1251' — при битом UTF-8 (символ <20>) перечитать байты как windows-1251
* (нужно потокам с кириллицей в cp1251, напр. «Новое Радио BY»).
*/
decode?: 'utf8' | 'auto-1251';
/** Доп. заголовки запроса (User-Agent и т.п.). */
headers?: Record<string, string>;
}
/**
* Единая реализация чтения первого StreamTitle из ICY-метаданных потока.
* Раньше один и тот же state-machine разбора icy-metaint был скопирован в трёх
* сервисах (icy/novoeby/love) — теперь источник один.
*
* Возвращает очищенный заголовок (без \x00, trim) либо null. Никогда не реджектит
* (ошибки сети/таймаут → null), чтобы вызов был безопасен в Promise.allSettled.
*/
export function readIcyStreamTitle(
url: string,
opts: IcyReadOptions = {},
): Promise<string | null> {
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');
// <20> (<28>) — признак невалидного UTF-8 → перечитываем как windows-1251.
const decoded =
decode === 'auto-1251' && utf8.includes('<27>')
? new TextDecoder('windows-1251').decode(titleBytes)
: utf8;
const clean = decoded.replace(/\x00/g, '').trim();
return clean || null;
}