From c4c475544a3807080af1f95a5804c51698b06008 Mon Sep 17 00:00:00 2001 From: nk Date: Sat, 6 Jun 2026 09:18:23 +0300 Subject: [PATCH] =?UTF-8?q?feat(now-playing):=20=D0=A0=D0=B0=D0=B4=D0=B8?= =?UTF-8?q?=D0=BE=20=D0=92=D0=B0=D0=BD=D1=8F=20+=20=D0=A0=D1=83=D1=81?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=D1=8F=20=D0=92=D0=BE=D0=BB=D0=BD=D0=B0;=20?= =?UTF-8?q?=D0=9F=D0=B8=D1=82=D0=B5=D1=80=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D1=91=D0=BD=20=D0=B2=20SpbRadio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Радио Ваня (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 --- src/now-playing/icy-now-playing.service.ts | 2 + src/now-playing/now-playing.module.ts | 6 +- .../piterfm-now-playing.service.ts | 109 ---------------- .../spb-radio-now-playing.service.ts | 117 ++++++++++++++++++ src/now-playing/volna-now-playing.service.ts | 99 +++++++++++++++ 5 files changed, 222 insertions(+), 111 deletions(-) delete mode 100644 src/now-playing/piterfm-now-playing.service.ts create mode 100644 src/now-playing/spb-radio-now-playing.service.ts create mode 100644 src/now-playing/volna-now-playing.service.ts diff --git a/src/now-playing/icy-now-playing.service.ts b/src/now-playing/icy-now-playing.service.ts index cf443c2..b861378 100644 --- a/src/now-playing/icy-now-playing.service.ts +++ b/src/now-playing/icy-now-playing.service.ts @@ -42,6 +42,8 @@ export class IcyNowPlayingService { 'Новое Радио BY', 'Питер ФМ', 'Орфей', + 'Радио Ваня', + 'Русская Волна', ], }, NOT: { streamUrl: { contains: 'emgsound.ru' } }, diff --git a/src/now-playing/now-playing.module.ts b/src/now-playing/now-playing.module.ts index b0ceceb..53ed99c 100644 --- a/src/now-playing/now-playing.module.ts +++ b/src/now-playing/now-playing.module.ts @@ -12,7 +12,8 @@ import { UnistarNowPlayingService } from './unistar-now-playing.service'; import { ZaycevNowPlayingService } from './zaycev-now-playing.service'; import { GooseNowPlayingService } from './goose-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 { ChartsModule } from '../charts/charts.module'; @@ -32,7 +33,8 @@ import { ChartsModule } from '../charts/charts.module'; ZaycevNowPlayingService, GooseNowPlayingService, NovoeByNowPlayingService, - PiterFmNowPlayingService, + SpbRadioNowPlayingService, + VolnaNowPlayingService, OrpheusNowPlayingService, ], exports: [NowPlayingService], diff --git a/src/now-playing/piterfm-now-playing.service.ts b/src/now-playing/piterfm-now-playing.service.ts deleted file mode 100644 index 74c6976..0000000 --- a/src/now-playing/piterfm-now-playing.service.ts +++ /dev/null @@ -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 | 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 = {}; - 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; - } -} diff --git a/src/now-playing/spb-radio-now-playing.service.ts b/src/now-playing/spb-radio-now-playing.service.ts new file mode 100644 index 0000000..d08b30b --- /dev/null +++ b/src/now-playing/spb-radio-now-playing.service.ts @@ -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 | 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 = {}; + 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; + } +} diff --git a/src/now-playing/volna-now-playing.service.ts b/src/now-playing/volna-now-playing.service.ts new file mode 100644 index 0000000..cd9481b --- /dev/null +++ b/src/now-playing/volna-now-playing.service.ts @@ -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 | 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; + } 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 | 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 }; + } +}