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>
107 lines
3.8 KiB
TypeScript
107 lines
3.8 KiB
TypeScript
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;
|
||
}
|
||
}
|