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

@@ -35,6 +35,7 @@ import {
RoleResponseDto,
UserRoleResponseDto,
} from './dto/admin.dto';
import { AdminService } from './admin.service';
@ApiTags('Admin')
@ApiBearerAuth()
@@ -43,9 +44,61 @@ import {
export class AdminController {
constructor(
private readonly prisma: PrismaService,
private readonly adminService: AdminService,
@Inject(CACHE_MANAGER) private cacheManager: cacheManager.Cache,
) {}
// ================== System Stats ==================
@Get('stats')
@ApiOperation({ summary: 'Sistem istatistiklerini getir' })
async getSystemStats(): Promise<ApiResponse<any>> {
const stats = await this.adminService.getSystemStats();
return createSuccessResponse(stats);
}
// ================== Plan Management ==================
@Get('plans')
@ApiOperation({ summary: 'Tüm planları getir (admin detay)' })
async getAllPlans(): Promise<ApiResponse<any>> {
const plans = await this.adminService.getAllPlans();
return createSuccessResponse(plans);
}
@Put('plans/:id')
@ApiOperation({ summary: 'Plan güncelle' })
async updatePlan(
@Param('id') id: string,
@Body() data: any,
): Promise<ApiResponse<any>> {
const plan = await this.adminService.updatePlan(id, data);
return createSuccessResponse(plan, 'Plan güncellendi');
}
// ================== Credit Management ==================
@Post('users/:userId/credits')
@ApiOperation({ summary: 'Kullanıcıya kredi ver' })
async grantCredits(
@Param('userId') userId: string,
@Body() data: { amount: number; description: string },
): Promise<ApiResponse<any>> {
const tx = await this.adminService.grantCredits(userId, data.amount, data.description);
return createSuccessResponse(tx, 'Kredi yüklendi');
}
// ================== User Detail ==================
@Get('users/:id/detail')
@ApiOperation({ summary: 'Kullanıcı detay — abonelik, projeler, krediler' })
async getUserDetail(
@Param('id') id: string,
): Promise<ApiResponse<any>> {
const user = await this.adminService.getUserDetail(id);
return createSuccessResponse(user);
}
// ================== Users Management ==================
@Get('users')
@@ -268,3 +321,4 @@ export class AdminController {
return createSuccessResponse(null, 'Permission removed from role');
}
}

View File

@@ -1,7 +1,13 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { StorageModule } from '../storage/storage.module';
@Module({
imports: [StorageModule],
controllers: [AdminController],
providers: [AdminService],
exports: [AdminService],
})
export class AdminModule {}

View File

@@ -0,0 +1,166 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { StorageService } from '../storage/storage.service';
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(
private readonly prisma: PrismaService,
private readonly storageService: StorageService,
) {}
// ── Sistem İstatistikleri ──────────────────────────────────────────
async getSystemStats() {
const [
totalUsers,
activeUsers,
totalProjects,
totalPlans,
storageStats,
recentUsers,
projectsByStatus,
] = await Promise.all([
this.prisma.user.count(),
this.prisma.user.count({ where: { isActive: true } }),
this.prisma.project.count(),
this.prisma.plan.count(),
this.storageService.getStorageStats().catch(() => null),
this.prisma.user.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
select: { id: true, email: true, firstName: true, lastName: true, createdAt: true },
}),
this.prisma.project.groupBy({
by: ['status'],
_count: { id: true },
}),
]);
// Kredi istatistikleri
const creditStats = await this.prisma.creditTransaction.aggregate({
_sum: { amount: true },
where: { type: 'grant' },
});
const creditUsed = await this.prisma.creditTransaction.aggregate({
_sum: { amount: true },
where: { type: 'usage' },
});
return {
users: {
total: totalUsers,
active: activeUsers,
inactive: totalUsers - activeUsers,
},
projects: {
total: totalProjects,
byStatus: projectsByStatus.reduce((acc, item) => {
acc[item.status] = item._count.id;
return acc;
}, {} as Record<string, number>),
},
credits: {
totalGranted: creditStats._sum.amount || 0,
totalUsed: Math.abs(creditUsed._sum.amount || 0),
},
plans: {
total: totalPlans,
},
storage: storageStats,
recentUsers,
};
}
// ── Plan Yönetimi ─────────────────────────────────────────────────
async getAllPlans() {
return this.prisma.plan.findMany({
orderBy: { sortOrder: 'asc' },
include: {
_count: {
select: { subscriptions: true },
},
},
});
}
async updatePlan(planId: string, data: {
displayName?: string;
description?: string;
monthlyPrice?: number;
yearlyPrice?: number;
monthlyCredits?: number;
maxDuration?: number;
maxResolution?: string;
maxProjects?: number;
isActive?: boolean;
features?: any;
}) {
return this.prisma.plan.update({
where: { id: planId },
data,
});
}
// ── Kullanıcı Kredi Yönetimi ──────────────────────────────────────
async grantCredits(userId: string, amount: number, description: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
creditTransactions: {
orderBy: { createdAt: 'desc' },
take: 1,
},
},
});
if (!user) {
throw new Error('Kullanıcı bulunamadı');
}
const currentBalance = user.creditTransactions[0]?.balanceAfter || 0;
const newBalance = currentBalance + amount;
return this.prisma.creditTransaction.create({
data: {
userId,
amount,
type: 'grant',
description,
balanceAfter: newBalance,
},
});
}
// ── Kullanıcı Detay ───────────────────────────────────────────────
async getUserDetail(userId: string) {
return this.prisma.user.findUnique({
where: { id: userId },
include: {
roles: {
include: { role: true },
},
projects: {
take: 10,
orderBy: { createdAt: 'desc' },
},
subscriptions: {
include: { plan: true },
take: 1,
orderBy: { createdAt: 'desc' },
},
creditTransactions: {
take: 10,
orderBy: { createdAt: 'desc' },
},
preferences: true,
},
});
}
}

