feat(now-playing): DFM/Крутой Медиа через dfm.ru/api/n/current

Сабканалы DFM (Skrillex, Daft Punk, K-Pop, Игромания и др.) не отдают ICY-метаданные.
Единый веб-API dfm.ru/api/n/current даёт текущий трек + WebP-обложку по всем ~147
каналам (ключ slug). DfmNowPlayingService матчит наши DFM-станции по нормализованному
имени (+ числовой префикс, + алиасы для годов/Игромании/Pioneer). ICY-поллер
исключает genre=DFM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-03 16:24:33 +03:00
parent 3215dd5a4e
commit 7e6b0c8dc6
3 changed files with 126 additions and 2 deletions

View File

@@ -0,0 +1,121 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
interface DfmCurrent {
artist?: string;
title?: string;
cover?: string;
genres?: string;
}
/**
* Now-playing для станций Крутой Медиа (DFM и все его сабканалы — Skrillex,
* Daft Punk, K-Pop, Игромания и т.д.). Единый веб-API dfm.ru/api/n/current
* отдаёт текущий трек + WebP-обложку по всем ~147 каналам (ключ — slug).
* Сопоставляем наши станции (группа DFM) по нормализованному имени.
*/
@Injectable()
export class DfmNowPlayingService {
private readonly logger = new Logger(DfmNowPlayingService.name);
private readonly headers = {
'User-Agent': 'Mozilla/5.0',
Referer: 'https://dfm.ru/',
};
// Стойкие случаи: нормализованное имя нашей станции -> slug в API
private readonly alias: Record<string, string> = {
'dance-gold-00-s': 'dance-gold-2000s',
'dance-gold-10-s': 'dance-gold-2010s',
'dance-gold-20-s': 'dance-gold-2020s',
'dance-gold-90-s': 'dance-gold-1990s',
'pop-gold-00-s': 'pop-gold-2000s',
'pop-gold-10-s': 'pop-gold-2010s',
'pop-gold-20-s': 'pop-gold2020s',
'pop-gold-90-s': 'pop-gold-1990s',
'festival-gold': 'festivals-gold',
pioneer: '59-dfm-pioneer',
игромания: '61-igromaniq',
'vocal-trance': 'trance',
'disco-90th': 'diskach-90h',
};
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollDfmNowPlaying() {
const stations = await this.prisma.station.findMany({
where: { genre: 'DFM' },
});
if (stations.length === 0) return;
const res = await fetch('https://dfm.ru/api/n/current', {
headers: this.headers,
});
if (!res.ok) {
this.logger.warn(`DFM api/n/current вернул ${res.status}`);
return;
}
const json = (await res.json()) as {
result?: { data?: Record<string, { current?: DfmCurrent }> };
};
const data = json.result?.data;
if (!data) return;
// Индекс: slug и его варианты (без дефисов, без числового префикса) -> current
const idx = new Map<string, DfmCurrent>();
for (const slug of Object.keys(data)) {
const cur = data[slug].current;
if (!cur?.artist || !cur?.title) continue;
const base = slug.replace(/^\d+-/, '');
for (const key of [slug, slug.replace(/-/g, ''), base, base.replace(/-/g, '')]) {
if (!idx.has(key)) idx.set(key, cur);
}
}
let updated = 0;
for (const station of stations) {
const n = this.norm(station.name);
const aliasSlug = this.alias[n];
const cur =
idx.get(n) ??
idx.get(n.replace(/-/g, '')) ??
(aliasSlug ? data[aliasSlug]?.current : undefined);
if (!cur?.artist || !cur?.title) continue;
const cover = cur.cover
? cur.cover.startsWith('http')
? cur.cover
: `https://dfm.ru${cur.cover}`
: null;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist: cur.artist.trim(),
song: cur.title.trim(),
coverUrl: cover,
});
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}
this.logger.log(`DFM poll: ${updated}/${stations.length} обновлено`);
}
private norm(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9а-я]+/gi, '-')
.replace(/^-|-$/g, '');
}
}

View File

@@ -24,11 +24,12 @@ export class IcyNowPlayingService {
@Interval(60000)
async pollIcyNowPlaying() {
// emgsound (ЕМГ) обрабатывает EmgNowPlayingService через meta-API; их HLS-потоки
// ICY-метаданных не отдают — исключаем, чтобы не тратить слоты впустую.
// ЕМГ (emgsound) и DFM/Крутой (genre=DFM) обрабатываются отдельными сервисами
// через их API — исключаем из ICY, чтобы не тратить слоты впустую.
const where = {
recordStationId: null,
isOnline: true,
genre: { not: 'DFM' },
NOT: { streamUrl: { contains: 'emgsound.ru' } },
};
const total = await this.prisma.station.count({ where });

View File

@@ -5,6 +5,7 @@ import { NowPlayingService } from './now-playing.service';
import { RecordStationSyncService } from './record-station-sync.service';
import { IcyNowPlayingService } from './icy-now-playing.service';
import { EmgNowPlayingService } from './emg-now-playing.service';
import { DfmNowPlayingService } from './dfm-now-playing.service';
import { ChartsModule } from '../charts/charts.module';
@Module({
@@ -16,6 +17,7 @@ import { ChartsModule } from '../charts/charts.module';
RecordStationSyncService,
IcyNowPlayingService,
EmgNowPlayingService,
DfmNowPlayingService,
],
exports: [NowPlayingService],
})