diff --git a/server/cookies.js b/server/cookies.js new file mode 100644 index 0000000..6e702ff --- /dev/null +++ b/server/cookies.js @@ -0,0 +1,153 @@ +// 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); + }); +} diff --git a/server/index.js b/server/index.js index 89e1559..02272af 100644 --- a/server/index.js +++ b/server/index.js @@ -26,6 +26,13 @@ import { downloadPodcastAudio, } from "./audio.js"; import { checkYtdlp, autoUpdateYtdlp } from "./ytdlp.js"; +import { + initCookies, + ytCookieArgs, + ytExtraArgs, + ytCookieMethod, + setupCookieRoutes, +} from "./cookies.js"; const execFileAsync = promisify(execFile); const app = express(); @@ -94,47 +101,8 @@ setInterval(() => { refreshServerApiKey("config poll").catch(() => {}); }, CONFIG_POLL_MS); -// ── YouTube cookies (bypass bot detection) ────────────────────────────── -// Priority: 1) cookies.txt file 2) --cookies-from-browser (local dev only) 3) no cookies -// On StartOS: cookies.txt lives in DATA_DIR -// On local dev: cookies.txt in project root, or --cookies-from-browser via YT_COOKIES_FROM -let ytCookiesBrowser = ""; -const ytCookiesFilePath = path.join(DATA_DIR, "cookies.txt"); -let ytCookiesFileExists = false; -try { - const envContent2 = await fs.readFile(envPath, "utf-8").catch(() => ""); - const cm = envContent2.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; -} - -function ytCookieArgs() { - // Prefer cookies.txt file (stable, doesn't depend on browser session) - if (ytCookiesFileExists) return ["--cookies", ytCookiesFilePath]; - // Fall back to live browser cookies (local dev only, not available on StartOS) - if (ytCookiesBrowser) return ["--cookies-from-browser", ytCookiesBrowser]; - return []; -} - -// Extra yt-dlp args for robustness (rate limiting) -function ytExtraArgs() { - const args = []; - // Sleep between requests to avoid rate limiting during batch operations - args.push("--sleep-interval", "1", "--max-sleep-interval", "3"); - return args; -} - -function ytCookieMethod() { - if (ytCookiesFileExists) return "cookies.txt"; - if (ytCookiesBrowser) return ytCookiesBrowser; - return "none"; -} +// Cookies state + helpers + routes moved to ./cookies.js +await initCookies({ dataDir: DATA_DIR, envPath }); function resolveApiKey(clientKey) { // Client can send "USE_SERVER_KEY" or empty to use the server's stored key @@ -403,80 +371,8 @@ app.post("/api/update-ytdlp", async (req, res) => { // ── Cookie management endpoints ─────────────────────────────────────────── -// 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 validation: should look like a Netscape cookie file - 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 }); - } -}); - -// Delete cookies.txt -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 }); - } -}); - -// Test if cookies work by trying a lightweight yt-dlp operation -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" }); - } - // Try to fetch title of a known video — lightweight, no download - const { stdout } = await execFileAsync("yt-dlp", [ - "--print", "%(title)s", - "--no-download", - ...cookieArgs, - "https://www.youtube.com/watch?v=dQw4w9WgXcQ", // well-known test video - ], { 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) }); - } -}); - -// Get cookie status -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); -}); +// /api/cookies/* routes registered via setupCookieRoutes (./cookies.js) +setupCookieRoutes(app); // ── History endpoints ─────────────────────────────────────────────────────