Files
ContentGen_BE/src/modules/storage/storage.service.ts
Harun CAN acb103657b
Some checks failed
Backend Deploy 🚀 / build-and-deploy (push) Has been cancelled
main
2026-03-30 00:21:32 +03:00

334 lines
9.5 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 { 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<string>('STORAGE_LOCAL_PATH', './data/media');
const port = this.configService.get<number>('PORT', 3000);
const cdnUrl = this.configService.get<string>('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<UploadResult> {
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<UploadResult> {
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<Buffer> {
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<void> {
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<boolean> {
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<number> {
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<string[]> {
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<void> {
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<number> {
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<string[]> {
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]}`;
}
}