diff --git a/src/enrich/enrichment.service.ts b/src/enrich/enrichment.service.ts index f6a7045..a8303f2 100644 --- a/src/enrich/enrichment.service.ts +++ b/src/enrich/enrichment.service.ts @@ -235,15 +235,68 @@ export class EnrichmentService { releaseDate: Date | null; genre: string | null; } | null> { - // Пунктуация в названии («St.Thomas», «feat.») ломает поиск iTunes — - // заменяем все не-буквенно-цифровые символы на пробел и схлопываем. - const clean = `${artist} ${song}` + // Попытка 1: как есть. Многие треки несут суффиксы «(Original Mix)», + // «(SEA)», «[... Dub]», «feat. X» — они ломают точный матч iTunes (limit=1). + let r = await this.itunesSearch(`${artist} ${song}`); + + // Попытка 2: очищенный запрос (без скобок/квадратных/feat) — даёт обложку + // базового трека, когда точный ремикс не нашёлся. + if (!r?.coverUrl) { + const cleaned = `${this.stripNoise(artist)} ${this.stripNoise(song)}` + .replace(/\s+/g, ' ') + .trim(); + const original = `${artist} ${song}`.toLowerCase(); + if (cleaned && cleaned.toLowerCase() !== original) { + const r2 = await this.itunesSearch(cleaned); + if (r2?.coverUrl) r = r2; + } + } + + // Попытка 3: Deezer (публичный API, без ключа) — у него хорошее покрытие + // электроники/ремиксов/лаунжа, которых нет в iTunes. Берём только обложку. + if (!r?.coverUrl) { + const dz = await this.fetchDeezerCover(artist, song); + if (dz) { + r = { + coverUrl: dz, + album: r?.album ?? null, + year: r?.year ?? null, + releaseDate: r?.releaseDate ?? null, + genre: r?.genre ?? null, + }; + } + } + + return r; + } + + /** Убирает «шумовые» суффиксы названия, мешающие матчу обложки. */ + private stripNoise(s: string): string { + return s + .replace(/\([^)]*\)/g, ' ') // (Original Mix), (SEA), (feat. X) + .replace(/\[[^\]]*\]/g, ' ') // [Luxar Brooklyn Dub] + .replace(/\b(?:feat|ft|featuring)\.?\s+.*$/gi, ' ') // feat. X … .replace(/[^\p{L}\p{N}]+/gu, ' ') .replace(/\s+/g, ' ') .trim(); + } + + /** Один поиск в iTunes по уже собранному запросу. Бросает при сбое сети/HTTP + * (отличаем сбой от чистого «не найдено» → null). */ + private async itunesSearch(rawTerm: string): Promise<{ + coverUrl: string | null; + album: string | null; + year: number | null; + releaseDate: Date | null; + genre: string | null; + } | null> { + const clean = rawTerm + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!clean) return null; const term = encodeURIComponent(clean); const url = `https://itunes.apple.com/search?term=${term}&entity=song&limit=1`; - // Бросаем при сетевой/HTTP-ошибке — это сбой, а не «не найдено». const res = await fetch(url, { headers: { 'User-Agent': 'radiOLA/1.0 +https://radiola.app' }, }); @@ -257,7 +310,7 @@ export class EnrichmentService { }>; }; const r = data.results?.[0]; - if (!r) return null; // чистое «не найдено» + if (!r) return null; const cover = r.artworkUrl100 ? r.artworkUrl100.replace(/\/\d+x\d+bb\./, '/600x600bb.') @@ -274,6 +327,31 @@ export class EnrichmentService { }; } + /** Обложка из Deezer (фолбэк). Best-effort: при любой ошибке → null. */ + private async fetchDeezerCover( + artist: string, + song: string, + ): Promise { + try { + const q = `${this.stripNoise(artist)} ${this.stripNoise(song)}` + .replace(/\s+/g, ' ') + .trim(); + if (!q) return null; + const url = `https://api.deezer.com/search?limit=1&q=${encodeURIComponent(q)}`; + 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 { + data?: Array<{ album?: { cover_xl?: string; cover_big?: string } }>; + }; + const al = data.data?.[0]?.album; + return al?.cover_xl ?? al?.cover_big ?? null; + } catch { + return null; + } + } + private isSelfHosted(url: string): boolean { return url.includes('/covers/'); }