View File

@@ -38,6 +38,14 @@ export class BillingController {
}
}
@Get('plans')
@Public()
@ApiOperation({ summary: 'Mevcut planları listele (public)' })
async getPlans() {
const plans = await this.billingService.getPlans();
return plans;
}
@Post('checkout')
@ApiBearerAuth()
@ApiOperation({ summary: 'Stripe Checkout session oluştur' })
@@ -62,8 +70,15 @@ export class BillingController {
@ApiOperation({ summary: 'Kredi işlem geçmişi' })
async getCreditHistory(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
// Default pagination
return this.billingService.getCreditBalance(userId);
return this.billingService.getCreditHistory(userId);
}
@Get('subscription')
@ApiBearerAuth()
@ApiOperation({ summary: 'Aktif abonelik bilgisi' })
async getSubscription(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
return this.billingService.getActiveSubscription(userId);
}
@Post('webhook')

View File

@@ -123,6 +123,32 @@ export class BillingService {
}
}
/**
* Aktif planları listele — pricing sayfası için
*/
async getPlans() {
const plans = await this.db.plan.findMany({
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
select: {
id: true,
name: true,
displayName: true,
description: true,
monthlyPrice: true,
yearlyPrice: true,
currency: true,
monthlyCredits: true,
maxDuration: true,
maxResolution: true,
maxProjects: true,
features: true,
},
});
return plans;
}
/**
* Kullanıcı kredi bakiyesi
*/
@@ -290,4 +316,53 @@ export class BillingService {
this.logger.log(`❌ Abonelik iptal edildi: ${stripeSubscription.id}`);
}
/**
* Kredi işlem geçmişi (son 50)
*/
async getCreditHistory(userId: string) {
const transactions = await this.db.creditTransaction.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 50,
select: {
id: true,
amount: true,
type: true,
description: true,
balanceAfter: true,
createdAt: true,
},
});
return { transactions };
}
/**
* Aktif abonelik bilgisi
*/
async getActiveSubscription(userId: string) {
const subscription = await this.db.subscription.findFirst({
where: { userId, status: 'active' },
include: { plan: true },
});
if (!subscription) {
return {
plan: 'Free',
status: 'free',
monthlyCredits: 3,
currentPeriodEnd: null,
};
}
return {
plan: subscription.plan.displayName,
status: subscription.status,
monthlyCredits: subscription.plan.monthlyCredits,
currentPeriodEnd: subscription.currentPeriodEnd,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
stripeSubscriptionId: subscription.stripeSubscriptionId,
};
}
}

View File

