diff --git a/src/charts/charts.service.ts b/src/charts/charts.service.ts index dd0b56e..53b4b80 100644 --- a/src/charts/charts.service.ts +++ b/src/charts/charts.service.ts @@ -154,9 +154,10 @@ export class ChartsService { this.logger.debug(`Записан трек: "${artist} — ${song}"`); - // Асинхронное обогащение нового трека (Discogs + WebP-обложка, fire-and-forget) + // Асинхронное обогащение (iTunes/Discogs + WebP-обложка, fire-and-forget). + // priority — трек играет прямо сейчас, обложка нужна быстро. if (track.enrichStatus !== 'done') { - this.enrichment.enqueue(track.id); + this.enrichment.enqueue(track.id, { priority: true }); } } catch (error) { // Ошибка сбора не должна ронять поллер diff --git a/src/enrich/enrichment.service.ts b/src/enrich/enrichment.service.ts index 78a8a1c..8262a46 100644 --- a/src/enrich/enrichment.service.ts +++ b/src/enrich/enrichment.service.ts @@ -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 { + 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/'); }