feat: ICY metadata fallback for non-Record stations

This commit is contained in:
nk
2026-06-02 20:03:25 +03:00
parent 09211dceb5
commit 1b0c59264f
2 changed files with 113 additions and 1 deletions

View 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() };
}
}

View File

@@ -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 {}