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) };
}
}

View File

@@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import { promises as fs } from 'fs';
import { join } from 'path';
import sharp from 'sharp';
/**
* Хранилище обложек треков.
* Скачивает картинку из любого источника, приводит к единому формату —
* WebP фиксированного размера (качество без видимых потерь, малый вес) —
* и сохраняет локально. В рантайме отдаём со своего домена, не зависим от чужих CDN.
*/
@Injectable()
export class CoverStorageService {
private readonly logger = new Logger(CoverStorageService.name);
private readonly dir =
process.env.COVERS_DIR || join(process.cwd(), 'data', 'covers');
private readonly publicBase = (process.env.PUBLIC_BASE_URL || '').replace(
/\/$/,
'',
);
private readonly size = 500; // квадрат 500×500 — хватает и карточке, и детальной
/**
* Скачивает и сохраняет обложку как WebP.
* key — стабильный ключ (normKey трека), чтобы имя файла было детерминированным.
* Возвращает публичный URL обложки или null.
*/
async store(sourceUrl: string, key: string): Promise<string | null> {
try {
const hash = createHash('sha1').update(key).digest('hex').slice(0, 16);
const fileName = `${hash}.webp`;
const filePath = join(this.dir, fileName);
const publicPath = `/covers/${fileName}`;
// Уже сохранена — повторно не качаем
try {
await fs.access(filePath);
return this.toPublicUrl(publicPath);
} catch {
// файла нет — продолжаем
}
const res = await fetch(sourceUrl, {
headers: { 'User-Agent': 'radiOLA/1.0 +https://radiola.app' },
});
if (!res.ok) return null;
const ctype = res.headers.get('content-type') ?? '';
if (!ctype.startsWith('image/')) return null;
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length === 0 || buf.length > 8 * 1024 * 1024) return null;
await fs.mkdir(this.dir, { recursive: true });
await sharp(buf)
.resize(this.size, this.size, { fit: 'cover', position: 'centre' })
.webp({ quality: 80 })
.toFile(filePath);
return this.toPublicUrl(publicPath);
} catch (e) {
this.logger.debug(`Не удалось сохранить обложку: ${(e as Error).message}`);
return null;
}
}
// Если задан PUBLIC_BASE_URL — отдаём абсолютный URL, иначе относительный путь
private toPublicUrl(path: string): string {
return this.publicBase ? `${this.publicBase}${path}` : path;
}
}

View File

@@ -0,0 +1,84 @@
import { Injectable, Logger } from '@nestjs/common';
// Результат обогащения из Discogs
export interface DiscogsResult {
discogsId: number | null;
genre: string | null;
styles: string[];
label: string | null;
year: number | null;
coverImageUrl: string | null;
}
// Сырой результат поиска Discogs (нужные поля)
interface DiscogsSearchItem {
id?: number;
genre?: string[];
style?: string[];
label?: string[];
year?: string | number;
cover_image?: string;
thumb?: string;
}
/**
* Клиент Discogs Database Search.
* Один запрос поиска уже отдаёт genre/style/label/year/cover_image —
* детальный запрос релиза не нужен.
* Токен берётся из env DISCOGS_TOKEN (личный токен из Settings → Developers).
*/
@Injectable()
export class DiscogsService {
private readonly logger = new Logger(DiscogsService.name);
private readonly token = process.env.DISCOGS_TOKEN ?? '';
private readonly userAgent = 'radiOLA/1.0 +https://radiola.app';
// Без токена обогащение жанрами не работает (поиск требует авторизации)
get enabled(): boolean {
return this.token.length > 0;
}
async lookup(artist: string, song: string): Promise<DiscogsResult | null> {
if (!this.enabled) return null;
const params = new URLSearchParams({
artist,
track: song,
type: 'release',
per_page: '5',
token: this.token,
});
const url = `https://api.discogs.com/database/search?${params.toString()}`;
const res = await fetch(url, {
headers: { 'User-Agent': this.userAgent, Accept: 'application/json' },
});
if (!res.ok) {
this.logger.debug(`Discogs ${res.status} для "${artist}${song}"`);
return null;
}
const data = (await res.json()) as { results?: DiscogsSearchItem[] };
const item = data.results?.[0];
if (!item) return null;
const cover =
item.cover_image && !item.cover_image.includes('spacer.gif')
? item.cover_image
: item.thumb && !item.thumb.includes('spacer.gif')
? item.thumb
: null;
const yearNum =
item.year != null ? parseInt(String(item.year), 10) || null : null;
return {
discogsId: typeof item.id === 'number' ? item.id : null,
genre: item.genre?.[0] ?? null,
styles: Array.isArray(item.style) ? item.style.slice(0, 6) : [],
label: item.label?.[0] ?? null,
year: yearNum,
coverImageUrl: cover,
};
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { DiscogsService } from './discogs.service';
import { CoverStorageService } from './cover-storage.service';
import { EnrichmentService } from './enrichment.service';
@Module({
providers: [DiscogsService, CoverStorageService, EnrichmentService],
exports: [EnrichmentService],
})
export class EnrichModule {}

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));
}
}

View File

@@ -1,10 +1,20 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { join } from 'path';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Раздача сохранённых обложек треков (/covers/*.webp) — свой CDN
const coversDir = process.env.COVERS_DIR || join(process.cwd(), 'data', 'covers');
app.useStaticAssets(coversDir, {
prefix: '/covers/',
maxAge: '30d',
immutable: true,
});
app.useGlobalPipes(
new ValidationPipe({