feat: 9:16 render'ı isteğe bağlı yap + Telegram'a video gönderip sunucudan sil (Faz 5)
- auto_render_enabled (varsayılan açık): kapatılınca transkript sonrası otomatik render tetiklenmez, aday TRANSCRIBED'de bekler — panelin zaten var olan "9:16 Render Et" butonuyla elle tetiklenebilir. - delete_after_telegram_send (varsayılan KAPALI — geri alınamaz bir davranış, bilinçli açılmalı): açıksa render biten video Telegram'a gerçek dosya olarak gönderilir (yeni send_telegram_video, sendVideo multipart upload, ~50MB bot-upload limiti önceden kontrol ediliyor); gönderim başarılıysa dosya diskten silinip ShortVideo.file_path NULL'a çekiliyor (DB kaydı/transkript kalıcı kalıyor), başarısızsa dosya sunucuda kalıp düz metin bildirimi gidiyor. Kanal sayısı arttıkça VPS disk doluluğunu önlemek için. - Panel: READY + file_path=null durumunda video/indir yerine "Telegram'a gönderildi, sunucuda saklanmıyor" notu (channels/[id], segments). Şema değişikliği yok — ShortVideo.filePath zaten nullable. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -155,6 +155,26 @@ export async function updateSttEnabled(formData: FormData) {
|
|||||||
revalidatePath("/settings");
|
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 updateNotificationPrefs(formData: FormData) {
|
export async function updateNotificationPrefs(formData: FormData) {
|
||||||
const streamStart = formData.get("notifyStreamStart") === "true";
|
const streamStart = formData.get("notifyStreamStart") === "true";
|
||||||
const renderDone = formData.get("notifyRenderDone") === "true";
|
const renderDone = formData.get("notifyRenderDone") === "true";
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
|||||||
{c.short && (
|
{c.short && (
|
||||||
<div className="mt-2 flex flex-col items-start gap-2">
|
<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>
|
<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">
|
<video controls preload="metadata" className="w-[220px] rounded-md border border-border">
|
||||||
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
|
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
|
||||||
@@ -277,6 +277,11 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
|||||||
</Button>
|
</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.status === "FAILED" && (
|
||||||
<>
|
<>
|
||||||
{c.short.errorMessage && (
|
{c.short.errorMessage && (
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default async function SegmentsPage() {
|
|||||||
{c.short && (
|
{c.short && (
|
||||||
<div className="mt-2 flex flex-col items-start gap-2">
|
<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>
|
<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">
|
<video controls preload="metadata" className="w-[220px] rounded-md border border-border">
|
||||||
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
|
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
|
||||||
@@ -99,6 +99,11 @@ export default async function SegmentsPage() {
|
|||||||
</Button>
|
</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.status === "FAILED" && (
|
||||||
<>
|
<>
|
||||||
{c.short.errorMessage && (
|
{c.short.errorMessage && (
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { updateYtdlpCookies, updateSystemSettings, updateNotificationPrefs, updateSttEnabled } from "../actions";
|
import {
|
||||||
|
updateYtdlpCookies,
|
||||||
|
updateSystemSettings,
|
||||||
|
updateNotificationPrefs,
|
||||||
|
updateSttEnabled,
|
||||||
|
updateAutoRenderEnabled,
|
||||||
|
updateDeleteAfterTelegramSend,
|
||||||
|
} from "../actions";
|
||||||
import { formatTr } from "../../lib/formatDate";
|
import { formatTr } from "../../lib/formatDate";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -14,15 +21,18 @@ export const dynamic = "force-dynamic";
|
|||||||
const STALE_COOKIE_HOURS = 4;
|
const STALE_COOKIE_HOURS = 4;
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
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] =
|
||||||
prisma.appSetting.findUnique({ where: { key: "ytdlp_cookies" } }),
|
await Promise.all([
|
||||||
prisma.appSetting.findUnique({ where: { key: "poll_interval_ms" } }),
|
prisma.appSetting.findUnique({ where: { key: "ytdlp_cookies" } }),
|
||||||
prisma.appSetting.findUnique({ where: { key: "raw_segment_ttl_hours" } }),
|
prisma.appSetting.findUnique({ where: { key: "poll_interval_ms" } }),
|
||||||
prisma.appSetting.findUnique({ where: { key: "notify_stream_start" } }),
|
prisma.appSetting.findUnique({ where: { key: "raw_segment_ttl_hours" } }),
|
||||||
prisma.appSetting.findUnique({ where: { key: "notify_render_done" } }),
|
prisma.appSetting.findUnique({ where: { key: "notify_stream_start" } }),
|
||||||
prisma.appSetting.findUnique({ where: { key: "notify_poll_error" } }),
|
prisma.appSetting.findUnique({ where: { key: "notify_render_done" } }),
|
||||||
prisma.appSetting.findUnique({ where: { key: "stt_enabled" } }),
|
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" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
const cookieAgeHours = setting ? (Date.now() - setting.updatedAt.getTime()) / 3_600_000 : null;
|
const cookieAgeHours = setting ? (Date.now() - setting.updatedAt.getTime()) / 3_600_000 : null;
|
||||||
const cookieStale = cookieAgeHours !== null && cookieAgeHours > STALE_COOKIE_HOURS;
|
const cookieStale = cookieAgeHours !== null && cookieAgeHours > STALE_COOKIE_HOURS;
|
||||||
@@ -32,6 +42,8 @@ export default async function SettingsPage() {
|
|||||||
const notifyRenderDone = notifyRender?.value !== "false";
|
const notifyRenderDone = notifyRender?.value !== "false";
|
||||||
const notifyPollError = notifyError?.value !== "false";
|
const notifyPollError = notifyError?.value !== "false";
|
||||||
const sttEnabled = sttSetting?.value !== "false";
|
const sttEnabled = sttSetting?.value !== "false";
|
||||||
|
const autoRenderEnabled = autoRenderSetting?.value !== "false";
|
||||||
|
const deleteAfterTelegramSend = deleteAfterSendSetting?.value === "true";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -122,6 +134,50 @@ export default async function SettingsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</Card>
|
</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">Depolama</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Bildirimler</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Bildirimler</CardTitle>
|
||||||
|
|||||||
@@ -158,3 +158,12 @@ async def update_short_video(
|
|||||||
file_path,
|
file_path,
|
||||||
error_message,
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -72,7 +72,10 @@ async def process_stt_scoring(job, job_token=None):
|
|||||||
f"({candidate['start_sec']}-{candidate['end_sec']}s):\n_{preview}_"
|
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")
|
print(f"[stt-scoring] candidate {candidate_id} transcribed")
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import httpx
|
|||||||
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||||
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
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:
|
async def send_telegram_message(text: str) -> None:
|
||||||
if not BOT_TOKEN or not CHAT_ID:
|
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:
|
if resp.status_code >= 400:
|
||||||
print(f"[telegram] failed to send message: {resp.status_code} {resp.text}")
|
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 . import db
|
||||||
from .queues import QUEUE_NAMES, REDIS_URL, SHARED_MEDIA_ROOT, VIDEO_RENDER_CONCURRENCY
|
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_MODEL_PATH = os.environ.get(
|
||||||
"FACE_DETECTOR_MODEL_PATH", "/app/models/blaze_face_short_range.tflite"
|
"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)
|
await db.update_short_video(short_id, status="READY", file_path=out_path)
|
||||||
if await db.get_setting("notify_render_done", "true") != "false":
|
caption = 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}")
|
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:
|
except Exception as exc:
|
||||||
print(f"[video-render] failed for candidate {candidate_id}: {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])
|
await db.update_short_video(short_id, status="FAILED", error_message=str(exc)[:500])
|
||||||
|
|||||||
Reference in New Issue
Block a user