From 38b2aee26d58ea4e201c076f0ed09cffe18ab478 Mon Sep 17 00:00:00 2001 From: nk Date: Wed, 3 Jun 2026 15:28:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(now-playing):=20EMG=20(=D0=95=D0=B2=D1=80?= =?UTF-8?q?=D0=BE=D0=BF=D0=B0=20=D0=9F=D0=BB=D1=8E=D1=81=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D1=80.)=20now-playing=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20meta.?= =?UTF-8?q?hostingradio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Станции группы ЕМГ (emgsound.ru) получают текущий трек + готовую WebP-обложку из единого meta.hostingradio.ru/emg/{slug}/history (slug из хоста потока, order=desc → первый = сейчас). Заводится через NowPlayingService.ingest (чарты + обогащение). ICY-поллер теперь пропускает emgsound (там HLS без ICY). Co-Authored-By: Claude Opus 4.8 --- src/now-playing/emg-now-playing.service.ts | 100 +++++++++++++++++++++ src/now-playing/icy-now-playing.service.ts | 8 +- src/now-playing/now-playing.module.ts | 2 + 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/now-playing/emg-now-playing.service.ts diff --git a/src/now-playing/emg-now-playing.service.ts b/src/now-playing/emg-now-playing.service.ts new file mode 100644 index 0000000..0caaefb --- /dev/null +++ b/src/now-playing/emg-now-playing.service.ts @@ -0,0 +1,100 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Interval } from '@nestjs/schedule'; +import { PrismaService } from '../prisma/prisma.service'; +import { NowPlayingService } from './now-playing.service'; + +// Элемент истории meta.hostingradio.ru (нужные поля) +interface EmgHistoryItem { + artist?: string; + title?: string; + type?: number; + coverImageWebpUrl600?: string; + coverImageUrl600?: string; +} + +/** + * Now-playing для станций группы ЕМГ (Европа Плюс, Ретро FM, Дорожное, Радио 7, + * Studio 21, Эльдорадио и их сабканалы). Все они вещают через emgsound.ru, а текущий + * трек с обложкой отдаёт единый сервис meta.hostingradio.ru/emg/{slug}/history. + * slug берём из хоста потока: hls-NN-{slug}.emgsound.ru. order=desc → первый = сейчас. + */ +@Injectable() +export class EmgNowPlayingService { + private readonly logger = new Logger(EmgNowPlayingService.name); + private readonly headers = { + 'User-Agent': 'Mozilla/5.0', + Origin: 'https://europaplus.ru', + Referer: 'https://europaplus.ru/', + }; + + constructor( + private readonly prisma: PrismaService, + private readonly nowPlayingService: NowPlayingService, + ) {} + + @Interval(30000) + async pollEmgNowPlaying() { + const stations = await this.prisma.station.findMany({ + where: { isOnline: true, streamUrl: { contains: 'emgsound.ru' } }, + }); + if (stations.length === 0) return; + + const { date, from, to } = this.mskWindow(); + let updated = 0; + + await Promise.allSettled( + stations.map(async (station) => { + const slug = this.extractSlug(station.streamUrl); + if (!slug) return; + + const url = + `https://meta.hostingradio.ru/emg/${slug}/history` + + `?format=native&types=3&order=desc&date=${date}&from=${from}&to=${to}`; + const res = await fetch(url, { headers: this.headers }); + if (!res.ok) return; + + const items = (await res.json()) as EmgHistoryItem[]; + const cur = Array.isArray(items) ? items[0] : null; + if (!cur?.artist || !cur?.title) return; + + await this.nowPlayingService.ingest({ + stationDbId: station.id, + stationNumericId: station.stationId, + artist: cur.artist.trim(), + song: cur.title.trim(), + coverUrl: cur.coverImageWebpUrl600 ?? cur.coverImageUrl600 ?? null, + }); + updated++; + }), + ); + + this.logger.log(`EMG poll: ${updated}/${stations.length} обновлено`); + } + + // hls-01-europaplus-kpop.emgsound.ru → europaplus-kpop + private extractSlug(streamUrl: string): string | null { + const m = streamUrl.match(/hls-\d+-([a-z0-9-]+)\.emgsound\.ru/i); + return m ? m[1].toLowerCase() : null; + } + + // Дата и окно времени по Москве (контейнер может быть в UTC) + private mskWindow(): { date: string; from: string; to: string } { + const fmt = (d: Date) => { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Europe/Moscow', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(d); + const g = (t: string) => parts.find((p) => p.type === t)?.value ?? '00'; + return { date: `${g('year')}-${g('month')}-${g('day')}`, time: `${g('hour')}:${g('minute')}` }; + }; + const now = new Date(); + const cur = fmt(now); + const start = fmt(new Date(now.getTime() - 120 * 60 * 1000)); + return { date: cur.date, from: start.time, to: cur.time }; + } +} diff --git a/src/now-playing/icy-now-playing.service.ts b/src/now-playing/icy-now-playing.service.ts index 3dc0519..edcd9b1 100644 --- a/src/now-playing/icy-now-playing.service.ts +++ b/src/now-playing/icy-now-playing.service.ts @@ -24,7 +24,13 @@ export class IcyNowPlayingService { @Interval(60000) async pollIcyNowPlaying() { - const where = { recordStationId: null, isOnline: true }; + // emgsound (ЕМГ) обрабатывает EmgNowPlayingService через meta-API; их HLS-потоки + // ICY-метаданных не отдают — исключаем, чтобы не тратить слоты впустую. + const where = { + recordStationId: null, + isOnline: true, + NOT: { streamUrl: { contains: 'emgsound.ru' } }, + }; const total = await this.prisma.station.count({ where }); if (total === 0) return; if (this.cursor >= total) this.cursor = 0; diff --git a/src/now-playing/now-playing.module.ts b/src/now-playing/now-playing.module.ts index 789d03c..eba94f7 100644 --- a/src/now-playing/now-playing.module.ts +++ b/src/now-playing/now-playing.module.ts @@ -4,6 +4,7 @@ import { NowPlayingController } from './now-playing.controller'; import { NowPlayingService } from './now-playing.service'; import { RecordStationSyncService } from './record-station-sync.service'; import { IcyNowPlayingService } from './icy-now-playing.service'; +import { EmgNowPlayingService } from './emg-now-playing.service'; import { ChartsModule } from '../charts/charts.module'; @Module({ @@ -14,6 +15,7 @@ import { ChartsModule } from '../charts/charts.module'; NowPlayingService, RecordStationSyncService, IcyNowPlayingService, + EmgNowPlayingService, ], exports: [NowPlayingService], })