Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fef7460cf | ||
|
|
783125b8f2 |
@@ -45,3 +45,8 @@ YTDLP_POT_PROVIDER_URL=
|
|||||||
# ISP proxy product specifically (e.g. Oxylabs "ISP Proxies"), not a plain
|
# ISP proxy product specifically (e.g. Oxylabs "ISP Proxies"), not a plain
|
||||||
# datacenter proxy — the latter has the same problem as the server itself.
|
# datacenter proxy — the latter has the same problem as the server itself.
|
||||||
PROXY_URL=
|
PROXY_URL=
|
||||||
|
|
||||||
|
# Processed raw recordings (raw_segments) older than this get auto-deleted
|
||||||
|
# (DB row + file) — PRD's auto-purge lifecycle. Only segments already fully
|
||||||
|
# analyzed (no candidate still waiting on STT) are eligible.
|
||||||
|
RAW_SEGMENT_TTL_HOURS=24
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { unlink } from "node:fs/promises";
|
||||||
|
import { prisma } from "@streamclipper/db";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // hourly
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PRD's auto-purge lifecycle: processed raw recordings get deleted 24h after
|
||||||
|
* capture. Only segments that are done being analyzed (PROCESSED/DISCARDED)
|
||||||
|
* and have no candidate still waiting on STT are eligible — deleting a raw
|
||||||
|
* file mid-analysis or mid-transcription would break the pipeline for it.
|
||||||
|
*/
|
||||||
|
async function cleanupOnce(): Promise<void> {
|
||||||
|
const cutoff = new Date(Date.now() - env.rawSegmentTtlHours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const staleSegments = await prisma.rawSegment.findMany({
|
||||||
|
where: {
|
||||||
|
createdAt: { lt: cutoff },
|
||||||
|
status: { in: ["PROCESSED", "DISCARDED"] },
|
||||||
|
candidates: { none: { status: "PENDING_STT" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (staleSegments.length > 0) {
|
||||||
|
console.log(`[cleanup] removed ${staleSegments.length} raw segment(s) older than ${env.rawSegmentTtlHours}h`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startCleanupLoop(): NodeJS.Timeout {
|
||||||
|
console.log(`[cleanup] checking every ${CHECK_INTERVAL_MS}ms, TTL ${env.rawSegmentTtlHours}h`);
|
||||||
|
cleanupOnce().catch((err) => console.error("[cleanup] initial run failed:", err));
|
||||||
|
return setInterval(() => {
|
||||||
|
cleanupOnce().catch((err) => console.error("[cleanup] run failed:", err));
|
||||||
|
}, CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
@@ -10,4 +10,5 @@ export const env = {
|
|||||||
ytdlpPotProviderUrl: process.env.YTDLP_POT_PROVIDER_URL ?? "",
|
ytdlpPotProviderUrl: process.env.YTDLP_POT_PROVIDER_URL ?? "",
|
||||||
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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { startPollingLoop } from "./youtubePolling";
|
|||||||
import { startStreamIngestWorker } from "./capture/streamIngest";
|
import { startStreamIngestWorker } from "./capture/streamIngest";
|
||||||
import { streamIngestQueue } from "./queues";
|
import { streamIngestQueue } from "./queues";
|
||||||
import { startServer } from "./server";
|
import { startServer } from "./server";
|
||||||
|
import { startCleanupLoop } from "./cleanup";
|
||||||
|
|
||||||
async function forceLiveBypass(youtubeUrl: string) {
|
async function forceLiveBypass(youtubeUrl: string) {
|
||||||
console.log(`[index] FORCE_LIVE_URL set — bypassing polling and capturing directly: ${youtubeUrl}`);
|
console.log(`[index] FORCE_LIVE_URL set — bypassing polling and capturing directly: ${youtubeUrl}`);
|
||||||
@@ -54,6 +55,7 @@ async function main() {
|
|||||||
await closeOrphanedSessions();
|
await closeOrphanedSessions();
|
||||||
startServer();
|
startServer();
|
||||||
startStreamIngestWorker();
|
startStreamIngestWorker();
|
||||||
|
startCleanupLoop();
|
||||||
|
|
||||||
if (env.forceLiveUrl) {
|
if (env.forceLiveUrl) {
|
||||||
await forceLiveBypass(env.forceLiveUrl);
|
await forceLiveBypass(env.forceLiveUrl);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<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="/settings">Ayarlar</Link>
|
<Link href="/settings">Ayarlar</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" }}>
|
||||||
<span className="mono">{session.username}</span>
|
<span className="mono">{session.username}</span>
|
||||||
<form action={logout}>
|
<form action={logout}>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { prisma } from "@streamclipper/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { getSession } from "../../lib/auth";
|
||||||
|
|
||||||
|
export async function addUser(formData: FormData) {
|
||||||
|
const username = String(formData.get("username") ?? "").trim();
|
||||||
|
const password = String(formData.get("password") ?? "");
|
||||||
|
|
||||||
|
if (!username || password.length < 8) {
|
||||||
|
throw new Error("Kullanıcı adı gerekli, şifre en az 8 karakter olmalı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
await prisma.user.create({ data: { username, passwordHash } });
|
||||||
|
|
||||||
|
revalidatePath("/users");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(userId: string) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (session?.sub === userId) {
|
||||||
|
throw new Error("Kendi hesabını silemezsin.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalUsers = await prisma.user.count();
|
||||||
|
if (totalUsers <= 1) {
|
||||||
|
throw new Error("Son kalan kullanıcı silinemez.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.delete({ where: { id: userId } });
|
||||||
|
revalidatePath("/users");
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { prisma } from "@streamclipper/db";
|
||||||
|
import { getSession } from "../../lib/auth";
|
||||||
|
import { addUser, deleteUser } from "./actions";
|
||||||
|
import { DeleteButton } from "../components/DeleteButton";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const fieldStyle = {
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "0.5rem 0.7rem",
|
||||||
|
color: "var(--text)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function UsersPage() {
|
||||||
|
const [users, session] = await Promise.all([
|
||||||
|
prisma.user.findMany({ orderBy: { createdAt: "asc" } }),
|
||||||
|
getSession(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Kullanıcılar</h1>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action={addUser}
|
||||||
|
className="card add-channel-form"
|
||||||
|
style={{ marginBottom: "1.5rem" }}
|
||||||
|
>
|
||||||
|
<input name="username" placeholder="Kullanıcı adı" required style={fieldStyle} />
|
||||||
|
<input name="password" type="password" placeholder="Şifre (en az 8 karakter)" required minLength={8} style={fieldStyle} />
|
||||||
|
<button type="submit">Kullanıcı Ekle</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kullanıcı Adı</th>
|
||||||
|
<th>Oluşturulma</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>
|
||||||
|
{u.username}
|
||||||
|
{u.id === session?.sub && <span className="badge muted" style={{ marginLeft: "0.5rem" }}>sen</span>}
|
||||||
|
</td>
|
||||||
|
<td className="mono">{u.createdAt.toLocaleString("tr-TR")}</td>
|
||||||
|
<td>
|
||||||
|
{u.id !== session?.sub && users.length > 1 && (
|
||||||
|
<form action={deleteUser.bind(null, u.id)}>
|
||||||
|
<DeleteButton confirmText={`${u.username} kullanıcısını silmek istediğine emin misin?`} />
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ services:
|
|||||||
YTDLP_POT_PROVIDER_URL: http://sc_pot_provider:4416
|
YTDLP_POT_PROVIDER_URL: http://sc_pot_provider:4416
|
||||||
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}
|
||||||
volumes:
|
volumes:
|
||||||
- shared-media:/shared-media
|
- shared-media:/shared-media
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
Reference in New Issue
Block a user