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:
72
src/enrich/cover-storage.service.ts
Normal file
72
src/enrich/cover-storage.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
84
src/enrich/discogs.service.ts
Normal file
84
src/enrich/discogs.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
10
src/enrich/enrich.module.ts
Normal file
10
src/enrich/enrich.module.ts
Normal 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 {}
|
||||
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