diff --git a/prisma/stations.json b/prisma/stations.json index 41bce57..0067647 100644 --- a/prisma/stations.json +++ b/prisma/stations.json @@ -9921,7 +9921,7 @@ "name": "Wake Up Show", "bitrate": "128", "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", "iconText": "", "textColor": "#FFFFFF", diff --git a/src/now-playing/icy-now-playing.service.ts b/src/now-playing/icy-now-playing.service.ts index 538c102..44863e9 100644 --- a/src/now-playing/icy-now-playing.service.ts +++ b/src/now-playing/icy-now-playing.service.ts @@ -39,6 +39,7 @@ export class IcyNowPlayingService { 'Unistar', 'Zaicev FM', 'Гусь', + 'Новое Радио BY', ], }, NOT: { streamUrl: { contains: 'emgsound.ru' } }, diff --git a/src/now-playing/novoeby-now-playing.service.ts b/src/now-playing/novoeby-now-playing.service.ts new file mode 100644 index 0000000..9e75493 --- /dev/null +++ b/src/now-playing/novoeby-now-playing.service.ts @@ -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 → каша «����»). Поэтому отдельный сервис: читаем 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 { + 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'); + // � — признак невалидного UTF-8 → это windows-1251. + const decoded = utf8.includes('�') + ? new TextDecoder('windows-1251').decode(titleBytes) + : utf8; + const clean = decoded.replace(/\x00/g, '').trim(); + if (!clean || clean.startsWith('{') || clean.startsWith('[')) return null; + return clean; + } +} diff --git a/src/now-playing/now-playing.module.ts b/src/now-playing/now-playing.module.ts index 1e09789..4f6793a 100644 --- a/src/now-playing/now-playing.module.ts +++ b/src/now-playing/now-playing.module.ts @@ -11,6 +11,7 @@ import { RoksNowPlayingService } from './roks-now-playing.service'; 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 { ChartsModule } from '../charts/charts.module'; @Module({ @@ -28,6 +29,7 @@ import { ChartsModule } from '../charts/charts.module'; UnistarNowPlayingService, ZaycevNowPlayingService, GooseNowPlayingService, + NovoeByNowPlayingService, ], exports: [NowPlayingService], })