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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user