import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as path from 'path'; import * as fs from 'fs/promises'; import * as crypto from 'crypto'; /** * Storage Service — Raspberry Pi 5 Üretim Ortamı İçin Optimize Edildi * * Tüm medya dosyaları Raspberry Pi'nin lokal diskinde saklanır. * Dosya yapısı: * {basePath}/ * ├── {projectId}/ * │ ├── scenes/ * │ │ ├── scene-001-video.mp4 * │ │ ├── scene-001-audio.mp3 * │ │ └── scene-002-video.mp4 * │ ├── audio/ * │ │ ├── narration.mp3 * │ │ └── music.mp3 * │ ├── images/ * │ │ ├── scene-001.png * │ │ └── scene-002.png * │ ├── output/ * │ │ ├── final-xxxx.mp4 * │ │ └── thumbnail.jpg * │ └── subtitles/ * │ └── captions.srt * └── temp/ (otomatik temizlenir) */ export interface UploadResult { key: string; url: string; bucket: string; sizeBytes: number; mimeType: string; } export interface StorageConfig { provider: 'local'; basePath: string; publicBaseUrl: string; } @Injectable() export class StorageService { private readonly logger = new Logger(StorageService.name); private readonly config: StorageConfig; constructor(private readonly configService: ConfigService) { const basePath = this.configService.get('STORAGE_LOCAL_PATH', './data/media'); const port = this.configService.get('PORT', 3000); const cdnUrl = this.configService.get('STORAGE_CDN_URL'); this.config = { provider: 'local', basePath: path.resolve(basePath), publicBaseUrl: cdnUrl || `http://localhost:${port}/media`, }; this.logger.log(`📦 Storage: lokal depolama — ${this.config.basePath}`); this.ensureBaseDir(); } /** * Başlangıçta temel dizini oluştur. */ private async ensureBaseDir() { try { await fs.mkdir(this.config.basePath, { recursive: true }); await fs.mkdir(path.join(this.config.basePath, 'temp'), { recursive: true }); } catch (error) { this.logger.error(`Temel dizin oluşturulamadı: ${error}`); } } // ── Key Generators ───────────────────────────────────────────────── getSceneVideoKey(projectId: string, sceneOrder: number): string { return `${projectId}/scenes/scene-${String(sceneOrder).padStart(3, '0')}-video.mp4`; } getSceneAudioKey(projectId: string, sceneOrder: number): string { return `${projectId}/audio/scene-${String(sceneOrder).padStart(3, '0')}-narration.mp3`; } getSceneImageKey(projectId: string, sceneOrder: number, ext = 'png'): string { return `${projectId}/images/scene-${String(sceneOrder).padStart(3, '0')}.${ext}`; } getFinalVideoKey(projectId: string): string { const hash = crypto.randomBytes(4).toString('hex'); return `${projectId}/output/final-${hash}.mp4`; } getThumbnailKey(projectId: string): string { return `${projectId}/output/thumbnail.jpg`; } getSubtitleKey(projectId: string): string { return `${projectId}/subtitles/captions.srt`; } getMusicKey(projectId: string): string { return `${projectId}/audio/background-music.mp3`; } getAmbientKey(projectId: string, sceneOrder: number): string { return `${projectId}/audio/ambient-${String(sceneOrder).padStart(3, '0')}.mp3`; } getTempKey(projectId: string, filename: string): string { return `temp/${projectId}-${filename}`; } // ── Core Operations ──────────────────────────────────────────────── /** * Dosya yükle (Buffer → disk). */ async upload(key: string, data: Buffer, mimeType: string): Promise { const filePath = path.join(this.config.basePath, key); const dir = path.dirname(filePath); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(filePath, data); const sizeBytes = data.length; this.logger.debug(`📥 Yüklendi: ${key} (${this.formatBytes(sizeBytes)}, ${mimeType})`); return { key, url: this.getPublicUrl(key), bucket: 'local', sizeBytes, mimeType, }; } /** * Stream olarak dosya yükle — büyük dosyalar için (Raspberry Pi bellek koruması). */ async uploadFromPath(key: string, sourcePath: string, mimeType: string): Promise { const destPath = path.join(this.config.basePath, key); const dir = path.dirname(destPath); await fs.mkdir(dir, { recursive: true }); await fs.copyFile(sourcePath, destPath); const stats = await fs.stat(destPath); this.logger.debug(`📥 Dosyadan yüklendi: ${key} (${this.formatBytes(stats.size)})`); return { key, url: this.getPublicUrl(key), bucket: 'local', sizeBytes: Number(stats.size), mimeType, }; } /** * Dosya indir (disk → Buffer). */ async download(key: string): Promise { const filePath = path.join(this.config.basePath, key); return fs.readFile(filePath); } /** * Dosyanın disk yolunu döndür (FFmpeg gibi araçlar için). */ getAbsolutePath(key: string): string { return path.join(this.config.basePath, key); } /** * Dosya sil. */ async delete(key: string): Promise { const filePath = path.join(this.config.basePath, key); try { await fs.unlink(filePath); this.logger.debug(`🗑️ Silindi: ${key}`); } catch { // Dosya bulunamadı — sessizce geç } } /** * Dosyanın mevcut olup olmadığını kontrol et. */ async exists(key: string): Promise { const filePath = path.join(this.config.basePath, key); try { await fs.access(filePath); return true; } catch { return false; } } /** * Dosya boyutunu al. */ async getFileSize(key: string): Promise { const filePath = path.join(this.config.basePath, key); const stats = await fs.stat(filePath); return Number(stats.size); } /** * Proje dosyalarını listele. */ async listProjectFiles(projectId: string): Promise { const projectDir = path.join(this.config.basePath, projectId); try { return await this.listFilesRecursive(projectDir, projectId); } catch { return []; } } /** * Proje dosyalarını tamamen temizle. */ async cleanupProject(projectId: string): Promise { const projectDir = path.join(this.config.basePath, projectId); try { await fs.rm(projectDir, { recursive: true, force: true }); this.logger.log(`🗑️ Proje dosyaları silindi: ${projectId}`); } catch (error) { this.logger.warn(`Proje temizleme hatası: ${projectId} — ${error}`); } } /** * Temp dosyalarını temizle (24 saatten eski). */ async cleanupTemp(): Promise { const tempDir = path.join(this.config.basePath, 'temp'); let cleaned = 0; try { const files = await fs.readdir(tempDir); const cutoff = Date.now() - 24 * 60 * 60 * 1000; for (const file of files) { const filePath = path.join(tempDir, file); const stats = await fs.stat(filePath); if (stats.mtimeMs < cutoff) { await fs.unlink(filePath); cleaned++; } } if (cleaned > 0) { this.logger.log(`🧹 ${cleaned} temp dosyası temizlendi`); } } catch { // temp dizini yoksa sorun değil } return cleaned; } /** * Disk kullanım istatistikleri. */ async getStorageStats(): Promise<{ totalFiles: number; totalSizeBytes: number; totalSizeHuman: string; }> { try { const files = await this.listFilesRecursive(this.config.basePath, ''); let totalSize = 0; for (const file of files) { try { const stats = await fs.stat(path.join(this.config.basePath, file)); totalSize += Number(stats.size); } catch { // skip } } return { totalFiles: files.length, totalSizeBytes: totalSize, totalSizeHuman: this.formatBytes(totalSize), }; } catch { return { totalFiles: 0, totalSizeBytes: 0, totalSizeHuman: '0 B' }; } } /** * Dosyanın public URL'ini oluştur. */ getPublicUrl(key: string): string { return `${this.config.publicBaseUrl}/${key}`; } // ── Private Helpers ──────────────────────────────────────────────── private async listFilesRecursive(dir: string, prefix: string): Promise { const entries = await fs.readdir(dir, { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { const relative = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isDirectory()) { files.push(...(await this.listFilesRecursive(path.join(dir, entry.name), relative))); } else { files.push(relative); } } return files; } private formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; } }