feat(enrich): обогащение треков через Discogs + самохостинг обложек (WebP)

При первом появлении трека подтягиваем жанр/стиль/лейбл/год из Discogs
и сохраняем обложку в едином формате WebP 500x500 у себя (/covers). Дальше
пользователю отдаём только из своей БД — внешние сервисы в рантайме не дёргаем.

- Track: +genre/styles/label/year/discogsId/enrichStatus (миграция)
- EnrichModule: DiscogsService (поиск), CoverStorageService (sharp->webp),
  EnrichmentService (очередь с троттлингом + бэкафилл-крон каждые 10 мин)
- charts: фильтр чартов по жанру (?genre=), GET /charts/genres,
  жанр/стиль/лейбл/год в выдаче чарта и детальной странице
- main: раздача /covers статикой; docker: volume covers_data + env
  DISCOGS_TOKEN/PUBLIC_BASE_URL/COVERS_DIR
- убран MusicBrainz-фолбэк (заменён Discogs)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-03 13:28:08 +03:00
parent 24ed44e8ab
commit 0efba7c691
14 changed files with 863 additions and 67 deletions

View File

@@ -0,0 +1,122 @@
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 ~60 запросов/мин с токеном)
private readonly queue: string[] = [];
private running = false;
private readonly throttleMs = 1200;
constructor(
private readonly prisma: PrismaService,
private readonly discogs: DiscogsService,
private readonly covers: CoverStorageService,
) {}
// Поставить трек в очередь (fire-and-forget из recordPlay)
enqueue(trackId: string): void {
if (this.queue.includes(trackId)) return;
this.queue.push(trackId);
void this.drain();
}
// Периодически добираем не обогащённые треки (в т.ч. накопленные ранее)
@Cron(CronExpression.EVERY_10_MINUTES)
async backfill(): Promise<void> {
if (!this.discogs.enabled) return; // без токена смысла нет — не крутим вхолостую
const pending = await this.prisma.track.findMany({
where: { enrichStatus: 'pending' },
select: { id: true },
orderBy: { firstSeenAt: 'desc' },
take: 30,
});
for (const t of pending) this.enqueue(t.id);
}
private async drain(): Promise<void> {
if (this.running) return;
this.running = true;
try {
while (this.queue.length > 0) {
const id = this.queue.shift();
if (!id) continue;
await 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;
// Обложку приводим к WebP и кладём к себе (если ещё не наша)
let coverUrl = track.coverUrl;
const candidate = data?.coverImageUrl ?? track.coverUrl;
if (candidate && !this.isSelfHosted(candidate)) {
const stored = await this.covers.store(candidate, track.normKey);
if (stored) coverUrl = stored;
}
// Без токена Discogs жанры не получим — оставляем статус pending,
// чтобы добрать позже (когда токен появится), но обложку уже сохранили.
const enriched = this.discogs.enabled;
await this.prisma.track.update({
where: { id: trackId },
data: {
genre: data?.genre ?? track.genre,
styles: data?.styles?.length ? data.styles : track.styles,
label: data?.label ?? track.label,
year: data?.year ?? track.year,
discogsId: data?.discogsId ?? track.discogsId,
coverUrl,
releaseDate:
!track.releaseDate && data?.year
? new Date(Date.UTC(data.year, 0, 1))
: track.releaseDate,
enrichStatus: enriched ? 'done' : 'pending',
enrichedAt: enriched ? new Date() : track.enrichedAt,
},
});
this.logger.debug(
`Обогащён "${track.artist}${track.song}": genre=${data?.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);
}
}
private isSelfHosted(url: string): boolean {
return url.includes('/covers/');
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}