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;
|
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/');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user