// YouTube cookies handling — bypasses YouTube's bot detection by either // using an uploaded cookies.txt file OR (local dev only) live browser // cookies via yt-dlp's --cookies-from-browser. Module owns its own // state (file path, file-exists flag, browser-name fallback). // // Priority order at runtime: // 1. cookies.txt file in DATA_DIR (preferred — stable, OS-independent) // 2. --cookies-from-browser (set via YT_COOKIES_FROM in .env; // local Mac dev only) // 3. no cookies (anonymous yt-dlp; YouTube may rate- // limit or require sign-in) import { execFile } from "child_process"; import { promisify } from "util"; import fs from "fs/promises"; import path from "path"; import express from "express"; const execFileAsync = promisify(execFile); // ── Module-private state ──────────────────────────────────────────────────── // Set by initCookies(); the cookies-file flag is also flipped by the // upload/delete routes. let ytCookiesBrowser = ""; let ytCookiesFileExists = false; let ytCookiesFilePath = null; // ── Initialization ────────────────────────────────────────────────────────── // Call once at boot. Reads YT_COOKIES_FROM out of .env (if any) and // checks whether cookies.txt is present. Idempotent. export async function initCookies({ dataDir, envPath }) { ytCookiesFilePath = path.join(dataDir, "cookies.txt"); try { const envContent = await fs.readFile(envPath, "utf-8").catch(() => ""); const cm = envContent.match(/^YT_COOKIES_FROM=(.+)$/m); if (cm) ytCookiesBrowser = cm[1].trim().replace(/^["']|["']$/g, "").toLowerCase(); } catch {} try { await fs.access(ytCookiesFilePath); ytCookiesFileExists = true; console.log(" 🍪 Found cookies.txt — will use for YouTube authentication"); } catch { ytCookiesFileExists = false; } } // ── yt-dlp arg helpers ────────────────────────────────────────────────────── // Used by the /api/process pipeline (and subscription channel discovery) // to spell out the right --cookies args based on what's available. export function ytCookieArgs() { if (ytCookiesFileExists) return ["--cookies", ytCookiesFilePath]; if (ytCookiesBrowser) return ["--cookies-from-browser", ytCookiesBrowser]; return []; } // Extra yt-dlp args for robustness — currently just rate-limit sleep // settings to avoid being flagged on batch operations. export function ytExtraArgs() { return ["--sleep-interval", "1", "--max-sleep-interval", "3"]; } // Human-readable description of which method is currently active. // Surfaced by /api/health and /api/cookies/status. export function ytCookieMethod() { if (ytCookiesFileExists) return "cookies.txt"; if (ytCookiesBrowser) return ytCookiesBrowser; return "none"; } // Lightweight read for the health check. export function getCookieFilePath() { return ytCookiesFilePath; } // ── Routes ────────────────────────────────────────────────────────────────── // Register /api/cookies/* on the given Express app. Pulls the active // state from this module so a successful upload/delete is reflected in // subsequent yt-dlp calls without re-import gymnastics. export function setupCookieRoutes(app) { // Upload cookies.txt content app.post("/api/cookies/upload", express.text({ type: "*/*", limit: "2mb" }), async (req, res) => { try { const content = req.body; if (!content || typeof content !== "string" || content.length < 20) { return res.status(400).json({ error: "Invalid cookie file content" }); } // Basic shape check — Netscape cookie format starts with a header // comment or has the tab-separated TRUE/FALSE markers. const firstLine = content.split("\n")[0].trim(); const looksValid = /^#.*cookie|^#.*http|^\./i.test(firstLine) || content.includes("\tTRUE\t") || content.includes("\tFALSE\t"); if (!looksValid) { return res.status(400).json({ error: "File doesn't look like a valid Netscape cookies.txt file. The first line should start with '# Netscape HTTP Cookie File' or '# HTTP Cookie File'." }); } await fs.writeFile(ytCookiesFilePath, content, "utf-8"); ytCookiesFileExists = true; console.log(" 🍪 cookies.txt uploaded via web UI"); res.json({ ok: true, message: "Cookies saved successfully" }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post("/api/cookies/delete", async (req, res) => { try { await fs.unlink(ytCookiesFilePath).catch(() => {}); ytCookiesFileExists = false; console.log(" 🍪 cookies.txt deleted via web UI"); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Lightweight check — fetches a known video's title to confirm cookies // unlock anonymous-restricted content. app.post("/api/cookies/test", async (req, res) => { try { const cookieArgs = ytCookieArgs(); if (cookieArgs.length === 0) { return res.json({ ok: false, error: "No cookies configured" }); } const { stdout } = await execFileAsync("yt-dlp", [ "--print", "%(title)s", "--no-download", ...cookieArgs, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", ], { timeout: 20000 }); const title = stdout.trim(); if (title && title.length > 0) { res.json({ ok: true, message: `Cookies working! Fetched: "${title}"` }); } else { res.json({ ok: false, error: "yt-dlp returned empty response" }); } } catch (err) { const msg = (err.stderr || "") + " " + (err.message || ""); const isAuth = /Sign in|bot|403/i.test(msg); res.json({ ok: false, error: isAuth ? "Cookies are expired or invalid — YouTube still requires sign-in" : msg.slice(0, 300) }); } }); app.get("/api/cookies/status", async (req, res) => { const info = { method: ytCookieMethod() }; if (ytCookiesFileExists) { try { const stat = await fs.stat(ytCookiesFilePath); info.fileAgeDays = Math.floor((Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24)); info.fileExpiring = info.fileAgeDays > 12; info.fileSize = stat.size; } catch {} } res.json(info); }); }