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:
@@ -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')
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user