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 {
|
model Station {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
stationId Int @unique @map("station_id")
|
stationId Int @unique @map("station_id")
|
||||||
name String
|
name String
|
||||||
prefix String
|
prefix String
|
||||||
streamUrl String @map("stream_url")
|
streamUrl String @map("stream_url")
|
||||||
coverUrl String? @map("cover_url")
|
coverUrl String? @map("cover_url")
|
||||||
genre String?
|
genre String?
|
||||||
tags String[]
|
tags String[]
|
||||||
sortOrder Int @map("sort_order")
|
sortOrder Int @map("sort_order")
|
||||||
source String // "record" | "local"
|
source String @default("local") // "record" | "local"
|
||||||
isOnline Boolean @default(true) @map("is_online")
|
isOnline Boolean @default(true) @map("is_online")
|
||||||
lastCheckAt DateTime? @map("last_check_at")
|
lastCheckAt DateTime? @map("last_check_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
recordStationId Int? @unique @map("record_station_id")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
favorites UserFavorite[]
|
favorites UserFavorite[]
|
||||||
history PlayHistory[]
|
history PlayHistory[]
|
||||||
@@ -56,6 +57,7 @@ model Station {
|
|||||||
|
|
||||||
@@index([isOnline])
|
@@index([isOnline])
|
||||||
@@index([source])
|
@@index([source])
|
||||||
|
@@index([recordStationId])
|
||||||
@@map("stations")
|
@@map("stations")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
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';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [NowPlayingGateway, NowPlayingService],
|
providers: [NowPlayingGateway, NowPlayingService, RecordStationSyncService],
|
||||||
exports: [NowPlayingService],
|
exports: [NowPlayingService],
|
||||||
})
|
})
|
||||||
export class NowPlayingModule {}
|
export class NowPlayingModule {}
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Interval } from '@nestjs/schedule';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { NowPlayingGateway } from './now-playing.gateway';
|
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()
|
@Injectable()
|
||||||
export class NowPlayingService {
|
export class NowPlayingService {
|
||||||
@@ -9,8 +25,60 @@ export class NowPlayingService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly gateway: NowPlayingGateway,
|
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(
|
async updateNowPlaying(
|
||||||
stationId: string,
|
stationId: string,
|
||||||
data: { song: string; artist: string; coverUrl?: 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