Files
radiola-backend/src/now-playing/orpheus-now-playing.service.ts
nk 3c4f349f71 fix(now-playing): Орфей — чиним кодировку (двойная мойибейк cp1251) + режем хекс-хвосты
status-json смешанной кодировки: часть тайтлов нормальный UTF-8, часть — cp1251-
байты, прочитанные как latin1 и завёрнутые в UTF-8 (мойибейк «Íèêîëà»→«Никола»).
fixEncoding: реальную кириллицу не трогаем, мойибейк (À-ÿ) восстанавливаем
latin1→windows-1251. Срезаем приклеенный служебный id трека (- f0098627), фильтр
hex/числовых плейсхолдеров усилен. Каналы без реального произведения — без подписи.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 09:01:03 +03:00

141 lines
5.5 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;
}
/**
* Чинит кодировку: реальную кириллицу (U+0400-04FF) не трогаем; «двойную
* мойибейк» (cp1251-байты, прочитанные как latin1 и завёрнутые в UTF-8 —
* признак латиницы-1 À-ÿ) восстанавливаем latin1→windows-1251.
*/
private fixEncoding(s: string): string {
if (/[Ѐ-ӿ]/.test(s)) return s; // уже корректная кириллица
if (/[À-ÿ]/.test(s)) {
try {
return new TextDecoder('windows-1251').decode(Buffer.from(s, 'latin1'));
} catch {
return s;
}
}
return s;
}
/** Разбирает title «Артист — Произведение», чиня кодировку и отсекая мусор/служебные id. */
private parseTitle(title?: string): { artist: string; song: string } | null {
if (!title) return null;
let t = this.fixEncoding(title).replace(/\s+/g, ' ').trim();
if (!t || t.toLowerCase() === 'undefined') return null;
// Срезаем хвостовой служебный id трека: « - f0098627», « - 147-1-10», « - 263-2-01»
t = t
.replace(/\s*[-—]\s*[0-9a-f]{6,}\s*$/i, '')
.replace(/\s*[-—]\s*\d+-\d+(?:-\d+)?\s*$/, '')
.trim();
if (!t) return null;
// Целиком мусор: hex-плейсхолдер, числовой код, URL, JSON
if (
/^[0-9a-f]{4,}$/i.test(t) ||
/^\d+-\d+/.test(t) ||
t.startsWith('http') ||
t.startsWith('{')
) {
return null;
}
// Разделитель — длинное тире или дефис с пробелами (оба длиной 3 символа: « X »)
const emIdx = t.indexOf(' — ');
const idx = emIdx >= 0 ? emIdx : 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;
// Часть после чистки всё ещё мусорная
if (/^[0-9a-f]{4,}$/i.test(song) || /^[0-9a-f]{4,}$/i.test(artist)) return null;
return { artist, song };
}
}