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

This commit is contained in:
Harun CAN
2026-05-01 00:45:33 +02:00
parent 1775ac1aa1
commit 5184db32cc
33 changed files with 1852 additions and 713 deletions
+1
View File
@@ -0,0 +1 @@
{"success":false,"status":401,"message":"Invalid email or password","data":null,"errors":[],"stack":"UnauthorizedException: INVALID_CREDENTIALS\n at AuthService.login (/Users/haruncan/Documents/GitHub/ContentGenerator/ContentGen_BE/dist/src/modules/auth/auth.service.js:128:19)\n at async AuthController.login (/Users/haruncan/Documents/GitHub/ContentGenerator/ContentGen_BE/dist/src/modules/auth/auth.controller.js:33:24)"}
+7
View File
@@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const users = await prisma.user.findMany();
console.log(users);
}
main().finally(() => prisma.$disconnect());
+1 -1
View File
@@ -96,7 +96,7 @@ import {
LoggerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
useFactory: (configService: ConfigService) => {
return {
pinoHttp: {
level: configService.get('app.isDevelopment') ? 'debug' : 'info',
@@ -80,7 +80,7 @@ export class GlobalExceptionFilter implements ExceptionFilter {
});
// Only update if translation exists (key is different from result)
if (translatedMessage !== `errors.${message}`) {
message = translatedMessage as string;
message = translatedMessage;
}
}
} catch {
+21 -14
View File
@@ -28,13 +28,14 @@ async function bootstrap() {
app.useLogger(app.get(Logger));
app.useGlobalInterceptors(new LoggerErrorInterceptor());
// Security Headers
app.use(helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: 'cross-origin' },
}));
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: 'cross-origin' },
}),
);
// Graceful Shutdown (Prisma & Docker)
app.enableShutdownHooks();
@@ -45,7 +46,10 @@ async function bootstrap() {
const nodeEnv = configService.get('NODE_ENV', 'development');
// ── Static File Serving — Medya dosyalarına HTTP erişim ──
const mediaPath = configService.get<string>('STORAGE_LOCAL_PATH', './data/media');
const mediaPath = configService.get<string>(
'STORAGE_LOCAL_PATH',
'./data/media',
);
const absoluteMediaPath = path.resolve(mediaPath);
// Medya dosyaları için CORS header'ları (Frontend farklı port'ta çalışıyor)
@@ -55,13 +59,16 @@ async function bootstrap() {
next();
});
app.use('/media', express.static(absoluteMediaPath, {
maxAge: '1d',
etag: true,
lastModified: true,
index: false,
dotfiles: 'deny',
}));
app.use(
'/media',
express.static(absoluteMediaPath, {
maxAge: '1d',
etag: true,
lastModified: true,
index: false,
dotfiles: 'deny',
}),
);
logger.log(`📂 Medya dizini: ${absoluteMediaPath} → /media/*`);
// Enable CORS
+13 -6
View File
@@ -84,7 +84,11 @@ export class AdminController {
@Param('userId') userId: string,
@Body() data: { amount: number; description: string },
): Promise<ApiResponse<any>> {
const tx = await this.adminService.grantCredits(userId, data.amount, data.description);
const tx = await this.adminService.grantCredits(
userId,
data.amount,
data.description,
);
return createSuccessResponse(tx, 'Kredi yüklendi');
}
@@ -92,9 +96,7 @@ export class AdminController {
@Get('users/:id/detail')
@ApiOperation({ summary: 'Kullanıcı detay — abonelik, projeler, krediler' })
async getUserDetail(
@Param('id') id: string,
): Promise<ApiResponse<any>> {
async getUserDetail(@Param('id') id: string): Promise<ApiResponse<any>> {
const user = await this.adminService.getUserDetail(id);
return createSuccessResponse(user);
}
@@ -325,7 +327,13 @@ export class AdminController {
@Get('projects')
@ApiOperation({ summary: 'Tüm projeleri getir (admin)' })
async getAllProjects(
@Query() query: { page?: number; limit?: number; status?: string; userId?: string },
@Query()
query: {
page?: number;
limit?: number;
status?: string;
userId?: string;
},
): Promise<ApiResponse<any>> {
const result = await this.adminService.getAllProjects({
page: query.page ? Number(query.page) : 1,
@@ -380,4 +388,3 @@ export class AdminController {
);
}
}
-1
View File
@@ -10,4 +10,3 @@ import { StorageModule } from '../storage/storage.module';
exports: [AdminService],
})
export class AdminModule {}
+50 -24
View File
@@ -33,7 +33,13 @@ export class AdminService {
this.prisma.user.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
select: { id: true, email: true, firstName: true, lastName: true, createdAt: true },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
createdAt: true,
},
}),
this.prisma.project.groupBy({
by: ['status'],
@@ -65,17 +71,23 @@ export class AdminService {
},
projects: {
total: totalProjects,
byStatus: projectsByStatus.reduce((acc, item) => {
acc[item.status] = item._count.id;
return acc;
}, {} as Record<string, number>),
byStatus: projectsByStatus.reduce(
(acc, item) => {
acc[item.status] = item._count.id;
return acc;
},
{} as Record<string, number>,
),
},
renderJobs: {
total: totalRenderJobs,
byStatus: renderJobsByStatus.reduce((acc, item) => {
acc[item.status] = item._count.id;
return acc;
}, {} as Record<string, number>),
byStatus: renderJobsByStatus.reduce(
(acc, item) => {
acc[item.status] = item._count.id;
return acc;
},
{} as Record<string, number>,
),
},
credits: {
totalGranted: creditStats._sum.amount || 0,
@@ -102,18 +114,21 @@ export class AdminService {
});
}
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;
}) {
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,
@@ -122,7 +137,12 @@ export class AdminService {
// ── Proje ve Render Yönetimi ──────────────────────────────────────
async getAllProjects(params: { page: number; limit: number; status?: string; userId?: string }) {
async getAllProjects(params: {
page: number;
limit: number;
status?: string;
userId?: string;
}) {
const { page, limit, status, userId } = params;
// Status filtresini prisma tarafında idari bir kontrole dönüştürmek gerek
@@ -133,7 +153,9 @@ export class AdminService {
const [data, total] = await Promise.all([
this.prisma.project.findMany({
where: whereCondition,
include: { user: { select: { email: true, firstName: true, lastName: true } } },
include: {
user: { select: { email: true, firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
@@ -144,7 +166,11 @@ export class AdminService {
return { data, total, page, limit, totalPages: Math.ceil(total / limit) };
}
async getAllRenderJobs(params: { page: number; limit: number; status?: string }) {
async getAllRenderJobs(params: {
page: number;
limit: number;
status?: string;
}) {
const { page, limit, status } = params;
const whereCondition: any = {};
+23 -11
View File
@@ -146,10 +146,12 @@ export class AuthService {
if (user.email === 'admin@contentgen.ai') {
const hasAdminRole = user.roles.some((ur) => ur.role.name === 'admin');
if (!hasAdminRole) {
const adminRole = await this.prisma.role.findUnique({ where: { name: 'admin' } });
const adminRole = await this.prisma.role.findUnique({
where: { name: 'admin' },
});
if (adminRole) {
await this.prisma.userRole.create({
data: { userId: user.id, roleId: adminRole.id }
data: { userId: user.id, roleId: adminRole.id },
});
// Refresh user object
const refreshedUser = await this.prisma.user.findUnique({
@@ -157,17 +159,25 @@ export class AuthService {
include: {
roles: {
include: {
role: { include: { permissions: { include: { permission: true } } } }
}
}
}
role: {
include: { permissions: { include: { permission: true } } },
},
},
},
},
});
if (refreshedUser) {
// Grant 999999 credits if not granted
const existingGrant = await this.prisma.creditTransaction.findFirst({
where: { userId: refreshedUser.id, type: 'grant', description: 'Admin başlangıç kredisi — sınırsız' },
});
const existingGrant = await this.prisma.creditTransaction.findFirst(
{
where: {
userId: refreshedUser.id,
type: 'grant',
description: 'Admin başlangıç kredisi — sınırsız',
},
},
);
if (!existingGrant) {
await this.prisma.creditTransaction.create({
data: {
@@ -179,7 +189,9 @@ export class AuthService {
},
});
}
return this.generateTokens(refreshedUser as unknown as UserWithRoles);
return this.generateTokens(
refreshedUser as unknown as UserWithRoles,
);
}
}
}
@@ -303,7 +315,7 @@ export class AuthService {
// Generate access token
const accessToken = this.jwtService.sign(payload, {
expiresIn: accessExpiration as any,
expiresIn: accessExpiration,
});
// Generate refresh token
+9 -2
View File
@@ -29,7 +29,10 @@ export class BillingController {
private readonly configService: ConfigService,
) {
const stripeKey = this.configService.get<string>('STRIPE_SECRET_KEY');
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET', '');
this.webhookSecret = this.configService.get<string>(
'STRIPE_WEBHOOK_SECRET',
'',
);
if (stripeKey) {
this.stripe = new Stripe(stripeKey);
@@ -54,7 +57,11 @@ export class BillingController {
@Body() body: { planName: string; billingCycle: 'monthly' | 'yearly' },
) {
const userId = req.user?.id || req.user?.sub;
return this.billingService.createCheckoutSession(userId, body.planName, body.billingCycle);
return this.billingService.createCheckoutSession(
userId,
body.planName,
body.billingCycle,
);
}
@Get('credits/balance')
+81 -28
View File
@@ -1,4 +1,9 @@
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import {
Injectable,
Logger,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../database/prisma.service';
import Stripe from 'stripe';
@@ -32,14 +37,20 @@ export class BillingService {
this.logger.log('💳 Stripe bağlantısı kuruldu');
} else {
this.stripe = null;
this.logger.warn('⚠️ STRIPE_SECRET_KEY ayarlanmamış — Ödeme sistemi devre dışı');
this.logger.warn(
'⚠️ STRIPE_SECRET_KEY ayarlanmamış — Ödeme sistemi devre dışı',
);
}
}
/**
* Checkout Session oluştur — Value-Based Pricing (pricing-strategy skill)
*/
async createCheckoutSession(userId: string, planName: string, billingCycle: 'monthly' | 'yearly') {
async createCheckoutSession(
userId: string,
planName: string,
billingCycle: 'monthly' | 'yearly',
) {
if (!this.stripe) {
throw new BadRequestException('Ödeme sistemi şu anda aktif değil');
}
@@ -61,12 +72,13 @@ export class BillingService {
throw new NotFoundException('Kullanıcı bulunamadı');
}
const priceId = billingCycle === 'yearly'
? plan.stripeYearlyPriceId
: plan.stripePriceId;
const priceId =
billingCycle === 'yearly' ? plan.stripeYearlyPriceId : plan.stripePriceId;
if (!priceId) {
throw new BadRequestException(`Bu plan için ${billingCycle} fiyat tanımlı değil`);
throw new BadRequestException(
`Bu plan için ${billingCycle} fiyat tanımlı değil`,
);
}
const session = await this.stripe.checkout.sessions.create({
@@ -88,7 +100,9 @@ export class BillingService {
cancel_url: `${this.configService.get('APP_URL')}/dashboard/pricing?checkout=cancelled`,
});
this.logger.log(`Checkout session oluşturuldu: ${session.id} — Plan: ${planName}`);
this.logger.log(
`Checkout session oluşturuldu: ${session.id} — Plan: ${planName}`,
);
return {
sessionId: session.id,
@@ -103,19 +117,19 @@ export class BillingService {
async handleWebhookEvent(event: Stripe.Event): Promise<void> {
switch (event.type) {
case 'checkout.session.completed':
await this.handleCheckoutComplete(event.data.object as Stripe.Checkout.Session);
await this.handleCheckoutComplete(event.data.object);
break;
case 'invoice.payment_succeeded':
await this.handlePaymentSucceeded(event.data.object as Stripe.Invoice);
await this.handlePaymentSucceeded(event.data.object);
break;
case 'customer.subscription.updated':
await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
await this.handleSubscriptionUpdated(event.data.object);
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
await this.handleSubscriptionDeleted(event.data.object);
break;
default:
@@ -157,7 +171,9 @@ export class BillingService {
where: { userId },
include: { role: true },
});
return userRoles.some((ur) => ur.role.name === 'admin' || ur.role.name === 'superadmin');
return userRoles.some(
(ur) => ur.role.name === 'admin' || ur.role.name === 'superadmin',
);
}
/**
@@ -193,7 +209,9 @@ export class BillingService {
const monthlyTransactions = transactions.filter(
(tx) => tx.amount < 0 && new Date(tx.createdAt) >= monthStart,
);
const monthlyUsed = Math.abs(monthlyTransactions.reduce((sum, tx) => sum + tx.amount, 0));
const monthlyUsed = Math.abs(
monthlyTransactions.reduce((sum, tx) => sum + tx.amount, 0),
);
// Kullanıcının aktif planından limit al
const subscription = await this.db.subscription.findFirst({
@@ -217,12 +235,24 @@ export class BillingService {
/**
* Kredi harca (video üretimi için)
*/
async spendCredits(userId: string, amount: number, projectId: string, description: string) {
async spendCredits(
userId: string,
amount: number,
projectId: string,
description: string,
) {
// Admin bypass — sınırsız kredi
const admin = await this.isAdmin(userId);
if (admin) {
this.logger.log(`🛡️ Admin kredi bypass: ${amount} — User: ${userId}, Project: ${projectId}`);
return { id: 'admin-bypass', amount: -amount, type: 'usage', description };
this.logger.log(
`🛡️ Admin kredi bypass: ${amount} — User: ${userId}, Project: ${projectId}`,
);
return {
id: 'admin-bypass',
amount: -amount,
type: 'usage',
description,
};
}
const balance = await this.getCreditBalance(userId);
@@ -244,14 +274,21 @@ export class BillingService {
},
});
this.logger.log(`Kredi harcandı: -${amount} — User: ${userId}, Project: ${projectId}`);
this.logger.log(
`Kredi harcandı: -${amount} — User: ${userId}, Project: ${projectId}`,
);
return transaction;
}
/**
* Kredi ekle (abonelik yenileme, bonus vb.)
*/
async grantCredits(userId: string, amount: number, type: string, description: string) {
async grantCredits(
userId: string,
amount: number,
type: string,
description: string,
) {
const currentBalance = await this.getCreditBalance(userId);
const transaction = await this.db.creditTransaction.create({
@@ -264,7 +301,9 @@ export class BillingService {
},
});
this.logger.log(`Kredi eklendi: +${amount} — User: ${userId}, Type: ${type}`);
this.logger.log(
`Kredi eklendi: +${amount} — User: ${userId}, Type: ${type}`,
);
return transaction;
}
@@ -296,16 +335,22 @@ export class BillingService {
});
// İlk ay kredilerini yükle
await this.grantCredits(userId, plan.monthlyCredits, 'grant', `${plan.displayName} abonelik kredisi`);
await this.grantCredits(
userId,
plan.monthlyCredits,
'grant',
`${plan.displayName} abonelik kredisi`,
);
this.logger.log(`✅ Abonelik aktif: User ${userId}, Plan ${plan.name}`);
}
private async handlePaymentSucceeded(invoice: Stripe.Invoice) {
const inv = invoice as any;
const subscriptionId = typeof inv.subscription === 'string'
? inv.subscription
: inv.subscription?.id;
const subscriptionId =
typeof inv.subscription === 'string'
? inv.subscription
: inv.subscription?.id;
if (!subscriptionId) return;
const subscription = await this.db.subscription.findFirst({
@@ -323,10 +368,14 @@ export class BillingService {
`${subscription.plan.displayName} aylık kredi yenileme`,
);
this.logger.log(`💰 Ödeme başarılı — kredi yenilendi: ${subscription.userId}`);
this.logger.log(
`💰 Ödeme başarılı — kredi yenilendi: ${subscription.userId}`,
);
}
private async handleSubscriptionUpdated(stripeSubscription: Stripe.Subscription) {
private async handleSubscriptionUpdated(
stripeSubscription: Stripe.Subscription,
) {
const periodStart = (stripeSubscription as any).current_period_start;
const periodEnd = (stripeSubscription as any).current_period_end;
@@ -335,13 +384,17 @@ export class BillingService {
data: {
status: stripeSubscription.status,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
...(periodStart && { currentPeriodStart: new Date(periodStart * 1000) }),
...(periodStart && {
currentPeriodStart: new Date(periodStart * 1000),
}),
...(periodEnd && { currentPeriodEnd: new Date(periodEnd * 1000) }),
},
});
}
private async handleSubscriptionDeleted(stripeSubscription: Stripe.Subscription) {
private async handleSubscriptionDeleted(
stripeSubscription: Stripe.Subscription,
) {
await this.db.subscription.updateMany({
where: { stripeSubscriptionId: stripeSubscription.id },
data: {
@@ -1,10 +1,10 @@
import { Controller, Get, Logger, Req } from '@nestjs/common';
import {
Controller,
Get,
Logger,
Req,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { DashboardService } from './dashboard.service';
@ApiTags('dashboard')
+8 -1
View File
@@ -45,7 +45,14 @@ export class DashboardService {
where: {
userId,
deletedAt: null,
status: { in: ['PENDING', 'GENERATING_MEDIA', 'RENDERING', 'GENERATING_SCRIPT'] },
status: {
in: [
'PENDING',
'GENERATING_MEDIA',
'RENDERING',
'GENERATING_SCRIPT',
],
},
},
}),
+15 -7
View File
@@ -20,7 +20,11 @@ import { Server, Socket } from 'socket.io';
*/
@WebSocketGateway({
cors: {
origin: ['http://localhost:3001', 'http://localhost:3000', process.env.FRONTEND_URL || '*'],
origin: [
'http://localhost:3001',
'http://localhost:3000',
process.env.FRONTEND_URL || '*',
],
credentials: true,
},
namespace: '/ws',
@@ -35,12 +39,16 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
handleConnection(client: Socket) {
this.connectedClients++;
this.logger.log(`Client bağlandı: ${client.id} (toplam: ${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})`);
this.logger.log(
`Client ayrıldı: ${client.id} (toplam: ${this.connectedClients})`,
);
}
/**
@@ -53,7 +61,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@MessageBody() data: { projectId: string },
) {
const room = `project:${data.projectId}`;
client.join(room);
void client.join(room);
this.logger.debug(`Client ${client.id} → room: ${room}`);
return { event: 'joined', data: { room, projectId: data.projectId } };
}
@@ -67,7 +75,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@MessageBody() data: { projectId: string },
) {
const room = `project:${data.projectId}`;
client.leave(room);
void client.leave(room);
this.logger.debug(`Client ${client.id} ← room: ${room}`);
return { event: 'left', data: { room } };
}
@@ -82,7 +90,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@MessageBody() data: { userId: string },
) {
const room = `user:${data.userId}`;
client.join(room);
void client.join(room);
this.logger.debug(`Client ${client.id} → user room: ${room}`);
return { event: 'joined', data: { room, userId: data.userId } };
}
@@ -96,7 +104,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@MessageBody() data: { userId: string },
) {
const room = `user:${data.userId}`;
client.leave(room);
void client.leave(room);
this.logger.debug(`Client ${client.id} ← user room: ${room}`);
return { event: 'left', data: { room } };
}
+31 -12
View File
@@ -6,27 +6,39 @@ import FormData from 'form-data';
@Injectable()
export class ExtractorService {
private readonly logger = new Logger(ExtractorService.name);
private readonly extractorUrl = process.env.EXTRACTOR_URL || 'http://contgen-ai-extractor:8000';
private readonly extractorUrl =
process.env.EXTRACTOR_URL || 'http://contgen-ai-extractor:8000';
constructor() {}
async extractFromUrl(url: string): Promise<string> {
this.logger.log(`URL'den içerik çekiliyor: ${url}`);
try {
const response = await axios.post(`${this.extractorUrl}/extract/url`, { url }, {
timeout: 60000 // 60 seconds timeout
});
const response = await axios.post(
`${this.extractorUrl}/extract/url`,
{ url },
{
timeout: 60000, // 60 seconds timeout
},
);
return response.data.content;
} catch (error: any) {
this.logger.error(`URL extraction failed: ${error.message}`);
if (error.response) {
throw new HttpException(error.response.data, error.response.status);
}
throw new HttpException('Extractor servisi bulunamadı veya zaman aşımına uğradı', HttpStatus.SERVICE_UNAVAILABLE);
throw new HttpException(
'Extractor servisi bulunamadı veya zaman aşımına uğradı',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
async extractFromFile(filePath: string, filename: string, mimeType: string): Promise<string> {
async extractFromFile(
filePath: string,
filename: string,
mimeType: string,
): Promise<string> {
this.logger.log(`Dosyadan içerik çekiliyor: ${filename}`);
try {
const formData = new FormData();
@@ -35,12 +47,16 @@ export class ExtractorService {
contentType: mimeType,
});
const response = await axios.post(`${this.extractorUrl}/extract/file`, formData, {
headers: {
...formData.getHeaders(),
const response = await axios.post(
`${this.extractorUrl}/extract/file`,
formData,
{
headers: {
...formData.getHeaders(),
},
timeout: 120000, // 2 minutes timeout for files
},
timeout: 120000 // 2 minutes timeout for files
});
);
return response.data.content;
} catch (error: any) {
@@ -48,7 +64,10 @@ export class ExtractorService {
if (error.response) {
throw new HttpException(error.response.data, error.response.status);
}
throw new HttpException('Extractor servisi bulunamadı veya zaman aşımına uğradı', HttpStatus.SERVICE_UNAVAILABLE);
throw new HttpException(
'Extractor servisi bulunamadı veya zaman aşımına uğradı',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}
+3 -2
View File
@@ -4,6 +4,7 @@ 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',
imageModel:
process.env.GEMINI_IMAGE_MODEL ||
'gemini-2.0-flash-preview-image-generation',
}));
+94 -29
View File
@@ -269,10 +269,17 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
const fallbackModel = 'gemini-3.1-flash-image-preview';
try {
this.logger.log(`🎨 Görsel üretiliyor: "${prompt.substring(0, 100)}..." [${aspectRatio}]`);
this.logger.log(
`🎨 Görsel üretiliyor: "${prompt.substring(0, 100)}..." [${aspectRatio}]`,
);
// En-boy oranına göre yönlendirmeyi zorla
const orientation = aspectRatio === '9:16' ? '(VERTICAL / PORTRAIT)' : aspectRatio === '16:9' ? '(HORIZONTAL / LANDSCAPE)' : '(SQUARE)';
const orientation =
aspectRatio === '9:16'
? '(VERTICAL / PORTRAIT)'
: aspectRatio === '16:9'
? '(HORIZONTAL / LANDSCAPE)'
: '(SQUARE)';
// Gemini modelleri ana konunun (subject) prompt'un en başında olmasını tercih eder.
// Jenerik stil kelimelerini sonuna ekliyoruz ki ana konu (prompt) kaybolmasın.
const enhancedPrompt = isIllustration
@@ -283,23 +290,36 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
for (let attempt = 1; attempt <= 2; attempt++) {
try {
this.logger.log(`🔄 Katman 1 (deneme ${attempt}/2): ${primaryModel}`);
const result = await this.tryGenerateContentImage(primaryModel, enhancedPrompt);
const result = await this.tryGenerateContentImage(
primaryModel,
enhancedPrompt,
);
if (result && result.buffer.length > 0) {
this.logger.log(`✅ Görsel üretildi (${primaryModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`);
this.logger.log(
`✅ Görsel üretildi (${primaryModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`,
);
return { buffer: result.buffer, mimeType: result.mimeType };
}
const reason = result?.errorReason || 'null response';
this.logger.warn(`⚠️ ${primaryModel} deneme ${attempt}: görsel döndürmedi (${reason})`);
this.logger.warn(
`⚠️ ${primaryModel} deneme ${attempt}: görsel döndürmedi (${reason})`,
);
if (['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(reason)) {
this.logger.warn(`🚫 Güvenlik/Politika filtresi tetiklendi (${reason}). Denemeler iptal ediliyor.`);
if (
['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(reason)
) {
this.logger.warn(
`🚫 Güvenlik/Politika filtresi tetiklendi (${reason}). Denemeler iptal ediliyor.`,
);
break; // Fail fast for safety blocks
}
if (attempt < 2) await this.sleep(2000);
} catch (err1: any) {
this.logger.warn(`⚠️ ${primaryModel} deneme ${attempt} hata: ${err1.message?.substring(0, 200)}`);
this.logger.warn(
`⚠️ ${primaryModel} deneme ${attempt} hata: ${err1.message?.substring(0, 200)}`,
);
if (attempt < 2) await this.sleep(2000);
}
}
@@ -307,18 +327,33 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
// ── Katman 2: gemini-3.1-flash-image-preview (Nano Banana 2) ──
try {
this.logger.log(`🔄 Katman 2: ${fallbackModel}`);
const result = await this.tryGenerateContentImage(fallbackModel, enhancedPrompt);
const result = await this.tryGenerateContentImage(
fallbackModel,
enhancedPrompt,
);
if (result && result.buffer.length > 0) {
this.logger.log(`✅ Görsel üretildi (${fallbackModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`);
this.logger.log(
`✅ Görsel üretildi (${fallbackModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`,
);
return { buffer: result.buffer, mimeType: result.mimeType };
}
this.logger.warn(`⚠️ ${fallbackModel}: görsel döndürmedi (${result?.errorReason || 'null response'})`);
this.logger.warn(
`⚠️ ${fallbackModel}: görsel döndürmedi (${result?.errorReason || 'null response'})`,
);
if (['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(result?.errorReason || '')) {
this.logger.warn(`🚫 Katman 2 Güvenlik/Politika filtresi tetiklendi. Katman 3'e geçiliyor.`);
if (
['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(
result?.errorReason || '',
)
) {
this.logger.warn(
`🚫 Katman 2 Güvenlik/Politika filtresi tetiklendi. Katman 3'e geçiliyor.`,
);
}
} catch (err2: any) {
this.logger.warn(`⚠️ ${fallbackModel} hata: ${err2.message?.substring(0, 200)}`);
this.logger.warn(
`⚠️ ${fallbackModel} hata: ${err2.message?.substring(0, 200)}`,
);
}
// ── Katman 3: Imagen 4 Fast (generateImages API) ──
@@ -335,20 +370,31 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
});
if (response.generatedImages?.[0]?.image?.imageBytes) {
const buffer = Buffer.from(response.generatedImages[0].image.imageBytes, 'base64');
const buffer = Buffer.from(
response.generatedImages[0].image.imageBytes,
'base64',
);
const mimeType = 'image/jpeg';
this.logger.log(`✅ Görsel üretildi (Imagen 4): ${(buffer.length / 1024).toFixed(1)} KB`);
this.logger.log(
`✅ Görsel üretildi (Imagen 4): ${(buffer.length / 1024).toFixed(1)} KB`,
);
return { buffer, mimeType };
}
this.logger.warn(`⚠️ Imagen 4: görsel döndürmedi. Üretilen görsel sayısı: ${response.generatedImages?.length || 0}`);
this.logger.warn(
`⚠️ Imagen 4: görsel döndürmedi. Üretilen görsel sayısı: ${response.generatedImages?.length || 0}`,
);
} catch (err3: any) {
this.logger.warn(`⚠️ Imagen 4 hata: ${err3.message?.substring(0, 200)}`);
this.logger.warn(
`⚠️ Imagen 4 hata: ${err3.message?.substring(0, 200)}`,
);
}
this.logger.error('❌ Tüm görsel üretim katmanları başarısız oldu');
return null;
} catch (error) {
this.logger.error(`Gemini görsel üretim hatası: ${error instanceof Error ? error.message : error}`);
this.logger.error(
`Gemini görsel üretim hatası: ${error instanceof Error ? error.message : error}`,
);
throw error;
}
}
@@ -360,7 +406,11 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
private async tryGenerateContentImage(
model: string,
prompt: string,
): Promise<{ buffer: Buffer; mimeType: string; errorReason?: string } | null> {
): Promise<{
buffer: Buffer;
mimeType: string;
errorReason?: string;
} | null> {
const response = await this.client!.models.generateContent({
model,
contents: prompt,
@@ -374,12 +424,18 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
// Safety filter veya boş yanıt kontrolü
if (!candidate?.content?.parts || candidate.content.parts.length === 0) {
const finishReason = candidate?.finishReason || 'UNKNOWN';
this.logger.warn(`⚠️ ${model}: boş yanıt (finishReason: ${finishReason})`);
return { buffer: Buffer.from([]), mimeType: '', errorReason: finishReason };
this.logger.warn(
`⚠️ ${model}: boş yanıt (finishReason: ${finishReason})`,
);
return {
buffer: Buffer.from([]),
mimeType: '',
errorReason: finishReason,
};
}
const imagePart = candidate.content.parts.find(
(p: any) => p.inlineData?.mimeType?.startsWith('image/'),
const imagePart = candidate.content.parts.find((p: any) =>
p.inlineData?.mimeType?.startsWith('image/'),
);
if (imagePart?.inlineData?.data) {
@@ -391,16 +447,26 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
// Text-only response geldi (görsel yok)
const textParts = candidate.content.parts.filter((p: any) => p.text);
if (textParts.length > 0) {
this.logger.warn(`⚠️ ${model}: sadece text döndü, görsel yok. Text: "${textParts[0].text?.substring(0, 100)}"`);
return { buffer: Buffer.from([]), mimeType: '', errorReason: 'TEXT_ONLY' };
this.logger.warn(
`⚠️ ${model}: sadece text döndü, görsel yok. Text: "${textParts[0].text?.substring(0, 100)}"`,
);
return {
buffer: Buffer.from([]),
mimeType: '',
errorReason: 'TEXT_ONLY',
};
}
return { buffer: Buffer.from([]), mimeType: '', errorReason: 'NO_IMAGE_DATA' };
return {
buffer: Buffer.from([]),
mimeType: '',
errorReason: 'NO_IMAGE_DATA',
};
}
/** Basit uyku fonksiyonu — retry aralarında kullanılır */
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
@@ -435,4 +501,3 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
return this.generateImage(prompt, '16:9');
}
}
@@ -9,7 +9,12 @@ import {
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { NotificationsService } from './notifications.service';
/**
@@ -62,10 +67,7 @@ export class NotificationsController {
@Patch(':id/read')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Bildirimi okundu olarak işaretle' })
async markAsRead(
@Req() req: any,
@Param('id') id: string,
) {
async markAsRead(@Req() req: any, @Param('id') id: string) {
const userId = req.user?.id || req.user?.sub;
return this.notificationsService.markAsRead(id, userId);
}
@@ -73,10 +75,7 @@ export class NotificationsController {
@Delete(':id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Bildirimi sil' })
async deleteNotification(
@Req() req: any,
@Param('id') id: string,
) {
async deleteNotification(@Req() req: any, @Param('id') id: string) {
const userId = req.user?.id || req.user?.sub;
return this.notificationsService.deleteNotification(id, userId);
}
@@ -1,4 +1,9 @@
import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common';
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';
@@ -77,11 +82,7 @@ export class NotificationsService {
/**
* Kullanıcının bildirimlerini getir (pagination).
*/
async getUserNotifications(
userId: string,
page = 1,
limit = 20,
) {
async getUserNotifications(userId: string, page = 1, limit = 20) {
const skip = (page - 1) * limit;
const [notifications, total] = await Promise.all([
@@ -162,7 +163,9 @@ export class NotificationsService {
},
});
this.logger.debug(`${result.count} bildirim okundu işaretlendi — User: ${userId}`);
this.logger.debug(
`${result.count} bildirim okundu işaretlendi — User: ${userId}`,
);
return { updated: result.count };
}
+76 -31
View File
@@ -64,7 +64,8 @@ export class CreateProjectDto {
aspectRatio?: AspectRatioDto;
@ApiPropertyOptional({
description: 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
description:
'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
example: 'CINEMATIC',
default: 'CINEMATIC',
})
@@ -73,7 +74,9 @@ export class CreateProjectDto {
@MaxLength(50)
videoStyle?: string;
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
@ApiPropertyOptional({
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
})
@IsString()
@IsOptional()
@MaxLength(200)
@@ -136,14 +139,17 @@ export class UpdateProjectDto {
aspectRatio?: AspectRatioDto;
@ApiPropertyOptional({
description: 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
description:
'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
})
@IsString()
@IsOptional()
@MaxLength(50)
videoStyle?: string;
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
@ApiPropertyOptional({
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
})
@IsString()
@IsOptional()
@MaxLength(200)
@@ -163,19 +169,19 @@ export class UpdateProjectDto {
*/
export class CreateFromTweetDto {
@ApiProperty({
description: 'X/Twitter tweet URL\'si',
description: "X/Twitter tweet URL'si",
example: 'https://x.com/elonmusk/status/1893456789012345678',
})
@IsString()
@IsNotEmpty({ message: 'Tweet URL\'si boş olamaz' })
@IsNotEmpty({ message: "Tweet URL'si boş olamaz" })
@Matches(
/^https?:\/\/(x\.com|twitter\.com)\/([\w]+\/status\/\d+|i\/status\/\d+)/,
{ message: 'Geçerli bir X/Twitter tweet URL\'si girin' },
{ message: "Geçerli bir X/Twitter tweet URL'si girin" },
)
tweetUrl: string;
@ApiPropertyOptional({
description: 'Proje başlığı (boş bırakılırsa tweet\'ten otomatik üretilir)',
description: "Proje başlığı (boş bırakılırsa tweet'ten otomatik üretilir)",
})
@IsString()
@IsOptional()
@@ -190,13 +196,17 @@ export class CreateFromTweetDto {
@IsOptional()
language?: string;
@ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 })
@ApiPropertyOptional({
enum: AspectRatioDto,
default: AspectRatioDto.PORTRAIT_9_16,
})
@IsEnum(AspectRatioDto)
@IsOptional()
aspectRatio?: AspectRatioDto;
@ApiPropertyOptional({
description: 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
description:
'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
default: 'CINEMATIC',
})
@IsString()
@@ -204,13 +214,18 @@ export class CreateFromTweetDto {
@MaxLength(50)
videoStyle?: string;
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
@ApiPropertyOptional({
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
})
@IsString()
@IsOptional()
@MaxLength(200)
cinematicReference?: string;
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
@ApiPropertyOptional({
description: 'Hedef video süresi (saniye)',
default: 60,
})
@IsInt()
@IsOptional()
@Min(15)
@@ -220,19 +235,19 @@ export class CreateFromTweetDto {
export class CreateFromYoutubeDto {
@ApiProperty({
description: 'YouTube Video URL\'si',
description: "YouTube Video URL'si",
example: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
})
@IsString()
@IsNotEmpty({ message: 'YouTube URL\'si boş olamaz' })
@Matches(
/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+$/,
{ message: 'Geçerli bir YouTube URL\'si girin' },
)
@IsNotEmpty({ message: "YouTube URL'si boş olamaz" })
@Matches(/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+$/, {
message: "Geçerli bir YouTube URL'si girin",
})
youtubeUrl: string;
@ApiPropertyOptional({
description: 'Proje başlığı (boş bırakılırsa YouTube\'dan otomatik üretilir)',
description:
"Proje başlığı (boş bırakılırsa YouTube'dan otomatik üretilir)",
})
@IsString()
@IsOptional()
@@ -247,7 +262,10 @@ export class CreateFromYoutubeDto {
@IsOptional()
language?: string;
@ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 })
@ApiPropertyOptional({
enum: AspectRatioDto,
default: AspectRatioDto.PORTRAIT_9_16,
})
@IsEnum(AspectRatioDto)
@IsOptional()
aspectRatio?: AspectRatioDto;
@@ -261,13 +279,18 @@ export class CreateFromYoutubeDto {
@MaxLength(50)
videoStyle?: string;
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
@ApiPropertyOptional({
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
})
@IsString()
@IsOptional()
@MaxLength(200)
cinematicReference?: string;
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
@ApiPropertyOptional({
description: 'Hedef video süresi (saniye)',
default: 60,
})
@IsInt()
@IsOptional()
@Min(15)
@@ -292,7 +315,10 @@ export class CreateFromDocumentDto {
@IsOptional()
language?: string;
@ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 })
@ApiPropertyOptional({
enum: AspectRatioDto,
default: AspectRatioDto.PORTRAIT_9_16,
})
@IsEnum(AspectRatioDto)
@IsOptional()
aspectRatio?: AspectRatioDto;
@@ -306,13 +332,18 @@ export class CreateFromDocumentDto {
@MaxLength(50)
videoStyle?: string;
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
@ApiPropertyOptional({
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
})
@IsString()
@IsOptional()
@MaxLength(200)
cinematicReference?: string;
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
@ApiPropertyOptional({
description: 'Hedef video süresi (saniye)',
default: 60,
})
@IsInt()
@IsOptional()
@Min(15)
@@ -360,13 +391,18 @@ export class CreateFromExtractedTextDto {
@MaxLength(50)
videoStyle?: string;
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
@ApiPropertyOptional({
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
})
@IsString()
@IsOptional()
@MaxLength(200)
cinematicReference?: string;
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
@ApiPropertyOptional({
description: 'Hedef video süresi (saniye)',
default: 60,
})
@IsInt()
@IsOptional()
@Min(15)
@@ -387,7 +423,8 @@ export class CreateFromTextDto {
text: string;
@ApiPropertyOptional({
description: 'Proje başlığı (boş bırakılırsa yapay zeka tarafından üretilir)',
description:
'Proje başlığı (boş bırakılırsa yapay zeka tarafından üretilir)',
})
@IsString()
@IsOptional()
@@ -402,7 +439,10 @@ export class CreateFromTextDto {
@IsOptional()
language?: string;
@ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 })
@ApiPropertyOptional({
enum: AspectRatioDto,
default: AspectRatioDto.PORTRAIT_9_16,
})
@IsEnum(AspectRatioDto)
@IsOptional()
aspectRatio?: AspectRatioDto;
@@ -416,13 +456,18 @@ export class CreateFromTextDto {
@MaxLength(50)
videoStyle?: string;
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
@ApiPropertyOptional({
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
})
@IsString()
@IsOptional()
@MaxLength(200)
cinematicReference?: string;
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
@ApiPropertyOptional({
description: 'Hedef video süresi (saniye)',
default: 60,
})
@IsInt()
@IsOptional()
@Min(15)
+79 -28
View File
@@ -138,7 +138,10 @@ export class ProjectsController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'AI ile senaryo üret (Gemini)' })
@ApiResponse({ status: 200, description: 'Senaryo üretildi ve kaydedildi' })
async generateScript(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
async generateScript(
@Param('id', ParseUUIDPipe) id: string,
@Req() req: any,
) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Senaryo üretimi başlatılıyor: ${id}`);
return this.projectsService.generateScript(userId, id);
@@ -167,10 +170,7 @@ export class ProjectsController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Aktif render işlemini iptal et' })
@ApiResponse({ status: 200, description: 'Render işlemi iptal edildi' })
async cancelRender(
@Param('id', ParseUUIDPipe) id: string,
@Req() req: any,
) {
async cancelRender(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Render iptal isteği: ${id}`);
return this.projectsService.cancelRenderJob(userId, id);
@@ -182,9 +182,15 @@ export class ProjectsController {
*/
@Post('from-tweet')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'X/Twitter tweet\'ten proje oluştur' })
@ApiResponse({ status: 201, description: 'Tweet\'ten proje oluşturuldu ve senaryo üretildi' })
@ApiResponse({ status: 400, description: 'Geçersiz tweet URL\'si veya tweet bulunamadı' })
@ApiOperation({ summary: "X/Twitter tweet'ten proje oluştur" })
@ApiResponse({
status: 201,
description: "Tweet'ten proje oluşturuldu ve senaryo üretildi",
})
@ApiResponse({
status: 400,
description: "Geçersiz tweet URL'si veya tweet bulunamadı",
})
async createFromTweet(@Body() dto: CreateFromTweetDto, @Req() req: any) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Tweet'ten proje oluşturuluyor: ${dto.tweetUrl}`);
@@ -197,8 +203,14 @@ export class ProjectsController {
@Post('from-youtube')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'YouTube videosundan proje oluştur' })
@ApiResponse({ status: 201, description: 'YouTube videosundan proje oluşturuldu ve senaryo üretildi' })
@ApiResponse({ status: 400, description: 'Geçersiz YouTube URL\'si veya video bulunamadı' })
@ApiResponse({
status: 201,
description: 'YouTube videosundan proje oluşturuldu ve senaryo üretildi',
})
@ApiResponse({
status: 400,
description: "Geçersiz YouTube URL'si veya video bulunamadı",
})
async createFromYoutube(@Body() dto: CreateFromYoutubeDto, @Req() req: any) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`YouTube'dan proje oluşturuluyor: ${dto.youtubeUrl}`);
@@ -211,7 +223,10 @@ export class ProjectsController {
@Post('from-text')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Serbest metinden proje oluştur' })
@ApiResponse({ status: 201, description: 'Metinden proje oluşturuldu ve senaryo üretildi' })
@ApiResponse({
status: 201,
description: 'Metinden proje oluşturuldu ve senaryo üretildi',
})
async createFromText(@Body() dto: CreateFromTextDto, @Req() req: any) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Serbest metinden proje oluşturuluyor...`);
@@ -226,7 +241,10 @@ export class ProjectsController {
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Dosyadan/Dokümandan proje oluştur' })
@ApiResponse({ status: 201, description: 'Belgeden proje oluşturuldu ve senaryo üretildi' })
@ApiResponse({
status: 201,
description: 'Belgeden proje oluşturuldu ve senaryo üretildi',
})
async createFromDocument(
@UploadedFile() file: Express.Multer.File,
@Body() dto: CreateFromDocumentDto,
@@ -248,12 +266,14 @@ export class ProjectsController {
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Dosyadan metin çıkar ve konu önerileri al' })
@ApiResponse({ status: 200, description: 'Metin ve konular başarıyla çıkarıldı' })
async extractDocumentTopics(
@UploadedFile() file: Express.Multer.File,
@Req() req: any,
) {
this.logger.log(`Dosyadan metin ve konular çıkarılıyor: ${file?.originalname}`);
@ApiResponse({
status: 200,
description: 'Metin ve konular başarıyla çıkarıldı',
})
async extractDocumentTopics(@UploadedFile() file: Express.Multer.File) {
this.logger.log(
`Dosyadan metin ve konular çıkarılıyor: ${file?.originalname}`,
);
if (!file) {
throw new BadRequestException('Dosya yüklenmedi');
}
@@ -266,10 +286,18 @@ export class ProjectsController {
@Post('document-from-topic')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Seçilen konu ve metin ile proje oluştur' })
@ApiResponse({ status: 201, description: 'Seçilen konu baz alınarak proje oluşturuldu' })
async createFromTopic(@Body() dto: CreateFromExtractedTextDto, @Req() req: any) {
@ApiResponse({
status: 201,
description: 'Seçilen konu baz alınarak proje oluşturuldu',
})
async createFromTopic(
@Body() dto: CreateFromExtractedTextDto,
@Req() req: any,
) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Metin ve konu üzerinden proje oluşturuluyor. Konu: ${dto.topic}`);
this.logger.log(
`Metin ve konu üzerinden proje oluşturuluyor. Konu: ${dto.topic}`,
);
return this.projectsService.createFromExtractedText(userId, dto);
}
@@ -282,7 +310,13 @@ export class ProjectsController {
async updateScene(
@Param('id', ParseUUIDPipe) id: string,
@Param('sceneId', ParseUUIDPipe) sceneId: string,
@Body() body: { narrationText?: string; visualPrompt?: string; subtitleText?: string; duration?: number },
@Body()
body: {
narrationText?: string;
visualPrompt?: string;
subtitleText?: string;
duration?: number;
},
@Req() req: any,
) {
const userId = req.user?.id || req.user?.sub;
@@ -326,7 +360,10 @@ export class ProjectsController {
@Post(':id/generate-seo-titles')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'AI ile 5 yeni SEO başlığı üret' })
@ApiResponse({ status: 200, description: 'SEO başlıkları başarıyla üretildi' })
@ApiResponse({
status: 200,
description: 'SEO başlıkları başarıyla üretildi',
})
async generateSeoTitles(
@Param('id', ParseUUIDPipe) id: string,
@Req() req: any,
@@ -361,15 +398,22 @@ export class ProjectsController {
*/
@Post(':id/translate')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Projeyi farklı bir dile çevir ve kopyasını oluştur' })
@ApiResponse({ status: 201, description: 'Proje çevirisi başarıyla tamamlandı' })
@ApiOperation({
summary: 'Projeyi farklı bir dile çevir ve kopyasını oluştur',
})
@ApiResponse({
status: 201,
description: 'Proje çevirisi başarıyla tamamlandı',
})
async translateProject(
@Param('id', ParseUUIDPipe) id: string,
@Body('targetLanguage') targetLanguage: string,
@Req() req: any,
) {
if (!targetLanguage) {
throw new BadRequestException('Hedef dil (targetLanguage) belirtilmelidir.');
throw new BadRequestException(
'Hedef dil (targetLanguage) belirtilmelidir.',
);
}
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Proje çevirisi isteniyor: ${id} -> ${targetLanguage}`);
@@ -392,7 +436,12 @@ export class ProjectsController {
) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Sahne görseli üretiliyor: ${sceneId} (proje: ${id})`);
return this.projectsService.generateSceneImage(userId, id, sceneId, body?.customPrompt);
return this.projectsService.generateSceneImage(
userId,
id,
sceneId,
body?.customPrompt,
);
}
/**
@@ -408,7 +457,9 @@ export class ProjectsController {
@Req() req: any,
) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Sahne görseli upscale ediliyor: ${sceneId} (proje: ${id})`);
this.logger.log(
`Sahne görseli upscale ediliyor: ${sceneId} (proje: ${id})`,
);
return this.projectsService.upscaleSceneImage(userId, id, sceneId);
}
}
+9 -1
View File
@@ -10,7 +10,15 @@ import { ExtractorModule } from '../extractor/extractor.module';
import { BillingModule } from '../billing/billing.module';
@Module({
imports: [VideoAiModule, VideoQueueModule, XTwitterModule, GeminiModule, StorageModule, ExtractorModule, BillingModule],
imports: [
VideoAiModule,
VideoQueueModule,
XTwitterModule,
GeminiModule,
StorageModule,
ExtractorModule,
BillingModule,
],
controllers: [ProjectsController],
providers: [ProjectsService],
exports: [ProjectsService],
+350 -133
View File
@@ -7,14 +7,22 @@ import {
import { TransitionType, AspectRatio } from '@prisma/client';
import { PrismaService } from '../../database/prisma.service';
import { VideoAiService } from '../video-ai/video-ai.service';
import { VideoQueueModule } from '../video-queue/video-queue.module';
import { VideoGenerationProducer } from '../video-queue/video-generation.producer';
import { XTwitterService } from '../x-twitter/x-twitter.service';
import { GeminiService } from '../gemini/gemini.service';
import { StorageService } from '../storage/storage.service';
import { ExtractorService } from '../extractor/extractor.service';
import { BillingService } from '../billing/billing.service';
import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto, CreateFromExtractedTextDto, CreateFromTextDto } from './dto/project.dto';
import {
CreateProjectDto,
UpdateProjectDto,
CreateFromTweetDto,
CreateFromYoutubeDto,
CreateFromDocumentDto,
CreateFromExtractedTextDto,
CreateFromTextDto,
} from './dto/project.dto';
import sharp from 'sharp';
import * as fs from 'fs/promises';
import * as os from 'os';
@@ -170,7 +178,9 @@ export class ProjectsService {
...(dto.language && { language: dto.language }),
...(dto.aspectRatio && { aspectRatio: dto.aspectRatio }),
...(dto.videoStyle && { videoStyle: dto.videoStyle }),
...(dto.cinematicReference !== undefined && { cinematicReference: dto.cinematicReference }),
...(dto.cinematicReference !== undefined && {
cinematicReference: dto.cinematicReference,
}),
...(dto.targetDuration && { targetDuration: dto.targetDuration }),
} as any,
});
@@ -180,48 +190,65 @@ export class ProjectsService {
// Stil ya da görsel parametre değiştiyse ve senaryo/sahneler varsa otomatik rewrite başlat
const styleChanged =
(dto.videoStyle && dto.videoStyle !== project.videoStyle) ||
(dto.cinematicReference !== undefined && dto.cinematicReference !== (project as any).cinematicReference) ||
(dto.aspectRatio && dto.aspectRatio !== project.aspectRatio);
(dto.cinematicReference !== undefined &&
dto.cinematicReference !== (project as any).cinematicReference) ||
(dto.aspectRatio &&
String(dto.aspectRatio) !== String(project.aspectRatio));
if (styleChanged && project.scenes && project.scenes.length > 0) {
this.logger.log(`Stil değişikliği tespit edildi (${projectId}), visual promptlar arka planda yenileniyor...`);
this.rewriteVisualPromptsBackground(userId, projectId, updated).catch((err) => {
this.logger.error(`Arka plan rewriteVisualPrompts hatası: ${err}`);
});
this.logger.log(
`Stil değişikliği tespit edildi (${projectId}), visual promptlar arka planda yenileniyor...`,
);
this.rewriteVisualPromptsBackground(userId, projectId, updated).catch(
(err) => {
this.logger.error(`Arka plan rewriteVisualPrompts hatası: ${err}`);
},
);
}
return updated;
}
// Arka planda tüm promptları güncelleyen metod
private async rewriteVisualPromptsBackground(userId: string, projectId: string, updatedProject: any) {
private async rewriteVisualPromptsBackground(
userId: string,
projectId: string,
updatedProject: any,
) {
const targetProject = await this.findOne(userId, projectId);
if (!targetProject || !targetProject.scenes || targetProject.scenes.length === 0) return;
if (
!targetProject ||
!targetProject.scenes ||
targetProject.scenes.length === 0
)
return;
try {
// Sahnelerin visual prompt'larını yenile (ID'leri ve narration'ları gönderiyoruz)
const mappedScenes = targetProject.scenes.map(s => ({
const mappedScenes = targetProject.scenes.map((s) => ({
id: s.id,
order: s.order,
narrationText: s.narrationText,
visualPrompt: s.visualPrompt
visualPrompt: s.visualPrompt,
}));
const rewritten = await this.videoAiService.rewriteAllVisualPrompts(
mappedScenes,
updatedProject.videoStyle,
(updatedProject as any).cinematicReference,
updatedProject.aspectRatio
updatedProject.cinematicReference,
updatedProject.aspectRatio,
);
// Veritabanına kaydet
for (const newScene of rewritten) {
await this.db.scene.update({
where: { id: newScene.id },
data: { visualPrompt: newScene.visualPrompt }
data: { visualPrompt: newScene.visualPrompt },
});
}
this.logger.log(`Görsel promptlar ${projectId} için başarıyla yenilendi.`);
this.logger.log(
`Görsel promptlar ${projectId} için başarıyla yenilendi.`,
);
} catch (err) {
this.logger.error(`Visual promptları yenileme işlemi başarısız: ${err}`);
}
@@ -297,15 +324,32 @@ export class ProjectsService {
errorMessage: null,
scriptVersion: { increment: 1 },
// AI'ın en güçlü SEO başlığını proje başlığı yap
title: (scriptJson.seo?.title || scriptJson.metadata?.title || project.title).substring(0, 190),
title: (
scriptJson.seo?.title ||
scriptJson.metadata?.title ||
project.title
).substring(0, 190),
// SEO & Social metadata (skill-enhanced)
seoTitle: (scriptJson.seo?.title || scriptJson.metadata?.title || '').substring(0, 190),
seoDescription: (scriptJson.seo?.description || scriptJson.metadata?.description || '').substring(0, 490),
seoTitleAlts: (scriptJson.seoTitleAlternatives || []).map((t: string) => t.substring(0, 190)),
seoScore: typeof scriptJson.seoScore === 'number' ? Math.min(100, Math.max(0, scriptJson.seoScore)) : null,
seoTitle: (
scriptJson.seo?.title ||
scriptJson.metadata?.title ||
''
).substring(0, 190),
seoDescription: (
scriptJson.seo?.description ||
scriptJson.metadata?.description ||
''
).substring(0, 490),
seoTitleAlts: (scriptJson.seoTitleAlternatives || []).map(
(t: string) => t.substring(0, 190),
),
seoScore:
typeof scriptJson.seoScore === 'number'
? Math.min(100, Math.max(0, scriptJson.seoScore))
: null,
seoKeywords: scriptJson.seo?.keywords || [],
seoSchemaJson: scriptJson.seo?.schemaMarkup as object || null,
socialContent: scriptJson.socialContent as object || null,
seoSchemaJson: (scriptJson.seo?.schemaMarkup as object) || null,
socialContent: (scriptJson.socialContent as object) || null,
},
include: {
scenes: { orderBy: { order: 'asc' } },
@@ -387,8 +431,11 @@ export class ProjectsService {
videoStyle: project.videoStyle,
targetDuration: project.targetDuration,
scenes: project.scenes.map((s) => {
const thumbnail = s.mediaAssets?.find(m => m.type === 'THUMBNAIL');
const imagePath = thumbnail && thumbnail.s3Key ? this.storageService.getAbsolutePath(thumbnail.s3Key) : undefined;
const thumbnail = s.mediaAssets?.find((m) => m.type === 'THUMBNAIL');
const imagePath =
thumbnail && thumbnail.s3Key
? this.storageService.getAbsolutePath(thumbnail.s3Key)
: undefined;
return {
id: s.id,
order: s.order,
@@ -472,8 +519,10 @@ export class ProjectsService {
const avgProcessingTime =
completedWithTime.length > 0
? Math.round(
completedWithTime.reduce((sum, j) => sum + (j.processingTimeMs ?? 0), 0) /
completedWithTime.length,
completedWithTime.reduce(
(sum, j) => sum + (j.processingTimeMs ?? 0),
0,
) / completedWithTime.length,
)
: null;
@@ -499,19 +548,21 @@ export class ProjectsService {
project: j.project,
logs: j.logs,
})),
recentJobs: [...completed, ...failed, ...cancelled].slice(0, 20).map((j) => ({
id: j.id,
status: j.status,
currentStage: j.currentStage,
attemptNumber: j.attemptNumber,
processingTimeMs: j.processingTimeMs,
errorMessage: j.errorMessage,
finalVideoUrl: j.finalVideoUrl,
createdAt: j.createdAt,
startedAt: j.startedAt,
completedAt: j.completedAt,
project: j.project,
})),
recentJobs: [...completed, ...failed, ...cancelled]
.slice(0, 20)
.map((j) => ({
id: j.id,
status: j.status,
currentStage: j.currentStage,
attemptNumber: j.attemptNumber,
processingTimeMs: j.processingTimeMs,
errorMessage: j.errorMessage,
finalVideoUrl: j.finalVideoUrl,
createdAt: j.createdAt,
startedAt: j.startedAt,
completedAt: j.completedAt,
project: j.project,
})),
};
}
@@ -533,7 +584,9 @@ export class ProjectsService {
}
if (project.renderJobs.length === 0) {
throw new BadRequestException('İptal edilecek aktif bir render işlemi bulunamadı');
throw new BadRequestException(
'İptal edilecek aktif bir render işlemi bulunamadı',
);
}
// Aktif olan ilk render job'u al
@@ -542,7 +595,10 @@ export class ProjectsService {
// Status'ü güncelle
await this.db.renderJob.update({
where: { id: activeJob.id },
data: { status: 'CANCELLED', errorMessage: 'Kullanıcı tarafından iptal edildi' },
data: {
status: 'CANCELLED',
errorMessage: 'Kullanıcı tarafından iptal edildi',
},
});
// Projeyi tekrar DRAFT durumuna döndür (senaryosu hâlâ mevcut)
@@ -551,7 +607,9 @@ export class ProjectsService {
data: { status: 'DRAFT' },
});
this.logger.log(`Render iptal edildi: Project ${projectId}, RenderJob ${activeJob.id}`);
this.logger.log(
`Render iptal edildi: Project ${projectId}, RenderJob ${activeJob.id}`,
);
return {
message: 'Render başarıyla iptal edildi',
@@ -666,8 +724,16 @@ export class ProjectsService {
status: 'DRAFT',
errorMessage: null,
scriptVersion: 1,
seoTitle: (scriptJson.seo?.title || scriptJson.metadata?.title || '').substring(0, 190),
seoDescription: (scriptJson.seo?.description || scriptJson.metadata?.description || '').substring(0, 490),
seoTitle: (
scriptJson.seo?.title ||
scriptJson.metadata?.title ||
''
).substring(0, 190),
seoDescription: (
scriptJson.seo?.description ||
scriptJson.metadata?.description ||
''
).substring(0, 490),
seoSchemaJson: (scriptJson.seo?.schemaMarkup as object) || null,
socialContent: (scriptJson.socialContent as object) || null,
},
@@ -708,10 +774,14 @@ export class ProjectsService {
* YouTube URL'sinden proje oluşturur. Extractor servisi kullanılarak video transkripti çekilir.
*/
async createFromYoutube(userId: string, dto: CreateFromYoutubeDto) {
this.logger.log(`YouTube videosundan proje oluşturuluyor: ${dto.youtubeUrl}`);
this.logger.log(
`YouTube videosundan proje oluşturuluyor: ${dto.youtubeUrl}`,
);
// 1. YouTube url'den MarkItDown yardımı ile metni çek
const extractedText = await this.extractorService.extractFromUrl(dto.youtubeUrl);
const extractedText = await this.extractorService.extractFromUrl(
dto.youtubeUrl,
);
// 2. Proje başlığı veya varsayılan prompt'u oluştur
const title = dto.title || 'YouTube Shorts Üretimi';
@@ -734,7 +804,9 @@ export class ProjectsService {
},
});
this.logger.log(`YouTube projesi oluşturuldu, senaryo üretiliyor: ${project.id}`);
this.logger.log(
`YouTube projesi oluşturuldu, senaryo üretiliyor: ${project.id}`,
);
try {
const scriptJson = await this.videoAiService.generateVideoScript({
@@ -774,14 +846,19 @@ export class ProjectsService {
},
});
this.logger.log(`YouTube senaryo tamamlandı: ${project.id}${scriptJson.scenes.length} sahne`);
this.logger.log(
`YouTube senaryo tamamlandı: ${project.id}${scriptJson.scenes.length} sahne`,
);
return updatedProject;
} catch (error) {
await this.db.project.update({
where: { id: project.id },
data: {
status: 'DRAFT',
errorMessage: error instanceof Error ? error.message : 'YouTube senaryo üretimi sırasında hata',
errorMessage:
error instanceof Error
? error.message
: 'YouTube senaryo üretimi sırasında hata',
},
});
throw error;
@@ -792,7 +869,9 @@ export class ProjectsService {
* PDF, Word vb. dokümandan metin çıkarır ve konu önerileri üretir.
*/
async extractDocumentTopics(file: Express.Multer.File) {
this.logger.log(`Belgeden konu önerileri çıkarılıyor: ${file.originalname}`);
this.logger.log(
`Belgeden konu önerileri çıkarılıyor: ${file.originalname}`,
);
let tempFilePath: string | null = null;
let extractedText = '';
@@ -801,42 +880,60 @@ export class ProjectsService {
if (file.path) {
tempFilePath = file.path;
} else if (file.buffer) {
tempFilePath = path.join(os.tmpdir(), `${Date.now()}-${file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`);
tempFilePath = path.join(
os.tmpdir(),
`${Date.now()}-${file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`,
);
await fs.writeFile(tempFilePath, file.buffer);
} else {
throw new Error("Dosya içeriği okunamadı (Buffer veya Path yok).");
throw new Error('Dosya içeriği okunamadı (Buffer veya Path yok).');
}
extractedText = await this.extractorService.extractFromFile(tempFilePath, file.originalname, file.mimetype);
extractedText = await this.extractorService.extractFromFile(
tempFilePath,
file.originalname,
file.mimetype,
);
} finally {
if (tempFilePath && !file.path) {
await fs.unlink(tempFilePath).catch(e => this.logger.warn(`Temp dosya silinemedi: ${e.message}`));
await fs
.unlink(tempFilePath)
.catch((e) =>
this.logger.warn(`Temp dosya silinemedi: ${e.message}`),
);
}
}
if (!extractedText || extractedText.trim().length === 0) {
throw new BadRequestException("Belgeden okunabilir metin çıkarılamadı.");
throw new BadRequestException('Belgeden okunabilir metin çıkarılamadı.');
}
// Kısa metinse doğrudan 1 konu öner (kendi başlığı gibi), uzunsa çoklu konu
let topics: string[] = [];
if (extractedText.length < 5000) {
topics = [file.originalname.split('.')[0] || "Belge Özeti"];
topics = [file.originalname.split('.')[0] || 'Belge Özeti'];
} else {
topics = await this.videoAiService.suggestDocumentTopics(extractedText, 4);
topics = await this.videoAiService.suggestDocumentTopics(
extractedText,
4,
);
}
return {
text: extractedText,
topics,
originalFilename: file.originalname
originalFilename: file.originalname,
};
}
/**
* PDF, Word vb. dokümandan proje oluşturur.
*/
async createFromDocument(userId: string, file: Express.Multer.File, dto: CreateFromDocumentDto) {
async createFromDocument(
userId: string,
file: Express.Multer.File,
dto: CreateFromDocumentDto,
) {
this.logger.log(`Belgeden proje oluşturuluyor: ${file.originalname}`);
let tempFilePath: string | null = null;
@@ -846,16 +943,27 @@ export class ProjectsService {
if (file.path) {
tempFilePath = file.path;
} else if (file.buffer) {
tempFilePath = path.join(os.tmpdir(), `${Date.now()}-${file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`);
tempFilePath = path.join(
os.tmpdir(),
`${Date.now()}-${file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`,
);
await fs.writeFile(tempFilePath, file.buffer);
} else {
throw new Error("Dosya içeriği okunamadı (Buffer veya Path yok).");
throw new Error('Dosya içeriği okunamadı (Buffer veya Path yok).');
}
extractedText = await this.extractorService.extractFromFile(tempFilePath, file.originalname, file.mimetype);
extractedText = await this.extractorService.extractFromFile(
tempFilePath,
file.originalname,
file.mimetype,
);
} finally {
if (tempFilePath && !file.path) {
await fs.unlink(tempFilePath).catch(e => this.logger.warn(`Temp dosya silinemedi: ${e.message}`));
await fs
.unlink(tempFilePath)
.catch((e) =>
this.logger.warn(`Temp dosya silinemedi: ${e.message}`),
);
}
}
@@ -919,7 +1027,10 @@ export class ProjectsService {
where: { id: project.id },
data: {
status: 'DRAFT',
errorMessage: error instanceof Error ? error.message : 'Belge senaryo üretimi sırasında hata',
errorMessage:
error instanceof Error
? error.message
: 'Belge senaryo üretimi sırasında hata',
},
});
throw error;
@@ -929,8 +1040,13 @@ export class ProjectsService {
/**
* Çıkarılmış metin ve kullanıcının seçtiği bir "topic" üzerinden proje oluşturur.
*/
async createFromExtractedText(userId: string, dto: CreateFromExtractedTextDto) {
this.logger.log(`Metin ve konu üzerinden proje oluşturuluyor: ${dto.topic}`);
async createFromExtractedText(
userId: string,
dto: CreateFromExtractedTextDto,
) {
this.logger.log(
`Metin ve konu üzerinden proje oluşturuluyor: ${dto.topic}`,
);
const title = dto.topic;
// Tam prompt metni (AI'a gönderilecek)
@@ -941,10 +1057,13 @@ export class ProjectsService {
const project = await this.db.project.create({
data: {
title,
description: dto.originalFilename ? `Belgeden üretildi: ${dto.originalFilename} (Konu: ${dto.topic})` : `Metinden üretildi (Konu: ${dto.topic})`,
description: dto.originalFilename
? `Belgeden üretildi: ${dto.originalFilename} (Konu: ${dto.topic})`
: `Metinden üretildi (Konu: ${dto.topic})`,
prompt: shortDbPrompt,
language: dto.language || 'tr',
aspectRatio: (dto.aspectRatio as AspectRatio) || AspectRatio.PORTRAIT_9_16,
aspectRatio:
(dto.aspectRatio as AspectRatio) || AspectRatio.PORTRAIT_9_16,
videoStyle: dto.videoStyle || 'CINEMATIC',
cinematicReference: dto.cinematicReference,
targetDuration: dto.targetDuration || 60,
@@ -995,15 +1114,16 @@ export class ProjectsService {
where: { id: project.id },
data: {
status: 'DRAFT',
errorMessage: error instanceof Error ? error.message : 'Konu bazlı senaryo üretimi sırasında hata',
errorMessage:
error instanceof Error
? error.message
: 'Konu bazlı senaryo üretimi sırasında hata',
},
});
throw error;
}
}
/**
* Kullanıcının doğrudan yazdığı serbest metinden (fikir, taslak, hikaye) proje oluşturur.
*/
@@ -1023,7 +1143,8 @@ export class ProjectsService {
description: `Serbest metin üzerinden üretildi.`,
prompt: shortDbPrompt,
language: dto.language || 'tr',
aspectRatio: (dto.aspectRatio as AspectRatio) || AspectRatio.PORTRAIT_9_16,
aspectRatio:
(dto.aspectRatio as AspectRatio) || AspectRatio.PORTRAIT_9_16,
videoStyle: dto.videoStyle || 'CINEMATIC',
cinematicReference: dto.cinematicReference,
targetDuration: dto.targetDuration || 60,
@@ -1074,7 +1195,10 @@ export class ProjectsService {
where: { id: project.id },
data: {
status: 'DRAFT',
errorMessage: error instanceof Error ? error.message : 'Serbest metin senaryo üretimi sırasında hata',
errorMessage:
error instanceof Error
? error.message
: 'Serbest metin senaryo üretimi sırasında hata',
},
});
throw error;
@@ -1088,7 +1212,12 @@ export class ProjectsService {
userId: string,
projectId: string,
sceneId: string,
data: { narrationText?: string; visualPrompt?: string; subtitleText?: string; duration?: number },
data: {
narrationText?: string;
visualPrompt?: string;
subtitleText?: string;
duration?: number;
},
) {
// Proje sahipliğini doğrula
const project = await this.findOne(userId, projectId);
@@ -1109,9 +1238,15 @@ export class ProjectsService {
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.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 },
@@ -1146,7 +1281,10 @@ export class ProjectsService {
// Stil DNA bilgilerini prompt'a dahil et
const cinematicRef = (project as any).cinematicReference || '';
const styleDNA = this.videoAiService.getStyleDNA(project.videoStyle, cinematicRef || undefined);
const styleDNA = this.videoAiService.getStyleDNA(
project.videoStyle,
cinematicRef || undefined,
);
const contextPrompt = `
Bir video senaryosunun ${scene.order}. sahnesini yeniden üret.
@@ -1202,11 +1340,18 @@ Sadece bu tek sahneyi üret. JSON formatında:
include: { mediaAssets: true },
});
this.logger.log(`Sahne yeniden üretildi: ${sceneId} (proje: ${projectId}) — Stil: ${project.videoStyle}${cinematicRef ? ', Ref: ' + cinematicRef : ''}`);
this.logger.log(
`Sahne yeniden üretildi: ${sceneId} (proje: ${projectId}) — Stil: ${project.videoStyle}${cinematicRef ? ', Ref: ' + cinematicRef : ''}`,
);
return updated;
}
async generateSceneImage(userId: string, projectId: string, sceneId: string, customPrompt?: string) {
async generateSceneImage(
userId: string,
projectId: string,
sceneId: string,
customPrompt?: string,
) {
const project = await this.findOne(userId, projectId);
const scene = project.scenes.find((s) => s.id === sceneId);
if (!scene) {
@@ -1215,16 +1360,20 @@ Sadece bu tek sahneyi üret. JSON formatında:
if (customPrompt && customPrompt !== scene.visualPrompt) {
// First update the prompt
await this.updateScene(userId, projectId, sceneId, { visualPrompt: customPrompt });
await this.updateScene(userId, projectId, sceneId, {
visualPrompt: customPrompt,
});
scene.visualPrompt = customPrompt;
}
this.logger.log(`Sahne görseli üretiliyor: ${sceneId} (proje: ${projectId})`);
this.logger.log(
`Sahne görseli üretiliyor: ${sceneId} (proje: ${projectId})`,
);
const aspectRatioMap: Record<string, '16:9' | '9:16' | '1:1'> = {
'PORTRAIT_9_16': '9:16',
'LANDSCAPE_16_9': '16:9',
'SQUARE_1_1': '1:1',
PORTRAIT_9_16: '9:16',
LANDSCAPE_16_9: '16:9',
SQUARE_1_1: '1:1',
};
const mappedRatio = aspectRatioMap[project.aspectRatio] || '9:16';
@@ -1239,7 +1388,9 @@ Sadece bu tek sahneyi üret. JSON formatında:
);
if (!imageResult) {
this.logger.warn(`⚠️ Orijinal prompt ile görsel üretilemedi. Güvenlik filtresine takılmış olabilir. Prompt'u anonimleştirip tekrar deniyoruz...`);
this.logger.warn(
`⚠️ Orijinal prompt ile görsel üretilemedi. Güvenlik filtresine takılmış olabilir. Prompt'u anonimleştirip tekrar deniyoruz...`,
);
try {
const rewritePrompt = `Rewrite the following image generation prompt. Remove the names of any real-world public figures (e.g., Elon Musk, politicians, celebrities, etc.) and replace their names with extremely detailed physical descriptions of their facial features, body type, age, hair style, and clothing, so the generated image will still look EXACTLY like them. Keep the rest of the prompt completely intact. Return ONLY the rewritten prompt without any conversational text or quotes.\n\nPrompt: ${scene.visualPrompt}`;
@@ -1249,23 +1400,35 @@ Sadece bu tek sahneyi üret. JSON formatında:
this.logger.log(`🔄 Anonimleştirilmiş Prompt: ${rewrittenPrompt}`);
const illustrationPrompt = `A highly detailed, premium digital illustration of the following scene. Make it an obvious illustration or artwork: ${rewrittenPrompt}. ${styleLabel}`;
imageResult = await this.geminiService.generateImage(illustrationPrompt, mappedRatio, true);
imageResult = await this.geminiService.generateImage(
illustrationPrompt,
mappedRatio,
true,
);
} catch (err: any) {
this.logger.error(`Anonimleştirilmiş illüstrasyon üretimi başarısız: ${err.message}`);
this.logger.error(
`Anonimleştirilmiş illüstrasyon üretimi başarısız: ${err.message}`,
);
}
}
if (!imageResult) {
throw new BadRequestException('Görsel üretilemedi, güvenlik filtreleri veya servis hatası nedeniyle işlem başarısız oldu.');
throw new BadRequestException(
'Görsel üretilemedi, güvenlik filtreleri veya servis hatası nedeniyle işlem başarısız oldu.',
);
}
// Storage'a kaydet
const key = this.storageService.getSceneImageKey(projectId, scene.order);
await this.storageService.upload(key, imageResult.buffer, imageResult.mimeType);
await this.storageService.upload(
key,
imageResult.buffer,
imageResult.mimeType,
);
const url = this.storageService.getPublicUrl(key);
// MediaRecord oluştur veya güncelle
let mediaAsset = scene.mediaAssets.find(m => m.type === 'THUMBNAIL');
const mediaAsset = scene.mediaAssets.find((m) => m.type === 'THUMBNAIL');
let mediaId = mediaAsset?.id;
if (!mediaId) {
const media = await this.db.mediaAsset.create({
@@ -1301,16 +1464,22 @@ Sadece bu tek sahneyi üret. JSON formatında:
throw new NotFoundException('Sahne bulunamadı');
}
const mediaAsset = scene.mediaAssets.find(m => m.type === 'THUMBNAIL');
let mediaId = mediaAsset?.id;
const mediaAsset = scene.mediaAssets.find((m) => m.type === 'THUMBNAIL');
const mediaId = mediaAsset?.id;
if (!mediaId) {
throw new BadRequestException('Bu sahne için upscaled edilecek görsel bulunamadı.');
throw new BadRequestException(
'Bu sahne için upscaled edilecek görsel bulunamadı.',
);
}
const media = await this.db.mediaAsset.findUnique({ where: { id: mediaId } });
const media = await this.db.mediaAsset.findUnique({
where: { id: mediaId },
});
if (!media) throw new NotFoundException('Medya kaydı bulunamadı');
this.logger.log(`Sahne görseli upscaled ediliyor (Sharp ile simülasyon): ${sceneId}`);
this.logger.log(
`Sahne görseli upscaled ediliyor (Sharp ile simülasyon): ${sceneId}`,
);
const key = this.storageService.getSceneImageKey(projectId, scene.order);
const absPath = this.storageService.getAbsolutePath(key);
@@ -1359,8 +1528,8 @@ Sadece bu tek sahneyi üret. JSON formatında:
const media = await this.db.mediaAsset.findFirst({
where: {
id: mediaId,
projectId: project.id
}
projectId: project.id,
},
});
if (!media) {
@@ -1374,7 +1543,7 @@ Sadece bu tek sahneyi üret. JSON formatında:
}
await this.db.mediaAsset.delete({
where: { id: mediaId }
where: { id: mediaId },
});
this.logger.log(`Medya silindi: ${mediaId} (Proje: ${projectId})`);
@@ -1388,12 +1557,21 @@ Sadece bu tek sahneyi üret. JSON formatında:
/**
* Çeviri İşlemi
*/
async translateProject(userId: string, projectId: string, targetLanguage: string) {
async translateProject(
userId: string,
projectId: string,
targetLanguage: string,
) {
const project = await this.findOne(userId, projectId);
if (!project) throw new NotFoundException('Proje bulunamadı');
// 1 Kredi Kesintisi
await this.billingService.spendCredits(userId, 1, projectId, `Proje çevirisi (${targetLanguage})`);
await this.billingService.spendCredits(
userId,
1,
projectId,
`Proje çevirisi (${targetLanguage})`,
);
const prompt = `
Translate the following video project to the language: ${targetLanguage}.
@@ -1401,19 +1579,23 @@ Keep all structural metadata intact. For 'visualPrompt', it MUST strictly remain
However, if the original visualPrompt contains any texts that are meant to be shown on screen (e.g., text on signs, labels, captions, or any on-screen text overlays), you MUST translate those specific on-screen text elements to ${targetLanguage} within the English visualPrompt. The rest of the visualPrompt should be kept in English.
Input Data:
${JSON.stringify({
title: project.title,
description: project.description,
seoTitle: project.seoTitle,
seoDescription: project.seoDescription,
socialContent: project.socialContent,
scenes: project.scenes.map(s => ({
id: s.id,
narrationText: s.narrationText,
subtitleText: s.subtitleText,
visualPrompt: s.visualPrompt
}))
}, null, 2)}`;
${JSON.stringify(
{
title: project.title,
description: project.description,
seoTitle: project.seoTitle,
seoDescription: project.seoDescription,
socialContent: project.socialContent,
scenes: project.scenes.map((s) => ({
id: s.id,
narrationText: s.narrationText,
subtitleText: s.subtitleText,
visualPrompt: s.visualPrompt,
})),
},
null,
2,
)}`;
const schemaStr = `{
"title": "string",
@@ -1438,15 +1620,26 @@ ${JSON.stringify({
let translatedData;
try {
const response = await this.geminiService.generateJSON(prompt, schemaStr, {
temperature: 0.3,
});
const response = await this.geminiService.generateJSON(
prompt,
schemaStr,
{
temperature: 0.3,
},
);
translatedData = response.data;
} catch (err) {
// Hata olursa krediyi iade et
await this.billingService.grantCredits(userId, 1, 'refund', `Proje çeviri hatası iadesi`);
await this.billingService.grantCredits(
userId,
1,
'refund',
`Proje çeviri hatası iadesi`,
);
this.logger.error(`Çeviri hatası: ${err.message}`);
throw new BadRequestException('Çeviri işlemi sırasında AI servisinde bir hata oluştu.');
throw new BadRequestException(
'Çeviri işlemi sırasında AI servisinde bir hata oluştu.',
);
}
const newProject = await this.db.project.create({
@@ -1460,8 +1653,15 @@ ${JSON.stringify({
cinematicReference: project.cinematicReference,
targetDuration: project.targetDuration,
seoKeywords: project.seoKeywords,
seoTitle: (translatedData.seoTitle || project.seoTitle || '').substring(0, 190),
seoDescription: (translatedData.seoDescription || project.seoDescription || '').substring(0, 490),
seoTitle: (translatedData.seoTitle || project.seoTitle || '').substring(
0,
190,
),
seoDescription: (
translatedData.seoDescription ||
project.seoDescription ||
''
).substring(0, 490),
socialContent: translatedData.socialContent || project.socialContent,
referenceUrl: project.referenceUrl,
sourceType: project.sourceType,
@@ -1469,27 +1669,32 @@ ${JSON.stringify({
status: 'DRAFT',
userId,
parentId: project.id,
}
},
});
for (const originalScene of project.scenes) {
const transScene = translatedData.scenes?.find((s: any) => s.id === originalScene.id);
const transScene = translatedData.scenes?.find(
(s: any) => s.id === originalScene.id,
);
await this.db.scene.create({
data: {
order: originalScene.order,
title: originalScene.title,
narrationText: transScene?.narrationText || originalScene.narrationText,
narrationText:
transScene?.narrationText || originalScene.narrationText,
subtitleText: transScene?.subtitleText || originalScene.subtitleText,
visualPrompt: transScene?.visualPrompt || originalScene.visualPrompt,
duration: originalScene.duration,
transitionType: originalScene.transitionType,
projectId: newProject.id,
}
},
});
}
this.logger.log(`Proje başarıyla çevrildi: ${projectId} -> ${newProject.id} (${targetLanguage})`);
this.logger.log(
`Proje başarıyla çevrildi: ${projectId} -> ${newProject.id} (${targetLanguage})`,
);
return newProject;
}
@@ -1542,7 +1747,9 @@ ${JSON.stringify({
},
});
this.logger.log(`${result.titles.length} SEO başlığı üretildi: ${projectId}`);
this.logger.log(
`${result.titles.length} SEO başlığı üretildi: ${projectId}`,
);
return {
titles: updated.seoTitleAlts,
@@ -1555,7 +1762,11 @@ ${JSON.stringify({
* Alternatif SEO başlıklarından birini seçerek projenin ana başlığını günceller.
* Ayrıca seoTitle ve socialContent.youtubeTitle alanlarını da senkronize eder.
*/
async selectSeoTitle(userId: string, projectId: string, selectedTitle: string) {
async selectSeoTitle(
userId: string,
projectId: string,
selectedTitle: string,
) {
const project = await this.db.project.findFirst({
where: { id: projectId, userId, deletedAt: null },
});
@@ -1571,9 +1782,13 @@ ${JSON.stringify({
const trimmedTitle = selectedTitle.substring(0, 190);
// socialContent varsa youtubeTitle'ı da güncelle
let updatedSocialContent = project.socialContent as Record<string, any> || {};
let updatedSocialContent =
(project.socialContent as Record<string, any>) || {};
if (updatedSocialContent && typeof updatedSocialContent === 'object') {
updatedSocialContent = { ...updatedSocialContent, youtubeTitle: trimmedTitle.substring(0, 60) };
updatedSocialContent = {
...updatedSocialContent,
youtubeTitle: trimmedTitle.substring(0, 60),
};
}
const updated = await this.db.project.update({
@@ -1601,7 +1816,9 @@ ${JSON.stringify({
},
});
this.logger.log(`Proje başlığı güncellendi: "${project.title}" → "${trimmedTitle}"`);
this.logger.log(
`Proje başlığı güncellendi: "${project.title}" → "${trimmedTitle}"`,
);
return updated;
}
}
@@ -68,7 +68,10 @@ export class RenderCallbackController {
private readonly configService: ConfigService,
private readonly notificationsService: NotificationsService,
) {
this.apiKey = this.configService.get<string>('RENDER_CALLBACK_API_KEY', 'contgen-worker-secret-2026');
this.apiKey = this.configService.get<string>(
'RENDER_CALLBACK_API_KEY',
'contgen-worker-secret-2026',
);
}
/**
@@ -316,4 +319,3 @@ export class RenderCallbackController {
}
}
}
+34 -9
View File
@@ -49,7 +49,10 @@ export class StorageService {
private readonly config: StorageConfig;
constructor(private readonly configService: ConfigService) {
const basePath = this.configService.get<string>('STORAGE_LOCAL_PATH', './data/media');
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');
@@ -60,7 +63,7 @@ export class StorageService {
};
this.logger.log(`📦 Storage: lokal depolama — ${this.config.basePath}`);
this.ensureBaseDir();
void this.ensureBaseDir();
}
/**
@@ -69,7 +72,9 @@ export class StorageService {
private async ensureBaseDir() {
try {
await fs.mkdir(this.config.basePath, { recursive: true });
await fs.mkdir(path.join(this.config.basePath, 'temp'), { recursive: true });
await fs.mkdir(path.join(this.config.basePath, 'temp'), {
recursive: true,
});
} catch (error) {
this.logger.error(`Temel dizin oluşturulamadı: ${error}`);
}
@@ -119,7 +124,11 @@ export class StorageService {
/**
* Dosya yükle (Buffer disk).
*/
async upload(key: string, data: Buffer, mimeType: string): Promise<UploadResult> {
async upload(
key: string,
data: Buffer,
mimeType: string,
): Promise<UploadResult> {
const filePath = path.join(this.config.basePath, key);
const dir = path.dirname(filePath);
@@ -128,7 +137,9 @@ export class StorageService {
const sizeBytes = data.length;
this.logger.debug(`📥 Yüklendi: ${key} (${this.formatBytes(sizeBytes)}, ${mimeType})`);
this.logger.debug(
`📥 Yüklendi: ${key} (${this.formatBytes(sizeBytes)}, ${mimeType})`,
);
return {
key,
@@ -142,7 +153,11 @@ export class StorageService {
/**
* 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> {
async uploadFromPath(
key: string,
sourcePath: string,
mimeType: string,
): Promise<UploadResult> {
const destPath = path.join(this.config.basePath, key);
const dir = path.dirname(destPath);
@@ -151,7 +166,9 @@ export class StorageService {
const stats = await fs.stat(destPath);
this.logger.debug(`📥 Dosyadan yüklendi: ${key} (${this.formatBytes(stats.size)})`);
this.logger.debug(
`📥 Dosyadan yüklendi: ${key} (${this.formatBytes(stats.size)})`,
);
return {
key,
@@ -307,14 +324,22 @@ export class StorageService {
// ── Private Helpers ────────────────────────────────────────────────
private async listFilesRecursive(dir: string, prefix: string): Promise<string[]> {
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)));
files.push(
...(await this.listFilesRecursive(
path.join(dir, entry.name),
relative,
)),
);
} else {
files.push(relative);
}
+6 -4
View File
@@ -72,10 +72,12 @@ export class UserResponseDto {
@Expose()
@Transform(({ obj }) => {
if (obj.roles && Array.isArray(obj.roles)) {
return obj.roles.map((r: any) => {
if (typeof r === 'string') return r;
return r?.role?.name || r?.name;
}).filter(Boolean);
return obj.roles
.map((r: any) => {
if (typeof r === 'string') return r;
return r?.role?.name || r?.name;
})
.filter(Boolean);
}
return [];
})
+15 -3
View File
@@ -1,4 +1,10 @@
import { Controller, Get, Patch, Body, BadRequestException } 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';
@@ -73,13 +79,19 @@ export class UsersController extends BaseController<
if (!fullUser) throw new BadRequestException('Kullanıcı bulunamadı');
const bcrypt = await import('bcrypt');
const isValid = await bcrypt.compare(body.currentPassword, fullUser.password);
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');
return createSuccessResponse(
{ success: true },
'Şifre başarıyla güncellendi',
);
}
// Override create to require admin role
File diff suppressed because it is too large Load Diff
@@ -51,18 +51,16 @@ export class VideoGenerationProducer {
* BullMQ: NestJS tarafında lifecycle tracking
* Redis List: C# Worker BRPOP ile consume eder
*/
async addVideoGenerationJob(payload: VideoGenerationJobPayload): Promise<string> {
const bullJob = await this.videoQueue.add(
'generate-video',
payload,
{
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { count: 100, age: 7 * 24 * 3600 },
removeOnFail: { count: 50 },
priority: 1,
},
);
async addVideoGenerationJob(
payload: VideoGenerationJobPayload,
): Promise<string> {
const bullJob = await this.videoQueue.add('generate-video', payload, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { count: 100, age: 7 * 24 * 3600 },
removeOnFail: { count: 50 },
priority: 1,
});
const workerPayload = JSON.stringify({
jobId: bullJob.id,
+4 -4
View File
@@ -1,4 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, Matches } from 'class-validator';
/**
@@ -10,14 +10,14 @@ import { IsString, IsNotEmpty, Matches } from 'class-validator';
*/
export class FetchTweetDto {
@ApiProperty({
description: 'X/Twitter tweet URL\'si',
description: "X/Twitter tweet URL'si",
example: 'https://x.com/elonmusk/status/1893456789012345678',
})
@IsString()
@IsNotEmpty({ message: 'Tweet URL\'si boş olamaz' })
@IsNotEmpty({ message: "Tweet URL'si boş olamaz" })
@Matches(
/^https?:\/\/(x\.com|twitter\.com)\/([\w]+\/status\/\d+|i\/status\/\d+)/,
{ message: 'Geçerli bir X/Twitter tweet URL\'si girin' },
{ message: "Geçerli bir X/Twitter tweet URL'si girin" },
)
tweetUrl: string;
}
+17 -10
View File
@@ -71,7 +71,7 @@ export class XTwitterService {
const parsed = this.parseFxTweet(response.tweet);
// Thread tespiti ve toplama
const thread = await this.collectThread(parsed, username);
const thread = this.collectThread(parsed, username);
if (thread.length > 1) {
parsed.isThread = true;
parsed.threadTweets = thread;
@@ -103,7 +103,7 @@ export class XTwitterService {
: tweet.text;
const wordCount = totalText.split(/\s+/).length;
const estimatedDuration = Math.min(
Math.max(Math.ceil((wordCount / 2.5) + 5), 15), // Min 15sn, ~2.5 kelime/sn okuma
Math.max(Math.ceil(wordCount / 2.5 + 5), 15), // Min 15sn, ~2.5 kelime/sn okuma
90, // Max 90sn
);
@@ -182,10 +182,10 @@ export class XTwitterService {
* Thread tweet'lerini toplar.
* FXTwitter'da direkt thread endpoint yok → author'un son tweet'lerinden thread'i tahmin et.
*/
private async collectThread(
private collectThread(
rootTweet: ParsedTweet,
username: string,
): Promise<ParsedTweet[]> {
_username: string,
): ParsedTweet[] {
const threadTweets: ParsedTweet[] = [rootTweet];
// Tweet'in reply olup olmadığını kontrol et
@@ -196,7 +196,9 @@ export class XTwitterService {
// İleride Xquik thread_extractor ile genişletilebilir
// Şu an: Eğer tweet uzunsa (280+ karakter) ve satır sonları varsa, thread benzeri
const lines = rootTweet.text.split('\n').filter((l) => l.trim().length > 0);
const lines = rootTweet.text
.split('\n')
.filter((l) => l.trim().length > 0);
if (lines.length >= 3) {
// Uzun tek tweet — thread gibi ele alınabilir
return threadTweets;
@@ -214,7 +216,9 @@ export class XTwitterService {
/**
* FXTwitter API response'unu ParsedTweet'e dönüştürür.
*/
private parseFxTweet(raw: NonNullable<FxTweetResponse['tweet']>): ParsedTweet {
private parseFxTweet(
raw: NonNullable<FxTweetResponse['tweet']>,
): ParsedTweet {
const views = raw.views || 1;
const engagement = raw.likes + raw.retweets + raw.replies;
@@ -236,7 +240,8 @@ export class XTwitterService {
retweets: raw.retweets,
likes: raw.likes,
views,
engagementRate: views > 0 ? Number(((engagement / views) * 100).toFixed(2)) : 0,
engagementRate:
views > 0 ? Number(((engagement / views) * 100).toFixed(2)) : 0,
},
media: (raw.media?.all || []).map((m) => ({
type: m.type,
@@ -261,7 +266,7 @@ export class XTwitterService {
* Engagement rate, takipçi oranı ve toplam etkileşim bazlı.
*/
private calculateViralScore(tweet: ParsedTweet): number {
const { metrics, author } = tweet;
const { metrics } = tweet;
let score = 0;
// Engagement rate katkısı (max 40 puan)
@@ -340,7 +345,9 @@ export class XTwitterService {
if (res.status === 429) {
const retryAfter = res.headers.get('Retry-After');
const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5000;
this.logger.warn(`Rate limited — ${delay}ms bekleniyor (deneme ${attempt}/${maxRetries})`);
this.logger.warn(
`Rate limited — ${delay}ms bekleniyor (deneme ${attempt}/${maxRetries})`,
);
await this.sleep(delay);
continue;
}
+10
View File
@@ -0,0 +1,10 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const admin = await prisma.user.findFirst({
where: { email: 'admin@contentgen.ai' },
include: { roles: { include: { role: true } } }
});
console.log('admin roles:', JSON.stringify(admin?.roles, null, 2));
}
main().catch(console.error).finally(() => prisma.$disconnect());
+7
View File
@@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const admin = await prisma.user.findFirst({ where: { email: 'admin@contentgen.ai' } });
console.log('admin record:', admin);
}
main().catch(console.error).finally(() => prisma.$disconnect());