StreamClipper AI Faz 1: ingestion + sinyal analizi + STT + altyapı iskeleti
yt-dlp headless capture (15dk segmentleme), ses peak + chat velocity sinyal tespiti, OpenAI Whisper STT (kelime zaman damgalı), BullMQ/Redis/Postgres altyapısı ve kanal durumu + transkript kütüphanesi gösteren Next.js panel. LLM virality skorlama, render/crop/altyazı ve multi-platform dağıtım bu fazın kapsamı dışında. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
shared-media
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
__pycache__
|
||||||
|
**/__pycache__
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Local dev (running services directly on the host) uses localhost.
|
||||||
|
# docker-compose overrides DATABASE_URL/REDIS_URL/SHARED_MEDIA_ROOT to
|
||||||
|
# internal container hostnames — see infra/docker-compose.yml.
|
||||||
|
DATABASE_URL=postgresql://streamclipper:streamclipper@localhost:5432/streamclipper
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
SHARED_MEDIA_ROOT=./shared-media
|
||||||
|
|
||||||
|
# Canlı yayın tespiti yt-dlp ile yapılıyor (bkz. youtubePolling.ts) —
|
||||||
|
# YouTube Data API key gerekmiyor.
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
TELEGRAM_CHAT_ID=
|
||||||
|
|
||||||
|
POLL_INTERVAL_MS=60000
|
||||||
|
SEGMENT_TIME_SEC=900
|
||||||
|
API_DAEMON_PORT=4001
|
||||||
|
|
||||||
|
# Set to a live YouTube URL to bypass the 60s polling loop and start
|
||||||
|
# capturing immediately — useful for testing the pipeline without waiting
|
||||||
|
# for a real scheduled stream.
|
||||||
|
FORCE_LIVE_URL=
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
shared-media/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# StreamClipper AI — Faz 1
|
||||||
|
|
||||||
|
`prd.md` içindeki tam kapsamlı otonom pipeline'ın ilk fazı: ingestion (yt-dlp
|
||||||
|
headless capture + 15dk segmentleme), sinyal analizi (ses peak + chat
|
||||||
|
velocity), Whisper STT (OpenAI API, kelime zaman damgalı), BullMQ/Redis/
|
||||||
|
Postgres altyapısı ve durum/transkript gösteren bir Next.js panel. LLM
|
||||||
|
virality skorlama, render/crop/altyazı ve multi-platform dağıtım bu fazda
|
||||||
|
**yok** — bkz. `prd.md` Modül 3 (skorlama kısmı), 4 ve 5.
|
||||||
|
|
||||||
|
## Klasör Yapısı
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/
|
||||||
|
frontend/ Next.js panel (kanal durumu, segment/transkript kütüphanesi)
|
||||||
|
api-daemon/ Node/TS — YouTube polling, BullMQ producer, yt-dlp/ffmpeg capture
|
||||||
|
worker/ Python — chat-downloader, sinyal analizi, Whisper STT
|
||||||
|
packages/
|
||||||
|
db/ Prisma schema + client (Node tarafı)
|
||||||
|
infra/
|
||||||
|
docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ön Koşullar
|
||||||
|
|
||||||
|
- Node.js 20+, pnpm, Python 3.12+, `ffmpeg`, `yt-dlp` (host'ta çalıştırırken)
|
||||||
|
- Docker + Docker Compose (container'larla çalıştırırken)
|
||||||
|
- **OpenAI API key** — Whisper STT için
|
||||||
|
- **Telegram bot token + chat id** — bildirimler için (`@BotFather`)
|
||||||
|
|
||||||
|
Canlı yayın tespiti YouTube Data API yerine `yt-dlp`'nin kendisiyle yapılıyor
|
||||||
|
(`/channel/<id>/live` kontrolü) — API key veya kota gerekmiyor.
|
||||||
|
|
||||||
|
## Kurulum
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# .env içindeki OPENAI_API_KEY / TELEGRAM_* değerlerini doldurun
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Yerelde (Docker olmadan)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Redis + Postgres'i ayağa kaldır
|
||||||
|
docker compose -f infra/docker-compose.yml up sc_redis sc_postgres
|
||||||
|
|
||||||
|
# Şemayı uygula
|
||||||
|
pnpm db:migrate
|
||||||
|
pnpm db:generate
|
||||||
|
|
||||||
|
# Test kanalı ekle (gerçek bir YouTube channel_id ile)
|
||||||
|
SEED_CHANNEL_ID=UCxxxxxxxx SEED_CHANNEL_NAME="Test Kanal" pnpm db:seed
|
||||||
|
|
||||||
|
# Servisleri ayrı terminallerde çalıştır
|
||||||
|
pnpm dev:daemon
|
||||||
|
pnpm dev:frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Python worker ayrı bir sanal ortamda çalışır (pnpm workspace'in parçası değil):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/worker
|
||||||
|
python3 -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python -m worker.main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose ile (tüm servisler)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f infra/docker-compose.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Panel: http://localhost:3000 · API daemon health: http://localhost:4001/health
|
||||||
|
|
||||||
|
## Test / Doğrulama Akışı
|
||||||
|
|
||||||
|
1. `FORCE_LIVE_URL=<gerçek veya kısa test yayını URL'si>` ile `apps/api-daemon`'ı
|
||||||
|
çalıştırırsan polling'i atlayıp doğrudan capture'ı tetikler.
|
||||||
|
2. `shared-media/raw/<sessionId>/` altında 15dk'lık segment dosyaları ve
|
||||||
|
`segments.csv` oluştuğunu doğrula; DB'de `raw_segments` satırları düşer.
|
||||||
|
3. `sc_heavy_worker` (Python) her tamamlanan segmenti `signal-detection`
|
||||||
|
kuyruğundan alıp ses peak + (varsa) chat velocity analiziyle
|
||||||
|
`candidate_segments` üretir.
|
||||||
|
4. Her aday, `stt-scoring` kuyruğunda OpenAI Whisper API ile transkribe edilir;
|
||||||
|
sonuç `candidate_segments.transcript_json` alanına yazılır ve Telegram'a
|
||||||
|
bildirim gider.
|
||||||
|
5. Panelde (`/` ve `/segments`) kanal durumu ve üretilen transkriptleri
|
||||||
|
kontrol et.
|
||||||
|
|
||||||
|
## Bilinen Sınırlamalar (bu faz kapsamı dışı)
|
||||||
|
|
||||||
|
- LLM virality skorlama, metadata üretimi, telif/görüntü filtresi yok.
|
||||||
|
- 9:16 crop/altyazı render motoru yok.
|
||||||
|
- YouTube/Instagram/TikTok'a otomatik yayınlama yok (bu API'ler ayrı onay
|
||||||
|
süreçleri gerektiriyor — `prd.md` tartışmasına bkz.).
|
||||||
|
- Aday pencere çıkarımı her zaman tek bir 15dk'lık segment dosyasıyla sınırlı;
|
||||||
|
segment sınırına yakın bir an kırpılabilir (segment'ler arası dikiş, gelecek
|
||||||
|
bir iyileştirme).
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg python3 python3-pip ca-certificates \
|
||||||
|
&& pip3 install --break-system-packages -U yt-dlp \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm install
|
||||||
|
RUN pnpm --filter @streamclipper/db exec prisma generate
|
||||||
|
|
||||||
|
CMD ["pnpm", "--filter", "@streamclipper/api-daemon", "dev"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@streamclipper/api-daemon",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"build": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@streamclipper/db": "workspace:*",
|
||||||
|
"bullmq": "^5.34.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"ioredis": "^5.4.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdir, readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { Worker, type Job } from "bullmq";
|
||||||
|
import { prisma } from "@streamclipper/db";
|
||||||
|
import { env } from "../env";
|
||||||
|
import { QUEUE_NAMES, signalDetectionQueue, type StreamIngestJob } from "../queues";
|
||||||
|
import { redisConnection } from "../redis";
|
||||||
|
|
||||||
|
const SEGMENT_LIST_POLL_MS = 5_000;
|
||||||
|
|
||||||
|
interface SegmentListEntry {
|
||||||
|
fileName: string;
|
||||||
|
startSec: number;
|
||||||
|
endSec: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSegmentListLines(raw: string): SegmentListEntry[] {
|
||||||
|
return raw
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const [fileName, startStr, endStr] = line.split(",");
|
||||||
|
return { fileName, startSec: Number(startStr), endSec: Number(endStr) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processCompletedSegments(
|
||||||
|
entries: SegmentListEntry[],
|
||||||
|
fromIndex: number,
|
||||||
|
outDir: string,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
let processed = fromIndex;
|
||||||
|
|
||||||
|
for (let i = fromIndex; i < entries.length; i++) {
|
||||||
|
const entry = entries[i];
|
||||||
|
const filePath = path.join(outDir, entry.fileName);
|
||||||
|
const duration = Math.round(entry.endSec - entry.startSec);
|
||||||
|
|
||||||
|
const segment = await prisma.rawSegment.create({
|
||||||
|
data: {
|
||||||
|
sessionId,
|
||||||
|
filePath,
|
||||||
|
duration,
|
||||||
|
startedAt: new Date(Date.now() - duration * 1000),
|
||||||
|
status: "PENDING",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await signalDetectionQueue.add("analyze-segment", {
|
||||||
|
rawSegmentId: segment.id,
|
||||||
|
filePath,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[stream-ingest] segment ready: ${entry.fileName} -> raw_segment ${segment.id}`);
|
||||||
|
processed = i + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
||||||
|
const { sessionId, youtubeUrl } = job.data;
|
||||||
|
const outDir = path.join(env.sharedMediaRoot, "raw", sessionId);
|
||||||
|
await mkdir(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const segmentListPath = path.join(outDir, "segments.csv");
|
||||||
|
|
||||||
|
console.log(`[stream-ingest] starting capture for session ${sessionId}: ${youtubeUrl}`);
|
||||||
|
|
||||||
|
const ytdlp = spawn("yt-dlp", ["-f", "best", "-o", "-", youtubeUrl], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ffmpeg = spawn(
|
||||||
|
"ffmpeg",
|
||||||
|
[
|
||||||
|
"-i", "pipe:0",
|
||||||
|
"-c", "copy",
|
||||||
|
"-f", "segment",
|
||||||
|
"-segment_time", String(env.segmentTimeSec),
|
||||||
|
"-reset_timestamps", "1",
|
||||||
|
"-segment_list", segmentListPath,
|
||||||
|
"-segment_list_type", "csv",
|
||||||
|
path.join(outDir, "segment_%03d.mp4"),
|
||||||
|
],
|
||||||
|
{ stdio: ["pipe", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
ytdlp.stdout.pipe(ffmpeg.stdin);
|
||||||
|
ytdlp.stderr.on("data", (chunk) => console.error(`[yt-dlp:${sessionId}]`, chunk.toString().trim()));
|
||||||
|
ffmpeg.stderr.on("data", () => {
|
||||||
|
/* ffmpeg logs are extremely verbose; only surface on error below */
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastProcessedIndex = 0;
|
||||||
|
const pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(segmentListPath, "utf8").catch(() => "");
|
||||||
|
if (!raw) return;
|
||||||
|
const entries = parseSegmentListLines(raw);
|
||||||
|
if (entries.length > lastProcessedIndex) {
|
||||||
|
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId);
|
||||||
|
await prisma.streamSession.update({
|
||||||
|
where: { id: sessionId },
|
||||||
|
data: { totalSegments: lastProcessedIndex },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[stream-ingest] segment list poll error for ${sessionId}:`, err);
|
||||||
|
}
|
||||||
|
}, SEGMENT_LIST_POLL_MS);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
ffmpeg.on("exit", (code) => {
|
||||||
|
console.log(`[stream-ingest] ffmpeg exited (${code}) for session ${sessionId}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
ytdlp.on("exit", (code) => {
|
||||||
|
if (code !== 0) console.error(`[stream-ingest] yt-dlp exited with code ${code} for session ${sessionId}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
|
||||||
|
// final sweep in case segments closed between the last poll and process exit
|
||||||
|
const raw = await readFile(segmentListPath, "utf8").catch(() => "");
|
||||||
|
const entries = parseSegmentListLines(raw);
|
||||||
|
if (entries.length > lastProcessedIndex) {
|
||||||
|
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.streamSession.update({
|
||||||
|
where: { id: sessionId },
|
||||||
|
data: { endedAt: new Date(), totalSegments: lastProcessedIndex },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[stream-ingest] capture finished for session ${sessionId}, ${lastProcessedIndex} segments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startStreamIngestWorker(): Worker<StreamIngestJob> {
|
||||||
|
return new Worker<StreamIngestJob>(QUEUE_NAMES.STREAM_INGEST, runCapture, {
|
||||||
|
connection: redisConnection,
|
||||||
|
concurrency: 3,
|
||||||
|
// Captures run for the lifetime of the stream (can be hours) — extend the
|
||||||
|
// default lock well beyond BullMQ's stalled-job assumptions for an MVP.
|
||||||
|
lockDuration: 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
|
||||||
|
sharedMediaRoot: process.env.SHARED_MEDIA_ROOT ?? "./shared-media",
|
||||||
|
pollIntervalMs: Number(process.env.POLL_INTERVAL_MS ?? 60_000),
|
||||||
|
segmentTimeSec: Number(process.env.SEGMENT_TIME_SEC ?? 900),
|
||||||
|
port: Number(process.env.API_DAEMON_PORT ?? 4001),
|
||||||
|
forceLiveUrl: process.env.FORCE_LIVE_URL ?? "",
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { prisma } from "@streamclipper/db";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { startPollingLoop } from "./youtubePolling";
|
||||||
|
import { startStreamIngestWorker } from "./capture/streamIngest";
|
||||||
|
import { streamIngestQueue } from "./queues";
|
||||||
|
import { startServer } from "./server";
|
||||||
|
|
||||||
|
async function forceLiveBypass(youtubeUrl: string) {
|
||||||
|
console.log(`[index] FORCE_LIVE_URL set — bypassing polling and capturing directly: ${youtubeUrl}`);
|
||||||
|
|
||||||
|
const channel = await prisma.channel.upsert({
|
||||||
|
where: { channelId: "forced-test-channel" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: "Forced Test Channel",
|
||||||
|
youtubeHandle: "@forced-test",
|
||||||
|
channelId: "forced-test-channel",
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = await prisma.streamSession.create({
|
||||||
|
data: { channelId: channel.id, liveVideoId: "forced" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await streamIngestQueue.add("start-capture", {
|
||||||
|
sessionId: session.id,
|
||||||
|
channelDbId: channel.id,
|
||||||
|
youtubeUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
startServer();
|
||||||
|
startStreamIngestWorker();
|
||||||
|
|
||||||
|
if (env.forceLiveUrl) {
|
||||||
|
await forceLiveBypass(env.forceLiveUrl);
|
||||||
|
} else {
|
||||||
|
startPollingLoop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("[index] fatal error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Queue } from "bullmq";
|
||||||
|
import { redisConnection } from "./redis";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue names are shared across the Node (api-daemon) and Python (worker)
|
||||||
|
* services via the protocol-compatible `bullmq` package on both sides.
|
||||||
|
* Keep this list in sync with apps/worker/worker/queues.py.
|
||||||
|
*/
|
||||||
|
export const QUEUE_NAMES = {
|
||||||
|
STREAM_INGEST: "stream-ingest",
|
||||||
|
SIGNAL_DETECTION: "signal-detection",
|
||||||
|
STT_SCORING: "stt-scoring",
|
||||||
|
// Defined for future modules (render/distribution) — no processor yet.
|
||||||
|
VIDEO_RENDER: "video-render",
|
||||||
|
POST_PUBLISH: "post-publish",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface StreamIngestJob {
|
||||||
|
sessionId: string;
|
||||||
|
channelDbId: string;
|
||||||
|
youtubeUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalDetectionJob {
|
||||||
|
rawSegmentId: string;
|
||||||
|
filePath: string;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const streamIngestQueue = new Queue<StreamIngestJob>(QUEUE_NAMES.STREAM_INGEST, {
|
||||||
|
connection: redisConnection,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const signalDetectionQueue = new Queue<SignalDetectionJob>(QUEUE_NAMES.SIGNAL_DETECTION, {
|
||||||
|
connection: redisConnection,
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import IORedis from "ioredis";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
export const redisConnection = new IORedis(env.redisUrl, {
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { prisma } from "@streamclipper/db";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
export function startServer() {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.get("/health", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
app.get("/status", async (_req, res) => {
|
||||||
|
const channels = await prisma.channel.findMany({
|
||||||
|
include: {
|
||||||
|
sessions: {
|
||||||
|
where: { endedAt: null },
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
channels: channels.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
isActive: c.isActive,
|
||||||
|
lastCheckedAt: c.lastCheckedAt,
|
||||||
|
recording: c.sessions.length > 0,
|
||||||
|
activeSessionId: c.sessions[0]?.id ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(env.port, () => {
|
||||||
|
console.log(`[server] api-daemon listening on :${env.port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN ?? "";
|
||||||
|
const CHAT_ID = process.env.TELEGRAM_CHAT_ID ?? "";
|
||||||
|
|
||||||
|
export async function sendTelegramMessage(text: string): Promise<void> {
|
||||||
|
if (!BOT_TOKEN || !CHAT_ID) {
|
||||||
|
console.warn("[telegram] TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID not set, skipping notification:", text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ chat_id: CHAT_ID, text, parse_mode: "Markdown" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("[telegram] failed to send message:", res.status, await res.text());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { prisma } from "@streamclipper/db";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { streamIngestQueue } from "./queues";
|
||||||
|
import { sendTelegramMessage } from "./telegram";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live detection goes through yt-dlp itself instead of the YouTube Data API.
|
||||||
|
* `search.list?eventType=live` costs 100 quota units per call against a
|
||||||
|
* 10,000/day free quota — polling one channel every 60s alone would need
|
||||||
|
* ~144,000 units/day, well over quota. yt-dlp's `/live` redirect check is
|
||||||
|
* free and needs no API key.
|
||||||
|
*/
|
||||||
|
async function findLiveVideoId(channelId: string): Promise<string | null> {
|
||||||
|
const liveUrl = `https://www.youtube.com/channel/${channelId}/live`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
"yt-dlp",
|
||||||
|
["--simulate", "--no-warnings", "--print", "%(id)s", liveUrl],
|
||||||
|
{ timeout: 20_000 },
|
||||||
|
);
|
||||||
|
const videoId = stdout.trim().split("\n")[0];
|
||||||
|
return videoId || null;
|
||||||
|
} catch {
|
||||||
|
// yt-dlp exits non-zero when the channel isn't currently live
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSession(channel: { id: string; name: string }, liveVideoId: string) {
|
||||||
|
const existing = await prisma.streamSession.findFirst({
|
||||||
|
where: { channelId: channel.id, endedAt: null },
|
||||||
|
});
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
const session = await prisma.streamSession.create({
|
||||||
|
data: { channelId: channel.id, liveVideoId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await streamIngestQueue.add("start-capture", {
|
||||||
|
sessionId: session.id,
|
||||||
|
channelDbId: channel.id,
|
||||||
|
youtubeUrl: `https://www.youtube.com/watch?v=${liveVideoId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendTelegramMessage(`🔴 *${channel.name}* canlıya geçti, kayıt başlatılıyor.`);
|
||||||
|
console.log(`[youtube-polling] started session ${session.id} for channel ${channel.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollOnce(): Promise<void> {
|
||||||
|
const channels = await prisma.channel.findMany({ where: { isActive: true } });
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
try {
|
||||||
|
const liveVideoId = await findLiveVideoId(channel.channelId);
|
||||||
|
await prisma.channel.update({
|
||||||
|
where: { id: channel.id },
|
||||||
|
data: { lastCheckedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (liveVideoId) {
|
||||||
|
await startSession(channel, liveVideoId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[youtube-polling] error polling channel ${channel.name}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startPollingLoop(): NodeJS.Timeout {
|
||||||
|
console.log(`[youtube-polling] polling every ${env.pollIntervalMs}ms`);
|
||||||
|
pollOnce().catch((err) => console.error("[youtube-polling] initial poll failed:", err));
|
||||||
|
return setInterval(() => {
|
||||||
|
pollOnce().catch((err) => console.error("[youtube-polling] poll failed:", err));
|
||||||
|
}, env.pollIntervalMs);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm install
|
||||||
|
RUN pnpm --filter @streamclipper/db exec prisma generate
|
||||||
|
|
||||||
|
CMD ["pnpm", "--filter", "@streamclipper/frontend", "dev"]
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #0b0d12;
|
||||||
|
--panel: #141822;
|
||||||
|
--border: #262c3a;
|
||||||
|
--text: #e6e9ef;
|
||||||
|
--muted: #8b93a7;
|
||||||
|
--accent: #4f8cff;
|
||||||
|
--ok: #37c976;
|
||||||
|
--warn: #f2b84b;
|
||||||
|
--err: #ef5b5b;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a.active {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.ok { background: rgba(55,201,118,0.15); color: var(--ok); }
|
||||||
|
.badge.muted { background: rgba(139,147,167,0.15); color: var(--muted); }
|
||||||
|
.badge.warn { background: rgba(242,184,75,0.15); color: var(--warn); }
|
||||||
|
.badge.err { background: rgba(239,91,91,0.15); color: var(--err); }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-preview {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "StreamClipper AI — Panel",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="tr">
|
||||||
|
<body>
|
||||||
|
<nav className="nav">
|
||||||
|
<Link href="/">Kanal Durumu</Link>
|
||||||
|
<Link href="/segments">Segment & Aday Kütüphanesi</Link>
|
||||||
|
</nav>
|
||||||
|
<main>{children}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { prisma } from "@streamclipper/db";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const channels = await prisma.channel.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
include: {
|
||||||
|
sessions: {
|
||||||
|
where: { endedAt: null },
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Kanal Durumu</h1>
|
||||||
|
{channels.length === 0 ? (
|
||||||
|
<p className="empty">
|
||||||
|
Henüz kanal eklenmedi. `channels` tablosuna bir kayıt ekleyin (Prisma Studio veya seed script).
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kanal</th>
|
||||||
|
<th>Durum</th>
|
||||||
|
<th>Kayıt</th>
|
||||||
|
<th>Son Kontrol</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{channels.map((c) => {
|
||||||
|
const recording = c.sessions.length > 0;
|
||||||
|
return (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>{c.name} <span className="mono">{c.youtubeHandle}</span></td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${c.isActive ? "ok" : "muted"}`}>
|
||||||
|
{c.isActive ? "Aktif" : "Pasif"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${recording ? "warn" : "muted"}`}>
|
||||||
|
{recording ? "🔴 Kayıtta" : "—"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="mono">
|
||||||
|
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { prisma } from "@streamclipper/db";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<string, string> = {
|
||||||
|
PENDING: "muted",
|
||||||
|
PROCESSED: "ok",
|
||||||
|
DISCARDED: "err",
|
||||||
|
PENDING_STT: "warn",
|
||||||
|
TRANSCRIBED: "ok",
|
||||||
|
FAILED: "err",
|
||||||
|
};
|
||||||
|
|
||||||
|
function transcriptPreview(transcriptJson: unknown): string | null {
|
||||||
|
if (!transcriptJson || typeof transcriptJson !== "object") return null;
|
||||||
|
const text = (transcriptJson as { text?: string }).text;
|
||||||
|
return text ? text.slice(0, 240) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SegmentsPage() {
|
||||||
|
const segments = await prisma.rawSegment.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 50,
|
||||||
|
include: { candidates: { orderBy: { createdAt: "desc" } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Segment & Aday Klip Kütüphanesi</h1>
|
||||||
|
{segments.length === 0 ? (
|
||||||
|
<p className="empty">Henüz işlenmiş segment yok.</p>
|
||||||
|
) : (
|
||||||
|
segments.map((segment) => (
|
||||||
|
<div className="card" key={segment.id}>
|
||||||
|
<div className="card-head">
|
||||||
|
<span className="mono">{segment.filePath}</span>
|
||||||
|
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mono">
|
||||||
|
{segment.duration}sn · {new Date(segment.startedAt).toLocaleString("tr-TR")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{segment.candidates.length === 0 ? (
|
||||||
|
<p className="empty" style={{ marginTop: "0.5rem" }}>
|
||||||
|
Bu segmentte aday klip bulunamadı.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
segment.candidates.map((c) => (
|
||||||
|
<div key={c.id} style={{ marginTop: "0.75rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}>
|
||||||
|
<div className="card-head">
|
||||||
|
<span className="mono">
|
||||||
|
{c.startSec}s – {c.endSec}s
|
||||||
|
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
|
||||||
|
{c.chatVelocityScore != null && " · chat spike"}
|
||||||
|
</span>
|
||||||
|
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span>
|
||||||
|
</div>
|
||||||
|
{transcriptPreview(c.transcriptJson) && (
|
||||||
|
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
transpilePackages: ["@streamclipper/db"],
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@streamclipper/frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3000",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@streamclipper/db": "workspace:*",
|
||||||
|
"next": "^15.1.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg libsndfile1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY apps/worker/requirements.txt ./apps/worker/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r apps/worker/requirements.txt
|
||||||
|
COPY apps/worker ./apps/worker
|
||||||
|
|
||||||
|
WORKDIR /app/apps/worker
|
||||||
|
CMD ["python", "-m", "worker.main"]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
bullmq>=2.9.0
|
||||||
|
asyncpg>=0.29.0
|
||||||
|
numpy>=1.26.0
|
||||||
|
soundfile>=0.12.1
|
||||||
|
openai>=1.50.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
chat-downloader>=0.2.7
|
||||||
|
python-dotenv>=1.0.0
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Captures a YouTube live chat as timestamped JSONL for the lifetime of a
|
||||||
|
StreamSession. chat-downloader's `get_chat` is a blocking generator, so each
|
||||||
|
session's capture runs on its own daemon thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from chat_downloader import ChatDownloader
|
||||||
|
|
||||||
|
from .queues import SHARED_MEDIA_ROOT
|
||||||
|
|
||||||
|
_active_sessions: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_loop(session_id: str, video_id: str) -> None:
|
||||||
|
out_dir = Path(SHARED_MEDIA_ROOT) / "raw" / session_id
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
chat_path = out_dir / "chat.jsonl"
|
||||||
|
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
|
|
||||||
|
print(f"[chat-capture] started for session {session_id} ({url})")
|
||||||
|
try:
|
||||||
|
chat = ChatDownloader().get_chat(url)
|
||||||
|
with chat_path.open("a", encoding="utf-8") as f:
|
||||||
|
for message in chat:
|
||||||
|
record = {
|
||||||
|
"timestamp": message.get("timestamp"),
|
||||||
|
"time_in_seconds": message.get("time_in_seconds"),
|
||||||
|
"author": (message.get("author") or {}).get("name"),
|
||||||
|
"message": message.get("message"),
|
||||||
|
}
|
||||||
|
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||||
|
f.flush()
|
||||||
|
except Exception as exc: # chat-downloader raises when the stream ends
|
||||||
|
print(f"[chat-capture] session {session_id} stopped: {exc}")
|
||||||
|
finally:
|
||||||
|
_active_sessions.discard(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def start_chat_capture(session_id: str, video_id: str | None) -> None:
|
||||||
|
if session_id in _active_sessions or not video_id or video_id == "forced":
|
||||||
|
return
|
||||||
|
_active_sessions.add(session_id)
|
||||||
|
thread = threading.Thread(target=_capture_loop, args=(session_id, video_id), daemon=True)
|
||||||
|
thread.start()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Raw asyncpg access against the same Postgres tables Prisma manages from the
|
||||||
|
Node side (packages/db/prisma/schema.prisma). Prisma's `@default(cuid())` is
|
||||||
|
applied by Prisma Client at insert time, not as a Postgres-level DEFAULT, so
|
||||||
|
rows written from here generate their own ids (uuid4) instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
_pool: asyncpg.Pool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pool() -> asyncpg.Pool:
|
||||||
|
global _pool
|
||||||
|
if _pool is None:
|
||||||
|
_pool = await asyncpg.create_pool(dsn=os.environ["DATABASE_URL"])
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
async def get_raw_segment(raw_segment_id: str) -> asyncpg.Record | None:
|
||||||
|
pool = await get_pool()
|
||||||
|
return await pool.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT rs.id, rs.file_path, rs.duration, rs.session_id
|
||||||
|
FROM raw_segments rs
|
||||||
|
WHERE rs.id = $1
|
||||||
|
""",
|
||||||
|
raw_segment_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_candidate_segment(
|
||||||
|
raw_segment_id: str,
|
||||||
|
start_sec: int,
|
||||||
|
end_sec: int,
|
||||||
|
audio_peak_score: float,
|
||||||
|
chat_velocity_score: float | None,
|
||||||
|
) -> str:
|
||||||
|
pool = await get_pool()
|
||||||
|
candidate_id = str(uuid.uuid4())
|
||||||
|
await pool.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO candidate_segments
|
||||||
|
(id, raw_segment_id, start_sec, end_sec, audio_peak_score, chat_velocity_score, status, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'PENDING_STT', now())
|
||||||
|
""",
|
||||||
|
candidate_id,
|
||||||
|
raw_segment_id,
|
||||||
|
start_sec,
|
||||||
|
end_sec,
|
||||||
|
audio_peak_score,
|
||||||
|
chat_velocity_score,
|
||||||
|
)
|
||||||
|
return candidate_id
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_raw_segment_processed(raw_segment_id: str) -> None:
|
||||||
|
pool = await get_pool()
|
||||||
|
await pool.execute(
|
||||||
|
"UPDATE raw_segments SET status = 'PROCESSED' WHERE id = $1", raw_segment_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_candidate_segment(candidate_id: str) -> asyncpg.Record | None:
|
||||||
|
pool = await get_pool()
|
||||||
|
return await pool.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT cs.id, cs.start_sec, cs.end_sec, rs.file_path, c.name AS channel_name
|
||||||
|
FROM candidate_segments cs
|
||||||
|
JOIN raw_segments rs ON rs.id = cs.raw_segment_id
|
||||||
|
JOIN stream_sessions ss ON ss.id = rs.session_id
|
||||||
|
JOIN channels c ON c.id = ss.channel_id
|
||||||
|
WHERE cs.id = $1
|
||||||
|
""",
|
||||||
|
candidate_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_candidate_transcript(candidate_id: str, transcript: dict, status: str) -> None:
|
||||||
|
pool = await get_pool()
|
||||||
|
await pool.execute(
|
||||||
|
"UPDATE candidate_segments SET transcript_json = $2::jsonb, status = $3 WHERE id = $1",
|
||||||
|
candidate_id,
|
||||||
|
json.dumps(transcript),
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_active_sessions() -> list[asyncpg.Record]:
|
||||||
|
pool = await get_pool()
|
||||||
|
return await pool.fetch("SELECT id, live_video_id FROM stream_sessions WHERE ended_at IS NULL")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .chat_capture import start_chat_capture
|
||||||
|
from .signal_detection import start_signal_detection_worker
|
||||||
|
from .stt_scoring import start_stt_scoring_worker
|
||||||
|
|
||||||
|
SESSION_WATCH_INTERVAL_SEC = 10
|
||||||
|
|
||||||
|
|
||||||
|
async def watch_sessions_for_chat_capture():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
sessions = await db.get_active_sessions()
|
||||||
|
for s in sessions:
|
||||||
|
start_chat_capture(s["id"], s["live_video_id"])
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[main] session watcher error: {exc}")
|
||||||
|
await asyncio.sleep(SESSION_WATCH_INTERVAL_SEC)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
signal_worker = start_signal_detection_worker()
|
||||||
|
stt_worker = start_stt_scoring_worker()
|
||||||
|
print("[main] worker started: signal-detection + stt-scoring queues")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await watch_sessions_for_chat_capture()
|
||||||
|
finally:
|
||||||
|
await signal_worker.close()
|
||||||
|
await stt_worker.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Kept in sync with apps/api-daemon/src/queues.ts — the protocol-compatible
|
||||||
|
# `bullmq` package on both sides lets Node and Python share these Redis queues.
|
||||||
|
QUEUE_NAMES = {
|
||||||
|
"STREAM_INGEST": "stream-ingest",
|
||||||
|
"SIGNAL_DETECTION": "signal-detection",
|
||||||
|
"STT_SCORING": "stt-scoring",
|
||||||
|
"VIDEO_RENDER": "video-render",
|
||||||
|
"POST_PUBLISH": "post-publish",
|
||||||
|
}
|
||||||
|
|
||||||
|
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379")
|
||||||
|
SHARED_MEDIA_ROOT = os.environ.get("SHARED_MEDIA_ROOT", "./shared-media")
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
from bullmq import Queue, Worker
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .queues import QUEUE_NAMES, REDIS_URL, SHARED_MEDIA_ROOT
|
||||||
|
|
||||||
|
WINDOW_SEC = 1.0
|
||||||
|
PEAK_STD_MULTIPLIER = 1.5
|
||||||
|
CANDIDATE_PAD_SEC = 45
|
||||||
|
CHAT_SPIKE_MULTIPLIER = 3
|
||||||
|
SEGMENT_TIME_SEC = int(os.environ.get("SEGMENT_TIME_SEC", "900"))
|
||||||
|
|
||||||
|
stt_scoring_queue = Queue(QUEUE_NAMES["STT_SCORING"], {"connection": REDIS_URL})
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_audio_peaks(file_path: str) -> list[tuple[float, float]]:
|
||||||
|
"""(time_sec, db_level) for 1s windows whose RMS dB is a statistical outlier."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".wav") as tmp:
|
||||||
|
subprocess.run(
|
||||||
|
["ffmpeg", "-y", "-i", file_path, "-ac", "1", "-ar", "16000", "-vn", tmp.name],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
data, sr = sf.read(tmp.name)
|
||||||
|
|
||||||
|
if data.ndim > 1:
|
||||||
|
data = data.mean(axis=1)
|
||||||
|
|
||||||
|
window_samples = int(WINDOW_SEC * sr)
|
||||||
|
n_windows = max(1, len(data) // window_samples) if window_samples else 0
|
||||||
|
db_levels = []
|
||||||
|
for i in range(n_windows):
|
||||||
|
chunk = data[i * window_samples : (i + 1) * window_samples]
|
||||||
|
rms = float(np.sqrt(np.mean(chunk.astype(np.float64) ** 2))) if len(chunk) else 0.0
|
||||||
|
db_levels.append(20 * math.log10(rms + 1e-9))
|
||||||
|
|
||||||
|
if not db_levels:
|
||||||
|
return []
|
||||||
|
|
||||||
|
mean_db = float(np.mean(db_levels))
|
||||||
|
std_db = float(np.std(db_levels)) or 1.0
|
||||||
|
threshold = mean_db + PEAK_STD_MULTIPLIER * std_db
|
||||||
|
|
||||||
|
return [(i * WINDOW_SEC, level) for i, level in enumerate(db_levels) if level > threshold]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_chat_velocity_peaks(session_id: str) -> list[float]:
|
||||||
|
"""Absolute (stream-relative) second offsets of minutes with a chat spike."""
|
||||||
|
chat_path = Path(SHARED_MEDIA_ROOT) / "raw" / session_id / "chat.jsonl"
|
||||||
|
if not chat_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
buckets: dict[int, int] = {}
|
||||||
|
with chat_path.open(encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
try:
|
||||||
|
record = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
t = record.get("time_in_seconds")
|
||||||
|
if t is None:
|
||||||
|
continue
|
||||||
|
minute = int(t // 60)
|
||||||
|
buckets[minute] = buckets.get(minute, 0) + 1
|
||||||
|
|
||||||
|
if not buckets:
|
||||||
|
return []
|
||||||
|
|
||||||
|
avg = sum(buckets.values()) / len(buckets)
|
||||||
|
return [minute * 60.0 for minute, count in buckets.items() if count > CHAT_SPIKE_MULTIPLIER * avg]
|
||||||
|
|
||||||
|
|
||||||
|
def _segment_index_from_filename(file_path: str) -> int:
|
||||||
|
stem = Path(file_path).stem # segment_003
|
||||||
|
try:
|
||||||
|
return int(stem.rsplit("_", 1)[-1])
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _build_candidate_windows(
|
||||||
|
audio_peaks: list[tuple[float, float]],
|
||||||
|
chat_peak_times_abs: list[float],
|
||||||
|
segment_abs_offset: float,
|
||||||
|
segment_duration: int,
|
||||||
|
) -> list[tuple[int, int, float, float | None]]:
|
||||||
|
windows: list[tuple[int, int, float, float | None]] = []
|
||||||
|
|
||||||
|
for peak_time, peak_db in audio_peaks:
|
||||||
|
abs_time = segment_abs_offset + peak_time
|
||||||
|
nearest_chat = min(
|
||||||
|
(c for c in chat_peak_times_abs if abs(c - abs_time) <= 60),
|
||||||
|
key=lambda c: abs(c - abs_time),
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
center = (abs_time + nearest_chat) / 2 if nearest_chat is not None else abs_time
|
||||||
|
|
||||||
|
# Known simplification: candidate windows are clamped to the current
|
||||||
|
# 15-minute segment file since STT later cuts audio from that single
|
||||||
|
# file. A peak near a segment boundary can get truncated — stitching
|
||||||
|
# across segments is a future improvement, not part of this phase.
|
||||||
|
rel_start = max(0, int(center - segment_abs_offset - CANDIDATE_PAD_SEC))
|
||||||
|
rel_end = min(segment_duration, int(center - segment_abs_offset + CANDIDATE_PAD_SEC))
|
||||||
|
if rel_end - rel_start < 5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
chat_score = 1.0 if nearest_chat is not None else None
|
||||||
|
windows.append((rel_start, rel_end, peak_db, chat_score))
|
||||||
|
|
||||||
|
windows.sort(key=lambda w: w[0])
|
||||||
|
merged: list[list] = []
|
||||||
|
for w in windows:
|
||||||
|
if merged and w[0] <= merged[-1][1]:
|
||||||
|
merged[-1][1] = max(merged[-1][1], w[1])
|
||||||
|
merged[-1][2] = max(merged[-1][2], w[2])
|
||||||
|
else:
|
||||||
|
merged.append(list(w))
|
||||||
|
|
||||||
|
return [tuple(m) for m in merged]
|
||||||
|
|
||||||
|
|
||||||
|
async def process_signal_detection(job, job_token=None):
|
||||||
|
raw_segment_id = job.data["rawSegmentId"]
|
||||||
|
file_path = job.data["filePath"]
|
||||||
|
session_id = job.data["sessionId"]
|
||||||
|
|
||||||
|
print(f"[signal-detection] analyzing {file_path}")
|
||||||
|
|
||||||
|
raw_segment = await db.get_raw_segment(raw_segment_id)
|
||||||
|
if raw_segment is None:
|
||||||
|
print(f"[signal-detection] raw_segment {raw_segment_id} not found, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
audio_peaks = await asyncio.to_thread(_extract_audio_peaks, file_path)
|
||||||
|
chat_peaks_abs = await asyncio.to_thread(_load_chat_velocity_peaks, session_id)
|
||||||
|
|
||||||
|
segment_index = _segment_index_from_filename(file_path)
|
||||||
|
segment_abs_offset = segment_index * SEGMENT_TIME_SEC
|
||||||
|
|
||||||
|
windows = _build_candidate_windows(
|
||||||
|
audio_peaks, chat_peaks_abs, segment_abs_offset, raw_segment["duration"]
|
||||||
|
)
|
||||||
|
|
||||||
|
for start_sec, end_sec, audio_score, chat_score in windows:
|
||||||
|
candidate_id = await db.insert_candidate_segment(
|
||||||
|
raw_segment_id, start_sec, end_sec, audio_score, chat_score
|
||||||
|
)
|
||||||
|
await stt_scoring_queue.add("score-candidate", {"candidateSegmentId": candidate_id})
|
||||||
|
print(f"[signal-detection] candidate {candidate_id} [{start_sec}-{end_sec}]s")
|
||||||
|
|
||||||
|
await db.mark_raw_segment_processed(raw_segment_id)
|
||||||
|
|
||||||
|
|
||||||
|
def start_signal_detection_worker() -> Worker:
|
||||||
|
return Worker(QUEUE_NAMES["SIGNAL_DETECTION"], process_signal_detection, {"connection": REDIS_URL})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from bullmq import Worker
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .queues import QUEUE_NAMES, REDIS_URL
|
||||||
|
from .telegram import send_telegram_message
|
||||||
|
|
||||||
|
_openai_client: OpenAI | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _client() -> OpenAI:
|
||||||
|
global _openai_client
|
||||||
|
if _openai_client is None:
|
||||||
|
_openai_client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
|
||||||
|
return _openai_client
|
||||||
|
|
||||||
|
|
||||||
|
def _transcribe(clip_path: str) -> dict:
|
||||||
|
with open(clip_path, "rb") as f:
|
||||||
|
transcript = _client().audio.transcriptions.create(
|
||||||
|
model="whisper-1",
|
||||||
|
file=f,
|
||||||
|
response_format="verbose_json",
|
||||||
|
timestamp_granularities=["word"],
|
||||||
|
)
|
||||||
|
return transcript.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
async def process_stt_scoring(job, job_token=None):
|
||||||
|
candidate_id = job.data["candidateSegmentId"]
|
||||||
|
candidate = await db.get_candidate_segment(candidate_id)
|
||||||
|
if candidate is None:
|
||||||
|
print(f"[stt-scoring] candidate {candidate_id} not found, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".mp3") as clip:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-ss", str(candidate["start_sec"]),
|
||||||
|
"-to", str(candidate["end_sec"]),
|
||||||
|
"-i", candidate["file_path"],
|
||||||
|
"-ac", "1", "-ar", "16000",
|
||||||
|
clip.name,
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
transcript_dict = await asyncio.to_thread(_transcribe, clip.name)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[stt-scoring] OpenAI call failed for {candidate_id}: {exc}")
|
||||||
|
await db.update_candidate_transcript(candidate_id, {"error": str(exc)}, "FAILED")
|
||||||
|
return
|
||||||
|
|
||||||
|
await db.update_candidate_transcript(candidate_id, transcript_dict, "TRANSCRIBED")
|
||||||
|
|
||||||
|
preview = (transcript_dict.get("text") or "")[:200]
|
||||||
|
await send_telegram_message(
|
||||||
|
f"🎬 *{candidate['channel_name']}* için yeni aday klip transkribe edildi "
|
||||||
|
f"({candidate['start_sec']}-{candidate['end_sec']}s):\n_{preview}_"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[stt-scoring] candidate {candidate_id} transcribed")
|
||||||
|
|
||||||
|
|
||||||
|
def start_stt_scoring_worker() -> Worker:
|
||||||
|
return Worker(QUEUE_NAMES["STT_SCORING"], process_stt_scoring, {"connection": REDIS_URL})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||||
|
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_telegram_message(text: str) -> None:
|
||||||
|
if not BOT_TOKEN or not CHAT_ID:
|
||||||
|
print(f"[telegram] not configured, skipping notification: {text}")
|
||||||
|
return
|
||||||
|
|
||||||
|
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url, json={"chat_id": CHAT_ID, "text": text, "parse_mode": "Markdown"}
|
||||||
|
)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
print(f"[telegram] failed to send message: {resp.status_code} {resp.text}")
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
services:
|
||||||
|
sc_redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
|
||||||
|
sc_postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: streamclipper
|
||||||
|
POSTGRES_PASSWORD: streamclipper
|
||||||
|
POSTGRES_DB: streamclipper
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
sc_api_daemon:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/api-daemon/Dockerfile
|
||||||
|
env_file: ../.env
|
||||||
|
environment:
|
||||||
|
REDIS_URL: redis://sc_redis:6379
|
||||||
|
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
|
||||||
|
SHARED_MEDIA_ROOT: /shared-media
|
||||||
|
volumes:
|
||||||
|
- shared-media:/shared-media
|
||||||
|
ports:
|
||||||
|
- "4001:4001"
|
||||||
|
depends_on:
|
||||||
|
- sc_redis
|
||||||
|
- sc_postgres
|
||||||
|
|
||||||
|
sc_heavy_worker:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/worker/Dockerfile
|
||||||
|
env_file: ../.env
|
||||||
|
environment:
|
||||||
|
REDIS_URL: redis://sc_redis:6379
|
||||||
|
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
|
||||||
|
SHARED_MEDIA_ROOT: /shared-media
|
||||||
|
volumes:
|
||||||
|
- shared-media:/shared-media
|
||||||
|
depends_on:
|
||||||
|
- sc_redis
|
||||||
|
- sc_postgres
|
||||||
|
|
||||||
|
sc_frontend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/frontend/Dockerfile
|
||||||
|
env_file: ../.env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- sc_postgres
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
postgres-data:
|
||||||
|
shared-media:
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "streamclipper-ai",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"packageManager": "pnpm@11.22.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"db:generate": "pnpm --filter @streamclipper/db exec prisma generate",
|
||||||
|
"db:migrate": "pnpm --filter @streamclipper/db exec prisma migrate dev",
|
||||||
|
"db:seed": "pnpm --filter @streamclipper/db run seed",
|
||||||
|
"dev:daemon": "pnpm --filter @streamclipper/api-daemon dev",
|
||||||
|
"dev:frontend": "pnpm --filter @streamclipper/frontend dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@streamclipper/db",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"generate": "prisma generate",
|
||||||
|
"migrate": "prisma migrate dev",
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"prisma": "^6.3.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RawSegmentStatus {
|
||||||
|
PENDING
|
||||||
|
PROCESSED
|
||||||
|
DISCARDED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CandidateSegmentStatus {
|
||||||
|
PENDING_STT
|
||||||
|
TRANSCRIBED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Channel {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
youtubeHandle String @map("youtube_handle")
|
||||||
|
channelId String @unique @map("channel_id")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
lastCheckedAt DateTime? @map("last_checked_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
sessions StreamSession[]
|
||||||
|
|
||||||
|
@@map("channels")
|
||||||
|
}
|
||||||
|
|
||||||
|
model StreamSession {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
channelId String @map("channel_id")
|
||||||
|
channel Channel @relation(fields: [channelId], references: [id])
|
||||||
|
liveVideoId String? @map("live_video_id")
|
||||||
|
startedAt DateTime @default(now()) @map("started_at")
|
||||||
|
endedAt DateTime? @map("ended_at")
|
||||||
|
totalSegments Int @default(0) @map("total_segments")
|
||||||
|
|
||||||
|
segments RawSegment[]
|
||||||
|
|
||||||
|
@@map("stream_sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model RawSegment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionId String @map("session_id")
|
||||||
|
session StreamSession @relation(fields: [sessionId], references: [id])
|
||||||
|
filePath String @map("file_path")
|
||||||
|
duration Int
|
||||||
|
startedAt DateTime @map("started_at")
|
||||||
|
status RawSegmentStatus @default(PENDING)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
candidates CandidateSegment[]
|
||||||
|
|
||||||
|
@@map("raw_segments")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CandidateSegment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
rawSegmentId String @map("raw_segment_id")
|
||||||
|
rawSegment RawSegment @relation(fields: [rawSegmentId], references: [id])
|
||||||
|
startSec Int @map("start_sec")
|
||||||
|
endSec Int @map("end_sec")
|
||||||
|
audioPeakScore Float? @map("audio_peak_score")
|
||||||
|
chatVelocityScore Float? @map("chat_velocity_score")
|
||||||
|
transcriptJson Json? @map("transcript_json")
|
||||||
|
status CandidateSegmentStatus @default(PENDING_STT)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("candidate_segments")
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { prisma } from "../src";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const channelId = process.env.SEED_CHANNEL_ID ?? "UCXXXXXXXXXXXXXXXXXXXXXX";
|
||||||
|
const channel = await prisma.channel.upsert({
|
||||||
|
where: { channelId },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: process.env.SEED_CHANNEL_NAME ?? "Test Kanal",
|
||||||
|
youtubeHandle: process.env.SEED_CHANNEL_HANDLE ?? "@test",
|
||||||
|
channelId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("Seeded channel:", channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(() => process.exit());
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __prisma: PrismaClient | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = global.__prisma ?? new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
global.__prisma = prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from "@prisma/client";
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Generated
+2070
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
|
allowBuilds:
|
||||||
|
'@prisma/client': true
|
||||||
|
'@prisma/engines': true
|
||||||
|
esbuild: true
|
||||||
|
msgpackr-extract: true
|
||||||
|
prisma: true
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# Ürün Gereksinim Dokümanı (PRD)
|
||||||
|
|
||||||
|
**Proje Adı:** StreamClipper AI
|
||||||
|
**Sürüm:** v2.0.0 (Uçtan Uca Otonom Pipeline & Dağıtım Spesifikasyonu)
|
||||||
|
**Tarih:** Ağustos 2026
|
||||||
|
**Dağıtım Ortamı:** Coolify (Multi-Container Docker Architecture)
|
||||||
|
**Hedef:** YouTube spor yayınlarını tarayıcısız otonom kaydeden, sinyal analizi ve yapay zeka ile viral kesitleri çıkaran, 9:16 formata dönüştüren ve YouTube Shorts / Instagram Reels / TikTok platformlarına zamanlayarak otomatik dağıtan tam kapsamlı içerik motoru.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Yönetici Özeti ve Problem Tanımı
|
||||||
|
|
||||||
|
### 1.1 Problem
|
||||||
|
* **İş Gücü Yükü:** Canlı yayınlardan manuel kesit almak saatlerce ekran başında beklemeyi, uzun videoları kurgu programlarında taramayı, dikey kadraja uydurmayı ve tek tek altyazı yazmayı gerektirir.
|
||||||
|
* **Hız Kaybı:** Spor gündeminde ve canlı maç tepkilerinde bir anın viralliği ilk 1-2 saat içinde zirve yapar; manuel montaj gecikmeleri etkileşim potansiyelini tüketir.
|
||||||
|
* **Dağıtım Sürtünmesi:** Aynı dikey videonun YouTube, Instagram ve TikTok için ayrı ayrı yüklenmesi, açıklama/etiket girilmesi operasyonel yavaşlığa yol açar.
|
||||||
|
|
||||||
|
### 1.2 Çözüm
|
||||||
|
Arka planda headless biçimde yayınları dinleyen, ses desibel patlamaları ve sohbet hızıyla tepe noktaları saptayan, STT + LLM ile mantıksal hikaye bütünlüğüne sahip 30-60 saniyelik klipleri seçen, dikey kadrajı konuşmacının yüzüne göre ortalayıp renkli dinamik altyazı basan ve onay sonrası tüm sosyal ağlara zamanlayarak yayınlayan otonom bir SaaS/içerik fabrikası.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Kullanıcı Tipleri ve Roller
|
||||||
|
|
||||||
|
* **Sistem Yöneticisi (Admin):** Takip edilecek kanalları, Docker/Coolify donanım limitlerini, API anahtarlarını ve Redis kuyruk yapılandırmasını yönetir.
|
||||||
|
* **İçerik Editörü:** Dashboard üzerinden üretilen klipleri izler, virallik puanlarını değerlendirir, gerekiyorsa saniyeleri kaydırır veya altyazı yazım hatalarını düzeltir, paylaşım kuyruğunu yönetir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Sistem Mimarisi ve Veri Akışı
|
||||||
|
[ Ingestion Motoru (yt-dlp) ] ──> [ Sinyal Tespiti (Audio/Chat) ]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ Render Motoru (OpenCV + Altyazı) ] <── [ STT (Whisper) + LLM Skorlama ]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ Next.js Panel & Önizleme ] ──(Onay / Auto)──> [ Multi-Platform Dağıtım & Scheduler ]
|
||||||
|
(YouTube, Instagram, TikTok)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Ürün Gereksinim Dokümanı (PRD)
|
||||||
|
|
||||||
|
**Proje Adı:** StreamClipper AI
|
||||||
|
**Sürüm:** v2.0.0 (Uçtan Uca Otonom Pipeline & Dağıtım Spesifikasyonu)
|
||||||
|
**Tarih:** Ağustos 2026
|
||||||
|
**Dağıtım Ortamı:** Coolify (Multi-Container Docker Architecture)
|
||||||
|
**Hedef:** YouTube spor yayınlarını tarayıcısız otonom kaydeden, sinyal analizi ve yapay zeka ile viral kesitleri çıkaran, 9:16 formata dönüştüren ve YouTube Shorts / Instagram Reels / TikTok platformlarına zamanlayarak otomatik dağıtan tam kapsamlı içerik motoru.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Yönetici Özeti ve Problem Tanımı
|
||||||
|
|
||||||
|
### 1.1 Problem
|
||||||
|
* **İş Gücü Yükü:** Canlı yayınlardan manuel kesit almak saatlerce ekran başında beklemeyi, uzun videoları kurgu programlarında taramayı, dikey kadraja uydurmayı ve tek tek altyazı yazmayı gerektirir.
|
||||||
|
* **Hız Kaybı:** Spor gündeminde ve canlı maç tepkilerinde bir anın viralliği ilk 1-2 saat içinde zirve yapar; manuel montaj gecikmeleri etkileşim potansiyelini tüketir.
|
||||||
|
* **Dağıtım Sürtünmesi:** Aynı dikey videonun YouTube, Instagram ve TikTok için ayrı ayrı yüklenmesi, açıklama/etiket girilmesi operasyonel yavaşlığa yol açar.
|
||||||
|
|
||||||
|
### 1.2 Çözüm
|
||||||
|
Arka planda headless biçimde yayınları dinleyen, ses desibel patlamaları ve sohbet hızıyla tepe noktaları saptayan, STT + LLM ile mantıksal hikaye bütünlüğüne sahip 30-60 saniyelik klipleri seçen, dikey kadrajı konuşmacının yüzüne göre ortalayıp renkli dinamik altyazı basan ve onay sonrası tüm sosyal ağlara zamanlayarak yayınlayan otonom bir SaaS/içerik fabrikası.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Kullanıcı Tipleri ve Roller
|
||||||
|
|
||||||
|
* **Sistem Yöneticisi (Admin):** Takip edilecek kanalları, Docker/Coolify donanım limitlerini, API anahtarlarını ve Redis kuyruk yapılandırmasını yönetir.
|
||||||
|
* **İçerik Editörü:** Dashboard üzerinden üretilen klipleri izler, virallik puanlarını değerlendirir, gerekiyorsa saniyeleri kaydırır veya altyazı yazım hatalarını düzeltir, paylaşım kuyruğunu yönetir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Sistem Mimarisi ve Veri Akışı
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
[ Ingestion Motoru (yt-dlp) ] ──> [ Sinyal Tespiti (Audio/Chat) ]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ Render Motoru (OpenCV + Altyazı) ] <── [ STT (Whisper) + LLM Skorlama ]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ Next.js Panel & Önizleme ] ──(Onay / Auto)──> [ Multi-Platform Dağıtım & Scheduler ]
|
||||||
|
(YouTube, Instagram, TikTok)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Fonksiyonel Modüller
|
||||||
|
|
||||||
|
### Modül 1: Ingestion & Canlı Yayın Dinleyici (Ingestion Daemon)
|
||||||
|
* **Otomatik Polling:** Tanımlı kanal listesini her 60 saniyede bir kontrol eder. Yayın başladığı anda yakalar.
|
||||||
|
* **Headless Stream Capture:** Tarayıcı çalıştırmadan doğrudan `yt-dlp` üzerinden raw HLS/DASH akışını diske yazar (CPU/GPU yükü oluşturmaz).
|
||||||
|
* **Segmentli Kayıt:** FFmpeg pipe yardımıyla saatler süren tek dosya yerine 15'er dakikalık bağımsız `.mp4` parçaları üretir.
|
||||||
|
* **Canlı Sohbet Kaydedici:** Yayındaki canlı sohbet akışını zaman damgalarıyla eşzamanlı toplar.
|
||||||
|
|
||||||
|
### Modül 2: Aday Segment & Sinyal Analiz Motoru
|
||||||
|
* **Ses Desibel Analizi (Audio Peak):** FFmpeg ses analiz filtreleriyle ortalamanın üzerindeki ani desibel sıçramalarını (bağırma, gol sevinci, tartışma) tespit eder.
|
||||||
|
* **Sohbet Yoğunluğu (Chat Velocity):** Dakika başına atılan mesaj sayısının ortalamanın 3 katına çıktığı anları işaretler.
|
||||||
|
* **Aday Pencere Çıkarımı:** Ses ve sohbet tepe noktalarının ortaklaştığı anların $\pm 45$ saniyesini aday aralık olarak kesip kuyruğa iletir.
|
||||||
|
|
||||||
|
### Modül 3: Yapay Zeka (STT, LLM & Güvenlik Analizi)
|
||||||
|
* **Kelime Seviyesi Transkripsiyon (STT):** Aday video bloğunun sesini kelime bazlı zaman damgalarıyla (`word_timestamps`) metne dönüştürür.
|
||||||
|
* **Virallik Puanlama ve Sınır Belirleme:**
|
||||||
|
* LLM metni analiz eder; 0-100 arası virallik puanı verir. Eşik değerin (varsayılan: 75) altındaki adayları eler.
|
||||||
|
* Giriş-gelişme-sonuç dengesi olan, 30-60 saniye aralığında kesin başlangıç ve bitiş saniyelerini belirler.
|
||||||
|
* **Metadata Üretimi:** Platforma özel başlık, açıklama metni ve algoritma uyumlu 3-5 adet hashtag üretir.
|
||||||
|
* **Telif / Maç Görüntüsü Filtresi:** Ekrandaki resmi maç özetlerini veya yayıncı arkasındaki telifli müzikleri algılar; video alanını otomatik bulanıklaştırır (blur) veya ses miksini dengeler.
|
||||||
|
|
||||||
|
### Modül 4: Akıllı Görüntü & Render Motoru
|
||||||
|
* **Dinamik 9:16 Kadrajlama (Auto-Reframe):** OpenCV/MediaPipe ile konuşmacının yüzünü takip eder; 16:9 yatay videodan yüzü merkezleyen dikey kadraj üretir.
|
||||||
|
* **Çoklu Şablon Desteği (Template Switcher):**
|
||||||
|
* *Tekil Yayıncı:* Ortalanmış dikey kadraj.
|
||||||
|
* *İkili / Konuklu Yayın:* İki yüz algılandığında dikeyde alt alta yerleştiren Split-Screen şablonu.
|
||||||
|
* *Yayıncı + Ekran:* Yayıncının yüzünü dairesel maskede tutarken alt alanı ekrana ayıran hazır düzen.
|
||||||
|
* **Dinamik Altyazı:** Kelime zamanlamalarına göre ekranda senkronize parlayan (karaoke/TikTok stili) renkli altyazı basar.
|
||||||
|
* **Arayüz Güvenli Bölgesi (Safe Zone):** Shorts ve Reels butonlarının (beğeni, yorum, profil) denk geldiği alt ve sağ kenarları boş bırakır.
|
||||||
|
|
||||||
|
### Modül 5: Multi-Platform Dağıtım & Post Scheduler
|
||||||
|
* **YouTube Data API v3:** Videoyu doğrudan kanala `private`, `unlisted` veya `scheduled` (zamanlanmış) olarak yükler.
|
||||||
|
* **Instagram Graph API:** Reels formatında kapak görseli, açıklama ve hashtag'lerle doğrudan veya ileri tarihli paylaşım yapar.
|
||||||
|
* **TikTok Content Posting API:** Hesaba doğrudan taslak (draft) veya zamanlanmış video gönderir.
|
||||||
|
* **Akıllı Yayın Kuyruğu (Queue Slots):** Aynı yayından çıkan birden fazla klibi algoritmayı boğmayacak şekilde kullanıcının belirlediği aralıklarla (örn. 3 saatte bir) otomatik paylaşıma dağıtır.
|
||||||
|
* **YouTube "Draft-Test" Güvenlik Aşaması:** Videoyu önce gizli yükleyip otomatik Content ID kontrolünü sorgular; telif uyarısı varsa durumu panele bildirir.
|
||||||
|
|
||||||
|
### Modül 6: Next.js Yönetim Paneli & Bildirimler
|
||||||
|
* **Canlı Durum Ekranı:** Hangi kanalların kayıtta olduğunu, aktif indirme durumunu ve kuyruk yoğunluğunu anlık gösterir.
|
||||||
|
* **Klip Kütüphanesi:** Üretilen kliplerin önizleme oynatıcısı, virallik skoru ve platform açıklamalarıyla listelenmesi.
|
||||||
|
* **Hızlı Kurgu / Düzeltme Editörü:** Başlangıç-bitiş saniyelerini ileri-geri kaydırma ve altyazı metnini manuel düzeltme imkanı.
|
||||||
|
* **Zamanlama Takvimi (Calendar View):** Hangi klibin ne zaman hangi platformda paylaşılacağını gösteren sürükle-bırak takvim.
|
||||||
|
* **Telegram / Discord Bot Bildirimi:** Yeni klip hazır olduğunda önizleme videosu ve `[Hemen Paylaş]`, `[Zamanla]`, `[Reddet]` aksiyon butonlarıyla bildirim iletir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. BullMQ Kuyruk Mimarisi
|
||||||
|
|
||||||
|
İş parçacıkları Redis tabanlı BullMQ üzerinde birbirinden izole çalışır:
|
||||||
|
|
||||||
|
| Kuyruk Adı | Tetikleyici | Görev | Yeniden Deneme (Retry) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `stream-ingest` | Polling servisi canlı algıladığında | 15 dk'lık blok kaydı diske yazma | 3 deneme, yayın biterse kapat |
|
||||||
|
| `signal-detection` | 15 dk'lık segment tamamlandığında | Desibel tepe noktaları ve chat hızı analizi | 2 deneme |
|
||||||
|
| `stt-scoring` | Aday pencere bulunduğunda | Whisper transkripti, LLM virallik ve süre analizi | 3 deneme, üstel geri çekilme |
|
||||||
|
| `video-render` | LLM onay verdiğinde | Yüz takibi, 9:16 crop, altyazı ve final render | 1 deneme, hata logla |
|
||||||
|
| `post-publish` | Zamanlanan saat geldiğinde | YouTube / Instagram / TikTok API çağrısı | 5 deneme, token hatasında bildirim |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Coolify Altyapı ve Konteyner Yapılandırması
|
||||||
|
|
||||||
|
Sistem Coolify üzerinde tek bir Docker Compose projesi olarak barındırılır:
|
||||||
|
|
||||||
|
* **Paylaşımlı Depolama (Shared Volume):**
|
||||||
|
`/shared-media` adlı kalıcı disk birimi tüm container'lara bağlanır:
|
||||||
|
* `/raw`: 15 dakikalık ham yayın kayıtları.
|
||||||
|
* `/candidates`: Kesilen ham aday parçalar.
|
||||||
|
* `/shorts`: Dağıtıma hazır final MP4 çıktıları.
|
||||||
|
* **Container Rolleri:**
|
||||||
|
* `sc_frontend`: Next.js (Dashboard & App Router)
|
||||||
|
* `sc_api_daemon`: Node.js API, YouTube Polling & Queue Dispatcher
|
||||||
|
* `sc_heavy_worker`: Python (Whisper, MediaPipe, FFmpeg Render Motoru)
|
||||||
|
* `sc_redis`: BullMQ kuyruk durum yönetimi
|
||||||
|
* `sc_postgres`: Kalıcı veri tabanı (Prisma ORM)
|
||||||
|
* **Kendi Kendini Temizleme (Auto-Purge Lifecycle):**
|
||||||
|
* İşlenmiş ham kayıtlar (`/raw`) 24 saat sonra cron ile otomatik temizlenir.
|
||||||
|
* Final Shorts videoları dağıtımı tamamlandıktan 7 gün sonra arşivlenir veya silinir.
|
||||||
|
* **Donanım Sınırlandırması:**
|
||||||
|
* `sc_heavy_worker` için kesin CPU/RAM limiti belirlenir; render esnasında panelin kilitlenmesi engellenir. Donanımda GPU varsa NVIDIA Docker Runtime ile FFmpeg NVENC hızlandırması açılır.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Veritabanı Şeması (Temel Varlıklar)
|
||||||
|
|
||||||
|
* **Channel:** `id`, `name`, `youtube_handle`, `channel_id`, `is_active`, `last_checked_at`
|
||||||
|
* **StreamSession:** `id`, `channel_id`, `started_at`, `ended_at`, `total_segments`
|
||||||
|
* **RawSegment:** `id`, `session_id`, `file_path`, `duration`, `status` (PENDING, PROCESSED, DISCARDED)
|
||||||
|
* **ShortVideo:** `id`, `segment_id`, `file_path`, `virality_score`, `start_sec`, `end_sec`, `title`, `description`, `tags`, `transcript_json`, `status` (DRAFT, APPROVED, REJECTED)
|
||||||
|
* **ScheduledPost:** `id`, `short_video_id`, `platform` (YOUTUBE, INSTAGRAM, TIKTOK), `scheduled_time`, `status` (QUEUED, PUBLISHED, FAILED), `external_post_id`, `error_message`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Başarı Kriterleri (KPI'lar)
|
||||||
|
|
||||||
|
* **Uçtan Uca Teslimat Hızı:** Canlı yayındaki olayın gerçekleştiği andan klibin paneline düşmesine kadar geçen sürenin 5 dakikayı aşmaması.
|
||||||
|
* **Manuel Müdahale Oranı:** Üretilen kliplerin en az %75'inin zamanlama kuyruğuna hiçbir kırpma gerektirmeden doğrudan eklenebilmesi.
|
||||||
|
* **Eşzamanlı Kapasite:** Sunucunun performans kaybı yaşamadan en az 3 farklı canlı yayını aynı anda kaydedip işleyebilmesi.
|
||||||
|
* **Dağıtım Başarısı:** Zamanlanan sosyal medya paylaşımlarının %99 oranında hatasız platformlara iletilmesi.
|
||||||
|
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user