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>
141 lines
5.5 KiB
TypeScript
141 lines
5.5 KiB
TypeScript
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 };
|
||
}
|
||
}
|