feat: now-playing polling from Record API with station mapping and WebSocket broadcast
This commit is contained in:
@@ -35,20 +35,21 @@ model MagicLinkToken {
|
||||
}
|
||||
|
||||
model Station {
|
||||
id String @id @default(cuid())
|
||||
stationId Int @unique @map("station_id")
|
||||
name String
|
||||
prefix String
|
||||
streamUrl String @map("stream_url")
|
||||
coverUrl String? @map("cover_url")
|
||||
genre String?
|
||||
tags String[]
|
||||
sortOrder Int @map("sort_order")
|
||||
source String // "record" | "local"
|
||||
isOnline Boolean @default(true) @map("is_online")
|
||||
lastCheckAt DateTime? @map("last_check_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id String @id @default(cuid())
|
||||
stationId Int @unique @map("station_id")
|
||||
name String
|
||||
prefix String
|
||||
streamUrl String @map("stream_url")
|
||||
coverUrl String? @map("cover_url")
|
||||
genre String?
|
||||
tags String[]
|
||||
sortOrder Int @map("sort_order")
|
||||
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")
|
||||
|
||||
favorites UserFavorite[]
|
||||
history PlayHistory[]
|
||||
@@ -56,6 +57,7 @@ model Station {
|
||||
|
||||
@@index([isOnline])
|
||||
@@index([source])
|
||||
@@index([recordStationId])
|
||||
@@map("stations")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
133
src/now-playing/record-station-sync.service.ts
Normal file
133
src/now-playing/record-station-sync.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user