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

@@ -26,13 +26,21 @@ export class ChartsController {
async getTopTracks(
@Query('period') period: string = 'week',
@Query('limit') limit: string = '100',
@Query('genre') genre?: string,
) {
const validPeriod: ChartPeriod =
period === 'day' || period === 'week' || period === 'month' || period === 'all'
? (period as ChartPeriod)
: 'week';
const parsedLimit = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 200);
return this.chartsService.getTopTracks(validPeriod, parsedLimit);
const genreFilter = genre?.trim() ? genre.trim() : undefined;
return this.chartsService.getTopTracks(validPeriod, parsedLimit, genreFilter);
}
@Get('genres')
@ApiOperation({ summary: 'Список доступных жанров для фильтра' })
async getGenres() {
return this.chartsService.getGenres();
}
@Get('tracks/:trackId')

View File

@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { ChartsController } from './charts.controller';
import { ChartsService } from './charts.service';
import { AuthModule } from '../auth/auth.module';
import { EnrichModule } from '../enrich/enrich.module';
@Module({
imports: [AuthModule],
imports: [AuthModule, EnrichModule],
controllers: [ChartsController],
providers: [ChartsService],
exports: [ChartsService],

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EnrichmentService } from '../enrich/enrichment.service';
// Период чарта
export type ChartPeriod = 'day' | 'week' | 'month' | 'all';
@@ -13,6 +14,10 @@ export interface ChartEntry {
artist: string;
song: string;
coverUrl: string | null;
genre: string | null;
styles: string[];
label: string | null;
year: number | null;
plays: number;
stationsCount: number;
likes: number;
@@ -53,7 +58,10 @@ interface RawStationRow {
export class ChartsService {
private readonly logger = new Logger(ChartsService.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly enrichment: EnrichmentService,
) {}
// Возвращает метку начала периода
private periodStart(period: ChartPeriod): Date {
@@ -125,21 +133,30 @@ export class ChartsService {
'|' +
song.toLowerCase().replace(/\s+/g, ' ');
// Не перетираем уже сохранённую (self-hosted) обложку сырым Record-URL
const track = await this.prisma.track.upsert({
where: { normKey },
create: { normKey, artist, song, coverUrl: coverUrl ?? null },
update: { coverUrl: coverUrl ?? null },
update: {},
});
// Если у трека ещё нет обложки, а Record прислал — подставим как стартовую
if (!track.coverUrl && coverUrl) {
await this.prisma.track.update({
where: { id: track.id },
data: { coverUrl },
});
}
await this.prisma.trackPlay.create({
data: { trackId: track.id, stationId: stationDbId },
});
this.logger.debug(`Записан трек: "${artist}${song}"`);
// Асинхронное обогащение нового трека (fire-and-forget)
if (!track.enrichedAt) {
void this.enrichTrack(track.id, artist, song);
// Асинхронное обогащение нового трека (Discogs + WebP-обложка, fire-and-forget)
if (track.enrichStatus !== 'done') {
this.enrichment.enqueue(track.id);
}
} catch (error) {
// Ошибка сбора не должна ронять поллер
@@ -147,65 +164,34 @@ export class ChartsService {
}
}
// Обогащение трека через MusicBrainz (fire-and-forget, best-effort)
private async enrichTrack(
trackId: string,
artist: string,
song: string,
): Promise<void> {
try {
const query = encodeURIComponent(`recording:"${song}" AND artist:"${artist}"`);
const url = `https://musicbrainz.org/ws/2/recording/?query=${query}&fmt=json&limit=1`;
const res = await fetch(url, {
headers: {
'User-Agent': 'radiOLA/1.0 ( blinnafeg@gmail.com )',
Accept: 'application/json',
},
});
if (!res.ok) return;
const data = (await res.json()) as {
recordings?: Array<{
releases?: Array<{
title: string;
date?: string;
}>;
}>;
};
const recording = data.recordings?.[0];
if (!recording) return;
const release = recording.releases?.[0];
const album = release?.title ?? null;
const releaseDate = release?.date ? new Date(release.date) : null;
// Проверяем, что дата валидна
const validDate =
releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate : null;
await this.prisma.track.update({
where: { id: trackId },
data: { album, releaseDate: validDate, enrichedAt: new Date() },
});
this.logger.debug(`Трек ${trackId} обогащён: альбом="${album}"`);
} catch (error) {
// Игнорируем ошибки обогащения — не критично
this.logger.debug(`Обогащение трека ${trackId} не удалось: ${error.message}`);
}
}
// Чарт треков за период
async getTopTracks(period: ChartPeriod, limit: number): Promise<{ items: ChartEntry[] }> {
// Чарт треков за период (с опциональным фильтром по жанру)
async getTopTracks(
period: ChartPeriod,
limit: number,
genre?: string,
): Promise<{ items: ChartEntry[] }> {
const since = this.periodStart(period);
const duration = this.periodDuration(period);
const prevSince = new Date(since.getTime() - duration);
// Фильтр по жанру: ограничиваем набор треков
let genreTrackIds: string[] | undefined;
if (genre) {
const matched = await this.prisma.track.findMany({
where: { genre: { equals: genre, mode: 'insensitive' } },
select: { id: true },
});
genreTrackIds = matched.map((t) => t.id);
if (genreTrackIds.length === 0) return { items: [] };
}
// Топ текущего периода: группировка по trackId
const currentGroups = await this.prisma.trackPlay.groupBy({
by: ['trackId'],
where: { playedAt: { gte: since } },
where: {
playedAt: { gte: since },
...(genreTrackIds ? { trackId: { in: genreTrackIds } } : {}),
},
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
take: limit,
@@ -265,7 +251,16 @@ export class ChartsService {
// Получаем данные треков
const tracks = await this.prisma.track.findMany({
where: { id: { in: trackIds } },
select: { id: true, artist: true, song: true, coverUrl: true },
select: {
id: true,
artist: true,
song: true,
coverUrl: true,
genre: true,
styles: true,
label: true,
year: true,
},
});
const tracksMap = new Map(tracks.map((t) => [t.id, t]));
@@ -290,6 +285,10 @@ export class ChartsService {
artist: track?.artist ?? '',
song: track?.song ?? '',
coverUrl: track?.coverUrl ?? null,
genre: track?.genre ?? null,
styles: track?.styles ?? [],
label: track?.label ?? null,
year: track?.year ?? null,
plays: g._count.id,
stationsCount: stationsMap.get(g.trackId) ?? 0,
likes: likesMap.get(g.trackId) ?? 0,
@@ -311,6 +310,10 @@ export class ChartsService {
song: string;
album: string | null;
coverUrl: string | null;
genre: string | null;
styles: string[];
label: string | null;
year: number | null;
releaseDate: string | null;
firstSeen: string | null;
totalPlays: number;
@@ -403,6 +406,10 @@ export class ChartsService {
song: track.song,
album: track.album ?? null,
coverUrl: track.coverUrl ?? null,
genre: track.genre ?? null,
styles: track.styles ?? [],
label: track.label ?? null,
year: track.year ?? null,
releaseDate: track.releaseDate ? track.releaseDate.toISOString() : null,
firstSeen: track.firstSeenAt ? track.firstSeenAt.toISOString() : null,
totalPlays: totalPlaysResult,
@@ -438,4 +445,15 @@ export class ChartsService {
});
return {};
}
// Список доступных жанров (для фильтра в чартах)
async getGenres(): Promise<{ genres: string[] }> {
const rows = await this.prisma.track.findMany({
where: { genre: { not: null } },
select: { genre: true },
distinct: ['genre'],
orderBy: { genre: 'asc' },
});
return { genres: rows.map((r) => r.genre).filter((g): g is string => !!g) };
}
}