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:
nk
2026-06-06 16:54:02 +03:00
parent a3434ed894
commit 944ec63df0
5 changed files with 204 additions and 295 deletions

View File

@@ -0,0 +1,36 @@
/**
* Единый реестр станций, у которых ЕСТЬ выделенный now-playing-сервис (берёт трек
* из их API или особого ICY). Общий ICY-поллер (IcyNowPlayingService) обязан их
* ПРОПУСКАТЬ — иначе двойной опрос и перезапись точных данных «сырым» ICY.
*
* ⚠️ Добавил новый dedicated-сервис — впиши сюда его признак:
* • host — если сервис выбирает станции по `streamUrl.contains(host)`
* • genre — если сервис выбирает станции по `genre`
* Список один-в-один повторяет селекторы сервисов, поэтому исключение в ICY
* всегда согласовано с тем, что реально обрабатывает выделенный сервис
* (раньше это был ручной genre-список, который легко забывали обновить).
*/
// Сервисы, выбирающие станции по хосту потока (streamUrl.contains)
export const DEDICATED_STREAM_HOSTS = [
'emgsound.ru', // EmgNowPlayingService
'radio7.hostingradio.ru', // EmgNowPlayingService (старые мейны Radio 7)
'unistar.by', // UnistarNowPlayingService
'abs.zaycev.fm', // ZaycevNowPlayingService
'radiogoose.ru', // GooseNowPlayingService
'novoeradio.by', // NovoeByNowPlayingService
'radio.orpheus.ru', // OrpheusNowPlayingService
'.101.ru', // Radio101NowPlayingService (Comedy Radio, Радио Energy)
'amgradio.ru', // VolnaNowPlayingService (Русская Волна)
'piterfm.cdnvideo.ru', // SpbRadioNowPlayingService (Питер ФМ)
'radiovanya.cdnvideo.ru', // SpbRadioNowPlayingService (Радио Ваня)
] as const;
// Сервисы, выбирающие станции по жанру (genre)
export const DEDICATED_GENRES = [
'DFM', // DfmNowPlayingService
'MAXIMUM', // DfmNowPlayingService
'Radio Monte Carlo', // DfmNowPlayingService
'Love Radio', // LoveNowPlayingService
'Radio ROKS', // RoksNowPlayingService
] as const;

View File

