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);
|
||||||
|
});
|
||||||
|
}
|
||||||
+11
-115
@@ -26,6 +26,13 @@ import {
|
|||||||
downloadPodcastAudio,
|
downloadPodcastAudio,
|
||||||
} from "./audio.js";
|
} from "./audio.js";
|
||||||
import { checkYtdlp, autoUpdateYtdlp } from "./ytdlp.js";
|
import { checkYtdlp, autoUpdateYtdlp } from "./ytdlp.js";
|
||||||
|
import {
|
||||||
|
initCookies,
|
||||||
|
ytCookieArgs,
|
||||||
|
ytExtraArgs,
|
||||||
|
ytCookieMethod,
|
||||||
|
setupCookieRoutes,
|
||||||
|
} from "./cookies.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -94,47 +101,8 @@ setInterval(() => {
|
|||||||
refreshServerApiKey("config poll").catch(() => {});
|
refreshServerApiKey("config poll").catch(() => {});
|
||||||
}, CONFIG_POLL_MS);
|
}, CONFIG_POLL_MS);
|
||||||
|
|
||||||
// ── YouTube cookies (bypass bot detection) ──────────────────────────────
|
// Cookies state + helpers + routes moved to ./cookies.js
|
||||||
// Priority: 1) cookies.txt file 2) --cookies-from-browser (local dev only) 3) no cookies
|
await initCookies({ dataDir: DATA_DIR, envPath });
|
||||||
// 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";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveApiKey(clientKey) {
|
function resolveApiKey(clientKey) {
|
||||||
// Client can send "USE_SERVER_KEY" or empty to use the server's stored key
|
// 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 ───────────────────────────────────────────
|
// ── Cookie management endpoints ───────────────────────────────────────────
|
||||||
|
|
||||||
// Upload cookies.txt content
|
// /api/cookies/* routes registered via setupCookieRoutes (./cookies.js)
|
||||||
app.post("/api/cookies/upload", express.text({ type: "*/*", limit: "2mb" }), async (req, res) => {
|
setupCookieRoutes(app);
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── History endpoints ─────────────────────────────────────────────────────
|
// ── History endpoints ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user