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:
@@ -14,10 +14,10 @@ import { CoverStorageService } from './cover-storage.service';
|
||||
export class EnrichmentService {
|
||||
private readonly logger = new Logger(EnrichmentService.name);
|
||||
|
||||
// Очередь обогащения с троттлингом (Discogs ~60 запросов/мин с токеном)
|
||||
// Очередь обогащения с троттлингом (под лимиты Discogs/iTunes)
|
||||
private readonly queue: string[] = [];
|
||||
private running = false;
|
||||
private readonly throttleMs = 1200;
|
||||
private readonly throttleMs = 1500;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -25,10 +25,19 @@ export class EnrichmentService {
|
||||
private readonly covers: CoverStorageService,
|
||||
) {}
|
||||
|
||||
// Поставить трек в очередь (fire-and-forget из recordPlay)
|
||||
enqueue(trackId: string): void {
|
||||
if (this.queue.includes(trackId)) return;
|
||||
this.queue.push(trackId);
|
||||
// Поставить трек в очередь. priority — играющие сейчас треки (в начало очереди),
|
||||
// чтобы обложка успела появиться, пока трек звучит.
|
||||
enqueue(trackId: string, opts?: { priority?: boolean }): void {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -74,9 +83,11 @@ export class EnrichmentService {
|
||||
? await this.discogs.lookup(track.artist, track.song)
|
||||
: null;
|
||||
|
||||
// Обложку приводим к WebP и кладём к себе (если ещё не наша)
|
||||
// Обложка: iTunes (покрытие почти как у Record — Apple-арт) → Discogs →
|
||||
// уже имеющаяся. Приводим к WebP и кладём к себе (если ещё не наша).
|
||||
const itunesCover = await this.fetchItunesCover(track.artist, track.song);
|
||||
let coverUrl = track.coverUrl;
|
||||
const candidate = data?.coverImageUrl ?? track.coverUrl;
|
||||
const candidate = itunesCover ?? data?.coverImageUrl ?? track.coverUrl;
|
||||
if (candidate && !this.isSelfHosted(candidate)) {
|
||||
const stored = await this.covers.store(candidate, track.normKey);
|
||||
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 {
|
||||
return url.includes('/covers/');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user