openvibers/openvibe.live Commits

Media: fix the yt-dlp JS runtime path, validate + live-test cookies

OP OpenVibers committed 19 Aug 2026 · 2af9adb09d · 3 files, +171 −4

Two separate reasons media requests were failing.

1. yt-dlp needs a JavaScript runtime for YouTube extraction; without one it
   takes a deprecated path that drops formats and warns on every call.
   NODE_CANDIDATES listed only /usr/local/bin/node and two nvm paths, but the
   only node on this host is /usr/bin/node — so getNodePath() returned null and
   --js-runtimes was silently never passed. Try process.execPath first (the node
   already running this server, so it exists by definition) and add /usr/bin/node.

2. The cookie save endpoint accepted any string over 10 characters, wrote it, and
   replied "Cookies saved". A jar that could never work looked identical to a
   good one. The jar in production holds only third-party __Secure-3P* cookies
   with none of the first-party session set (SID/HSID/SSID/APISID/SAPISID/
   __Secure-1PSID) — an incognito or partial export. It parses fine and is not
   signed in, which is exactly why every request still hit the bot check.

   Saving now inspects the jar (format, youtube domains, first- vs third-party
   session cookies, expiry) AND runs a real extraction before reporting success.
   A jar that fails the live test is rolled back to the previous one, since a
   save that breaks extraction is worse than no save. force:true overrides both.
   Adds POST /media-tools/cookies/check to re-test whatever is currently saved.

Note: no player_client (tv/android/ios/web_embedded) bypasses the check from this
host, so genuinely signed-in cookies are required — the code can only make that
diagnosable, which is what this does.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Xw6Sf5zzSALw8m93XNu5JE