@@ -2,8 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
import * as http from 'http';
import * as https from 'https';
import { readIcyStreamTitle } from './icy-reader';
import { DEDICATED_GENRES, DEDICATED_STREAM_HOSTS } from './dedicated-sources';
/**
* Сбор now-playing для не-Record станций (DFM и др.) через ICY-метаданные потока.
@@ -24,32 +24,17 @@ export class IcyNowPlayingService {
@Interval(60000)
async pollIcyNowPlaying() {
// ЕМГ (emgsound) и DFM/Крутой (genre=DFM) обрабатываются отдельными сервисами
// через их API — исключаем из ICY, чтобы не тратить слоты впустую.
// Станции с выделенным now-playing-сервисом (через их API) исключаем из ICY,
// чтобы не тратить слоты впустую и не перезаписывать точные данные сырым ICY.
// Источник исключений — единый реестр dedicated-sources (host + genre),
// согласованный с селекторами самих сервисов.
const where = {
recordStationId: null,
isOnline: true,
genre: {
notIn: [
'DFM',
'MAXIMUM',
'Love Radio',
'Radio Monte Carlo',
'Radio ROKS',
'Unistar',
'Zaicev FM',
'Гусь',
'Новое Радио BY',
'Питер ФМ',
'Орфей',
'Радио Ваня',
'Русская Волна',
'Comedy Radio',
'Радио Energy',
'Radio 7',
],
},
NOT: { streamUrl: { contains: 'emgsound.ru' } },
genre: { notIn: [...DEDICATED_GENRES] },
AND: DEDICATED_STREAM_HOSTS.map((host) => ({
NOT: { streamUrl: { contains: host } },
})),
};
const total = await this.prisma.station.count({ where });
if (total === 0) return;
@@ -70,8 +55,8 @@ export class IcyNowPlayingService {
const batch = stations.slice(i, i + 10);
const results = await Promise.allSettled(
batch.map(async (station) => {
const track = await this.parseIcyMetadata(station.streamUrl);
if (!track || !track.artist || !track.song) return null;
const track = await this.parseIcyTrack(station.streamUrl);
if (!track) return null;
await this.nowPlayingService.ingest({
stationDbId: station.id,
@@ -94,108 +79,18 @@ export class IcyNowPlayingService {
);
}
private async parseIcyMetadata(
/** Читает StreamTitle через общий ICY-ридер и разбирает «Артист - Песня». */
private async parseIcyTrack(
url: string,
): Promise<{ artist: string; song: string } | null> {
return new Promise((resolve, reject) => {
const client = url.startsWith('https') ? https : http;
const req = client.get(
url,
{ headers: { 'Icy-MetaData': '1' }, timeout: 5000 },
(res) => {
const metaint = parseInt(
(res.headers['icy-metaint'] as string) || '0',
);
if (!metaint) {
req.destroy();
resolve(null);
return;
}
let audioBytesRead = 0;
let metaLength = 0;
let metaBytesRead = 0;
let metaBuffer = Buffer.alloc(0);
let state: 'audio' | 'meta-length' | 'meta' = 'audio';
res.on('data', (chunk: Buffer) => {
let offset = 0;
while (offset < chunk.length) {
if (state === 'audio') {
const need = metaint - audioBytesRead;
const available = chunk.length - offset;
const take = Math.min(need, available);
audioBytesRead += take;
offset += take;
if (audioBytesRead >= metaint) {
state = 'meta-length';
}
} else if (state === 'meta-length') {
metaLength = chunk[offset] * 16;
offset++;
if (metaLength === 0) {
audioBytesRead = 0;
state = 'audio';
} else {
metaBuffer = Buffer.alloc(0);
metaBytesRead = 0;
state = 'meta';
}
} else if (state === 'meta') {
const need = metaLength - metaBytesRead;
const available = chunk.length - offset;
const take = Math.min(need, available);
metaBuffer = Buffer.concat([
metaBuffer,
chunk.slice(offset, offset + take),
]);
metaBytesRead += take;
offset += take;
if (metaBytesRead >= metaLength) {
const metaStr = metaBuffer
.toString('utf-8')
.replace(/\x00/g, '');
const match = metaStr.match(/StreamTitle='([^']+)'/);
req.destroy();
if (!match) {
resolve(null);
return;
}
const raw = match[1].trim();
// Некоторые потоки (101.ru и др.) шлют в StreamTitle JSON-статус,
// а не название трека — это не трек, отсекаем.
if (raw.startsWith('{') || raw.startsWith('[')) {
resolve(null);
return;
}
const raw = await readIcyStreamTitle(url, { timeoutMs: 5000 });
if (!raw) return null;
// Некоторые потоки (101.ru и др.) шлют в StreamTitle JSON-статус, а не трек.
if (raw.startsWith('{') || raw.startsWith('[')) return null;
const parts = raw.split(' - ', 2);
if (parts.length < 2) {
resolve({ artist: raw, song: raw });
} else {
resolve({
artist: parts[0].trim(),
song: parts[1].trim(),
});
}
return;
}
}
}
});
res.on('error', (err) => {
req.destroy();
reject(err);
});
res.on('end', () => resolve(null));
},
);
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('Timeout'));
});
});
const artist = parts.length < 2 ? raw : parts[0].trim();
const song = parts.length < 2 ? raw : parts[1].trim();
if (!artist || !song) return null;
return { artist, song };
}
}

View 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;
}

View File

