feat: now-playing polling from Record API with station mapping and WebSocket broadcast

This commit is contained in:
nk
2026-06-02 19:31:48 +03:00
parent 2ae682fb68
commit 7823b17d55
4 changed files with 219 additions and 15 deletions

View File

@@ -44,9 +44,10 @@ model Station {
genre String?
tags String[]
sortOrder Int @map("sort_order")
source String // "record" | "local"
source String @default("local") // "record" | "local"
isOnline Boolean @default(true) @map("is_online")
lastCheckAt DateTime? @map("last_check_at")
recordStationId Int? @unique @map("record_station_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -56,6 +57,7 @@ model Station {
@@index([isOnline])
@@index([source])
@@index([recordStationId])
@@map("stations")
}

View File

@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { NowPlayingGateway } from './now-playing.gateway';
import { NowPlayingService } from './now-playing.service';
import { RecordStationSyncService } from './record-station-sync.service';
@Module({
providers: [NowPlayingGateway, NowPlayingService],
providers: [NowPlayingGateway, NowPlayingService, RecordStationSyncService],
exports: [NowPlayingService],
})
export class NowPlayingModule {}

View File

@@ -1,6 +1,22 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingGateway } from './now-playing.gateway';
import { RecordStationSyncService } from './record-station-sync.service';
interface RecordTrack {
id: number;
artist: string;
song: string;
image100?: string;
image200?: string;
image600?: string;
}
interface RecordNowPlayingItem {
id: number;
track: RecordTrack;
}
@Injectable()
export class NowPlayingService {
@@ -9,8 +25,60 @@ export class NowPlayingService {
constructor(
private readonly prisma: PrismaService,
private readonly gateway: NowPlayingGateway,
private readonly recordSync: RecordStationSyncService,
) {}
@Interval(30000)
async pollRecordNowPlaying() {
try {
const response = await fetch(
'https://www.radiorecord.ru/api/stations/now/',
{ headers: { 'Accept-Encoding': 'gzip, deflate' } },
);
if (!response.ok) {
this.logger.warn(`Record API returned ${response.status}`);
return;
}
const data = (await response.json()) as {
result: RecordNowPlayingItem[];
};
const nowPlaying = data.result ?? [];
for (const np of nowPlaying) {
const stationId =
this.recordSync.getStationIdByNowPlayingId(np.id);
if (!stationId) continue;
const coverUrl = np.track.image600 ?? np.track.image200 ?? np.track.image100;
const updated = await this.prisma.nowPlaying.upsert({
where: { stationId },
create: {
stationId,
song: np.track.song,
artist: np.track.artist,
coverUrl,
},
update: {
song: np.track.song,
artist: np.track.artist,
coverUrl,
},
});
this.gateway.broadcastNowPlaying(stationId, {
song: np.track.song,
artist: np.track.artist,
coverUrl,
updatedAt: updated.updatedAt,
});
}
} catch (error) {
this.logger.error(`Failed to poll now playing: ${error.message}`);
}
}
async updateNowPlaying(
stationId: string,
data: { song: string; artist: string; coverUrl?: string },

View File

@@ -0,0 +1,133 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
interface RecordStation {
id: number;
prefix: string;
title: string;
stream_320?: string;
stream_128?: string;
stream_64?: string;
}
interface RecordTrack {
id: number;
artist: string;
song: string;
image100?: string;
image200?: string;
image600?: string;
}
interface RecordNowPlayingItem {
id: number;
track: RecordTrack;
}
@Injectable()
export class RecordStationSyncService implements OnModuleInit {
private readonly logger = new Logger(RecordStationSyncService.name);
private nowPlayingIdToStationId = new Map<number, string>();
constructor(private readonly prisma: PrismaService) {}
async onModuleInit() {
try {
await this.syncRecordStations();
} catch (error) {
this.logger.error(
`Failed to sync Record stations on init: ${error.message}`,
);
}
}
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async syncRecordStations(): Promise<number> {
this.logger.log('Fetching Record stations list...');
const [stationsRes, nowPlayingRes] = await Promise.all([
fetch('https://www.radiorecord.ru/api/stations/', {
headers: { 'Accept-Encoding': 'gzip, deflate' },
}),
fetch('https://www.radiorecord.ru/api/stations/now/', {
headers: { 'Accept-Encoding': 'gzip, deflate' },
}),
]);
if (!stationsRes.ok) {
throw new Error(`Stations API returned ${stationsRes.status}`);
}
if (!nowPlayingRes.ok) {
throw new Error(`NowPlaying API returned ${nowPlayingRes.status}`);
}
const stationsData = (await stationsRes.json()) as {
result: { stations: RecordStation[] };
};
const nowPlayingData = (await nowPlayingRes.json()) as {
result: RecordNowPlayingItem[];
};
const recordStations = stationsData.result?.stations ?? [];
const nowPlaying = nowPlayingData.result ?? [];
this.logger.log(
`Fetched ${recordStations.length} stations and ${nowPlaying.length} now-playing entries`,
);
const ourStations = await this.prisma.station.findMany();
let matched = 0;
const newMap = new Map<number, string>();
for (let i = 0; i < recordStations.length; i++) {
const rs = recordStations[i];
const np = nowPlaying[i];
const recordStream = this.normalizeStreamUrl(
rs.stream_320 ?? rs.stream_128 ?? rs.stream_64,
);
if (!recordStream) continue;
const match = ourStations.find((s) => {
const ourStream = this.normalizeStreamUrl(s.streamUrl);
return ourStream === recordStream;
});
if (match && np) {
await this.prisma.station.update({
where: { id: match.id },
data: {
recordStationId: rs.id,
source: 'record',
prefix: rs.prefix,
coverUrl:
match.coverUrl ??
`https://www.radiorecord.ru/upload/stations_images/${rs.prefix}_image600_colored_fill.png`,
},
});
newMap.set(np.id, match.id);
matched++;
}
}
this.nowPlayingIdToStationId = newMap;
this.logger.log(
`Matched and updated ${matched} stations, mapped ${newMap.size} now-playing IDs`,
);
return matched;
}
getStationIdByNowPlayingId(nowPlayingId: number): string | undefined {
return this.nowPlayingIdToStationId.get(nowPlayingId);
}
private normalizeStreamUrl(url?: string): string | null {
if (!url) return null;
try {
const u = new URL(url);
return (u.hostname + u.pathname).toLowerCase().replace(/^www\./, '');
} catch {
return url.toLowerCase();
}
}
}