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:
@@ -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) {
|
||||||
// Ошибка сбора не должна ронять поллер
|
// Ошибка сбора не должна ронять поллер
|
||||||
|
|||||||
@@ -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/');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user