From bd2cd36f1e4244c2c4b261e235b1eb37aae82844 Mon Sep 17 00:00:00 2001 From: nk Date: Wed, 3 Jun 2026 20:09:01 +0300 Subject: [PATCH] =?UTF-8?q?fix(now-playing):=20Love=20Radio=20=E2=80=94=20?= =?UTF-8?q?ICY=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20n340-=D0=BF=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=20(per-channel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit player/online кэширует один трек на все каналы. Берём трек из ICY самих потоков (каждый поток физически разный), читая их с сессионным UID (бэкенд берёт из config). Теперь у каждого Love-канала свой трек. Co-Authored-By: Claude Opus 4.8 --- src/now-playing/love-now-playing.service.ts | 178 +++++++++++++++----- 1 file changed, 137 insertions(+), 41 deletions(-) diff --git a/src/now-playing/love-now-playing.service.ts b/src/now-playing/love-now-playing.service.ts index 4dfc3f2..fa521bb 100644 --- a/src/now-playing/love-now-playing.service.ts +++ b/src/now-playing/love-now-playing.service.ts @@ -2,40 +2,33 @@ import { Injectable, Logger } from '@nestjs/common'; import { Interval } from '@nestjs/schedule'; import { PrismaService } from '../prisma/prisma.service'; import { NowPlayingService } from './now-playing.service'; - -interface LoveSong { - artistTitle?: string | null; - songTitle?: string | null; -} +import * as http from 'http'; +import * as https from 'https'; /** - * Now-playing для Love Radio. В ICY-потоках вместо трека приходит «onlinestop56k» - * (имя мута), поэтому берём текущий трек из их API: - * api.loveradio.ru/api/v1/love-radio/player/online?musicStreamId={id} - * (data.song — текущий трек канала; history/list игнорирует id и даёт только главный). - * Обложек API не даёт — их подставит наше обогащение. + * Now-playing для Love Radio. Их API (player/online) кэширует один трек на все + * каналы (игнорит musicStreamId), поэтому берём метаданные из САМИХ потоков: + * каждый n340-поток физически разный и несёт свой трек в ICY StreamTitle. + * Потоки защищены — нужен сессионный UID из player/config (привязан к IP сервера, + * бэкенд и читает поток со своего IP). Обложек нет — их даёт обогащение. */ @Injectable() export class LoveNowPlayingService { private readonly logger = new Logger(LoveNowPlayingService.name); - private readonly headers = { - 'User-Agent': 'Mozilla/5.0', - Origin: 'https://www.loveradio.ru', - Referer: 'https://www.loveradio.ru/', - }; + private uid: string | null = null; - // Имя нашей станции -> musicStreamId в API Love Radio - private readonly streamId: Record = { - 'Love Radio': 28, - 'Love RnB': 2, - 'Love Top40': 3, - 'Love Dance': 4, - 'Love Chill': 5, - 'Love Gold': 6, - 'Love Russian': 7, - 'Love KPOP': 10, - 'Love Power': 11, - 'Love Summer': 1, + // Имя нашей станции -> mount потока на n340 + private readonly mount: Record = { + 'Love Radio': '12_love_128', + 'Love RnB': '6_rnb_24', + 'Love Top40': '9_top40_24', + 'Love Dance': '7_dance_24', + 'Love Gold': '3_gold_56', + 'Love Russian': '8_russian_24', + 'Love KPOP': '11_kpop_28', + 'Love Power': '15_power_24', + 'Love Chill': '4_chill_24', + 'Love Summer': '5_summer_24', }; constructor( @@ -50,23 +43,27 @@ export class LoveNowPlayingService { }); if (stations.length === 0) return; + const uid = await this.getUid(); + if (!uid) return; + let updated = 0; await Promise.allSettled( stations.map(async (station) => { - const id = this.streamId[station.name]; - if (!id) return; - - const url = - `https://api.loveradio.ru/api/v1/love-radio/player/online` + - `?musicStreamId=${id}`; - const res = await fetch(url, { headers: this.headers }); - if (!res.ok) return; - - const json = (await res.json()) as { data?: { song?: LoveSong } }; - const cur = json.data?.song; - const artist = (cur?.artistTitle ?? '').trim(); - const song = (cur?.songTitle ?? '').trim(); - if (!artist || !song) return; // между треками — не перетираем + const m = this.mount[station.name]; + if (!m) return; + const url = `https://stream2.n340.com/${m}?type=aac&UID=${uid}`; + const title = await this.readIcyTitle(url); + if (!title) return; + // «onlinestop56k» = заглушка (UID протух) — сбросим, добёрём на след. цикле + if (title === 'onlinestop56k') { + this.uid = null; + return; + } + const parts = title.split(' - '); + if (parts.length < 2) return; + const artist = parts[0].trim(); + const song = parts.slice(1).join(' - ').trim(); + if (!artist || !song) return; await this.nowPlayingService.ingest({ stationDbId: station.id, @@ -87,4 +84,103 @@ export class LoveNowPlayingService { this.logger.log(`Love poll: ${updated}/${stations.length} обновлено`); } + + // Сессионный UID из player/config (кэшируем; сбрасываем при заглушке) + private async getUid(): Promise { + if (this.uid) return this.uid; + try { + const res = await fetch( + 'https://api.loveradio.ru/api/v1/love-radio/player/config', + { + headers: { + 'User-Agent': 'Mozilla/5.0', + Referer: 'https://www.loveradio.ru/', + Origin: 'https://www.loveradio.ru', + }, + }, + ); + if (!res.ok) return null; + const json = (await res.json()) as { data?: { uid?: string } }; + this.uid = json.data?.uid ?? null; + return this.uid; + } catch { + return null; + } + } + + // Читает первый StreamTitle из ICY-метаданных потока + private readIcyTitle(url: string): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (v: string | null) => { + if (!done) { + done = true; + resolve(v); + } + }; + try { + const lib = url.startsWith('https') ? https : http; + const req = lib.get( + url, + { headers: { 'Icy-MetaData': '1', 'User-Agent': 'Mozilla/5.0' }, timeout: 8000 }, + (res) => { + const metaint = parseInt((res.headers['icy-metaint'] as string) || '0'); + if (!metaint) { + req.destroy(); + finish(null); + return; + } + let audio = 0; + let metaLen = 0; + let metaRead = 0; + let buf = Buffer.alloc(0); + let state: 'audio' | 'len' | 'meta' = 'audio'; + res.on('data', (chunk: Buffer) => { + let off = 0; + while (off < chunk.length) { + if (state === 'audio') { + const take = Math.min(metaint - audio, chunk.length - off); + audio += take; + off += take; + if (audio >= metaint) state = 'len'; + } else if (state === 'len') { + metaLen = chunk[off] * 16; + off++; + if (metaLen === 0) { + audio = 0; + state = 'audio'; + } else { + buf = Buffer.alloc(0); + metaRead = 0; + state = 'meta'; + } + } else { + const take = Math.min(metaLen - metaRead, chunk.length - off); + buf = Buffer.concat([buf, chunk.slice(off, off + take)]); + metaRead += take; + off += take; + if (metaRead >= metaLen) { + req.destroy(); + const s = buf.toString('utf-8').replace(/\x00/g, ''); + const mt = s.match(/StreamTitle='([^']*)'/); + finish(mt ? mt[1].trim() : null); + return; + } + } + } + }); + res.on('error', () => finish(null)); + res.on('end', () => finish(null)); + }, + ); + req.on('error', () => finish(null)); + req.on('timeout', () => { + req.destroy(); + finish(null); + }); + } catch { + finish(null); + } + }); + } }