From eccc74166a08eddd8d9f2db8e300666bd4407274 Mon Sep 17 00:00:00 2001 From: ayrisdev Date: Sun, 30 Aug 2026 14:36:50 +0300 Subject: [PATCH] =?UTF-8?q?StreamClipper=20AI=20Faz=201:=20ingestion=20+?= =?UTF-8?q?=20sinyal=20analizi=20+=20STT=20+=20altyap=C4=B1=20iskeleti?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 14 + .env.example | 21 + .gitignore | 14 + README.md | 99 + apps/api-daemon/Dockerfile | 15 + apps/api-daemon/package.json | 24 + apps/api-daemon/src/capture/streamIngest.ts | 152 ++ apps/api-daemon/src/env.ts | 10 + apps/api-daemon/src/index.ts | 47 + apps/api-daemon/src/queues.ts | 36 + apps/api-daemon/src/redis.ts | 6 + apps/api-daemon/src/server.ts | 36 + apps/api-daemon/src/telegram.ts | 20 + apps/api-daemon/src/youtubePolling.ts | 80 + apps/api-daemon/tsconfig.json | 13 + apps/frontend/Dockerfile | 10 + apps/frontend/app/globals.css | 117 ++ apps/frontend/app/layout.tsx | 21 + apps/frontend/app/page.tsx | 61 + apps/frontend/app/segments/page.tsx | 69 + apps/frontend/next-env.d.ts | 5 + apps/frontend/next.config.js | 6 + apps/frontend/package.json | 22 + apps/frontend/tsconfig.json | 21 + apps/worker/Dockerfile | 13 + apps/worker/requirements.txt | 8 + apps/worker/worker/__init__.py | 0 apps/worker/worker/chat_capture.py | 48 + apps/worker/worker/db.py | 95 + apps/worker/worker/main.py | 35 + apps/worker/worker/queues.py | 18 + apps/worker/worker/signal_detection.py | 164 ++ apps/worker/worker/stt_scoring.py | 74 + apps/worker/worker/telegram.py | 20 + infra/docker-compose.yml | 67 + package.json | 16 + packages/db/package.json | 24 + packages/db/prisma/schema.prisma | 78 + packages/db/prisma/seed.ts | 23 + packages/db/src/index.ts | 14 + packages/db/tsconfig.json | 14 + pnpm-lock.yaml | 2070 +++++++++++++++++++ pnpm-workspace.yaml | 9 + prd.md | 188 ++ 44 files changed, 3897 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/api-daemon/Dockerfile create mode 100644 apps/api-daemon/package.json create mode 100644 apps/api-daemon/src/capture/streamIngest.ts create mode 100644 apps/api-daemon/src/env.ts create mode 100644 apps/api-daemon/src/index.ts create mode 100644 apps/api-daemon/src/queues.ts create mode 100644 apps/api-daemon/src/redis.ts create mode 100644 apps/api-daemon/src/server.ts create mode 100644 apps/api-daemon/src/telegram.ts create mode 100644 apps/api-daemon/src/youtubePolling.ts create mode 100644 apps/api-daemon/tsconfig.json create mode 100644 apps/frontend/Dockerfile create mode 100644 apps/frontend/app/globals.css create mode 100644 apps/frontend/app/layout.tsx create mode 100644 apps/frontend/app/page.tsx create mode 100644 apps/frontend/app/segments/page.tsx create mode 100644 apps/frontend/next-env.d.ts create mode 100644 apps/frontend/next.config.js create mode 100644 apps/frontend/package.json create mode 100644 apps/frontend/tsconfig.json create mode 100644 apps/worker/Dockerfile create mode 100644 apps/worker/requirements.txt create mode 100644 apps/worker/worker/__init__.py create mode 100644 apps/worker/worker/chat_capture.py create mode 100644 apps/worker/worker/db.py create mode 100644 apps/worker/worker/main.py create mode 100644 apps/worker/worker/queues.py create mode 100644 apps/worker/worker/signal_detection.py create mode 100644 apps/worker/worker/stt_scoring.py create mode 100644 apps/worker/worker/telegram.py create mode 100644 infra/docker-compose.yml create mode 100644 package.json create mode 100644 packages/db/package.json create mode 100644 packages/db/prisma/schema.prisma create mode 100644 packages/db/prisma/seed.ts create mode 100644 packages/db/src/index.ts create mode 100644 packages/db/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 prd.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6aab7c5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +**/node_modules +.next +dist +build +shared-media +.git +*.log +.env +.env.local +__pycache__ +**/__pycache__ +.venv +venv diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e764b35 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e06eabe --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +.pnpm-store/ +.next/ +dist/ +build/ +*.log +.env +.env.local +__pycache__/ +*.pyc +.venv/ +venv/ +shared-media/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f057fe --- /dev/null +++ b/README.md @@ -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//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=` ile `apps/api-daemon`'ı + çalıştırırsan polling'i atlayıp doğrudan capture'ı tetikler. +2. `shared-media/raw//` 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). diff --git a/apps/api-daemon/Dockerfile b/apps/api-daemon/Dockerfile new file mode 100644 index 0000000..c136f38 --- /dev/null +++ b/apps/api-daemon/Dockerfile @@ -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"] diff --git a/apps/api-daemon/package.json b/apps/api-daemon/package.json new file mode 100644 index 0000000..a8ed89f --- /dev/null +++ b/apps/api-daemon/package.json @@ -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" + } +} diff --git a/apps/api-daemon/src/capture/streamIngest.ts b/apps/api-daemon/src/capture/streamIngest.ts new file mode 100644 index 0000000..5c73632 --- /dev/null +++ b/apps/api-daemon/src/capture/streamIngest.ts @@ -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 { + 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): Promise { + 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((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 { + return new Worker(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, + }); +} diff --git a/apps/api-daemon/src/env.ts b/apps/api-daemon/src/env.ts new file mode 100644 index 0000000..37a7bac --- /dev/null +++ b/apps/api-daemon/src/env.ts @@ -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 ?? "", +}; diff --git a/apps/api-daemon/src/index.ts b/apps/api-daemon/src/index.ts new file mode 100644 index 0000000..de28a1f --- /dev/null +++ b/apps/api-daemon/src/index.ts @@ -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); +}); diff --git a/apps/api-daemon/src/queues.ts b/apps/api-daemon/src/queues.ts new file mode 100644 index 0000000..4693928 --- /dev/null +++ b/apps/api-daemon/src/queues.ts @@ -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(QUEUE_NAMES.STREAM_INGEST, { + connection: redisConnection, +}); + +export const signalDetectionQueue = new Queue(QUEUE_NAMES.SIGNAL_DETECTION, { + connection: redisConnection, +}); diff --git a/apps/api-daemon/src/redis.ts b/apps/api-daemon/src/redis.ts new file mode 100644 index 0000000..59c26c8 --- /dev/null +++ b/apps/api-daemon/src/redis.ts @@ -0,0 +1,6 @@ +import IORedis from "ioredis"; +import { env } from "./env"; + +export const redisConnection = new IORedis(env.redisUrl, { + maxRetriesPerRequest: null, +}); diff --git a/apps/api-daemon/src/server.ts b/apps/api-daemon/src/server.ts new file mode 100644 index 0000000..4c151f8 --- /dev/null +++ b/apps/api-daemon/src/server.ts @@ -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}`); + }); +} diff --git a/apps/api-daemon/src/telegram.ts b/apps/api-daemon/src/telegram.ts new file mode 100644 index 0000000..9097d70 --- /dev/null +++ b/apps/api-daemon/src/telegram.ts @@ -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 { + 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()); + } +} diff --git a/apps/api-daemon/src/youtubePolling.ts b/apps/api-daemon/src/youtubePolling.ts new file mode 100644 index 0000000..bcc9452 --- /dev/null +++ b/apps/api-daemon/src/youtubePolling.ts @@ -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 { + 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 { + 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); +} diff --git a/apps/api-daemon/tsconfig.json b/apps/api-daemon/tsconfig.json new file mode 100644 index 0000000..49d983b --- /dev/null +++ b/apps/api-daemon/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile new file mode 100644 index 0000000..b4fad2e --- /dev/null +++ b/apps/frontend/Dockerfile @@ -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"] diff --git a/apps/frontend/app/globals.css b/apps/frontend/app/globals.css new file mode 100644 index 0000000..90ed3fc --- /dev/null +++ b/apps/frontend/app/globals.css @@ -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; +} diff --git a/apps/frontend/app/layout.tsx b/apps/frontend/app/layout.tsx new file mode 100644 index 0000000..c2bf62e --- /dev/null +++ b/apps/frontend/app/layout.tsx @@ -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 ( + + + +
{children}
+ + + ); +} diff --git a/apps/frontend/app/page.tsx b/apps/frontend/app/page.tsx new file mode 100644 index 0000000..bb0e7c7 --- /dev/null +++ b/apps/frontend/app/page.tsx @@ -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 ( + <> +

Kanal Durumu

+ {channels.length === 0 ? ( +

+ Henüz kanal eklenmedi. `channels` tablosuna bir kayıt ekleyin (Prisma Studio veya seed script). +

+ ) : ( + + + + + + + + + + + {channels.map((c) => { + const recording = c.sessions.length > 0; + return ( + + + + + + + ); + })} + +
KanalDurumKayıtSon Kontrol
{c.name} {c.youtubeHandle} + + {c.isActive ? "Aktif" : "Pasif"} + + + + {recording ? "🔴 Kayıtta" : "—"} + + + {c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"} +
+ )} + + ); +} diff --git a/apps/frontend/app/segments/page.tsx b/apps/frontend/app/segments/page.tsx new file mode 100644 index 0000000..8df892d --- /dev/null +++ b/apps/frontend/app/segments/page.tsx @@ -0,0 +1,69 @@ +import { prisma } from "@streamclipper/db"; + +export const dynamic = "force-dynamic"; + +const STATUS_BADGE: Record = { + 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 ( + <> +

Segment & Aday Klip Kütüphanesi

+ {segments.length === 0 ? ( +

Henüz işlenmiş segment yok.

+ ) : ( + segments.map((segment) => ( +
+
+ {segment.filePath} + {segment.status} +
+
+ {segment.duration}sn · {new Date(segment.startedAt).toLocaleString("tr-TR")} +
+ + {segment.candidates.length === 0 ? ( +

+ Bu segmentte aday klip bulunamadı. +

+ ) : ( + segment.candidates.map((c) => ( +
+
+ + {c.startSec}s – {c.endSec}s + {c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`} + {c.chatVelocityScore != null && " · chat spike"} + + {c.status} +
+ {transcriptPreview(c.transcriptJson) && ( +

{transcriptPreview(c.transcriptJson)}

+ )} +
+ )) + )} +
+ )) + )} + + ); +} diff --git a/apps/frontend/next-env.d.ts b/apps/frontend/next-env.d.ts new file mode 100644 index 0000000..1b3be08 --- /dev/null +++ b/apps/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/frontend/next.config.js b/apps/frontend/next.config.js new file mode 100644 index 0000000..bd38071 --- /dev/null +++ b/apps/frontend/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ["@streamclipper/db"], +}; + +module.exports = nextConfig; diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 0000000..61afa21 --- /dev/null +++ b/apps/frontend/package.json @@ -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" + } +} diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json new file mode 100644 index 0000000..afedc74 --- /dev/null +++ b/apps/frontend/tsconfig.json @@ -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"] +} diff --git a/apps/worker/Dockerfile b/apps/worker/Dockerfile new file mode 100644 index 0000000..06bc407 --- /dev/null +++ b/apps/worker/Dockerfile @@ -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"] diff --git a/apps/worker/requirements.txt b/apps/worker/requirements.txt new file mode 100644 index 0000000..d4b20bd --- /dev/null +++ b/apps/worker/requirements.txt @@ -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 diff --git a/apps/worker/worker/__init__.py b/apps/worker/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/worker/worker/chat_capture.py b/apps/worker/worker/chat_capture.py new file mode 100644 index 0000000..38d37c1 --- /dev/null +++ b/apps/worker/worker/chat_capture.py @@ -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() diff --git a/apps/worker/worker/db.py b/apps/worker/worker/db.py new file mode 100644 index 0000000..41912a2 --- /dev/null +++ b/apps/worker/worker/db.py @@ -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") diff --git a/apps/worker/worker/main.py b/apps/worker/worker/main.py new file mode 100644 index 0000000..32e7e70 --- /dev/null +++ b/apps/worker/worker/main.py @@ -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()) diff --git a/apps/worker/worker/queues.py b/apps/worker/worker/queues.py new file mode 100644 index 0000000..35ef236 --- /dev/null +++ b/apps/worker/worker/queues.py @@ -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") diff --git a/apps/worker/worker/signal_detection.py b/apps/worker/worker/signal_detection.py new file mode 100644 index 0000000..03cf358 --- /dev/null +++ b/apps/worker/worker/signal_detection.py @@ -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}) diff --git a/apps/worker/worker/stt_scoring.py b/apps/worker/worker/stt_scoring.py new file mode 100644 index 0000000..db37392 --- /dev/null +++ b/apps/worker/worker/stt_scoring.py @@ -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}) diff --git a/apps/worker/worker/telegram.py b/apps/worker/worker/telegram.py new file mode 100644 index 0000000..fb29b7d --- /dev/null +++ b/apps/worker/worker/telegram.py @@ -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}") diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..0b1b953 --- /dev/null +++ b/infra/docker-compose.yml @@ -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: diff --git a/package.json b/package.json new file mode 100644 index 0000000..f3516ff --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..5161dd0 --- /dev/null +++ b/packages/db/package.json @@ -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" + } +} diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma new file mode 100644 index 0000000..1fc4a2c --- /dev/null +++ b/packages/db/prisma/schema.prisma @@ -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") +} diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts new file mode 100644 index 0000000..eb82acd --- /dev/null +++ b/packages/db/prisma/seed.ts @@ -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()); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..44aa2dd --- /dev/null +++ b/packages/db/src/index.ts @@ -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"; diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..98eab96 --- /dev/null +++ b/packages/db/tsconfig.json @@ -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"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..5114d6b --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2070 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + apps/api-daemon: + dependencies: + '@streamclipper/db': + specifier: workspace:* + version: link:../../packages/db + bullmq: + specifier: ^5.34.0 + version: 5.81.4 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.21.1 + version: 4.22.2 + ioredis: + specifier: ^5.4.1 + version: 5.11.1 + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/node': + specifier: ^22.10.0 + version: 22.20.1 + tsx: + specifier: ^4.19.2 + version: 4.23.12 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + + apps/frontend: + dependencies: + '@streamclipper/db': + specifier: workspace:* + version: link:../../packages/db + next: + specifier: ^15.1.0 + version: 15.5.24(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.0.0 + version: 19.2.8 + react-dom: + specifier: ^19.0.0 + version: 19.2.8(react@19.2.8) + devDependencies: + '@types/node': + specifier: ^22.10.0 + version: 22.20.1 + '@types/react': + specifier: ^19.0.0 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.5(@types/react@19.2.18) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + + packages/db: + dependencies: + '@prisma/client': + specifier: ^6.3.0 + version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + devDependencies: + '@types/node': + specifier: ^22.10.0 + version: 22.20.1 + prisma: + specifier: ^6.3.0 + version: 6.19.3(typescript@5.9.3) + tsx: + specifier: ^4.19.2 + version: 4.23.12 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + +packages: + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + + '@next/env@15.5.24': + resolution: {integrity: sha512-mBDF7T0XKZjs9SpUAl0buizVO+O02ULjOvWX8o/AZo/5AGw/UAS1Zzcylmd4pqbftzmKQi+L/nB4jgBYKEAl5Q==} + + '@next/swc-darwin-arm64@15.5.24': + resolution: {integrity: sha512-AGdNLvxZNY6eR2iSnV+6wUa8CiHTMr4F7g3uHH7fT4ICIJBE00R9u4tzN/Vuwsw0cOi8MTD2HJcTCb6siMH88Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.5.24': + resolution: {integrity: sha512-9HrQajBMmGcrrrvDfRimiCrbAPh3E6uHJmwBovYr6Yrmi9p9PZqI876BrXX280wICh3o2XwUlp4blkB0NNBqFg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.5.24': + resolution: {integrity: sha512-rl9LSfE75si0WT3cDgdUC1XYCKS+TgxC+/IjitmeycrAG18X/plIP1/vy8dd/HPycYcIvE688PD7FuvEAiEAew==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@15.5.24': + resolution: {integrity: sha512-TlNAnpsjxSF3aAUtqnfmtXXf8m9sIDBlmF3c7bTAlnshUYu2U0OxN2uf5d0gcFwqHVEdivJNBcCaqNOwPGNimw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@15.5.24': + resolution: {integrity: sha512-7dwtlhr0SLndqTG1z9ncRkbJswDZiKWlxzFyXDvJ2RDZRDRHp8zyMJ4D9UH/FgnQeXxxB6gZy2pMcIUoNKQ4pA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@15.5.24': + resolution: {integrity: sha512-kGZxM+WhkYs0276lFrMkj7PRtXT3Btp6cwvfSO/cCVLxJJttB5Ccnl2niaCgUja8HgSbEVnMHpg3FJWoOJ9e/g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@15.5.24': + resolution: {integrity: sha512-jBDDkZ/qKAqkWivWDMkJSXUzbzV0QKRBKJjEHUAvSB97Hzw7NLzJ6yV56Lts/wjir7s4P31GYgpbS6ZL+hasAA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.5.24': + resolution: {integrity: sha512-JqtwjvvorjacQ0spgjmUJoxySoYgPwdT1sFdQ0/zmW4iMlP2hjYlCoJIyS7o6Epb4Fug8eco3HXFoBDUCDeH7Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@prisma/client@6.19.3': + resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.3': + resolution: {integrity: sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==} + + '@prisma/debug@6.19.3': + resolution: {integrity: sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines@6.19.3': + resolution: {integrity: sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==} + + '@prisma/fetch-engine@6.19.3': + resolution: {integrity: sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==} + + '@prisma/get-platform@6.19.3': + resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/express-serve-static-core@4.19.9': + resolution: {integrity: sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/react-dom@19.2.5': + resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + body-parser@1.20.6: + resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bullmq@5.81.4: + resolution: {integrity: sha512-n+WHSzz20KooBGoJyASre6oJNz/p5f1IJRRN2ibD+NWPQaNpNQmDrwYuPO+bsXrQeM1MQzUxXGbdjmyOFKP2xQ==} + engines: {node: '>=12.22.0'} + peerDependencies: + redis: '>=5.0.0' + peerDependenciesMeta: + redis: + optional: true + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + deprecated: v4 is no longer maintained, upgrade to v5 + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + effect@3.21.0: + resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + next@15.5.24: + resolution: {integrity: sha512-Y+xn8EQCoC3ZbsFPyzE+tE8XOdrWeUdUF7NeXbmg9DsgAxl5UYxlsrvgVESHTyTGigoTa1bCUrxn70F5bqt0Gw==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + nypm@0.6.9: + resolution: {integrity: sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==} + engines: {node: '>=18'} + hasBin: true + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + prisma@6.19.3: + resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + +snapshots: + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true + + '@ioredis/commands@1.10.0': {} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + + '@next/env@15.5.24': {} + + '@next/swc-darwin-arm64@15.5.24': + optional: true + + '@next/swc-darwin-x64@15.5.24': + optional: true + + '@next/swc-linux-arm64-gnu@15.5.24': + optional: true + + '@next/swc-linux-arm64-musl@15.5.24': + optional: true + + '@next/swc-linux-x64-gnu@15.5.24': + optional: true + + '@next/swc-linux-x64-musl@15.5.24': + optional: true + + '@next/swc-win32-arm64-msvc@15.5.24': + optional: true + + '@next/swc-win32-x64-msvc@15.5.24': + optional: true + + '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.3(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@6.19.3': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.21.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.3': {} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.3 + '@prisma/get-platform': 6.19.3 + + '@prisma/fetch-engine@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.3 + + '@prisma/get-platform@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + + '@standard-schema/spec@1.1.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.20.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.20.1 + + '@types/express-serve-static-core@4.19.9': + dependencies: + '@types/node': 22.20.1 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.9 + '@types/qs': 6.15.1 + '@types/serve-static': 1.15.10 + + '@types/http-errors@2.0.5': {} + + '@types/mime@1.3.5': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/react-dom@19.2.5(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 22.20.1 + + '@types/send@1.2.1': + dependencies: + '@types/node': 22.20.1 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.20.1 + '@types/send': 0.17.6 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + array-flatten@1.1.1: {} + + body-parser@1.20.6: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bullmq@5.81.4: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.11.1 + msgpackr: 2.0.5 + node-abort-controller: 3.1.1 + semver: 7.8.5 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 16.6.1 + exsolve: 1.1.1 + giget: 2.0.0 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.1 + rc9: 2.1.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001810: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.2: {} + + client-only@0.0.1: {} + + cluster-key-slot@1.1.1: {} + + confbox@0.2.4: {} + + consola@3.4.2: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + + csstype@3.2.3: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deepmerge-ts@7.1.5: {} + + defu@6.1.7: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + destr@2.0.5: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: + optional: true + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + effect@3.21.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + empathic@2.0.0: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.6 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + exsolve@1.1.1: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.7 + node-fetch-native: 1.6.7 + nypm: 0.6.9 + pathe: 2.0.3 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + ipaddr.js@1.9.1: {} + + jiti@2.7.0: {} + + luxon@3.7.2: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.5: + optionalDependencies: + msgpackr-extract: 3.0.4 + + nanoid@3.3.18: {} + + negotiator@0.6.3: {} + + next@15.5.24(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@next/env': 15.5.24 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001810 + postcss: 8.4.31 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + styled-jsx: 5.1.6(react@19.2.8) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.24 + '@next/swc-darwin-x64': 15.5.24 + '@next/swc-linux-arm64-gnu': 15.5.24 + '@next/swc-linux-arm64-musl': 15.5.24 + '@next/swc-linux-x64-gnu': 15.5.24 + '@next/swc-linux-x64-musl': 15.5.24 + '@next/swc-win32-arm64-msvc': 15.5.24 + '@next/swc-win32-x64-msvc': 15.5.24 + sharp: 0.35.4(@types/node@22.20.1) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + + node-abort-controller@3.1.1: {} + + node-fetch-native@1.6.7: {} + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + nypm@0.6.9: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.3.0 + + object-inspect@1.13.4: {} + + ohash@2.0.12: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + parseurl@1.3.3: {} + + path-to-regexp@0.1.13: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + + postcss@8.4.31: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prisma@6.19.3(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.3 + '@prisma/engines': 6.19.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pure-rand@6.1.0: {} + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + rc9@2.1.2: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react@19.2.8: {} + + readdirp@4.1.2: {} + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@7.8.5: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + sharp@0.35.4(@types/node@22.20.1): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 22.20.1 + optional: true + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + standard-as-callback@2.1.0: {} + + statuses@2.0.2: {} + + styled-jsx@5.1.6(react@19.2.8): + dependencies: + client-only: 0.0.1 + react: 19.2.8 + + tinyexec@1.3.0: {} + + toidentifier@1.0.1: {} + + tslib@2.8.1: {} + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + utils-merge@1.0.1: {} + + vary@1.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..462482a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +packages: + - "apps/*" + - "packages/*" +allowBuilds: + '@prisma/client': true + '@prisma/engines': true + esbuild: true + msgpackr-extract: true + prisma: true diff --git a/prd.md b/prd.md new file mode 100644 index 0000000..6dbf588 --- /dev/null +++ b/prd.md @@ -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. + +``` \ No newline at end of file