@@ -0,0 +1,50 @@
import {
Controller,
Get,
Logger,
Req,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { DashboardService } from './dashboard.service';
@ApiTags('dashboard')
@ApiBearerAuth()
@Controller('dashboard')
export class DashboardController {
private readonly logger = new Logger(DashboardController.name);
constructor(private readonly dashboardService: DashboardService) {}
/**
* Dashboard ana istatistikleri — kullanıcı bazlı.
*/
@Get('stats')
@ApiOperation({ summary: 'Dashboard istatistiklerini getir' })
@ApiResponse({ status: 200, description: 'Dashboard istatistikleri' })
async getStats(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
this.logger.debug(`Dashboard stats istendi: ${userId}`);
return this.dashboardService.getStats(userId);
}
/**
* Video üretim kuyruğu durumu.
*/
@Get('queue-status')
@ApiOperation({ summary: 'Kuyruk durum bilgisi' })
@ApiResponse({ status: 200, description: 'Kuyruk ve WebSocket durumu' })
async getQueueStatus() {
return this.dashboardService.getQueueStatus();
}
/**
* Bu ayki video üretim chart verisi (gün bazlı).
*/
@Get('chart')
@ApiOperation({ summary: 'Aylık video üretim chart verisi' })
@ApiResponse({ status: 200, description: 'Gün bazlı üretim verileri' })
async getMonthlyChart(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
return this.dashboardService.getMonthlyChart(userId);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
import { VideoQueueModule } from '../video-queue/video-queue.module';
import { EventsModule } from '../events/events.module';
@Module({
imports: [VideoQueueModule, EventsModule],
controllers: [DashboardController],
providers: [DashboardService],
exports: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,190 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { VideoGenerationProducer } from '../video-queue/video-generation.producer';
import { EventsGateway } from '../events/events.gateway';
@Injectable()
export class DashboardService {
private readonly logger = new Logger(DashboardService.name);
constructor(
private readonly db: PrismaService,
private readonly videoGenerationProducer: VideoGenerationProducer,
private readonly eventsGateway: EventsGateway,
) {}
/**
* Dashboard istatistiklerini hesaplar.
* UserID bazlı filtreleme — her kullanıcı kendi verilerini görür.
* Gerçek plan bilgisini Subscription → Plan tablosundan çeker.
*/
async getStats(userId: string) {
const [
totalProjects,
completedVideos,
activeRenderJobs,
failedProjects,
draftProjects,
totalCreditsUsed,
recentProjects,
activeSubscription,
creditBalance,
] = await Promise.all([
// Toplam proje sayısı
this.db.project.count({
where: { userId, deletedAt: null },
}),
// Tamamlanan video sayısı
this.db.project.count({
where: { userId, status: 'COMPLETED', deletedAt: null },
}),
// Aktif render job sayısı
this.db.project.count({
where: {
userId,
deletedAt: null,
status: { in: ['PENDING', 'GENERATING_MEDIA', 'RENDERING', 'GENERATING_SCRIPT'] },
},
}),
// Başarısız projeler
this.db.project.count({
where: { userId, status: 'FAILED', deletedAt: null },
}),
// Draft projeler
this.db.project.count({
where: { userId, status: 'DRAFT', deletedAt: null },
}),
// Toplam harcanan kredi
this.db.project.aggregate({
where: { userId, deletedAt: null },
_sum: { creditsUsed: true },
}),
// Son 5 proje
this.db.project.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: 'desc' },
take: 5,
select: {
id: true,
title: true,
status: true,
progress: true,
thumbnailUrl: true,
finalVideoUrl: true,
videoStyle: true,
aspectRatio: true,
language: true,
sourceType: true,
createdAt: true,
updatedAt: true,
completedAt: true,
},
}),
// Kullanıcının aktif aboneliği (plan dahil)
this.db.subscription.findFirst({
where: {
userId,
status: { in: ['active', 'trialing'] },
},
include: { plan: true },
orderBy: { createdAt: 'desc' },
}),
// Bu ayki net kredi bakiyesi (CreditTransaction tablosundan)
this.db.creditTransaction.aggregate({
where: {
userId,
createdAt: {
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
},
},
_sum: { amount: true },
}),
]);
// Plan bilgisini aktif abonelikten çek, yoksa free plan varsayılanları
const plan = activeSubscription?.plan;
const currentPlan = plan?.name || 'free';
const monthlyLimit = plan?.monthlyCredits ?? 3;
const maxDuration = plan?.maxDuration ?? 30;
const maxResolution = plan?.maxResolution ?? '720p';
// Kredi hesaplama: CreditTransaction tablosundan kalan bakiye
const creditsUsed = totalCreditsUsed._sum.creditsUsed || 0;
const netCreditBalance = creditBalance._sum.amount || 0;
const creditsRemaining = Math.max(0, netCreditBalance);
return {
totalProjects,
completedVideos,
activeRenderJobs,
failedProjects,
draftProjects,
totalCreditsUsed: creditsUsed,
creditsRemaining,
monthlyLimit,
currentPlan,
maxDuration,
maxResolution,
recentProjects,
};
}
/**
* Kuyruk durumunu BullMQ ve Worker kuyruğundan alır.
*/
async getQueueStatus() {
const queueStats = await this.videoGenerationProducer.getQueueStats();
const wsClients = this.eventsGateway.getConnectedClientsCount();
return {
queue: queueStats,
websocket: {
connectedClients: wsClients,
},
};
}
/**
* Bu ay üretilen videoların gün bazlı dağılımı (chart verisi).
*/
async getMonthlyChart(userId: string) {
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const projects = await this.db.project.findMany({
where: {
userId,
deletedAt: null,
createdAt: { gte: startOfMonth },
},
select: {
createdAt: true,
status: true,
},
orderBy: { createdAt: 'asc' },
});
// Gün bazlı gruplama
const dailyMap: Record<string, { created: number; completed: number }> = {};
projects.forEach((p) => {
const day = p.createdAt.toISOString().split('T')[0];
if (!dailyMap[day]) dailyMap[day] = { created: 0, completed: 0 };
dailyMap[day].created++;
if (p.status === 'COMPLETED') dailyMap[day].completed++;
});
return Object.entries(dailyMap).map(([date, counts]) => ({
date,
...counts,
}));
}
}

View File

@@ -0,0 +1,205 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
/**
* ContentGen AI — Gerçek Zamanlı WebSocket Gateway
*
* Frontend'e render ilerleme bildirimlerini iletir.
* C# Worker → RenderCallbackController → EventsGateway → Frontend
*
* Room yapısı: "project:{projectId}" — her proje kendi room'una abone olur
*/
@WebSocketGateway({
cors: {
origin: ['http://localhost:3001', 'http://localhost:3000', process.env.FRONTEND_URL || '*'],
credentials: true,
},
namespace: '/ws',
transports: ['websocket', 'polling'],
})
export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private readonly logger = new Logger(EventsGateway.name);
private connectedClients = 0;
handleConnection(client: Socket) {
this.connectedClients++;
this.logger.log(`Client bağlandı: ${client.id} (toplam: ${this.connectedClients})`);
}
handleDisconnect(client: Socket) {
this.connectedClients--;
this.logger.log(`Client ayrıldı: ${client.id} (toplam: ${this.connectedClients})`);
}
/**
* Frontend, proje detay sayfasına girdiğinde bu event'i gönderir.
* Client ilgili proje room'una katılır.
*/
@SubscribeMessage('join:project')
handleJoinProject(
@ConnectedSocket() client: Socket,
@MessageBody() data: { projectId: string },
) {
const room = `project:${data.projectId}`;
client.join(room);
this.logger.debug(`Client ${client.id} → room: ${room}`);
return { event: 'joined', data: { room, projectId: data.projectId } };
}
/**
* Frontend, proje sayfasından ayrıldığında bu event'i gönderir.
*/
@SubscribeMessage('leave:project')
handleLeaveProject(
@ConnectedSocket() client: Socket,
@MessageBody() data: { projectId: string },
) {
const room = `project:${data.projectId}`;
client.leave(room);
this.logger.debug(`Client ${client.id} ← room: ${room}`);
return { event: 'left', data: { room } };
}
/**
* Kullanıcı kendi bildirim room'una katılır.
* Frontend login sonrası bu event'i göndererek anlık bildirimleri alır.
*/
@SubscribeMessage('join:user')
handleJoinUser(
@ConnectedSocket() client: Socket,
@MessageBody() data: { userId: string },
) {
const room = `user:${data.userId}`;
client.join(room);
this.logger.debug(`Client ${client.id} → user room: ${room}`);
return { event: 'joined', data: { room, userId: data.userId } };
}
/**
* Kullanıcı bildirim room'undan ayrılır.
*/
@SubscribeMessage('leave:user')
handleLeaveUser(
@ConnectedSocket() client: Socket,
@MessageBody() data: { userId: string },
) {
const room = `user:${data.userId}`;
client.leave(room);
this.logger.debug(`Client ${client.id} ← user room: ${room}`);
return { event: 'left', data: { room } };
}
// ═══════════════════════════════════════════════════════
// Server → Client Events (RenderCallbackController tarafından tetiklenir)
// ═══════════════════════════════════════════════════════
/**
* Render ilerleme bildirimi gönder.
* Stage: 'tts' | 'image_generation' | 'music_generation' | 'compositing' | 'encoding'
*/
emitRenderProgress(
projectId: string,
payload: {
progress: number;
stage: string;
stageLabel: string;
currentScene?: number;
totalScenes?: number;
eta?: number; // Tahmini kalan saniye
},
) {
this.server.to(`project:${projectId}`).emit('render:progress', {
projectId,
...payload,
timestamp: new Date().toISOString(),
});
}
/**
* Render tamamlandı bildirimi.
*/
emitRenderCompleted(
projectId: string,
payload: {
finalVideoUrl: string;
thumbnailUrl?: string;
processingTimeMs: number;
fileSize: number;
},
) {
this.server.to(`project:${projectId}`).emit('render:completed', {
projectId,
...payload,
timestamp: new Date().toISOString(),
});
}
/**
* Render hatası bildirimi.
*/
emitRenderFailed(
projectId: string,
payload: {
error: string;
stage: string;
attemptNumber: number;
canRetry: boolean;
},
) {
this.server.to(`project:${projectId}`).emit('render:failed', {
projectId,
...payload,
timestamp: new Date().toISOString(),
});
}
/**
* Proje durum değişikliği bildirimi (status change).
*/
emitProjectStatusChanged(projectId: string, status: string) {
this.server.to(`project:${projectId}`).emit('project:status', {
projectId,
status,
timestamp: new Date().toISOString(),
});
}
/**
* Kullanıcıya anlık bildirim gönder.
* NotificationsService.createNotification() tarafından çağrılır.
*/
emitNotification(
userId: string,
payload: {
id: string;
type: string;
title: string;
message?: string | null;
metadata?: unknown;
isRead: boolean;
createdAt: string;
},
) {
this.server.to(`user:${userId}`).emit('notification:new', {
...payload,
timestamp: new Date().toISOString(),
});
}
/** Bağlı client sayısını döndür (health check için) */
getConnectedClientsCount(): number {
return this.connectedClients;
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';
@Module({
providers: [EventsGateway],
exports: [EventsGateway],
})
export class EventsModule {}

View File

@@ -4,4 +4,6 @@ export const geminiConfig = registerAs('gemini', () => ({
enabled: process.env.ENABLE_GEMINI === 'true',
apiKey: process.env.GOOGLE_API_KEY,
defaultModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
imageModel: process.env.GEMINI_IMAGE_MODEL || 'gemini-2.0-flash-preview-image-generation',
}));

View File

@@ -237,4 +237,100 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
throw new Error('Failed to parse AI response as JSON');
}
}
// ── Görsel Üretim (Gemini Image Generation) ─────────────────────────
/**
* Gemini Image Generation API ile görsel üret.
* Raspberry Pi 5 bellek koruması için buffer olarak döner.
*
* @param prompt - İngilizce görsel açıklaması
* @param aspectRatio - Görsel en-boy oranı (16:9, 9:16, 1:1)
* @returns Base64 decoded image buffer ve mime type
*/
async generateImage(
prompt: string,
aspectRatio: '16:9' | '9:16' | '1:1' = '16:9',
): Promise<{ buffer: Buffer; mimeType: string } | null> {
if (!this.isAvailable()) {
throw new Error('Gemini AI is not available. Check your configuration.');
}
const imageModel = this.configService.get<string>(
'gemini.imageModel',
'gemini-2.0-flash-preview-image-generation',
);
try {
this.logger.debug(`🎨 Görsel üretiliyor: "${prompt.substring(0, 80)}..." [${aspectRatio}]`);
const response = await this.client!.models.generateContent({
model: imageModel,
contents: [
{
role: 'user',
parts: [
{
text: `Generate a high-quality image for this description: ${prompt}. Aspect ratio: ${aspectRatio}. Style: photorealistic, cinematic lighting, detailed.`,
},
],
},
],
config: {
responseModalities: ['TEXT', 'IMAGE'] as any,
},
});
// Gemini image response'dan image part'ı çıkar
const parts = (response as any).candidates?.[0]?.content?.parts || [];
for (const part of parts) {
if (part.inlineData?.data) {
const buffer = Buffer.from(part.inlineData.data, 'base64');
const mimeType = part.inlineData.mimeType || 'image/png';
this.logger.log(`✅ Görsel üretildi: ${(buffer.length / 1024).toFixed(1)} KB`);
return { buffer, mimeType };
}
}
this.logger.warn('Gemini görsel üretemedi — boş yanıt');
return null;
} catch (error) {
this.logger.error(`Gemini görsel üretim hatası: ${error}`);
throw error;
}
}
/**
* Sahne bazlı görsel üret — visualPrompt ve video stili kullanarak.
*
* @param visualPrompt - Sahnenin İngilizce görsel açıklaması
* @param style - Video stili (cinematic, documentary, educational vb.)
* @param aspectRatio - En-boy oranı
* @returns Buffer ve mimeType
*/
async generateImageForScene(
visualPrompt: string,
style: string = 'cinematic',
aspectRatio: '16:9' | '9:16' | '1:1' = '16:9',
): Promise<{ buffer: Buffer; mimeType: string } | null> {
const enhancedPrompt = `${visualPrompt}. Style: ${style}, professional production quality, volumetric lighting, sharp details, 8K resolution.`;
return this.generateImage(enhancedPrompt, aspectRatio);
}
/**
* Video için thumbnail görsel üret — proje başlığı ve açıklamasından.
*
* @param title - Video başlığı
* @param description - Video açıklaması
* @returns Buffer ve mimeType
*/
async generateThumbnail(
title: string,
description: string,
): Promise<{ buffer: Buffer; mimeType: string } | null> {
const prompt = `Create a compelling YouTube video thumbnail for a video titled "${title}". ${description}. Make it eye-catching with bold, dynamic composition. No text overlay needed.`;
return this.generateImage(prompt, '16:9');
}
}

