feat(now-playing): DFM и др. ICY-станции — обложки + чарты + ротация

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 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-03 14:08:07 +03:00
parent 149421740f
commit f379110975
2 changed files with 115 additions and 85 deletions

View File

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