diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 517252a..5d64aa4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/src/now-playing/now-playing.module.ts b/src/now-playing/now-playing.module.ts index 0d5e78d..72e4b04 100644 --- a/src/now-playing/now-playing.module.ts +++ b/src/now-playing/now-playing.module.ts @@ -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 {} diff --git a/src/now-playing/now-playing.service.ts b/src/now-playing/now-playing.service.ts index 28c4923..376880b 100644 --- a/src/now-playing/now-playing.service.ts +++ b/src/now-playing/now-playing.service.ts @@ -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 }, diff --git a/src/now-playing/record-station-sync.service.ts b/src/now-playing/record-station-sync.service.ts new file mode 100644 index 0000000..62e6848 --- /dev/null +++ b/src/now-playing/record-station-sync.service.ts @@ -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(); + + 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 { + 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(); + + 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(); + } + } +}