fix(now-playing): Love Radio — ICY авторизованных n340-потоков (per-channel)
player/online кэширует один трек на все каналы. Берём трек из ICY самих потоков (каждый поток физически разный), читая их с сессионным UID (бэкенд берёт из config). Теперь у каждого Love-канала свой трек. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,40 +2,33 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { Interval } from '@nestjs/schedule';
|
import { Interval } from '@nestjs/schedule';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { NowPlayingService } from './now-playing.service';
|
import { NowPlayingService } from './now-playing.service';
|
||||||
|
import * as http from 'http';
|
||||||
interface LoveSong {
|
import * as https from 'https';
|
||||||
artistTitle?: string | null;
|
|
||||||
songTitle?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Now-playing для Love Radio. В ICY-потоках вместо трека приходит «onlinestop56k»
|
* Now-playing для Love Radio. Их API (player/online) кэширует один трек на все
|
||||||
* (имя мута), поэтому берём текущий трек из их API:
|
* каналы (игнорит musicStreamId), поэтому берём метаданные из САМИХ потоков:
|
||||||
* api.loveradio.ru/api/v1/love-radio/player/online?musicStreamId={id}
|
* каждый n340-поток физически разный и несёт свой трек в ICY StreamTitle.
|
||||||
* (data.song — текущий трек канала; history/list игнорирует id и даёт только главный).
|
* Потоки защищены — нужен сессионный UID из player/config (привязан к IP сервера,
|
||||||
* Обложек API не даёт — их подставит наше обогащение.
|
* бэкенд и читает поток со своего IP). Обложек нет — их даёт обогащение.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LoveNowPlayingService {
|
export class LoveNowPlayingService {
|
||||||
private readonly logger = new Logger(LoveNowPlayingService.name);
|
private readonly logger = new Logger(LoveNowPlayingService.name);
|
||||||
private readonly headers = {
|
private uid: string | null = null;
|
||||||
'User-Agent': 'Mozilla/5.0',
|
|
||||||
Origin: 'https://www.loveradio.ru',
|
|
||||||
Referer: 'https://www.loveradio.ru/',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Имя нашей станции -> musicStreamId в API Love Radio
|
// Имя нашей станции -> mount потока на n340
|
||||||
private readonly streamId: Record<string, number> = {
|
private readonly mount: Record<string, string> = {
|
||||||
'Love Radio': 28,
|
'Love Radio': '12_love_128',
|
||||||
'Love RnB': 2,
|
'Love RnB': '6_rnb_24',
|
||||||
'Love Top40': 3,
|
'Love Top40': '9_top40_24',
|
||||||
'Love Dance': 4,
|
'Love Dance': '7_dance_24',
|
||||||
'Love Chill': 5,
|
'Love Gold': '3_gold_56',
|
||||||
'Love Gold': 6,
|
'Love Russian': '8_russian_24',
|
||||||
'Love Russian': 7,
|
'Love KPOP': '11_kpop_28',
|
||||||
'Love KPOP': 10,
|
'Love Power': '15_power_24',
|
||||||
'Love Power': 11,
|
'Love Chill': '4_chill_24',
|
||||||
'Love Summer': 1,
|
'Love Summer': '5_summer_24',
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -50,23 +43,27 @@ export class LoveNowPlayingService {
|
|||||||
});
|
});
|
||||||
if (stations.length === 0) return;
|
if (stations.length === 0) return;
|
||||||
|
|
||||||
|
const uid = await this.getUid();
|
||||||
|
if (!uid) return;
|
||||||
|
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
stations.map(async (station) => {
|
stations.map(async (station) => {
|
||||||
const id = this.streamId[station.name];
|
const m = this.mount[station.name];
|
||||||
if (!id) return;
|
if (!m) return;
|
||||||
|
const url = `https://stream2.n340.com/${m}?type=aac&UID=${uid}`;
|
||||||
const url =
|
const title = await this.readIcyTitle(url);
|
||||||
`https://api.loveradio.ru/api/v1/love-radio/player/online` +
|
if (!title) return;
|
||||||
`?musicStreamId=${id}`;
|
// «onlinestop56k» = заглушка (UID протух) — сбросим, добёрём на след. цикле
|
||||||
const res = await fetch(url, { headers: this.headers });
|
if (title === 'onlinestop56k') {
|
||||||
if (!res.ok) return;
|
this.uid = null;
|
||||||
|
return;
|
||||||
const json = (await res.json()) as { data?: { song?: LoveSong } };
|
}
|
||||||
const cur = json.data?.song;
|
const parts = title.split(' - ');
|
||||||
const artist = (cur?.artistTitle ?? '').trim();
|
if (parts.length < 2) return;
|
||||||
const song = (cur?.songTitle ?? '').trim();
|
const artist = parts[0].trim();
|
||||||
if (!artist || !song) return; // между треками — не перетираем
|
const song = parts.slice(1).join(' - ').trim();
|
||||||
|
if (!artist || !song) return;
|
||||||
|
|
||||||
await this.nowPlayingService.ingest({
|
await this.nowPlayingService.ingest({
|
||||||
stationDbId: station.id,
|
stationDbId: station.id,
|
||||||
@@ -87,4 +84,103 @@ export class LoveNowPlayingService {
|
|||||||
|
|
||||||
this.logger.log(`Love poll: ${updated}/${stations.length} обновлено`);
|
this.logger.log(`Love poll: ${updated}/${stations.length} обновлено`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сессионный UID из player/config (кэшируем; сбрасываем при заглушке)
|
||||||
|
private async getUid(): Promise<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user