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