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 { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingGateway } from './now-playing.gateway'; import { NowPlayingGateway } from './now-playing.gateway';
import * as http from 'http';
import * as https from 'https';
@Injectable() @Injectable()
export class IcyNowPlayingService { export class IcyNowPlayingService {
@@ -74,52 +76,98 @@ export class IcyNowPlayingService {
private async parseIcyMetadata( private async parseIcyMetadata(
url: string, url: string,
): Promise<{ artist: string; song: string } | null> { ): Promise<{ artist: string; song: string } | null> {
const response = await fetch(url, { return new Promise((resolve, reject) => {
headers: { 'Icy-MetaData': '1' }, const client = url.startsWith('https') ? https : http;
signal: AbortSignal.timeout(5000), const req = client.get(
}); url,
{ headers: { 'Icy-MetaData': '1' }, timeout: 5000 },
const metaintHeader = response.headers.get('icy-metaint'); (res) => {
if (!metaintHeader) return null; const metaint = parseInt(
const metaint = parseInt(metaintHeader, 10); (res.headers['icy-metaint'] as string) || '0',
if (!metaint || isNaN(metaint)) return null; );
if (!metaint) {
const body = response.body; req.destroy();
if (!body) return null; resolve(null);
const reader = body.getReader(); return;
// 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 let audioBytesRead = 0;
const lenChunk = await reader.read(); let metaLength = 0;
if (lenChunk.done || !lenChunk.value.length) return null; let metaBytesRead = 0;
const metaLength = lenChunk.value[0] * 16;
if (metaLength === 0) return null;
// Read metadata bytes
let metaBuffer = Buffer.alloc(0); let metaBuffer = Buffer.alloc(0);
while (metaBuffer.length < metaLength) { let state: 'audio' | 'meta-length' | 'meta' = 'audio';
const chunk = await reader.read();
if (chunk.done) break; res.on('data', (chunk: Buffer) => {
metaBuffer = Buffer.concat([metaBuffer, Buffer.from(chunk.value)]); 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') {
reader.cancel(); metaLength = chunk[offset] * 16;
offset++;
const metaStr = metaBuffer.toString('utf-8').replace(/\x00/g, ''); 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='([^']+)'/); const match = metaStr.match(/StreamTitle='([^']+)'/);
if (!match) return null; req.destroy();
if (!match) {
resolve(null);
return;
}
const parts = match[1].split(' - ', 2); const parts = match[1].split(' - ', 2);
if (parts.length < 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'));
});
});
} }
} }