Files
radiola-backend/src/now-playing/unistar-now-playing.service.ts
nk 4d9fd24074 feat(now-playing): now-playing + обложки для каналов Unistar
8 HLS-каналов Unistar (Беларусь) не отдают ICY, поэтому трек брали неоткуда.
Добавлен UnistarNowPlayingService (@Interval 30с): тянет текущий трек из их API
https://api3.unistar.by/client/latest/{slug} (slug = сегмент /hls/{slug}/ потока),
берёт Artist/Title и имя файла обложки (unistar.by/upload/music/photos/), ingest'ит.
Только Type=Music. Unistar исключён из ICY-поллера.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:37:17 +03:00

107 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
// Ответ https://api3.unistar.by/client/latest/{slug}
interface UnistarLatest {
latest: {
channel_name?: string;
element_data?: {
Type?: string; // Music | Commercial | Program | Jingle | News...
Title?: string;
Artist?: string;
PictureFile?: string;
FullPictureUrl?: string;
};
} | null;
}
/**
* Now-playing для каналов Unistar (Беларусь). Все 8 каналов вещают через HLS
* (usp.unistar.by) — ICY-метаданных нет, поэтому трек берём из их API:
* GET https://api3.unistar.by/client/latest/{slug}, где slug = alt_name канала
* (unistar_main, unistar_top, ...), он же сегмент пути потока /hls/{slug}/master.m3u8.
* API отдаёт исполнителя, название и имя файла обложки (своя картинка трека).
*/
@Injectable()
export class UnistarNowPlayingService {
private readonly logger = new Logger(UnistarNowPlayingService.name);
// База для имён файлов обложек (pics_path из appData плеера Unistar)
private readonly picsBase = 'https://unistar.by/upload/music/photos/';
private readonly headers = {
'User-Agent': 'Mozilla/5.0',
Origin: 'https://unistar.by',
Referer: 'https://unistar.by/',
};
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollUnistarNowPlaying() {
// Не фильтруем по isOnline: health-check ошибочно метит HLS-потоки offline.
const stations = await this.prisma.station.findMany({
where: { streamUrl: { contains: 'unistar.by' } },
});
if (stations.length === 0) return;
let updated = 0;
await Promise.allSettled(
stations.map(async (station) => {
const slug = this.extractSlug(station.streamUrl);
if (!slug) return;
const res = await fetch(
`https://api3.unistar.by/client/latest/${slug}`,
{ headers: this.headers },
);
if (!res.ok) return;
const data = (await res.json()) as UnistarLatest;
const el = data.latest?.element_data;
// Только музыка: рекламу/программы/джинглы не показываем как трек.
if (!el || el.Type !== 'Music') return;
const artist = (el.Artist ?? '').trim();
const song = (el.Title ?? '').trim();
if (!artist || !song) return;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist,
song,
coverUrl: this.buildCoverUrl(el.PictureFile ?? el.FullPictureUrl),
});
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}),
);
this.logger.log(`Unistar poll: ${updated}/${stations.length} обновлено`);
}
// http://edge1.usp.unistar.by/hls/unistar_top/master.m3u8 → unistar_top
private extractSlug(streamUrl: string): string | null {
const m = streamUrl.match(/\/hls\/([a-z0-9_]+)\//i);
return m ? m[1].toLowerCase() : null;
}
// Имя файла обложки → полный URL (или абсолютный URL как есть)
private buildCoverUrl(pic?: string): string | null {
const p = (pic ?? '').trim();
if (!p) return null;
return p.startsWith('http') ? p : this.picsBase + p;
}
}