refactor(now-playing): единый IcyReader + реестр dedicated-источников
Убраны 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 легко забывали добавить).
This commit is contained in:
36
src/now-playing/dedicated-sources.ts
Normal file
36
src/now-playing/dedicated-sources.ts
Normal file
@@ -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;
|
||||||
@@ -2,8 +2,8 @@ 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';
|
import { readIcyStreamTitle } from './icy-reader';
|
||||||
import * as https from 'https';
|
import { DEDICATED_GENRES, DEDICATED_STREAM_HOSTS } from './dedicated-sources';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сбор now-playing для не-Record станций (DFM и др.) через ICY-метаданные потока.
|
* Сбор now-playing для не-Record станций (DFM и др.) через ICY-метаданные потока.
|
||||||
@@ -24,32 +24,17 @@ export class IcyNowPlayingService {
|
|||||||
|
|
||||||
@Interval(60000)
|
@Interval(60000)
|
||||||
async pollIcyNowPlaying() {
|
async pollIcyNowPlaying() {
|
||||||
// ЕМГ (emgsound) и DFM/Крутой (genre=DFM) обрабатываются отдельными сервисами
|
// Станции с выделенным now-playing-сервисом (через их API) исключаем из ICY,
|
||||||
// через их API — исключаем из ICY, чтобы не тратить слоты впустую.
|
// чтобы не тратить слоты впустую и не перезаписывать точные данные сырым ICY.
|
||||||
|
// Источник исключений — единый реестр dedicated-sources (host + genre),
|
||||||
|
// согласованный с селекторами самих сервисов.
|
||||||
const where = {
|
const where = {
|
||||||
recordStationId: null,
|
recordStationId: null,
|
||||||
isOnline: true,
|
isOnline: true,
|
||||||
genre: {
|
genre: { notIn: [...DEDICATED_GENRES] },
|
||||||
notIn: [
|
AND: DEDICATED_STREAM_HOSTS.map((host) => ({
|
||||||
'DFM',
|
NOT: { streamUrl: { contains: host } },
|
||||||
'MAXIMUM',
|
})),
|
||||||
'Love Radio',
|
|
||||||
'Radio Monte Carlo',
|
|
||||||
'Radio ROKS',
|
|
||||||
'Unistar',
|
|
||||||
'Zaicev FM',
|
|
||||||
'Гусь',
|
|
||||||
'Новое Радио BY',
|
|
||||||
'Питер ФМ',
|
|
||||||
'Орфей',
|
|
||||||
'Радио Ваня',
|
|
||||||
'Русская Волна',
|
|
||||||
'Comedy Radio',
|
|
||||||
'Радио Energy',
|
|
||||||
'Radio 7',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
NOT: { streamUrl: { contains: 'emgsound.ru' } },
|
|
||||||
};
|
};
|
||||||
const total = await this.prisma.station.count({ where });
|
const total = await this.prisma.station.count({ where });
|
||||||
if (total === 0) return;
|
if (total === 0) return;
|
||||||
@@ -70,8 +55,8 @@ export class IcyNowPlayingService {
|
|||||||
const batch = stations.slice(i, i + 10);
|
const batch = stations.slice(i, i + 10);
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
batch.map(async (station) => {
|
batch.map(async (station) => {
|
||||||
const track = await this.parseIcyMetadata(station.streamUrl);
|
const track = await this.parseIcyTrack(station.streamUrl);
|
||||||
if (!track || !track.artist || !track.song) return null;
|
if (!track) return null;
|
||||||
|
|
||||||
await this.nowPlayingService.ingest({
|
await this.nowPlayingService.ingest({
|
||||||
stationDbId: station.id,
|
stationDbId: station.id,
|
||||||
@@ -94,108 +79,18 @@ export class IcyNowPlayingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async parseIcyMetadata(
|
/** Читает StreamTitle через общий ICY-ридер и разбирает «Артист - Песня». */
|
||||||
|
private async parseIcyTrack(
|
||||||
url: string,
|
url: string,
|
||||||
): Promise<{ artist: string; song: string } | null> {
|
): Promise<{ artist: string; song: string } | null> {
|
||||||
return new Promise((resolve, reject) => {
|
const raw = await readIcyStreamTitle(url, { timeoutMs: 5000 });
|
||||||
const client = url.startsWith('https') ? https : http;
|
if (!raw) return null;
|
||||||
const req = client.get(
|
// Некоторые потоки (101.ru и др.) шлют в StreamTitle JSON-статус, а не трек.
|
||||||
url,
|
if (raw.startsWith('{') || raw.startsWith('[')) return null;
|
||||||
{ headers: { 'Icy-MetaData': '1' }, timeout: 5000 },
|
const parts = raw.split(' - ', 2);
|
||||||
(res) => {
|
const artist = parts.length < 2 ? raw : parts[0].trim();
|
||||||
const metaint = parseInt(
|
const song = parts.length < 2 ? raw : parts[1].trim();
|
||||||
(res.headers['icy-metaint'] as string) || '0',
|
if (!artist || !song) return null;
|
||||||
);
|
return { artist, song };
|
||||||
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'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
135
src/now-playing/icy-reader.ts
Normal file
135
src/now-playing/icy-reader.ts
Normal file
@@ -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 (символ <20>) перечитать байты как windows-1251
|
||||||
|
* (нужно потокам с кириллицей в cp1251, напр. «Новое Радио BY»).
|
||||||
|
*/
|
||||||
|
decode?: 'utf8' | 'auto-1251';
|
||||||
|
/** Доп. заголовки запроса (User-Agent и т.п.). */
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единая реализация чтения первого StreamTitle из ICY-метаданных потока.
|
||||||
|
* Раньше один и тот же state-machine разбора icy-metaint был скопирован в трёх
|
||||||
|
* сервисах (icy/novoeby/love) — теперь источник один.
|
||||||
|
*
|
||||||
|
* Возвращает очищенный заголовок (без \x00, trim) либо null. Никогда не реджектит
|
||||||
|
* (ошибки сети/таймаут → null), чтобы вызов был безопасен в Promise.allSettled.
|
||||||
|
*/
|
||||||
|
export function readIcyStreamTitle(
|
||||||
|
url: string,
|
||||||
|
opts: IcyReadOptions = {},
|
||||||
|
): Promise<string | null> {
|
||||||
|
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');
|
||||||
|
// <20> (<28>) — признак невалидного UTF-8 → перечитываем как windows-1251.
|
||||||
|
const decoded =
|
||||||
|
decode === 'auto-1251' && utf8.includes('<27>')
|
||||||
|
? new TextDecoder('windows-1251').decode(titleBytes)
|
||||||
|
: utf8;
|
||||||
|
const clean = decoded.replace(/\x00/g, '').trim();
|
||||||
|
return clean || null;
|
||||||
|
}
|
||||||
@@ -2,8 +2,7 @@ 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';
|
import { readIcyStreamTitle } from './icy-reader';
|
||||||
import * as https from 'https';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Now-playing для Love Radio. Их API (player/online) кэширует один трек на все
|
* Now-playing для Love Radio. Их API (player/online) кэширует один трек на все
|
||||||
@@ -52,7 +51,9 @@ export class LoveNowPlayingService {
|
|||||||
const m = this.mount[station.name];
|
const m = this.mount[station.name];
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
const url = `https://stream2.n340.com/${m}?type=aac&UID=${uid}`;
|
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;
|
if (!title) return;
|
||||||
// «onlinestop56k» = заглушка (UID протух) — сбросим, добёрём на след. цикле
|
// «onlinestop56k» = заглушка (UID протух) — сбросим, добёрём на след. цикле
|
||||||
if (title === 'onlinestop56k') {
|
if (title === 'onlinestop56k') {
|
||||||
@@ -108,79 +109,4 @@ export class LoveNowPlayingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Читает первый 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ 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';
|
import { readIcyStreamTitle } from './icy-reader';
|
||||||
import * as https from 'https';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Now-playing для «Новое Радио BY» (Беларусь, live.novoeradio.by). Их Icecast-мейны
|
* Now-playing для «Новое Радио BY» (Беларусь, live.novoeradio.by). Их Icecast-мейны
|
||||||
@@ -31,8 +30,11 @@ export class NovoeByNowPlayingService {
|
|||||||
let updated = 0;
|
let updated = 0;
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
stations.map(async (station) => {
|
stations.map(async (station) => {
|
||||||
const title = await this.readIcyTitle(station.streamUrl).catch(() => null);
|
// Кириллица в их ICY — windows-1251 (decode: auto-1251).
|
||||||
if (!title) return;
|
const title = await readIcyStreamTitle(station.streamUrl, {
|
||||||
|
decode: 'auto-1251',
|
||||||
|
});
|
||||||
|
if (!title || title.startsWith('{') || title.startsWith('[')) return;
|
||||||
|
|
||||||
// Джинглы/заставки без трека («NOVOE RADIO MEGAMIX» и т.п.) — пропускаем.
|
// Джинглы/заставки без трека («NOVOE RADIO MEGAMIX» и т.п.) — пропускаем.
|
||||||
const sep = title.indexOf(' - ');
|
const sep = title.indexOf(' - ');
|
||||||
@@ -60,89 +62,4 @@ export class NovoeByNowPlayingService {
|
|||||||
|
|
||||||
this.logger.log(`NovoeBY poll: ${updated}/${stations.length} обновлено`);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user