Changes

ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 7564d282..8408928f 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -1019,18 +1019,86 @@ router.get('/media-tools/status', async (req, res) => {
});
// PUT /api/admin/media-tools/cookies — Upload/paste cookies.txt content
--router.put('/media-tools/cookies', (req, res) => {
++//
++// Validates and then actually USES the cookies before reporting success. This used to
++// accept any string over 10 characters and reply "Cookies saved", so a jar that could
++// never work — e.g. one holding only third-party __Secure-3P* cookies — looked exactly
++// like a good one while every request kept failing YouTube's bot check.
++router.put('/media-tools/cookies', async (req, res) => {
try {
-- const { cookies } = req.body;
++ const { cookies, force } = req.body;
if (!cookies || typeof cookies !== 'string' || cookies.trim().length < 10) {
return res.status(400).json({ error: 'Cookies content is required (Netscape cookies.txt format)' });
}
++
++ const { inspectCookies } = require('../media/cookie-inspector');
++ const check = inspectCookies(cookies);
++
++ // Refuse a jar we can already tell is unusable, unless explicitly forced.
++ if (!check.ok && !force) {
++ return res.status(422).json({
++ error: check.errors[0] || 'These cookies will not work',
++ check,
++ hint: 'Export cookies.txt from a browser tab where you are SIGNED IN to YouTube (not incognito). Send force:true to save anyway.',
++ });
++ }
++
const cookiesPath = downloader.getCookiesPath();
const dir = path.dirname(cookiesPath);
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
++ // Keep the old jar so a failed live test can be rolled back rather than leaving
++ // the service worse off than before the save.
++ let previous = null;
++ try { previous = fs.readFileSync(cookiesPath, 'utf8'); } catch { /* none yet */ }
fs.writeFileSync(cookiesPath, cookies.trim() + '\n', 'utf8');
-- console.log(`[Admin] yt-dlp cookies updated by ${req.user.username} (${cookies.length} bytes)`);
-- res.json({ message: 'Cookies saved', size: cookies.length });
++
++ // Prove they work against a real video rather than trusting the shape.
++ let test = { ran: false };
++ try {
++ const url = String(req.body.test_url || '').trim() || 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
++ const info = await downloader.getInfo(url);
++ test = { ran: true, ok: true, title: info?.title || null, duration: info?.duration || null, url };
++ } catch (e) {
++ test = { ran: true, ok: false, error: String(e.message || e).slice(0, 400) };
++ }
++
++ if (test.ran && !test.ok && !force) {
++ // Put the previous jar back — a save that breaks extraction is worse than no save.
++ try {
++ if (previous != null) fs.writeFileSync(cookiesPath, previous, 'utf8');
++ else fs.unlinkSync(cookiesPath);
++ } catch { /* */ }
++ return res.status(422).json({
++ error: 'Cookies saved cleanly but a live extraction still failed — they are not being accepted by YouTube.',
++ check, test, restored: previous != null,
++ hint: 'Re-export while signed in. Send force:true to keep them anyway.',
++ });
++ }
++
++ console.log(`[Admin] yt-dlp cookies updated by ${req.user.username} (${cookies.length} bytes, live test ${test.ok ? 'PASSED' : 'failed'})`);
++ res.json({ message: 'Cookies saved', size: cookies.length, check, test });
++ } catch (err) {
++ res.status(500).json({ error: err.message });
++ }
++});
++
++// POST /api/admin/media-tools/cookies/check — validate + live-test the CURRENT jar
++router.post('/media-tools/cookies/check', async (req, res) => {
++ try {
++ const { inspectCookies } = require('../media/cookie-inspector');
++ const cookiesPath = downloader.getCookiesPath();
++ let content = '';
++ try { content = fs.readFileSync(cookiesPath, 'utf8'); } catch { /* none */ }
++ const check = content ? inspectCookies(content) : { ok: false, errors: ['No cookies file saved'], warnings: [], cookieCount: 0 };
++ let test = { ran: false };
++ try {
++ const url = String(req.body?.test_url || '').trim() || 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
++ const info = await downloader.getInfo(url);
++ test = { ran: true, ok: true, title: info?.title || null, duration: info?.duration || null, url };
++ } catch (e) {
++ test = { ran: true, ok: false, error: String(e.message || e).slice(0, 400) };
++ }
++ res.json({ check, test });
} catch (err) {
res.status(500).json({ error: err.message });
}
ddiff --git a/server/media/cookie-inspector.js b/server/media/cookie-inspector.js
nnew file mode 100644
iindex 00000000..7b501f8d
---- /dev/null
++++ b/server/media/cookie-inspector.js
@@@ -0,0 +1,91 @@
++
++/**
++ * Inspect a Netscape cookies.txt for the things that actually decide whether YouTube
++ * will serve us video, and report them individually.
++ *
++ * The save endpoint used to accept any string over 10 characters and report success, so
++ * a cookie file that could never work looked identical to a good one — which is how a
++ * jar holding only third-party cookies sat in place while every request failed the bot
++ * check. These are the checks that distinguish the two.
++ *
++ * YouTube's first-party session lives in SID/HSID/SSID/APISID/SAPISID (and the
++ * __Secure-1P* variants). A jar with only the __Secure-3P* set is a third-party/partial
++ * export: it looks plausible, parses fine, and is NOT signed in.
++ */
++const YT_FIRST_PARTY = ['SID', 'HSID', 'SSID', 'APISID', 'SAPISID', '__Secure-1PSID', '__Secure-1PAPISID'];
++const YT_THIRD_PARTY = ['__Secure-3PSID', '__Secure-3PAPISID', '__Secure-3PSIDTS'];
++
++function inspectCookies(text) {
++ const raw = String(text || '');
++ const lines = raw.split('\n');
++ const rows = [];
++ let malformed = 0;
++
++ for (const line of lines) {
++ const t = line.trim();
++ if (!t || t.startsWith('#')) continue;
++ // domain, includeSubdomains, path, secure, expiry, name, value
++ const parts = line.split('\t');
++ if (parts.length < 7) { malformed++; continue; }
++ rows.push({
++ domain: parts[0].trim(),
++ expiry: parseInt(parts[4], 10) || 0,
++ name: parts[5].trim(),
++ });
++ }
++
++ const domains = [...new Set(rows.map(r => r.domain))];
++ const yt = rows.filter(r => /youtube\.com$|^\.?youtube\.com$/i.test(r.domain) || /google\.com$/i.test(r.domain));
++ const names = new Set(yt.map(r => r.name));
++
++ const firstParty = YT_FIRST_PARTY.filter(n => names.has(n));
++ const thirdParty = YT_THIRD_PARTY.filter(n => names.has(n));
++
++ const now = Math.floor(Date.now() / 1000);
++ // expiry 0 = session cookie; those are fine, they just do not carry an expiry.
++ const dated = yt.filter(r => r.expiry > 0);
++ const expired = dated.filter(r => r.expiry < now);
++ const soonest = dated.length ? Math.min(...dated.map(r => r.expiry)) : 0;
++
++ const errors = [];
++ const warnings = [];
++
++ if (!rows.length) {
++ errors.push('No cookie lines found. This must be Netscape cookies.txt format — tab-separated, one cookie per line.');
++ }
++ if (malformed) {
++ warnings.push(`${malformed} line(s) were not tab-separated and were ignored. Some editors and copy/paste convert tabs to spaces — export the file rather than pasting from a viewer.`);
++ }
++ if (rows.length && !yt.length) {
++ errors.push('No youtube.com or google.com cookies present.');
++ }
++ if (yt.length && !firstParty.length) {
++ errors.push(
++ 'Not signed in: no first-party session cookies (' + YT_FIRST_PARTY.slice(0, 5).join(', ') + ').' +
++ (thirdParty.length
++ ? ` Only third-party cookies were found (${thirdParty.join(', ')}), which is what an export from an incognito window or a partial/consent-only session looks like. YouTube will still demand a sign-in check.`
++ : '')
++ );
++ }
++ if (expired.length && expired.length === dated.length) {
++ errors.push('Every dated cookie in this file has already expired.');
++ } else if (expired.length) {
++ warnings.push(`${expired.length} of ${dated.length} dated cookies have expired.`);
++ }
++
++ return {
++ ok: errors.length === 0,
++ cookieCount: rows.length,
++ youtubeCount: yt.length,
++ domains: domains.slice(0, 12),
++ firstParty,
++ thirdParty,
++ missingFirstParty: YT_FIRST_PARTY.filter(n => !names.has(n)),
++ expiredCount: expired.length,
++ expiresAt: soonest ? new Date(soonest * 1000).toISOString() : null,
++ errors,
++ warnings,
++ };
++}
++
++module.exports = { inspectCookies, YT_FIRST_PARTY, YT_THIRD_PARTY };
ddiff --git a/server/media/media-downloader.js b/server/media/media-downloader.js
iindex 6e196c46..f5655e80 100644
---- a/server/media/media-downloader.js
++++ b/server/media/media-downloader.js
@@@ -25,8 +25,16 @@ function getDb() {
const YTDLP_PATH = process.env.YTDLP_PATH || '/usr/local/bin/yt-dlp';
const CACHE_DIR = path.resolve('./data/media/cache');
const COOKIES_PATH = path.resolve('./data/media/cookies.txt');
++// yt-dlp needs a JavaScript runtime for YouTube extraction; without one it falls back to
++// a deprecated path that drops formats and warns on every call. This list was all
++// hardcoded install locations and missed /usr/bin/node — the only node on this host — so
++// the flag was silently never passed. process.execPath is the node already running this
++// server, which is by definition present and executable, so try that first and keep the
++// fixed paths as fallbacks.
const NODE_CANDIDATES = [
process.env.YTDLP_NODE_PATH,
++ process.execPath,
++ '/usr/bin/node',
'/usr/local/bin/node',
'/opt/nvm/versions/node/v20.20.1/bin/node',
'/home/ubuntu/.nvm/versions/node/v20.20.1/bin/node',