main
Some checks failed
Backend Deploy 🚀 / build-and-deploy (push) Has been cancelled

This commit is contained in:
Harun CAN
2026-03-30 00:21:32 +03:00
parent 85c35c73e8
commit acb103657b
29 changed files with 11473 additions and 13081 deletions

View File

@@ -5,14 +5,11 @@ import * as fs from 'fs/promises';
import * as crypto from 'crypto';
/**
* Storage Service — Medya dosyalarının yönetimi
*
* Strateji: file-organizer skill'inden elde edilen bilgilerle tasarlandı
* - Geliştirme ortamı: Lokal dosya sistemi (/data/media/)
* - Üretim ortamı: Cloudflare R2 / AWS S3
*
* Storage Service — Raspberry Pi 5 Üretim Ortamı İçin Optimize Edildi
*
* Tüm medya dosyaları Raspberry Pi'nin lokal diskinde saklanır.
* Dosya yapısı:
* /data/media/
* {basePath}/
* ├── {projectId}/
* │ ├── scenes/
* │ │ ├── scene-001-video.mp4
@@ -21,12 +18,15 @@ import * as crypto from 'crypto';
* │ ├── audio/
* │ │ ├── narration.mp3
* │ │ └── music.mp3
* │ ├── images/
* │ │ ├── scene-001.png
* │ │ └── scene-002.png
* │ ├── output/
* │ │ ├── final-video.mp4
* │ │ ├── final-xxxx.mp4
* │ │ └── thumbnail.jpg
* │ └── subtitles/
* │ └── captions.srt
* └── temp/ (otomatik temizlenir)
* └── temp/ (otomatik temizlenir)
*/
export interface UploadResult {
@@ -38,10 +38,9 @@ export interface UploadResult {
}
export interface StorageConfig {
provider: 'local' | 's3' | 'r2';
provider: 'local';
basePath: string;
bucket: string;
cdnUrl?: string;
publicBaseUrl: string;
}
@Injectable()
@@ -50,101 +49,186 @@ export class StorageService {
private readonly config: StorageConfig;
constructor(private readonly configService: ConfigService) {
const provider = this.configService.get<string>('STORAGE_PROVIDER', 'local');
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: provider as StorageConfig['provider'],
basePath: this.configService.get<string>('STORAGE_LOCAL_PATH', './data/media'),
bucket: this.configService.get<string>('STORAGE_BUCKET', 'contentgen-media'),
cdnUrl: this.configService.get<string>('STORAGE_CDN_URL'),
provider: 'local',
basePath: path.resolve(basePath),
publicBaseUrl: cdnUrl || `http://localhost:${port}/media`,
};
this.logger.log(`📦 Storage provider: ${this.config.provider}`);
this.logger.log(`📦 Storage: lokal depolama — ${this.config.basePath}`);
this.ensureBaseDir();
}
/**
* Sahne videosu için anahtar oluştur
* 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`;
}
/**
* Sahne ses kaydı için anahtar oluştur
*/
getSceneAudioKey(projectId: string, sceneOrder: number): string {
return `${projectId}/audio/scene-${String(sceneOrder).padStart(3, '0')}-narration.mp3`;
}
/**
* Final video için anahtar oluştur
*/
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`;
}
/**
* Thumbnail için anahtar oluştur
*/
getThumbnailKey(projectId: string): string {
return `${projectId}/output/thumbnail.jpg`;
}
/**
* Altyazı dosyası için anahtar oluştur
*/
getSubtitleKey(projectId: string): string {
return `${projectId}/subtitles/captions.srt`;
}
/**
* Müzik dosyası için anahtar oluştur
*/
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
* Dosya yükle (Buffer → disk).
*/
async upload(key: string, data: Buffer, mimeType: string): Promise<UploadResult> {
if (this.config.provider === 'local') {
return this.uploadLocal(key, data, mimeType);
}
const filePath = path.join(this.config.basePath, key);
const dir = path.dirname(filePath);
// S3/R2 desteği sonra eklenecek
return this.uploadLocal(key, data, mimeType);
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,
};
}
/**
* Dosya indir
* 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> {
if (this.config.provider === 'local') {
return this.downloadLocal(key);
}
return this.downloadLocal(key);
const filePath = path.join(this.config.basePath, key);
return fs.readFile(filePath);
}
/**
* Dosya sil
* 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> {
if (this.config.provider === 'local') {
return this.deleteLocal(key);
const filePath = path.join(this.config.basePath, key);
try {
await fs.unlink(filePath);
this.logger.debug(`🗑️ Silindi: ${key}`);
} catch {
// Dosya bulunamadı — sessizce geç
}
return this.deleteLocal(key);
}
/**
* Proje dosyalarını temizle
* 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}`);
@@ -154,51 +238,96 @@ export class StorageService {
}
/**
* Dosyanın public URL'ini oluştur
* 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 {
if (this.config.cdnUrl) {
return `${this.config.cdnUrl}/${key}`;
}
if (this.config.provider === 'local') {
return `/media/${key}`;
}
return `https://${this.config.bucket}.r2.dev/${key}`;
return `${this.config.publicBaseUrl}/${key}`;
}
// ── Private: Lokal dosya sistemi ──────────────────────────────────
// ── Private Helpers ────────────────────────────────────────────────
private async uploadLocal(key: string, data: Buffer, mimeType: string): Promise<UploadResult> {
const filePath = path.join(this.config.basePath, key);
const dir = path.dirname(filePath);
private async listFilesRecursive(dir: string, prefix: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files: string[] = [];
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, data);
this.logger.debug(`📥 Dosya yüklendi: ${key} (${data.length} bytes)`);
return {
key,
url: this.getPublicUrl(key),
bucket: this.config.bucket,
sizeBytes: data.length,
mimeType,
};
}
private async downloadLocal(key: string): Promise<Buffer> {
const filePath = path.join(this.config.basePath, key);
return fs.readFile(filePath);
}
private async deleteLocal(key: string): Promise<void> {
const filePath = path.join(this.config.basePath, key);
try {
await fs.unlink(filePath);
} catch {
// Dosya bulunamadı — sessizce geç
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]}`;
}
}