Files
radiola-backend/src/now-playing/orpheus-now-playing.service.ts
nk c87a0caa5c 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>
2026-06-06 08:34:32 +03:00

111 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 };
}
}