fix: use Node.js http module for reliable ICY parsing
This commit is contained in:
@@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user