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() };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,15 @@ import { Module } from '@nestjs/common';
|
|||||||
import { NowPlayingGateway } from './now-playing.gateway';
|
import { NowPlayingGateway } from './now-playing.gateway';
|
||||||
import { NowPlayingService } from './now-playing.service';
|
import { NowPlayingService } from './now-playing.service';
|
||||||
import { RecordStationSyncService } from './record-station-sync.service';
|
import { RecordStationSyncService } from './record-station-sync.service';
|
||||||
|
import { IcyNowPlayingService } from './icy-now-playing.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [NowPlayingGateway, NowPlayingService, RecordStationSyncService],
|
providers: [
|
||||||
|
NowPlayingGateway,
|
||||||
|
NowPlayingService,
|
||||||
|
RecordStationSyncService,
|
||||||
|
IcyNowPlayingService,
|
||||||
|
],
|
||||||
exports: [NowPlayingService],
|
exports: [NowPlayingService],
|
||||||
})
|
})
|
||||||
export class NowPlayingModule {}
|
export class NowPlayingModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user