Убраны 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 легко забывали добавить).
97 lines
3.8 KiB
TypeScript
97 lines
3.8 KiB
TypeScript
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 };
|
||
}
|
||
}
|