From bbfec76a7b134df7e292a0139cf5e138edf89f72 Mon Sep 17 00:00:00 2001 From: nk Date: Tue, 2 Jun 2026 20:19:21 +0300 Subject: [PATCH] fix: use Node.js http module for reliable ICY parsing --- src/now-playing/icy-now-playing.service.ts | 140 ++++++++++++++------- 1 file changed, 94 insertions(+), 46 deletions(-) diff --git a/src/now-playing/icy-now-playing.service.ts b/src/now-playing/icy-now-playing.service.ts index 59a740e..e37b42e 100644 --- a/src/now-playing/icy-now-playing.service.ts +++ b/src/now-playing/icy-now-playing.service.ts @@ -2,6 +2,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { Interval } from '@nestjs/schedule'; import { PrismaService } from '../prisma/prisma.service'; import { NowPlayingGateway } from './now-playing.gateway'; +import * as http from 'http'; +import * as https from 'https'; @Injectable() export class IcyNowPlayingService { @@ -74,52 +76,98 @@ export class IcyNowPlayingService { private async parseIcyMetadata( url: string, ): Promise<{ artist: string; song: string } | null> { - const response = await fetch(url, { - headers: { 'Icy-MetaData': '1' }, - signal: AbortSignal.timeout(5000), + 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 parts = match[1].split(' - ', 2); + if (parts.length < 2) { + resolve({ artist: match[1], song: match[1] }); + } 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 metaintHeader = response.headers.get('icy-metaint'); - if (!metaintHeader) return null; - const metaint = parseInt(metaintHeader, 10); - if (!metaint || isNaN(metaint)) return null; - - const body = response.body; - if (!body) return null; - const reader = body.getReader(); - - // Skip audio bytes up to metaint - let skipped = 0; - while (skipped < metaint) { - const chunk = await reader.read(); - if (chunk.done) return null; - skipped += chunk.value.length; - } - - // Read metadata length byte - const lenChunk = await reader.read(); - if (lenChunk.done || !lenChunk.value.length) return null; - const metaLength = lenChunk.value[0] * 16; - if (metaLength === 0) return null; - - // Read metadata bytes - let metaBuffer = Buffer.alloc(0); - while (metaBuffer.length < metaLength) { - const chunk = await reader.read(); - if (chunk.done) break; - metaBuffer = Buffer.concat([metaBuffer, Buffer.from(chunk.value)]); - } - - reader.cancel(); - - const metaStr = metaBuffer.toString('utf-8').replace(/\x00/g, ''); - const match = metaStr.match(/StreamTitle='([^']+)'/); - if (!match) return null; - - const parts = match[1].split(' - ', 2); - if (parts.length < 2) { - return { artist: match[1], song: match[1] }; - } - return { artist: parts[0].trim(), song: parts[1].trim() }; } }