feat(now-playing): Новое Радио BY — now-playing с правильной кодировкой + Wake Up
5 мейнов live.novoeradio.by отдают ICY, но кириллица в windows-1251 (общий поллер читал UTF-8 → каша). NovoeByNowPlayingService (@Interval 30с): свой ICY- ридер с декодом UTF-8→fallback 1251, джинглы без трека пропускаем, обложка через обогащение. Исключён из общего ICY-поллера. Маунт Wake Up исправлен (wakeupshow→wakeup, был 404/offline) в seed и прод-БД. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9921,7 +9921,7 @@
|
|||||||
"name": "Wake Up Show",
|
"name": "Wake Up Show",
|
||||||
"bitrate": "128",
|
"bitrate": "128",
|
||||||
"site": "https://www.novoeradio.by/",
|
"site": "https://www.novoeradio.by/",
|
||||||
"stream": "https://live.novoeradio.by:444/live/novoeradio_wakeupshow_aac128/icecast.audio",
|
"stream": "https://live.novoeradio.by:444/live/novoeradio_wakeup_aac128/icecast.audio",
|
||||||
"type": "aac",
|
"type": "aac",
|
||||||
"iconText": "",
|
"iconText": "",
|
||||||
"textColor": "#FFFFFF",
|
"textColor": "#FFFFFF",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export class IcyNowPlayingService {
|
|||||||
'Unistar',
|
'Unistar',
|
||||||
'Zaicev FM',
|
'Zaicev FM',
|
||||||
'Гусь',
|
'Гусь',
|
||||||
|
'Новое Радио BY',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
NOT: { streamUrl: { contains: 'emgsound.ru' } },
|
NOT: { streamUrl: { contains: 'emgsound.ru' } },
|
||||||
|
|||||||
148
src/now-playing/novoeby-now-playing.service.ts
Normal file
148
src/now-playing/novoeby-now-playing.service.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Interval } from '@nestjs/schedule';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { NowPlayingService } from './now-playing.service';
|
||||||
|
import * as http from 'http';
|
||||||
|
import * as https from 'https';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Now-playing для «Новое Радио BY» (Беларусь, live.novoeradio.by). Их Icecast-мейны
|
||||||
|
* отдают ICY StreamTitle, НО кириллица в нём — windows-1251 (общий ICY-поллер читает
|
||||||
|
* как UTF-8 → каша «<><C2AB><EFBFBD><EFBFBD>»). Поэтому отдельный сервис: читаем ICY и декодируем UTF-8,
|
||||||
|
* а при «битых» байтах — windows-1251. Опрос 30с (общий поллер крутит всё по кругу
|
||||||
|
* ~9 мин — для now-playing слишком лениво). Обложку даёт обогащение (iTunes/Deezer).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class NovoeByNowPlayingService {
|
||||||
|
private readonly logger = new Logger(NovoeByNowPlayingService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly nowPlayingService: NowPlayingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Interval(30000)
|
||||||
|
async pollNovoeByNowPlaying() {
|
||||||
|
const stations = await this.prisma.station.findMany({
|
||||||
|
where: { streamUrl: { contains: 'novoeradio.by' } },
|
||||||
|
});
|
||||||
|
if (stations.length === 0) return;
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
await Promise.allSettled(
|
||||||
|
stations.map(async (station) => {
|
||||||
|
const title = await this.readIcyTitle(station.streamUrl).catch(() => null);
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
// Джинглы/заставки без трека («NOVOE RADIO MEGAMIX» и т.п.) — пропускаем.
|
||||||
|
const sep = title.indexOf(' - ');
|
||||||
|
if (sep < 0) return;
|
||||||
|
const artist = title.slice(0, sep).trim();
|
||||||
|
const song = title.slice(sep + 3).trim();
|
||||||
|
if (!artist || !song) return;
|
||||||
|
|
||||||
|
await this.nowPlayingService.ingest({
|
||||||
|
stationDbId: station.id,
|
||||||
|
stationNumericId: station.stationId,
|
||||||
|
artist,
|
||||||
|
song,
|
||||||
|
coverUrl: null,
|
||||||
|
});
|
||||||
|
if (!station.isOnline) {
|
||||||
|
await this.prisma.station.update({
|
||||||
|
where: { id: station.id },
|
||||||
|
data: { isOnline: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updated++;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`NovoeBY poll: ${updated}/${stations.length} обновлено`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Читает StreamTitle из ICY-потока; декод UTF-8, при невалидных байтах — windows-1251. */
|
||||||
|
private readIcyTitle(url: string): Promise<string | null> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = url.startsWith('https') ? https : http;
|
||||||
|
const req = client.get(
|
||||||
|
url,
|
||||||
|
{ headers: { 'Icy-MetaData': '1' }, timeout: 8000 },
|
||||||
|
(res) => {
|
||||||
|
const metaint = parseInt((res.headers['icy-metaint'] as string) || '0');
|
||||||
|
if (!metaint) {
|
||||||
|
req.destroy();
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let audio = 0;
|
||||||
|
let metaLen = 0;
|
||||||
|
let metaBuf = Buffer.alloc(0);
|
||||||
|
let state: 'audio' | 'len' | 'meta' = 'audio';
|
||||||
|
|
||||||
|
res.on('data', (chunk: Buffer) => {
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < chunk.length) {
|
||||||
|
if (state === 'audio') {
|
||||||
|
const take = Math.min(metaint - audio, chunk.length - offset);
|
||||||
|
audio += take;
|
||||||
|
offset += take;
|
||||||
|
if (audio >= metaint) state = 'len';
|
||||||
|
} else if (state === 'len') {
|
||||||
|
metaLen = chunk[offset] * 16;
|
||||||
|
offset++;
|
||||||
|
if (metaLen === 0) {
|
||||||
|
audio = 0;
|
||||||
|
state = 'audio';
|
||||||
|
} else {
|
||||||
|
metaBuf = Buffer.alloc(0);
|
||||||
|
state = 'meta';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const take = Math.min(metaLen - metaBuf.length, chunk.length - offset);
|
||||||
|
metaBuf = Buffer.concat([metaBuf, chunk.slice(offset, offset + take)]);
|
||||||
|
offset += take;
|
||||||
|
if (metaBuf.length >= metaLen) {
|
||||||
|
req.destroy();
|
||||||
|
resolve(this.extractTitle(metaBuf));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.on('error', (err) => {
|
||||||
|
req.destroy();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
res.on('end', () => resolve(null));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on('error', (err) => reject(err));
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('timeout'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Достаёт StreamTitle из блока метаданных с корректной кодировкой. */
|
||||||
|
private extractTitle(buf: Buffer): string | null {
|
||||||
|
// Границы по latin1 (1 байт = 1 символ) — чтобы не сбить смещения мультибайтом.
|
||||||
|
const latin = buf.toString('latin1');
|
||||||
|
const start = latin.indexOf("StreamTitle='");
|
||||||
|
if (start < 0) return null;
|
||||||
|
const from = start + "StreamTitle='".length;
|
||||||
|
const end = latin.indexOf("';", from);
|
||||||
|
if (end < 0) return null;
|
||||||
|
const titleBytes = buf.slice(from, end);
|
||||||
|
|
||||||
|
const utf8 = titleBytes.toString('utf8');
|
||||||
|
// <20> — признак невалидного UTF-8 → это windows-1251.
|
||||||
|
const decoded = utf8.includes('<27>')
|
||||||
|
? new TextDecoder('windows-1251').decode(titleBytes)
|
||||||
|
: utf8;
|
||||||
|
const clean = decoded.replace(/\x00/g, '').trim();
|
||||||
|
if (!clean || clean.startsWith('{') || clean.startsWith('[')) return null;
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { RoksNowPlayingService } from './roks-now-playing.service';
|
|||||||
import { UnistarNowPlayingService } from './unistar-now-playing.service';
|
import { UnistarNowPlayingService } from './unistar-now-playing.service';
|
||||||
import { ZaycevNowPlayingService } from './zaycev-now-playing.service';
|
import { ZaycevNowPlayingService } from './zaycev-now-playing.service';
|
||||||
import { GooseNowPlayingService } from './goose-now-playing.service';
|
import { GooseNowPlayingService } from './goose-now-playing.service';
|
||||||
|
import { NovoeByNowPlayingService } from './novoeby-now-playing.service';
|
||||||
import { ChartsModule } from '../charts/charts.module';
|
import { ChartsModule } from '../charts/charts.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -28,6 +29,7 @@ import { ChartsModule } from '../charts/charts.module';
|
|||||||
UnistarNowPlayingService,
|
UnistarNowPlayingService,
|
||||||
ZaycevNowPlayingService,
|
ZaycevNowPlayingService,
|
||||||
GooseNowPlayingService,
|
GooseNowPlayingService,
|
||||||
|
NovoeByNowPlayingService,
|
||||||
],
|
],
|
||||||
exports: [NowPlayingService],
|
exports: [NowPlayingService],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user