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>
This commit is contained in:
nk
2026-06-05 19:37:17 +03:00
parent 1f67e01ac8
commit 4d9fd24074
3 changed files with 109 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
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;
}
}