From 2e6c272eeead8cf20d3a284417744298d5a7eab5 Mon Sep 17 00:00:00 2001 From: Harun CAN Date: Mon, 11 May 2026 07:32:58 +0200 Subject: [PATCH] main --- Dockerfile | 4 +- check_db.js | 18 +- media-worker/Program.cs | 3 + media-worker/Services/VideoRenderPipeline.cs | 7 + media-worker/Services/VoiceboxTtsService.cs | 166 ++++ package.json | 1 + pnpm-lock.yaml | 21 +- prisma/schema.prisma | 762 +++++++++--------- seed_voice.js | 21 + src/app.module.ts | 2 + src/modules/voicebox/voicebox.controller.ts | 57 ++ src/modules/voicebox/voicebox.module.ts | 12 + src/modules/voicebox/voicebox.service.ts | 240 ++++++ .../youtube-tools/tube-strategist.service.ts | 239 +++++- .../youtube-tools/youtube-tools.controller.ts | 36 +- test-generate.js | 34 + test_sse.js | 37 + 17 files changed, 1260 insertions(+), 400 deletions(-) create mode 100644 media-worker/Services/VoiceboxTtsService.cs create mode 100644 seed_voice.js create mode 100644 src/modules/voicebox/voicebox.controller.ts create mode 100644 src/modules/voicebox/voicebox.module.ts create mode 100644 src/modules/voicebox/voicebox.service.ts create mode 100644 test-generate.js create mode 100644 test_sse.js diff --git a/Dockerfile b/Dockerfile index 73ff791..82bbbf1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /app RUN apk add --no-cache openssl libc6-compat # pnpm kurulumu (workspace kuralı gereği) -RUN corepack enable && corepack prepare pnpm@latest --activate +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate # Paket dosyalarını kopyala COPY package.json pnpm-lock.yaml ./ @@ -29,7 +29,7 @@ FROM node:20-alpine AS production RUN apk add --no-cache openssl libc6-compat # pnpm kurulumu -RUN corepack enable && corepack prepare pnpm@latest --activate +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate WORKDIR /app diff --git a/check_db.js b/check_db.js index beed215..00d50bc 100644 --- a/check_db.js +++ b/check_db.js @@ -1,12 +1,18 @@ 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 } } } +async function check() { + const episodes = await prisma.tubeStrategistEpisode.findMany({ + orderBy: { createdAt: 'desc' }, + take: 1 }); - console.log(JSON.stringify(admin, null, 2)); + + if (episodes.length > 0) { + const analysis = episodes[0].masterAnalysis; + console.log("Thumbnail URL:", JSON.stringify(analysis.thumbnailUrl, null, 2)); + } else { + console.log("No episodes found."); + } } -main().catch(console.error).finally(() => prisma.$disconnect()); +check().catch(console.error).finally(() => prisma.$disconnect()); diff --git a/media-worker/Program.cs b/media-worker/Program.cs index 8df6988..7b5936c 100644 --- a/media-worker/Program.cs +++ b/media-worker/Program.cs @@ -73,6 +73,9 @@ try builder.Services.AddHttpClient("MinimaxTTS") .AddPolicyHandler(combinedPolicy); + builder.Services.AddHttpClient("VoiceboxTTS") + .AddPolicyHandler(combinedPolicy); + builder.Services.AddHttpClient("Suno") .AddPolicyHandler(combinedPolicy); diff --git a/media-worker/Services/VideoRenderPipeline.cs b/media-worker/Services/VideoRenderPipeline.cs index f8e9209..626e838 100644 --- a/media-worker/Services/VideoRenderPipeline.cs +++ b/media-worker/Services/VideoRenderPipeline.cs @@ -25,6 +25,7 @@ public class VideoRenderPipeline private readonly TtsService _tts; private readonly OpenAiTtsService _openAiTts; private readonly MinimaxTtsService _minimaxTts; + private readonly VoiceboxTtsService _voiceboxTts; private readonly SunoMusicService _sunoMusic; private readonly AudioCraftService _audioCraft; private readonly RemotionService _remotion; @@ -38,6 +39,7 @@ public class VideoRenderPipeline TtsService tts, OpenAiTtsService openAiTts, MinimaxTtsService minimaxTts, + VoiceboxTtsService voiceboxTts, SunoMusicService sunoMusic, AudioCraftService audioCraft, RemotionService remotion, @@ -50,6 +52,7 @@ public class VideoRenderPipeline _tts = tts; _openAiTts = openAiTts; _minimaxTts = minimaxTts; + _voiceboxTts = voiceboxTts; _sunoMusic = sunoMusic; _audioCraft = audioCraft; _remotion = remotion; @@ -276,6 +279,10 @@ public class VideoRenderPipeline { result = await _minimaxTts.GenerateNarrationAsync(scene, outputDir, voiceStyle, ct); } + else if (!string.IsNullOrEmpty(scene.TtsProvider) && scene.TtsProvider.Equals("voicebox", StringComparison.OrdinalIgnoreCase)) + { + result = await _voiceboxTts.GenerateNarrationAsync(scene, outputDir, voiceStyle, ct); + } else { // Default: ElevenLabs diff --git a/media-worker/Services/VoiceboxTtsService.cs b/media-worker/Services/VoiceboxTtsService.cs new file mode 100644 index 0000000..01c3978 --- /dev/null +++ b/media-worker/Services/VoiceboxTtsService.cs @@ -0,0 +1,166 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SaasMediaWorker.Configuration; +using SaasMediaWorker.Models; + +namespace SaasMediaWorker.Services; + +/// +/// VoiceBox AI Studio Client — Metin → Ses dönüşümü (%100 Yerel ve Ücretsiz). +/// +public class VoiceboxTtsService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly ApiSettings _settings; + + public VoiceboxTtsService( + HttpClient httpClient, + ILogger logger, + IOptions settings) + { + _httpClient = httpClient; + _logger = logger; + _settings = settings.Value; + + // Docker ağı üzerinde voicebox servisine bağlanır + // C# projesinin appsettings.json dosyasından da okunabilir ama varsayılanı atıyoruz + _httpClient.BaseAddress = new Uri("http://contgen-ai-voicebox:17493/"); + _httpClient.Timeout = TimeSpan.FromMinutes(5); // Yerel render işlemci hızına bağlı sürebilir + } + + /// + /// Bir sahnenin narration metnini sese çevirir ve dosyaya kaydeder. + /// + public async Task GenerateNarrationAsync( + ScenePayload scene, + string outputDirectory, + string voiceStyle, + CancellationToken ct) + { + _logger.LogInformation( + "🎙️ VoiceBox TTS üretimi — Sahne {Order}: \"{Text}\"", + scene.Order, + scene.NarrationText[..Math.Min(60, scene.NarrationText.Length)]); + + // VoiceBox'ta varsayılan bir profil (Örn: Kokoro default) + var profileId = string.IsNullOrWhiteSpace(voiceStyle) ? "b6a8a474-0fc0-4a8f-b9f1-a1e4c84a8649" : voiceStyle; + + var requestBody = new + { + text = scene.NarrationText, + profile_id = profileId, + language = "tr", + engine = "kokoro" + }; + + var content = new StringContent( + JsonSerializer.Serialize(requestBody), + Encoding.UTF8, + "application/json"); + + // 1. Asenkron üretim başlat + var generateResponse = await _httpClient.PostAsync("generate", content, ct); + generateResponse.EnsureSuccessStatusCode(); + + var genJsonStr = await generateResponse.Content.ReadAsStringAsync(ct); + using var genDoc = JsonDocument.Parse(genJsonStr); + var generationId = genDoc.RootElement.GetProperty("id").GetString(); + + if (string.IsNullOrEmpty(generationId)) + { + throw new Exception("VoiceBox üretim başlatıldı ancak ID alınamadı."); + } + + // 2. Durumu polling ile kontrol et + string status = "generating"; + int attempts = 0; + int maxAttempts = 120; // 120 * 2sn = 4 dakika + + while (status != "completed" && status != "failed" && attempts < maxAttempts && !ct.IsCancellationRequested) + { + await Task.Delay(2000, ct); + + var historyResponse = await _httpClient.GetAsync("history", ct); + if (historyResponse.IsSuccessStatusCode) + { + var historyStr = await historyResponse.Content.ReadAsStringAsync(ct); + using var historyDoc = JsonDocument.Parse(historyStr); + + JsonElement itemsElement; + if (historyDoc.RootElement.ValueKind == JsonValueKind.Array) + { + itemsElement = historyDoc.RootElement; + } + else if (historyDoc.RootElement.TryGetProperty("items", out var itemsProp) && itemsProp.ValueKind == JsonValueKind.Array) + { + itemsElement = itemsProp; + } + else + { + continue; // Geçersiz format + } + + foreach (var item in itemsElement.EnumerateArray()) + { + if (item.GetProperty("id").GetString() == generationId) + { + if (item.TryGetProperty("status", out var statusProp)) + { + status = statusProp.GetString() ?? "completed"; + } + else + { + status = "completed"; + } + + if (status == "failed") + { + var errorMsg = item.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Bilinmeyen üretim hatası"; + throw new Exception($"VoiceBox ses üretemedi: {errorMsg}"); + } + break; + } + } + } + attempts++; + } + + if (status != "completed") + { + throw new Exception("VoiceBox ses üretimi zaman aşımına uğradı."); + } + + // 3. Üretilen ses dosyasını indir + var audioResponse = await _httpClient.GetAsync($"audio/{generationId}", ct); + audioResponse.EnsureSuccessStatusCode(); + + var audioBytes = await audioResponse.Content.ReadAsByteArrayAsync(ct); + + var extension = audioResponse.Content.Headers.ContentType?.MediaType == "audio/mpeg" ? "mp3" : "wav"; + var outputPath = Path.Combine(outputDirectory, $"scene_{scene.Order:D2}_narration.{extension}"); + + await File.WriteAllBytesAsync(outputPath, audioBytes, ct); + + var fileInfo = new FileInfo(outputPath); + + _logger.LogInformation( + "VoiceBox TTS tamamlandı — Sahne {Order}: {Size} bytes", + scene.Order, fileInfo.Length); + + return new GeneratedMediaFile + { + SceneId = scene.Id, + SceneOrder = scene.Order, + Type = MediaFileType.AudioNarration, + LocalPath = outputPath, + FileSizeBytes = fileInfo.Length, + DurationSeconds = scene.Duration, + MimeType = $"audio/{extension}", + AiProvider = "voicebox" + }; + } +} diff --git a/package.json b/package.json index 009c82c..3e57253 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.964.0", "@google/genai": "^1.35.0", + "@nestjs/axios": "^4.0.1", "@nestjs/bullmq": "^11.0.4", "@nestjs/cache-manager": "^3.1.0", "@nestjs/common": "^11.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4a4b11..dd3fdba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@google/genai': specifier: ^1.35.0 version: 1.47.0 + '@nestjs/axios': + specifier: ^4.0.1 + version: 4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.15.0)(rxjs@7.8.2) '@nestjs/bullmq': specifier: ^11.0.4 version: 11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(bullmq@5.71.1) @@ -46,7 +49,7 @@ importers: version: 11.2.6(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/terminus': specifier: ^11.0.0 - version: 11.1.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.15.0)(rxjs@7.8.2))(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/throttler': specifier: ^6.5.0 version: 6.5.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2) @@ -1136,6 +1139,13 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@nestjs/axios@4.0.1': + resolution: {integrity: sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + axios: ^1.3.1 + rxjs: ^7.0.0 + '@nestjs/bull-shared@11.0.4': resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==} peerDependencies: @@ -5793,6 +5803,12 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nestjs/axios@4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.15.0)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + axios: 1.15.0 + rxjs: 7.8.2 + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)': dependencies: '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5948,7 +5964,7 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.4 - '@nestjs/terminus@11.1.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/terminus@11.1.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.15.0)(rxjs@7.8.2))(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5957,6 +5973,7 @@ snapshots: reflect-metadata: 0.2.2 rxjs: 7.8.2 optionalDependencies: + '@nestjs/axios': 4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.15.0)(rxjs@7.8.2) '@prisma/client': 5.22.0(prisma@5.22.0) '@nestjs/testing@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-express@11.1.17)': diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1d29ffb..35b9582 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,72 +15,72 @@ datasource db { // ============================================ model User { - id String @id @default(uuid()) - email String @unique - password String - firstName String? - lastName String? - isActive Boolean @default(true) - + id String @id @default(uuid()) + email String @unique + password String + firstName String? + lastName String? + isActive Boolean @default(true) + // Core Relations roles UserRole[] refreshTokens RefreshToken[] - + // Video SaaS Relations - projects Project[] - subscriptions Subscription[] - creditTransactions CreditTransaction[] - templateUsages TemplateUsage[] - notifications Notification[] - preferences UserPreference? - youtubeAnalyses YoutubeAnalysis[] - youtubeSeoAnalyses YoutubeSeoAnalysis[] + projects Project[] + subscriptions Subscription[] + creditTransactions CreditTransaction[] + templateUsages TemplateUsage[] + notifications Notification[] + preferences UserPreference? + youtubeAnalyses YoutubeAnalysis[] + youtubeSeoAnalyses YoutubeSeoAnalysis[] tubeStrategistProjects TubeStrategistProject[] - + // Multi-tenancy (optional) - tenantId String? - tenant Tenant? @relation(fields: [tenantId], references: [id]) - + tenantId String? + tenant Tenant? @relation(fields: [tenantId], references: [id]) + // Timestamps & Soft Delete - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? @@index([email]) @@index([tenantId]) } model Role { - id String @id @default(uuid()) - name String @unique + id String @id @default(uuid()) + name String @unique description String? - isSystem Boolean @default(false) - + isSystem Boolean @default(false) + // Relations users UserRole[] permissions RolePermission[] - + // Timestamps & Soft Delete - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? @@index([name]) } model Permission { - id String @id @default(uuid()) - name String @unique + id String @id @default(uuid()) + name String @unique description String? - resource String // e.g., "users", "posts" - action String // e.g., "create", "read", "update", "delete" - + resource String // e.g., "users", "posts" + action String // e.g., "create", "read", "update", "delete" + // Relations - roles RolePermission[] - + roles RolePermission[] + // Timestamps - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([resource, action]) @@index([resource]) @@ -135,14 +135,14 @@ model RefreshToken { // ============================================ model Tenant { - id String @id @default(uuid()) - name String - slug String @unique - isActive Boolean @default(true) - + id String @id @default(uuid()) + name String + slug String @unique + isActive Boolean @default(true) + // Relations - users User[] - + users User[] + // Timestamps & Soft Delete createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -156,12 +156,12 @@ model Tenant { // ============================================ model Translation { - id String @id @default(uuid()) + id String @id @default(uuid()) key String - locale String // e.g., "en", "tr", "de" + locale String // e.g., "en", "tr", "de" value String - namespace String @default("common") // e.g., "common", "errors", "validation" - + namespace String @default("common") // e.g., "common", "errors", "validation" + // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -247,73 +247,73 @@ enum SourceType { // ============================================ model Project { - id String @id @default(uuid()) - title String @db.VarChar(200) - description String? @db.VarChar(1000) - prompt String @db.Text - + id String @id @default(uuid()) + title String @db.VarChar(200) + description String? @db.VarChar(1000) + prompt String @db.Text + // AI Generated Script - scriptJson Json? // Gemini API raw JSON output - scriptVersion Int @default(0) - + scriptJson Json? // Gemini API raw JSON output + scriptVersion Int @default(0) + // Configuration - language String @default("tr") @db.VarChar(5) // ISO 639-1 - aspectRatio AspectRatio @default(PORTRAIT_9_16) - videoStyle String @default("CINEMATIC") @db.VarChar(50) - cinematicReference String? @db.VarChar(200) - targetDuration Int @default(60) // saniye - + language String @default("tr") @db.VarChar(5) // ISO 639-1 + aspectRatio AspectRatio @default(PORTRAIT_9_16) + videoStyle String @default("CINEMATIC") @db.VarChar(50) + cinematicReference String? @db.VarChar(200) + targetDuration Int @default(60) // saniye + // SEO & Social Content (skill-enhanced) - seoKeywords String[] // Hedeflenen SEO anahtar kelimeler - seoTitle String? @db.VarChar(200) - seoDescription String? @db.VarChar(500) - seoTitleAlts String[] // 5 alternatif SEO başlığı (AI üretimi) - seoScore Int? // 0-100 arası SEO güç skoru - seoSchemaJson Json? // VideoObject structured data - socialContent Json? // { youtubeTitle, tiktokCaption, instagramCaption, twitterText } - referenceUrl String? @db.VarChar(500) - + seoKeywords String[] // Hedeflenen SEO anahtar kelimeler + seoTitle String? @db.VarChar(200) + seoDescription String? @db.VarChar(500) + seoTitleAlts String[] // 5 alternatif SEO başlığı (AI üretimi) + seoScore Int? // 0-100 arası SEO güç skoru + seoSchemaJson Json? // VideoObject structured data + socialContent Json? // { youtubeTitle, tiktokCaption, instagramCaption, twitterText } + referenceUrl String? @db.VarChar(500) + // İçerik Kaynağı - sourceType SourceType @default(MANUAL) // MANUAL, X_TWEET, YOUTUBE - sourceTweetData Json? // X/Twitter tweet verisi (id, author, metrics, media) - + sourceType SourceType @default(MANUAL) // MANUAL, X_TWEET, YOUTUBE + sourceTweetData Json? // X/Twitter tweet verisi (id, author, metrics, media) + // Processing - status ProjectStatus @default(DRAFT) - progress Int @default(0) // 0-100 - errorMessage String? - + status ProjectStatus @default(DRAFT) + progress Int @default(0) // 0-100 + errorMessage String? + // Output - finalVideoUrl String? - thumbnailUrl String? - + finalVideoUrl String? + thumbnailUrl String? + // Stats - creditsUsed Int @default(0) - viewCount Int @default(0) - + creditsUsed Int @default(0) + viewCount Int @default(0) + // Template Support - isTemplate Boolean @default(false) - templateId String? // Hangi şablondan klonlandı? - template Template? @relation("ClonedFrom", fields: [templateId], references: [id]) - + isTemplate Boolean @default(false) + templateId String? // Hangi şablondan klonlandı? + template Template? @relation("ClonedFrom", fields: [templateId], references: [id]) + // Relations userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) scenes Scene[] mediaAssets MediaAsset[] renderJobs RenderJob[] - templateEntry Template? @relation("SourceProject") + templateEntry Template? @relation("SourceProject") seoScoreHistory SeoScoreHistory[] - + // Parent-Child relationship for translations and versions - parentId String? - parentProject Project? @relation("ProjectVersions", fields: [parentId], references: [id]) - childProjects Project[] @relation("ProjectVersions") - + parentId String? + parentProject Project? @relation("ProjectVersions", fields: [parentId], references: [id]) + childProjects Project[] @relation("ProjectVersions") + // Timestamps & Soft Delete - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - completedAt DateTime? - deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + completedAt DateTime? + deletedAt DateTime? @@index([userId]) @@index([status]) @@ -327,14 +327,14 @@ model Project { // ============================================ model SeoScoreHistory { - id String @id @default(uuid()) - score Int // 0-100 - event String @db.VarChar(50) // script_generated, title_changed, seo_titles_regenerated - metadata Json? // { selectedTitle, keywords, ... } - + id String @id @default(uuid()) + score Int // 0-100 + event String @db.VarChar(50) // script_generated, title_changed, seo_titles_regenerated + metadata Json? // { selectedTitle, keywords, ... } + projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) @@index([projectId]) @@ -342,27 +342,27 @@ model SeoScoreHistory { } model Scene { - id String @id @default(uuid()) - order Int // Sahne sırası (1, 2, 3...) - title String? @db.VarChar(200) - + id String @id @default(uuid()) + order Int // Sahne sırası (1, 2, 3...) + title String? @db.VarChar(200) + // Content - narrationText String @db.Text // Hedef dildeki anlatım metni - visualPrompt String @db.Text // İngilizce — Higgsfield AI prompt - subtitleText String? @db.Text // Ekranda görünecek altyazı - + narrationText String @db.Text // Hedef dildeki anlatım metni + visualPrompt String @db.Text // İngilizce — Higgsfield AI prompt + subtitleText String? @db.Text // Ekranda görünecek altyazı + // Timing - duration Float @default(5.0) // saniye - transitionType TransitionType @default(CUT) - + duration Float @default(5.0) // saniye + transitionType TransitionType @default(CUT) + // Relations - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - mediaAssets MediaAsset[] - + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + mediaAssets MediaAsset[] + // Timestamps - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([projectId]) @@index([order]) @@ -373,33 +373,33 @@ model Scene { // ============================================ model MediaAsset { - id String @id @default(uuid()) - type MediaType - + id String @id @default(uuid()) + type MediaType + // Storage - s3Key String? // Cloudflare R2 / S3 object key - s3Bucket String? @db.VarChar(100) - url String? // Public CDN URL - + s3Key String? // Cloudflare R2 / S3 object key + s3Bucket String? @db.VarChar(100) + url String? // Public CDN URL + // Metadata - fileName String? @db.VarChar(255) - mimeType String? @db.VarChar(100) - sizeBytes BigInt? - durationMs Int? // Medya süresi (video/audio için) - + fileName String? @db.VarChar(255) + mimeType String? @db.VarChar(100) + sizeBytes BigInt? + durationMs Int? // Medya süresi (video/audio için) + // AI Provider Info - aiProvider String? @db.VarChar(50) // higgsfield, elevenlabs, suno - aiJobId String? // Dış API job ID'si - + aiProvider String? @db.VarChar(50) // higgsfield, elevenlabs, suno + aiJobId String? // Dış API job ID'si + // Relations - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - sceneId String? // null = proje genelinde (müzik, final vb.) - scene Scene? @relation(fields: [sceneId], references: [id], onDelete: SetNull) - + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + sceneId String? // null = proje genelinde (müzik, final vb.) + scene Scene? @relation(fields: [sceneId], references: [id], onDelete: SetNull) + // Timestamps - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([projectId]) @@index([sceneId]) @@ -407,37 +407,37 @@ model MediaAsset { } model RenderJob { - id String @id @default(uuid()) - status RenderJobStatus @default(QUEUED) - currentStage RenderStage? - + id String @id @default(uuid()) + status RenderJobStatus @default(QUEUED) + currentStage RenderStage? + // Queue Info - queueName String @default("video-generation") @db.VarChar(100) - bullJobId String? @db.VarChar(100) // BullMQ job ID - + queueName String @default("video-generation") @db.VarChar(100) + bullJobId String? @db.VarChar(100) // BullMQ job ID + // Retry - attemptNumber Int @default(1) - maxAttempts Int @default(3) - + attemptNumber Int @default(1) + maxAttempts Int @default(3) + // Processing - workerHostname String? @db.VarChar(100) - processingTimeMs Int? // Toplam render süresi + workerHostname String? @db.VarChar(100) + processingTimeMs Int? // Toplam render süresi errorMessage String? - + // Output - finalVideoUrl String? - finalVideoS3Key String? - + finalVideoUrl String? + finalVideoS3Key String? + // Relations - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - logs RenderLog[] - + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + logs RenderLog[] + // Timestamps - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - startedAt DateTime? - completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + startedAt DateTime? + completedAt DateTime? @@index([projectId]) @@index([status]) @@ -445,19 +445,19 @@ model RenderJob { } model RenderLog { - id String @id @default(uuid()) - stage RenderStage - level String @default("info") @db.VarChar(10) // info, warn, error - message String @db.Text - durationMs Int? // Bu aşamanın süresi - metadata Json? // Ek JSON veri - + id String @id @default(uuid()) + stage RenderStage + level String @default("info") @db.VarChar(10) // info, warn, error + message String @db.Text + durationMs Int? // Bu aşamanın süresi + metadata Json? // Ek JSON veri + // Relations - renderJobId String - renderJob RenderJob @relation(fields: [renderJobId], references: [id], onDelete: Cascade) - + renderJobId String + renderJob RenderJob @relation(fields: [renderJobId], references: [id], onDelete: Cascade) + // Timestamps - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@index([renderJobId]) @@index([stage]) @@ -468,37 +468,37 @@ model RenderLog { // ============================================ model Template { - id String @id @default(uuid()) - + id String @id @default(uuid()) + // Display - title String @db.VarChar(200) - description String? @db.VarChar(500) - thumbnailUrl String? - previewVideoUrl String? - + title String @db.VarChar(200) + description String? @db.VarChar(500) + thumbnailUrl String? + previewVideoUrl String? + // Categorization - category String @default("general") @db.VarChar(50) - tags String[] // PostgreSQL array - language String @default("tr") @db.VarChar(5) - + category String @default("general") @db.VarChar(50) + tags String[] // PostgreSQL array + language String @default("tr") @db.VarChar(5) + // Source - originalProjectId String @unique - originalProject Project @relation("SourceProject", fields: [originalProjectId], references: [id]) - + originalProjectId String @unique + originalProject Project @relation("SourceProject", fields: [originalProjectId], references: [id]) + // Stats - usageCount Int @default(0) - rating Float @default(0) - ratingCount Int @default(0) - isFeatured Boolean @default(false) - isPublished Boolean @default(true) - + usageCount Int @default(0) + rating Float @default(0) + ratingCount Int @default(0) + isFeatured Boolean @default(false) + isPublished Boolean @default(true) + // Relations - clonedProjects Project[] @relation("ClonedFrom") - usages TemplateUsage[] - + clonedProjects Project[] @relation("ClonedFrom") + usages TemplateUsage[] + // Timestamps - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([category]) @@index([language]) @@ -507,14 +507,14 @@ model Template { } model TemplateUsage { - id String @id @default(uuid()) - templateId String - template Template @relation(fields: [templateId], references: [id], onDelete: Cascade) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - clonedProjectId String? // Oluşturulan projenin ID'si - - createdAt DateTime @default(now()) + id String @id @default(uuid()) + templateId String + template Template @relation(fields: [templateId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + clonedProjectId String? // Oluşturulan projenin ID'si + + createdAt DateTime @default(now()) @@index([templateId]) @@index([userId]) @@ -525,65 +525,65 @@ model TemplateUsage { // ============================================ model Plan { - id String @id @default(uuid()) - name String @unique @db.VarChar(50) // free, pro, business - displayName String @db.VarChar(100) - description String? @db.VarChar(500) - + id String @id @default(uuid()) + name String @unique @db.VarChar(50) // free, pro, business + displayName String @db.VarChar(100) + description String? @db.VarChar(500) + // Pricing - monthlyPrice Int @default(0) // cent cinsinden (1900 = $19) - yearlyPrice Int? // Yıllık indirimli fiyat - currency String @default("usd") @db.VarChar(3) - + monthlyPrice Int @default(0) // cent cinsinden (1900 = $19) + yearlyPrice Int? // Yıllık indirimli fiyat + currency String @default("usd") @db.VarChar(3) + // Limits - monthlyCredits Int @default(3) - maxDuration Int @default(30) // saniye - maxResolution String @default("720p") @db.VarChar(10) - maxProjects Int @default(5) - + monthlyCredits Int @default(3) + maxDuration Int @default(30) // saniye + maxResolution String @default("720p") @db.VarChar(10) + maxProjects Int @default(5) + // Stripe - stripePriceId String? @db.VarChar(100) - stripeYearlyPriceId String? @db.VarChar(100) - + stripePriceId String? @db.VarChar(100) + stripeYearlyPriceId String? @db.VarChar(100) + // Features - features Json? // { "templates": true, "priorityQueue": false, ... } - isActive Boolean @default(true) - sortOrder Int @default(0) - + features Json? // { "templates": true, "priorityQueue": false, ... } + isActive Boolean @default(true) + sortOrder Int @default(0) + // Relations - subscriptions Subscription[] - + subscriptions Subscription[] + // Timestamps - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([name]) @@index([isActive]) } model Subscription { - id String @id @default(uuid()) - status String @default("active") @db.VarChar(20) // active, canceled, past_due, trialing - + id String @id @default(uuid()) + status String @default("active") @db.VarChar(20) // active, canceled, past_due, trialing + // Stripe - stripeSubscriptionId String? @unique @db.VarChar(100) - stripeCustomerId String? @db.VarChar(100) - + stripeSubscriptionId String? @unique @db.VarChar(100) + stripeCustomerId String? @db.VarChar(100) + // Billing Cycle - currentPeriodStart DateTime? - currentPeriodEnd DateTime? - cancelAtPeriodEnd Boolean @default(false) - + currentPeriodStart DateTime? + currentPeriodEnd DateTime? + cancelAtPeriodEnd Boolean @default(false) + // Relations - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - planId String - plan Plan @relation(fields: [planId], references: [id]) - + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + planId String + plan Plan @relation(fields: [planId], references: [id]) + // Timestamps - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - canceledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + canceledAt DateTime? @@index([userId]) @@index([planId]) @@ -592,21 +592,21 @@ model Subscription { } model CreditTransaction { - id String @id @default(uuid()) - amount Int // Pozitif: ekleme, Negatif: harcama - type String @db.VarChar(30) // grant, usage, refund, bonus - description String? @db.VarChar(200) - + id String @id @default(uuid()) + amount Int // Pozitif: ekleme, Negatif: harcama + type String @db.VarChar(30) // grant, usage, refund, bonus + description String? @db.VarChar(200) + // Relations - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - projectId String? // Hangi projede harcandı - + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + projectId String? // Hangi projede harcandı + // Balance Snapshot - balanceAfter Int @default(0) - + balanceAfter Int @default(0) + // Timestamps - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@index([userId]) @@index([type]) @@ -618,43 +618,43 @@ model CreditTransaction { // ============================================ model UserPreference { - id String @id @default(uuid()) - + id String @id @default(uuid()) + // Defaults - defaultLanguage String @default("tr") @db.VarChar(5) - defaultVideoStyle String @default("CINEMATIC") @db.VarChar(50) - defaultDuration Int @default(60) - + defaultLanguage String @default("tr") @db.VarChar(5) + defaultVideoStyle String @default("CINEMATIC") @db.VarChar(50) + defaultDuration Int @default(60) + // UI - theme String @default("dark") @db.VarChar(10) + theme String @default("dark") @db.VarChar(10) emailNotifications Boolean @default(true) pushNotifications Boolean @default(true) - + // Relations - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // Timestamps - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId]) } model Notification { - id String @id @default(uuid()) - type String @db.VarChar(30) // render_complete, render_failed, credit_low, system - title String @db.VarChar(200) - message String? @db.Text - isRead Boolean @default(false) - metadata Json? // { projectId, renderJobId, ... } - + id String @id @default(uuid()) + type String @db.VarChar(30) // render_complete, render_failed, credit_low, system + title String @db.VarChar(200) + message String? @db.Text + isRead Boolean @default(false) + metadata Json? // { projectId, renderJobId, ... } + // Relations - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // Timestamps - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) readAt DateTime? @@index([userId]) @@ -667,23 +667,23 @@ model Notification { // ============================================ model YoutubeAnalysis { - id String @id @default(uuid()) - videoUrl String @db.VarChar(500) - videoId String @db.VarChar(100) - title String? @db.VarChar(500) - thumbnail String? @db.VarChar(500) - viewCount String? @db.VarChar(50) - likeCount String? @db.VarChar(50) + id String @id @default(uuid()) + videoUrl String @db.VarChar(500) + videoId String @db.VarChar(100) + title String? @db.VarChar(500) + thumbnail String? @db.VarChar(500) + viewCount String? @db.VarChar(50) + likeCount String? @db.VarChar(50) commentCount Int? - analysisData Json // Bütün Gemini analizi burada duracak - + analysisData Json // Bütün Gemini analizi burada duracak + // Relations - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // Timestamps - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId]) @@index([videoId]) @@ -694,21 +694,21 @@ model YoutubeAnalysis { // ============================================ model YoutubeSeoAnalysis { - id String @id @default(uuid()) - videoUrl String @db.VarChar(500) - videoId String @db.VarChar(100) - title String? @db.VarChar(500) - thumbnail String? @db.VarChar(500) - seoScore Int @default(0) - analysisData Json // Bütün Gemini analizi (Başlık, Açıklama, Keywords, Shorts fikirleri) burada duracak - + id String @id @default(uuid()) + videoUrl String @db.VarChar(500) + videoId String @db.VarChar(100) + title String? @db.VarChar(500) + thumbnail String? @db.VarChar(500) + seoScore Int @default(0) + analysisData Json // Bütün Gemini analizi (Başlık, Açıklama, Keywords, Shorts fikirleri) burada duracak + // Relations - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // Timestamps - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId]) @@index([videoId]) @@ -718,78 +718,82 @@ model YoutubeSeoAnalysis { // ============================================ 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 + 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 + communityInsights Json? // Yorum/Transkript analizinden çıkan gelecek bölüm fikirleri ve virallik skorları - 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[] - + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + videos TubeStrategistVideo[] + episodes TubeStrategistEpisode[] + // Timestamps - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + 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 - + 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) - + projectId String + project TubeStrategistProject @relation(fields: [projectId], references: [id], onDelete: Cascade) + // Timestamps - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + 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 + 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? + sponsorshipPitch Json? // Sponsorluk e-posta şablonları ve kitle analizleri + thumbnailMatrix Json? // A/B Test başlıkları ve küçük resim konseptleri + shortsConcepts Json? // Ana bölümden çıkarılmış 3-5 adet kısa içerik fikri + + projectId String + project TubeStrategistProject @relation(fields: [projectId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([projectId]) } diff --git a/seed_voice.js b/seed_voice.js new file mode 100644 index 0000000..176bb84 --- /dev/null +++ b/seed_voice.js @@ -0,0 +1,21 @@ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function main() { + await prisma.voiceProfile.create({ + data: { + name: 'Özgür AI (Tüm Motorlar)', + description: 'İstediğiniz ses motorunu seçebileceğiniz serbest profil.', + voice_type: 'cloned', + language: 'tr', + tags: ['serbest', 'çok-dilli'], + preview_url: null, + personality: 'Sen yardımcı bir asistansın.', + default_engine: 'qwen' + } + }); + console.log('Profile created'); +} +main().catch(e => { + console.error(e); +}).finally(() => prisma.$disconnect()); diff --git a/src/app.module.ts b/src/app.module.ts index 97b869b..45586ba 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -54,6 +54,7 @@ import { DashboardModule } from './modules/dashboard/dashboard.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { ExtractorModule } from './modules/extractor/extractor.module'; import { YoutubeToolsModule } from './modules/youtube-tools/youtube-tools.module'; +import { VoiceboxModule } from './modules/voicebox/voicebox.module'; // Guards import { @@ -204,6 +205,7 @@ import { NotificationsModule, ExtractorModule, YoutubeToolsModule, + VoiceboxModule, ], providers: [ // Global Exception Filter diff --git a/src/modules/voicebox/voicebox.controller.ts b/src/modules/voicebox/voicebox.controller.ts new file mode 100644 index 0000000..2f1ba2f --- /dev/null +++ b/src/modules/voicebox/voicebox.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Get, Post, Body, Res, HttpStatus, Param } from '@nestjs/common'; +import { Public } from '../../common/decorators'; +import { VoiceboxService } from './voicebox.service'; +import type { Response } from 'express'; + +@Controller('voicebox') +export class VoiceboxController { + constructor(private readonly voiceboxService: VoiceboxService) {} + + @Get('profiles') + async getProfiles() { + return this.voiceboxService.getProfiles(); + } + + @Get('history') + async getHistory() { + return this.voiceboxService.getHistory(); + } + + @Public() + @Get('audio/:id') + async getAudio(@Param('id') id: string, @Res() res: Response) { + const audioBuffer = await this.voiceboxService.getAudio(id); + const bufferToSend = Buffer.isBuffer(audioBuffer) ? audioBuffer : Buffer.from(audioBuffer); + + res.set({ + 'Content-Type': 'audio/wav', + 'Content-Disposition': `inline; filename="history_${id}.wav"`, + 'Content-Length': bufferToSend.length, + }); + + res.status(HttpStatus.OK).send(bufferToSend); + } + + @Post('generate') + async generateSpeech( + @Body() body: { text: string; profileId: string; language?: string; engine?: string; modelSize?: string; instruct?: string; seed?: number }, + @Res() res: Response + ) { + const { text, profileId, language, engine, modelSize, instruct, seed } = body; + const audioBuffer = await this.voiceboxService.generateSpeech(text, profileId, { language, engine, modelSize, instruct, seed }); + const bufferToSend = Buffer.isBuffer(audioBuffer) ? audioBuffer : Buffer.from(audioBuffer); + + res.set({ + 'Content-Type': 'audio/wav', + 'Content-Disposition': `attachment; filename="voicebox_${Date.now()}.wav"`, + 'Content-Length': bufferToSend.length, + }); + + res.status(HttpStatus.OK).send(bufferToSend); + } + + @Post('speak') + async speak(@Body() body: { text: string; profile: string; personality?: boolean }) { + return this.voiceboxService.speak(body.text, body.profile, body.personality); + } +} diff --git a/src/modules/voicebox/voicebox.module.ts b/src/modules/voicebox/voicebox.module.ts new file mode 100644 index 0000000..be7f30a --- /dev/null +++ b/src/modules/voicebox/voicebox.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { VoiceboxController } from './voicebox.controller'; +import { VoiceboxService } from './voicebox.service'; +import { HttpModule } from '@nestjs/axios'; + +@Module({ + imports: [HttpModule], + controllers: [VoiceboxController], + providers: [VoiceboxService], + exports: [VoiceboxService], +}) +export class VoiceboxModule {} diff --git a/src/modules/voicebox/voicebox.service.ts b/src/modules/voicebox/voicebox.service.ts new file mode 100644 index 0000000..1b42122 --- /dev/null +++ b/src/modules/voicebox/voicebox.service.ts @@ -0,0 +1,240 @@ +import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { lastValueFrom } from 'rxjs'; +import * as FormData from 'form-data'; + +@Injectable() +export class VoiceboxService { + private readonly logger = new Logger(VoiceboxService.name); + private readonly voiceboxUrl = 'http://contgen-ai-voicebox:17493'; // Docker network URL + + constructor(private readonly httpService: HttpService) {} + + /** + * VoiceBox'taki tüm ses profillerini getirir + */ + async getProfiles() { + try { + const response: any = await lastValueFrom( + this.httpService.get(`${this.voiceboxUrl}/profiles`) + ); + + let profiles = response.data; + + // Eğer VoiceBox veritabanı boşsa (ilk kurulum), varsayılan Kokoro profillerini oluştur + if (!profiles || (Array.isArray(profiles) && profiles.length === 0)) { + this.logger.log('VoiceBox DB is empty. Auto-provisioning default Kokoro profiles...'); + + const alloyProfile = { + name: 'Alloy (US)', + language: 'en', + voice_type: 'preset', + preset_engine: 'kokoro', + preset_voice_id: 'af_alloy', + default_engine: 'kokoro' + }; + + const adamProfile = { + name: 'Adam (US)', + language: 'en', + voice_type: 'preset', + preset_engine: 'kokoro', + preset_voice_id: 'am_adam', + default_engine: 'kokoro' + }; + + try { + await lastValueFrom(this.httpService.post(`${this.voiceboxUrl}/profiles`, alloyProfile)); + await lastValueFrom(this.httpService.post(`${this.voiceboxUrl}/profiles`, adamProfile)); + + // Yeniden çek + const retryResponse: any = await lastValueFrom(this.httpService.get(`${this.voiceboxUrl}/profiles`)); + profiles = retryResponse.data; + } catch (postError: any) { + this.logger.error('Failed to auto-provision default profiles', postError.message); + } + } + + // Edge TTS profilleri kontrolü + const hasEdgeTTS = Array.isArray(profiles) && profiles.some((p: any) => p.preset_engine === 'edge_tts'); + if (Array.isArray(profiles) && !hasEdgeTTS) { + this.logger.log('Auto-provisioning missing Edge TTS profiles...'); + const ahmetProfile = { name: 'Ahmet (TR)', language: 'tr', voice_type: 'preset', preset_engine: 'edge_tts', preset_voice_id: 'tr-TR-AhmetNeural', default_engine: 'edge_tts' }; + const emelProfile = { name: 'Emel (TR)', language: 'tr', voice_type: 'preset', preset_engine: 'edge_tts', preset_voice_id: 'tr-TR-EmelNeural', default_engine: 'edge_tts' }; + try { + await lastValueFrom(this.httpService.post(`${this.voiceboxUrl}/profiles`, ahmetProfile)); + await lastValueFrom(this.httpService.post(`${this.voiceboxUrl}/profiles`, emelProfile)); + const retryResponse: any = await lastValueFrom(this.httpService.get(`${this.voiceboxUrl}/profiles`)); + profiles = retryResponse.data; + } catch (postError: any) { + this.logger.error('Failed to auto-provision Edge TTS profiles', postError.message); + } + } + + return profiles; + } catch (error: any) { + this.logger.error('Error fetching VoiceBox profiles', error.message); + throw new HttpException( + 'VoiceBox profilleri alınamadı', + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + /** + * Metinden ses üretir (TTS) + * @param text Sese dönüştürülecek metin (paralinguistic tag'ler içerebilir) + * @param profileId Kullanılacak ses profilinin ID'si veya adı + * @param language Hedef dil (örn: "tr") + */ + async generateSpeech(text: string, profileId: string, options: { language?: string, engine?: string, modelSize?: string, instruct?: string, seed?: number } = {}) { + try { + const profiles = await this.getProfiles(); + let profile = profiles.find((p: any) => p.id === profileId || p.preset_voice_id === profileId); + + let profileIdToUse = profileId; + + // Eğer kullanıcı açıkça bir motor seçtiyse onu kullan, yoksa profilin varsayılanını al, o da yoksa kokoro kullan + const engine = options.engine || profile?.default_engine || profile?.preset_engine || 'kokoro'; + + // Uyumsuzluk Kontrolü: Eğer engine "edge_tts" ama seçilen profil kokoro vb. destekliyorsa veya profili bulamadıysak, otomatik Edge TTS profilini kullan + if (engine === 'edge_tts' && (!profile || profile.preset_engine !== 'edge_tts')) { + const edgeProfile = profiles.find((p: any) => p.preset_engine === 'edge_tts'); + if (edgeProfile) { + profileIdToUse = edgeProfile.id; + profile = edgeProfile; + this.logger.warn(`Overriding incompatible profile ${profileId} with ${edgeProfile.name} because engine is edge_tts`); + } + } + + const payload: any = { + text, + profile_id: profileIdToUse, + language: options.language || 'tr', + engine + }; + + if (options.modelSize) payload.model_size = options.modelSize; + if (options.instruct) payload.instruct = options.instruct; + if (options.seed !== undefined && options.seed !== null) payload.seed = options.seed; + + // Asenkron üretim başlat + const generateResponse: any = await lastValueFrom( + this.httpService.post(`${this.voiceboxUrl}/generate`, payload) + ); + const generationId = generateResponse.data.id; + + // Durumu polling ile kontrol et + let status = 'generating'; + let attempts = 0; + let lastError = null; + + while (status !== 'completed' && status !== 'failed' && attempts < 120) { + await new Promise(r => setTimeout(r, 2000)); + try { + const genResponse: any = await lastValueFrom( + this.httpService.get(`${this.voiceboxUrl}/history/${generationId}`) + ); + const gen = genResponse.data; + + if (gen) { + status = gen.status || 'completed'; + if (status === 'failed') { + throw new Error(gen.error || 'Bilinmeyen üretim hatası'); + } + } + } catch (err: any) { + // If 404, it might still be generating or not inserted into DB yet, continue polling + if (err.response && err.response.status === 404) { + // keep generating + } else { + // Rethrow actual errors (like the one we throw above for status='failed') + if (err.message && err.message !== 'Request failed with status code 404') { + throw err; + } + } + } + attempts++; + } + + if (status !== 'completed') { + throw new Error('Ses üretimi zaman aşımına uğradı'); + } + + return await this.getAudio(generationId); + } catch (error: any) { + this.logger.error('Error generating speech via VoiceBox', error.message); + throw new HttpException( + error.message || 'VoiceBox ses üretemedi', + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + /** + * VoiceBox üretim geçmişini getirir + */ + async getHistory() { + try { + const response: any = await lastValueFrom( + this.httpService.get(`${this.voiceboxUrl}/history`) + ); + return response.data; + } catch (error: any) { + this.logger.error('Error fetching VoiceBox history', error.message); + throw new HttpException( + 'VoiceBox üretim geçmişi alınamadı', + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + /** + * VoiceBox geçmiş ses dosyasını getirir + */ + async getAudio(generationId: string) { + try { + const response: any = await lastValueFrom( + this.httpService.get(`${this.voiceboxUrl}/audio/${generationId}`, { + responseType: 'arraybuffer' + }) + ); + return response.data; + } catch (error: any) { + this.logger.error(`Error fetching VoiceBox audio for ${generationId}`, error.message); + throw new HttpException( + 'Ses dosyası bulunamadı', + HttpStatus.NOT_FOUND + ); + } + } + + /** + * Agent / Script üzerinden direk ses çıkışı yapar + * @param text Söylenecek metin + * @param profile Kullanılacak profil + * @param personality Karakter uygulanacaksa true + */ + async speak(text: string, profile: string, personality: boolean = false) { + try { + const response: any = await lastValueFrom( + this.httpService.post(`${this.voiceboxUrl}/speak`, { + text, + profile, + personality + }, { + headers: { + 'X-Voicebox-Client-Id': 'contgen-ai-backend' + } + }) + ); + return response.data; + } catch (error: any) { + this.logger.error('Error triggering VoiceBox speak', error.message); + throw new HttpException( + 'VoiceBox konuşma işlemi başarısız', + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/src/modules/youtube-tools/tube-strategist.service.ts b/src/modules/youtube-tools/tube-strategist.service.ts index 088238d..0325632 100644 --- a/src/modules/youtube-tools/tube-strategist.service.ts +++ b/src/modules/youtube-tools/tube-strategist.service.ts @@ -7,6 +7,7 @@ import { Innertube } from 'youtubei.js'; import { YoutubeTranscript } from 'youtube-transcript'; import { GeminiService } from '../gemini/gemini.service'; import { VideoAiService } from '../video-ai/video-ai.service'; +import { StorageService } from '../storage/storage.service'; @Injectable() export class TubeStrategistService { @@ -18,7 +19,8 @@ export class TubeStrategistService { private readonly configService: ConfigService, private readonly prisma: PrismaService, private readonly videoAiService: VideoAiService, - private readonly geminiService: GeminiService + private readonly geminiService: GeminiService, + private readonly storageService: StorageService ) { const apiKey = this.configService.get('gemini.apiKey') || @@ -670,7 +672,23 @@ export class TubeStrategistService { data: updateData }); - return mergedAnalysis; + // Otomatik olarak tüm modüler analizleri çalıştır ve veritabanına kaydet + try { + this.logger.log(`[analyzeEpisode] Modüler analizler başlatılıyor... (${episodeId})`); + await this.generateEpisodeQuestions(episodeId, userId); + await this.generateEpisodeSeoMarketing(episodeId, userId); + await this.generateEpisodeCrisisSponsors(episodeId, userId); + this.logger.log(`[analyzeEpisode] Modüler analizler tamamlandı. (${episodeId})`); + } catch (e: any) { + this.logger.error(`[analyzeEpisode] Modüler analiz sırasında hata: ${e.message}`); + } + + // En güncel haliyle bölümü çekip döndür + const finalEpisode = await this.prisma.tubeStrategistEpisode.findUnique({ + where: { id: episodeId } + }); + + return finalEpisode?.masterAnalysis || mergedAnalysis; } // ========================================== @@ -697,6 +715,16 @@ export class TubeStrategistService { }); } + async deleteProject(projectId: string, userId: string) { + const project = await this.getProjectById(projectId, userId); + + await this.prisma.tubeStrategistProject.delete({ + where: { id: project.id } + }); + + return { success: true }; + } + async addDocumentToProject(projectId: string, userId: string, dto: AddDocumentDto) { await this.getProjectById(projectId, userId); @@ -833,7 +861,8 @@ export class TubeStrategistService { 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.)" } - } + }, + required: ['question', 'neuroMarketingAnswerDirection', 'neuroMarketingScore', 'targetArea'] }, description: "TAM OLARAK 5 adet yeni derin, kışkırtıcı veya sarsıcı soru." } @@ -899,7 +928,8 @@ export class TubeStrategistService { 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.)" } - } + }, + required: ['question', 'neuroMarketingAnswerDirection', 'neuroMarketingScore', 'targetArea'] }, description: "TAM OLARAK 20 adet derin, kışkırtıcı veya sarsıcı mülakat/röportaj sorusu ve detayları" } @@ -1002,9 +1032,11 @@ export class TubeStrategistService { 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ğı" } - } + reasoning: { type: Type.STRING, description: "Bu marka neden bu içeriğe çok uygun? Kısa bir analiz." }, + integrationIdea: { type: Type.STRING, description: "Videonun içine bu markayı nasıl doğal ve etkili bir şekilde entegre ederiz?" }, + emailDraft: { type: Type.STRING, description: "Bu markaya gönderilecek işbirliği için Türkçe soğuk e-posta (cold email) taslağı" } + }, + required: ['brandName', 'reasoning', 'integrationIdea', 'emailDraft'] }, description: "Konuyla doğrudan eşleşebilecek TAM OLARAK 10 adet marka ve onlara özel mail taslakları" } @@ -1043,20 +1075,207 @@ export class TubeStrategistService { try { // 16:9 = 1920x1080 -> aspectRatio '16:9' - const imageUrl = await this.geminiService.generateImage(prompt, '16:9'); + const imageResult = await this.geminiService.generateImage(prompt, '16:9'); - masterAnalysis.thumbnailUrl = imageUrl; + if (!imageResult) { + throw new Error('Görsel üretilemedi (boş sonuç döndü).'); + } + + const timestamp = Date.now(); + const ext = imageResult.mimeType === 'image/png' ? 'png' : 'jpg'; + const thumbnailKey = `ts-ep-${episodeId}/thumbnails/thumb-${timestamp}.${ext}`; + + const uploadResult = await this.storageService.upload( + thumbnailKey, + imageResult.buffer, + imageResult.mimeType + ); + + masterAnalysis.thumbnailUrl = uploadResult.url; await this.prisma.tubeStrategistEpisode.update({ where: { id: episodeId }, data: { masterAnalysis } }); - return { thumbnailUrl: imageUrl }; + return { thumbnailUrl: uploadResult.url }; } catch (error: any) { this.logger.error(`Thumbnail üretilirken hata oluştu: ${error.message}`); throw new Error(`Thumbnail üretilemedi: ${error.message}`); } } + + async generateCommunityIdeas(projectId: string, userId: string) { + const project = await this.getProjectById(projectId, userId); + + // Yorumlardan ya da transkriptlerden kitle analizi yapmak için prompt + // DB'deki videoların transcriptlerini birleştirelim + const transcripts = project.videos.map(v => v.transcript).filter(t => t).join("\n\n").substring(0, 15000); + + const schema: any = { + type: Type.OBJECT, + properties: { + insights: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + topic: { type: Type.STRING }, + viralityScore: { type: Type.NUMBER }, + demandReason: { type: Type.STRING }, + proposedTitle: { type: Type.STRING } + }, + required: ['topic', 'viralityScore', 'demandReason', 'proposedTitle'] + } + } + }, + required: ['insights'] + }; + + const prompt = `Aşağıdaki metinler bir YouTube kanalındaki geçmiş videoların transkript/özetleridir. + Kitle dinamiklerini, izleyicinin cevaplanmamış olası sorularını ve "Gizli Taleplerini" analiz et. + Bu kitleye sunulabilecek, yüksek virallik (0-100) taşıyan yepyeni GELECEK BÖLÜM (Next Episode) fikirleri üret. + + GEÇMİŞ İÇERİKLER: + ${transcripts} + `; + + const response = await this.withRetry(() => this.ai.models.generateContent({ + model: 'gemini-3-pro-preview', + contents: prompt, + config: { responseMimeType: 'application/json', responseSchema: schema } + })); + + const communityInsights = JSON.parse(response.text || '{}'); + await this.prisma.tubeStrategistProject.update({ + where: { id: projectId }, data: { communityInsights } + }); + + return communityInsights; + } + + async generateEpisodeThumbnailMatrix(episodeId: string, userId: string) { + const episode = await this.getEpisodeById(episodeId, userId); + + const schema: any = { + type: Type.OBJECT, + properties: { + concepts: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + conceptName: { type: Type.STRING, description: "Örn: Merak Uyandırıcı, Otoriter vs" }, + title: { type: Type.STRING, description: "YouTube CTR odaklı çarpıcı başlık" }, + thumbnailDescription: { type: Type.STRING, description: "Görsel kompozisyon, renkler, yüz ifadesi, arka plan" } + }, + required: ['conceptName', 'title', 'thumbnailDescription'] + } + } + }, + required: ['concepts'] + }; + + const prompt = `YouTube Bölüm Konusu: ${episode.topic} + Format: ${episode.format} + Hedef Kitle: ${episode.targetAudience} + + Bu bölüm için YouTube CTR (Tıklanma Oranı) odaklı, birbirinden tamamen farklı 3 adet A/B Test konsepti üret. (Örn: Merak Uyandırıcı, Otoriter, Eğlenceli). Her konsept için vurucu bir başlık ve detaylı bir thumbnail görsel açıklaması yaz.`; + + const response = await this.withRetry(() => this.ai.models.generateContent({ + model: 'gemini-2.5-flash', + contents: prompt, + config: { responseMimeType: 'application/json', responseSchema: schema } + })); + + const thumbnailMatrix = JSON.parse(response.text || '{}'); + await this.prisma.tubeStrategistEpisode.update({ + where: { id: episodeId }, data: { thumbnailMatrix } + }); + + return thumbnailMatrix; + } + + async generateEpisodeShorts(episodeId: string, userId: string) { + const episode = await this.getEpisodeById(episodeId, userId); + + const schema: any = { + type: Type.OBJECT, + properties: { + shorts: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + conceptTitle: { type: Type.STRING }, + hook: { type: Type.STRING, description: "İlk 3 saniye kancası" }, + body: { type: Type.STRING, description: "İçerik özü ve tempo önerileri" }, + cta: { type: Type.STRING, description: "Harekete geçirici mesaj" } + }, + required: ['conceptTitle', 'hook', 'body', 'cta'] + } + } + }, + required: ['shorts'] + }; + + const prompt = `Uzun format bölüm konusu: ${episode.topic} + Bu uzun içeriğin senaryosunu TikTok, Reels ve YouTube Shorts için Gen-Z temposunda parçalara ayır. + Toplam 4 adet bağımsız, viral potansiyeli yüksek Shorts / Reels senaryo konsepti üret.`; + + const response = await this.withRetry(() => this.ai.models.generateContent({ + model: 'gemini-2.5-flash', + contents: prompt, + config: { responseMimeType: 'application/json', responseSchema: schema } + })); + + const shortsConcepts = JSON.parse(response.text || '{}'); + await this.prisma.tubeStrategistEpisode.update({ + where: { id: episodeId }, data: { shortsConcepts } + }); + + return shortsConcepts; + } + + async generateEpisodeSponsorship(episodeId: string, userId: string) { + const episode = await this.getEpisodeById(episodeId, userId); + + const schema: any = { + type: Type.OBJECT, + properties: { + pitchDeck: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + industry: { type: Type.STRING, description: "Örn: VPN, FinTech, Yazılım" }, + reasoning: { type: Type.STRING, description: "Bu içerik bu sektöre neden uygun?" }, + emailDraft: { type: Type.STRING, description: "Sponsorluk için markalara atılacak profesyonel, soğuk e-posta (cold email) şablonu" } + }, + required: ['industry', 'reasoning', 'emailDraft'] + } + } + }, + required: ['pitchDeck'] + }; + + const prompt = `Bölüm Konusu: ${episode.topic} + Hedef Kitle: ${episode.targetAudience} + + Bu videonun hedef kitlesini ve konusunu analiz et. Bu videoya sponsor olabilecek 3 adet jenerik endüstri/sektör (Örn: Finans Uygulaması, Siber Güvenlik Firması vb.) belirle. Her biri için markaların ilgisini çekecek Türkçe bir cold-email (soğuk e-posta) şablonu yaz.`; + + const response = await this.withRetry(() => this.ai.models.generateContent({ + model: 'gemini-2.5-flash', + contents: prompt, + config: { responseMimeType: 'application/json', responseSchema: schema } + })); + + const sponsorshipPitch = JSON.parse(response.text || '{}'); + await this.prisma.tubeStrategistEpisode.update({ + where: { id: episodeId }, data: { sponsorshipPitch } + }); + + return sponsorshipPitch; + } } diff --git a/src/modules/youtube-tools/youtube-tools.controller.ts b/src/modules/youtube-tools/youtube-tools.controller.ts index 3ba3215..f7e2faf 100644 --- a/src/modules/youtube-tools/youtube-tools.controller.ts +++ b/src/modules/youtube-tools/youtube-tools.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Put, Body, Get, Param, UseGuards, HttpCode, HttpStatus, Req } from '@nestjs/common'; +import { Controller, Post, Put, Delete, 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'; @@ -210,6 +210,12 @@ export class YoutubeToolsController { return this.tubeStrategistService.updateProject(id, req.user.id, dto); } + @Delete('strategist/projects/:id') + @ApiOperation({ summary: 'Tube Strategist: Projeyi ve bağlı tüm verileri siler' }) + async deleteProject(@Param('id') id: string, @Req() req: any) { + return this.tubeStrategistService.deleteProject(id, req.user.id); + } + @Post('strategist/projects/:id/document') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Tube Strategist: Projeye manuel metin dokümanı ekler' }) @@ -223,4 +229,32 @@ export class YoutubeToolsController { async getTopicSuggestions(@Param('id') id: string, @Req() req: any) { return this.tubeStrategistService.getTopicSuggestions(id, req.user.id); } + + @Post('strategist/projects/:id/community-ideas') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Tube Strategist: Topluluk Yorum/Transkript analizi ile yeni fikir üretir' }) + async generateCommunityIdeas(@Param('id') id: string, @Req() req: any) { + return this.tubeStrategistService.generateCommunityIdeas(id, req.user.id); + } + + @Post('strategist/episodes/:id/thumbnail-matrix') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Tube Strategist: Episode için Thumbnail & Title A/B testi konseptleri üretir' }) + async generateEpisodeThumbnailMatrix(@Param('id') id: string, @Req() req: any) { + return this.tubeStrategistService.generateEpisodeThumbnailMatrix(id, req.user.id); + } + + @Post('strategist/episodes/:id/shorts-concepts') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Tube Strategist: Uzun formattan Shorts/Reels fikirleri çıkarır' }) + async generateEpisodeShorts(@Param('id') id: string, @Req() req: any) { + return this.tubeStrategistService.generateEpisodeShorts(id, req.user.id); + } + + @Post('strategist/episodes/:id/sponsorship') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Tube Strategist: Sponsorluk markaları ve e-posta taslakları üretir' }) + async generateEpisodeSponsorship(@Param('id') id: string, @Req() req: any) { + return this.tubeStrategistService.generateEpisodeSponsorship(id, req.user.id); + } } diff --git a/test-generate.js b/test-generate.js new file mode 100644 index 0000000..4b4e44f --- /dev/null +++ b/test-generate.js @@ -0,0 +1,34 @@ +const axios = require('axios'); +const fs = require('fs'); +async function test() { + try { + const res = await axios.post('http://localhost:17493/generate', { + text: "deneme ses", + profile_id: "b8d26247-c8bc-4c06-9364-5b09d11de4bf", + language: "en", + engine: "kokoro" + }); + console.log('Init:', res.data); + const id = res.data.id; + let status = res.data.status; + while (status !== 'completed') { + await new Promise(r => setTimeout(r, 1000)); + const hist = await axios.get('http://localhost:17493/history'); + const item = hist.data.items ? hist.data.items.find(i => i.id === id) : hist.data.find(i => i.id === id); + status = item ? item.status : 'failed'; + console.log('Status:', status); + if (status === 'failed') { + console.error('Failed!', item.error); + break; + } + } + if (status === 'completed') { + const audioRes = await axios.get(`http://localhost:17493/audio/${id}`, { responseType: 'arraybuffer' }); + fs.writeFileSync('/tmp/test-kokoro-direct.wav', audioRes.data); + console.log('Audio saved, length:', audioRes.data.length); + } + } catch (e) { + console.error('Error:', e.response?.data || e.message); + } +} +test(); diff --git a/test_sse.js b/test_sse.js new file mode 100644 index 0000000..bd46f12 --- /dev/null +++ b/test_sse.js @@ -0,0 +1,37 @@ +const axios = require('axios'); +const http = require('http'); + +async function test() { + console.log('Sending POST /generate...'); + const res = await axios.post('http://localhost:17493/generate', { + profile_id: 'b8d26247-c8bc-4c06-9364-5b09d11de4bf', + text: 'Hello world again', + language: 'en', + engine: 'kokoro' + }); + const genId = res.data.id; + console.log('Generation ID:', genId); + + console.log('Polling status...'); + return new Promise((resolve, reject) => { + http.get(`http://localhost:17493/generate/${genId}/status`, (response) => { + response.on('data', (chunk) => { + const lines = chunk.toString().split('\n'); + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = JSON.parse(line.substring(6)); + console.log('Status update:', data); + if (data.status === 'completed') { + console.log('Finished!'); + resolve(data); + } else if (data.status === 'failed') { + console.log('Failed:', data.error); + reject(new Error(data.error)); + } + } + } + }); + }); + }); +} +test().catch(console.error);