feat: ICY metadata fallback for non-Record stations
This commit is contained in:
106
src/now-playing/icy-now-playing.service.ts
Normal file
106
src/now-playing/icy-now-playing.service.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NowPlayingGateway } from './now-playing.gateway';
|
||||
|
||||
@Injectable()
|
||||
export class IcyNowPlayingService {
|
||||
private readonly logger = new Logger(IcyNowPlayingService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly gateway: NowPlayingGateway,
|
||||
) {}
|
||||
|
||||
@Interval(60000)
|
||||
async pollIcyNowPlaying() {
|
||||
const stations = await this.prisma.station.findMany({
|
||||
where: { recordStationId: null, isOnline: true },
|
||||
});
|
||||
|
||||
for (const station of stations) {
|
||||
try {
|
||||
const track = await this.parseIcyMetadata(station.streamUrl);
|
||||
if (!track) continue;
|
||||
|
||||
const updated = await this.prisma.nowPlaying.upsert({
|
||||
where: { stationId: station.id },
|
||||
create: {
|
||||
stationId: station.id,
|
||||
song: track.song,
|
||||
artist: track.artist,
|
||||
coverUrl: null,
|
||||
},
|
||||
update: {
|
||||
song: track.song,
|
||||
artist: track.artist,
|
||||
coverUrl: null,
|
||||
},
|
||||
});
|
||||
|
||||
this.gateway.broadcastNowPlaying(station.stationId.toString(), {
|
||||
song: track.song,
|
||||
artist: track.artist,
|
||||
coverUrl: null,
|
||||
updatedAt: updated.updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.debug(
|
||||
`ICY failed for ${station.name}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async parseIcyMetadata(
|
||||
url: string,
|
||||
): Promise<{ artist: string; song: string } | null> {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'Icy-MetaData': '1' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
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() };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user