feat(enrich): больше обложек — очистка запроса iTunes + фолбэк Deezer

Суффиксы названий («(Original Mix)», «(SEA)», «[... Dub]», «feat. X»)
ломали точный матч iTunes (limit=1) — у многих треков (особенно электроника/
лаунж/ремиксы Royal Radio и др.) обложка не находилась, хотя в iTunes/Deezer
она есть. Теперь fetchItunes: (1) запрос как есть → (2) очищенный (без скобок/
feat) → (3) фолбэк Deezer (публичный API, без ключа) только за обложкой.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 15:50:20 +03:00
parent 35f9a2b7cc
commit ba9b4054e8

View File

@@ -235,15 +235,68 @@ export class EnrichmentService {
releaseDate: Date | null; releaseDate: Date | null;
genre: string | null; genre: string | null;
} | null> { } | null> {
// Пунктуация в названии («St.Thomas», «feat.») ломает поиск iTunes — // Попытка 1: как есть. Многие треки несут суффиксы «(Original Mix)»,
// заменяем все не-буквенно-цифровые символы на пробел и схлопываем. // «(SEA)», «[... Dub]», «feat. X» — они ломают точный матч iTunes (limit=1).
const clean = `${artist} ${song}` 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(/[^\p{L}\p{N}]+/gu, ' ')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim(); .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 term = encodeURIComponent(clean);
const url = `https://itunes.apple.com/search?term=${term}&entity=song&limit=1`; const url = `https://itunes.apple.com/search?term=${term}&entity=song&limit=1`;
// Бросаем при сетевой/HTTP-ошибке — это сбой, а не «не найдено».
const res = await fetch(url, { const res = await fetch(url, {
headers: { 'User-Agent': 'radiOLA/1.0 +https://radiola.app' }, headers: { 'User-Agent': 'radiOLA/1.0 +https://radiola.app' },
}); });
@@ -257,7 +310,7 @@ export class EnrichmentService {
}>; }>;
}; };
const r = data.results?.[0]; const r = data.results?.[0];
if (!r) return null; // чистое «не найдено» if (!r) return null;
const cover = r.artworkUrl100 const cover = r.artworkUrl100
? r.artworkUrl100.replace(/\/\d+x\d+bb\./, '/600x600bb.') ? 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<string | null> {
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 { private isSelfHosted(url: string): boolean {
return url.includes('/covers/'); return url.includes('/covers/');
} }