openvibers/openvibe.live Commits
Media: fix the yt-dlp JS runtime path, validate + live-test cookies
Changes
@@@ -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 });}nnew file mode 100644@@@ -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 };@@@ -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',
@@ -1019,18 +1019,86 @@ router.get('/media-tools/status', async (req, res) => {
@@ -1019,18 +1019,86 @@ router.get('/media-tools/status', async (req, res) => {
});
});
// PUT /api/admin/media-tools/cookies — Upload/paste cookies.txt content
// 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 {
try {
- const { cookies } = req.body;
+ const { cookies, force } = req.body;
if (!cookies || typeof cookies !== 'string' || cookies.trim().length < 10) {
if (!cookies || typeof cookies !== 'string' || cookies.trim().length < 10) {
return res.status(400).json({ error: 'Cookies content is required (Netscape cookies.txt format)' });
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 cookiesPath = downloader.getCookiesPath();
const dir = path.dirname(cookiesPath);
const dir = path.dirname(cookiesPath);
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
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');
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) {
} catch (err) {
res.status(500).json({ error: err.message });
res.status(500).json({ error: err.message });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -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 };
@@ -25,8 +25,16 @@ function getDb() {
@@ -25,8 +25,16 @@ function getDb() {
const YTDLP_PATH = process.env.YTDLP_PATH || '/usr/local/bin/yt-dlp';
const YTDLP_PATH = process.env.YTDLP_PATH || '/usr/local/bin/yt-dlp';
const CACHE_DIR = path.resolve('./data/media/cache');
const CACHE_DIR = path.resolve('./data/media/cache');
const COOKIES_PATH = path.resolve('./data/media/cookies.txt');
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 = [
const NODE_CANDIDATES = [
process.env.YTDLP_NODE_PATH,
process.env.YTDLP_NODE_PATH,
+ process.execPath,
+ '/usr/bin/node',
'/usr/local/bin/node',
'/usr/local/bin/node',
'/opt/nvm/versions/node/v20.20.1/bin/node',
'/opt/nvm/versions/node/v20.20.1/bin/node',
'/home/ubuntu/.nvm/versions/node/v20.20.1/bin/node',
'/home/ubuntu/.nvm/versions/node/v20.20.1/bin/node',