From f379110975bcc9c8eb0c80324fcdf5df12c8634f Mon Sep 17 00:00:00 2001 From: nk Date: Wed, 3 Jun 2026 14:08:07 +0300 Subject: [PATCH] =?UTF-8?q?feat(now-playing):=20DFM=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D1=80.=20ICY-=D1=81=D1=82=D0=B0=D0=BD=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D0=BE=D0=B1=D0=BB=D0=BE=D0=B6=D0=BA=D0=B8=20+=20?= =?UTF-8?q?=D1=87=D0=B0=D1=80=D1=82=D1=8B=20+=20=D1=80=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ICY-станции (DFM и пр.) теперь полноценно «как Record»: - ICY-поллер вызывает recordPlay → треки идут в чарты и обогащаются Discogs, откуда берётся обложка (раньше now_playing писался напрямую, мимо чартов) - обложка now-playing: если источник не дал (ICY всегда null) — подставляем обложку обогащённого трека из нашей БД по normKey (NowPlayingService.resolveCover) - ротация курсора по всем станциям (окно 70) вместо первых 50 по кругу — раньше 363 из 413 станций не опрашивались - общий NowPlayingService.ingest для Record и ICY (дедуп логики) Co-Authored-By: Claude Opus 4.8 --- src/now-playing/icy-now-playing.service.ts | 63 +++++----- src/now-playing/now-playing.service.ts | 137 +++++++++++++-------- 2 files changed, 115 insertions(+), 85 deletions(-) diff --git a/src/now-playing/icy-now-playing.service.ts b/src/now-playing/icy-now-playing.service.ts index e37b42e..3dc0519 100644 --- a/src/now-playing/icy-now-playing.service.ts +++ b/src/now-playing/icy-now-playing.service.ts @@ -1,26 +1,42 @@ import { Injectable, Logger } from '@nestjs/common'; import { Interval } from '@nestjs/schedule'; import { PrismaService } from '../prisma/prisma.service'; -import { NowPlayingGateway } from './now-playing.gateway'; +import { NowPlayingService } from './now-playing.service'; import * as http from 'http'; import * as https from 'https'; +/** + * Сбор 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 gateway: NowPlayingGateway, + private readonly nowPlayingService: NowPlayingService, ) {} @Interval(60000) async pollIcyNowPlaying() { - this.logger.log('Starting ICY now playing poll...'); + const where = { recordStationId: null, isOnline: true }; + 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: { recordStationId: null, isOnline: true }, - take: 50, + where, + orderBy: { stationId: 'asc' }, + skip: offset, + take: this.windowSize, }); + this.cursor += this.windowSize; let successCount = 0; @@ -29,47 +45,26 @@ export class IcyNowPlayingService { const results = await Promise.allSettled( batch.map(async (station) => { const track = await this.parseIcyMetadata(station.streamUrl); - if (!track) return null; + if (!track || !track.artist || !track.song) return null; - const updated = await this.prisma.nowPlaying.upsert({ - where: { stationId: station.id }, - create: { - stationId: station.id, - song: track.song, - artist: track.artist, - coverUrl: null, - }, - update: { - song: track.song, - artist: track.artist, - coverUrl: null, - }, - }); - - this.gateway.broadcastNowPlaying(station.stationId.toString(), { - song: track.song, + await this.nowPlayingService.ingest({ + stationDbId: station.id, + stationNumericId: station.stationId, artist: track.artist, + song: track.song, coverUrl: null, - updatedAt: updated.updatedAt, }); return track; }), ); - for (let j = 0; j < results.length; j++) { - const result = results[j]; - if (result.status === 'fulfilled' && result.value) { - successCount++; - } else if (result.status === 'rejected') { - this.logger.warn( - `ICY failed for ${batch[j].name}: ${result.reason?.message || result.reason}`, - ); - } + for (const result of results) { + if (result.status === 'fulfilled' && result.value) successCount++; } } this.logger.log( - `ICY poll complete: ${successCount}/${stations.length} stations updated`, + `ICY poll: ${successCount}/${stations.length} updated (offset ${offset}/${total})`, ); } diff --git a/src/now-playing/now-playing.service.ts b/src/now-playing/now-playing.service.ts index 05abfe7..7ebe39d 100644 --- a/src/now-playing/now-playing.service.ts +++ b/src/now-playing/now-playing.service.ts @@ -56,49 +56,17 @@ export class NowPlayingService { const mapping = this.recordSync.getStationByNowPlayingId(np.id); if (!mapping) continue; - const coverUrl = np.track.image600 ?? np.track.image200 ?? np.track.image100; + const coverUrl = + np.track.image600 ?? np.track.image200 ?? np.track.image100; - // Получаем текущее состояние до апдейта, чтобы определить смену трека - const prev = await this.prisma.nowPlaying.findUnique({ - where: { stationId: mapping.dbId }, - }); - - const updated = await this.prisma.nowPlaying.upsert({ - where: { stationId: mapping.dbId }, - create: { - stationId: mapping.dbId, - song: np.track.song, - artist: np.track.artist, - coverUrl, - }, - update: { - song: np.track.song, - artist: np.track.artist, - coverUrl, - }, - }); - - this.gateway.broadcastNowPlaying(mapping.stationId.toString(), { - song: np.track.song, + await this.ingest({ + stationDbId: mapping.dbId, + stationNumericId: mapping.stationId, artist: np.track.artist, + song: np.track.song, coverUrl, - updatedAt: updated.updatedAt, }); updatedCount++; - - // Засчитываем проигрывание только при смене трека - const trackChanged = - !prev || - prev.song !== np.track.song || - prev.artist !== np.track.artist; - if (trackChanged) { - void this.chartsService.recordPlay({ - artist: np.track.artist, - song: np.track.song, - coverUrl, - stationDbId: mapping.dbId, - }); - } } this.logger.log( @@ -113,6 +81,12 @@ export class NowPlayingService { stationId: string, data: { song: string; artist: string; coverUrl?: string }, ) { + const coverUrl = await this.resolveCover( + data.artist, + data.song, + data.coverUrl, + ); + // Получаем текущее состояние до апдейта, чтобы определить смену трека const prev = await this.prisma.nowPlaying.findUnique({ where: { stationId }, @@ -120,23 +94,14 @@ export class NowPlayingService { const nowPlaying = await this.prisma.nowPlaying.upsert({ where: { stationId }, - create: { - stationId, - song: data.song, - artist: data.artist, - coverUrl: data.coverUrl, - }, - update: { - song: data.song, - artist: data.artist, - coverUrl: data.coverUrl, - }, + create: { stationId, song: data.song, artist: data.artist, coverUrl }, + update: { song: data.song, artist: data.artist, coverUrl }, }); this.gateway.broadcastNowPlaying(stationId, { song: data.song, artist: data.artist, - coverUrl: data.coverUrl, + coverUrl, updatedAt: nowPlaying.updatedAt, }); @@ -147,7 +112,7 @@ export class NowPlayingService { void this.chartsService.recordPlay({ artist: data.artist, song: data.song, - coverUrl: data.coverUrl, + coverUrl, stationDbId: stationId, }); } @@ -155,6 +120,76 @@ export class NowPlayingService { return nowPlaying; } + /** + * Универсальный приём now-playing из любого источника (Record / ICY). + * Если источник не дал обложку — подставляем обложку обогащённого трека + * из нашей БД (по normKey). Обновляет now_playing, шлёт сокет, засчитывает + * проигрывание при смене трека (что запускает обогащение через Discogs). + */ + async ingest(params: { + stationDbId: string; + stationNumericId: number; + artist: string; + song: string; + coverUrl?: string | null; + }): Promise { + const { stationDbId, stationNumericId, artist, song } = params; + const coverUrl = await this.resolveCover(artist, song, params.coverUrl); + + const prev = await this.prisma.nowPlaying.findUnique({ + where: { stationId: stationDbId }, + }); + + const updated = await this.prisma.nowPlaying.upsert({ + where: { stationId: stationDbId }, + create: { stationId: stationDbId, song, artist, coverUrl }, + update: { song, artist, coverUrl }, + }); + + this.gateway.broadcastNowPlaying(stationNumericId.toString(), { + song, + artist, + coverUrl, + updatedAt: updated.updatedAt, + }); + + const trackChanged = !prev || prev.song !== song || prev.artist !== artist; + if (trackChanged) { + void this.chartsService.recordPlay({ + artist, + song, + coverUrl, + stationDbId, + }); + } + } + + // Нормализованный ключ трека — совпадает с ChartsService.recordPlay + private buildNormKey(artist: string, song: string): string { + return ( + artist.trim().toLowerCase().replace(/\s+/g, ' ') + + '|' + + song.trim().toLowerCase().replace(/\s+/g, ' ') + ); + } + + // Обложка: если источник дал — берём её, иначе обложку из обогащённого трека + private async resolveCover( + artist: string, + song: string, + provided?: string | null, + ): Promise { + if (provided) return provided; + const a = (artist ?? '').trim(); + const s = (song ?? '').trim(); + if (!a || !s) return null; + const track = await this.prisma.track.findUnique({ + where: { normKey: this.buildNormKey(a, s) }, + select: { coverUrl: true }, + }); + return track?.coverUrl ?? null; + } + async getNowPlaying(stationId: string) { return this.prisma.nowPlaying.findUnique({ where: { stationId },