diff --git a/src/now-playing/icy-now-playing.service.ts b/src/now-playing/icy-now-playing.service.ts index 44863e9..cf443c2 100644 --- a/src/now-playing/icy-now-playing.service.ts +++ b/src/now-playing/icy-now-playing.service.ts @@ -40,6 +40,8 @@ export class IcyNowPlayingService { 'Zaicev FM', 'Гусь', 'Новое Радио BY', + 'Питер ФМ', + 'Орфей', ], }, NOT: { streamUrl: { contains: 'emgsound.ru' } }, diff --git a/src/now-playing/now-playing.module.ts b/src/now-playing/now-playing.module.ts index 4f6793a..b0ceceb 100644 --- a/src/now-playing/now-playing.module.ts +++ b/src/now-playing/now-playing.module.ts @@ -12,6 +12,8 @@ import { UnistarNowPlayingService } from './unistar-now-playing.service'; import { ZaycevNowPlayingService } from './zaycev-now-playing.service'; import { GooseNowPlayingService } from './goose-now-playing.service'; import { NovoeByNowPlayingService } from './novoeby-now-playing.service'; +import { PiterFmNowPlayingService } from './piterfm-now-playing.service'; +import { OrpheusNowPlayingService } from './orpheus-now-playing.service'; import { ChartsModule } from '../charts/charts.module'; @Module({ @@ -30,6 +32,8 @@ import { ChartsModule } from '../charts/charts.module'; ZaycevNowPlayingService, GooseNowPlayingService, NovoeByNowPlayingService, + PiterFmNowPlayingService, + OrpheusNowPlayingService, ], exports: [NowPlayingService], }) diff --git a/src/now-playing/orpheus-now-playing.service.ts b/src/now-playing/orpheus-now-playing.service.ts new file mode 100644 index 0000000..980f9b8 --- /dev/null +++ b/src/now-playing/orpheus-now-playing.service.ts @@ -0,0 +1,110 @@ +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 }; + } +} diff --git a/src/now-playing/piterfm-now-playing.service.ts b/src/now-playing/piterfm-now-playing.service.ts new file mode 100644 index 0000000..74c6976 --- /dev/null +++ b/src/now-playing/piterfm-now-playing.service.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Interval } from '@nestjs/schedule'; +import { PrismaService } from '../prisma/prisma.service'; +import { NowPlayingService } from './now-playing.service'; + +interface PiterStream { + id: number; + slug: string; +} +interface PiterPlaylist { + items?: Array<{ + track?: { + name?: string; + imglarge?: string; + imgsmall?: string; + artist?: { name?: string }; + }; + }>; +} + +/** + * Now-playing для Питер ФМ (radiopiterfm.ru, Icecast cdnvideo). Потоки НЕ дают трек + * по ICY (пусто/URL сайта), но у сайта есть API: + * `/api/v1/streams/` → список (slug↔id), `/api/v5/playlists/{id}/` → items[0].track + * {name, artist.name, imglarge} с готовой обложкой (iTunes mzstatic). slug = последний + * сегмент пути нашего stream_url (piterfm, pfm_ddt, …). + */ +@Injectable() +export class PiterFmNowPlayingService { + private readonly logger = new Logger(PiterFmNowPlayingService.name); + private readonly base = 'https://radiopiterfm.ru'; + private readonly headers = { 'User-Agent': 'Mozilla/5.0' }; + + constructor( + private readonly prisma: PrismaService, + private readonly nowPlayingService: NowPlayingService, + ) {} + + @Interval(30000) + async pollPiterFmNowPlaying() { + const stations = await this.prisma.station.findMany({ + where: { streamUrl: { contains: 'piterfm.cdnvideo.ru' } }, + }); + if (stations.length === 0) return; + + // slug → id из списка стримов + const slugToId = await this.loadStreamMap(); + if (!slugToId) return; + + let updated = 0; + await Promise.allSettled( + stations.map(async (station) => { + const slug = this.extractSlug(station.streamUrl); + const id = slug ? slugToId[slug] : undefined; + if (!id) return; + + const res = await fetch(`${this.base}/api/v5/playlists/${id}/`, { + headers: this.headers, + }); + if (!res.ok) return; + const data = (await res.json()) as PiterPlaylist; + const t = data.items?.[0]?.track; + const artist = (t?.artist?.name ?? '').trim(); + const song = (t?.name ?? '').trim(); + if (!artist || !song) return; + + await this.nowPlayingService.ingest({ + stationDbId: station.id, + stationNumericId: station.stationId, + artist, + song, + coverUrl: t?.imglarge ?? t?.imgsmall ?? null, + }); + if (!station.isOnline) { + await this.prisma.station.update({ + where: { id: station.id }, + data: { isOnline: true }, + }); + } + updated++; + }), + ); + + this.logger.log(`PiterFM poll: ${updated}/${stations.length} обновлено`); + } + + private async loadStreamMap(): Promise | null> { + try { + const res = await fetch(`${this.base}/api/v1/streams/`, { + headers: this.headers, + }); + if (!res.ok) return null; + const data = (await res.json()) as { items?: PiterStream[] }; + const map: Record = {}; + for (const s of data.items ?? []) { + if (s.slug) map[s.slug] = s.id; + } + return map; + } catch { + return null; + } + } + + // http://icecast-piterfm.cdnvideo.ru/pfm_ddt → pfm_ddt + private extractSlug(streamUrl: string): string | null { + const m = streamUrl.match(/cdnvideo\.ru\/([a-z0-9_]+)/i); + return m ? m[1].toLowerCase() : null; + } +}