Files
radiola-backend/src/now-playing/icy-now-playing.service.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

97 lines
3.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 { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
import { readIcyStreamTitle } from './icy-reader';
import { DEDICATED_GENRES, DEDICATED_STREAM_HOSTS } from './dedicated-sources';
/**
* Сбор now-playing для не-Record станций (DFM и др.) через ICY-метаданные потока.
* Станций много (сотни), поэтому за один тик опрашиваем окно и сдвигаем курсор —
* за несколько минут проходим все по кругу. Обложку и зачёт в чарты/обогащение
* берёт на себя NowPlayingService.ingest (обложка подтянется из нашей БД).
*/
@Injectable()
export class IcyNowPlayingService {
private readonly logger = new Logger(IcyNowPlayingService.name);
private cursor = 0;
private readonly windowSize = 70;
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(60000)
async pollIcyNowPlaying() {
// Станции с выделенным now-playing-сервисом (через их API) исключаем из ICY,
// чтобы не тратить слоты впустую и не перезаписывать точные данные сырым ICY.
// Источник исключений — единый реестр dedicated-sources (host + genre),
// согласованный с селекторами самих сервисов.
const where = {
recordStationId: null,
isOnline: true,
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;
if (this.cursor >= total) this.cursor = 0;
const offset = this.cursor;
const stations = await this.prisma.station.findMany({
where,
orderBy: { stationId: 'asc' },
skip: offset,
take: this.windowSize,
});
this.cursor += this.windowSize;
let successCount = 0;
for (let i = 0; i < stations.length; i += 10) {
const batch = stations.slice(i, i + 10);
const results = await Promise.allSettled(
batch.map(async (station) => {
const track = await this.parseIcyTrack(station.streamUrl);
if (!track) return null;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist: track.artist,
song: track.song,
coverUrl: null,
});
return track;
}),
);
for (const result of results) {
if (result.status === 'fulfilled' && result.value) successCount++;
}
}
this.logger.log(
`ICY poll: ${successCount}/${stations.length} updated (offset ${offset}/${total})`,
);
}
/** Читает StreamTitle через общий ICY-ридер и разбирает «Артист - Песня». */
private async parseIcyTrack(
url: string,
): Promise<{ artist: string; song: string } | null> {
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 };
}
}