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:
122
src/enrich/enrichment.service.ts
Normal file
122
src/enrich/enrichment.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user