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:
110
src/now-playing/orpheus-now-playing.service.ts
Normal file
110
src/now-playing/orpheus-now-playing.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user