From 1b0c59264f584e87e7d56fa3ab20fb37b49b4d61 Mon Sep 17 00:00:00 2001 From: nk Date: Tue, 2 Jun 2026 20:03:25 +0300 Subject: [PATCH] feat: ICY metadata fallback for non-Record stations --- src/now-playing/icy-now-playing.service.ts | 106 +++++++++++++++++++++ src/now-playing/now-playing.module.ts | 8 +- 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/now-playing/icy-now-playing.service.ts diff --git a/src/now-playing/icy-now-playing.service.ts b/src/now-playing/icy-now-playing.service.ts new file mode 100644 index 0000000..1831009 --- /dev/null +++ b/src/now-playing/icy-now-playing.service.ts @@ -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() }; + } +} diff --git a/src/now-playing/now-playing.module.ts b/src/now-playing/now-playing.module.ts index 72e4b04..d09189c 100644 --- a/src/now-playing/now-playing.module.ts +++ b/src/now-playing/now-playing.module.ts @@ -2,9 +2,15 @@ import { Module } from '@nestjs/common'; import { NowPlayingGateway } from './now-playing.gateway'; import { NowPlayingService } from './now-playing.service'; import { RecordStationSyncService } from './record-station-sync.service'; +import { IcyNowPlayingService } from './icy-now-playing.service'; @Module({ - providers: [NowPlayingGateway, NowPlayingService, RecordStationSyncService], + providers: [ + NowPlayingGateway, + NowPlayingService, + RecordStationSyncService, + IcyNowPlayingService, + ], exports: [NowPlayingService], }) export class NowPlayingModule {}