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
+6
View File
@@ -55,3 +55,9 @@ RAW_SEGMENT_TTL_HOURS=24
# işinden çok daha ağır — düşük tutulur ki aynı anda birden fazla render # işinden çok daha ağır — düşük tutulur ki aynı anda birden fazla render
# worker container'ını boğmasın. # worker container'ını boğmasın.
VIDEO_RENDER_CONCURRENCY=2 VIDEO_RENDER_CONCURRENCY=2
# api-daemon'ın durdur/zorla-başlat/render-tetikle gibi durum-değiştiren
# endpoint'lerini korur (bu daemon'ın portu Coolify'de public'e açık).
# frontend ve api-daemon aynı değeri kullanmalı. Generate: openssl rand -hex 24
# Boş bırakılırsa (sadece local dev için) bu endpoint'ler korumasız kalır.
INTERNAL_API_TOKEN=
+21 -1
View File
@@ -1,4 +1,4 @@
import { spawn } from "node:child_process"; import { spawn, type ChildProcess } from "node:child_process";
import { mkdir, readFile } from "node:fs/promises"; import { mkdir, readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { Worker, type Job } from "bullmq"; import { Worker, type Job } from "bullmq";
@@ -17,6 +17,24 @@ const SEGMENT_LIST_POLL_MS = 5_000;
*/ */
export const lastCaptureDebug = new Map<string, string[]>(); export const lastCaptureDebug = new Map<string, string[]>();
/**
* Live yt-dlp processes, keyed by sessionId — lets a panel "stop" action
* reach a specific in-progress capture. Only yt-dlp is tracked/killed:
* ffmpeg is downstream of it in the pipe, so ending yt-dlp closes its stdin
* with EOF and ffmpeg finishes the same way it does on a natural stream end
* (flushes the last partial segment, exits, existing finalize code runs
* unchanged).
*/
const activeCaptures = new Map<string, ChildProcess>();
/** Returns false if the session has no tracked live process (already ended, or this daemon didn't start it — e.g. after a restart). */
export function stopCapture(sessionId: string): boolean {
const ytdlp = activeCaptures.get(sessionId);
if (!ytdlp) return false;
ytdlp.kill("SIGTERM");
return true;
}
function appendCaptureDebug(sessionId: string, line: string) { function appendCaptureDebug(sessionId: string, line: string) {
const lines = lastCaptureDebug.get(sessionId) ?? []; const lines = lastCaptureDebug.get(sessionId) ?? [];
lines.push(line); lines.push(line);
@@ -95,6 +113,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
], ],
{ stdio: ["ignore", "pipe", "pipe"] }, { stdio: ["ignore", "pipe", "pipe"] },
); );
activeCaptures.set(sessionId, ytdlp);
const ffmpeg = spawn( const ffmpeg = spawn(
"ffmpeg", "ffmpeg",
@@ -153,6 +172,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
}); });
clearInterval(pollTimer); clearInterval(pollTimer);
activeCaptures.delete(sessionId);
// final sweep in case segments closed between the last poll and process exit // final sweep in case segments closed between the last poll and process exit
const raw = await readFile(segmentListPath, "utf8").catch(() => ""); const raw = await readFile(segmentListPath, "utf8").catch(() => "");
+11 -3
View File
@@ -3,6 +3,13 @@ import { prisma } from "@streamclipper/db";
import { env } from "./env"; import { env } from "./env";
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // hourly const CHECK_INTERVAL_MS = 60 * 60 * 1000; // hourly
const TTL_SETTING_KEY = "raw_segment_ttl_hours";
async function getTtlHours(): Promise<number> {
const setting = await prisma.appSetting.findUnique({ where: { key: TTL_SETTING_KEY } });
const parsed = setting ? Number(setting.value) : NaN;
return Number.isFinite(parsed) && parsed > 0 ? parsed : env.rawSegmentTtlHours;
}
/** /**
* PRD's auto-purge lifecycle: processed raw recordings get deleted 24h after * PRD's auto-purge lifecycle: processed raw recordings get deleted 24h after
@@ -11,7 +18,8 @@ const CHECK_INTERVAL_MS = 60 * 60 * 1000; // hourly
* file mid-analysis or mid-transcription would break the pipeline for it. * file mid-analysis or mid-transcription would break the pipeline for it.
*/ */
async function cleanupOnce(): Promise<void> { async function cleanupOnce(): Promise<void> {
const cutoff = new Date(Date.now() - env.rawSegmentTtlHours * 60 * 60 * 1000); const ttlHours = await getTtlHours();
const cutoff = new Date(Date.now() - ttlHours * 60 * 60 * 1000);
const staleSegments = await prisma.rawSegment.findMany({ const staleSegments = await prisma.rawSegment.findMany({
where: { where: {
@@ -28,12 +36,12 @@ async function cleanupOnce(): Promise<void> {
} }
if (staleSegments.length > 0) { if (staleSegments.length > 0) {
console.log(`[cleanup] removed ${staleSegments.length} raw segment(s) older than ${env.rawSegmentTtlHours}h`); console.log(`[cleanup] removed ${staleSegments.length} raw segment(s) older than ${ttlHours}h`);
} }
} }
export function startCleanupLoop(): NodeJS.Timeout { export function startCleanupLoop(): NodeJS.Timeout {
console.log(`[cleanup] checking every ${CHECK_INTERVAL_MS}ms, TTL ${env.rawSegmentTtlHours}h`); console.log(`[cleanup] checking every ${CHECK_INTERVAL_MS}ms, default TTL ${env.rawSegmentTtlHours}h`);
cleanupOnce().catch((err) => console.error("[cleanup] initial run failed:", err)); cleanupOnce().catch((err) => console.error("[cleanup] initial run failed:", err));
return setInterval(() => { return setInterval(() => {
cleanupOnce().catch((err) => console.error("[cleanup] run failed:", err)); cleanupOnce().catch((err) => console.error("[cleanup] run failed:", err));
+1
View File
@@ -11,4 +11,5 @@ export const env = {
proxyUrl: process.env.PROXY_URL ?? "", proxyUrl: process.env.PROXY_URL ?? "",
maxConcurrentCaptures: Number(process.env.MAX_CONCURRENT_CAPTURES ?? 10), maxConcurrentCaptures: Number(process.env.MAX_CONCURRENT_CAPTURES ?? 10),
rawSegmentTtlHours: Number(process.env.RAW_SEGMENT_TTL_HOURS ?? 24), rawSegmentTtlHours: Number(process.env.RAW_SEGMENT_TTL_HOURS ?? 24),
internalApiToken: process.env.INTERNAL_API_TOKEN ?? "",
}; };
+10
View File
@@ -34,3 +34,13 @@ export const streamIngestQueue = new Queue<StreamIngestJob>(QUEUE_NAMES.STREAM_I
export const signalDetectionQueue = new Queue<SignalDetectionJob>(QUEUE_NAMES.SIGNAL_DETECTION, { export const signalDetectionQueue = new Queue<SignalDetectionJob>(QUEUE_NAMES.SIGNAL_DETECTION, {
connection: redisConnection, connection: redisConnection,
}); });
export interface VideoRenderJob {
candidateSegmentId: string;
}
// Consumed by the Python worker (apps/worker/worker/video_render.py) — job
// name/payload shape must match what stt_scoring.py already enqueues.
export const videoRenderQueue = new Queue<VideoRenderJob>(QUEUE_NAMES.VIDEO_RENDER, {
connection: redisConnection,
});
+68 -2
View File
@@ -1,11 +1,13 @@
import express from "express"; import express from "express";
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { env } from "./env"; import { env } from "./env";
import { lastPollErrors } from "./youtubePolling"; import { lastPollErrors, checkChannel, startCaptureSession } from "./youtubePolling";
import { lastCaptureDebug } from "./capture/streamIngest"; import { lastCaptureDebug, stopCapture } from "./capture/streamIngest";
import { videoRenderQueue } from "./queues";
export function startServer() { export function startServer() {
const app = express(); const app = express();
app.use(express.json());
app.get("/health", (_req, res) => res.json({ ok: true })); app.get("/health", (_req, res) => res.json({ ok: true }));
@@ -37,6 +39,70 @@ export function startServer() {
res.json({ lines: lastCaptureDebug.get(req.params.sessionId) ?? [] }); 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, () => { app.listen(env.port, () => {
console.log(`[server] api-daemon listening on :${env.port}`); console.log(`[server] api-daemon listening on :${env.port}`);
}); });
+62 -15
View File
@@ -69,11 +69,22 @@ async function findLiveVideoId(channelId: string): Promise<string | null> {
} }
} }
async function startSession(channel: { id: string; name: string }, liveVideoId: string) { /**
* Shared by the automatic poll loop and the panel's manual "force-start"
* action — both just need a channel + a URL to begin capturing. A no-op if
* that channel already has an open session (covers both callers: the poll
* loop re-checking a channel it's already recording, and someone hitting
* force-start on a channel that's already live).
*/
export async function startCaptureSession(
channel: { id: string; name: string },
youtubeUrl: string,
liveVideoId: string | null = null,
): Promise<{ started: boolean }> {
const existing = await prisma.streamSession.findFirst({ const existing = await prisma.streamSession.findFirst({
where: { channelId: channel.id, endedAt: null }, where: { channelId: channel.id, endedAt: null },
}); });
if (existing) return; if (existing) return { started: false };
const session = await prisma.streamSession.create({ const session = await prisma.streamSession.create({
data: { channelId: channel.id, liveVideoId }, data: { channelId: channel.id, liveVideoId },
@@ -82,18 +93,22 @@ async function startSession(channel: { id: string; name: string }, liveVideoId:
await streamIngestQueue.add("start-capture", { await streamIngestQueue.add("start-capture", {
sessionId: session.id, sessionId: session.id,
channelDbId: channel.id, channelDbId: channel.id,
youtubeUrl: `https://www.youtube.com/watch?v=${liveVideoId}`, youtubeUrl,
}); });
const notifySetting = await prisma.appSetting.findUnique({ where: { key: "notify_stream_start" } });
if (notifySetting?.value !== "false") {
await sendTelegramMessage(`🔴 *${channel.name}* canlıya geçti, kayıt başlatılıyor.`); 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}`); console.log(`[youtube-polling] started session ${session.id} for channel ${channel.name}`);
return { started: true };
} }
export async function pollOnce(): Promise<void> { /** Runs the live-check for a single channel and applies its result — the unit both `pollOnce` and the panel's manual "şimdi kontrol et" action reuse. */
const channels = await prisma.channel.findMany({ where: { isActive: true } }); export async function checkChannel(channel: { id: string; name: string; channelId: string }): Promise<{
liveVideoId: string | null;
for (const channel of channels) { error: string | null;
try { }> {
const liveVideoId = await findLiveVideoId(channel.channelId); const liveVideoId = await findLiveVideoId(channel.channelId);
await prisma.channel.update({ await prisma.channel.update({
where: { id: channel.id }, where: { id: channel.id },
@@ -101,18 +116,50 @@ export async function pollOnce(): Promise<void> {
}); });
if (liveVideoId) { if (liveVideoId) {
await startSession(channel, liveVideoId); await startCaptureSession(channel, `https://www.youtube.com/watch?v=${liveVideoId}`, liveVideoId);
} }
return { liveVideoId, error: lastPollErrors.get(channel.channelId) ?? null };
}
export async function pollOnce(): Promise<void> {
const channels = await prisma.channel.findMany({ where: { isActive: true } });
for (const channel of channels) {
try {
await checkChannel(channel);
} catch (err) { } catch (err) {
console.error(`[youtube-polling] error polling channel ${channel.name}:`, err); console.error(`[youtube-polling] error polling channel ${channel.name}:`, err);
} }
} }
} }
export function startPollingLoop(): NodeJS.Timeout { const POLL_INTERVAL_SETTING_KEY = "poll_interval_ms";
console.log(`[youtube-polling] polling every ${env.pollIntervalMs}ms`);
pollOnce().catch((err) => console.error("[youtube-polling] initial poll failed:", err)); async function getPollIntervalMs(): Promise<number> {
return setInterval(() => { const setting = await prisma.appSetting.findUnique({ where: { key: POLL_INTERVAL_SETTING_KEY } });
pollOnce().catch((err) => console.error("[youtube-polling] poll failed:", err)); const parsed = setting ? Number(setting.value) : NaN;
}, env.pollIntervalMs); return Number.isFinite(parsed) && parsed >= 5_000 ? parsed : env.pollIntervalMs;
}
/**
* Self-rescheduling instead of setInterval so a panel-side change to the
* poll_interval_ms AppSetting (read fresh on every iteration, same pattern
* as ytdlpAntiBotArgs()'s cookies) takes effect on the very next cycle
* without a redeploy.
*/
export function startPollingLoop(): void {
let stopped = false;
async function tick() {
if (stopped) return;
await pollOnce().catch((err) => console.error("[youtube-polling] poll failed:", err));
const intervalMs = await getPollIntervalMs().catch(() => env.pollIntervalMs);
if (!stopped) setTimeout(tick, intervalMs);
}
getPollIntervalMs()
.then((ms) => console.log(`[youtube-polling] polling every ${ms}ms`))
.catch(() => {});
tick();
} }
+80
View File
@@ -4,6 +4,7 @@ import { unlink } from "node:fs/promises";
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { postToApiDaemon } from "../lib/apiDaemon";
export async function addChannel(formData: FormData) { export async function addChannel(formData: FormData) {
const name = String(formData.get("name") ?? "").trim(); const name = String(formData.get("name") ?? "").trim();
@@ -59,6 +60,85 @@ export async function deleteChannel(channelId: string) {
redirect("/"); redirect("/");
} }
export async function toggleChannelActive(channelId: string, nextActive: boolean) {
await prisma.channel.update({ where: { id: channelId }, data: { isActive: nextActive } });
revalidatePath("/", "layout");
}
export async function toggleAllChannels(nextActive: boolean) {
await prisma.channel.updateMany({ data: { isActive: nextActive } });
revalidatePath("/", "layout");
}
export async function stopSession(sessionId: string) {
await postToApiDaemon(`/sessions/${sessionId}/stop`);
revalidatePath("/", "layout");
}
export async function checkChannelNow(channelId: string) {
await postToApiDaemon(`/channels/${channelId}/check-now`);
revalidatePath("/", "layout");
}
export async function forceStartCapture(channelId: string, formData: FormData) {
const url = String(formData.get("url") ?? "").trim();
if (!url) throw new Error("YouTube URL zorunlu.");
await postToApiDaemon(`/channels/${channelId}/force-start`, { url });
revalidatePath("/", "layout");
}
export async function retryRender(candidateId: string) {
await postToApiDaemon(`/shorts/${candidateId}/render`);
revalidatePath("/segments");
revalidatePath("/", "layout");
}
export async function cancelRender(candidateId: string) {
await postToApiDaemon(`/shorts/${candidateId}/cancel`);
revalidatePath("/segments");
revalidatePath("/", "layout");
}
export async function updateSystemSettings(formData: FormData) {
const pollSeconds = Number(formData.get("pollIntervalSec"));
const ttlHours = Number(formData.get("ttlHours"));
if (Number.isFinite(pollSeconds) && pollSeconds >= 5) {
await prisma.appSetting.upsert({
where: { key: "poll_interval_ms" },
create: { key: "poll_interval_ms", value: String(pollSeconds * 1000) },
update: { value: String(pollSeconds * 1000) },
});
}
if (Number.isFinite(ttlHours) && ttlHours > 0) {
await prisma.appSetting.upsert({
where: { key: "raw_segment_ttl_hours" },
create: { key: "raw_segment_ttl_hours", value: String(ttlHours) },
update: { value: String(ttlHours) },
});
}
revalidatePath("/settings");
}
export async function updateNotificationPrefs(formData: FormData) {
const streamStart = formData.get("notifyStreamStart") === "on";
const renderDone = formData.get("notifyRenderDone") === "on";
await prisma.appSetting.upsert({
where: { key: "notify_stream_start" },
create: { key: "notify_stream_start", value: String(streamStart) },
update: { value: String(streamStart) },
});
await prisma.appSetting.upsert({
where: { key: "notify_render_done" },
create: { key: "notify_render_done", value: String(renderDone) },
update: { value: String(renderDone) },
});
revalidatePath("/settings");
}
export async function updateYtdlpCookies(formData: FormData) { export async function updateYtdlpCookies(formData: FormData) {
const value = String(formData.get("cookies") ?? "").trim(); const value = String(formData.get("cookies") ?? "").trim();
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { API_DAEMON_URL } from "../../../../../lib/apiDaemon";
/** Client components can't reach api-daemon's internal Docker hostname directly — this proxies the (auth-free, read-only) debug endpoint through the frontend's own origin. */
export async function GET(_req: Request, { params }: { params: Promise<{ sessionId: string }> }) {
const { sessionId } = await params;
const res = await fetch(`${API_DAEMON_URL}/debug/capture/${sessionId}`, { cache: "no-store" });
if (!res.ok) return NextResponse.json({ lines: [] }, { status: res.status });
const data = await res.json();
return NextResponse.json(data);
}
+70 -1
View File
@@ -2,8 +2,19 @@ import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { fetchChannelStatuses } from "../../../lib/apiDaemon"; import { fetchChannelStatuses } from "../../../lib/apiDaemon";
import { deleteRawSegment, deleteCandidateSegment, deleteChannel } from "../../actions"; import {
deleteRawSegment,
deleteCandidateSegment,
deleteChannel,
stopSession,
forceStartCapture,
retryRender,
cancelRender,
} from "../../actions";
import { DeleteButton } from "../../components/DeleteButton"; import { DeleteButton } from "../../components/DeleteButton";
import { ConfirmButton } from "../../components/ConfirmButton";
import { LiveLogViewer } from "../../components/LiveLogViewer";
import { SessionTimer } from "../../components/SessionTimer";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -48,6 +59,7 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
if (!channel) notFound(); if (!channel) notFound();
const status = statuses.get(channel.id); const status = statuses.get(channel.id);
const activeSession = channel.sessions.find((s) => s.endedAt === null);
return ( return (
<> <>
@@ -64,8 +76,21 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
<div className="card"> <div className="card">
<div className="card-head"> <div className="card-head">
<span>Canlı Durum</span> <span>Canlı Durum</span>
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<span className={`badge ${status?.recording ? "warn" : "muted"}`}> <span className={`badge ${status?.recording ? "warn" : "muted"}`}>
{status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"} {status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"}
{status?.recording && activeSession && (
<>
{" · "}
<SessionTimer startedAt={activeSession.startedAt.toISOString()} />
</>
)}
</span>
{status?.recording && activeSession && (
<form action={stopSession.bind(null, activeSession.id)}>
<ConfirmButton confirmText="Bu kaydı şimdi durdurmak istediğine emin misin?" label="Durdur" />
</form>
)}
</span> </span>
</div> </div>
<div className="mono"> <div className="mono">
@@ -85,6 +110,27 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
</pre> </pre>
</div> </div>
)} )}
{status?.recording && activeSession?.liveVideoId && activeSession.liveVideoId !== "forced" && (
<iframe
src={`https://www.youtube.com/embed/${activeSession.liveVideoId}`}
title="Canlı yayın önizleme"
style={{ width: "100%", maxWidth: "480px", aspectRatio: "16/9", marginTop: "0.6rem", border: "none", borderRadius: "6px" }}
allow="autoplay; encrypted-media"
/>
)}
{status?.recording && activeSession && <LiveLogViewer sessionId={activeSession.id} />}
<details style={{ marginTop: "0.75rem" }}>
<summary className="mono" style={{ cursor: "pointer", fontSize: "0.8rem" }}>
Manuel URL ile kayıt başlat
</summary>
<form action={forceStartCapture.bind(null, channel.id)} style={{ display: "flex", gap: "0.4rem", marginTop: "0.5rem" }}>
<input name="url" placeholder="https://www.youtube.com/watch?v=..." required style={{ flex: 1 }} />
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
Başlat
</button>
</form>
</details>
</div> </div>
<h1 style={{ marginTop: "2rem" }}>Yayın Geçmişi</h1> <h1 style={{ marginTop: "2rem" }}>Yayın Geçmişi</h1>
@@ -153,8 +199,31 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
{c.short.status === "FAILED" && c.short.errorMessage && ( {c.short.status === "FAILED" && c.short.errorMessage && (
<p className="mono" style={{ fontSize: "0.75rem", marginTop: "0.3rem" }}>{c.short.errorMessage}</p> <p className="mono" style={{ fontSize: "0.75rem", marginTop: "0.3rem" }}>{c.short.errorMessage}</p>
)} )}
{c.short.status === "FAILED" && (
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}>
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
Tekrar Dene
</button>
</form>
)}
{c.short.status === "RENDERING" && (
<form action={cancelRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}>
<ConfirmButton
confirmText="Render'ı başarısız say ve kilidi aç? (Çalışan process'i öldürmez, sadece durumu sıfırlar)"
label="Takıldıysa İptal Et"
variant="muted"
/>
</form>
)}
</div> </div>
)} )}
{!c.short && c.status === "TRANSCRIBED" && (
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.5rem" }}>
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
9:16 Render Et
</button>
</form>
)}
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}> <form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}>
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" /> <DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" />
@@ -0,0 +1,24 @@
"use client";
export function ConfirmButton({
confirmText,
label,
variant = "err",
}: {
confirmText: string;
label: string;
variant?: "err" | "warn" | "muted" | "ok";
}) {
return (
<button
type="submit"
className={`badge ${variant}`}
style={{ border: "none", cursor: "pointer" }}
onClick={(e) => {
if (!confirm(confirmText)) e.preventDefault();
}}
>
{label}
</button>
);
}
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef, useState } from "react";
export function LiveLogViewer({ sessionId }: { sessionId: string }) {
const [lines, setLines] = useState<string[]>([]);
const boxRef = useRef<HTMLPreElement>(null);
useEffect(() => {
let cancelled = false;
async function poll() {
try {
const res = await fetch(`/api/debug/capture/${sessionId}`, { cache: "no-store" });
const data = (await res.json()) as { lines: string[] };
if (!cancelled) setLines(data.lines);
} catch {
// transient fetch failures are fine to just skip — next tick retries
}
}
poll();
const timer = setInterval(poll, 4000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [sessionId]);
useEffect(() => {
if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
}, [lines]);
return (
<pre
ref={boxRef}
className="mono"
style={{
maxHeight: "220px",
overflowY: "auto",
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: "6px",
padding: "0.6rem 0.7rem",
fontSize: "0.7rem",
whiteSpace: "pre-wrap",
marginTop: "0.5rem",
}}
>
{lines.length > 0 ? lines.join("\n") : "Log bekleniyor…"}
</pre>
);
}
@@ -0,0 +1,23 @@
"use client";
import { useEffect, useState } from "react";
function formatElapsed(ms: number): string {
const totalSec = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
export function SessionTimer({ startedAt }: { startedAt: string }) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
return <span className="mono">{formatElapsed(now - new Date(startedAt).getTime())}</span>;
}
+1
View File
@@ -18,6 +18,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<nav className="nav"> <nav className="nav">
<Link href="/">Kanal Durumu</Link> <Link href="/">Kanal Durumu</Link>
<Link href="/segments">Segment & Aday Kütüphanesi</Link> <Link href="/segments">Segment & Aday Kütüphanesi</Link>
<Link href="/stats">İstatistik</Link>
<Link href="/settings">Ayarlar</Link> <Link href="/settings">Ayarlar</Link>
<Link href="/users">Kullanıcılar</Link> <Link href="/users">Kullanıcılar</Link>
<span style={{ marginLeft: "auto", display: "flex", gap: "1rem", alignItems: "center" }}> <span style={{ marginLeft: "auto", display: "flex", gap: "1rem", alignItems: "center" }}>
+28 -1
View File
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { addChannel } from "./actions"; import { addChannel, toggleChannelActive, toggleAllChannels, checkChannelNow } from "./actions";
import { fetchChannelStatuses } from "../lib/apiDaemon"; import { fetchChannelStatuses } from "../lib/apiDaemon";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -34,6 +34,19 @@ export default async function DashboardPage() {
{channels.length === 0 ? ( {channels.length === 0 ? (
<p className="empty">Henüz kanal eklenmedi.</p> <p className="empty">Henüz kanal eklenmedi.</p>
) : ( ) : (
<>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.6rem" }}>
<form action={toggleAllChannels.bind(null, true)}>
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
Tümünü Devam Ettir
</button>
</form>
<form action={toggleAllChannels.bind(null, false)}>
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}>
Tümünü Duraklat
</button>
</form>
</div>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -41,6 +54,7 @@ export default async function DashboardPage() {
<th>Durum</th> <th>Durum</th>
<th>Kayıt</th> <th>Kayıt</th>
<th>Son Kontrol</th> <th>Son Kontrol</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -71,11 +85,24 @@ export default async function DashboardPage() {
<td className="mono"> <td className="mono">
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"} {c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
</td> </td>
<td style={{ display: "flex", gap: "0.35rem" }}>
<form action={toggleChannelActive.bind(null, c.id, !c.isActive)}>
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}>
{c.isActive ? "Duraklat" : "Devam Ettir"}
</button>
</form>
<form action={checkChannelNow.bind(null, c.id)}>
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}>
Şimdi Kontrol Et
</button>
</form>
</td>
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
</>
)} )}
</> </>
); );
+25 -1
View File
@@ -1,6 +1,7 @@
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { deleteRawSegment, deleteCandidateSegment } from "../actions"; import { deleteRawSegment, deleteCandidateSegment, retryRender, cancelRender } from "../actions";
import { DeleteButton } from "../components/DeleteButton"; import { DeleteButton } from "../components/DeleteButton";
import { ConfirmButton } from "../components/ConfirmButton";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -92,8 +93,31 @@ export default async function SegmentsPage() {
{c.short.status === "FAILED" && c.short.errorMessage && ( {c.short.status === "FAILED" && c.short.errorMessage && (
<p className="mono" style={{ fontSize: "0.75rem", marginTop: "0.3rem" }}>{c.short.errorMessage}</p> <p className="mono" style={{ fontSize: "0.75rem", marginTop: "0.3rem" }}>{c.short.errorMessage}</p>
)} )}
{c.short.status === "FAILED" && (
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}>
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
Tekrar Dene
</button>
</form>
)}
{c.short.status === "RENDERING" && (
<form action={cancelRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}>
<ConfirmButton
confirmText="Render'ı başarısız say ve kilidi aç? (Çalışan process'i öldürmez, sadece durumu sıfırlar)"
label="Takıldıysa İptal Et"
variant="muted"
/>
</form>
)}
</div> </div>
)} )}
{!c.short && c.status === "TRANSCRIBED" && (
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.5rem" }}>
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
9:16 Render Et
</button>
</form>
)}
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}> <form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}>
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" /> <DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" />
+98 -2
View File
@@ -1,10 +1,25 @@
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { updateYtdlpCookies } from "../actions"; import { updateYtdlpCookies, updateSystemSettings, updateNotificationPrefs } from "../actions";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const STALE_COOKIE_HOURS = 4;
export default async function SettingsPage() { export default async function SettingsPage() {
const setting = await prisma.appSetting.findUnique({ where: { key: "ytdlp_cookies" } }); const [setting, pollSetting, ttlSetting, notifyStart, notifyRender] = await Promise.all([
prisma.appSetting.findUnique({ where: { key: "ytdlp_cookies" } }),
prisma.appSetting.findUnique({ where: { key: "poll_interval_ms" } }),
prisma.appSetting.findUnique({ where: { key: "raw_segment_ttl_hours" } }),
prisma.appSetting.findUnique({ where: { key: "notify_stream_start" } }),
prisma.appSetting.findUnique({ where: { key: "notify_render_done" } }),
]);
const cookieAgeHours = setting ? (Date.now() - setting.updatedAt.getTime()) / 3_600_000 : null;
const cookieStale = cookieAgeHours !== null && cookieAgeHours > STALE_COOKIE_HOURS;
const pollIntervalSec = pollSetting ? Math.round(Number(pollSetting.value) / 1000) : 60;
const ttlHours = ttlSetting ? Number(ttlSetting.value) : 24;
const notifyStreamStart = notifyStart?.value !== "false";
const notifyRenderDone = notifyRender?.value !== "false";
return ( return (
<> <>
@@ -17,6 +32,14 @@ export default async function SettingsPage() {
{setting ? `son güncelleme: ${setting.updatedAt.toLocaleString("tr-TR")}` : "hiç ayarlanmadı"} {setting ? `son güncelleme: ${setting.updatedAt.toLocaleString("tr-TR")}` : "hiç ayarlanmadı"}
</span> </span>
</div> </div>
{cookieStale && (
<p style={{ marginBottom: "0.5rem" }}>
<span className="badge warn">
{Math.round(cookieAgeHours!)} saattir güncellenmedi büyük/popüler kanallarda bot-check
hatası görülebilir, taze bir cookies.txt ile güncelle
</span>
</p>
)}
<p className="empty" style={{ marginBottom: "0.5rem" }}> <p className="empty" style={{ marginBottom: "0.5rem" }}>
YouTube canlı yayın kontrolü ve kayıt için kullanılıyor. Google, oturum çerezlerinin bir YouTube canlı yayın kontrolü ve kayıt için kullanılıyor. Google, oturum çerezlerinin bir
kısmını birkaç saatte bir yeniliyor burada eskidiğini fark edersen (kanal detay kısmını birkaç saatte bir yeniliyor burada eskidiğini fark edersen (kanal detay
@@ -59,6 +82,79 @@ export default async function SettingsPage() {
</button> </button>
</form> </form>
</div> </div>
<div className="card">
<div className="card-head">
<span>Sistem Parametreleri</span>
</div>
<p className="empty" style={{ marginBottom: "0.5rem" }}>
Bir sonraki döngüden itibaren geçerli olur, redeploy gerekmez.
</p>
<form action={updateSystemSettings} style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}>
<label className="mono" style={{ fontSize: "0.8rem" }}>
Poll aralığı (saniye)
<input name="pollIntervalSec" type="number" min={5} defaultValue={pollIntervalSec} style={{ display: "block", marginTop: "0.25rem" }} />
</label>
<label className="mono" style={{ fontSize: "0.8rem" }}>
Ham segment TTL (saat)
<input name="ttlHours" type="number" min={1} defaultValue={ttlHours} style={{ display: "block", marginTop: "0.25rem" }} />
</label>
<p className="empty" style={{ fontSize: "0.75rem", margin: 0 }}>
Eşzamanlı capture limiti ve render eşzamanlılığı BullMQ worker&apos;ları başlatılırken
sabitleniyor bunları değiştirmek için Coolify&apos;deki `MAX_CONCURRENT_CAPTURES` /
`VIDEO_RENDER_CONCURRENCY` env var&apos;larını güncelleyip redeploy etmek gerekiyor.
</p>
<button
type="submit"
style={{
alignSelf: "flex-start",
background: "var(--accent)",
border: "none",
borderRadius: "6px",
padding: "0.5rem 1rem",
color: "#fff",
fontWeight: 600,
fontSize: "0.9rem",
cursor: "pointer",
}}
>
Kaydet
</button>
</form>
</div>
<div className="card">
<div className="card-head">
<span>Bildirimler</span>
</div>
<form action={updateNotificationPrefs} style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<label className="mono" style={{ fontSize: "0.85rem", display: "flex", alignItems: "center", gap: "0.4rem" }}>
<input type="checkbox" name="notifyStreamStart" defaultChecked={notifyStreamStart} />
Yayın başladığında Telegram bildirimi
</label>
<label className="mono" style={{ fontSize: "0.85rem", display: "flex", alignItems: "center", gap: "0.4rem" }}>
<input type="checkbox" name="notifyRenderDone" defaultChecked={notifyRenderDone} />
9:16 render tamamlandığında Telegram bildirimi
</label>
<button
type="submit"
style={{
alignSelf: "flex-start",
marginTop: "0.3rem",
background: "var(--accent)",
border: "none",
borderRadius: "6px",
padding: "0.5rem 1rem",
color: "#fff",
fontWeight: 600,
fontSize: "0.9rem",
cursor: "pointer",
}}
>
Kaydet
</button>
</form>
</div>
</> </>
); );
} }
+72
View File
@@ -0,0 +1,72 @@
import { prisma } from "@streamclipper/db";
export const dynamic = "force-dynamic";
const STT_COST_PER_MINUTE = 0.006; // OpenAI Whisper API, doğrulanmalı — bkz. openai.com/pricing
const GB_PER_HOUR_ESTIMATE = 1.3; // 2026-09-02 oturumunda ölçülen ~2.8 Mbps ortalama bitrate'ten
function fmt(n: number, digits = 1): string {
return n.toLocaleString("tr-TR", { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
export default async function StatsPage() {
const [transcribed, durationSum, readyShorts, failedShorts, totalCandidates] = await Promise.all([
prisma.candidateSegment.findMany({
where: { status: "TRANSCRIBED" },
select: { startSec: true, endSec: true },
}),
prisma.rawSegment.aggregate({ _sum: { duration: true } }),
prisma.shortVideo.count({ where: { status: "READY" } }),
prisma.shortVideo.count({ where: { status: "FAILED" } }),
prisma.candidateSegment.count(),
]);
const sttSeconds = transcribed.reduce((sum, c) => sum + Math.max(0, c.endSec - c.startSec), 0);
const sttMinutes = sttSeconds / 60;
const sttCost = sttMinutes * STT_COST_PER_MINUTE;
const captureSeconds = durationSum._sum.duration ?? 0;
const captureHours = captureSeconds / 3600;
const estimatedGb = captureHours * GB_PER_HOUR_ESTIMATE;
return (
<>
<h1>Kullanım & Maliyet</h1>
<p className="empty" style={{ marginBottom: "1rem" }}>
Şimdiye kadarki toplam kullanım tüm zamanlar. STT maliyeti ve disk kullanımı tahmini
(bkz. maliyet analizi), gerçek faturayla küçük farklar olabilir.
</p>
<div className="card" style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: "1rem" }}>
<div>
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TOPLAM KAYIT SÜRESİ</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{fmt(captureHours)} sa</div>
</div>
<div>
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TOPLAM STT SÜRESİ</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{fmt(sttMinutes)} dk</div>
</div>
<div>
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TAHMİNİ WHISPER MALİYETİ</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>${fmt(sttCost, 2)}</div>
</div>
<div>
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TAHMİNİ DİSK KULLANIMI</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{fmt(estimatedGb)} GB</div>
</div>
<div>
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>ÜRETİLEN 9:16 KLİP</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{readyShorts}</div>
</div>
<div>
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>BAŞARISIZ RENDER</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{failedShorts}</div>
</div>
<div>
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TOPLAM ADAY KLİP</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{totalCandidates}</div>
</div>
</div>
</>
);
}
+19 -1
View File
@@ -8,7 +8,25 @@ export interface ChannelStatus {
lastPollError: string | null; lastPollError: string | null;
} }
const API_DAEMON_URL = process.env.API_DAEMON_URL ?? "http://localhost:4001"; export const API_DAEMON_URL = process.env.API_DAEMON_URL ?? "http://localhost:4001";
/** POSTs to a state-changing api-daemon endpoint with the shared internal auth header (see INTERNAL_API_TOKEN in .env.example). */
export async function postToApiDaemon<T = unknown>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${API_DAEMON_URL}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
...(process.env.INTERNAL_API_TOKEN ? { authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
cache: "no-store",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`api-daemon ${path} failed (${res.status}): ${text.slice(0, 300)}`);
}
return res.json() as Promise<T>;
}
/** /**
* lastPollError only exists in api-daemon's in-memory map (not persisted to * lastPollError only exists in api-daemon's in-memory map (not persisted to
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -21,6 +21,13 @@ async def get_pool() -> asyncpg.Pool:
return _pool return _pool
async def get_setting(key: str, default: str | None = None) -> str | None:
"""Same app_settings table the Node side reads for cookies/poll-interval — settings authored from the panel are shared across both languages."""
pool = await get_pool()
row = await pool.fetchrow("SELECT value FROM app_settings WHERE key = $1", key)
return row["value"] if row else default
async def get_raw_segment(raw_segment_id: str) -> asyncpg.Record | None: async def get_raw_segment(raw_segment_id: str) -> asyncpg.Record | None:
pool = await get_pool() pool = await get_pool()
return await pool.fetchrow( return await pool.fetchrow(
+1
View File
@@ -245,6 +245,7 @@ async def process_video_render(job, job_token=None):
) )
await db.update_short_video(short_id, status="READY", file_path=out_path) await db.update_short_video(short_id, status="READY", file_path=out_path)
if await db.get_setting("notify_render_done", "true") != "false":
await send_telegram_message(f"🎬 *{candidate['channel_name']}* için 9:16 kısa video hazır!") await send_telegram_message(f"🎬 *{candidate['channel_name']}* için 9:16 kısa video hazır!")
print(f"[video-render] short {short_id} ready: {out_path}") print(f"[video-render] short {short_id} ready: {out_path}")
except Exception as exc: except Exception as exc:
+2
View File
@@ -42,6 +42,7 @@ services:
MAX_CONCURRENT_CAPTURES: ${MAX_CONCURRENT_CAPTURES:-10} MAX_CONCURRENT_CAPTURES: ${MAX_CONCURRENT_CAPTURES:-10}
PROXY_URL: ${PROXY_URL:-} PROXY_URL: ${PROXY_URL:-}
RAW_SEGMENT_TTL_HOURS: ${RAW_SEGMENT_TTL_HOURS:-24} RAW_SEGMENT_TTL_HOURS: ${RAW_SEGMENT_TTL_HOURS:-24}
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
volumes: volumes:
- shared-media:/shared-media - shared-media:/shared-media
ports: ports:
@@ -82,6 +83,7 @@ services:
API_DAEMON_URL: http://sc_api_daemon:4001 API_DAEMON_URL: http://sc_api_daemon:4001
SHARED_MEDIA_ROOT: /shared-media SHARED_MEDIA_ROOT: /shared-media
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set} SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
volumes: volumes:
- shared-media:/shared-media - shared-media:/shared-media
ports: ports: