Files
Keysat 2c655dc9ee Module split: extract cookies state + helpers + routes to server/cookies.js
• initCookies({ dataDir, envPath })  — boot-time setup; reads .env's
                                         YT_COOKIES_FROM and probes for
                                         cookies.txt
  • ytCookieArgs() / ytExtraArgs()     — yt-dlp arg builders
  • ytCookieMethod()                   — human-readable active method
  • setupCookieRoutes(app)             — registers the four /api/cookies/*
                                         routes (upload / delete / test /
                                         status)

Module owns its private state (browser-name, file-exists, file-path).
Upload and delete routes flip the file-exists flag inside the same
module, so subsequent yt-dlp calls reflect the change immediately
without callers re-reading.

server/index.js: 2614 → 2510 lines.

Smoke tested: server boots; /api/license-status and /api/health
respond. /api/cookies/status returns the existing license-gated 402
for unlicensed callers (unchanged behavior).
2026-05-08 16:57:03 -05:00

154 lines
6.6 KiB
JavaScript

// 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 <name> (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);
});
}