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