fix: use Node.js http module for reliable ICY parsing

This commit is contained in:
nk
2026-06-02 20:19:21 +03:00
parent d0874ae9db
commit bbfec76a7b

View File

@@ -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),
});
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;
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;
}
// 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 audioBytesRead = 0;
let metaLength = 0;
let metaBytesRead = 0;
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)]);
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';
}
reader.cancel();
const metaStr = metaBuffer.toString('utf-8').replace(/\x00/g, '');
} 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='([^']+)'/);
if (!match) return null;
req.destroy();
if (!match) {
resolve(null);
return;
}
const parts = match[1].split(' - ', 2);
if (parts.length < 2) {
return { artist: match[1], song: match[1] };
resolve({ artist: match[1], song: match[1] });
} else {
resolve({
artist: parts[0].trim(),
song: parts[1].trim(),
});
}
return { 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'));
});
});
}
}