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; } /** Разбирает 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 }; } }