Files
radiola-backend/src/now-playing/dfm-now-playing.service.ts
nk 51576f7198 feat(now-playing): Радио Монте-Карло через Крутой Медиа API
Все 21 канал Монте-Карло — сеть Крутой Медиа (dfm.ru/api/n/current).
Добавил genre='Radio Monte Carlo' в DfmNowPlayingService, матчинг по
слагу из маута потока (basename без битрейта: blues96.aacp -> blues),
исключил из ICY-поллера. Чинит 5 каналов, залипших на 'Дух — Тишина'
(Blues, Chill Lounge, Italiano, Meditation, Summertime).
2026-06-04 10:55:09 +03:00

150 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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',
// MAXIMUM (тот же Крутой Медиа, тот же /api/n/current)
britpop: '130-maxbritpop',
covers: '129-maxcover',
'heavy-80-s': '131-max80',
'heavy-monday': '141-heavymonday',
'maximum-90th': '145-maximum90',
millenium: '140-millenium',
'new-russians': '125-maxnewrussians',
punk: '132-maxpunk',
rhcp: '123-maxrhcp',
'rock-hits': '144-rockhits',
rugby: '139-rugby',
'russian-rock': '90-russkijrok',
soft: '127-maxsoft',
};
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollDfmNowPlaying() {
// DFM, MAXIMUM и Радио Монте-Карло — все сети Крутой Медиа, общий API
// dfm.ru/api/n/current
const stations = await this.prisma.station.findMany({
where: { genre: { in: ['DFM', 'MAXIMUM', 'Radio Monte Carlo'] } },
});
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];
// Слаг из маута потока (basename без битрейта) — основной ключ для
// Монте-Карло (имя станции не совпадает с API-слагом, а маут совпадает:
// `mccovers96.aacp` → `mccovers`, `blues96.aacp` → `blues`).
const mount = this.mountSlug(station.streamUrl);
const cur =
idx.get(n) ??
idx.get(n.replace(/-/g, '')) ??
(mount ? idx.get(mount) : undefined) ??
(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(`Krutoy poll: ${updated}/${stations.length} обновлено`);
}
private norm(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9а-я]+/gi, '-')
.replace(/^-|-$/g, '');
}
// Слаг из маута потока: basename пути без расширения и хвостового битрейта.
// `http://mc-blues.hostingradio.ru/blues96.aacp` → `blues`.
private mountSlug(streamUrl: string): string | null {
const m = streamUrl.match(/\/([a-z0-9_-]+?)\d*\.(?:aacp|aac|mp3|m3u8)/i);
return m ? m[1].toLowerCase() : null;
}
}