feat(now-playing): Radio ROKS через TavR Media API (трек + обложки)

Главный канал ROKS не отдаёт трек по ICY (StreamTitle пустой), сабканалы —
без обложек. Новый RoksNowPlayingService опрашивает o.tavr.media/roks
(главный) и /roks4songs (сабканалы по type ukr/bal/new/har), отдаёт и трек,
и обложку static.radioroks.ua. Исключил genre='Radio ROKS' из ICY-поллера.
This commit is contained in:
nk
2026-06-04 11:44:30 +03:00
parent 51576f7198
commit 3c6dbed659
3 changed files with 108 additions and 1 deletions

View File

@@ -0,0 +1,97 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
interface TavrTrack {
singer?: string;
song?: string;
cover?: string;
type?: string;
}
/**
* Now-playing для Radio ROKS (TavR Media). Главный канал не отдаёт трек по ICY
* (StreamTitle пустой), а сабканалы — без обложек. У TavR есть JSON-API, который
* даёт и трек, и обложку (static.radioroks.ua, 500×500):
* • https://o.tavr.media/roks — главный канал, [0] = текущий трек
* • https://o.tavr.media/roks4songs — сабканалы, по полю type (ukr/bal/new/har)
*/
@Injectable()
export class RoksNowPlayingService {
private readonly logger = new Logger(RoksNowPlayingService.name);
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
// Подстрока в имени нашей станции -> type в roks4songs.
// HD-варианты ловятся теми же правилами (Ballads HD, New Rock HD и т.д.).
private readonly typeByName: { match: RegExp; type: string }[] = [
{ match: /ballads/i, type: 'bal' },
{ match: /hard/i, type: 'har' },
{ match: /new\s*rock/i, type: 'new' },
{ match: /ukrai|укра/i, type: 'ukr' },
];
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollRoks() {
const stations = await this.prisma.station.findMany({
where: { genre: 'Radio ROKS' },
});
if (stations.length === 0) return;
const [main, subs] = await Promise.all([
this.fetchTavr('https://o.tavr.media/roks'),
this.fetchTavr('https://o.tavr.media/roks4songs'),
]);
if (!main && !subs) return;
const mainCur = main?.[0];
// type -> текущий трек сабканала (первый по времени = играющий сейчас)
const subByType = new Map<string, TavrTrack>();
for (const s of subs ?? []) {
if (s.type && !subByType.has(s.type)) subByType.set(s.type, s);
}
let updated = 0;
for (const station of stations) {
const rule = this.typeByName.find((r) => r.match.test(station.name));
const cur = rule ? subByType.get(rule.type) : mainCur;
if (!cur?.singer || !cur?.song) continue;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist: cur.singer.trim(),
song: cur.song.trim(),
coverUrl: cur.cover || null,
});
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}
this.logger.log(`ROKS poll: ${updated}/${stations.length} обновлено`);
}
private async fetchTavr(url: string): Promise<TavrTrack[] | null> {
try {
const res = await fetch(url, { headers: this.headers });
if (!res.ok) {
this.logger.warn(`${url} вернул ${res.status}`);
return null;
}
return (await res.json()) as TavrTrack[];
} catch (e) {
this.logger.warn(`${url}: ${(e as Error).message}`);
return null;
}
}
}