generated from fahricansecer/boilerplate-be
334 lines
9.5 KiB
TypeScript
334 lines
9.5 KiB
TypeScript
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]}`;
|
||
}
|
||
}
|