View File

@@ -0,0 +1,83 @@
import {
Controller,
Get,
Patch,
Delete,
Param,
Query,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { NotificationsService } from './notifications.service';
/**
* Notifications Controller — Kullanıcı Bildirim Endpoint'leri
*
* GET /notifications → Bildirim listesi (paginated)
* GET /notifications/unread-count → Okunmamış sayısı
* PATCH /notifications/:id/read → Tekil okundu işaretle
* PATCH /notifications/read-all → Tümünü okundu yap
* DELETE /notifications/:id → Silme
*/
@ApiTags('Notifications')
@ApiBearerAuth()
@Controller('notifications')
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get()
@ApiOperation({ summary: 'Kullanıcının bildirimlerini getir' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getNotifications(
@Req() req: any,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
const userId = req.user?.id || req.user?.sub;
return this.notificationsService.getUserNotifications(
userId,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
}
@Get('unread-count')
@ApiOperation({ summary: 'Okunmamış bildirim sayısı' })
async getUnreadCount(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
return this.notificationsService.getUnreadCount(userId);
}
@Patch('read-all')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Tüm bildirimleri okundu olarak işaretle' })
async markAllAsRead(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
return this.notificationsService.markAllAsRead(userId);
}
@Patch(':id/read')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Bildirimi okundu olarak işaretle' })
async markAsRead(
@Req() req: any,
@Param('id') id: string,
) {
const userId = req.user?.id || req.user?.sub;
return this.notificationsService.markAsRead(id, userId);
}
@Delete(':id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Bildirimi sil' })
async deleteNotification(
@Req() req: any,
@Param('id') id: string,
) {
const userId = req.user?.id || req.user?.sub;
return this.notificationsService.deleteNotification(id, userId);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { NotificationsController } from './notifications.controller';
import { EventsModule } from '../events/events.module';
@Module({
imports: [EventsModule],
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,211 @@
import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../database/prisma.service';
import { EventsGateway } from '../events/events.gateway';
/**
* Notifications Service — In-App Bildirim Yönetimi
*
* Render sonuçları, düşük kredi uyarıları ve sistem bildirimleri
* için veritabanı + WebSocket push altyapısı.
*
* Flow:
* 1. createNotification() → DB'ye yaz + WebSocket push (anlık)
* 2. Frontend: WebSocket dinle + API ile liste çek
* 3. Kullanıcı: okundu işaretle / sil
*/
export type NotificationType =
| 'render_complete'
| 'render_failed'
| 'credit_low'
| 'credit_added'
| 'system';
export interface CreateNotificationPayload {
userId: string;
type: NotificationType;
title: string;
message?: string;
metadata?: Record<string, unknown>;
}
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
constructor(
private readonly db: PrismaService,
private readonly eventsGateway: EventsGateway,
) {}
/**
* Bildirim oluştur — DB'ye yaz + WebSocket ile anlık push.
*/
async createNotification(payload: CreateNotificationPayload) {
const notification = await this.db.notification.create({
data: {
userId: payload.userId,
type: payload.type,
title: payload.title,
message: payload.message || null,
metadata: payload.metadata
? (payload.metadata as Prisma.InputJsonValue)
: Prisma.JsonNull,
isRead: false,
},
});
// WebSocket ile kullanıcıya anlık bildirim gönder
this.eventsGateway.emitNotification(payload.userId, {
id: notification.id,
type: notification.type,
title: notification.title,
message: notification.message,
metadata: notification.metadata,
isRead: false,
createdAt: notification.createdAt.toISOString(),
});
this.logger.debug(
`Bildirim oluşturuldu: [${payload.type}] "${payload.title}" → User: ${payload.userId}`,
);
return notification;
}
/**
* Kullanıcının bildirimlerini getir (pagination).
*/
async getUserNotifications(
userId: string,
page = 1,
limit = 20,
) {
const skip = (page - 1) * limit;
const [notifications, total] = await Promise.all([
this.db.notification.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
skip,
take: limit,
select: {
id: true,
type: true,
title: true,
message: true,
isRead: true,
metadata: true,
createdAt: true,
readAt: true,
},
}),
this.db.notification.count({ where: { userId } }),
]);
return {
data: notifications,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Okunmamış bildirim sayısı.
*/
async getUnreadCount(userId: string): Promise<{ count: number }> {
const count = await this.db.notification.count({
where: { userId, isRead: false },
});
return { count };
}
/**
* Tekil bildirimi okundu olarak işaretle.
*/
async markAsRead(notificationId: string, userId: string) {
const notification = await this.db.notification.findUnique({
where: { id: notificationId },
});
if (!notification) {
throw new NotFoundException('Bildirim bulunamadı');
}
if (notification.userId !== userId) {
throw new ForbiddenException('Bu bildirime erişim izniniz yok');
}
return this.db.notification.update({
where: { id: notificationId },
data: {
isRead: true,
readAt: new Date(),
},
});
}
/**
* Kullanıcının tüm bildirimlerini okundu olarak işaretle.
*/
async markAllAsRead(userId: string) {
const result = await this.db.notification.updateMany({
where: { userId, isRead: false },
data: {
isRead: true,
readAt: new Date(),
},
});
this.logger.debug(`${result.count} bildirim okundu işaretlendi — User: ${userId}`);
return { updated: result.count };
}
/**
* Bildirimi sil.
*/
async deleteNotification(notificationId: string, userId: string) {
const notification = await this.db.notification.findUnique({
where: { id: notificationId },
});
if (!notification) {
throw new NotFoundException('Bildirim bulunamadı');
}
if (notification.userId !== userId) {
throw new ForbiddenException('Bu bildirime erişim izniniz yok');
}
await this.db.notification.delete({
where: { id: notificationId },
});
return { deleted: true };
}
/**
* Eski bildirimleri temizle (30 günden eski, okunmuş olanlar).
* Cron job veya admin endpoint'i ile çağrılabilir.
*/
async cleanupOldNotifications(): Promise<{ deleted: number }> {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const result = await this.db.notification.deleteMany({
where: {
isRead: true,
createdAt: { lt: thirtyDaysAgo },
},
});
this.logger.log(`Eski bildirim temizliği: ${result.count} adet silindi`);
return { deleted: result.count };
}
}

View File

@@ -150,4 +150,38 @@ export class ProjectsController {
this.logger.log(`Tweet'ten proje oluşturuluyor: ${dto.tweetUrl}`);
return this.projectsService.createFromTweet(userId, dto);
}
/**
* Tekil sahne güncelleme (narrasyon, görsel prompt, süre).
*/
@Patch(':id/scenes/:sceneId')
@ApiOperation({ summary: 'Sahneyi güncelle' })
@ApiResponse({ status: 200, description: 'Sahne güncellendi' })
async updateScene(
@Param('id', ParseUUIDPipe) id: string,
@Param('sceneId', ParseUUIDPipe) sceneId: string,
@Body() body: { narrationText?: string; visualPrompt?: string; subtitleText?: string; duration?: number },
@Req() req: any,
) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Sahne güncelleniyor: ${sceneId} (proje: ${id})`);
return this.projectsService.updateScene(userId, id, sceneId, body);
}
/**
* Tekil sahneyi AI ile yeniden üretir.
*/
@Post(':id/scenes/:sceneId/regenerate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Sahneyi AI ile yeniden üret' })
@ApiResponse({ status: 200, description: 'Sahne yeniden üretildi' })
async regenerateScene(
@Param('id', ParseUUIDPipe) id: string,
@Param('sceneId', ParseUUIDPipe) sceneId: string,
@Req() req: any,
) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Sahne yeniden üretiliyor: ${sceneId} (proje: ${id})`);
return this.projectsService.regenerateScene(userId, id, sceneId);
}
}

View File

@@ -473,4 +473,98 @@ export class ProjectsService {
throw error;
}
}
/**
* Tekil sahne güncelleme — narrasyon, görsel prompt, altyazı veya süre.
*/
async updateScene(
userId: string,
projectId: string,
sceneId: string,
data: { narrationText?: string; visualPrompt?: string; subtitleText?: string; duration?: number },
) {
// Proje sahipliğini doğrula
const project = await this.findOne(userId, projectId);
if (project.status !== 'DRAFT' && project.status !== 'FAILED') {
throw new BadRequestException(
`Sahneler yalnızca DRAFT veya FAILED durumunda düzenlenebilir. Mevcut: ${project.status}`,
);
}
// Sahnenin bu projeye ait olduğunu doğrula
const scene = project.scenes.find((s) => s.id === sceneId);
if (!scene) {
throw new NotFoundException(`Sahne bulunamadı: ${sceneId}`);
}
const updated = await this.db.scene.update({
where: { id: sceneId },
data: {
...(data.narrationText !== undefined && { narrationText: data.narrationText }),
...(data.visualPrompt !== undefined && { visualPrompt: data.visualPrompt }),
...(data.subtitleText !== undefined && { subtitleText: data.subtitleText }),
...(data.duration !== undefined && { duration: data.duration }),
},
include: { mediaAssets: true },
});
this.logger.log(`Sahne güncellendi: ${sceneId} (proje: ${projectId})`);
return updated;
}
/**
* Tekil sahneyi AI ile yeniden üretir.
* Mevcut sahnenin sıra numarası ve bağlamı korunur, sadece içerik yeniden üretilir.
*/
async regenerateScene(userId: string, projectId: string, sceneId: string) {
const project = await this.findOne(userId, projectId);
if (project.status !== 'DRAFT' && project.status !== 'FAILED') {
throw new BadRequestException(
`Sahneler yalnızca DRAFT veya FAILED durumunda yeniden üretilebilir.`,
);
}
const scene = project.scenes.find((s) => s.id === sceneId);
if (!scene) {
throw new NotFoundException(`Sahne bulunamadı: ${sceneId}`);
}
// Bağlam: Önceki ve sonraki sahne bilgisi
const prevScene = project.scenes.find((s) => s.order === scene.order - 1);
const nextScene = project.scenes.find((s) => s.order === scene.order + 1);
const contextPrompt = `
Bir video senaryosunun ${scene.order}. sahnesini yeniden üret.
Proje konusu: ${project.prompt}
Proje dili: ${project.language}
Video stili: ${project.videoStyle}
${prevScene ? `Önceki sahne: "${prevScene.narrationText}"` : ''}
${nextScene ? `Sonraki sahne: "${nextScene.narrationText}"` : ''}
Sadece bu tek sahneyi üret. JSON formatında:
{
"narrationText": "...",
"visualPrompt": "...",
"subtitleText": "...",
"durationSeconds": ${scene.duration}
}`;
const result = await this.videoAiService.generateSingleScene(contextPrompt);
const updated = await this.db.scene.update({
where: { id: sceneId },
data: {
narrationText: result.narrationText,
visualPrompt: result.visualPrompt,
subtitleText: result.subtitleText || result.narrationText,
duration: result.durationSeconds || scene.duration,
},
include: { mediaAssets: true },
});
this.logger.log(`Sahne yeniden üretildi: ${sceneId} (proje: ${projectId})`);
return updated;
}
}

