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 легко забывали добавить).
This commit is contained in:
135
src/now-playing/icy-reader.ts
Normal file
135
src/now-playing/icy-reader.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user