feat(enrich): iTunes-фолбэк для жанра/альбома/года (гибрид с Discogs)

iTunes-запрос (уже делается ради обложки) теперь отдаёт и альбом/год/жанр.
Жанр: Discogs (тонкий) → iTunes (грубый фолбэк) — поднимает покрытие жанров
в чартах. Альбом и дата релиза заполняются из iTunes. Стили и лейбл — по-прежнему
только Discogs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-03 15:00:02 +03:00
parent 554c1730a3
commit 5bd7bfb923

View File

@@ -115,40 +115,49 @@ export class EnrichmentService {
? await this.discogs.lookup(track.artist, track.song)
: null;
// Обложка: iTunes (покрытие почти как у Record — Apple-арт) → Discogs →
// уже имеющаяся. Приводим к WebP и кладём к себе (если ещё не наша).
const itunesCover = await this.fetchItunesCover(track.artist, track.song);
// iTunes: обложка (покрытие почти как у Record) + альбом/год/жанр как
// фолбэк к Discogs. Гибрид: стили и лейбл — только Discogs.
const itunes = await this.fetchItunes(track.artist, track.song);
// Обложка → WebP к себе (если ещё не наша)
let coverUrl = track.coverUrl;
const candidate = itunesCover ?? data?.coverImageUrl ?? track.coverUrl;
const candidate = itunes?.coverUrl ?? data?.coverImageUrl ?? track.coverUrl;
if (candidate && !this.isSelfHosted(candidate)) {
const stored = await this.covers.store(candidate, track.normKey);
if (stored) coverUrl = stored;
}
// Без токена Discogs жанры не получим — оставляем статус pending,
// чтобы добрать позже (когда токен появится), но обложку уже сохранили.
// Жанр: Discogs приоритетнее (тонкий), затем iTunes (грубый фолбэк)
const genre = data?.genre ?? itunes?.genre ?? track.genre;
const year = data?.year ?? itunes?.year ?? track.year;
const album = track.album ?? itunes?.album ?? null;
const releaseDate =
track.releaseDate ??
itunes?.releaseDate ??
(data?.year ? new Date(Date.UTC(data.year, 0, 1)) : null);
// Без токена Discogs стили/лейбл не получим — оставляем pending, чтобы
// добрать позже (но обложку/жанр-iTunes уже сохранили).
const enriched = this.discogs.enabled;
await this.prisma.track.update({
where: { id: trackId },
data: {
genre: data?.genre ?? track.genre,
genre,
styles: data?.styles?.length ? data.styles : track.styles,
label: data?.label ?? track.label,
year: data?.year ?? track.year,
year,
album,
discogsId: data?.discogsId ?? track.discogsId,
coverUrl,
releaseDate:
!track.releaseDate && data?.year
? new Date(Date.UTC(data.year, 0, 1))
: track.releaseDate,
releaseDate,
enrichStatus: enriched ? 'done' : 'pending',
enrichedAt: enriched ? new Date() : track.enrichedAt,
},
});
this.logger.debug(
`Обогащён "${track.artist}${track.song}": genre=${data?.genre ?? '—'}, label=${data?.label ?? '—'}`,
`Обогащён "${track.artist}${track.song}": genre=${genre ?? '—'}, label=${data?.label ?? '—'}`,
);
} catch (e) {
this.logger.debug(`Обогащение ${trackId} не удалось: ${(e as Error).message}`);
@@ -158,12 +167,18 @@ export class EnrichmentService {
}
}
// Обложка из iTunes Search API (без ключа, высокое покрытие).
// artworkUrl100 апскейлим до 600×600.
private async fetchItunesCover(
// iTunes Search API (без ключа, высокое покрытие): обложка (600×600) +
// альбом/год/жанр/дата релиза.
private async fetchItunes(
artist: string,
song: string,
): Promise<string | null> {
): Promise<{
coverUrl: string | null;
album: string | null;
year: number | null;
releaseDate: Date | null;
genre: string | null;
} | null> {
try {
const term = encodeURIComponent(`${artist} ${song}`.trim());
const url = `https://itunes.apple.com/search?term=${term}&entity=song&limit=1`;
@@ -172,11 +187,29 @@ export class EnrichmentService {
});
if (!res.ok) return null;
const data = (await res.json()) as {
results?: Array<{ artworkUrl100?: string }>;
results?: Array<{
artworkUrl100?: string;
collectionName?: string;
releaseDate?: string;
primaryGenreName?: string;
}>;
};
const r = data.results?.[0];
if (!r) return null;
const cover = r.artworkUrl100
? r.artworkUrl100.replace(/\/\d+x\d+bb\./, '/600x600bb.')
: null;
const rd = r.releaseDate ? new Date(r.releaseDate) : null;
const validDate = rd && !isNaN(rd.getTime()) ? rd : null;
return {
coverUrl: cover,
album: r.collectionName ?? null,
year: validDate ? validDate.getUTCFullYear() : null,
releaseDate: validDate,
genre: r.primaryGenreName ?? null,
};
const art = data.results?.[0]?.artworkUrl100;
if (!art) return null;
return art.replace(/\/\d+x\d+bb\./, '/600x600bb.');
} catch {
return null;
}