diff --git a/Dockerfile b/Dockerfile index e314c53..73ff791 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,7 @@ RUN npx prisma generate COPY --chown=node:node --from=builder /app/dist ./dist # 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 ENV NODE_ENV=production @@ -62,4 +62,4 @@ RUN mkdir -p /data/media && chown -R node:node /data/media USER node # Uygulamayı başlat -CMD ["node", "dist/main.js"] \ No newline at end of file +CMD ["node", "dist/src/main.js"] \ No newline at end of file diff --git a/check_db.js b/check_db.js new file mode 100644 index 0000000..beed215 --- /dev/null +++ b/check_db.js @@ -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()); diff --git a/check_login.js b/check_login.js new file mode 100644 index 0000000..6a49ba7 --- /dev/null +++ b/check_login.js @@ -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(); diff --git a/check_login2.js b/check_login2.js new file mode 100644 index 0000000..d98d9e0 --- /dev/null +++ b/check_login2.js @@ -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(); diff --git a/check_login3.js b/check_login3.js new file mode 100644 index 0000000..040f19e --- /dev/null +++ b/check_login3.js @@ -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(); diff --git a/check_login4.js b/check_login4.js new file mode 100644 index 0000000..9f581db --- /dev/null +++ b/check_login4.js @@ -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(); diff --git a/check_me.js b/check_me.js new file mode 100644 index 0000000..845fd28 --- /dev/null +++ b/check_me.js @@ -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(); diff --git a/check_users.ts b/check_users.ts new file mode 100644 index 0000000..c624520 --- /dev/null +++ b/check_users.ts @@ -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(); + }); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index adb23b4..1d29ffb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,6 +35,7 @@ model User { preferences UserPreference? youtubeAnalyses YoutubeAnalysis[] youtubeSeoAnalyses YoutubeSeoAnalysis[] + tubeStrategistProjects TubeStrategistProject[] // Multi-tenancy (optional) tenantId String? @@ -712,3 +713,83 @@ model YoutubeSeoAnalysis { @@index([userId]) @@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]) +} diff --git a/reset_pwd.js b/reset_pwd.js new file mode 100644 index 0000000..9ac36d7 --- /dev/null +++ b/reset_pwd.js @@ -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()); diff --git a/src/main.ts b/src/main.ts index 345153b..1a1b2a5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -120,6 +120,11 @@ async function bootstrap() { }); 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'); diff --git a/src/main.ts.orig b/src/main.ts.orig new file mode 100644 index 0000000..345153b --- /dev/null +++ b/src/main.ts.orig @@ -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('PORT', 3000); + const nodeEnv = configService.get('NODE_ENV', 'development'); + + // ── Static File Serving — Medya dosyalarına HTTP erişim ── + const mediaPath = configService.get( + '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(); diff --git a/src/main.ts.patch b/src/main.ts.patch new file mode 100644 index 0000000..36b4323 --- /dev/null +++ b/src/main.ts.patch @@ -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'); + diff --git a/src/modules/youtube-tools/dto/tube-strategist.dto.ts b/src/modules/youtube-tools/dto/tube-strategist.dto.ts index ddc5d34..03195ec 100644 --- a/src/modules/youtube-tools/dto/tube-strategist.dto.ts +++ b/src/modules/youtube-tools/dto/tube-strategist.dto.ts @@ -1,48 +1,176 @@ +import { IsString, IsOptional, IsArray, ValidateNested, IsNumber, IsIn } from 'class-validator'; +import { Type } from 'class-transformer'; + export class UploadedFileDto { + @IsString() name: string; + + @IsString() content: string; } export class AnalyzeContentDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => UploadedFileDto) transcripts: UploadedFileDto[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => UploadedFileDto) comments: UploadedFileDto[]; - tone: string; - duration: string; - speakerName: string; - topicFocus: string; - targetAudience: string; + + @IsString() + @IsOptional() + tone?: string; + + @IsString() + @IsOptional() + duration?: string; + + @IsString() + @IsOptional() + speakerName?: string; + + @IsString() + @IsOptional() + topicFocus?: string; + + @IsString() + @IsOptional() + targetAudience?: string; } export class StrategyResultDto { + @IsString() title: string; + + @IsString() + @IsOptional() psychologicalTheme?: string; + + @IsString() + @IsOptional() inspiredByGap?: string; + + @IsString() + @IsOptional() hook?: string; + + @IsString() + @IsOptional() thumbnailConcept?: string; - segments: { - type: string; - duration: string; - description: string; - keyPoints: string[]; - neuroObjective?: string; - }[]; - interviewQuestions: string[]; - selectedComments: { - username?: string; - text: string; - insightValue?: string; - sourceFile?: string; - }[]; - commercialAnalysis: { - suitableIndustries: string[]; - brandSafetyScore: number; - suggestedBrands: string[]; - monetizationPotential: string; - }; - chartData?: { topic: string; emotionalArousal: number }[]; + + @IsArray() + @IsOptional() + segments?: any[]; + + @IsArray() + @IsOptional() + interviewQuestions?: string[]; + + @IsArray() + @IsOptional() + selectedComments?: any[]; + + @IsOptional() + commercialAnalysis?: any; + + @IsArray() + @IsOptional() + chartData?: any[]; } export class CommercialAnalysisDto { + @IsString() title: string; + + @IsArray() + @IsString({ each: true }) 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; +} diff --git a/src/modules/youtube-tools/tube-strategist.service.ts b/src/modules/youtube-tools/tube-strategist.service.ts index 9609713..088238d 100644 --- a/src/modules/youtube-tools/tube-strategist.service.ts +++ b/src/modules/youtube-tools/tube-strategist.service.ts @@ -1,18 +1,39 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; 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() export class TubeStrategistService { private readonly logger = new Logger(TubeStrategistService.name); 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 = this.configService.get('gemini.apiKey') || process.env.GOOGLE_API_KEY; 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(fn: () => Promise, retries = 3, delay = 2000): Promise { @@ -138,7 +159,11 @@ export class TubeStrategistService { }); } - async generateSeoReport(strategy: StrategyResultDto): Promise { + async generateSeoReport(projectId: string, userId: string): Promise { + const project = await this.getProjectById(projectId, userId); + const masterAnalysis: any = project.masterAnalysis || {}; + const title = masterAnalysis.title || project.name; + const schema: any = { type: Type.OBJECT, properties: { @@ -166,7 +191,7 @@ export class TubeStrategistService { }; const prompt = ` - Video: "${strategy.title}". + Video: "${title}". Görev: Profesyonel YouTube SEO analizi yap. Ö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. @@ -177,10 +202,18 @@ export class TubeStrategistService { contents: prompt, 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 { + async generateNeuroReport(projectId: string, userId: string): Promise { + const project = await this.getProjectById(projectId, userId); + const masterAnalysis: any = project.masterAnalysis || {}; + const title = masterAnalysis.title || project.name; + const schema: any = { type: Type.OBJECT, properties: { @@ -199,13 +232,21 @@ export class TubeStrategistService { }; const response = await this.ai.models.generateContent({ 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 }, }); - 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 { + async generateMarketingReport(projectId: string, userId: string): Promise { + const project = await this.getProjectById(projectId, userId); + const masterAnalysis: any = project.masterAnalysis || {}; + const title = masterAnalysis.title || project.name; + const schema: any = { type: Type.OBJECT, properties: { @@ -223,13 +264,23 @@ export class TubeStrategistService { }; const response = await this.ai.models.generateContent({ 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 }, }); - 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 { + async generateDeepCommercialAnalysis(projectId: string, userId: string): Promise { + 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 = { type: Type.OBJECT, properties: { @@ -242,14 +293,770 @@ export class TubeStrategistService { const response = await this.ai.models.generateContent({ 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: { responseMimeType: 'application/json', 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 { + 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}`); + } } } + diff --git a/src/modules/youtube-tools/youtube-tools.controller.ts b/src/modules/youtube-tools/youtube-tools.controller.ts index 9757885..3ba3215 100644 --- a/src/modules/youtube-tools/youtube-tools.controller.ts +++ b/src/modules/youtube-tools/youtube-tools.controller.ts @@ -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 { TubeStrategistService } from './tube-strategist.service'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; 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') @ApiBearerAuth() @@ -75,6 +75,93 @@ export class YoutubeToolsController { // 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') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Tube Strategist: Ana İçerik Stratejisi Analizi' }) @@ -82,31 +169,58 @@ export class YoutubeToolsController { return this.tubeStrategistService.analyzeContent(dto); } - @Post('strategist/seo') + @Post('strategist/projects/:id/seo') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Tube Strategist: SEO Raporu Üretimi' }) - async strategistSeo(@Body() dto: StrategyResultDto) { - return this.tubeStrategistService.generateSeoReport(dto); + async strategistSeo(@Param('id') id: string, @Req() req: any) { + return this.tubeStrategistService.generateSeoReport(id, req.user.id); } - @Post('strategist/neuro') + @Post('strategist/projects/:id/neuro') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Tube Strategist: Nöro-Pazarlama Raporu Üretimi' }) - async strategistNeuro(@Body() dto: StrategyResultDto) { - return this.tubeStrategistService.generateNeuroReport(dto); + async strategistNeuro(@Param('id') id: string, @Req() req: any) { + return this.tubeStrategistService.generateNeuroReport(id, req.user.id); } - @Post('strategist/marketing') + @Post('strategist/projects/:id/marketing') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Tube Strategist: Pazarlama & Viral Raporu Üretimi' }) - async strategistMarketing(@Body() dto: StrategyResultDto) { - return this.tubeStrategistService.generateMarketingReport(dto); + async strategistMarketing(@Param('id') id: string, @Req() req: any) { + return this.tubeStrategistService.generateMarketingReport(id, req.user.id); } - @Post('strategist/commercial') + @Post('strategist/projects/:id/commercial') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Tube Strategist: Ticari Sponsorluk Taslağı Üretimi' }) - async strategistCommercial(@Body() dto: CommercialAnalysisDto) { - return this.tubeStrategistService.generateDeepCommercialAnalysis(dto); + async strategistCommercial(@Param('id') id: string, @Req() req: any) { + 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); } } diff --git a/src/modules/youtube-tools/youtube-tools.module.ts b/src/modules/youtube-tools/youtube-tools.module.ts index 078fff2..966c1b6 100644 --- a/src/modules/youtube-tools/youtube-tools.module.ts +++ b/src/modules/youtube-tools/youtube-tools.module.ts @@ -2,10 +2,12 @@ import { Module } from '@nestjs/common'; import { YoutubeToolsController } from './youtube-tools.controller'; import { YoutubeToolsService } from './youtube-tools.service'; import { TubeStrategistService } from './tube-strategist.service'; +import { DatabaseModule } from '../../database/database.module'; import { GeminiModule } from '../gemini/gemini.module'; +import { VideoAiModule } from '../video-ai/video-ai.module'; @Module({ - imports: [GeminiModule], + imports: [GeminiModule, DatabaseModule, VideoAiModule], controllers: [YoutubeToolsController], providers: [YoutubeToolsService, TubeStrategistService], exports: [YoutubeToolsService, TubeStrategistService],