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