From 944ec63df0277e8b885bdb84c8a0d738f1dca645 Mon Sep 17 00:00:00 2001 From: nk Date: Sat, 6 Jun 2026 16:54:02 +0300 Subject: [PATCH] =?UTF-8?q?refactor(now-playing):=20=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20IcyReader=20+=20=D1=80=D0=B5=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=80=20dedicated-=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Убраны 3 копии state-machine разбора icy-metaint (icy/novoeby/love) → один readIcyStreamTitle в icy-reader.ts (с опцией decode auto-1251 для cp1251-потоков и корректным терминатором ';' — апострофы в названиях больше не обрезаются). Ручной genre-notIn список в IcyNowPlayingService заменён центральным реестром dedicated-sources.ts (host + genre), согласованным с селекторами самих сервисов: добавил выделенный сервис — впиши host/genre в одно место, ICY его пропустит автоматически. Исключение по хосту провабельно совпадает с тем, что сервис реально обрабатывает (раньше genre легко забывали добавить). --- src/now-playing/dedicated-sources.ts | 36 +++++ src/now-playing/icy-now-playing.service.ts | 151 +++--------------- src/now-playing/icy-reader.ts | 135 ++++++++++++++++ src/now-playing/love-now-playing.service.ts | 82 +--------- .../novoeby-now-playing.service.ts | 95 +---------- 5 files changed, 204 insertions(+), 295 deletions(-) create mode 100644 src/now-playing/dedicated-sources.ts create mode 100644 src/now-playing/icy-reader.ts diff --git a/src/now-playing/dedicated-sources.ts b/src/now-playing/dedicated-sources.ts new file mode 100644 index 0000000..f7c6383 --- /dev/null +++ b/src/now-playing/dedicated-sources.ts @@ -0,0 +1,36 @@ +/** + * Единый реестр станций, у которых ЕСТЬ выделенный now-playing-сервис (берёт трек + * из их API или особого ICY). Общий ICY-поллер (IcyNowPlayingService) обязан их + * ПРОПУСКАТЬ — иначе двойной опрос и перезапись точных данных «сырым» ICY. + * + * ⚠️ Добавил новый dedicated-сервис — впиши сюда его признак: + * • host — если сервис выбирает станции по `streamUrl.contains(host)` + * • genre — если сервис выбирает станции по `genre` + * Список один-в-один повторяет селекторы сервисов, поэтому исключение в ICY + * всегда согласовано с тем, что реально обрабатывает выделенный сервис + * (раньше это был ручной genre-список, который легко забывали обновить). + */ + +// Сервисы, выбирающие станции по хосту потока (streamUrl.contains) +export const DEDICATED_STREAM_HOSTS = [ + 'emgsound.ru', // EmgNowPlayingService + 'radio7.hostingradio.ru', // EmgNowPlayingService (старые мейны Radio 7) + 'unistar.by', // UnistarNowPlayingService + 'abs.zaycev.fm', // ZaycevNowPlayingService + 'radiogoose.ru', // GooseNowPlayingService + 'novoeradio.by', // NovoeByNowPlayingService + 'radio.orpheus.ru', // OrpheusNowPlayingService + '.101.ru', // Radio101NowPlayingService (Comedy Radio, Радио Energy) + 'amgradio.ru', // VolnaNowPlayingService (Русская Волна) + 'piterfm.cdnvideo.ru', // SpbRadioNowPlayingService (Питер ФМ) + 'radiovanya.cdnvideo.ru', // SpbRadioNowPlayingService (Радио Ваня) +] as const; + +// Сервисы, выбирающие станции по жанру (genre) +export const DEDICATED_GENRES = [ + 'DFM', // DfmNowPlayingService + 'MAXIMUM', // DfmNowPlayingService + 'Radio Monte Carlo', // DfmNowPlayingService + 'Love Radio', // LoveNowPlayingService + 'Radio ROKS', // RoksNowPlayingService +] as const; diff --git a/src/now-playing/icy-now-playing.service.ts b/src/now-playing/icy-now-playing.service.ts index 87f22fc..443ca05 100644 --- a/src/now-playing/icy-now-playing.service.ts +++ b/src/now-playing/icy-now-playing.service.ts @@ -2,8 +2,8 @@ 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'; +import { readIcyStreamTitle } from './icy-reader'; +import { DEDICATED_GENRES, DEDICATED_STREAM_HOSTS } from './dedicated-sources'; /** * Сбор now-playing для не-Record станций (DFM и др.) через ICY-метаданные потока. @@ -24,32 +24,17 @@ export class IcyNowPlayingService { @Interval(60000) async pollIcyNowPlaying() { - // ЕМГ (emgsound) и DFM/Крутой (genre=DFM) обрабатываются отдельными сервисами - // через их API — исключаем из ICY, чтобы не тратить слоты впустую. + // Станции с выделенным now-playing-сервисом (через их API) исключаем из ICY, + // чтобы не тратить слоты впустую и не перезаписывать точные данные сырым ICY. + // Источник исключений — единый реестр dedicated-sources (host + genre), + // согласованный с селекторами самих сервисов. const where = { recordStationId: null, isOnline: true, - genre: { - notIn: [ - 'DFM', - 'MAXIMUM', - 'Love Radio', - 'Radio Monte Carlo', - 'Radio ROKS', - 'Unistar', - 'Zaicev FM', - 'Гусь', - 'Новое Радио BY', - 'Питер ФМ', - 'Орфей', - 'Радио Ваня', - 'Русская Волна', - 'Comedy Radio', - 'Радио Energy', - 'Radio 7', - ], - }, - NOT: { streamUrl: { contains: 'emgsound.ru' } }, + genre: { notIn: [...DEDICATED_GENRES] }, + AND: DEDICATED_STREAM_HOSTS.map((host) => ({ + NOT: { streamUrl: { contains: host } }, + })), }; const total = await this.prisma.station.count({ where }); if (total === 0) return; @@ -70,8 +55,8 @@ export class IcyNowPlayingService { const batch = stations.slice(i, i + 10); const results = await Promise.allSettled( batch.map(async (station) => { - const track = await this.parseIcyMetadata(station.streamUrl); - if (!track || !track.artist || !track.song) return null; + const track = await this.parseIcyTrack(station.streamUrl); + if (!track) return null; await this.nowPlayingService.ingest({ stationDbId: station.id, @@ -94,108 +79,18 @@ export class IcyNowPlayingService { ); } - private async parseIcyMetadata( + /** Читает StreamTitle через общий ICY-ридер и разбирает «Артист - Песня». */ + private async parseIcyTrack( url: string, ): Promise<{ artist: string; song: string } | null> { - return new Promise((resolve, reject) => { - const client = url.startsWith('https') ? https : http; - const req = client.get( - url, - { headers: { 'Icy-MetaData': '1' }, timeout: 5000 }, - (res) => { - const metaint = parseInt( - (res.headers['icy-metaint'] as string) || '0', - ); - if (!metaint) { - req.destroy(); - resolve(null); - return; - } - - let audioBytesRead = 0; - let metaLength = 0; - let metaBytesRead = 0; - let metaBuffer = Buffer.alloc(0); - let state: 'audio' | 'meta-length' | 'meta' = 'audio'; - - res.on('data', (chunk: Buffer) => { - let offset = 0; - while (offset < chunk.length) { - if (state === 'audio') { - const need = metaint - audioBytesRead; - const available = chunk.length - offset; - const take = Math.min(need, available); - audioBytesRead += take; - offset += take; - if (audioBytesRead >= metaint) { - state = 'meta-length'; - } - } else if (state === 'meta-length') { - metaLength = chunk[offset] * 16; - offset++; - if (metaLength === 0) { - audioBytesRead = 0; - state = 'audio'; - } else { - metaBuffer = Buffer.alloc(0); - metaBytesRead = 0; - state = 'meta'; - } - } else if (state === 'meta') { - const need = metaLength - metaBytesRead; - const available = chunk.length - offset; - const take = Math.min(need, available); - metaBuffer = Buffer.concat([ - metaBuffer, - chunk.slice(offset, offset + take), - ]); - metaBytesRead += take; - offset += take; - if (metaBytesRead >= metaLength) { - const metaStr = metaBuffer - .toString('utf-8') - .replace(/\x00/g, ''); - const match = metaStr.match(/StreamTitle='([^']+)'/); - req.destroy(); - if (!match) { - resolve(null); - return; - } - const raw = match[1].trim(); - // Некоторые потоки (101.ru и др.) шлют в StreamTitle JSON-статус, - // а не название трека — это не трек, отсекаем. - if (raw.startsWith('{') || raw.startsWith('[')) { - resolve(null); - return; - } - const parts = raw.split(' - ', 2); - if (parts.length < 2) { - resolve({ artist: raw, song: raw }); - } else { - resolve({ - artist: parts[0].trim(), - song: parts[1].trim(), - }); - } - 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')); - }); - }); + const raw = await readIcyStreamTitle(url, { timeoutMs: 5000 }); + if (!raw) return null; + // Некоторые потоки (101.ru и др.) шлют в StreamTitle JSON-статус, а не трек. + if (raw.startsWith('{') || raw.startsWith('[')) return null; + const parts = raw.split(' - ', 2); + const artist = parts.length < 2 ? raw : parts[0].trim(); + const song = parts.length < 2 ? raw : parts[1].trim(); + if (!artist || !song) return null; + return { artist, song }; } } diff --git a/src/now-playing/icy-reader.ts b/src/now-playing/icy-reader.ts new file mode 100644 index 0000000..37068e2 --- /dev/null +++ b/src/now-playing/icy-reader.ts @@ -0,0 +1,135 @@ +import * as http from 'http'; +import * as https from 'https'; + +export interface IcyReadOptions { + /** Таймаут сокета, мс (по умолчанию 8000). */ + timeoutMs?: number; + /** + * Декодирование StreamTitle: + * - 'utf8' — как есть (по умолчанию); + * - 'auto-1251' — при битом UTF-8 (символ �) перечитать байты как windows-1251 + * (нужно потокам с кириллицей в cp1251, напр. «Новое Радио BY»). + */ + decode?: 'utf8' | 'auto-1251'; + /** Доп. заголовки запроса (User-Agent и т.п.). */ + headers?: Record; +} + +/** + * Единая реализация чтения первого StreamTitle из ICY-метаданных потока. + * Раньше один и тот же state-machine разбора icy-metaint был скопирован в трёх + * сервисах (icy/novoeby/love) — теперь источник один. + * + * Возвращает очищенный заголовок (без \x00, trim) либо null. Никогда не реджектит + * (ошибки сети/таймаут → null), чтобы вызов был безопасен в Promise.allSettled. + */ +export function readIcyStreamTitle( + url: string, + opts: IcyReadOptions = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? 8000; + const decode = opts.decode ?? 'utf8'; + 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', ...(opts.headers ?? {}) }, + timeout: timeoutMs, + }, + (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 metaBuf = 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 { + metaBuf = Buffer.alloc(0); + state = 'meta'; + } + } else { + const take = Math.min( + metaLen - metaBuf.length, + chunk.length - off, + ); + metaBuf = Buffer.concat([ + metaBuf, + chunk.slice(off, off + take), + ]); + off += take; + if (metaBuf.length >= metaLen) { + req.destroy(); + finish(extractTitle(metaBuf, decode)); + 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); + } + }); +} + +/** + * Достаёт StreamTitle из блока ICY-метаданных. Границы ищем побайтово (latin1), + * чтобы мультибайтовая кириллица не сбила смещения; терминатор — `';` (а не первый + * апостроф — иначе названия с апострофом, напр. «Song's Name», обрезались бы). + */ +function extractTitle(buf: Buffer, decode: 'utf8' | 'auto-1251'): string | null { + 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 = + decode === 'auto-1251' && utf8.includes('�') + ? new TextDecoder('windows-1251').decode(titleBytes) + : utf8; + const clean = decoded.replace(/\x00/g, '').trim(); + return clean || null; +} diff --git a/src/now-playing/love-now-playing.service.ts b/src/now-playing/love-now-playing.service.ts index fa521bb..538b00f 100644 --- a/src/now-playing/love-now-playing.service.ts +++ b/src/now-playing/love-now-playing.service.ts @@ -2,8 +2,7 @@ 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'; +import { readIcyStreamTitle } from './icy-reader'; /** * Now-playing для Love Radio. Их API (player/online) кэширует один трек на все @@ -52,7 +51,9 @@ export class LoveNowPlayingService { 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); + const title = await readIcyStreamTitle(url, { + headers: { 'User-Agent': 'Mozilla/5.0' }, + }); if (!title) return; // «onlinestop56k» = заглушка (UID протух) — сбросим, добёрём на след. цикле if (title === 'onlinestop56k') { @@ -108,79 +109,4 @@ export class LoveNowPlayingService { } } - // Читает первый 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); - } - }); - } } diff --git a/src/now-playing/novoeby-now-playing.service.ts b/src/now-playing/novoeby-now-playing.service.ts index 9e75493..b30078b 100644 --- a/src/now-playing/novoeby-now-playing.service.ts +++ b/src/now-playing/novoeby-now-playing.service.ts @@ -2,8 +2,7 @@ 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'; +import { readIcyStreamTitle } from './icy-reader'; /** * Now-playing для «Новое Радио BY» (Беларусь, live.novoeradio.by). Их Icecast-мейны @@ -31,8 +30,11 @@ export class NovoeByNowPlayingService { let updated = 0; await Promise.allSettled( stations.map(async (station) => { - const title = await this.readIcyTitle(station.streamUrl).catch(() => null); - if (!title) return; + // Кириллица в их ICY — windows-1251 (decode: auto-1251). + const title = await readIcyStreamTitle(station.streamUrl, { + decode: 'auto-1251', + }); + if (!title || title.startsWith('{') || title.startsWith('[')) return; // Джинглы/заставки без трека («NOVOE RADIO MEGAMIX» и т.п.) — пропускаем. const sep = title.indexOf(' - '); @@ -60,89 +62,4 @@ export class NovoeByNowPlayingService { 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; - } }