feat(now-playing): Питер ФМ и Орфей

Питер ФМ (16 каналов, cdnvideo) — ICY пуст; берём трек+обложку из их API
radiopiterfm.ru: /api/v1/streams/ (slug↔id) + /api/v5/playlists/{id}/ →
items[0].track {name, artist.name, imglarge}. Обложки готовые (iTunes).
Орфей (классика, radio.orpheus.ru) — через Icecast status-json.xsl по маунтам
(Chan_N), парсим «Композитор — Произведение», отсекаем мусор (hex/URL/undefined);
обложка через обогащение. Оба жанра исключены из общего ICY-поллера.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-06 08:34:32 +03:00
parent 426fd0e197
commit c87a0caa5c
4 changed files with 225 additions and 0 deletions

View File

@@ -40,6 +40,8 @@ export class IcyNowPlayingService {
'Zaicev FM', 'Zaicev FM',
'Гусь', 'Гусь',
'Новое Радио BY', 'Новое Радио BY',
'Питер ФМ',
'Орфей',
], ],
}, },
NOT: { streamUrl: { contains: 'emgsound.ru' } }, NOT: { streamUrl: { contains: 'emgsound.ru' } },

View File

@@ -12,6 +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 { OrpheusNowPlayingService } from './orpheus-now-playing.service';
import { ChartsModule } from '../charts/charts.module'; import { ChartsModule } from '../charts/charts.module';
@Module({ @Module({
@@ -30,6 +32,8 @@ import { ChartsModule } from '../charts/charts.module';
ZaycevNowPlayingService, ZaycevNowPlayingService,
GooseNowPlayingService, GooseNowPlayingService,
NovoeByNowPlayingService, NovoeByNowPlayingService,
PiterFmNowPlayingService,
OrpheusNowPlayingService,
], ],
exports: [NowPlayingService], exports: [NowPlayingService],
}) })

View File

@@ -0,0 +1,110 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
interface IceSource {
listenurl?: string;
title?: string;
}
/**
* Now-playing для «Орфей» (классика, radio.orpheus.ru). ICY в потоке часто пуст или
* с хекс-плейсхолдером, но Icecast `status-json.xsl` отдаёт title по всем маунтам.
* Качество разное: у части каналов нормальный «Композитор — Произведение», у части
* мусор (hex/URL/undefined) — его пропускаем. Обложки у классики в iTunes почти нет,
* поэтому coverUrl=null (что найдётся — подтянет обогащение). Каналы на
* orfeyfm.hostingradio.ru (главный FM) трека не дают — остаются без подписи.
*/
@Injectable()
export class OrpheusNowPlayingService {
private readonly logger = new Logger(OrpheusNowPlayingService.name);
private readonly statusUrl = 'https://radio.orpheus.ru:8000/status-json.xsl';
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollOrpheusNowPlaying() {
const stations = await this.prisma.station.findMany({
where: { streamUrl: { contains: 'radio.orpheus.ru' } },
});
if (stations.length === 0) return;
const byMount = await this.loadStatus();
if (!byMount) return;
let updated = 0;
await Promise.allSettled(
stations.map(async (station) => {
const mount = this.extractMount(station.streamUrl);
const title = mount ? byMount[mount] : undefined;
const parsed = this.parseTitle(title);
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(`Orpheus poll: ${updated}/${stations.length} обновлено`);
}
private async loadStatus(): Promise<Record<string, string> | null> {
try {
const res = await fetch(this.statusUrl, { headers: this.headers });
if (!res.ok) return null;
const data = (await res.json()) as { icestats?: { source?: IceSource | IceSource[] } };
const src = data.icestats?.source;
const arr = Array.isArray(src) ? src : src ? [src] : [];
const map: Record<string, string> = {};
for (const s of arr) {
const mount = (s.listenurl ?? '').split('/').pop();
if (mount && typeof s.title === 'string') map[mount] = s.title;
}
return map;
} catch {
return null;
}
}
// https://radio.orpheus.ru:8000/Chan_8_192.mp3 → Chan_8_192.mp3
private extractMount(streamUrl: string): string | null {
const m = streamUrl.match(/\/([A-Za-z0-9_]+\.(?:mp3|aac))(?:$|\?)/);
return m ? m[1] : null;
}
/** Разбирает title «Артист — Произведение», отсекая мусор. */
private parseTitle(title?: string): { artist: string; song: string } | null {
if (!title) return null;
const t = title.trim();
if (!t || t === 'undefined') return null;
// hex-плейсхолдеры (0003b6d2), URL-источники (http://fonotron.ru), JSON
if (/^[0-9a-f]{6,}$/i.test(t) || t.startsWith('http') || t.startsWith('{')) {
return null;
}
// Разделитель — длинное тире или дефис с пробелами
const idx = t.indexOf(' — ') >= 0 ? t.indexOf(' — ') : t.indexOf(' - ');
if (idx < 0) return null;
const sepLen = t.indexOf(' — ') >= 0 ? 3 : 3;
const artist = t.slice(0, idx).trim();
const song = t.slice(idx + sepLen).trim();
if (!artist || !song) return null;
return { artist, song };
}
}

View File

@@ -0,0 +1,109 @@
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;
}
}