@@ -2,8 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
import * as http from 'http';
import * as https from 'https';
import { readIcyStreamTitle } from './icy-reader';
/**
* Now-playing для Love Radio. Их API (player/online) кэширует один трек на все
@@ -52,7 +51,9 @@ export class LoveNowPlayingService {
const m = this.mount[station.name];
if (!m) return;
const url = `https://stream2.n340.com/${m}?type=aac&UID=${uid}`;
const title = await this.readIcyTitle(url);
const title = await readIcyStreamTitle(url, {
headers: { 'User-Agent': 'Mozilla/5.0' },
});
if (!title) return;
// «onlinestop56k» = заглушка (UID протух) — сбросим, добёрём на след. цикле
if (title === 'onlinestop56k') {
@@ -108,79 +109,4 @@ export class LoveNowPlayingService {
}
}
// Читает первый StreamTitle из ICY-метаданных потока
private readIcyTitle(url: string): Promise<string | null> {
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', 'User-Agent': 'Mozilla/5.0' }, timeout: 8000 },
(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 metaRead = 0;
let buf = 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 {
buf = Buffer.alloc(0);
metaRead = 0;
state = 'meta';
}
} else {
const take = Math.min(metaLen - metaRead, chunk.length - off);
buf = Buffer.concat([buf, chunk.slice(off, off + take)]);
metaRead += take;
off += take;
if (metaRead >= metaLen) {
req.destroy();
const s = buf.toString('utf-8').replace(/\x00/g, '');
const mt = s.match(/StreamTitle='([^']*)'/);
finish(mt ? mt[1].trim() : null);
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);
}
});
}
}

View File

@@ -2,8 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
import * as http from 'http';
import * as https from 'https';
import { readIcyStreamTitle } from './icy-reader';
/**
* Now-playing для «Новое Радио BY» (Беларусь, live.novoeradio.by). Их Icecast-мейны
@@ -31,8 +30,11 @@ export class NovoeByNowPlayingService {
let updated = 0;
await Promise.allSettled(
stations.map(async (station) => {
const title = await this.readIcyTitle(station.streamUrl).catch(() => null);
if (!title) return;
// Кириллица в их ICY — windows-1251 (decode: auto-1251).
const title = await readIcyStreamTitle(station.streamUrl, {
decode: 'auto-1251',
});
if (!title || title.startsWith('{') || title.startsWith('[')) return;
// Джинглы/заставки без трека («NOVOE RADIO MEGAMIX» и т.п.) — пропускаем.
const sep = title.indexOf(' - ');
@@ -60,89 +62,4 @@ export class NovoeByNowPlayingService {
this.logger.log(`NovoeBY poll: ${updated}/${stations.length} обновлено`);
}
/** Читает StreamTitle из ICY-потока; декод UTF-8, при невалидных байтах — windows-1251. */
private readIcyTitle(url: string): Promise<string | null> {
return new Promise((resolve, reject) => {
const client = url.startsWith('https') ? https : http;
const req = client.get(
url,
{ headers: { 'Icy-MetaData': '1' }, timeout: 8000 },
(res) => {
const metaint = parseInt((res.headers['icy-metaint'] as string) || '0');
if (!metaint) {
req.destroy();
resolve(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 offset = 0;
while (offset < chunk.length) {
if (state === 'audio') {
const take = Math.min(metaint - audio, chunk.length - offset);
audio += take;
offset += take;
if (audio >= metaint) state = 'len';
} else if (state === 'len') {
metaLen = chunk[offset] * 16;
offset++;
if (metaLen === 0) {
audio = 0;
state = 'audio';
} else {
metaBuf = Buffer.alloc(0);
state = 'meta';
}
} else {
const take = Math.min(metaLen - metaBuf.length, chunk.length - offset);
metaBuf = Buffer.concat([metaBuf, chunk.slice(offset, offset + take)]);
offset += take;
if (metaBuf.length >= metaLen) {
req.destroy();
resolve(this.extractTitle(metaBuf));
return;
}
}
}
});
res.on('error', (err) => {
req.destroy();
reject(err);
});
res.on('end', () => resolve(null));
},
);
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('timeout'));
});
});
}
/** Достаёт StreamTitle из блока метаданных с корректной кодировкой. */
private extractTitle(buf: Buffer): string | null {
// Границы по latin1 (1 байт = 1 символ) — чтобы не сбить смещения мультибайтом.
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> — признак невалидного UTF-8 → это windows-1251.
const decoded = utf8.includes('<27>')
? new TextDecoder('windows-1251').decode(titleBytes)
: utf8;
const clean = decoded.replace(/\x00/g, '').trim();
if (!clean || clean.startsWith('{') || clean.startsWith('[')) return null;
return clean;
}
}