Files
ContentGen_BE/src/modules/render-callback/render-callback.controller.ts
T
Harun CAN 5184db32cc
Backend Deploy 🚀 / build-and-deploy (push) Has been cancelled
main
2026-05-01 00:45:33 +02:00

322 lines
9.1 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 {
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';
// ── DTO Tanımları ──────────────────────────────────────────
export class RenderProgressDto {
projectId: string;
renderJobId?: string;
progress: number;
stage: string;
stageLabel: string;
currentScene?: number;
totalScenes?: number;
eta?: number;
stepDurationMs?: number;
}
export class RenderCompletedDto {
projectId: string;
renderJobId?: string;
finalVideoUrl: string;
thumbnailUrl?: string;
processingTimeMs: number;
fileSize: number;
}
export class RenderFailedDto {
projectId: string;
renderJobId?: string;
error: string;
stage: string;
attemptNumber: number;
canRetry?: boolean;
}
/**
* 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';
}
}
}