feat: interaktif panel kontrolleri (Faz 3)

Yayın durdurma, kanal duraklat/devam (tekli+toplu), şimdi kontrol et,
manuel URL ile zorla kayıt başlatma, canlı player+log önizleme, oturum
süre sayacı, başarısız/takılı render için tekrar dene ve iptal, poll
aralığı + ham segment TTL'yi panelden canlı değiştirme, cookie bayatlık
uyarısı, Telegram bildirim aç/kapa, ve kullanım/maliyet özeti (/stats).

api-daemon'ın yeni state-değiştiren endpoint'leri (stop/force-start/
render/cancel) INTERNAL_API_TOKEN ile korunuyor — bu daemon'ın portu
Coolify'de public'e açık olduğu için korumasız bırakmak güvenlik açığı
olurdu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 13:38:47 +03:00
co-authored by Claude Sonnet 5
parent 2fc3254d64
commit 43ccbd9ec7
23 changed files with 742 additions and 75 deletions
+68 -2
View File
@@ -1,11 +1,13 @@
import express from "express";
import { prisma } from "@streamclipper/db";
import { env } from "./env";
import { lastPollErrors } from "./youtubePolling";
import { lastCaptureDebug } from "./capture/streamIngest";
import { lastPollErrors, checkChannel, startCaptureSession } from "./youtubePolling";
import { lastCaptureDebug, stopCapture } from "./capture/streamIngest";
import { videoRenderQueue } from "./queues";
export function startServer() {
const app = express();
app.use(express.json());
app.get("/health", (_req, res) => res.json({ ok: true }));
@@ -37,6 +39,70 @@ export function startServer() {
res.json({ lines: lastCaptureDebug.get(req.params.sessionId) ?? [] });
});
/**
* These endpoints change state (stop a capture, start one against an
* arbitrary URL, re-run a render) — unlike /health and /status they must
* not be reachable by anyone who finds this daemon's public sslip.io URL.
* If INTERNAL_API_TOKEN isn't set (local dev), they stay open — matches
* SESSION_SECRET's existing "insecure default, fine for local" pattern.
*/
function requireInternalToken(req: express.Request, res: express.Response, next: express.NextFunction) {
if (!env.internalApiToken) return next();
const header = req.header("authorization") ?? "";
if (header === `Bearer ${env.internalApiToken}`) return next();
res.status(401).json({ error: "unauthorized" });
}
app.post("/sessions/:sessionId/stop", requireInternalToken, (req, res) => {
const stopped = stopCapture(req.params.sessionId);
res.json({ stopped });
});
app.post("/channels/:channelId/check-now", requireInternalToken, async (req, res) => {
const channel = await prisma.channel.findUnique({ where: { id: req.params.channelId } });
if (!channel) return res.status(404).json({ error: "channel not found" });
const result = await checkChannel(channel);
res.json(result);
});
app.post("/channels/:channelId/force-start", requireInternalToken, async (req, res) => {
const channel = await prisma.channel.findUnique({ where: { id: req.params.channelId } });
if (!channel) return res.status(404).json({ error: "channel not found" });
const youtubeUrl = String(req.body?.url ?? "").trim();
if (!youtubeUrl) return res.status(400).json({ error: "url is required" });
const result = await startCaptureSession(channel, youtubeUrl);
res.json(result);
});
app.post("/shorts/:candidateId/render", requireInternalToken, async (req, res) => {
const candidateId = req.params.candidateId;
const candidate = await prisma.candidateSegment.findUnique({ where: { id: candidateId } });
if (!candidate) return res.status(404).json({ error: "candidate not found" });
await prisma.shortVideo.upsert({
where: { candidateId },
create: { candidateId, status: "PENDING" },
update: { status: "PENDING", errorMessage: null },
});
await videoRenderQueue.add("render-short", { candidateSegmentId: candidateId });
res.json({ queued: true });
});
app.post("/shorts/:candidateId/cancel", requireInternalToken, async (req, res) => {
const candidateId = req.params.candidateId;
const short = await prisma.shortVideo.findUnique({ where: { candidateId } });
if (!short) return res.status(404).json({ error: "short not found" });
// Doesn't kill the in-flight Python render process — just clears the
// stuck DB state so the panel can offer a retry instead of showing
// "RENDERING" forever if a worker died mid-job.
await prisma.shortVideo.update({
where: { candidateId },
data: { status: "FAILED", errorMessage: "Manuel olarak iptal edildi." },
});
res.json({ cancelled: true });
});
app.listen(env.port, () => {
console.log(`[server] api-daemon listening on :${env.port}`);
});