View File

@@ -0,0 +1,318 @@
import {
Controller,
Post,
Body,
HttpCode,
HttpStatus,
Logger,
Headers,
UnauthorizedException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { EventsGateway } from '../events/events.gateway';
import { PrismaService } from '../../database/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { Public } from '../../common/decorators';
import { ProjectStatus, RenderStage } from '@prisma/client';
/**
* Render Callback Controller
*
* C# Media Worker, render ilerlemesini bu endpoint'ler aracılığıyla bildirir.
* Public endpoint — JWT gerekmez, bunun yerine API key ile korunur.
*
* Flow: C# Worker → HTTP POST → Bu controller → EventsGateway → Frontend (WebSocket)
*/
@ApiTags('render-callback')
@Controller('render-callback')
export class RenderCallbackController {
private readonly logger = new Logger(RenderCallbackController.name);
private readonly apiKey: string;
constructor(
private readonly eventsGateway: EventsGateway,
private readonly db: PrismaService,
private readonly configService: ConfigService,
private readonly notificationsService: NotificationsService,
) {
this.apiKey = this.configService.get<string>('RENDER_CALLBACK_API_KEY', 'contgen-worker-secret-2026');
}
/**
* API key doğrulaması — C# Worker'ın kimliğini kontrol eder.
*/
private validateApiKey(authHeader?: string) {
const key = authHeader?.replace('Bearer ', '');
if (key !== this.apiKey) {
throw new UnauthorizedException('Geçersiz worker API key');
}
}
/**
* Render ilerleme bildirimi.
* C# Worker her aşamada bu endpoint'i çağırır.
*/
@Public()
@Post('progress')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Render ilerleme bildirimi (Worker → API)' })
@ApiHeader({ name: 'Authorization', description: 'Bearer {WORKER_API_KEY}' })
@ApiResponse({ status: 200, description: 'İlerleme kaydedildi' })
async reportProgress(
@Headers('authorization') authHeader: string,
@Body() body: RenderProgressDto,
) {
this.validateApiKey(authHeader);
this.logger.log(
`Progress: ${body.projectId} — %${body.progress} [${body.stage}] sahne ${body.currentScene}/${body.totalScenes}`,
);
// DB'de proje progress'ini güncelle
await this.db.project.update({
where: { id: body.projectId },
data: {
progress: body.progress,
status: this.mapStageToStatus(body.stage) as ProjectStatus,
},
});
// RenderJob log yaz
if (body.renderJobId) {
await this.db.renderLog.create({
data: {
renderJobId: body.renderJobId,
stage: body.stage as RenderStage,
level: 'INFO',
message: body.stageLabel || `${body.stage} — %${body.progress}`,
durationMs: body.stepDurationMs,
},
});
}
// WebSocket ile frontend'e bildir
this.eventsGateway.emitRenderProgress(body.projectId, {
progress: body.progress,
stage: body.stage,
stageLabel: body.stageLabel,
currentScene: body.currentScene,
totalScenes: body.totalScenes,
eta: body.eta,
});
return { status: 'ok', progress: body.progress };
}
/**
* Render tamamlandı bildirimi.
*/
@Public()
@Post('completed')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Render tamamlandı bildirimi (Worker → API)' })
@ApiHeader({ name: 'Authorization', description: 'Bearer {WORKER_API_KEY}' })
async reportCompleted(
@Headers('authorization') authHeader: string,
@Body() body: RenderCompletedDto,
) {
this.validateApiKey(authHeader);
this.logger.log(
`COMPLETED: ${body.projectId} — video: ${body.finalVideoUrl} (${body.processingTimeMs}ms)`,
);
// Proje durumunu güncelle
await this.db.project.update({
where: { id: body.projectId },
data: {
status: 'COMPLETED',
progress: 100,
finalVideoUrl: body.finalVideoUrl,
thumbnailUrl: body.thumbnailUrl || null,
completedAt: new Date(),
},
});
// RenderJob durumunu güncelle
if (body.renderJobId) {
await this.db.renderJob.update({
where: { id: body.renderJobId },
data: {
status: 'COMPLETED',
finalVideoUrl: body.finalVideoUrl,
processingTimeMs: body.processingTimeMs,
completedAt: new Date(),
},
});
await this.db.renderLog.create({
data: {
renderJobId: body.renderJobId,
stage: 'FINALIZATION' as RenderStage,
level: 'INFO',
message: `Video tamamlandı${body.processingTimeMs}ms, ${body.fileSize} bytes`,
durationMs: body.processingTimeMs,
},
});
}
// WebSocket ile frontend'e bildir
this.eventsGateway.emitRenderCompleted(body.projectId, {
finalVideoUrl: body.finalVideoUrl,
thumbnailUrl: body.thumbnailUrl,
processingTimeMs: body.processingTimeMs,
fileSize: body.fileSize,
});
this.eventsGateway.emitProjectStatusChanged(body.projectId, 'COMPLETED');
// Proje sahibine in-app bildirim gönder
const project = await this.db.project.findUnique({
where: { id: body.projectId },
select: { userId: true, title: true },
});
if (project) {
await this.notificationsService.createNotification({
userId: project.userId,
type: 'render_complete',
title: 'Video hazır! 🎬',
message: `"${project.title}" videonuz başarıyla oluşturuldu.`,
metadata: {
projectId: body.projectId,
renderJobId: body.renderJobId,
finalVideoUrl: body.finalVideoUrl,
},
});
}
return { status: 'ok', projectId: body.projectId };
}
/**
* Render hatası bildirimi.
*/
@Public()
@Post('failed')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Render hatası bildirimi (Worker → API)' })
@ApiHeader({ name: 'Authorization', description: 'Bearer {WORKER_API_KEY}' })
async reportFailed(
@Headers('authorization') authHeader: string,
@Body() body: RenderFailedDto,
) {
this.validateApiKey(authHeader);
this.logger.error(
`FAILED: ${body.projectId}${body.error} [${body.stage}] attempt: ${body.attemptNumber}`,
);
// Proje durumunu güncelle
await this.db.project.update({
where: { id: body.projectId },
data: {
status: 'FAILED',
errorMessage: body.error,
},
});
// RenderJob durumunu güncelle
if (body.renderJobId) {
await this.db.renderJob.update({
where: { id: body.renderJobId },
data: {
status: 'FAILED',
errorMessage: body.error,
completedAt: new Date(),
},
});
await this.db.renderLog.create({
data: {
renderJobId: body.renderJobId,
stage: body.stage as RenderStage,
level: 'ERROR',
message: body.error,
},
});
}
// WebSocket ile frontend'e bildir
this.eventsGateway.emitRenderFailed(body.projectId, {
error: body.error,
stage: body.stage,
attemptNumber: body.attemptNumber,
canRetry: body.canRetry ?? true,
});
this.eventsGateway.emitProjectStatusChanged(body.projectId, 'FAILED');
// Proje sahibine in-app bildirim gönder
const project = await this.db.project.findUnique({
where: { id: body.projectId },
select: { userId: true, title: true },
});
if (project) {
await this.notificationsService.createNotification({
userId: project.userId,
type: 'render_failed',
title: 'Video oluşturulamadı ⚠️',
message: `"${project.title}" işlenirken hata oluştu: ${body.error}`,
metadata: {
projectId: body.projectId,
renderJobId: body.renderJobId,
stage: body.stage,
canRetry: body.canRetry ?? true,
},
});
}
return { status: 'ok', projectId: body.projectId };
}
private mapStageToStatus(stage: string): string {
switch (stage) {
case 'tts':
case 'image_generation':
case 'music_generation':
return 'GENERATING_MEDIA';
case 'compositing':
case 'encoding':
return 'RENDERING';
default:
return 'GENERATING_MEDIA';
}
}
}
// ── DTO Tanımları ──────────────────────────────────────────
class RenderProgressDto {
projectId: string;
renderJobId?: string;
progress: number;
stage: string;
stageLabel: string;
currentScene?: number;
totalScenes?: number;
eta?: number;
stepDurationMs?: number;
}
class RenderCompletedDto {
projectId: string;
renderJobId?: string;
finalVideoUrl: string;
thumbnailUrl?: string;
processingTimeMs: number;
fileSize: number;
}
class RenderFailedDto {
projectId: string;
renderJobId?: string;
error: string;
stage: string;
attemptNumber: number;
canRetry?: boolean;
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RenderCallbackController } from './render-callback.controller';
import { EventsModule } from '../events/events.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [EventsModule, NotificationsModule],
controllers: [RenderCallbackController],
})
export class RenderCallbackModule {}

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]}`;
}
}

View File

@@ -1,4 +1,4 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, Patch, Body, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { BaseController } from '../../common/base';
import { UsersService } from './users.service';
@@ -43,6 +43,45 @@ export class UsersController extends BaseController<
);
}
@Patch('me')
async updateProfile(
@CurrentUser() user: AuthenticatedUser,
@Body() body: { firstName?: string; lastName?: string },
): Promise<ApiResponse<UserResponseDto>> {
const updated = await this.usersService.update(user.id, {
firstName: body.firstName,
lastName: body.lastName,
});
return createSuccessResponse(
plainToInstance(UserResponseDto, updated),
'Profil başarıyla güncellendi',
);
}
@Patch('me/password')
async changePassword(
@CurrentUser() user: AuthenticatedUser,
@Body() body: { currentPassword: string; newPassword: string },
): Promise<ApiResponse<{ success: boolean }>> {
if (!body.currentPassword || !body.newPassword) {
throw new BadRequestException('Mevcut ve yeni şifre gerekli');
}
if (body.newPassword.length < 8) {
throw new BadRequestException('Yeni şifre en az 8 karakter olmalı');
}
const fullUser = await this.usersService.findOne(user.id);
if (!fullUser) throw new BadRequestException('Kullanıcı bulunamadı');
const bcrypt = await import('bcrypt');
const isValid = await bcrypt.compare(body.currentPassword, fullUser.password);
if (!isValid) {
throw new BadRequestException('Mevcut şifre hatalı');
}
await this.usersService.update(user.id, { password: body.newPassword });
return createSuccessResponse({ success: true }, 'Şifre başarıyla güncellendi');
}
// Override create to require admin role
@Roles('admin')
async create(

View File

@@ -546,4 +546,44 @@ export class VideoAiService {
return parsed;
}
/**
* Tekil sahne yeniden üretimi — sınırlı bağlam ile sadece 1 sahne üretir.
*/
async generateSingleScene(contextPrompt: string): Promise<{
narrationText: string;
visualPrompt: string;
subtitleText: string;
durationSeconds: number;
}> {
if (!this.genAI) {
throw new InternalServerErrorException('AI servisi etkin değil — Google API Key gerekli.');
}
try {
const response = await this.genAI.models.generateContent({
model: this.modelName,
contents: contextPrompt,
config: {
responseMimeType: 'application/json',
temperature: 0.8,
maxOutputTokens: 1024,
},
});
const rawText = response.text || '';
const cleaned = rawText.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
const parsed = JSON.parse(cleaned);
return {
narrationText: parsed.narrationText || 'Yeniden üretilen sahne.',
visualPrompt: parsed.visualPrompt || 'Cinematic establishing shot.',
subtitleText: parsed.subtitleText || parsed.narrationText || '',
durationSeconds: parsed.durationSeconds || 5,
};
} catch (error) {
this.logger.error(`Tekil sahne üretim hatası: ${error}`);
throw new InternalServerErrorException('Sahne yeniden üretilemedi.');
}
}
}