feat: birden fazla YouTube hesabı arasında cookie'yi dağıt (Faz 6)
Proxy IP'lerini havuza dağıttığımız aynı sorun cookie/hesap seviyesinde de vardı: tek bir Google hesabı 7-8 kanalı taramaya çalışınca YouTube hesabın kendisini şüpheli bulup çoğu/tüm kanalda "Sign in to confirm you're not a bot" veriyordu — proxy IP'den bağımsız bir sinyal, IP dağıtımı bunu çözemez. Yeni CookieProfile tablosu: her kanal, DB id'sinden aynı deterministik hash ile (proxyUrlForChannel'daki gibi) havuzdaki hesaplardan birine sabitlenir. Her profil kendi ayrı geçici dosyasına yazılıyor (/tmp/yt-cookies-<id>.txt) — tek paylaşılan dosya, farklı profil kullanan kanallar eşzamanlı çalışınca birbirinin cookie'sini ezerdi. Hiç profil eklenmemişse eski tekli ytdlp_cookies ayarına geri düşüyor (geriye dönük uyumlu, hemen kırılmaz). Ayarlar sayfasında yeni "Cookie Hesapları" kartı: hesap ekle/sil/yenile, her biri için ayrı bayatlık rozeti. Eski tekli cookie kartı "tekli/eski" etiketiyle fallback olarak duruyor. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,8 @@ import { writeFileSync } from "node:fs";
|
|||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
|
|
||||||
const COOKIES_PATH = "/tmp/yt-cookies.txt";
|
const LEGACY_COOKIES_PATH = "/tmp/yt-cookies.txt";
|
||||||
const COOKIES_SETTING_KEY = "ytdlp_cookies";
|
const LEGACY_COOKIES_SETTING_KEY = "ytdlp_cookies";
|
||||||
|
|
||||||
// Oxylabs' static ISP pool: one dedicated (non-rotating) IP per port,
|
// Oxylabs' static ISP pool: one dedicated (non-rotating) IP per port,
|
||||||
// confirmed against the dashboard's Proxy list (isp.oxylabs.io:8001..8010 ->
|
// confirmed against the dashboard's Proxy list (isp.oxylabs.io:8001..8010 ->
|
||||||
@@ -30,6 +30,43 @@ function proxyUrlForChannel(baseProxyUrl: string, channelKey: string): string {
|
|||||||
return `${prefix}${basePort + offset}`;
|
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
|
* YouTube's anti-bot layer against yt-dlp has three parts, all needed
|
||||||
* together: a "Sign in to confirm you're not a bot" wall (avoided with an
|
* together: a "Sign in to confirm you're not a bot" wall (avoided with an
|
||||||
@@ -52,10 +89,9 @@ export async function ytdlpAntiBotArgs(
|
|||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
||||||
const setting = await prisma.appSetting.findUnique({ where: { key: COOKIES_SETTING_KEY } });
|
const cookiesPath = await cookiesFilePathForChannel(channelKey);
|
||||||
if (setting?.value) {
|
if (cookiesPath) {
|
||||||
writeFileSync(COOKIES_PATH, setting.value, { mode: 0o600 });
|
args.push("--cookies", cookiesPath);
|
||||||
args.push("--cookies", COOKIES_PATH);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (env.ytdlpPotProviderUrl) {
|
if (env.ytdlpPotProviderUrl) {
|
||||||
|
|||||||
@@ -214,3 +214,30 @@ export async function updateYtdlpCookies(formData: FormData) {
|
|||||||
|
|
||||||
revalidatePath("/settings");
|
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,6 +6,9 @@ import {
|
|||||||
updateSttEnabled,
|
updateSttEnabled,
|
||||||
updateAutoRenderEnabled,
|
updateAutoRenderEnabled,
|
||||||
updateDeleteAfterTelegramSend,
|
updateDeleteAfterTelegramSend,
|
||||||
|
addCookieProfile,
|
||||||
|
updateCookieProfile,
|
||||||
|
deleteCookieProfile,
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
import { formatTr } from "../../lib/formatDate";
|
import { formatTr } from "../../lib/formatDate";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,26 +16,39 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { SettingSwitch } from "../components/SettingSwitch";
|
import { SettingSwitch } from "../components/SettingSwitch";
|
||||||
|
import { ConfirmButton } from "../components/ConfirmButton";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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, autoRenderSetting, deleteAfterSendSetting] =
|
const [
|
||||||
await Promise.all([
|
setting,
|
||||||
prisma.appSetting.findUnique({ where: { key: "ytdlp_cookies" } }),
|
pollSetting,
|
||||||
prisma.appSetting.findUnique({ where: { key: "poll_interval_ms" } }),
|
ttlSetting,
|
||||||
prisma.appSetting.findUnique({ where: { key: "raw_segment_ttl_hours" } }),
|
notifyStart,
|
||||||
prisma.appSetting.findUnique({ where: { key: "notify_stream_start" } }),
|
notifyRender,
|
||||||
prisma.appSetting.findUnique({ where: { key: "notify_render_done" } }),
|
notifyError,
|
||||||
prisma.appSetting.findUnique({ where: { key: "notify_poll_error" } }),
|
sttSetting,
|
||||||
prisma.appSetting.findUnique({ where: { key: "stt_enabled" } }),
|
autoRenderSetting,
|
||||||
prisma.appSetting.findUnique({ where: { key: "auto_render_enabled" } }),
|
deleteAfterSendSetting,
|
||||||
prisma.appSetting.findUnique({ where: { key: "delete_after_telegram_send" } }),
|
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" } }),
|
||||||
|
prisma.appSetting.findUnique({ where: { key: "notify_stream_start" } }),
|
||||||
|
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.cookieProfile.findMany({ orderBy: { createdAt: "asc" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
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;
|
||||||
@@ -49,35 +65,120 @@ export default async function SettingsPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Ayarlar</h1>
|
<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>
|
<Card>
|
||||||
<CardHeader className="flex-row items-center justify-between">
|
<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">
|
<span className="font-mono-num text-xs text-muted-foreground">
|
||||||
{setting ? `son güncelleme: ${formatTr(setting.updatedAt)}` : "hiç ayarlanmadı"}
|
{setting ? `son güncelleme: ${formatTr(setting.updatedAt)}` : "hiç ayarlanmadı"}
|
||||||
</span>
|
</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="flex flex-col gap-3">
|
||||||
{cookieStale && (
|
{cookieStale && cookieProfiles.length === 0 && (
|
||||||
<Badge variant="warn" className="w-fit">
|
<Badge variant="warn" className="w-fit">
|
||||||
⚠️ {Math.round(cookieAgeHours!)} saattir güncellenmedi — büyük/popüler kanallarda bot-check hatası
|
⚠️ {Math.round(cookieAgeHours!)} saattir güncellenmedi — büyük/popüler kanallarda bot-check hatası
|
||||||
görülebilir
|
görülebilir
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
YouTube canlı yayın kontrolü ve kayıt için kullanılıyor. Google, oturum çerezlerinin bir kısmını birkaç
|
{cookieProfiles.length > 0
|
||||||
saatte bir yeniliyor — burada eskidiğini fark edersen (kanal detay sayfasında "son poll hatası"
|
? "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."
|
||||||
olarak görürsün) taze bir cookies.txt ile güncelle. Ana hesabın yerine ayrı, önemsiz bir Google hesabı
|
: "Yukarıda hiç hesap eklenmediği sürece tüm kanallar bu tek cookie'yi paylaşır."}
|
||||||
kullanman önerilir.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<form action={updateYtdlpCookies} className="flex flex-col gap-3">
|
<form action={updateYtdlpCookies} className="flex flex-col gap-3">
|
||||||
<Textarea
|
<Textarea
|
||||||
name="cookies"
|
name="cookies"
|
||||||
required
|
required
|
||||||
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
|
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
|
||||||
rows={10}
|
rows={8}
|
||||||
className="font-mono-num text-xs"
|
className="font-mono-num text-xs"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" className="self-start">
|
<Button type="submit" variant="outline" className="self-start">
|
||||||
Kaydet
|
Kaydet
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
@@ -101,6 +101,21 @@ model ShortVideo {
|
|||||||
@@map("short_videos")
|
@@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 {
|
model AppSetting {
|
||||||
key String @id
|
key String @id
|
||||||
value String
|
value String
|
||||||
|
|||||||
Reference in New Issue
Block a user