From 7e6b0c8dc642dd9b0aeb0eaf59da93f4f305b7f7 Mon Sep 17 00:00:00 2001 From: nk Date: Wed, 3 Jun 2026 16:24:33 +0300 Subject: [PATCH] =?UTF-8?q?feat(now-playing):=20DFM/=D0=9A=D1=80=D1=83?= =?UTF-8?q?=D1=82=D0=BE=D0=B9=20=D0=9C=D0=B5=D0=B4=D0=B8=D0=B0=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20dfm.ru/api/n/current?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сабканалы 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 --- src/now-playing/dfm-now-playing.service.ts | 121 +++++++++++++++++++++ src/now-playing/icy-now-playing.service.ts | 5 +- src/now-playing/now-playing.module.ts | 2 + 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 src/now-playing/dfm-now-playing.service.ts diff --git a/src/now-playing/dfm-now-playing.service.ts b/src/now-playing/dfm-now-playing.service.ts new file mode 100644 index 0000000..8343cf6 --- /dev/null +++ b/src/now-playing/dfm-now-playing.service.ts @@ -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 = { + '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 }; + }; + const data = json.result?.data; + if (!data) return; + + // Индекс: slug и его варианты (без дефисов, без числового префикса) -> current + const idx = new Map(); + 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, ''); + } +} diff --git a/src/now-playing/icy-now-playing.service.ts b/src/now-playing/icy-now-playing.service.ts index 767ba88..262f8c7 100644 --- a/src/now-playing/icy-now-playing.service.ts +++ b/src/now-playing/icy-now-playing.service.ts @@ -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 }); diff --git a/src/now-playing/now-playing.module.ts b/src/now-playing/now-playing.module.ts index eba94f7..ddc3b42 100644 --- a/src/now-playing/now-playing.module.ts +++ b/src/now-playing/now-playing.module.ts @@ -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], })