Радио Ваня (20 каналов) — тот же движок/API, что Питер ФМ (один разработчик):
объединил в SpbRadioNowPlayingService (NETWORKS=[piterfm, radiovanya]), матч
станции по МАУНТУ из поля link (у Вани slug≠маунт). Обложки iTunes.
Русская Волна (~27, amgradio.ru, ICY нет) — VolnaNowPlayingService: единый
info.volna.top/radio.json, поля {prefix}_title, маунт→префикс (RusRock128→rusrock,
ChillaFM128→chilla). Обложки через обогащение. Оба жанра исключены из ICY-поллера.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
100 lines
3.5 KiB
TypeScript
100 lines
3.5 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
||
import { Interval } from '@nestjs/schedule';
|
||
import { PrismaService } from '../prisma/prisma.service';
|
||
import { NowPlayingService } from './now-playing.service';
|
||
|
||
/**
|
||
* Now-playing для «Русская Волна» (сеть Волна / amgradio.ru, ~27 каналов). Потоки
|
||
* mp3.amgradio.ru НЕ отдают ICY. Единый JSON со всеми каналами:
|
||
* `https://info.volna.top/radio.json` → поля `{prefix}_title` = «АРТИСТ - ПЕСНЯ».
|
||
* Маунт нашего потока (RusRock128, ChillaFM128…) приводим к префиксу volna
|
||
* (rusrock, chilla — с fallback на отброс «fm»). Обложек у них нет (covers 404) →
|
||
* подтянет обогащение (iTunes/Deezer).
|
||
*/
|
||
@Injectable()
|
||
export class VolnaNowPlayingService {
|
||
private readonly logger = new Logger(VolnaNowPlayingService.name);
|
||
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
|
||
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly nowPlayingService: NowPlayingService,
|
||
) {}
|
||
|
||
@Interval(30000)
|
||
async pollVolnaNowPlaying() {
|
||
const stations = await this.prisma.station.findMany({
|
||
where: { streamUrl: { contains: 'amgradio.ru' } },
|
||
});
|
||
if (stations.length === 0) return;
|
||
|
||
let data: Record<string, unknown> | null = null;
|
||
try {
|
||
const res = await fetch('https://info.volna.top/radio.json', {
|
||
headers: this.headers,
|
||
});
|
||
if (res.ok) data = (await res.json()) as Record<string, unknown>;
|
||
} catch {
|
||
data = null;
|
||
}
|
||
if (!data) return;
|
||
|
||
const prefixes = new Set(
|
||
Object.keys(data)
|
||
.filter((k) => k.endsWith('_title'))
|
||
.map((k) => k.slice(0, -'_title'.length)),
|
||
);
|
||
|
||
let updated = 0;
|
||
await Promise.allSettled(
|
||
stations.map(async (station) => {
|
||
const prefix = this.resolvePrefix(station.streamUrl, prefixes);
|
||
if (!prefix) return;
|
||
const raw = data[`${prefix}_title`];
|
||
const parsed = this.parseTitle(typeof raw === 'string' ? raw : undefined);
|
||
if (!parsed) return;
|
||
|
||
await this.nowPlayingService.ingest({
|
||
stationDbId: station.id,
|
||
stationNumericId: station.stationId,
|
||
artist: parsed.artist,
|
||
song: parsed.song,
|
||
coverUrl: null,
|
||
});
|
||
if (!station.isOnline) {
|
||
await this.prisma.station.update({
|
||
where: { id: station.id },
|
||
data: { isOnline: true },
|
||
});
|
||
}
|
||
updated++;
|
||
}),
|
||
);
|
||
this.logger.log(`Volna poll: ${updated}/${stations.length} обновлено`);
|
||
}
|
||
|
||
// mp3.amgradio.ru/RusRock128 → rusrock ; ChillaFM128 → chilla (fallback -fm)
|
||
private resolvePrefix(streamUrl: string, prefixes: Set<string>): string | null {
|
||
const m = streamUrl.match(/amgradio\.ru\/([A-Za-z0-9_]+)/i);
|
||
if (!m) return null;
|
||
const norm = m[1].replace(/\d+$/, '').toLowerCase();
|
||
if (prefixes.has(norm)) return norm;
|
||
if (norm.endsWith('fm') && prefixes.has(norm.slice(0, -2))) {
|
||
return norm.slice(0, -2);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private parseTitle(title?: string): { artist: string; song: string } | null {
|
||
if (!title) return null;
|
||
const t = title.trim();
|
||
if (!t || /listen radio|^https?:/i.test(t)) return null;
|
||
const idx = t.indexOf(' - ');
|
||
if (idx < 0) return null;
|
||
const artist = t.slice(0, idx).trim();
|
||
const song = t.slice(idx + 3).trim();
|
||
if (!artist || !song) return null;
|
||
return { artist, song };
|
||
}
|
||
}
|