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).
This commit is contained in:
@@ -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 <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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user