Files
radiola-backend/src/enrich/cover-storage.service.ts
nk 0efba7c691 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>
2026-06-03 13:28:22 +03:00

73 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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