feat(now-playing): Радио Ваня + Русская Волна; Питер объединён в SpbRadio
Радио Ваня (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>
This commit is contained in:
@@ -42,6 +42,8 @@ export class IcyNowPlayingService {
|
|||||||
'Новое Радио BY',
|
'Новое Радио BY',
|
||||||
'Питер ФМ',
|
'Питер ФМ',
|
||||||
'Орфей',
|
'Орфей',
|
||||||
|
'Радио Ваня',
|
||||||
|
'Русская Волна',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
NOT: { streamUrl: { contains: 'emgsound.ru' } },
|
NOT: { streamUrl: { contains: 'emgsound.ru' } },
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { UnistarNowPlayingService } from './unistar-now-playing.service';
|
|||||||
import { ZaycevNowPlayingService } from './zaycev-now-playing.service';
|
import { ZaycevNowPlayingService } from './zaycev-now-playing.service';
|
||||||
import { GooseNowPlayingService } from './goose-now-playing.service';
|
import { GooseNowPlayingService } from './goose-now-playing.service';
|
||||||
import { NovoeByNowPlayingService } from './novoeby-now-playing.service';
|
import { NovoeByNowPlayingService } from './novoeby-now-playing.service';
|
||||||
import { PiterFmNowPlayingService } from './piterfm-now-playing.service';
|
import { SpbRadioNowPlayingService } from './spb-radio-now-playing.service';
|
||||||
|
import { VolnaNowPlayingService } from './volna-now-playing.service';
|
||||||
import { OrpheusNowPlayingService } from './orpheus-now-playing.service';
|
import { OrpheusNowPlayingService } from './orpheus-now-playing.service';
|
||||||
import { ChartsModule } from '../charts/charts.module';
|
import { ChartsModule } from '../charts/charts.module';
|
||||||
|
|
||||||
@@ -32,7 +33,8 @@ import { ChartsModule } from '../charts/charts.module';
|
|||||||
ZaycevNowPlayingService,
|
ZaycevNowPlayingService,
|
||||||
GooseNowPlayingService,
|
GooseNowPlayingService,
|
||||||
NovoeByNowPlayingService,
|
NovoeByNowPlayingService,
|
||||||
PiterFmNowPlayingService,
|
SpbRadioNowPlayingService,
|
||||||
|
VolnaNowPlayingService,
|
||||||
OrpheusNowPlayingService,
|
OrpheusNowPlayingService,
|
||||||
],
|
],
|
||||||
exports: [NowPlayingService],
|
exports: [NowPlayingService],
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Interval } from '@nestjs/schedule';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { NowPlayingService } from './now-playing.service';
|
|
||||||
|
|
||||||
interface PiterStream {
|
|
||||||
id: number;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
interface PiterPlaylist {
|
|
||||||
items?: Array<{
|
|
||||||
track?: {
|
|
||||||
name?: string;
|
|
||||||
imglarge?: string;
|
|
||||||
imgsmall?: string;
|
|
||||||
artist?: { name?: string };
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Now-playing для Питер ФМ (radiopiterfm.ru, Icecast cdnvideo). Потоки НЕ дают трек
|
|
||||||
* по ICY (пусто/URL сайта), но у сайта есть API:
|
|
||||||
* `/api/v1/streams/` → список (slug↔id), `/api/v5/playlists/{id}/` → items[0].track
|
|
||||||
* {name, artist.name, imglarge} с готовой обложкой (iTunes mzstatic). slug = последний
|
|
||||||
* сегмент пути нашего stream_url (piterfm, pfm_ddt, …).
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class PiterFmNowPlayingService {
|
|
||||||
private readonly logger = new Logger(PiterFmNowPlayingService.name);
|
|
||||||
private readonly base = 'https://radiopiterfm.ru';
|
|
||||||
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly nowPlayingService: NowPlayingService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Interval(30000)
|
|
||||||
async pollPiterFmNowPlaying() {
|
|
||||||
const stations = await this.prisma.station.findMany({
|
|
||||||
where: { streamUrl: { contains: 'piterfm.cdnvideo.ru' } },
|
|
||||||
});
|
|
||||||
if (stations.length === 0) return;
|
|
||||||
|
|
||||||
// slug → id из списка стримов
|
|
||||||
const slugToId = await this.loadStreamMap();
|
|
||||||
if (!slugToId) return;
|
|
||||||
|
|
||||||
let updated = 0;
|
|
||||||
await Promise.allSettled(
|
|
||||||
stations.map(async (station) => {
|
|
||||||
const slug = this.extractSlug(station.streamUrl);
|
|
||||||
const id = slug ? slugToId[slug] : undefined;
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
const res = await fetch(`${this.base}/api/v5/playlists/${id}/`, {
|
|
||||||
headers: this.headers,
|
|
||||||
});
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = (await res.json()) as PiterPlaylist;
|
|
||||||
const t = data.items?.[0]?.track;
|
|
||||||
const artist = (t?.artist?.name ?? '').trim();
|
|
||||||
const song = (t?.name ?? '').trim();
|
|
||||||
if (!artist || !song) return;
|
|
||||||
|
|
||||||
await this.nowPlayingService.ingest({
|
|
||||||
stationDbId: station.id,
|
|
||||||
stationNumericId: station.stationId,
|
|
||||||
artist,
|
|
||||||
song,
|
|
||||||
coverUrl: t?.imglarge ?? t?.imgsmall ?? null,
|
|
||||||
});
|
|
||||||
if (!station.isOnline) {
|
|
||||||
await this.prisma.station.update({
|
|
||||||
where: { id: station.id },
|
|
||||||
data: { isOnline: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
updated++;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`PiterFM poll: ${updated}/${stations.length} обновлено`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadStreamMap(): Promise<Record<string, number> | null> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${this.base}/api/v1/streams/`, {
|
|
||||||
headers: this.headers,
|
|
||||||
});
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const data = (await res.json()) as { items?: PiterStream[] };
|
|
||||||
const map: Record<string, number> = {};
|
|
||||||
for (const s of data.items ?? []) {
|
|
||||||
if (s.slug) map[s.slug] = s.id;
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// http://icecast-piterfm.cdnvideo.ru/pfm_ddt → pfm_ddt
|
|
||||||
private extractSlug(streamUrl: string): string | null {
|
|
||||||
const m = streamUrl.match(/cdnvideo\.ru\/([a-z0-9_]+)/i);
|
|
||||||
return m ? m[1].toLowerCase() : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
117
src/now-playing/spb-radio-now-playing.service.ts
Normal file
117
src/now-playing/spb-radio-now-playing.service.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Interval } from '@nestjs/schedule';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { NowPlayingService } from './now-playing.service';
|
||||||
|
|
||||||
|
interface SpbStream {
|
||||||
|
id: number;
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
interface SpbPlaylist {
|
||||||
|
items?: Array<{
|
||||||
|
track?: {
|
||||||
|
name?: string;
|
||||||
|
imglarge?: string;
|
||||||
|
imgsmall?: string;
|
||||||
|
artist?: { name?: string };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сети на одном движке (radiopiterfm.ru / radiovanya.ru — один разработчик, СПб):
|
||||||
|
// API `/api/v1/streams/` (link↔id) + `/api/v5/playlists/{id}/` → items[0].track.
|
||||||
|
const NETWORKS = [
|
||||||
|
{ base: 'https://radiopiterfm.ru', host: 'piterfm.cdnvideo.ru', label: 'PiterFM' },
|
||||||
|
{ base: 'https://radiovanya.ru', host: 'radiovanya.cdnvideo.ru', label: 'RadioVanya' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Now-playing для Питер ФМ и Радио Ваня. Потоки (cdnvideo Icecast) НЕ дают трек по ICY
|
||||||
|
* (пусто / URL сайта), но у сайтов общий API. Матчим станцию по МАУНТУ из поля `link`
|
||||||
|
* стрима (у Вани slug≠маунт, поэтому не по slug). Обложки готовые (iTunes mzstatic).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SpbRadioNowPlayingService {
|
||||||
|
private readonly logger = new Logger(SpbRadioNowPlayingService.name);
|
||||||
|
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly nowPlayingService: NowPlayingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Interval(30000)
|
||||||
|
async pollSpbNowPlaying() {
|
||||||
|
for (const net of NETWORKS) {
|
||||||
|
await this.pollNetwork(net).catch((e) =>
|
||||||
|
this.logger.warn(`${net.label}: ${e?.message ?? e}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollNetwork(net: (typeof NETWORKS)[number]) {
|
||||||
|
const stations = await this.prisma.station.findMany({
|
||||||
|
where: { streamUrl: { contains: net.host } },
|
||||||
|
});
|
||||||
|
if (stations.length === 0) return;
|
||||||
|
|
||||||
|
const mountToId = await this.loadStreamMap(net.base);
|
||||||
|
if (!mountToId) return;
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
await Promise.allSettled(
|
||||||
|
stations.map(async (station) => {
|
||||||
|
const mount = this.extractMount(station.streamUrl);
|
||||||
|
const id = mount ? mountToId[mount.toLowerCase()] : undefined;
|
||||||
|
if (id === undefined) return;
|
||||||
|
|
||||||
|
const res = await fetch(`${net.base}/api/v5/playlists/${id}/`, {
|
||||||
|
headers: this.headers,
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const t = ((await res.json()) as SpbPlaylist).items?.[0]?.track;
|
||||||
|
const artist = (t?.artist?.name ?? '').trim();
|
||||||
|
const song = (t?.name ?? '').trim();
|
||||||
|
if (!artist || !song) return;
|
||||||
|
|
||||||
|
await this.nowPlayingService.ingest({
|
||||||
|
stationDbId: station.id,
|
||||||
|
stationNumericId: station.stationId,
|
||||||
|
artist,
|
||||||
|
song,
|
||||||
|
coverUrl: t?.imglarge ?? t?.imgsmall ?? null,
|
||||||
|
});
|
||||||
|
if (!station.isOnline) {
|
||||||
|
await this.prisma.station.update({
|
||||||
|
where: { id: station.id },
|
||||||
|
data: { isOnline: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updated++;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(`${net.label} poll: ${updated}/${stations.length} обновлено`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadStreamMap(base: string): Promise<Record<string, number> | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/api/v1/streams/`, { headers: this.headers });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json()) as { items?: SpbStream[] };
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
for (const s of data.items ?? []) {
|
||||||
|
const mount = this.extractMount(s.link ?? '');
|
||||||
|
if (mount) map[mount.toLowerCase()] = s.id;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .../cdnvideo.ru/pfm_ddt → pfm_ddt ; .../radiovanya → radiovanya
|
||||||
|
private extractMount(url: string): string | null {
|
||||||
|
const m = url.match(/cdnvideo\.ru\/([A-Za-z0-9_]+)/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/now-playing/volna-now-playing.service.ts
Normal file
99
src/now-playing/volna-now-playing.service.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user