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) ? await this.discogs.lookup(track.artist, track.song)
: null; : null;
// Обложка: iTunes (покрытие почти как у Record — Apple-арт) → Discogs → // iTunes: обложка (покрытие почти как у Record) + альбом/год/жанр как
// уже имеющаяся. Приводим к WebP и кладём к себе (если ещё не наша). // фолбэк к Discogs. Гибрид: стили и лейбл — только Discogs.
const itunesCover = await this.fetchItunesCover(track.artist, track.song); const itunes = await this.fetchItunes(track.artist, track.song);
// Обложка → WebP к себе (если ещё не наша)
let coverUrl = track.coverUrl; let coverUrl = track.coverUrl;
const candidate = itunesCover ?? data?.coverImageUrl ?? track.coverUrl; const candidate = itunes?.coverUrl ?? data?.coverImageUrl ?? track.coverUrl;
if (candidate && !this.isSelfHosted(candidate)) { if (candidate && !this.isSelfHosted(candidate)) {
const stored = await this.covers.store(candidate, track.normKey); const stored = await this.covers.store(candidate, track.normKey);
if (stored) coverUrl = stored; 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; const enriched = this.discogs.enabled;
await this.prisma.track.update({ await this.prisma.track.update({
where: { id: trackId }, where: { id: trackId },
data: { data: {
genre: data?.genre ?? track.genre, genre,
styles: data?.styles?.length ? data.styles : track.styles, styles: data?.styles?.length ? data.styles : track.styles,
label: data?.label ?? track.label, label: data?.label ?? track.label,
year: data?.year ?? track.year, year,
album,
discogsId: data?.discogsId ?? track.discogsId, discogsId: data?.discogsId ?? track.discogsId,
coverUrl, coverUrl,
releaseDate: releaseDate,
!track.releaseDate && data?.year
? new Date(Date.UTC(data.year, 0, 1))
: track.releaseDate,
enrichStatus: enriched ? 'done' : 'pending', enrichStatus: enriched ? 'done' : 'pending',
enrichedAt: enriched ? new Date() : track.enrichedAt, enrichedAt: enriched ? new Date() : track.enrichedAt,
}, },
}); });
this.logger.debug( this.logger.debug(
`Обогащён "${track.artist}${track.song}": genre=${data?.genre ?? '—'}, label=${data?.label ?? '—'}`, `Обогащён "${track.artist}${track.song}": genre=${genre ?? '—'}, label=${data?.label ?? '—'}`,
); );
} catch (e) { } catch (e) {
this.logger.debug(`Обогащение ${trackId} не удалось: ${(e as Error).message}`); this.logger.debug(`Обогащение ${trackId} не удалось: ${(e as Error).message}`);
@@ -158,12 +167,18 @@ export class EnrichmentService {
} }
} }
// Обложка из iTunes Search API (без ключа, высокое покрытие). // iTunes Search API (без ключа, высокое покрытие): обложка (600×600) +
// artworkUrl100 апскейлим до 600×600. // альбом/год/жанр/дата релиза.
private async fetchItunesCover( private async fetchItunes(
artist: string, artist: string,
song: string, song: string,
): Promise<string | null> { ): Promise<{
coverUrl: string | null;
album: string | null;
year: number | null;
releaseDate: Date | null;
genre: string | null;
} | null> {
try { try {
const term = encodeURIComponent(`${artist} ${song}`.trim()); const term = encodeURIComponent(`${artist} ${song}`.trim());
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`;
@@ -172,11 +187,29 @@ export class EnrichmentService {
}); });
if (!res.ok) return null; if (!res.ok) return null;
const data = (await res.json()) as { 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 { } catch {
return null; return null;
} }