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}`); }); }