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:
@@ -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<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 {
|
||||
return url.includes('/covers/');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user