Compare commits
8
Commits
b5c761afbf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11e98b300e | ||
|
|
9aed4e891e | ||
|
|
ccd922ba37 | ||
|
|
eb2a9d28ed | ||
|
|
63443f3392 | ||
|
|
57ca4b5878 | ||
|
|
59888a6513 | ||
|
|
597b1186ab |
+1
-1
@@ -12,7 +12,7 @@ TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
|
||||
POLL_INTERVAL_MS=60000
|
||||
SEGMENT_TIME_SEC=300
|
||||
SEGMENT_TIME_SEC=180
|
||||
API_DAEMON_PORT=4001
|
||||
|
||||
# Signs the panel's login session cookies (frontend). Generate with:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Worker, type Job } from "bullmq";
|
||||
import { prisma } from "@streamclipper/db";
|
||||
@@ -7,6 +7,7 @@ import { env } from "../env";
|
||||
import { QUEUE_NAMES, signalDetectionQueue, type StreamIngestJob } from "../queues";
|
||||
import { redisConnection } from "../redis";
|
||||
import { ytdlpAntiBotArgs } from "../ytdlpCookies";
|
||||
import { sendTelegramVideo } from "../telegram";
|
||||
|
||||
const SEGMENT_LIST_POLL_MS = 5_000;
|
||||
|
||||
@@ -59,13 +60,20 @@ function parseSegmentListLines(raw: string): SegmentListEntry[] {
|
||||
});
|
||||
}
|
||||
|
||||
async function sendRawSegmentsToTelegramEnabled(): Promise<boolean> {
|
||||
const setting = await prisma.appSetting.findUnique({ where: { key: "send_raw_segments_to_telegram" } });
|
||||
return setting?.value === "true";
|
||||
}
|
||||
|
||||
async function processCompletedSegments(
|
||||
entries: SegmentListEntry[],
|
||||
fromIndex: number,
|
||||
outDir: string,
|
||||
sessionId: string,
|
||||
channelName: string,
|
||||
): Promise<number> {
|
||||
let processed = fromIndex;
|
||||
const sendRawToTelegram = await sendRawSegmentsToTelegramEnabled();
|
||||
|
||||
for (let i = fromIndex; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
@@ -82,13 +90,29 @@ async function processCompletedSegments(
|
||||
},
|
||||
});
|
||||
|
||||
await signalDetectionQueue.add("analyze-segment", {
|
||||
rawSegmentId: segment.id,
|
||||
filePath,
|
||||
sessionId,
|
||||
});
|
||||
if (sendRawToTelegram) {
|
||||
// Kullanıcı transkript/9:16 zincirini hiç istemiyor — ham segmenti
|
||||
// analiz kuyruğuna hiç sokmadan doğrudan gönderiyoruz. Kuyruğa
|
||||
// eklemiyoruz çünkü aşağıda dosyayı silmek, o segmenti daha sonra
|
||||
// okumaya çalışacak sinyal analizini bozardı.
|
||||
const caption = `🎬 *${channelName}* — ${duration}sn ham kayıt`;
|
||||
const sent = await sendTelegramVideo(filePath, caption);
|
||||
if (sent) {
|
||||
await unlink(filePath).catch(() => {});
|
||||
await prisma.rawSegment.update({ where: { id: segment.id }, data: { status: "SENT_TO_TELEGRAM", filePath: null } });
|
||||
console.log(`[stream-ingest] segment ${entry.fileName} sent to Telegram and removed from disk`);
|
||||
} else {
|
||||
console.warn(`[stream-ingest] segment ${entry.fileName} failed to send to Telegram, kept on disk`);
|
||||
}
|
||||
} else {
|
||||
await signalDetectionQueue.add("analyze-segment", {
|
||||
rawSegmentId: segment.id,
|
||||
filePath,
|
||||
sessionId,
|
||||
});
|
||||
console.log(`[stream-ingest] segment ready: ${entry.fileName} -> raw_segment ${segment.id}`);
|
||||
}
|
||||
|
||||
console.log(`[stream-ingest] segment ready: ${entry.fileName} -> raw_segment ${segment.id}`);
|
||||
processed = i + 1;
|
||||
}
|
||||
|
||||
@@ -96,7 +120,9 @@ async function processCompletedSegments(
|
||||
}
|
||||
|
||||
async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
||||
const { sessionId, youtubeUrl, segmentTimeSec } = job.data;
|
||||
const { sessionId, youtubeUrl, segmentTimeSec, channelDbId } = job.data;
|
||||
const channel = await prisma.channel.findUnique({ where: { id: channelDbId } });
|
||||
const channelName = channel?.name ?? "Bilinmeyen kanal";
|
||||
const outDir = path.join(env.sharedMediaRoot, "raw", sessionId);
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
@@ -108,7 +134,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
||||
"yt-dlp",
|
||||
[
|
||||
"--js-runtimes", "node",
|
||||
...(await ytdlpAntiBotArgs()),
|
||||
...(await ytdlpAntiBotArgs(channelDbId, { includeAndroidClient: false })),
|
||||
"-f", "bestvideo+bestaudio/best", "-o", "-", youtubeUrl,
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
@@ -150,7 +176,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
||||
if (!raw) return;
|
||||
const entries = parseSegmentListLines(raw);
|
||||
if (entries.length > lastProcessedIndex) {
|
||||
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId);
|
||||
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId, channelName);
|
||||
await prisma.streamSession.update({
|
||||
where: { id: sessionId },
|
||||
data: { totalSegments: lastProcessedIndex },
|
||||
@@ -178,7 +204,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
||||
const raw = await readFile(segmentListPath, "utf8").catch(() => "");
|
||||
const entries = parseSegmentListLines(raw);
|
||||
if (entries.length > lastProcessedIndex) {
|
||||
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId);
|
||||
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId, channelName);
|
||||
}
|
||||
|
||||
await prisma.streamSession.update({
|
||||
|
||||
@@ -32,7 +32,7 @@ async function cleanupOnce(): Promise<void> {
|
||||
for (const segment of staleSegments) {
|
||||
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: segment.id } });
|
||||
await prisma.rawSegment.delete({ where: { id: segment.id } });
|
||||
await unlink(segment.filePath).catch(() => {});
|
||||
if (segment.filePath) await unlink(segment.filePath).catch(() => {});
|
||||
}
|
||||
|
||||
if (staleSegments.length > 0) {
|
||||
|
||||
@@ -4,7 +4,7 @@ export const env = {
|
||||
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
|
||||
sharedMediaRoot: process.env.SHARED_MEDIA_ROOT ?? "./shared-media",
|
||||
pollIntervalMs: Number(process.env.POLL_INTERVAL_MS ?? 60_000),
|
||||
segmentTimeSec: Number(process.env.SEGMENT_TIME_SEC ?? 300),
|
||||
segmentTimeSec: Number(process.env.SEGMENT_TIME_SEC ?? 180),
|
||||
port: Number(process.env.API_DAEMON_PORT ?? 4001),
|
||||
forceLiveUrl: process.env.FORCE_LIVE_URL ?? "",
|
||||
ytdlpPotProviderUrl: process.env.YTDLP_POT_PROVIDER_URL ?? "",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN ?? "";
|
||||
const CHAT_ID = process.env.TELEGRAM_CHAT_ID ?? "";
|
||||
|
||||
// Telegram's bot-upload limit (no local Bot API server).
|
||||
const MAX_VIDEO_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export async function sendTelegramMessage(text: string): Promise<void> {
|
||||
if (!BOT_TOKEN || !CHAT_ID) {
|
||||
console.warn("[telegram] TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID not set, skipping notification:", text);
|
||||
@@ -18,3 +24,40 @@ export async function sendTelegramMessage(text: string): Promise<void> {
|
||||
console.error("[telegram] failed to send message:", res.status, await res.text());
|
||||
}
|
||||
}
|
||||
|
||||
/** Uploads a video file to Telegram. Returns false (never throws) on any failure — the caller decides what that means for the local file. */
|
||||
export async function sendTelegramVideo(filePath: string, caption: string): Promise<boolean> {
|
||||
if (!BOT_TOKEN || !CHAT_ID) {
|
||||
console.warn("[telegram] TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID not set, skipping video:", caption);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { size } = await stat(filePath);
|
||||
if (size > MAX_VIDEO_BYTES) {
|
||||
console.warn(`[telegram] video ${filePath} is ${size} bytes, over Telegram's bot-upload limit — skipping`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await readFile(filePath);
|
||||
const form = new FormData();
|
||||
form.set("chat_id", CHAT_ID);
|
||||
form.set("caption", caption);
|
||||
form.set("parse_mode", "Markdown");
|
||||
form.set("video", new Blob([buffer], { type: "video/mp4" }), path.basename(filePath));
|
||||
|
||||
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendVideo`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("[telegram] failed to send video:", res.status, await res.text());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("[telegram] failed to upload video:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,29 @@ const BENIGN_POLL_MESSAGE = /is not currently live|will begin in/;
|
||||
* solver) is included too — some channels return "The page needs to be
|
||||
* reloaded" on the plain metadata fetch too, not just format resolution.
|
||||
*/
|
||||
async function notifyIfEnabled(key: string, text: string): Promise<void> {
|
||||
/**
|
||||
* A channel whose capture keeps crashing and restarting (see streamIngest's
|
||||
* crash-loop notes) fires "started" + "poll failing" + "poll recovered" over
|
||||
* and over within minutes — observed directly once several channels were
|
||||
* live at once. One shared per-channel cooldown across all three message
|
||||
* types means a flapping channel says its piece once, then goes quiet for a
|
||||
* while instead of narrating every blip.
|
||||
*/
|
||||
const NOTIFY_COOLDOWN_MS = 10 * 60 * 1000;
|
||||
const lastNotifyAt = new Map<string, number>();
|
||||
|
||||
async function notifyIfEnabled(key: string, channelId: string, text: string): Promise<void> {
|
||||
const last = lastNotifyAt.get(channelId) ?? 0;
|
||||
if (Date.now() - last < NOTIFY_COOLDOWN_MS) return;
|
||||
|
||||
const setting = await prisma.appSetting.findUnique({ where: { key } });
|
||||
if (setting?.value !== "false") await sendTelegramMessage(text);
|
||||
if (setting?.value === "false") return;
|
||||
|
||||
lastNotifyAt.set(channelId, Date.now());
|
||||
await sendTelegramMessage(text);
|
||||
}
|
||||
|
||||
async function findLiveVideoId(channelId: string, channelName: string): Promise<string | null> {
|
||||
async function findLiveVideoId(channelId: string, channelName: string, channelDbId: string): Promise<string | null> {
|
||||
const liveUrl = `https://www.youtube.com/channel/${channelId}/live`;
|
||||
|
||||
try {
|
||||
@@ -59,7 +76,7 @@ async function findLiveVideoId(channelId: string, channelName: string): Promise<
|
||||
"--no-warnings",
|
||||
"--js-runtimes",
|
||||
"node",
|
||||
...(await ytdlpAntiBotArgs()),
|
||||
...(await ytdlpAntiBotArgs(channelDbId)),
|
||||
"--print",
|
||||
"%(id)s",
|
||||
liveUrl,
|
||||
@@ -70,7 +87,7 @@ async function findLiveVideoId(channelId: string, channelName: string): Promise<
|
||||
// this runs on every successful poll, most of which were never broken.
|
||||
if (lastPollErrors.has(channelId)) {
|
||||
lastPollErrors.delete(channelId);
|
||||
await notifyIfEnabled("notify_poll_error", `✅ *${channelName}* canlı-tespiti tekrar çalışıyor.`);
|
||||
await notifyIfEnabled("notify_poll_error", channelDbId, `✅ *${channelName}* canlı-tespiti tekrar çalışıyor.`);
|
||||
}
|
||||
const videoId = stdout.trim().split("\n")[0];
|
||||
return videoId || null;
|
||||
@@ -86,6 +103,7 @@ async function findLiveVideoId(channelId: string, channelName: string): Promise<
|
||||
if (!wasAlreadyFailing) {
|
||||
await notifyIfEnabled(
|
||||
"notify_poll_error",
|
||||
channelDbId,
|
||||
`⚠️ *${channelName}* canlı-tespiti başarısız oluyor:\n\`${message.slice(0, 300)}\``,
|
||||
);
|
||||
}
|
||||
@@ -124,7 +142,7 @@ export async function startCaptureSession(
|
||||
segmentTimeSec: channel.segmentTimeSec ?? undefined,
|
||||
});
|
||||
|
||||
await notifyIfEnabled("notify_stream_start", `🔴 *${channel.name}* canlıya geçti, kayıt başlatılıyor.`);
|
||||
await notifyIfEnabled("notify_stream_start", channel.id, `🔴 *${channel.name}* canlıya geçti, kayıt başlatılıyor.`);
|
||||
console.log(`[youtube-polling] started session ${session.id} for channel ${channel.name}`);
|
||||
return { started: true };
|
||||
}
|
||||
@@ -139,7 +157,7 @@ export async function checkChannel(channel: {
|
||||
liveVideoId: string | null;
|
||||
error: PollError | null;
|
||||
}> {
|
||||
const liveVideoId = await findLiveVideoId(channel.channelId, channel.name);
|
||||
const liveVideoId = await findLiveVideoId(channel.channelId, channel.name, channel.id);
|
||||
await prisma.channel.update({
|
||||
where: { id: channel.id },
|
||||
data: { lastCheckedAt: new Date() },
|
||||
@@ -155,12 +173,31 @@ export async function checkChannel(channel: {
|
||||
export async function pollOnce(): Promise<void> {
|
||||
const channels = await prisma.channel.findMany({ where: { isActive: true } });
|
||||
|
||||
const openSessions = await prisma.streamSession.findMany({
|
||||
where: { endedAt: null, channelId: { in: channels.map((c) => c.id) } },
|
||||
select: { channelId: true },
|
||||
});
|
||||
const recordingChannelIds = new Set(openSessions.map((s) => s.channelId));
|
||||
|
||||
for (const channel of channels) {
|
||||
// Already capturing this channel — we already know it's live, so skip
|
||||
// the bot-check-prone /live lookup entirely. With several channels
|
||||
// sharing one cookie session + one proxy IP, re-checking channels that
|
||||
// don't need it multiplies request volume through that single shared
|
||||
// identity for no reason — a likely contributor to multiple channels
|
||||
// failing detection at once once a few are live simultaneously.
|
||||
if (recordingChannelIds.has(channel.id)) continue;
|
||||
|
||||
try {
|
||||
await checkChannel(channel);
|
||||
} catch (err) {
|
||||
console.error(`[youtube-polling] error polling channel ${channel.name}:`, err);
|
||||
}
|
||||
|
||||
// Stagger checks instead of firing them back-to-back — a tight burst of
|
||||
// near-simultaneous requests through the same cookie/IP looks more
|
||||
// automated than the same requests spread out.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,70 @@ import { writeFileSync } from "node:fs";
|
||||
import { prisma } from "@streamclipper/db";
|
||||
import { env } from "./env";
|
||||
|
||||
const COOKIES_PATH = "/tmp/yt-cookies.txt";
|
||||
const COOKIES_SETTING_KEY = "ytdlp_cookies";
|
||||
const LEGACY_COOKIES_PATH = "/tmp/yt-cookies.txt";
|
||||
const LEGACY_COOKIES_SETTING_KEY = "ytdlp_cookies";
|
||||
|
||||
// Oxylabs' static ISP pool: one dedicated (non-rotating) IP per port,
|
||||
// confirmed against the dashboard's Proxy list (isp.oxylabs.io:8001..8010 ->
|
||||
// 10 distinct IPs). PROXY_URL's own port is the base; funneling every
|
||||
// channel through that single port meant 7-8 simultaneous channels' worth
|
||||
// of YouTube-facing traffic all looked like it came from one IP.
|
||||
const PROXY_PORT_POOL_SIZE = 10;
|
||||
|
||||
function simpleHash(input: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/** Deterministically pins a channel to one of the pool's ports — same channel always gets the same IP (so it isn't mid-session IP-mismatched against a live URL signed for a different one), different channels spread across the pool. */
|
||||
function proxyUrlForChannel(baseProxyUrl: string, channelKey: string): string {
|
||||
const match = baseProxyUrl.match(/^(.*:)(\d+)$/);
|
||||
if (!match) return baseProxyUrl;
|
||||
const [, prefix, basePortStr] = match;
|
||||
const basePort = Number(basePortStr);
|
||||
const offset = simpleHash(channelKey) % PROXY_PORT_POOL_SIZE;
|
||||
return `${prefix}${basePort + offset}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks which cookie profile a channel uses (same deterministic-hash
|
||||
* pinning as proxyUrlForChannel — same channel always gets the same
|
||||
* account, different channels spread across the pool) and writes it to a
|
||||
* profile-specific temp file. Falls back to the old single global
|
||||
* ytdlp_cookies AppSetting if no profiles have been added yet, so nothing
|
||||
* breaks before the panel's Settings page is used to add accounts.
|
||||
*
|
||||
* A single shared cookie file was a real bottleneck once several channels
|
||||
* were active at once: it isn't just proxy IP reputation that YouTube can
|
||||
* flag, it's the *account* — one Google session making requests that look
|
||||
* like it's scanning many unrelated channels trips its own abuse signal,
|
||||
* independent of which IP each request came from. Splitting across
|
||||
* separate throwaway accounts dilutes that per-account signal the same way
|
||||
* the proxy pool dilutes per-IP signal.
|
||||
*
|
||||
* Profile-specific file paths (not one shared /tmp/yt-cookies.txt) matter
|
||||
* once multiple channels can be using *different* profiles concurrently —
|
||||
* a shared path would let one channel's capture process read a file another
|
||||
* channel's poll check just overwrote with a different account's cookies.
|
||||
*/
|
||||
async function cookiesFilePathForChannel(channelKey: string): Promise<string | null> {
|
||||
const profiles = await prisma.cookieProfile.findMany({ orderBy: { createdAt: "asc" } });
|
||||
|
||||
if (profiles.length === 0) {
|
||||
const legacy = await prisma.appSetting.findUnique({ where: { key: LEGACY_COOKIES_SETTING_KEY } });
|
||||
if (!legacy?.value) return null;
|
||||
writeFileSync(LEGACY_COOKIES_PATH, legacy.value, { mode: 0o600 });
|
||||
return LEGACY_COOKIES_PATH;
|
||||
}
|
||||
|
||||
const profile = profiles[simpleHash(channelKey) % profiles.length];
|
||||
const path = `/tmp/yt-cookies-${profile.id}.txt`;
|
||||
writeFileSync(path, profile.value, { mode: 0o600 });
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube's anti-bot layer against yt-dlp has three parts, all needed
|
||||
@@ -21,13 +83,15 @@ const COOKIES_SETTING_KEY = "ytdlp_cookies";
|
||||
* current value and rewrites the temp file, so a panel update takes effect
|
||||
* on the very next yt-dlp invocation without a redeploy.
|
||||
*/
|
||||
export async function ytdlpAntiBotArgs(): Promise<string[]> {
|
||||
export async function ytdlpAntiBotArgs(
|
||||
channelKey: string,
|
||||
{ includeAndroidClient = true }: { includeAndroidClient?: boolean } = {},
|
||||
): Promise<string[]> {
|
||||
const args: string[] = [];
|
||||
|
||||
const setting = await prisma.appSetting.findUnique({ where: { key: COOKIES_SETTING_KEY } });
|
||||
if (setting?.value) {
|
||||
writeFileSync(COOKIES_PATH, setting.value, { mode: 0o600 });
|
||||
args.push("--cookies", COOKIES_PATH);
|
||||
const cookiesPath = await cookiesFilePathForChannel(channelKey);
|
||||
if (cookiesPath) {
|
||||
args.push("--cookies", cookiesPath);
|
||||
}
|
||||
|
||||
if (env.ytdlpPotProviderUrl) {
|
||||
@@ -39,10 +103,18 @@ export async function ytdlpAntiBotArgs(): Promise<string[]> {
|
||||
// you're not a bot" ile tıkanabiliyor — küçük kanallarda aynı kombinasyon
|
||||
// sorunsuz çalışıyor. mweb/android'i cookie-uyumlu fallback olarak
|
||||
// ekleyip yt-dlp'nin ilk başarılı client'ı kullanmasını sağlıyoruz.
|
||||
args.push("--extractor-args", "youtube:player_client=web,mweb,android");
|
||||
//
|
||||
// Ama "android" client'ı yt-dlp'nin kendi GitHub issue'larında birden
|
||||
// fazla kez videoplayback/segment isteklerinde 403 Forbidden'a sebep
|
||||
// olarak raporlanmış — sade metadata/tespit isteğinde (--simulate, format
|
||||
// çözümlemesi yok) risk taşımıyor ama gerçek segment indirimi sırasında
|
||||
// (asıl capture) bu 403'lerin kaynağı olabilir. O yüzden capture çağrısı
|
||||
// android'i devre dışı bırakıyor, sadece tespit çağrısı kullanıyor.
|
||||
const clients = includeAndroidClient ? "web,mweb,android" : "web,mweb";
|
||||
args.push("--extractor-args", `youtube:player_client=${clients}`);
|
||||
|
||||
if (env.proxyUrl) {
|
||||
args.push("--proxy", env.proxyUrl);
|
||||
args.push("--proxy", proxyUrlForChannel(env.proxyUrl, channelKey));
|
||||
}
|
||||
|
||||
return args;
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function deleteRawSegment(segmentId: string) {
|
||||
await deleteShortsForCandidates(segment.candidates.map((c) => c.id));
|
||||
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: segmentId } });
|
||||
await prisma.rawSegment.delete({ where: { id: segmentId } });
|
||||
await unlink(segment.filePath).catch(() => {});
|
||||
if (segment.filePath) await unlink(segment.filePath).catch(() => {});
|
||||
|
||||
revalidatePath("/segments");
|
||||
revalidatePath("/", "layout");
|
||||
@@ -61,7 +61,7 @@ export async function deleteChannel(channelId: string) {
|
||||
});
|
||||
const rawSegmentIds = sessions.flatMap((s) => s.segments.map((seg) => seg.id));
|
||||
const candidateIds = sessions.flatMap((s) => s.segments.flatMap((seg) => seg.candidates.map((c) => c.id)));
|
||||
const filePaths = sessions.flatMap((s) => s.segments.map((seg) => seg.filePath));
|
||||
const filePaths = sessions.flatMap((s) => s.segments.map((seg) => seg.filePath)).filter((p): p is string => Boolean(p));
|
||||
|
||||
await deleteShortsForCandidates(candidateIds);
|
||||
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: { in: rawSegmentIds } } });
|
||||
@@ -155,6 +155,36 @@ export async function updateSttEnabled(formData: FormData) {
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
export async function updateAutoRenderEnabled(formData: FormData) {
|
||||
const enabled = formData.get("autoRenderEnabled") === "true";
|
||||
await prisma.appSetting.upsert({
|
||||
where: { key: "auto_render_enabled" },
|
||||
create: { key: "auto_render_enabled", value: String(enabled) },
|
||||
update: { value: String(enabled) },
|
||||
});
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
export async function updateDeleteAfterTelegramSend(formData: FormData) {
|
||||
const enabled = formData.get("deleteAfterTelegramSend") === "true";
|
||||
await prisma.appSetting.upsert({
|
||||
where: { key: "delete_after_telegram_send" },
|
||||
create: { key: "delete_after_telegram_send", value: String(enabled) },
|
||||
update: { value: String(enabled) },
|
||||
});
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
export async function updateSendRawSegmentsToTelegram(formData: FormData) {
|
||||
const enabled = formData.get("sendRawSegmentsToTelegram") === "true";
|
||||
await prisma.appSetting.upsert({
|
||||
where: { key: "send_raw_segments_to_telegram" },
|
||||
create: { key: "send_raw_segments_to_telegram", value: String(enabled) },
|
||||
update: { value: String(enabled) },
|
||||
});
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
export async function updateNotificationPrefs(formData: FormData) {
|
||||
const streamStart = formData.get("notifyStreamStart") === "true";
|
||||
const renderDone = formData.get("notifyRenderDone") === "true";
|
||||
@@ -194,3 +224,30 @@ export async function updateYtdlpCookies(formData: FormData) {
|
||||
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
export async function addCookieProfile(formData: FormData) {
|
||||
const label = String(formData.get("label") ?? "").trim();
|
||||
const value = String(formData.get("value") ?? "").trim();
|
||||
|
||||
if (!label || !value) {
|
||||
throw new Error("Hesap adı ve cookie içeriği zorunlu.");
|
||||
}
|
||||
|
||||
await prisma.cookieProfile.create({ data: { label, value } });
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
export async function updateCookieProfile(profileId: string, formData: FormData) {
|
||||
const value = String(formData.get("value") ?? "").trim();
|
||||
if (!value) {
|
||||
throw new Error("Cookie içeriği boş olamaz.");
|
||||
}
|
||||
|
||||
await prisma.cookieProfile.update({ where: { id: profileId }, data: { value } });
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
export async function deleteCookieProfile(profileId: string) {
|
||||
await prisma.cookieProfile.delete({ where: { id: profileId } });
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const { id } = await params;
|
||||
|
||||
const segment = await prisma.rawSegment.findUnique({ where: { id } });
|
||||
if (!segment) return new Response("Not found", { status: 404 });
|
||||
if (!segment?.filePath) return new Response("Not found", { status: 404 });
|
||||
|
||||
return streamVideoFile(req, segment.filePath, `${id}.mp4`);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]
|
||||
PENDING: "muted",
|
||||
PROCESSED: "ok",
|
||||
DISCARDED: "err",
|
||||
SENT_TO_TELEGRAM: "ok",
|
||||
PENDING_STT: "warn",
|
||||
TRANSCRIBED: "ok",
|
||||
RENDERING: "warn",
|
||||
@@ -225,30 +226,43 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
||||
</span>
|
||||
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
|
||||
</div>
|
||||
<span className="font-mono-num text-[0.7rem] text-muted-foreground">{segment.filePath}</span>
|
||||
|
||||
<video controls preload="metadata" className="mt-2 w-full max-w-md rounded-md border border-border">
|
||||
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
|
||||
</video>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
|
||||
</Button>
|
||||
<form action={deleteRawSegment.bind(null, segment.id)}>
|
||||
<ConfirmButton
|
||||
confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
|
||||
label="Sil"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{segment.candidates.length === 0 && segment.status !== "PENDING" && (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Bu segmentte aday klip bulunamadı (sinyal analizi ilginç bir an tespit etmedi).
|
||||
{segment.filePath ? (
|
||||
<>
|
||||
<span className="font-mono-num text-[0.7rem] text-muted-foreground">{segment.filePath}</span>
|
||||
<video controls preload="metadata" className="mt-2 w-full max-w-md rounded-md border border-border">
|
||||
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
|
||||
</video>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
|
||||
</Button>
|
||||
<form action={deleteRawSegment.bind(null, segment.id)}>
|
||||
<ConfirmButton
|
||||
confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
|
||||
label="Sil"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
✅ Telegram'a gönderildi, sunucuda saklanmıyor.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{segment.status === "SENT_TO_TELEGRAM" ? (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Analiz edilmeden (transkript/9:16 atlanarak) doğrudan gönderildi.
|
||||
</p>
|
||||
) : (
|
||||
segment.candidates.length === 0 &&
|
||||
segment.status !== "PENDING" && (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Bu segmentte aday klip bulunamadı (sinyal analizi ilginç bir an tespit etmedi).
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
{segment.candidates.length > 0 && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{segment.candidates.map((c) => (
|
||||
@@ -267,7 +281,7 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
||||
{c.short && (
|
||||
<div className="mt-2 flex flex-col items-start gap-2">
|
||||
<Badge variant={STATUS_BADGE[c.short.status] ?? "muted"}>9:16: {c.short.status}</Badge>
|
||||
{c.short.status === "READY" && (
|
||||
{c.short.status === "READY" && c.short.filePath && (
|
||||
<>
|
||||
<video controls preload="metadata" className="w-[220px] rounded-md border border-border">
|
||||
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
|
||||
@@ -277,6 +291,11 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{c.short.status === "READY" && !c.short.filePath && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
✅ Telegram'a gönderildi, sunucuda saklanmıyor.
|
||||
</p>
|
||||
)}
|
||||
{c.short.status === "FAILED" && (
|
||||
<>
|
||||
{c.short.errorMessage && (
|
||||
|
||||
@@ -13,6 +13,7 @@ const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]
|
||||
PENDING: "muted",
|
||||
PROCESSED: "ok",
|
||||
DISCARDED: "err",
|
||||
SENT_TO_TELEGRAM: "ok",
|
||||
PENDING_STT: "warn",
|
||||
TRANSCRIBED: "ok",
|
||||
RENDERING: "warn",
|
||||
@@ -53,23 +54,32 @@ export default async function SegmentsPage() {
|
||||
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<video controls preload="metadata" className="w-full max-w-md rounded-md border border-border">
|
||||
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
|
||||
</video>
|
||||
{segment.filePath ? (
|
||||
<>
|
||||
<video controls preload="metadata" className="w-full max-w-md rounded-md border border-border">
|
||||
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
|
||||
</video>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
|
||||
</Button>
|
||||
<form action={deleteRawSegment.bind(null, segment.id)}>
|
||||
<ConfirmButton
|
||||
confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
|
||||
label="Sil"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">✅ Telegram'a gönderildi, sunucuda saklanmıyor.</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
|
||||
</Button>
|
||||
<form action={deleteRawSegment.bind(null, segment.id)}>
|
||||
<ConfirmButton
|
||||
confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
|
||||
label="Sil"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{segment.candidates.length === 0 ? (
|
||||
{segment.status === "SENT_TO_TELEGRAM" ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bu segment analiz edilmeden (transkript/9:16 atlanarak) doğrudan Telegram'a gönderildi.
|
||||
</p>
|
||||
) : segment.candidates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Bu segmentte aday klip bulunamadı.</p>
|
||||
) : (
|
||||
segment.candidates.map((c) => (
|
||||
@@ -89,7 +99,7 @@ export default async function SegmentsPage() {
|
||||
{c.short && (
|
||||
<div className="mt-2 flex flex-col items-start gap-2">
|
||||
<Badge variant={STATUS_BADGE[c.short.status] ?? "muted"}>9:16: {c.short.status}</Badge>
|
||||
{c.short.status === "READY" && (
|
||||
{c.short.status === "READY" && c.short.filePath && (
|
||||
<>
|
||||
<video controls preload="metadata" className="w-[220px] rounded-md border border-border">
|
||||
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
|
||||
@@ -99,6 +109,11 @@ export default async function SegmentsPage() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{c.short.status === "READY" && !c.short.filePath && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
✅ Telegram'a gönderildi, sunucuda saklanmıyor.
|
||||
</p>
|
||||
)}
|
||||
{c.short.status === "FAILED" && (
|
||||
<>
|
||||
{c.short.errorMessage && (
|
||||
|
||||
@@ -1,20 +1,45 @@
|
||||
import { prisma } from "@streamclipper/db";
|
||||
import { updateYtdlpCookies, updateSystemSettings, updateNotificationPrefs, updateSttEnabled } from "../actions";
|
||||
import {
|
||||
updateYtdlpCookies,
|
||||
updateSystemSettings,
|
||||
updateNotificationPrefs,
|
||||
updateSttEnabled,
|
||||
updateAutoRenderEnabled,
|
||||
updateDeleteAfterTelegramSend,
|
||||
updateSendRawSegmentsToTelegram,
|
||||
addCookieProfile,
|
||||
updateCookieProfile,
|
||||
deleteCookieProfile,
|
||||
} from "../actions";
|
||||
import { formatTr } from "../../lib/formatDate";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SettingSwitch } from "../components/SettingSwitch";
|
||||
import { ConfirmButton } from "../components/ConfirmButton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STALE_COOKIE_HOURS = 4;
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const [setting, pollSetting, ttlSetting, notifyStart, notifyRender, notifyError, sttSetting] = await Promise.all([
|
||||
const [
|
||||
setting,
|
||||
pollSetting,
|
||||
ttlSetting,
|
||||
notifyStart,
|
||||
notifyRender,
|
||||
notifyError,
|
||||
sttSetting,
|
||||
autoRenderSetting,
|
||||
deleteAfterSendSetting,
|
||||
sendRawSetting,
|
||||
cookieProfiles,
|
||||
] = 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" } }),
|
||||
@@ -22,6 +47,10 @@ export default async function SettingsPage() {
|
||||
prisma.appSetting.findUnique({ where: { key: "notify_render_done" } }),
|
||||
prisma.appSetting.findUnique({ where: { key: "notify_poll_error" } }),
|
||||
prisma.appSetting.findUnique({ where: { key: "stt_enabled" } }),
|
||||
prisma.appSetting.findUnique({ where: { key: "auto_render_enabled" } }),
|
||||
prisma.appSetting.findUnique({ where: { key: "delete_after_telegram_send" } }),
|
||||
prisma.appSetting.findUnique({ where: { key: "send_raw_segments_to_telegram" } }),
|
||||
prisma.cookieProfile.findMany({ orderBy: { createdAt: "asc" } }),
|
||||
]);
|
||||
|
||||
const cookieAgeHours = setting ? (Date.now() - setting.updatedAt.getTime()) / 3_600_000 : null;
|
||||
@@ -32,40 +61,128 @@ export default async function SettingsPage() {
|
||||
const notifyRenderDone = notifyRender?.value !== "false";
|
||||
const notifyPollError = notifyError?.value !== "false";
|
||||
const sttEnabled = sttSetting?.value !== "false";
|
||||
const autoRenderEnabled = autoRenderSetting?.value !== "false";
|
||||
const deleteAfterTelegramSend = deleteAfterSendSetting?.value === "true";
|
||||
const sendRawSegmentsToTelegram = sendRawSetting?.value === "true";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Ayarlar</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Cookie Hesapları</CardTitle>
|
||||
<CardDescription>
|
||||
Her kanal, aşağıdaki hesaplardan birine sabit olarak atanır (aynı kanal hep aynı hesabı kullanır, kanallar
|
||||
havuza dağılır). Tek hesap çok kanalı taramaya çalışınca YouTube hesabın kendisini şüpheli bulup
|
||||
"Sign in to confirm you're not a bot" hatası verebiliyor — birkaç ayrı, önemsiz Google
|
||||
hesabı ekleyerek bu yükü dağıt. Her hesap ayrı bir tarayıcıda giriş yapılıp cookies.txt olarak dışa
|
||||
aktarılmalı.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{cookieProfiles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Henüz hesap eklenmedi — aşağıdan en az bir tane ekle. Eklenene kadar aşağıdaki tekli/eski cookie ayarı
|
||||
kullanılmaya devam eder.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{cookieProfiles.map((profile) => {
|
||||
const ageHours = (Date.now() - profile.updatedAt.getTime()) / 3_600_000;
|
||||
const stale = ageHours > STALE_COOKIE_HOURS;
|
||||
return (
|
||||
<div key={profile.id} className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{profile.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono-num text-xs text-muted-foreground">
|
||||
{formatTr(profile.updatedAt)}
|
||||
</span>
|
||||
<form action={deleteCookieProfile.bind(null, profile.id)}>
|
||||
<ConfirmButton confirmText={`${profile.label} hesabını silmek istediğine emin misin?`} label="Sil" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{stale && (
|
||||
<Badge variant="warn" className="mt-2 w-fit">
|
||||
⚠️ {Math.round(ageHours)} saattir güncellenmedi
|
||||
</Badge>
|
||||
)}
|
||||
<details className="mt-2 text-sm">
|
||||
<summary className="cursor-pointer font-mono-num text-xs text-muted-foreground">
|
||||
Cookie'yi yenile
|
||||
</summary>
|
||||
<form action={updateCookieProfile.bind(null, profile.id)} className="mt-2 flex flex-col gap-2">
|
||||
<Textarea
|
||||
name="value"
|
||||
required
|
||||
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
|
||||
rows={6}
|
||||
className="font-mono-num text-xs"
|
||||
/>
|
||||
<Button type="submit" size="sm" className="self-start">
|
||||
Kaydet
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<form action={addCookieProfile} className="flex flex-col gap-2">
|
||||
<Label htmlFor="newProfileLabel" className="text-xs text-muted-foreground">
|
||||
Yeni hesap ekle
|
||||
</Label>
|
||||
<Input id="newProfileLabel" name="label" placeholder="Hesap adı (ör. Hesap 2)" required />
|
||||
<Textarea
|
||||
name="value"
|
||||
required
|
||||
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
|
||||
rows={6}
|
||||
className="font-mono-num text-xs"
|
||||
/>
|
||||
<Button type="submit" className="self-start">
|
||||
Hesap Ekle
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">YouTube Cookie'leri</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
YouTube Cookie'leri (tekli / eski)
|
||||
</CardTitle>
|
||||
<span className="font-mono-num text-xs text-muted-foreground">
|
||||
{setting ? `son güncelleme: ${formatTr(setting.updatedAt)}` : "hiç ayarlanmadı"}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{cookieStale && (
|
||||
{cookieStale && cookieProfiles.length === 0 && (
|
||||
<Badge variant="warn" className="w-fit">
|
||||
⚠️ {Math.round(cookieAgeHours!)} saattir güncellenmedi — büyük/popüler kanallarda bot-check hatası
|
||||
görülebilir
|
||||
</Badge>
|
||||
)}
|
||||
<CardDescription>
|
||||
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 sayfasında "son poll hatası"
|
||||
olarak görürsün) taze bir cookies.txt ile güncelle. Ana hesabın yerine ayrı, önemsiz bir Google hesabı
|
||||
kullanman önerilir.
|
||||
{cookieProfiles.length > 0
|
||||
? "Yukarıda en az bir hesap eklendiği için bu ayar artık kullanılmıyor — sadece geriye dönük referans için duruyor."
|
||||
: "Yukarıda hiç hesap eklenmediği sürece tüm kanallar bu tek cookie'yi paylaşır."}
|
||||
</CardDescription>
|
||||
<form action={updateYtdlpCookies} className="flex flex-col gap-3">
|
||||
<Textarea
|
||||
name="cookies"
|
||||
required
|
||||
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
|
||||
rows={10}
|
||||
rows={8}
|
||||
className="font-mono-num text-xs"
|
||||
/>
|
||||
<Button type="submit" className="self-start">
|
||||
<Button type="submit" variant="outline" className="self-start">
|
||||
Kaydet
|
||||
</Button>
|
||||
</form>
|
||||
@@ -122,6 +239,75 @@ export default async function SettingsPage() {
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">9:16 Render</CardTitle>
|
||||
<CardDescription>
|
||||
Kapatırsan transkribe edilen adaylar için otomatik render tetiklenmez, klip
|
||||
"TRANSCRIBED" durumunda bekler — kanal/segment sayfalarındaki "9:16 Render Et"
|
||||
butonuyla istediğini elle render edebilirsin.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={updateAutoRenderEnabled}>
|
||||
<CardContent>
|
||||
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
|
||||
Otomatik 9:16 render aktif
|
||||
<SettingSwitch name="autoRenderEnabled" defaultChecked={autoRenderEnabled} />
|
||||
</Label>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit">Kaydet</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Ham Kayıt Modu</CardTitle>
|
||||
<CardDescription>
|
||||
Açarsan her ham segment (analiz/transkript/9:16 zincirine hiç girmeden) tamamlanır tamamlanmaz doğrudan
|
||||
Telegram'a gönderilip sunucudan silinir — transkript ve 9:16 render tamamen atlanır. Bunu açtıysan
|
||||
STT ve 9:16 Render ayarlarının kapalı olması mantıklı (aksi halde ikisi de boşa kalır, çünkü bu mod
|
||||
segmenti analiz kuyruğuna hiç sokmuyor). ~50MB'ı geçen segmentler gönderilemez, sunucuda kalır —
|
||||
gerekirse yukarıdaki "Sistem Parametreleri"nden segment süresini kısalt.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={updateSendRawSegmentsToTelegram}>
|
||||
<CardContent>
|
||||
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
|
||||
Ham segmenti doğrudan Telegram'a gönder (analiz atlanır)
|
||||
<SettingSwitch name="sendRawSegmentsToTelegram" defaultChecked={sendRawSegmentsToTelegram} />
|
||||
</Label>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit">Kaydet</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Depolama (9:16 klipler)</CardTitle>
|
||||
<CardDescription>
|
||||
Yukarıdaki "Ham Kayıt Modu"ndan farklı — bu, STT+9:16 render zincirinden geçmiş klipler için.
|
||||
Açarsan her render biten video Telegram'a gönderilip <strong>sunucudan kalıcı olarak
|
||||
silinir</strong> — sadece Telegram'daki kopyada kalır (geri alınamaz). Gönderim başarısız olursa
|
||||
dosya sunucuda kalır. Kanal sayısı arttıkça disk doluluğunu önlemek için.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={updateDeleteAfterTelegramSend}>
|
||||
<CardContent>
|
||||
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
|
||||
Telegram'a gönderip sunucudan sil
|
||||
<SettingSwitch name="deleteAfterTelegramSend" defaultChecked={deleteAfterTelegramSend} />
|
||||
</Label>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit">Kaydet</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Bildirimler</CardTitle>
|
||||
|
||||
@@ -158,3 +158,12 @@ async def update_short_video(
|
||||
file_path,
|
||||
error_message,
|
||||
)
|
||||
|
||||
|
||||
async def clear_short_video_file(short_id: str) -> None:
|
||||
"""update_short_video's COALESCE keeps the old file_path when passed None — this explicitly nulls it out once the rendered file has been deleted from disk (e.g. after a successful Telegram delivery)."""
|
||||
pool = await get_pool()
|
||||
await pool.execute(
|
||||
"UPDATE short_videos SET file_path = NULL, updated_at = now() WHERE id = $1",
|
||||
short_id,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ WINDOW_SEC = 1.0
|
||||
PEAK_STD_MULTIPLIER = 1.5
|
||||
CANDIDATE_PAD_SEC = 45
|
||||
CHAT_SPIKE_MULTIPLIER = 3
|
||||
SEGMENT_TIME_SEC = int(os.environ.get("SEGMENT_TIME_SEC", "300"))
|
||||
SEGMENT_TIME_SEC = int(os.environ.get("SEGMENT_TIME_SEC", "180"))
|
||||
|
||||
stt_scoring_queue = Queue(QUEUE_NAMES["STT_SCORING"], {"connection": REDIS_URL})
|
||||
|
||||
|
||||
@@ -72,7 +72,10 @@ async def process_stt_scoring(job, job_token=None):
|
||||
f"({candidate['start_sec']}-{candidate['end_sec']}s):\n_{preview}_"
|
||||
)
|
||||
|
||||
await video_render_queue.add("render-short", {"candidateSegmentId": candidate_id})
|
||||
if await db.get_setting("auto_render_enabled", "true") != "false":
|
||||
await video_render_queue.add("render-short", {"candidateSegmentId": candidate_id})
|
||||
else:
|
||||
print(f"[stt-scoring] auto-render kapalı, {candidate_id} render kuyruğuna eklenmedi (panelden manuel tetiklenebilir)")
|
||||
|
||||
print(f"[stt-scoring] candidate {candidate_id} transcribed")
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ import httpx
|
||||
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||
|
||||
# Telegram's bot-upload limit (no local Bot API server) — checked before
|
||||
# attempting an upload so an oversized file fails fast instead of burning
|
||||
# time/bandwidth on a request Telegram will reject anyway.
|
||||
MAX_VIDEO_BYTES = 50 * 1024 * 1024
|
||||
|
||||
|
||||
async def send_telegram_message(text: str) -> None:
|
||||
if not BOT_TOKEN or not CHAT_ID:
|
||||
@@ -18,3 +23,35 @@ async def send_telegram_message(text: str) -> None:
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
print(f"[telegram] failed to send message: {resp.status_code} {resp.text}")
|
||||
|
||||
|
||||
async def send_telegram_video(video_path: str, caption: str) -> bool:
|
||||
"""Uploads the actual rendered clip to Telegram. Returns False (without
|
||||
raising) on any failure — the caller decides what that means for the
|
||||
local file (e.g. keep it if delivery didn't succeed)."""
|
||||
if not BOT_TOKEN or not CHAT_ID:
|
||||
print(f"[telegram] not configured, skipping video: {caption}")
|
||||
return False
|
||||
|
||||
size = os.path.getsize(video_path)
|
||||
if size > MAX_VIDEO_BYTES:
|
||||
print(f"[telegram] video {video_path} is {size} bytes, over Telegram's bot-upload limit — skipping")
|
||||
return False
|
||||
|
||||
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendVideo"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
with open(video_path, "rb") as f:
|
||||
resp = await client.post(
|
||||
url,
|
||||
data={"chat_id": CHAT_ID, "caption": caption, "parse_mode": "Markdown"},
|
||||
files={"video": (os.path.basename(video_path), f, "video/mp4")},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
print(f"[telegram] failed to upload video: {exc}")
|
||||
return False
|
||||
|
||||
if resp.status_code >= 400:
|
||||
print(f"[telegram] failed to send video: {resp.status_code} {resp.text}")
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -25,7 +25,7 @@ import mediapipe as mp
|
||||
|
||||
from . import db
|
||||
from .queues import QUEUE_NAMES, REDIS_URL, SHARED_MEDIA_ROOT, VIDEO_RENDER_CONCURRENCY
|
||||
from .telegram import send_telegram_message
|
||||
from .telegram import send_telegram_message, send_telegram_video
|
||||
|
||||
FACE_MODEL_PATH = os.environ.get(
|
||||
"FACE_DETECTOR_MODEL_PATH", "/app/models/blaze_face_short_range.tflite"
|
||||
@@ -245,9 +245,24 @@ async def process_video_render(job, job_token=None):
|
||||
)
|
||||
|
||||
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!")
|
||||
print(f"[video-render] short {short_id} ready: {out_path}")
|
||||
caption = f"🎬 *{candidate['channel_name']}* için 9:16 kısa video hazır!"
|
||||
|
||||
if await db.get_setting("delete_after_telegram_send", "false") == "true":
|
||||
# Video delivery *is* the notification here — it doesn't also
|
||||
# gate on notify_render_done. Only delete the local file once
|
||||
# Telegram actually has it; a failed upload leaves it in place
|
||||
# with a plain-text heads-up instead of silently losing it.
|
||||
if await send_telegram_video(out_path, caption):
|
||||
os.remove(out_path)
|
||||
await db.clear_short_video_file(short_id)
|
||||
print(f"[video-render] short {short_id} sent to Telegram and removed from disk")
|
||||
else:
|
||||
await send_telegram_message(f"{caption}\n(video Telegram'a gönderilemedi, sunucuda kaldı)")
|
||||
print(f"[video-render] short {short_id} ready: {out_path}")
|
||||
else:
|
||||
if await db.get_setting("notify_render_done", "true") != "false":
|
||||
await send_telegram_message(caption)
|
||||
print(f"[video-render] short {short_id} ready: {out_path}")
|
||||
except Exception as exc:
|
||||
print(f"[video-render] failed for candidate {candidate_id}: {exc}")
|
||||
await db.update_short_video(short_id, status="FAILED", error_message=str(exc)[:500])
|
||||
|
||||
+2
-2
@@ -33,7 +33,7 @@ services:
|
||||
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
|
||||
SHARED_MEDIA_ROOT: /shared-media
|
||||
POLL_INTERVAL_MS: ${POLL_INTERVAL_MS:-60000}
|
||||
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-300}
|
||||
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-180}
|
||||
API_DAEMON_PORT: ${API_DAEMON_PORT:-4001}
|
||||
FORCE_LIVE_URL: ${FORCE_LIVE_URL:-}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
REDIS_URL: redis://sc_redis:6379
|
||||
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
|
||||
SHARED_MEDIA_ROOT: /shared-media
|
||||
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-300}
|
||||
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-180}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "cookie_profiles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "cookie_profiles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "RawSegmentStatus" ADD VALUE 'SENT_TO_TELEGRAM';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "raw_segments" ALTER COLUMN "file_path" DROP NOT NULL;
|
||||
@@ -11,6 +11,10 @@ enum RawSegmentStatus {
|
||||
PENDING
|
||||
PROCESSED
|
||||
DISCARDED
|
||||
// Analiz zincirine (sinyal analizi/STT/9:16) hiç girmeden ham haliyle
|
||||
// doğrudan Telegram'a gönderildi — file_path bu durumda null (dosya
|
||||
// gönderim sonrası diskten silindi).
|
||||
SENT_TO_TELEGRAM
|
||||
}
|
||||
|
||||
enum CandidateSegmentStatus {
|
||||
@@ -60,7 +64,7 @@ model RawSegment {
|
||||
id String @id @default(cuid())
|
||||
sessionId String @map("session_id")
|
||||
session StreamSession @relation(fields: [sessionId], references: [id])
|
||||
filePath String @map("file_path")
|
||||
filePath String? @map("file_path")
|
||||
duration Int
|
||||
startedAt DateTime @map("started_at")
|
||||
status RawSegmentStatus @default(PENDING)
|
||||
@@ -101,6 +105,21 @@ model ShortVideo {
|
||||
@@map("short_videos")
|
||||
}
|
||||
|
||||
// Birden fazla YouTube hesabının cookie'si — her kanal (channel.id'den
|
||||
// deterministik bir hash ile) bu havuzdaki bir profile sabitlenir, tek bir
|
||||
// hesabın çok fazla kanalı taramasından kaynaklanan hesap-seviyesi
|
||||
// bot-tespitini havuza yayarak azaltır (bkz. proxyUrlForChannel ile aynı
|
||||
// mantık, ytdlpCookies.ts).
|
||||
model CookieProfile {
|
||||
id String @id @default(cuid())
|
||||
label String
|
||||
value String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("cookie_profiles")
|
||||
}
|
||||
|
||||
model AppSetting {
|
||||
key String @id
|
||||
value String
|
||||
|
||||
Reference in New Issue
Block a user