feat: panelden video izleme/indirme/silme + eşzamanlı yayın kapasitesi düzeltmesi

- streamIngest worker concurrency 3'ten sabit değildi, artık
  MAX_CONCURRENT_CAPTURES (varsayılan 10) ile ayarlanabiliyor. Eskiden
  3'ten fazla kanal aynı anda canlıya geçerse fazlası, ilk 3'ten biri
  bitene kadar (saatlerce) hiç kayda alınmıyordu.
- Python worker'larda (signal-detection, stt-scoring) bullmq varsayılan
  concurrency'si 1'di — WORKER_CONCURRENCY (varsayılan 5) ile
  paralelleştirildi, birden fazla kanal aynı anda segment/transkript
  üretirse sıraya takılmasın diye.
- Frontend artık shared-media volume'üne bağlı: /api/segments/[id]/video
  route'u Range destekli video stream/indirme sağlıyor. Segment ve
  kanal detay sayfalarına <video> oynatıcı, indirme linki ve onaylı
  silme (RawSegment + CandidateSegment, dosya dahil) eklendi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 18:33:33 +03:00
co-authored by Claude Sonnet 5
parent fd21bbfb14
commit 8a92b9b019
11 changed files with 148 additions and 5 deletions
+9 -1
View File
@@ -172,7 +172,15 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
export function startStreamIngestWorker(): Worker<StreamIngestJob> {
return new Worker<StreamIngestJob>(QUEUE_NAMES.STREAM_INGEST, runCapture, {
connection: redisConnection,
concurrency: 3,
// Each capture job runs for the entire duration of a live stream
// (potentially hours) before its BullMQ job resolves. If concurrency is
// lower than the number of channels going live at once, the extra
// channels queue behind already-running captures and may not start
// recording until one of those finishes — i.e. effectively never, for
// streams still live. This must cover the realistic simultaneous-channel
// count; the real ceiling is the server's CPU/bandwidth for running that
// many yt-dlp+ffmpeg pairs at once, not this number.
concurrency: env.maxConcurrentCaptures,
// 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,
+1
View File
@@ -9,4 +9,5 @@ export const env = {
forceLiveUrl: process.env.FORCE_LIVE_URL ?? "",
ytdlpCookiesB64: process.env.YTDLP_COOKIES_B64 ?? "",
ytdlpPotProviderUrl: process.env.YTDLP_POT_PROVIDER_URL ?? "",
maxConcurrentCaptures: Number(process.env.MAX_CONCURRENT_CAPTURES ?? 10),
};
+20
View File
@@ -1,5 +1,6 @@
"use server";
import { unlink } from "node:fs/promises";
import { prisma } from "@streamclipper/db";
import { revalidatePath } from "next/cache";
@@ -18,3 +19,22 @@ export async function addChannel(formData: FormData) {
revalidatePath("/");
}
export async function deleteRawSegment(segmentId: string) {
const segment = await prisma.rawSegment.findUnique({ where: { id: segmentId } });
if (!segment) return;
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: segmentId } });
await prisma.rawSegment.delete({ where: { id: segmentId } });
await unlink(segment.filePath).catch(() => {});
revalidatePath("/segments");
revalidatePath("/", "layout");
}
export async function deleteCandidateSegment(candidateId: string) {
await prisma.candidateSegment.delete({ where: { id: candidateId } });
revalidatePath("/segments");
revalidatePath("/", "layout");
}
@@ -0,0 +1,48 @@
import { createReadStream, statSync } from "node:fs";
import { Readable } from "node:stream";
import type { NextRequest } from "next/server";
import { prisma } from "@streamclipper/db";
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const segment = await prisma.rawSegment.findUnique({ where: { id } });
if (!segment) return new Response("Not found", { status: 404 });
let size: number;
try {
size = statSync(segment.filePath).size;
} catch {
return new Response("File not found on disk", { status: 404 });
}
const headers: Record<string, string> = {
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
};
if (req.nextUrl.searchParams.get("download") === "1") {
headers["Content-Disposition"] = `attachment; filename="${id}.mp4"`;
}
const range = req.headers.get("range");
const match = range ? /bytes=(\d+)-(\d*)/.exec(range) : null;
if (match) {
const start = Number(match[1]);
const end = match[2] ? Number(match[2]) : size - 1;
const stream = createReadStream(segment.filePath, { start, end });
return new Response(Readable.toWeb(stream) as ReadableStream, {
status: 206,
headers: {
...headers,
"Content-Range": `bytes ${start}-${end}/${size}`,
"Content-Length": String(end - start + 1),
},
});
}
const stream = createReadStream(segment.filePath);
return new Response(Readable.toWeb(stream) as ReadableStream, {
headers: { ...headers, "Content-Length": String(size) },
});
}
+16
View File
@@ -2,6 +2,8 @@ import { notFound } from "next/navigation";
import Link from "next/link";
import { prisma } from "@streamclipper/db";
import { fetchChannelStatuses } from "../../../lib/apiDaemon";
import { deleteRawSegment, deleteCandidateSegment } from "../../actions";
import { DeleteButton } from "../../components/DeleteButton";
export const dynamic = "force-dynamic";
@@ -102,6 +104,17 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span>
</div>
<video controls preload="metadata" style={{ width: "100%", maxWidth: "480px", marginTop: "0.5rem", borderRadius: "6px" }}>
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.5rem" }}>
<a className="badge muted" href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
<form action={deleteRawSegment.bind(null, segment.id)}>
<DeleteButton confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz." />
</form>
</div>
{segment.candidates.map((c) => (
<div key={c.id} style={{ marginTop: "0.5rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}>
<div className="card-head">
@@ -114,6 +127,9 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
{transcriptPreview(c.transcriptJson) && (
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p>
)}
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}>
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" />
</form>
</div>
))}
</div>
@@ -0,0 +1,16 @@
"use client";
export function DeleteButton({ confirmText, label = "Sil" }: { confirmText: string; label?: string }) {
return (
<button
type="submit"
className="badge err"
style={{ border: "none", cursor: "pointer" }}
onClick={(e) => {
if (!confirm(confirmText)) e.preventDefault();
}}
>
{label}
</button>
);
}
+16
View File
@@ -1,4 +1,6 @@
import { prisma } from "@streamclipper/db";
import { deleteRawSegment, deleteCandidateSegment } from "../actions";
import { DeleteButton } from "../components/DeleteButton";
export const dynamic = "force-dynamic";
@@ -40,6 +42,17 @@ export default async function SegmentsPage() {
{segment.duration}sn · {new Date(segment.startedAt).toLocaleString("tr-TR")}
</div>
<video controls preload="metadata" style={{ width: "100%", maxWidth: "480px", marginTop: "0.5rem", borderRadius: "6px" }}>
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.5rem" }}>
<a className="badge muted" href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
<form action={deleteRawSegment.bind(null, segment.id)}>
<DeleteButton confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz." />
</form>
</div>
{segment.candidates.length === 0 ? (
<p className="empty" style={{ marginTop: "0.5rem" }}>
Bu segmentte aday klip bulunamadı.
@@ -58,6 +71,9 @@ export default async function SegmentsPage() {
{transcriptPreview(c.transcriptJson) && (
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p>
)}
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}>
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" />
</form>
</div>
))
)}
+5
View File
@@ -16,3 +16,8 @@ QUEUE_NAMES = {
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379")
SHARED_MEDIA_ROOT = os.environ.get("SHARED_MEDIA_ROOT", "./shared-media")
# bullmq-python defaults Worker concurrency to 1, so several channels
# producing segments/candidates around the same time would otherwise process
# strictly one at a time and build up a backlog.
WORKER_CONCURRENCY = int(os.environ.get("WORKER_CONCURRENCY", "5"))
+6 -2
View File
@@ -11,7 +11,7 @@ import soundfile as sf
from bullmq import Queue, Worker
from . import db
from .queues import QUEUE_NAMES, REDIS_URL, SHARED_MEDIA_ROOT
from .queues import QUEUE_NAMES, REDIS_URL, SHARED_MEDIA_ROOT, WORKER_CONCURRENCY
WINDOW_SEC = 1.0
PEAK_STD_MULTIPLIER = 1.5
@@ -161,4 +161,8 @@ async def process_signal_detection(job, job_token=None):
def start_signal_detection_worker() -> Worker:
return Worker(QUEUE_NAMES["SIGNAL_DETECTION"], process_signal_detection, {"connection": REDIS_URL})
return Worker(
QUEUE_NAMES["SIGNAL_DETECTION"],
process_signal_detection,
{"connection": REDIS_URL, "concurrency": WORKER_CONCURRENCY},
)
+6 -2
View File
@@ -7,7 +7,7 @@ from bullmq import Worker
from openai import OpenAI
from . import db
from .queues import QUEUE_NAMES, REDIS_URL
from .queues import QUEUE_NAMES, REDIS_URL, WORKER_CONCURRENCY
from .telegram import send_telegram_message
_openai_client: OpenAI | None = None
@@ -71,4 +71,8 @@ async def process_stt_scoring(job, job_token=None):
def start_stt_scoring_worker() -> Worker:
return Worker(QUEUE_NAMES["STT_SCORING"], process_stt_scoring, {"connection": REDIS_URL})
return Worker(
QUEUE_NAMES["STT_SCORING"],
process_stt_scoring,
{"connection": REDIS_URL, "concurrency": WORKER_CONCURRENCY},
)