Files
radiola-backend/src/enrich/enrichment.service.ts
nk ba9b4054e8 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>
2026-06-04 15:50:20 +03:00

363 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { DiscogsService } from './discogs.service';
import { CoverStorageService } from './cover-storage.service';
/**
* Оркестратор обогащения трека: при первом появлении трека подтягиваем
* жанр/стиль/лейбл/год из Discogs и сохраняем обложку в едином формате (WebP)
* у себя. Дальше пользователю отдаём только из своей БД — внешние сервисы
* в рантайме не дёргаем.
*/
@Injectable()
export class EnrichmentService {
private readonly logger = new Logger(EnrichmentService.name);
// Очередь обогащения с троттлингом (под лимиты Discogs/iTunes)
private readonly queue: string[] = [];
private running = false;
// Discogs сам себя лимитирует (rate-limiter в DiscogsService), поэтому можно
// выше параллельность: обложки (iTunes, без лимита) льются быстрее.
private readonly throttleMs = 150;
private readonly concurrency = 12;
constructor(
private readonly prisma: PrismaService,
private readonly discogs: DiscogsService,
private readonly covers: CoverStorageService,
) {}
// Поставить трек в очередь. priority — играющие сейчас треки (в начало очереди),
// чтобы обложка успела появиться, пока трек звучит.
enqueue(trackId: string, opts?: { priority?: boolean }): void {
const idx = this.queue.indexOf(trackId);
if (idx !== -1) {
if (opts?.priority && idx > 0) {
this.queue.splice(idx, 1);
this.queue.unshift(trackId);
}
return;
}
if (opts?.priority) this.queue.unshift(trackId);
else this.queue.push(trackId);
void this.drain();
}
// Непрерывно добираем холодный бэклог: когда очередь почти пуста — подкидываем
// батч pending (не-приоритетно, играющие треки всё равно идут вперёд).
// Раз в минуту, чтобы конвейер не простаивал между всплесками now-playing.
@Cron(CronExpression.EVERY_MINUTE)
async backfill(): Promise<void> {
if (!this.discogs.enabled) return; // без токена смысла нет — не крутим вхолостую
if (this.queue.length > this.concurrency) return; // ещё есть что жевать
const pending = await this.prisma.track.findMany({
where: { enrichStatus: 'pending' },
select: { id: true },
orderBy: { firstSeenAt: 'desc' },
take: 100,
});
for (const t of pending) this.enqueue(t.id);
}
// Раз в минуту обеспечиваем ОБЛОЖКУ у играющих СЕЙЧАС треков — быстрый проход
// ТОЛЬКО через iTunes (без Discogs, который лимитирован 54/мин и тормозил бы
// обложки). Полное обогащение (жанр/стили) идёт фоном через backfill/enqueue.
private nowPlayingRunning = false;
@Cron(CronExpression.EVERY_MINUTE)
async enrichNowPlaying(): Promise<void> {
if (this.nowPlayingRunning) return; // не накладываем проходы
this.nowPlayingRunning = true;
try {
await this.runEnrichNowPlaying();
} finally {
this.nowPlayingRunning = false;
}
}
private async runEnrichNowPlaying(): Promise<void> {
const rows = await this.prisma.nowPlaying.findMany({
select: { artist: true, song: true },
});
const todo: { id: string; artist: string; song: string; normKey: string }[] = [];
for (const r of rows) {
const artist = (r.artist ?? '').trim();
const song = (r.song ?? '').trim();
if (!artist || !song) continue;
const normKey = this.buildNormKey(artist, song);
const track = await this.prisma.track.upsert({
where: { normKey },
create: { normKey, artist, song },
update: {},
select: { id: true, coverUrl: true, enrichStatus: true },
});
if (!track.coverUrl) todo.push({ id: track.id, artist, song, normKey });
// полное обогащение (жанр) — в общую очередь, если ещё не сделано
if (track.enrichStatus !== 'done') this.enqueue(track.id);
}
// Быстрый cover-only проход, по 8 параллельно — чтобы успевать за сменой
// треков по всем сетям (~120/мин)
for (let i = 0; i < todo.length; i += 8) {
await Promise.all(todo.slice(i, i + 8).map((t) => this.coverFast(t)));
}
}
// Только обложка через iTunes (без Discogs) — для быстрого покрытия эфира
private async coverFast(t: {
id: string;
artist: string;
song: string;
normKey: string;
}): Promise<void> {
try {
const itunes = await this.fetchItunes(t.artist, t.song);
const candidate = itunes?.coverUrl;
if (!candidate) return;
const stored = await this.covers.store(candidate, t.normKey);
if (!stored) return;
await this.prisma.track.update({
where: { id: t.id },
data: {
coverUrl: stored,
genre: itunes?.genre ?? undefined,
album: itunes?.album ?? undefined,
},
});
} catch {
// сбой iTunes (429/сеть) — добёрём на следующем тике
}
}
// Нормализованный ключ — как в ChartsService.recordPlay
private buildNormKey(artist: string, song: string): string {
return (
artist.toLowerCase().replace(/\s+/g, ' ') +
'|' +
song.toLowerCase().replace(/\s+/g, ' ')
);
}
private async drain(): Promise<void> {
if (this.running) return;
this.running = true;
try {
while (this.queue.length > 0) {
const batch = this.queue.splice(0, this.concurrency);
await Promise.all(batch.map((id) => this.enrichOne(id)));
await this.sleep(this.throttleMs);
}
} finally {
this.running = false;
}
}
private async enrichOne(trackId: string): Promise<void> {
try {
const track = await this.prisma.track.findUnique({
where: { id: trackId },
});
if (!track || track.enrichStatus === 'done') return;
const data = this.discogs.enabled
? await this.discogs.lookup(track.artist, track.song)
: null;
// iTunes: обложка (покрытие почти как у Record) + альбом/год/жанр как
// фолбэк к Discogs. Гибрид: стили и лейбл — только Discogs.
// Отличаем сбой запроса (ретраить) от чистого «не найдено» (done).
let itunes: Awaited<ReturnType<typeof this.fetchItunes>> = null;
let itunesFailed = false;
try {
itunes = await this.fetchItunes(track.artist, track.song);
} catch {
itunesFailed = true;
}
// Обложка → WebP к себе (если ещё не наша)
let coverUrl = 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 приоритетнее (тонкий), затем 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);
// Помечаем done, если обогатились. НЕ помечаем (оставляем pending для
// ретрая), если: нет токена Discogs, ИЛИ запрос к iTunes упал И обложку
// так и не получили (транзиентный сбой — промах не должен застывать).
const enriched = this.discogs.enabled && !(itunesFailed && !coverUrl);
await this.prisma.track.update({
where: { id: trackId },
data: {
genre,
styles: data?.styles?.length ? data.styles : track.styles,
label: data?.label ?? track.label,
year,
album,
discogsId: data?.discogsId ?? track.discogsId,
coverUrl,
releaseDate,
enrichStatus: enriched ? 'done' : 'pending',
enrichedAt: enriched ? new Date() : track.enrichedAt,
},
});
this.logger.debug(
`Обогащён "${track.artist}${track.song}": genre=${genre ?? '—'}, label=${data?.label ?? '—'}`,
);
} catch (e) {
this.logger.debug(`Обогащение ${trackId} не удалось: ${(e as Error).message}`);
await this.prisma.track
.update({ where: { id: trackId }, data: { enrichStatus: 'failed' } })
.catch(() => undefined);
}
}
// iTunes Search API (без ключа, высокое покрытие): обложка (600×600) +
// альбом/год/жанр/дата релиза.
private async fetchItunes(
artist: string,
song: string,
): Promise<{
coverUrl: string | null;
album: string | null;
year: number | null;
releaseDate: Date | null;
genre: string | null;
} | null> {
// Попытка 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`;
const res = await fetch(url, {
headers: { 'User-Agent': 'radiOLA/1.0 +https://radiola.app' },
});
if (!res.ok) throw new Error(`iTunes ${res.status}`);
const data = (await res.json()) as {
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,
};
}
/** Обложка из 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/');
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}