feat(enrich): обложки через iTunes Search + приоритет играющим трекам

Покрытие обложек у Discogs низкое (нет не-электроники, нишевого). Добавлен
iTunes Search API (без ключа, Apple-арт — как у Record) основным источником
обложки: iTunes → Discogs → существующая, далее WebP. Играющие сейчас треки
(recordPlay) ставятся в НАЧАЛО очереди обогащения — обложка успевает появиться,
пока трек звучит. Троттлинг 1.5с.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-03 14:33:52 +03:00
parent 96fabac7f5
commit 916fc301e4
2 changed files with 46 additions and 10 deletions

View File

@@ -154,9 +154,10 @@ export class ChartsService {
this.logger.debug(`Записан трек: "${artist}${song}"`); this.logger.debug(`Записан трек: "${artist}${song}"`);
// Асинхронное обогащение нового трека (Discogs + WebP-обложка, fire-and-forget) // Асинхронное обогащение (iTunes/Discogs + WebP-обложка, fire-and-forget).
// priority — трек играет прямо сейчас, обложка нужна быстро.
if (track.enrichStatus !== 'done') { if (track.enrichStatus !== 'done') {
this.enrichment.enqueue(track.id); this.enrichment.enqueue(track.id, { priority: true });
} }
} catch (error) { } catch (error) {
// Ошибка сбора не должна ронять поллер // Ошибка сбора не должна ронять поллер

View File

@@ -14,10 +14,10 @@ import { CoverStorageService } from './cover-storage.service';
export class EnrichmentService { export class EnrichmentService {
private readonly logger = new Logger(EnrichmentService.name); private readonly logger = new Logger(EnrichmentService.name);
// Очередь обогащения с троттлингом (Discogs ~60 запросов/мин с токеном) // Очередь обогащения с троттлингом (под лимиты Discogs/iTunes)
private readonly queue: string[] = []; private readonly queue: string[] = [];
private running = false; private running = false;
private readonly throttleMs = 1200; private readonly throttleMs = 1500;
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@@ -25,10 +25,19 @@ export class EnrichmentService {
private readonly covers: CoverStorageService, private readonly covers: CoverStorageService,
) {} ) {}
// Поставить трек в очередь (fire-and-forget из recordPlay) // Поставить трек в очередь. priority — играющие сейчас треки (в начало очереди),
enqueue(trackId: string): void { // чтобы обложка успела появиться, пока трек звучит.
if (this.queue.includes(trackId)) return; enqueue(trackId: string, opts?: { priority?: boolean }): void {
this.queue.push(trackId); const idx = this.queue.indexOf(trackId);
if (idx !== -1) {
if (opts?.priority && idx > 0) {
this.queue.splice(idx, 1);
this.queue.unshift(trackId);
}
return;
}
if (opts?.priority) this.queue.unshift(trackId);
else this.queue.push(trackId);
void this.drain(); void this.drain();
} }
@@ -74,9 +83,11 @@ export class EnrichmentService {
? await this.discogs.lookup(track.artist, track.song) ? await this.discogs.lookup(track.artist, track.song)
: null; : null;
// Обложку приводим к WebP и кладём к себе (если ещё не наша) // Обложка: iTunes (покрытие почти как у Record — Apple-арт) → Discogs →
// уже имеющаяся. Приводим к WebP и кладём к себе (если ещё не наша).
const itunesCover = await this.fetchItunesCover(track.artist, track.song);
let coverUrl = track.coverUrl; let coverUrl = track.coverUrl;
const candidate = data?.coverImageUrl ?? track.coverUrl; const candidate = itunesCover ?? data?.coverImageUrl ?? track.coverUrl;
if (candidate && !this.isSelfHosted(candidate)) { if (candidate && !this.isSelfHosted(candidate)) {
const stored = await this.covers.store(candidate, track.normKey); const stored = await this.covers.store(candidate, track.normKey);
if (stored) coverUrl = stored; if (stored) coverUrl = stored;
@@ -115,6 +126,30 @@ export class EnrichmentService {
} }
} }
// Обложка из iTunes Search API (без ключа, высокое покрытие).
// artworkUrl100 апскейлим до 600×600.
private async fetchItunesCover(
artist: string,
song: string,
): Promise<string | null> {
try {
const term = encodeURIComponent(`${artist} ${song}`.trim());
const url = `https://itunes.apple.com/search?term=${term}&entity=song&limit=1`;
const res = await fetch(url, {
headers: { 'User-Agent': 'radiOLA/1.0 +https://radiola.app' },
});
if (!res.ok) return null;
const data = (await res.json()) as {
results?: Array<{ artworkUrl100?: string }>;
};
const art = data.results?.[0]?.artworkUrl100;
if (!art) return null;
return art.replace(/\/\d+x\d+bb\./, '/600x600bb.');
} catch {
return null;
}
}
private isSelfHosted(url: string): boolean { private isSelfHosted(url: string): boolean {
return url.includes('/covers/'); return url.includes('/covers/');
} }