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>
110 lines
4.2 KiB
TypeScript
110 lines
4.2 KiB
TypeScript
import express from "express";
|
|
import { prisma } from "@streamclipper/db";
|
|
import { env } from "./env";
|
|
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 }));
|
|
|
|
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,
|
|
lastPollError: lastPollErrors.get(c.channelId) ?? null,
|
|
})),
|
|
});
|
|
});
|
|
|
|
app.get("/debug/capture/:sessionId", (req, res) => {
|
|
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}`);
|
|
});
|
|
}
|