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

@@ -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 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 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);
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 };
}
}