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

This commit is contained in:
Harun CAN
2026-05-11 07:32:58 +02:00
parent 58832e99d1
commit 2e6c272eee
17 changed files with 1260 additions and 400 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ WORKDIR /app
RUN apk add --no-cache openssl libc6-compat RUN apk add --no-cache openssl libc6-compat
# pnpm kurulumu (workspace kuralı gereği) # 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 # Paket dosyalarını kopyala
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
@@ -29,7 +29,7 @@ FROM node:20-alpine AS production
RUN apk add --no-cache openssl libc6-compat RUN apk add --no-cache openssl libc6-compat
# pnpm kurulumu # pnpm kurulumu
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app WORKDIR /app
+12 -6
View File
@@ -1,12 +1,18 @@
const { PrismaClient } = require('@prisma/client'); const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function check() {
const admin = await prisma.user.findFirst({ const episodes = await prisma.tubeStrategistEpisode.findMany({
where: { email: 'admin@contentgen.ai' }, orderBy: { createdAt: 'desc' },
include: { roles: { include: { role: true } } } 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());
+3
View File
@@ -73,6 +73,9 @@ try
builder.Services.AddHttpClient<MinimaxTtsService>("MinimaxTTS") builder.Services.AddHttpClient<MinimaxTtsService>("MinimaxTTS")
.AddPolicyHandler(combinedPolicy); .AddPolicyHandler(combinedPolicy);
builder.Services.AddHttpClient<VoiceboxTtsService>("VoiceboxTTS")
.AddPolicyHandler(combinedPolicy);
builder.Services.AddHttpClient<SunoMusicService>("Suno") builder.Services.AddHttpClient<SunoMusicService>("Suno")
.AddPolicyHandler(combinedPolicy); .AddPolicyHandler(combinedPolicy);
@@ -25,6 +25,7 @@ public class VideoRenderPipeline
private readonly TtsService _tts; private readonly TtsService _tts;
private readonly OpenAiTtsService _openAiTts; private readonly OpenAiTtsService _openAiTts;
private readonly MinimaxTtsService _minimaxTts; private readonly MinimaxTtsService _minimaxTts;
private readonly VoiceboxTtsService _voiceboxTts;
private readonly SunoMusicService _sunoMusic; private readonly SunoMusicService _sunoMusic;
private readonly AudioCraftService _audioCraft; private readonly AudioCraftService _audioCraft;
private readonly RemotionService _remotion; private readonly RemotionService _remotion;
@@ -38,6 +39,7 @@ public class VideoRenderPipeline
TtsService tts, TtsService tts,
OpenAiTtsService openAiTts, OpenAiTtsService openAiTts,
MinimaxTtsService minimaxTts, MinimaxTtsService minimaxTts,
VoiceboxTtsService voiceboxTts,
SunoMusicService sunoMusic, SunoMusicService sunoMusic,
AudioCraftService audioCraft, AudioCraftService audioCraft,
RemotionService remotion, RemotionService remotion,
@@ -50,6 +52,7 @@ public class VideoRenderPipeline
_tts = tts; _tts = tts;
_openAiTts = openAiTts; _openAiTts = openAiTts;
_minimaxTts = minimaxTts; _minimaxTts = minimaxTts;
_voiceboxTts = voiceboxTts;
_sunoMusic = sunoMusic; _sunoMusic = sunoMusic;
_audioCraft = audioCraft; _audioCraft = audioCraft;
_remotion = remotion; _remotion = remotion;
@@ -276,6 +279,10 @@ public class VideoRenderPipeline
{ {
result = await _minimaxTts.GenerateNarrationAsync(scene, outputDir, voiceStyle, ct); 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 else
{ {
// Default: ElevenLabs // Default: ElevenLabs
+166
View File
@@ -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;
/// <summary>
/// VoiceBox AI Studio Client — Metin → Ses dönüşümü (%100 Yerel ve Ücretsiz).
/// </summary>
public class VoiceboxTtsService
{
private readonly HttpClient _httpClient;
private readonly ILogger<VoiceboxTtsService> _logger;
private readonly ApiSettings _settings;
public VoiceboxTtsService(
HttpClient httpClient,
ILogger<VoiceboxTtsService> logger,
IOptions<ApiSettings> 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
}
/// <summary>
/// Bir sahnenin narration metnini sese çevirir ve dosyaya kaydeder.
/// </summary>
public async Task<GeneratedMediaFile> 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"
};
}
}
+1
View File
@@ -24,6 +24,7 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.964.0", "@aws-sdk/client-s3": "^3.964.0",
"@google/genai": "^1.35.0", "@google/genai": "^1.35.0",
"@nestjs/axios": "^4.0.1",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.1.0", "@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
+19 -2
View File
@@ -14,6 +14,9 @@ importers:
'@google/genai': '@google/genai':
specifier: ^1.35.0 specifier: ^1.35.0
version: 1.47.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': '@nestjs/bullmq':
specifier: ^11.0.4 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) 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) 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': '@nestjs/terminus':
specifier: ^11.0.0 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': '@nestjs/throttler':
specifier: ^6.5.0 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) 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': '@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} 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': '@nestjs/bull-shared@11.0.4':
resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==} resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==}
peerDependencies: peerDependencies:
@@ -5793,6 +5803,12 @@ snapshots:
'@tybys/wasm-util': 0.10.1 '@tybys/wasm-util': 0.10.1
optional: true 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)': '@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: 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/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-transformer: 0.5.1
class-validator: 0.14.4 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: 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/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) '@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 reflect-metadata: 0.2.2
rxjs: 7.8.2 rxjs: 7.8.2
optionalDependencies: 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) '@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)': '@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)':
+383 -379
View File
@@ -15,72 +15,72 @@ datasource db {
// ============================================ // ============================================
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
password String password String
firstName String? firstName String?
lastName String? lastName String?
isActive Boolean @default(true) isActive Boolean @default(true)
// Core Relations // Core Relations
roles UserRole[] roles UserRole[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
// Video SaaS Relations // Video SaaS Relations
projects Project[] projects Project[]
subscriptions Subscription[] subscriptions Subscription[]
creditTransactions CreditTransaction[] creditTransactions CreditTransaction[]
templateUsages TemplateUsage[] templateUsages TemplateUsage[]
notifications Notification[] notifications Notification[]
preferences UserPreference? preferences UserPreference?
youtubeAnalyses YoutubeAnalysis[] youtubeAnalyses YoutubeAnalysis[]
youtubeSeoAnalyses YoutubeSeoAnalysis[] youtubeSeoAnalyses YoutubeSeoAnalysis[]
tubeStrategistProjects TubeStrategistProject[] tubeStrategistProjects TubeStrategistProject[]
// Multi-tenancy (optional) // Multi-tenancy (optional)
tenantId String? tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id]) tenant Tenant? @relation(fields: [tenantId], references: [id])
// Timestamps & Soft Delete // Timestamps & Soft Delete
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
@@index([email]) @@index([email])
@@index([tenantId]) @@index([tenantId])
} }
model Role { model Role {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String @unique
description String? description String?
isSystem Boolean @default(false) isSystem Boolean @default(false)
// Relations // Relations
users UserRole[] users UserRole[]
permissions RolePermission[] permissions RolePermission[]
// Timestamps & Soft Delete // Timestamps & Soft Delete
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
@@index([name]) @@index([name])
} }
model Permission { model Permission {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String @unique
description String? description String?
resource String // e.g., "users", "posts" resource String // e.g., "users", "posts"
action String // e.g., "create", "read", "update", "delete" action String // e.g., "create", "read", "update", "delete"
// Relations // Relations
roles RolePermission[] roles RolePermission[]
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([resource, action]) @@unique([resource, action])
@@index([resource]) @@index([resource])
@@ -135,14 +135,14 @@ model RefreshToken {
// ============================================ // ============================================
model Tenant { model Tenant {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
slug String @unique slug String @unique
isActive Boolean @default(true) isActive Boolean @default(true)
// Relations // Relations
users User[] users User[]
// Timestamps & Soft Delete // Timestamps & Soft Delete
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -156,12 +156,12 @@ model Tenant {
// ============================================ // ============================================
model Translation { model Translation {
id String @id @default(uuid()) id String @id @default(uuid())
key String key String
locale String // e.g., "en", "tr", "de" locale String // e.g., "en", "tr", "de"
value String value String
namespace String @default("common") // e.g., "common", "errors", "validation" namespace String @default("common") // e.g., "common", "errors", "validation"
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -247,73 +247,73 @@ enum SourceType {
// ============================================ // ============================================
model Project { model Project {
id String @id @default(uuid()) id String @id @default(uuid())
title String @db.VarChar(200) title String @db.VarChar(200)
description String? @db.VarChar(1000) description String? @db.VarChar(1000)
prompt String @db.Text prompt String @db.Text
// AI Generated Script // AI Generated Script
scriptJson Json? // Gemini API raw JSON output scriptJson Json? // Gemini API raw JSON output
scriptVersion Int @default(0) scriptVersion Int @default(0)
// Configuration // Configuration
language String @default("tr") @db.VarChar(5) // ISO 639-1 language String @default("tr") @db.VarChar(5) // ISO 639-1
aspectRatio AspectRatio @default(PORTRAIT_9_16) aspectRatio AspectRatio @default(PORTRAIT_9_16)
videoStyle String @default("CINEMATIC") @db.VarChar(50) videoStyle String @default("CINEMATIC") @db.VarChar(50)
cinematicReference String? @db.VarChar(200) cinematicReference String? @db.VarChar(200)
targetDuration Int @default(60) // saniye targetDuration Int @default(60) // saniye
// SEO & Social Content (skill-enhanced) // SEO & Social Content (skill-enhanced)
seoKeywords String[] // Hedeflenen SEO anahtar kelimeler seoKeywords String[] // Hedeflenen SEO anahtar kelimeler
seoTitle String? @db.VarChar(200) seoTitle String? @db.VarChar(200)
seoDescription String? @db.VarChar(500) seoDescription String? @db.VarChar(500)
seoTitleAlts String[] // 5 alternatif SEO başlığı (AI üretimi) seoTitleAlts String[] // 5 alternatif SEO başlığı (AI üretimi)
seoScore Int? // 0-100 arası SEO güç skoru seoScore Int? // 0-100 arası SEO güç skoru
seoSchemaJson Json? // VideoObject structured data seoSchemaJson Json? // VideoObject structured data
socialContent Json? // { youtubeTitle, tiktokCaption, instagramCaption, twitterText } socialContent Json? // { youtubeTitle, tiktokCaption, instagramCaption, twitterText }
referenceUrl String? @db.VarChar(500) referenceUrl String? @db.VarChar(500)
// İçerik Kaynağı // İçerik Kaynağı
sourceType SourceType @default(MANUAL) // MANUAL, X_TWEET, YOUTUBE sourceType SourceType @default(MANUAL) // MANUAL, X_TWEET, YOUTUBE
sourceTweetData Json? // X/Twitter tweet verisi (id, author, metrics, media) sourceTweetData Json? // X/Twitter tweet verisi (id, author, metrics, media)
// Processing // Processing
status ProjectStatus @default(DRAFT) status ProjectStatus @default(DRAFT)
progress Int @default(0) // 0-100 progress Int @default(0) // 0-100
errorMessage String? errorMessage String?
// Output // Output
finalVideoUrl String? finalVideoUrl String?
thumbnailUrl String? thumbnailUrl String?
// Stats // Stats
creditsUsed Int @default(0) creditsUsed Int @default(0)
viewCount Int @default(0) viewCount Int @default(0)
// Template Support // Template Support
isTemplate Boolean @default(false) isTemplate Boolean @default(false)
templateId String? // Hangi şablondan klonlandı? templateId String? // Hangi şablondan klonlandı?
template Template? @relation("ClonedFrom", fields: [templateId], references: [id]) template Template? @relation("ClonedFrom", fields: [templateId], references: [id])
// Relations // Relations
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
scenes Scene[] scenes Scene[]
mediaAssets MediaAsset[] mediaAssets MediaAsset[]
renderJobs RenderJob[] renderJobs RenderJob[]
templateEntry Template? @relation("SourceProject") templateEntry Template? @relation("SourceProject")
seoScoreHistory SeoScoreHistory[] seoScoreHistory SeoScoreHistory[]
// Parent-Child relationship for translations and versions // Parent-Child relationship for translations and versions
parentId String? parentId String?
parentProject Project? @relation("ProjectVersions", fields: [parentId], references: [id]) parentProject Project? @relation("ProjectVersions", fields: [parentId], references: [id])
childProjects Project[] @relation("ProjectVersions") childProjects Project[] @relation("ProjectVersions")
// Timestamps & Soft Delete // Timestamps & Soft Delete
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
completedAt DateTime? completedAt DateTime?
deletedAt DateTime? deletedAt DateTime?
@@index([userId]) @@index([userId])
@@index([status]) @@index([status])
@@ -327,14 +327,14 @@ model Project {
// ============================================ // ============================================
model SeoScoreHistory { model SeoScoreHistory {
id String @id @default(uuid()) id String @id @default(uuid())
score Int // 0-100 score Int // 0-100
event String @db.VarChar(50) // script_generated, title_changed, seo_titles_regenerated event String @db.VarChar(50) // script_generated, title_changed, seo_titles_regenerated
metadata Json? // { selectedTitle, keywords, ... } metadata Json? // { selectedTitle, keywords, ... }
projectId String projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([projectId]) @@index([projectId])
@@ -342,27 +342,27 @@ model SeoScoreHistory {
} }
model Scene { model Scene {
id String @id @default(uuid()) id String @id @default(uuid())
order Int // Sahne sırası (1, 2, 3...) order Int // Sahne sırası (1, 2, 3...)
title String? @db.VarChar(200) title String? @db.VarChar(200)
// Content // Content
narrationText String @db.Text // Hedef dildeki anlatım metni narrationText String @db.Text // Hedef dildeki anlatım metni
visualPrompt String @db.Text // İngilizce — Higgsfield AI prompt visualPrompt String @db.Text // İngilizce — Higgsfield AI prompt
subtitleText String? @db.Text // Ekranda görünecek altyazı subtitleText String? @db.Text // Ekranda görünecek altyazı
// Timing // Timing
duration Float @default(5.0) // saniye duration Float @default(5.0) // saniye
transitionType TransitionType @default(CUT) transitionType TransitionType @default(CUT)
// Relations // Relations
projectId String projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
mediaAssets MediaAsset[] mediaAssets MediaAsset[]
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([projectId]) @@index([projectId])
@@index([order]) @@index([order])
@@ -373,33 +373,33 @@ model Scene {
// ============================================ // ============================================
model MediaAsset { model MediaAsset {
id String @id @default(uuid()) id String @id @default(uuid())
type MediaType type MediaType
// Storage // Storage
s3Key String? // Cloudflare R2 / S3 object key s3Key String? // Cloudflare R2 / S3 object key
s3Bucket String? @db.VarChar(100) s3Bucket String? @db.VarChar(100)
url String? // Public CDN URL url String? // Public CDN URL
// Metadata // Metadata
fileName String? @db.VarChar(255) fileName String? @db.VarChar(255)
mimeType String? @db.VarChar(100) mimeType String? @db.VarChar(100)
sizeBytes BigInt? sizeBytes BigInt?
durationMs Int? // Medya süresi (video/audio için) durationMs Int? // Medya süresi (video/audio için)
// AI Provider Info // AI Provider Info
aiProvider String? @db.VarChar(50) // higgsfield, elevenlabs, suno aiProvider String? @db.VarChar(50) // higgsfield, elevenlabs, suno
aiJobId String? // Dış API job ID'si aiJobId String? // Dış API job ID'si
// Relations // Relations
projectId String projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
sceneId String? // null = proje genelinde (müzik, final vb.) sceneId String? // null = proje genelinde (müzik, final vb.)
scene Scene? @relation(fields: [sceneId], references: [id], onDelete: SetNull) scene Scene? @relation(fields: [sceneId], references: [id], onDelete: SetNull)
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([projectId]) @@index([projectId])
@@index([sceneId]) @@index([sceneId])
@@ -407,37 +407,37 @@ model MediaAsset {
} }
model RenderJob { model RenderJob {
id String @id @default(uuid()) id String @id @default(uuid())
status RenderJobStatus @default(QUEUED) status RenderJobStatus @default(QUEUED)
currentStage RenderStage? currentStage RenderStage?
// Queue Info // Queue Info
queueName String @default("video-generation") @db.VarChar(100) queueName String @default("video-generation") @db.VarChar(100)
bullJobId String? @db.VarChar(100) // BullMQ job ID bullJobId String? @db.VarChar(100) // BullMQ job ID
// Retry // Retry
attemptNumber Int @default(1) attemptNumber Int @default(1)
maxAttempts Int @default(3) maxAttempts Int @default(3)
// Processing // Processing
workerHostname String? @db.VarChar(100) workerHostname String? @db.VarChar(100)
processingTimeMs Int? // Toplam render süresi processingTimeMs Int? // Toplam render süresi
errorMessage String? errorMessage String?
// Output // Output
finalVideoUrl String? finalVideoUrl String?
finalVideoS3Key String? finalVideoS3Key String?
// Relations // Relations
projectId String projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
logs RenderLog[] logs RenderLog[]
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
startedAt DateTime? startedAt DateTime?
completedAt DateTime? completedAt DateTime?
@@index([projectId]) @@index([projectId])
@@index([status]) @@index([status])
@@ -445,19 +445,19 @@ model RenderJob {
} }
model RenderLog { model RenderLog {
id String @id @default(uuid()) id String @id @default(uuid())
stage RenderStage stage RenderStage
level String @default("info") @db.VarChar(10) // info, warn, error level String @default("info") @db.VarChar(10) // info, warn, error
message String @db.Text message String @db.Text
durationMs Int? // Bu aşamanın süresi durationMs Int? // Bu aşamanın süresi
metadata Json? // Ek JSON veri metadata Json? // Ek JSON veri
// Relations // Relations
renderJobId String renderJobId String
renderJob RenderJob @relation(fields: [renderJobId], references: [id], onDelete: Cascade) renderJob RenderJob @relation(fields: [renderJobId], references: [id], onDelete: Cascade)
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([renderJobId]) @@index([renderJobId])
@@index([stage]) @@index([stage])
@@ -468,37 +468,37 @@ model RenderLog {
// ============================================ // ============================================
model Template { model Template {
id String @id @default(uuid()) id String @id @default(uuid())
// Display // Display
title String @db.VarChar(200) title String @db.VarChar(200)
description String? @db.VarChar(500) description String? @db.VarChar(500)
thumbnailUrl String? thumbnailUrl String?
previewVideoUrl String? previewVideoUrl String?
// Categorization // Categorization
category String @default("general") @db.VarChar(50) category String @default("general") @db.VarChar(50)
tags String[] // PostgreSQL array tags String[] // PostgreSQL array
language String @default("tr") @db.VarChar(5) language String @default("tr") @db.VarChar(5)
// Source // Source
originalProjectId String @unique originalProjectId String @unique
originalProject Project @relation("SourceProject", fields: [originalProjectId], references: [id]) originalProject Project @relation("SourceProject", fields: [originalProjectId], references: [id])
// Stats // Stats
usageCount Int @default(0) usageCount Int @default(0)
rating Float @default(0) rating Float @default(0)
ratingCount Int @default(0) ratingCount Int @default(0)
isFeatured Boolean @default(false) isFeatured Boolean @default(false)
isPublished Boolean @default(true) isPublished Boolean @default(true)
// Relations // Relations
clonedProjects Project[] @relation("ClonedFrom") clonedProjects Project[] @relation("ClonedFrom")
usages TemplateUsage[] usages TemplateUsage[]
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([category]) @@index([category])
@@index([language]) @@index([language])
@@ -507,14 +507,14 @@ model Template {
} }
model TemplateUsage { model TemplateUsage {
id String @id @default(uuid()) id String @id @default(uuid())
templateId String templateId String
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade) template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
clonedProjectId String? // Oluşturulan projenin ID'si clonedProjectId String? // Oluşturulan projenin ID'si
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([templateId]) @@index([templateId])
@@index([userId]) @@index([userId])
@@ -525,65 +525,65 @@ model TemplateUsage {
// ============================================ // ============================================
model Plan { model Plan {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique @db.VarChar(50) // free, pro, business name String @unique @db.VarChar(50) // free, pro, business
displayName String @db.VarChar(100) displayName String @db.VarChar(100)
description String? @db.VarChar(500) description String? @db.VarChar(500)
// Pricing // Pricing
monthlyPrice Int @default(0) // cent cinsinden (1900 = $19) monthlyPrice Int @default(0) // cent cinsinden (1900 = $19)
yearlyPrice Int? // Yıllık indirimli fiyat yearlyPrice Int? // Yıllık indirimli fiyat
currency String @default("usd") @db.VarChar(3) currency String @default("usd") @db.VarChar(3)
// Limits // Limits
monthlyCredits Int @default(3) monthlyCredits Int @default(3)
maxDuration Int @default(30) // saniye maxDuration Int @default(30) // saniye
maxResolution String @default("720p") @db.VarChar(10) maxResolution String @default("720p") @db.VarChar(10)
maxProjects Int @default(5) maxProjects Int @default(5)
// Stripe // Stripe
stripePriceId String? @db.VarChar(100) stripePriceId String? @db.VarChar(100)
stripeYearlyPriceId String? @db.VarChar(100) stripeYearlyPriceId String? @db.VarChar(100)
// Features // Features
features Json? // { "templates": true, "priorityQueue": false, ... } features Json? // { "templates": true, "priorityQueue": false, ... }
isActive Boolean @default(true) isActive Boolean @default(true)
sortOrder Int @default(0) sortOrder Int @default(0)
// Relations // Relations
subscriptions Subscription[] subscriptions Subscription[]
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([name]) @@index([name])
@@index([isActive]) @@index([isActive])
} }
model Subscription { model Subscription {
id String @id @default(uuid()) id String @id @default(uuid())
status String @default("active") @db.VarChar(20) // active, canceled, past_due, trialing status String @default("active") @db.VarChar(20) // active, canceled, past_due, trialing
// Stripe // Stripe
stripeSubscriptionId String? @unique @db.VarChar(100) stripeSubscriptionId String? @unique @db.VarChar(100)
stripeCustomerId String? @db.VarChar(100) stripeCustomerId String? @db.VarChar(100)
// Billing Cycle // Billing Cycle
currentPeriodStart DateTime? currentPeriodStart DateTime?
currentPeriodEnd DateTime? currentPeriodEnd DateTime?
cancelAtPeriodEnd Boolean @default(false) cancelAtPeriodEnd Boolean @default(false)
// Relations // Relations
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
planId String planId String
plan Plan @relation(fields: [planId], references: [id]) plan Plan @relation(fields: [planId], references: [id])
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
canceledAt DateTime? canceledAt DateTime?
@@index([userId]) @@index([userId])
@@index([planId]) @@index([planId])
@@ -592,21 +592,21 @@ model Subscription {
} }
model CreditTransaction { model CreditTransaction {
id String @id @default(uuid()) id String @id @default(uuid())
amount Int // Pozitif: ekleme, Negatif: harcama amount Int // Pozitif: ekleme, Negatif: harcama
type String @db.VarChar(30) // grant, usage, refund, bonus type String @db.VarChar(30) // grant, usage, refund, bonus
description String? @db.VarChar(200) description String? @db.VarChar(200)
// Relations // Relations
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
projectId String? // Hangi projede harcandı projectId String? // Hangi projede harcandı
// Balance Snapshot // Balance Snapshot
balanceAfter Int @default(0) balanceAfter Int @default(0)
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([userId]) @@index([userId])
@@index([type]) @@index([type])
@@ -618,43 +618,43 @@ model CreditTransaction {
// ============================================ // ============================================
model UserPreference { model UserPreference {
id String @id @default(uuid()) id String @id @default(uuid())
// Defaults // Defaults
defaultLanguage String @default("tr") @db.VarChar(5) defaultLanguage String @default("tr") @db.VarChar(5)
defaultVideoStyle String @default("CINEMATIC") @db.VarChar(50) defaultVideoStyle String @default("CINEMATIC") @db.VarChar(50)
defaultDuration Int @default(60) defaultDuration Int @default(60)
// UI // UI
theme String @default("dark") @db.VarChar(10) theme String @default("dark") @db.VarChar(10)
emailNotifications Boolean @default(true) emailNotifications Boolean @default(true)
pushNotifications Boolean @default(true) pushNotifications Boolean @default(true)
// Relations // Relations
userId String @unique userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([userId]) @@index([userId])
} }
model Notification { model Notification {
id String @id @default(uuid()) id String @id @default(uuid())
type String @db.VarChar(30) // render_complete, render_failed, credit_low, system type String @db.VarChar(30) // render_complete, render_failed, credit_low, system
title String @db.VarChar(200) title String @db.VarChar(200)
message String? @db.Text message String? @db.Text
isRead Boolean @default(false) isRead Boolean @default(false)
metadata Json? // { projectId, renderJobId, ... } metadata Json? // { projectId, renderJobId, ... }
// Relations // Relations
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
readAt DateTime? readAt DateTime?
@@index([userId]) @@index([userId])
@@ -667,23 +667,23 @@ model Notification {
// ============================================ // ============================================
model YoutubeAnalysis { model YoutubeAnalysis {
id String @id @default(uuid()) id String @id @default(uuid())
videoUrl String @db.VarChar(500) videoUrl String @db.VarChar(500)
videoId String @db.VarChar(100) videoId String @db.VarChar(100)
title String? @db.VarChar(500) title String? @db.VarChar(500)
thumbnail String? @db.VarChar(500) thumbnail String? @db.VarChar(500)
viewCount String? @db.VarChar(50) viewCount String? @db.VarChar(50)
likeCount String? @db.VarChar(50) likeCount String? @db.VarChar(50)
commentCount Int? commentCount Int?
analysisData Json // Bütün Gemini analizi burada duracak analysisData Json // Bütün Gemini analizi burada duracak
// Relations // Relations
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([userId]) @@index([userId])
@@index([videoId]) @@index([videoId])
@@ -694,21 +694,21 @@ model YoutubeAnalysis {
// ============================================ // ============================================
model YoutubeSeoAnalysis { model YoutubeSeoAnalysis {
id String @id @default(uuid()) id String @id @default(uuid())
videoUrl String @db.VarChar(500) videoUrl String @db.VarChar(500)
videoId String @db.VarChar(100) videoId String @db.VarChar(100)
title String? @db.VarChar(500) title String? @db.VarChar(500)
thumbnail String? @db.VarChar(500) thumbnail String? @db.VarChar(500)
seoScore Int @default(0) seoScore Int @default(0)
analysisData Json // Bütün Gemini analizi (Başlık, Açıklama, Keywords, Shorts fikirleri) burada duracak analysisData Json // Bütün Gemini analizi (Başlık, Açıklama, Keywords, Shorts fikirleri) burada duracak
// Relations // Relations
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([userId]) @@index([userId])
@@index([videoId]) @@index([videoId])
@@ -718,78 +718,82 @@ model YoutubeSeoAnalysis {
// ============================================ // ============================================
model TubeStrategistProject { model TubeStrategistProject {
id String @id @default(uuid()) id String @id @default(uuid())
name String @db.VarChar(500) name String @db.VarChar(500)
status String @default("DRAFT") // DRAFT, ANALYZING, COMPLETED status String @default("DRAFT") // DRAFT, ANALYZING, COMPLETED
// Settings // Settings
tone String? tone String?
duration String? duration String?
speakerName String? speakerName String?
targetAudience String? targetAudience String?
topicFocus String? topicFocus String?
formatDescription String? @db.Text 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 // Relations
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
videos TubeStrategistVideo[] videos TubeStrategistVideo[]
episodes TubeStrategistEpisode[] episodes TubeStrategistEpisode[]
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([userId]) @@index([userId])
} }
model TubeStrategistVideo { model TubeStrategistVideo {
id String @id @default(uuid()) id String @id @default(uuid())
youtubeUrl String @db.VarChar(500) youtubeUrl String @db.VarChar(500)
videoId String @db.VarChar(100) videoId String @db.VarChar(100)
title String? @db.VarChar(500) title String? @db.VarChar(500)
thumbnail String? @db.VarChar(500) thumbnail String? @db.VarChar(500)
transcript String? @db.Text transcript String? @db.Text
transcriptDuration Int? // in seconds transcriptDuration Int? // in seconds
totalComments Int @default(0) totalComments Int @default(0)
mainComments Int @default(0) mainComments Int @default(0)
replyComments Int @default(0) replyComments Int @default(0)
viewCount String? @db.VarChar(50) viewCount String? @db.VarChar(50)
likeCount String? @db.VarChar(50) likeCount String? @db.VarChar(50)
commentsJson Json? // Storing top comments or buckets commentsJson Json? // Storing top comments or buckets
tier1Analysis Json? // Individual video analysis tier1Analysis Json? // Individual video analysis
// Relations // Relations
projectId String projectId String
project TubeStrategistProject @relation(fields: [projectId], references: [id], onDelete: Cascade) project TubeStrategistProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([projectId]) @@index([projectId])
} }
model TubeStrategistEpisode { model TubeStrategistEpisode {
id String @id @default(uuid()) id String @id @default(uuid())
topic String @db.VarChar(500) topic String @db.VarChar(500)
targetAudience String? @db.VarChar(500) targetAudience String? @db.VarChar(500)
duration String? @db.VarChar(100) duration String? @db.VarChar(100)
format String? @db.VarChar(100) format String? @db.VarChar(100)
status String @default("DRAFT") // DRAFT, ANALYZING, COMPLETED status String @default("DRAFT") // DRAFT, ANALYZING, COMPLETED
masterAnalysis Json? masterAnalysis Json?
sponsorshipPitch Json? // Sponsorluk e-posta şablonları ve kitle analizleri
projectId String thumbnailMatrix Json? // A/B Test başlıkları ve küçük resim konseptleri
project TubeStrategistProject @relation(fields: [projectId], references: [id], onDelete: Cascade) shortsConcepts Json? // Ana bölümden çıkarılmış 3-5 adet kısa içerik fikri
createdAt DateTime @default(now()) projectId String
updatedAt DateTime @updatedAt project TubeStrategistProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([projectId]) @@index([projectId])
} }
+21
View File
@@ -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());
+2
View File
@@ -54,6 +54,7 @@ import { DashboardModule } from './modules/dashboard/dashboard.module';
import { NotificationsModule } from './modules/notifications/notifications.module'; import { NotificationsModule } from './modules/notifications/notifications.module';
import { ExtractorModule } from './modules/extractor/extractor.module'; import { ExtractorModule } from './modules/extractor/extractor.module';
import { YoutubeToolsModule } from './modules/youtube-tools/youtube-tools.module'; import { YoutubeToolsModule } from './modules/youtube-tools/youtube-tools.module';
import { VoiceboxModule } from './modules/voicebox/voicebox.module';
// Guards // Guards
import { import {
@@ -204,6 +205,7 @@ import {
NotificationsModule, NotificationsModule,
ExtractorModule, ExtractorModule,
YoutubeToolsModule, YoutubeToolsModule,
VoiceboxModule,
], ],
providers: [ providers: [
// Global Exception Filter // Global Exception Filter
@@ -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);
}
}
+12
View File
@@ -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 {}
+240
View File
@@ -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<any>(`${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<any>(`${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<any>(`${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<any>(`${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<any>(`${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<any>(`${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<any>(`${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<any>(`${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
);
}
}
}
@@ -7,6 +7,7 @@ import { Innertube } from 'youtubei.js';
import { YoutubeTranscript } from 'youtube-transcript'; import { YoutubeTranscript } from 'youtube-transcript';
import { GeminiService } from '../gemini/gemini.service'; import { GeminiService } from '../gemini/gemini.service';
import { VideoAiService } from '../video-ai/video-ai.service'; import { VideoAiService } from '../video-ai/video-ai.service';
import { StorageService } from '../storage/storage.service';
@Injectable() @Injectable()
export class TubeStrategistService { export class TubeStrategistService {
@@ -18,7 +19,8 @@ export class TubeStrategistService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly videoAiService: VideoAiService, private readonly videoAiService: VideoAiService,
private readonly geminiService: GeminiService private readonly geminiService: GeminiService,
private readonly storageService: StorageService
) { ) {
const apiKey = const apiKey =
this.configService.get<string>('gemini.apiKey') || this.configService.get<string>('gemini.apiKey') ||
@@ -670,7 +672,23 @@ export class TubeStrategistService {
data: updateData 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) { async addDocumentToProject(projectId: string, userId: string, dto: AddDocumentDto) {
await this.getProjectById(projectId, userId); 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ı." }, 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)" }, 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.)" } 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." 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ı." }, 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)" }, 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.)" } 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ı" 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, type: Type.OBJECT,
properties: { properties: {
brandName: { type: Type.STRING }, brandName: { type: Type.STRING },
integrationStrategy: { type: Type.STRING }, reasoning: { type: Type.STRING, description: "Bu marka neden bu içeriğe çok uygun? Kısa bir analiz." },
coldEmailDraft: { type: Type.STRING, description: "Bu markaya gönderilecek işbirliği için Türkçe soğuk e-posta (cold email) taslağı" } 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ı" 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 { try {
// 16:9 = 1920x1080 -> aspectRatio '16:9' // 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({ await this.prisma.tubeStrategistEpisode.update({
where: { id: episodeId }, where: { id: episodeId },
data: { masterAnalysis } data: { masterAnalysis }
}); });
return { thumbnailUrl: imageUrl }; return { thumbnailUrl: uploadResult.url };
} catch (error: any) { } catch (error: any) {
this.logger.error(`Thumbnail üretilirken hata oluştu: ${error.message}`); this.logger.error(`Thumbnail üretilirken hata oluştu: ${error.message}`);
throw new Error(`Thumbnail üretilemedi: ${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;
}
} }
@@ -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 { YoutubeToolsService } from './youtube-tools.service';
import { TubeStrategistService } from './tube-strategist.service'; import { TubeStrategistService } from './tube-strategist.service';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
@@ -210,6 +210,12 @@ export class YoutubeToolsController {
return this.tubeStrategistService.updateProject(id, req.user.id, dto); 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') @Post('strategist/projects/:id/document')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Tube Strategist: Projeye manuel metin dokümanı ekler' }) @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) { async getTopicSuggestions(@Param('id') id: string, @Req() req: any) {
return this.tubeStrategistService.getTopicSuggestions(id, req.user.id); 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);
}
} }
+34
View File
@@ -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();
+37
View File
@@ -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);