generated from fahricansecer/boilerplate-be
+2
-2
@@ -47,7 +47,7 @@ RUN npx prisma generate
|
|||||||
COPY --chown=node:node --from=builder /app/dist ./dist
|
COPY --chown=node:node --from=builder /app/dist ./dist
|
||||||
|
|
||||||
# Eğer i18n varsa onu da taşı
|
# Eğer i18n varsa onu da taşı
|
||||||
COPY --chown=node:node --from=builder /app/src/i18n ./dist/i18n
|
COPY --chown=node:node --from=builder /app/src/i18n ./dist/src/i18n
|
||||||
|
|
||||||
# Ortam değişkeni
|
# Ortam değişkeni
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -62,4 +62,4 @@ RUN mkdir -p /data/media && chown -R node:node /data/media
|
|||||||
USER node
|
USER node
|
||||||
|
|
||||||
# Uygulamayı başlat
|
# Uygulamayı başlat
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/src/main.js"]
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
const { PrismaClient } = require('@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(JSON.stringify(admin, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const res = await axios.post('http://localhost:3001/api/v1/auth/login', {
|
||||||
|
email: 'admin@contentgen.ai',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res.data, null, 2));
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const res = await axios.post('http://localhost:3000/api/v1/auth/login', {
|
||||||
|
email: 'admin@contentgen.ai',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res.data, null, 2));
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const res = await axios.post('http://localhost:3000/api/auth/login', {
|
||||||
|
email: 'admin@contentgen.ai',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res.data, null, 2));
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const res = await axios.post('http://localhost:3000/api/auth/login', {
|
||||||
|
email: 'admin@contentgen.ai',
|
||||||
|
password: 'admin123'
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res.data, null, 2));
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main();
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const login = await axios.post('http://localhost:3000/api/auth/login', {
|
||||||
|
email: 'admin@contentgen.ai',
|
||||||
|
password: 'admin123'
|
||||||
|
});
|
||||||
|
const token = login.data.data.accessToken;
|
||||||
|
|
||||||
|
const res = await axios.get('http://localhost:3000/api/users/me', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res.data, null, 2));
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main();
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
include: {
|
||||||
|
roles: {
|
||||||
|
include: { role: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(users, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(e => console.error(e))
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ model User {
|
|||||||
preferences UserPreference?
|
preferences UserPreference?
|
||||||
youtubeAnalyses YoutubeAnalysis[]
|
youtubeAnalyses YoutubeAnalysis[]
|
||||||
youtubeSeoAnalyses YoutubeSeoAnalysis[]
|
youtubeSeoAnalyses YoutubeSeoAnalysis[]
|
||||||
|
tubeStrategistProjects TubeStrategistProject[]
|
||||||
|
|
||||||
// Multi-tenancy (optional)
|
// Multi-tenancy (optional)
|
||||||
tenantId String?
|
tenantId String?
|
||||||
@@ -712,3 +713,83 @@ model YoutubeSeoAnalysis {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([videoId])
|
@@index([videoId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tube Strategist
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model TubeStrategistProject {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @db.VarChar(500)
|
||||||
|
status String @default("DRAFT") // DRAFT, ANALYZING, COMPLETED
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
tone String?
|
||||||
|
duration String?
|
||||||
|
speakerName String?
|
||||||
|
targetAudience String?
|
||||||
|
topicFocus String?
|
||||||
|
formatDescription String? @db.Text
|
||||||
|
|
||||||
|
masterAnalysis Json? // Bütün master analizi burada duracak
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
videos TubeStrategistVideo[]
|
||||||
|
episodes TubeStrategistEpisode[]
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TubeStrategistVideo {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
youtubeUrl String @db.VarChar(500)
|
||||||
|
videoId String @db.VarChar(100)
|
||||||
|
title String? @db.VarChar(500)
|
||||||
|
thumbnail String? @db.VarChar(500)
|
||||||
|
|
||||||
|
transcript String? @db.Text
|
||||||
|
transcriptDuration Int? // in seconds
|
||||||
|
|
||||||
|
totalComments Int @default(0)
|
||||||
|
mainComments Int @default(0)
|
||||||
|
replyComments Int @default(0)
|
||||||
|
viewCount String? @db.VarChar(50)
|
||||||
|
likeCount String? @db.VarChar(50)
|
||||||
|
|
||||||
|
commentsJson Json? // Storing top comments or buckets
|
||||||
|
tier1Analysis Json? // Individual video analysis
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
projectId String
|
||||||
|
project TubeStrategistProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([projectId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TubeStrategistEpisode {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
topic String @db.VarChar(500)
|
||||||
|
targetAudience String? @db.VarChar(500)
|
||||||
|
duration String? @db.VarChar(100)
|
||||||
|
format String? @db.VarChar(100)
|
||||||
|
|
||||||
|
status String @default("DRAFT") // DRAFT, ANALYZING, COMPLETED
|
||||||
|
masterAnalysis Json?
|
||||||
|
|
||||||
|
projectId String
|
||||||
|
project TubeStrategistProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([projectId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const hash = await bcrypt.hash('admin123', 10);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { email: 'admin@contentgen.ai' },
|
||||||
|
data: { password: hash }
|
||||||
|
});
|
||||||
|
console.log("Password reset to admin123");
|
||||||
|
}
|
||||||
|
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||||
@@ -120,6 +120,11 @@ async function bootstrap() {
|
|||||||
});
|
});
|
||||||
logger.log('Swagger hazır');
|
logger.log('Swagger hazır');
|
||||||
|
|
||||||
|
// Increase global timeout for long-running AI tasks (e.g. YouTube Analysis)
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
server.setTimeout(300000); // 5 minutes
|
||||||
|
server.keepAliveTimeout = 300000;
|
||||||
|
|
||||||
logger.log(`Port ${port} üzerinde dinleniyor...`);
|
logger.log(`Port ${port} üzerinde dinleniyor...`);
|
||||||
await app.listen(port, '0.0.0.0');
|
await app.listen(port, '0.0.0.0');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe, Logger as NestLogger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import { Logger, LoggerErrorInterceptor } from 'nestjs-pino';
|
||||||
|
import * as express from 'express';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// Prisma BigInt alanları JSON'a serialize edilemiyor — global polyfill
|
||||||
|
// MediaAsset.sizeBytes gibi alanlar BigInt tipinde
|
||||||
|
(BigInt.prototype as any).toJSON = function () {
|
||||||
|
return Number(this);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const logger = new NestLogger('Bootstrap');
|
||||||
|
|
||||||
|
logger.log('🔄 ContentGen AI başlatılıyor...');
|
||||||
|
|
||||||
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
bufferLogs: true,
|
||||||
|
rawBody: true, // Stripe webhook imza doğrulaması için gerekli
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use Pino Logger
|
||||||
|
app.useLogger(app.get(Logger));
|
||||||
|
app.useGlobalInterceptors(new LoggerErrorInterceptor());
|
||||||
|
|
||||||
|
// Security Headers
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Graceful Shutdown (Prisma & Docker)
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
|
// Get config service
|
||||||
|
const configService = app.get(ConfigService);
|
||||||
|
const port = configService.get<number>('PORT', 3000);
|
||||||
|
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 absoluteMediaPath = path.resolve(mediaPath);
|
||||||
|
|
||||||
|
// Medya dosyaları için CORS header'ları (Frontend farklı port'ta çalışıyor)
|
||||||
|
app.use('/media', (req: any, res: any, next: any) => {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'/media',
|
||||||
|
express.static(absoluteMediaPath, {
|
||||||
|
maxAge: '1d',
|
||||||
|
etag: true,
|
||||||
|
lastModified: true,
|
||||||
|
index: false,
|
||||||
|
dotfiles: 'deny',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
logger.log(`📂 Medya dizini: ${absoluteMediaPath} → /media/*`);
|
||||||
|
|
||||||
|
// Enable CORS
|
||||||
|
app.enableCors({
|
||||||
|
origin: true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global prefix
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
|
// Validation pipe (Strict)
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transformOptions: {
|
||||||
|
enableImplicitConversion: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Swagger setup
|
||||||
|
const swaggerConfig = new DocumentBuilder()
|
||||||
|
.setTitle('ContentGen AI — Video Generation SaaS API')
|
||||||
|
.setDescription(
|
||||||
|
'AI destekli video üretim platformu. Senaryo oluşturma, medya üretimi, render pipeline ve billing yönetimi.',
|
||||||
|
)
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.addTag('Auth', 'Kimlik doğrulama')
|
||||||
|
.addTag('Users', 'Kullanıcı yönetimi')
|
||||||
|
.addTag('Projects', 'Proje ve senaryo yönetimi')
|
||||||
|
.addTag('Dashboard', 'İstatistikler ve grafikler')
|
||||||
|
.addTag('Billing', 'Abonelik ve kredi yönetimi')
|
||||||
|
.addTag('Templates', 'Şablon pazaryeri')
|
||||||
|
.addTag('Notifications', 'Bildirim yönetimi')
|
||||||
|
.addTag('Admin', 'Yönetici paneli')
|
||||||
|
.addTag('Health', 'Sistem sağlık kontrolü')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
logger.log('Swagger başlatılıyor...');
|
||||||
|
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
|
SwaggerModule.setup('api/docs', app, document, {
|
||||||
|
swaggerOptions: {
|
||||||
|
persistAuthorization: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.log('Swagger hazır');
|
||||||
|
|
||||||
|
logger.log(`Port ${port} üzerinde dinleniyor...`);
|
||||||
|
await app.listen(port, '0.0.0.0');
|
||||||
|
|
||||||
|
logger.log('═══════════════════════════════════════════════════════════');
|
||||||
|
logger.log(`🚀 ContentGen AI API: http://localhost:${port}/api`);
|
||||||
|
logger.log(`📚 Swagger Docs: http://localhost:${port}/api/docs`);
|
||||||
|
logger.log(`💚 Health Check: http://localhost:${port}/api/health`);
|
||||||
|
logger.log(`📂 Medya Dosyaları: http://localhost:${port}/media/`);
|
||||||
|
logger.log(`🌍 Ortam: ${nodeEnv.toUpperCase()}`);
|
||||||
|
logger.log('═══════════════════════════════════════════════════════════');
|
||||||
|
|
||||||
|
if (nodeEnv === 'development') {
|
||||||
|
logger.warn('⚠️ Geliştirme modunda çalışıyor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
--- /Users/haruncan/Documents/GitHub/ContentGenerator/ContentGen_BE/src/main.ts
|
||||||
|
+++ /Users/haruncan/Documents/GitHub/ContentGenerator/ContentGen_BE/src/main.ts
|
||||||
|
@@ -122,6 +122,11 @@
|
||||||
|
});
|
||||||
|
logger.log('Swagger hazır');
|
||||||
|
|
||||||
|
+ // Increase global timeout for long-running AI tasks (e.g. YouTube Analysis)
|
||||||
|
+ const server = app.getHttpServer();
|
||||||
|
+ server.setTimeout(300000); // 5 minutes
|
||||||
|
+ server.keepAliveTimeout = 300000;
|
||||||
|
+
|
||||||
|
logger.log(`Port ${port} üzerinde dinleniyor...`);
|
||||||
|
await app.listen(port, '0.0.0.0');
|
||||||
|
|
||||||
@@ -1,48 +1,176 @@
|
|||||||
|
import { IsString, IsOptional, IsArray, ValidateNested, IsNumber, IsIn } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class UploadedFileDto {
|
export class UploadedFileDto {
|
||||||
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnalyzeContentDto {
|
export class AnalyzeContentDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => UploadedFileDto)
|
||||||
transcripts: UploadedFileDto[];
|
transcripts: UploadedFileDto[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => UploadedFileDto)
|
||||||
comments: UploadedFileDto[];
|
comments: UploadedFileDto[];
|
||||||
tone: string;
|
|
||||||
duration: string;
|
@IsString()
|
||||||
speakerName: string;
|
@IsOptional()
|
||||||
topicFocus: string;
|
tone?: string;
|
||||||
targetAudience: string;
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
duration?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
speakerName?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
topicFocus?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
targetAudience?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StrategyResultDto {
|
export class StrategyResultDto {
|
||||||
|
@IsString()
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
psychologicalTheme?: string;
|
psychologicalTheme?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
inspiredByGap?: string;
|
inspiredByGap?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
hook?: string;
|
hook?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
thumbnailConcept?: string;
|
thumbnailConcept?: string;
|
||||||
segments: {
|
|
||||||
type: string;
|
@IsArray()
|
||||||
duration: string;
|
@IsOptional()
|
||||||
description: string;
|
segments?: any[];
|
||||||
keyPoints: string[];
|
|
||||||
neuroObjective?: string;
|
@IsArray()
|
||||||
}[];
|
@IsOptional()
|
||||||
interviewQuestions: string[];
|
interviewQuestions?: string[];
|
||||||
selectedComments: {
|
|
||||||
username?: string;
|
@IsArray()
|
||||||
text: string;
|
@IsOptional()
|
||||||
insightValue?: string;
|
selectedComments?: any[];
|
||||||
sourceFile?: string;
|
|
||||||
}[];
|
@IsOptional()
|
||||||
commercialAnalysis: {
|
commercialAnalysis?: any;
|
||||||
suitableIndustries: string[];
|
|
||||||
brandSafetyScore: number;
|
@IsArray()
|
||||||
suggestedBrands: string[];
|
@IsOptional()
|
||||||
monetizationPotential: string;
|
chartData?: any[];
|
||||||
};
|
|
||||||
chartData?: { topic: string; emotionalArousal: number }[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CommercialAnalysisDto {
|
export class CommercialAnalysisDto {
|
||||||
|
@IsString()
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
industries: string[];
|
industries: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CreateProjectDto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
tone?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
targetDuration?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
speakerName?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
targetAudience?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
formatDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateProjectDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
tone?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
targetDuration?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
speakerName?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
targetAudience?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
formatDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddVideoDto {
|
||||||
|
@IsString()
|
||||||
|
youtubeUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddDocumentDto {
|
||||||
|
@IsString()
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['transcript', 'comments'])
|
||||||
|
type: 'transcript' | 'comments';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateEpisodeDto {
|
||||||
|
@IsString()
|
||||||
|
topic: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
targetAudience?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
duration?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
format?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,39 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { GoogleGenAI, Type } from '@google/genai';
|
import { GoogleGenAI, Type } from '@google/genai';
|
||||||
import { AnalyzeContentDto, StrategyResultDto, CommercialAnalysisDto } from './dto/tube-strategist.dto';
|
import { AnalyzeContentDto, StrategyResultDto, CommercialAnalysisDto, CreateProjectDto, UpdateProjectDto, AddVideoDto, AddDocumentDto, CreateEpisodeDto } from './dto/tube-strategist.dto';
|
||||||
|
import { PrismaService } from '../../database/prisma.service';
|
||||||
|
import { Innertube } from 'youtubei.js';
|
||||||
|
import { YoutubeTranscript } from 'youtube-transcript';
|
||||||
|
import { GeminiService } from '../gemini/gemini.service';
|
||||||
|
import { VideoAiService } from '../video-ai/video-ai.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TubeStrategistService {
|
export class TubeStrategistService {
|
||||||
private readonly logger = new Logger(TubeStrategistService.name);
|
private readonly logger = new Logger(TubeStrategistService.name);
|
||||||
private ai: GoogleGenAI;
|
private ai: GoogleGenAI;
|
||||||
|
private youtubeClient: Innertube | null = null;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly videoAiService: VideoAiService,
|
||||||
|
private readonly geminiService: GeminiService
|
||||||
|
) {
|
||||||
const apiKey =
|
const apiKey =
|
||||||
this.configService.get<string>('gemini.apiKey') ||
|
this.configService.get<string>('gemini.apiKey') ||
|
||||||
process.env.GOOGLE_API_KEY;
|
process.env.GOOGLE_API_KEY;
|
||||||
this.ai = new GoogleGenAI({ apiKey });
|
this.ai = new GoogleGenAI({ apiKey });
|
||||||
|
this.initYoutubeClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initYoutubeClient() {
|
||||||
|
try {
|
||||||
|
this.youtubeClient = await Innertube.create({ lang: 'tr', location: 'TR' });
|
||||||
|
this.logger.log('youtubei.js Innertube client başlatıldı (TubeStrategist).');
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Innertube başlatılamadı (TubeStrategist): ${error.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async withRetry<T>(fn: () => Promise<T>, retries = 3, delay = 2000): Promise<T> {
|
private async withRetry<T>(fn: () => Promise<T>, retries = 3, delay = 2000): Promise<T> {
|
||||||
@@ -138,7 +159,11 @@ export class TubeStrategistService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateSeoReport(strategy: StrategyResultDto): Promise<any> {
|
async generateSeoReport(projectId: string, userId: string): Promise<any> {
|
||||||
|
const project = await this.getProjectById(projectId, userId);
|
||||||
|
const masterAnalysis: any = project.masterAnalysis || {};
|
||||||
|
const title = masterAnalysis.title || project.name;
|
||||||
|
|
||||||
const schema: any = {
|
const schema: any = {
|
||||||
type: Type.OBJECT,
|
type: Type.OBJECT,
|
||||||
properties: {
|
properties: {
|
||||||
@@ -166,7 +191,7 @@ export class TubeStrategistService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const prompt = `
|
const prompt = `
|
||||||
Video: "${strategy.title}".
|
Video: "${title}".
|
||||||
Görev: Profesyonel YouTube SEO analizi yap.
|
Görev: Profesyonel YouTube SEO analizi yap.
|
||||||
ÖNEMLİ: 'tags' dizisine sadece virgülle ayrılacak saf kelimeleri yaz, başında # olmasın.
|
ÖNEMLİ: 'tags' dizisine sadece virgülle ayrılacak saf kelimeleri yaz, başında # olmasın.
|
||||||
5 tane alternatif başlık üret ve her birine 0-100 arası başarı (neuro) puanı ver.
|
5 tane alternatif başlık üret ve her birine 0-100 arası başarı (neuro) puanı ver.
|
||||||
@@ -177,10 +202,18 @@ export class TubeStrategistService {
|
|||||||
contents: prompt,
|
contents: prompt,
|
||||||
config: { responseMimeType: 'application/json', responseSchema: schema },
|
config: { responseMimeType: 'application/json', responseSchema: schema },
|
||||||
});
|
});
|
||||||
return JSON.parse(response.text || '{}');
|
|
||||||
|
const report = JSON.parse(response.text || '{}');
|
||||||
|
masterAnalysis.seoAnalysis = report;
|
||||||
|
await this.prisma.tubeStrategistProject.update({ where: { id: projectId }, data: { masterAnalysis } });
|
||||||
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateNeuroReport(strategy: StrategyResultDto): Promise<any> {
|
async generateNeuroReport(projectId: string, userId: string): Promise<any> {
|
||||||
|
const project = await this.getProjectById(projectId, userId);
|
||||||
|
const masterAnalysis: any = project.masterAnalysis || {};
|
||||||
|
const title = masterAnalysis.title || project.name;
|
||||||
|
|
||||||
const schema: any = {
|
const schema: any = {
|
||||||
type: Type.OBJECT,
|
type: Type.OBJECT,
|
||||||
properties: {
|
properties: {
|
||||||
@@ -199,13 +232,21 @@ export class TubeStrategistService {
|
|||||||
};
|
};
|
||||||
const response = await this.ai.models.generateContent({
|
const response = await this.ai.models.generateContent({
|
||||||
model: 'gemini-3-pro-preview',
|
model: 'gemini-3-pro-preview',
|
||||||
contents: `Video Konsepti: "${strategy.title}". Bu video için aşırı detaylı nöro-pazarlama analizi yap. İzleyicinin beyninde oluşacak dopamin döngüsünü kurgula.`,
|
contents: `Video Konsepti: "${title}". Bu video için aşırı detaylı nöro-pazarlama analizi yap. İzleyicinin beyninde oluşacak dopamin döngüsünü kurgula.`,
|
||||||
config: { responseMimeType: 'application/json', responseSchema: schema },
|
config: { responseMimeType: 'application/json', responseSchema: schema },
|
||||||
});
|
});
|
||||||
return JSON.parse(response.text || '{}');
|
|
||||||
|
const report = JSON.parse(response.text || '{}');
|
||||||
|
masterAnalysis.neuroReport = report;
|
||||||
|
await this.prisma.tubeStrategistProject.update({ where: { id: projectId }, data: { masterAnalysis } });
|
||||||
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateMarketingReport(strategy: StrategyResultDto): Promise<any> {
|
async generateMarketingReport(projectId: string, userId: string): Promise<any> {
|
||||||
|
const project = await this.getProjectById(projectId, userId);
|
||||||
|
const masterAnalysis: any = project.masterAnalysis || {};
|
||||||
|
const title = masterAnalysis.title || project.name;
|
||||||
|
|
||||||
const schema: any = {
|
const schema: any = {
|
||||||
type: Type.OBJECT,
|
type: Type.OBJECT,
|
||||||
properties: {
|
properties: {
|
||||||
@@ -223,13 +264,23 @@ export class TubeStrategistService {
|
|||||||
};
|
};
|
||||||
const response = await this.ai.models.generateContent({
|
const response = await this.ai.models.generateContent({
|
||||||
model: 'gemini-3-pro-preview',
|
model: 'gemini-3-pro-preview',
|
||||||
contents: `Video Konsepti: "${strategy.title}". Bu videoyu viral yapmak için pazarlama stratejisi ve persona analizi üret.`,
|
contents: `Video Konsepti: "${title}". Bu videoyu viral yapmak için pazarlama stratejisi ve persona analizi üret.`,
|
||||||
config: { responseMimeType: 'application/json', responseSchema: schema },
|
config: { responseMimeType: 'application/json', responseSchema: schema },
|
||||||
});
|
});
|
||||||
return JSON.parse(response.text || '{}');
|
|
||||||
|
const report = JSON.parse(response.text || '{}');
|
||||||
|
masterAnalysis.marketingInsights = report;
|
||||||
|
await this.prisma.tubeStrategistProject.update({ where: { id: projectId }, data: { masterAnalysis } });
|
||||||
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateDeepCommercialAnalysis(dto: CommercialAnalysisDto): Promise<any> {
|
async generateDeepCommercialAnalysis(projectId: string, userId: string): Promise<any> {
|
||||||
|
const project = await this.getProjectById(projectId, userId);
|
||||||
|
const masterAnalysis: any = project.masterAnalysis || {};
|
||||||
|
const title = masterAnalysis.title || project.name;
|
||||||
|
const commercialAnalysis = masterAnalysis.commercialAnalysis || {};
|
||||||
|
const industries = commercialAnalysis.suitableIndustries || [];
|
||||||
|
|
||||||
const schema: any = {
|
const schema: any = {
|
||||||
type: Type.OBJECT,
|
type: Type.OBJECT,
|
||||||
properties: {
|
properties: {
|
||||||
@@ -242,14 +293,770 @@ export class TubeStrategistService {
|
|||||||
|
|
||||||
const response = await this.ai.models.generateContent({
|
const response = await this.ai.models.generateContent({
|
||||||
model: 'gemini-3-pro-preview',
|
model: 'gemini-3-pro-preview',
|
||||||
contents: `Video: "${dto.title}". Sektörler: ${dto.industries.join(
|
contents: `Video: "${title}". Sektörler: ${industries.join(
|
||||||
', ',
|
', ',
|
||||||
)}. Türkiye'den 5 gerçek marka seç ve her birine özel mail taslağı oluştur.`,
|
)}. Türkiye'den 5 gerçek marka seç ve her birine özel sponsorluk teklifi e-posta taslağı oluştur.`,
|
||||||
config: {
|
config: {
|
||||||
responseMimeType: 'application/json',
|
responseMimeType: 'application/json',
|
||||||
responseSchema: schema,
|
responseSchema: schema,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return JSON.parse(response.text || '{}');
|
|
||||||
|
const report = JSON.parse(response.text || '{}');
|
||||||
|
|
||||||
|
// Update the sub-object for commercial
|
||||||
|
masterAnalysis.commercialAnalysis = { ...commercialAnalysis, deepAnalysis: report };
|
||||||
|
await this.prisma.tubeStrategistProject.update({ where: { id: projectId }, data: { masterAnalysis } });
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateThumbnailImage(projectId: string, userId: string, prompt: string): Promise<any> {
|
||||||
|
const project = await this.getProjectById(projectId, userId);
|
||||||
|
const masterAnalysis: any = project.masterAnalysis || {};
|
||||||
|
|
||||||
|
// As Imagen integration may be complex, return a placeholder for now,
|
||||||
|
// or we can simulate it with a generic image URL.
|
||||||
|
const generatedThumbnail = "https://via.placeholder.com/1280x720.png?text=AI+Thumbnail+Generated";
|
||||||
|
|
||||||
|
masterAnalysis.generatedThumbnail = generatedThumbnail;
|
||||||
|
await this.prisma.tubeStrategistProject.update({ where: { id: projectId }, data: { masterAnalysis } });
|
||||||
|
|
||||||
|
return { url: generatedThumbnail };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// PROJECT BASED METHODS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
async createProject(userId: string, dto: CreateProjectDto) {
|
||||||
|
return this.prisma.tubeStrategistProject.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
name: dto.name,
|
||||||
|
tone: dto.tone,
|
||||||
|
duration: dto.targetDuration,
|
||||||
|
speakerName: dto.speakerName,
|
||||||
|
targetAudience: dto.targetAudience,
|
||||||
|
formatDescription: dto.formatDescription,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjects(userId: string) {
|
||||||
|
return this.prisma.tubeStrategistProject.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
include: { _count: { select: { videos: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectById(projectId: string, userId: string) {
|
||||||
|
const project = await this.prisma.tubeStrategistProject.findFirst({
|
||||||
|
where: { id: projectId, userId },
|
||||||
|
include: {
|
||||||
|
videos: true,
|
||||||
|
episodes: { orderBy: { createdAt: 'desc' } }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!project) throw new NotFoundException('Proje bulunamadı');
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addVideoToProject(projectId: string, userId: string, dto: AddVideoDto) {
|
||||||
|
// 1. Verify Project
|
||||||
|
const project = await this.getProjectById(projectId, userId);
|
||||||
|
|
||||||
|
// 2. Extract Video ID
|
||||||
|
const urlPattern = /(?:v=|\/)([0-9A-Za-z_-]{11}).*/;
|
||||||
|
const match = dto.youtubeUrl.match(urlPattern);
|
||||||
|
const videoId = match ? match[1] : null;
|
||||||
|
if (!videoId) throw new Error("Geçersiz YouTube URL'si");
|
||||||
|
|
||||||
|
this.logger.log(`[TubeStrategist] Video ekleniyor: ${videoId}`);
|
||||||
|
|
||||||
|
// 3. Fetch Info via Innertube
|
||||||
|
if (!this.youtubeClient) await this.initYoutubeClient();
|
||||||
|
const info = await this.youtubeClient!.getInfo(videoId);
|
||||||
|
const videoDetails = info.basic_info;
|
||||||
|
const title = videoDetails.title;
|
||||||
|
const thumbnail = videoDetails.thumbnail?.[0]?.url;
|
||||||
|
const viewCount = videoDetails.view_count?.toString();
|
||||||
|
const likeCount = videoDetails.like_count?.toString();
|
||||||
|
|
||||||
|
// 4. Fetch Transcript
|
||||||
|
let transcriptText = "";
|
||||||
|
let transcriptDuration = 0;
|
||||||
|
try {
|
||||||
|
const transcriptList = await YoutubeTranscript.fetchTranscript(videoId);
|
||||||
|
transcriptText = transcriptList.map((t: any) => t.text).join(' ');
|
||||||
|
transcriptDuration = Math.floor(transcriptList[transcriptList.length - 1].offset / 1000) || 0;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`Transkript alınamadı: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fetch Comments
|
||||||
|
const comments: any[] = [];
|
||||||
|
let mainComments = 0;
|
||||||
|
let replyComments = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const commentThread = await this.youtubeClient!.getComments(videoId);
|
||||||
|
let currentThread = commentThread;
|
||||||
|
let pages = 0;
|
||||||
|
while (currentThread.has_continuation && comments.length < 5000 && pages < 100) {
|
||||||
|
pages++;
|
||||||
|
const next = await currentThread.getContinuation();
|
||||||
|
if (next.contents) {
|
||||||
|
for (const thread of next.contents) {
|
||||||
|
if (thread.comment?.content?.text) {
|
||||||
|
const rCount = thread.comment.reply_count ? parseInt(thread.comment.reply_count.toString().replace(/[^0-9]/g, '')) || 0 : 0;
|
||||||
|
const lCount = thread.comment.like_count ? parseInt(thread.comment.like_count.toString().replace(/[^0-9]/g, '')) || 0 : 0;
|
||||||
|
mainComments++;
|
||||||
|
replyComments += rCount;
|
||||||
|
comments.push({
|
||||||
|
text: thread.comment.content.text,
|
||||||
|
likes: lCount,
|
||||||
|
replies: rCount,
|
||||||
|
author: thread.comment.author?.name || 'Anonim',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
currentThread = next;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`Yorumlar alınamadı: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top 20 Comments
|
||||||
|
const sortedComments = [...comments].sort((a, b) => (b.likes + b.replies * 2) - (a.likes + a.replies * 2));
|
||||||
|
const top20Comments = sortedComments.slice(0, 20);
|
||||||
|
|
||||||
|
// 6. Tier 1 AI Analysis
|
||||||
|
const prompt = `Sen uzman bir Youtube Data Analistisin. Aşağıda bir videonun transkripti ve en çok etkileşim alan 20 yorumu verilmiştir. Lütfen bu videonun ana fikrini, insanların ne hissettiğini ve yorumlardaki içgörüleri özetle.
|
||||||
|
|
||||||
|
VİDEO BAŞLIĞI: ${title}
|
||||||
|
GÖRÜNTÜLENME: ${viewCount}
|
||||||
|
BEĞENİ: ${likeCount}
|
||||||
|
|
||||||
|
TOP 20 YORUM:
|
||||||
|
${JSON.stringify(top20Comments, null, 2)}
|
||||||
|
|
||||||
|
TRANSKRİPT (ilk 30000 karakter):
|
||||||
|
${transcriptText.substring(0, 30000)}
|
||||||
|
|
||||||
|
JSON Formatında dön:
|
||||||
|
{
|
||||||
|
"summary": "Videonun kısa özeti",
|
||||||
|
"sentiment": "Genel duygu durumu (pozitif/negatif/nötr)",
|
||||||
|
"keyInsights": ["İçgörü 1", "İçgörü 2"],
|
||||||
|
"audienceReaction": "İzleyici tepkisi analizi"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
let tier1Analysis = {};
|
||||||
|
try {
|
||||||
|
const result = await this.ai.models.generateContent({
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
contents: prompt,
|
||||||
|
config: { responseMimeType: 'application/json' },
|
||||||
|
});
|
||||||
|
tier1Analysis = JSON.parse(result.text || '{}');
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error(`Tier 1 AI Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Save to DB
|
||||||
|
const savedVideo = await this.prisma.tubeStrategistVideo.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
youtubeUrl: dto.youtubeUrl,
|
||||||
|
videoId,
|
||||||
|
title,
|
||||||
|
thumbnail,
|
||||||
|
transcript: transcriptText.substring(0, 50000), // Avoid DB limits
|
||||||
|
transcriptDuration,
|
||||||
|
totalComments: mainComments + replyComments,
|
||||||
|
mainComments,
|
||||||
|
replyComments,
|
||||||
|
viewCount,
|
||||||
|
likeCount,
|
||||||
|
commentsJson: top20Comments,
|
||||||
|
tier1Analysis,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return savedVideo;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEpisode(projectId: string, userId: string, dto: CreateEpisodeDto) {
|
||||||
|
// Proje var mı kontrol et
|
||||||
|
await this.getProjectById(projectId, userId);
|
||||||
|
|
||||||
|
return this.prisma.tubeStrategistEpisode.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
topic: dto.topic,
|
||||||
|
targetAudience: dto.targetAudience,
|
||||||
|
duration: dto.duration,
|
||||||
|
format: dto.format,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEpisodesByProject(projectId: string, userId: string) {
|
||||||
|
// Güvenlik: Proje user'a ait mi
|
||||||
|
await this.getProjectById(projectId, userId);
|
||||||
|
|
||||||
|
return this.prisma.tubeStrategistEpisode.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEpisodeById(episodeId: string, userId: string) {
|
||||||
|
const episode = await this.prisma.tubeStrategistEpisode.findUnique({
|
||||||
|
where: { id: episodeId },
|
||||||
|
include: { project: { include: { videos: true } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!episode || episode.project.userId !== userId) {
|
||||||
|
throw new NotFoundException('Bölüm bulunamadı veya yetkiniz yok');
|
||||||
|
}
|
||||||
|
return episode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async analyzeEpisode(episodeId: string, userId: string) {
|
||||||
|
const episode = await this.getEpisodeById(episodeId, userId);
|
||||||
|
if (!episode.project.videos || episode.project.videos.length === 0) {
|
||||||
|
throw new Error("Projede referans alınacak (analiz edilecek) video yok.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Analyzing state
|
||||||
|
await this.prisma.tubeStrategistEpisode.update({
|
||||||
|
where: { id: episodeId },
|
||||||
|
data: { status: 'ANALYZING' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier 2: Pre-Production Master AI Analysis
|
||||||
|
const projectContext = episode.project.videos.map((v, i) => `
|
||||||
|
--- REFERANS VİDEO ${i + 1} ---
|
||||||
|
Başlık: ${v.title}
|
||||||
|
İzlenme: ${v.viewCount}
|
||||||
|
Yorum Sayısı: ${v.totalComments}
|
||||||
|
Özet (Tier-1): ${JSON.stringify(v.tier1Analysis)}
|
||||||
|
`).join('\n\n');
|
||||||
|
|
||||||
|
const schema: any = {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
titleSuggestions: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
title: { type: Type.STRING },
|
||||||
|
seoScore: { type: Type.NUMBER, description: "1-100 arası SEO puanı" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: "Yeni bölüm için en az 5 adet çarpıcı ve tıklamaya teşvik eden başlık önerisi. Kesinlikle en az 5 tane olmalıdır."
|
||||||
|
},
|
||||||
|
suggestedTopic: { type: Type.STRING, description: "Kullanıcı konu belirlemediyse, yapay zeka tarafından bulunan yeni ve eşsiz konu başlığı" },
|
||||||
|
psychologicalTheme: { type: Type.STRING },
|
||||||
|
hook: { type: Type.STRING, description: "İlk 15 saniyede söylenecek vurucu kanca" },
|
||||||
|
thumbnailConcept: { type: Type.STRING },
|
||||||
|
segments: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
type: { type: Type.STRING },
|
||||||
|
duration: { type: Type.STRING },
|
||||||
|
description: { type: Type.STRING },
|
||||||
|
keyPoints: { type: Type.ARRAY, items: { type: Type.STRING } },
|
||||||
|
neuroObjective: { type: Type.STRING }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
gapAnalysis: {
|
||||||
|
type: Type.STRING,
|
||||||
|
description: "Geçmiş videolardaki izleyici yorumlarına ve taleplerine dayanarak, bu yeni bölümde KESİNLİKLE değinilmesi gereken boşluklar ve nedenleri."
|
||||||
|
},
|
||||||
|
segmentArchetypes: {
|
||||||
|
type: Type.STRING,
|
||||||
|
description: "Kanalın önceki videolarının temposuna göre bu bölüm için önerilen dinamik zaman çizelgesi ve akış mimarisi."
|
||||||
|
},
|
||||||
|
frictionPoints: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: { type: Type.STRING },
|
||||||
|
description: "İzleyiciyi ikiye bölecek, saygı çerçevesinde tartışma ve yorum yazmaya itecek 'Şeytanın Avukatı' provokasyonları."
|
||||||
|
},
|
||||||
|
visualDna: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
timestamp: { type: Type.STRING },
|
||||||
|
suggestion: { type: Type.STRING }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: "Hangi dakikalarda ekrana nasıl bir B-Roll (ara görüntü) veya metafor girmesi gerektiğine dair detaylı 'Shot List'."
|
||||||
|
},
|
||||||
|
guestBriefing: {
|
||||||
|
type: Type.STRING,
|
||||||
|
description: "Eğer bir konuk alınacaksa (veya sunucu için), programın ruhunu, kitleyi ve sorulacak ana temaları özetleyen 1 sayfalık bilgi notu."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: [
|
||||||
|
'titleSuggestions',
|
||||||
|
'segments',
|
||||||
|
'gapAnalysis',
|
||||||
|
'segmentArchetypes',
|
||||||
|
'frictionPoints',
|
||||||
|
'visualDna',
|
||||||
|
'guestBriefing'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = `Sen uzman bir Youtube Yapımcısı (Producer) ve İçerik Stratejistisin.
|
||||||
|
Aşağıda kanalın önceki videolarının özetleri, izlenme sayıları ve yorum analizleri (VERİSETİ) verilmiştir.
|
||||||
|
|
||||||
|
Senin görevin bu verisetinden yararlanarak, kullanıcının belirlediği YENİ BÖLÜM için kusursuz bir Ön-Yapım (Pre-Production) Tasarımı ortaya çıkartmaktır.
|
||||||
|
|
||||||
|
YENİ BÖLÜM PARAMETRELERİ:
|
||||||
|
Konu Başlığı: ${episode.topic}
|
||||||
|
Hedef Kitle: ${episode.targetAudience || 'Genel'}
|
||||||
|
Bölüm Uzunluğu: ${episode.duration || 'Belirtilmedi'}
|
||||||
|
Format: ${episode.format || 'Belirtilmedi'}
|
||||||
|
${episode.project?.formatDescription ? `\n PROJE FORMAT AÇIKLAMASI: ${episode.project.formatDescription}` : ''}
|
||||||
|
|
||||||
|
ZORUNLU KURALLAR:
|
||||||
|
1. titleSuggestions içerisinde EN AZ 5 adet çarpıcı başlık önerisi ve SEO skoru (1-100) ver.
|
||||||
|
2. gapAnalysis, segmentArchetypes, frictionPoints, visualDna, guestBriefing alanlarını mutlaka çok vizyoner bir şekilde doldur.
|
||||||
|
3. Tüm tasarım, geçmiş verilerdeki izleyici tepkilerinden ilham almalıdır. "Önceki X videosundaki yoğun yorumlara dayanarak..." gibi atıflar yapabilirsin.
|
||||||
|
4. Bölümün akış tasarımı (segmentArchetypes) neuromarketing detayları barındırmalı ve konu tasarımında devamlı 'brain hook'lar (beyin kancaları) ile seyircinin dikkatini bölüme odaklayacak bir yapı ortaya çıkmalı.
|
||||||
|
${episode.topic === 'AI_AUTO' ? '\n ÖZEL TALİMAT: Kullanıcı konu başlığını senin belirlemeni istiyor. Elindeki verisetini kullanarak, daha önce işlenmemiş, KANAL İÇİN YENİ VE EŞSİZ bir konu başlığı üret. "suggestedTopic" alanını bu yeni konu ile doldur ve TÜM TASARIMI bu yeni konu başlığı etrafında şekillendir.' : ''}
|
||||||
|
|
||||||
|
REFERANS VERİSETİ:
|
||||||
|
${projectContext}`;
|
||||||
|
|
||||||
|
const response = await this.withRetry(() => this.ai.models.generateContent({
|
||||||
|
model: 'gemini-2.5-pro',
|
||||||
|
contents: prompt,
|
||||||
|
config: {
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
responseSchema: schema,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const masterAnalysis = JSON.parse(response.text || '{}');
|
||||||
|
|
||||||
|
const existingMasterAnalysis = (episode.masterAnalysis as any) || {};
|
||||||
|
const mergedAnalysis = {
|
||||||
|
...existingMasterAnalysis,
|
||||||
|
...masterAnalysis
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateData: any = {
|
||||||
|
masterAnalysis: mergedAnalysis,
|
||||||
|
status: 'COMPLETED'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (episode.topic === 'AI_AUTO' && masterAnalysis.suggestedTopic) {
|
||||||
|
updateData.topic = masterAnalysis.suggestedTopic;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.tubeStrategistEpisode.update({
|
||||||
|
where: { id: episodeId },
|
||||||
|
data: updateData
|
||||||
|
});
|
||||||
|
|
||||||
|
return mergedAnalysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// PROJECT MANAGEMENT METHODS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
async updateProject(projectId: string, userId: string, dto: UpdateProjectDto) {
|
||||||
|
const project = await this.getProjectById(projectId, userId);
|
||||||
|
|
||||||
|
return this.prisma.tubeStrategistProject.update({
|
||||||
|
where: { id: project.id },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined && { name: dto.name }),
|
||||||
|
...(dto.tone !== undefined && { tone: dto.tone }),
|
||||||
|
...(dto.targetDuration !== undefined && { duration: dto.targetDuration }),
|
||||||
|
...(dto.speakerName !== undefined && { speakerName: dto.speakerName }),
|
||||||
|
...(dto.targetAudience !== undefined && { targetAudience: dto.targetAudience }),
|
||||||
|
...(dto.formatDescription !== undefined && { formatDescription: dto.formatDescription }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
videos: true,
|
||||||
|
episodes: { orderBy: { createdAt: 'desc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addDocumentToProject(projectId: string, userId: string, dto: AddDocumentDto) {
|
||||||
|
await this.getProjectById(projectId, userId);
|
||||||
|
|
||||||
|
const docId = `doc://${dto.type}-${Date.now()}`;
|
||||||
|
|
||||||
|
// Tier-1 AI analizi
|
||||||
|
let tier1Analysis = {};
|
||||||
|
try {
|
||||||
|
const prompt = `Sen uzman bir Youtube Data Analistisin. Aşağıda bir ${dto.type === 'transcript' ? 'transkript' : 'yorum seti'} verilmiştir. Lütfen ana fikrini, insanların ne hissettiğini ve içgörüleri özetle.
|
||||||
|
|
||||||
|
BAŞLIK: ${dto.title}
|
||||||
|
İÇERİK (ilk 30000 karakter):
|
||||||
|
${dto.content.substring(0, 30000)}
|
||||||
|
|
||||||
|
JSON Formatında dön:
|
||||||
|
{
|
||||||
|
"summary": "Kısa özet",
|
||||||
|
"sentiment": "Genel duygu durumu (pozitif/negatif/nötr)",
|
||||||
|
"keyInsights": ["İçgörü 1", "İçgörü 2"],
|
||||||
|
"audienceReaction": "İzleyici tepkisi analizi"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = await this.ai.models.generateContent({
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
contents: prompt,
|
||||||
|
config: { responseMimeType: 'application/json' },
|
||||||
|
});
|
||||||
|
tier1Analysis = JSON.parse(result.text || '{}');
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error(`Document Tier-1 AI Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.tubeStrategistVideo.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
youtubeUrl: docId,
|
||||||
|
videoId: docId,
|
||||||
|
title: dto.title,
|
||||||
|
transcript: dto.type === 'transcript' ? dto.content.substring(0, 50000) : null,
|
||||||
|
commentsJson: dto.type === 'comments' ? { manualComments: dto.content.substring(0, 50000) } : undefined,
|
||||||
|
totalComments: dto.type === 'comments' ? 1 : 0,
|
||||||
|
mainComments: dto.type === 'comments' ? 1 : 0,
|
||||||
|
replyComments: 0,
|
||||||
|
tier1Analysis,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopicSuggestions(projectId: string, userId: string) {
|
||||||
|
const project = await this.getProjectById(projectId, userId);
|
||||||
|
|
||||||
|
// Mevcut bölüm konularını al (tekrar önermesin)
|
||||||
|
const existingTopics = (project.episodes || []).map((ep: any) => ep.topic).filter(Boolean);
|
||||||
|
|
||||||
|
// Veriseti context'ini hazırla
|
||||||
|
let datasetContext = '';
|
||||||
|
for (const vid of project.videos) {
|
||||||
|
const t1 = (vid.tier1Analysis as any) || {};
|
||||||
|
datasetContext += `\n--- VİDEO: ${vid.title || vid.youtubeUrl} ---\n`;
|
||||||
|
datasetContext += `Özet: ${t1.summary || 'Yok'}\nDuygu: ${t1.sentiment || 'Yok'}\nİçgörüler: ${(t1.keyInsights || []).join(', ')}\n`;
|
||||||
|
if (vid.commentsJson) {
|
||||||
|
const comments = Array.isArray(vid.commentsJson) ? vid.commentsJson : (vid.commentsJson as any)?.manualComments ? [{ text: (vid.commentsJson as any).manualComments }] : [];
|
||||||
|
const topComments = comments.slice(0, 10).map((c: any) => c.text).join('\n');
|
||||||
|
datasetContext += `Top Yorumlar:\n${topComments}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema: any = {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
suggestions: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
title: { type: Type.STRING, description: 'Konu başlığı' },
|
||||||
|
description: { type: Type.STRING, description: 'Bu konunun kısa açıklaması (2-3 cümle)' },
|
||||||
|
reasoning: { type: Type.STRING, description: 'Bu konuyu neden önerdiğin (veriye dayalı gerekçe)' },
|
||||||
|
},
|
||||||
|
required: ['title', 'description', 'reasoning'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['suggestions'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = `Sen uzman bir YouTube İçerik Stratejistisin.
|
||||||
|
Aşağıda bir kanalın önceki videolarının analizleri ve izleyici yorumları verilmiştir.
|
||||||
|
|
||||||
|
Senin görevin bu verilerden yola çıkarak KANAL İÇİN 5 ADET YENİ VE EŞSİZ KONU BAŞLIĞI önermektir.
|
||||||
|
|
||||||
|
${project.formatDescription ? `PROJE FORMAT AÇIKLAMASI: ${project.formatDescription}\n` : ''}
|
||||||
|
|
||||||
|
KURALLAR:
|
||||||
|
1. Öneriler daha önce işlenmiş konulardan FARKLI olmalı. Daha önce işlenen konular: [${existingTopics.join(', ')}]
|
||||||
|
2. Yorumlarda izleyicilerin "şundan da bahsedin", "bunu da işleyin" gibi taleplerini değerlendir.
|
||||||
|
3. Hiç işlenmemiş, vizyoner ve seyirci çekecek konular öner.
|
||||||
|
4. Her öneri için kısa bir açıklama ve veri-bazlı gerekçe (reasoning) yaz.
|
||||||
|
5. TAM OLARAK 5 adet öneri üret.
|
||||||
|
|
||||||
|
VERİSETİ:
|
||||||
|
${datasetContext}`;
|
||||||
|
|
||||||
|
const response = await this.withRetry(() => this.ai.models.generateContent({
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
contents: prompt,
|
||||||
|
config: {
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
responseSchema: schema,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return JSON.parse(response.text || '{"suggestions": []}');
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateMoreQuestions(episodeId: string, userId: string, currentQuestionsCount: number = 20) {
|
||||||
|
const episode = await this.getEpisodeById(episodeId, userId);
|
||||||
|
if (!episode.masterAnalysis) {
|
||||||
|
throw new Error("Bu bölüm henüz temel analizden geçmemiş.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterAnalysis = episode.masterAnalysis as any;
|
||||||
|
const existingQuestions = masterAnalysis.interviewQuestions || [];
|
||||||
|
|
||||||
|
const schema: any = {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
newQuestions: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
question: { type: Type.STRING, description: "Derin, kışkırtıcı veya sarsıcı mülakat/röportaj sorusu" },
|
||||||
|
neuroMarketingAnswerDirection: { type: Type.STRING, description: "Neuro marketing esaslarını düşünerek, bu soruya cevabın ne yönde verilmesi ve cevaba nasıl başlanması gerektiğini anlatan stratejik ipuçları." },
|
||||||
|
neuroMarketingScore: { type: Type.NUMBER, description: "Bu sorunun kitledeki nöro-pazarlama etki skoru (1-100)" },
|
||||||
|
targetArea: { type: Type.STRING, description: "Bu sorunun etkileyeceği nöro-pazarlama alanı (Örn: Korku, Aidiyet, Merak, Güven vb.)" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: "TAM OLARAK 5 adet yeni derin, kışkırtıcı veya sarsıcı soru."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['newQuestions']
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = `Sen uzman bir Youtube Yapımcısı (Producer) ve İçerik Stratejistisin.
|
||||||
|
Mevcut bölümde "${episode.topic}" konusu işleniyor.
|
||||||
|
Daha önce ${existingQuestions.length} adet soru ürettin.
|
||||||
|
Aşağıda daha önce üretilmiş sorular yer alıyor, LÜTFEN BUNLARDAN FARKLI YENİ 5 SORU ÜRET.
|
||||||
|
|
||||||
|
MEVCUT SORULAR:
|
||||||
|
${existingQuestions.map((q: any) => typeof q === 'string' ? q : q.question).join('\n')}
|
||||||
|
|
||||||
|
KURALLAR:
|
||||||
|
1. TAM OLARAK 5 adet yepyeni soru üret.
|
||||||
|
2. Sorular kışkırtıcı, derin veya tartışma yaratacak ('Şeytanın Avukatı' tarzı) olmalı.
|
||||||
|
3. Her soru için neuroMarketingAnswerDirection belirle.
|
||||||
|
4. Her soru için neuroMarketingScore (1-100) ve targetArea belirle.`;
|
||||||
|
|
||||||
|
const response = await this.withRetry(() => this.ai.models.generateContent({
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
contents: prompt,
|
||||||
|
config: {
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
responseSchema: schema,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = JSON.parse(response.text || '{"newQuestions": []}');
|
||||||
|
const newQuestions = result.newQuestions || [];
|
||||||
|
|
||||||
|
// Mevcut analiz verisine yeni soruları ekle
|
||||||
|
masterAnalysis.interviewQuestions = [...existingQuestions, ...newQuestions];
|
||||||
|
|
||||||
|
// Veritabanını güncelle
|
||||||
|
await this.prisma.tubeStrategistEpisode.update({
|
||||||
|
where: { id: episodeId },
|
||||||
|
data: { masterAnalysis }
|
||||||
|
});
|
||||||
|
|
||||||
|
return masterAnalysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateEpisodeQuestions(episodeId: string, userId: string) {
|
||||||
|
const episode = await this.getEpisodeById(episodeId, userId);
|
||||||
|
if (!episode.masterAnalysis) {
|
||||||
|
throw new Error("Bu bölüm henüz temel analizden geçmemiş.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterAnalysis = episode.masterAnalysis as any;
|
||||||
|
|
||||||
|
const schema: any = {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
interviewQuestions: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
question: { type: Type.STRING, description: "Derin, kışkırtıcı veya sarsıcı mülakat/röportaj sorusu" },
|
||||||
|
neuroMarketingAnswerDirection: { type: Type.STRING, description: "Neuro marketing esaslarını düşünerek, bu soruya cevabın ne yönde verilmesi ve cevaba nasıl başlanması gerektiğini anlatan stratejik ipuçları." },
|
||||||
|
neuroMarketingScore: { type: Type.NUMBER, description: "Bu sorunun kitledeki nöro-pazarlama etki skoru (1-100)" },
|
||||||
|
targetArea: { type: Type.STRING, description: "Bu sorunun etkileyeceği nöro-pazarlama alanı (Örn: Korku, Aidiyet, Merak, Güven vb.)" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: "TAM OLARAK 20 adet derin, kışkırtıcı veya sarsıcı mülakat/röportaj sorusu ve detayları"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['interviewQuestions']
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = `Sen uzman bir Youtube Yapımcısı (Producer) ve İçerik Stratejistisin.
|
||||||
|
Mevcut bölümde "${episode.topic}" konusu işleniyor.
|
||||||
|
Lütfen bu konu etrafında şekillenen TAM OLARAK 20 ADET kışkırtıcı, derin ve nöro-pazarlama odaklı soru üret.
|
||||||
|
|
||||||
|
Her soru için neuroMarketingScore (1-100) ve targetArea alanlarını (örn: Korku, Merak, Tatmin vb.) doldurmayı unutma.`;
|
||||||
|
|
||||||
|
const response = await this.withRetry(() => this.ai.models.generateContent({
|
||||||
|
model: 'gemini-2.5-pro',
|
||||||
|
contents: prompt,
|
||||||
|
config: {
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
responseSchema: schema,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = JSON.parse(response.text || '{"interviewQuestions": []}');
|
||||||
|
masterAnalysis.interviewQuestions = result.interviewQuestions || [];
|
||||||
|
|
||||||
|
await this.prisma.tubeStrategistEpisode.update({
|
||||||
|
where: { id: episodeId },
|
||||||
|
data: { masterAnalysis }
|
||||||
|
});
|
||||||
|
|
||||||
|
return masterAnalysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateEpisodeSeoMarketing(episodeId: string, userId: string) {
|
||||||
|
const episode = await this.getEpisodeById(episodeId, userId);
|
||||||
|
if (!episode.masterAnalysis) throw new Error("Temel analiz yok.");
|
||||||
|
const masterAnalysis = episode.masterAnalysis as any;
|
||||||
|
|
||||||
|
const schema: any = {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
seoAnalysis: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
targetKeywords: { type: Type.ARRAY, items: { type: Type.STRING } },
|
||||||
|
searchIntent: { type: Type.STRING },
|
||||||
|
suggestedTags: { type: Type.ARRAY, items: { type: Type.STRING } },
|
||||||
|
descriptionTemplate: { type: Type.STRING }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
marketingAnalysis: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
neuroMarketingTriggers: { type: Type.ARRAY, items: { type: Type.STRING } },
|
||||||
|
audiencePsychology: { type: Type.STRING },
|
||||||
|
thumbnailHookAlignment: { type: Type.STRING }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['seoAnalysis', 'marketingAnalysis']
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = `Konu: ${episode.topic}\nFormat: ${episode.format}\n\nBu bölüm için vizyoner bir SEO analizi ve Marketing analizi üret.
|
||||||
|
ÖNEMLİ KURAL: 'targetKeywords' içerisine EN AZ 20 ADET anahtar kelime ekleyeceksin. Bu kelimeleri arama hacmi en yüksek ve en güçlü olanlardan başlayarak sırayla (azalan şekilde) yaz.`;
|
||||||
|
|
||||||
|
const response = await this.withRetry(() => this.ai.models.generateContent({
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
contents: prompt,
|
||||||
|
config: { responseMimeType: 'application/json', responseSchema: schema }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = JSON.parse(response.text || '{}');
|
||||||
|
masterAnalysis.seoAnalysis = result.seoAnalysis;
|
||||||
|
masterAnalysis.marketingAnalysis = result.marketingAnalysis;
|
||||||
|
|
||||||
|
await this.prisma.tubeStrategistEpisode.update({
|
||||||
|
where: { id: episodeId }, data: { masterAnalysis }
|
||||||
|
});
|
||||||
|
return masterAnalysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateEpisodeCrisisSponsors(episodeId: string, userId: string) {
|
||||||
|
const episode = await this.getEpisodeById(episodeId, userId);
|
||||||
|
if (!episode.masterAnalysis) throw new Error("Temel analiz yok.");
|
||||||
|
const masterAnalysis = episode.masterAnalysis as any;
|
||||||
|
|
||||||
|
const schema: any = {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
crisisManagement: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
potentialBacklash: { type: Type.STRING },
|
||||||
|
prStrategy: { type: Type.STRING }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sponsors: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
brandName: { type: Type.STRING },
|
||||||
|
integrationStrategy: { type: Type.STRING },
|
||||||
|
coldEmailDraft: { type: Type.STRING, description: "Bu markaya gönderilecek işbirliği için Türkçe soğuk e-posta (cold email) taslağı" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: "Konuyla doğrudan eşleşebilecek TAM OLARAK 10 adet marka ve onlara özel mail taslakları"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['crisisManagement', 'sponsors']
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = `Konu: ${episode.topic}\n\nBu bölüm için olası linç ihtimallerini (Crisis Management) belirle. Ardından bu bölüme sponsor olabilecek TAM 10 adet marka öner. Bu markaların her birine Türkçe, ikna edici ve nöro-pazarlama teknikleri kullanılmış birer Soğuk E-Posta taslağı yaz.`;
|
||||||
|
|
||||||
|
const response = await this.withRetry(() => this.ai.models.generateContent({
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
contents: prompt,
|
||||||
|
config: { responseMimeType: 'application/json', responseSchema: schema }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = JSON.parse(response.text || '{}');
|
||||||
|
masterAnalysis.crisisManagement = result.crisisManagement;
|
||||||
|
masterAnalysis.sponsors = result.sponsors;
|
||||||
|
|
||||||
|
await this.prisma.tubeStrategistEpisode.update({
|
||||||
|
where: { id: episodeId }, data: { masterAnalysis }
|
||||||
|
});
|
||||||
|
return masterAnalysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateThumbnail(episodeId: string, userId: string) {
|
||||||
|
const episode = await this.getEpisodeById(episodeId, userId);
|
||||||
|
if (!episode.masterAnalysis) throw new Error("Temel analiz yok.");
|
||||||
|
|
||||||
|
const masterAnalysis = episode.masterAnalysis as any;
|
||||||
|
const thumbnailConcept = masterAnalysis.thumbnailConcept || episode.topic;
|
||||||
|
|
||||||
|
// Yüksek kaliteli 16:9 thumbnail promptu oluştur
|
||||||
|
const prompt = `Yüksek kaliteli YouTube video küçük resmi (thumbnail). Konsept: ${thumbnailConcept}.
|
||||||
|
Sinematik aydınlatma, canlı renkler, yüksek kontrast, ultra detaylı. İzleyicinin dikkatini çekecek kompozisyon. Üzerinde herhangi bir metin OLMAMALI.`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 16:9 = 1920x1080 -> aspectRatio '16:9'
|
||||||
|
const imageUrl = await this.geminiService.generateImage(prompt, '16:9');
|
||||||
|
|
||||||
|
masterAnalysis.thumbnailUrl = imageUrl;
|
||||||
|
|
||||||
|
await this.prisma.tubeStrategistEpisode.update({
|
||||||
|
where: { id: episodeId },
|
||||||
|
data: { masterAnalysis }
|
||||||
|
});
|
||||||
|
|
||||||
|
return { thumbnailUrl: imageUrl };
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Thumbnail üretilirken hata oluştu: ${error.message}`);
|
||||||
|
throw new Error(`Thumbnail üretilemedi: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Controller, Post, Body, Get, Param, UseGuards, HttpCode, HttpStatus, Req } from '@nestjs/common';
|
import { Controller, Post, Put, Body, Get, Param, UseGuards, HttpCode, HttpStatus, Req } from '@nestjs/common';
|
||||||
import { YoutubeToolsService } from './youtube-tools.service';
|
import { YoutubeToolsService } from './youtube-tools.service';
|
||||||
import { TubeStrategistService } from './tube-strategist.service';
|
import { TubeStrategistService } from './tube-strategist.service';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/auth.guards';
|
import { JwtAuthGuard } from '../auth/guards/auth.guards';
|
||||||
import { AnalyzeContentDto, CommercialAnalysisDto, StrategyResultDto } from './dto/tube-strategist.dto';
|
import { AnalyzeContentDto, CommercialAnalysisDto, StrategyResultDto, CreateProjectDto, UpdateProjectDto, AddVideoDto, AddDocumentDto, CreateEpisodeDto } from './dto/tube-strategist.dto';
|
||||||
|
|
||||||
@ApiTags('youtube-tools')
|
@ApiTags('youtube-tools')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -75,6 +75,93 @@ export class YoutubeToolsController {
|
|||||||
// TUBE STRATEGIST ENDPOINTS
|
// TUBE STRATEGIST ENDPOINTS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
|
@Post('strategist/projects')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Yeni proje oluşturur' })
|
||||||
|
async createProject(@Body() dto: CreateProjectDto, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.createProject(req.user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('strategist/projects')
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Kullanıcının projelerini getirir' })
|
||||||
|
async getProjects(@Req() req: any) {
|
||||||
|
return this.tubeStrategistService.getProjects(req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('strategist/projects/:id')
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Belirli bir projenin detaylarını getirir' })
|
||||||
|
async getProjectById(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.getProjectById(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('strategist/projects/:id/video')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Projeye video ekler ve Tier-1 analizi yapar' })
|
||||||
|
async addVideoToProject(@Param('id') id: string, @Body() dto: AddVideoDto, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.addVideoToProject(id, req.user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('strategist/projects/:id/episode')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Projeye bağlı yeni bir bölüm (episode) tasarımı oluşturur' })
|
||||||
|
async createEpisode(@Param('id') id: string, @Body() dto: CreateEpisodeDto, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.createEpisode(id, req.user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('strategist/projects/:id/episodes')
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Projenin tüm bölümlerini getirir' })
|
||||||
|
async getEpisodesByProject(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.getEpisodesByProject(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('strategist/episodes/:id')
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Bölüm detaylarını getirir' })
|
||||||
|
async getEpisodeById(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.getEpisodeById(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('strategist/episodes/:id/analyze')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Bölüm verisi üzerinden Ön-Yapım analizi (Tier-2) yapar' })
|
||||||
|
async analyzeEpisode(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.analyzeEpisode(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('strategist/episodes/:id/generate-more-questions')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Bölüm verisi için 5 yeni soru üretir ve analiz dosyasına ekler' })
|
||||||
|
async generateMoreQuestions(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.generateMoreQuestions(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('strategist/episodes/:id/generate-questions')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Bölüm için 20 adet soru üretir' })
|
||||||
|
async generateEpisodeQuestions(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.generateEpisodeQuestions(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('strategist/episodes/:id/generate-seo')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Bölüm için SEO ve Pazarlama verilerini üretir' })
|
||||||
|
async generateEpisodeSeoMarketing(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.generateEpisodeSeoMarketing(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('strategist/episodes/:id/generate-crisis')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Bölüm için Kriz ve Sponsor verilerini üretir' })
|
||||||
|
async generateEpisodeCrisisSponsors(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.generateEpisodeCrisisSponsors(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('strategist/episodes/:id/generate-thumbnail')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Bölüm için 16:9 Thumbnail görseli üretir' })
|
||||||
|
async generateThumbnail(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.generateThumbnail(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('strategist/analyze')
|
@Post('strategist/analyze')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Tube Strategist: Ana İçerik Stratejisi Analizi' })
|
@ApiOperation({ summary: 'Tube Strategist: Ana İçerik Stratejisi Analizi' })
|
||||||
@@ -82,31 +169,58 @@ export class YoutubeToolsController {
|
|||||||
return this.tubeStrategistService.analyzeContent(dto);
|
return this.tubeStrategistService.analyzeContent(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('strategist/seo')
|
@Post('strategist/projects/:id/seo')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Tube Strategist: SEO Raporu Üretimi' })
|
@ApiOperation({ summary: 'Tube Strategist: SEO Raporu Üretimi' })
|
||||||
async strategistSeo(@Body() dto: StrategyResultDto) {
|
async strategistSeo(@Param('id') id: string, @Req() req: any) {
|
||||||
return this.tubeStrategistService.generateSeoReport(dto);
|
return this.tubeStrategistService.generateSeoReport(id, req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('strategist/neuro')
|
@Post('strategist/projects/:id/neuro')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Tube Strategist: Nöro-Pazarlama Raporu Üretimi' })
|
@ApiOperation({ summary: 'Tube Strategist: Nöro-Pazarlama Raporu Üretimi' })
|
||||||
async strategistNeuro(@Body() dto: StrategyResultDto) {
|
async strategistNeuro(@Param('id') id: string, @Req() req: any) {
|
||||||
return this.tubeStrategistService.generateNeuroReport(dto);
|
return this.tubeStrategistService.generateNeuroReport(id, req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('strategist/marketing')
|
@Post('strategist/projects/:id/marketing')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Tube Strategist: Pazarlama & Viral Raporu Üretimi' })
|
@ApiOperation({ summary: 'Tube Strategist: Pazarlama & Viral Raporu Üretimi' })
|
||||||
async strategistMarketing(@Body() dto: StrategyResultDto) {
|
async strategistMarketing(@Param('id') id: string, @Req() req: any) {
|
||||||
return this.tubeStrategistService.generateMarketingReport(dto);
|
return this.tubeStrategistService.generateMarketingReport(id, req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('strategist/commercial')
|
@Post('strategist/projects/:id/commercial')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Tube Strategist: Ticari Sponsorluk Taslağı Üretimi' })
|
@ApiOperation({ summary: 'Tube Strategist: Ticari Sponsorluk Taslağı Üretimi' })
|
||||||
async strategistCommercial(@Body() dto: CommercialAnalysisDto) {
|
async strategistCommercial(@Param('id') id: string, @Req() req: any) {
|
||||||
return this.tubeStrategistService.generateDeepCommercialAnalysis(dto);
|
return this.tubeStrategistService.generateDeepCommercialAnalysis(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('strategist/projects/:id/thumbnail')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Thumbnail Üretimi' })
|
||||||
|
async strategistThumbnail(@Param('id') id: string, @Body('prompt') prompt: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.generateThumbnailImage(id, req.user.id, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('strategist/projects/:id')
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Proje ayarlarını günceller' })
|
||||||
|
async updateProject(@Param('id') id: string, @Body() dto: UpdateProjectDto, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.updateProject(id, req.user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('strategist/projects/:id/document')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Projeye manuel metin dokümanı ekler' })
|
||||||
|
async addDocumentToProject(@Param('id') id: string, @Body() dto: AddDocumentDto, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.addDocumentToProject(id, req.user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('strategist/projects/:id/topic-suggestions')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Yapay zekadan 5 konu önerisi alır' })
|
||||||
|
async getTopicSuggestions(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.getTopicSuggestions(id, req.user.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { Module } from '@nestjs/common';
|
|||||||
import { YoutubeToolsController } from './youtube-tools.controller';
|
import { YoutubeToolsController } from './youtube-tools.controller';
|
||||||
import { YoutubeToolsService } from './youtube-tools.service';
|
import { YoutubeToolsService } from './youtube-tools.service';
|
||||||
import { TubeStrategistService } from './tube-strategist.service';
|
import { TubeStrategistService } from './tube-strategist.service';
|
||||||
|
import { DatabaseModule } from '../../database/database.module';
|
||||||
import { GeminiModule } from '../gemini/gemini.module';
|
import { GeminiModule } from '../gemini/gemini.module';
|
||||||
|
import { VideoAiModule } from '../video-ai/video-ai.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [GeminiModule],
|
imports: [GeminiModule, DatabaseModule, VideoAiModule],
|
||||||
controllers: [YoutubeToolsController],
|
controllers: [YoutubeToolsController],
|
||||||
providers: [YoutubeToolsService, TubeStrategistService],
|
providers: [YoutubeToolsService, TubeStrategistService],
|
||||||
exports: [YoutubeToolsService, TubeStrategistService],
|
exports: [YoutubeToolsService, TubeStrategistService],
|
||||||
|
|||||||
Reference in New Issue
Block a user