openvibers/openvibe.tools Commits

YT: periodic upstream self-check, status in /api/health and a banner while YouTube refuses the server; README explains the two ways out

OP OpenVibers committed 18 Sept 2026 · 073c6ffdac · 4 files, +68 −2


Changes

ddiff --git a/README.md b/README.md
iindex 3dfdcc0..c36b4d5 100644
---- a/README.md
++++ b/README.md
@@@ -58,6 +58,18 @@ Public, cacheable outputs on the apex: `/` (every tool by family, planned tools
Deploy with `deploy/scripts/deploy.sh` (it refreshes the copied shared package inside each app).
++## YouTube downloader: when YouTube refuses the server
++
++YouTube blocks some datacentre addresses outright ("Sign in to confirm you're not a bot"). The app
++checks every 30 minutes (`GET /api/health` → `youtube: ok | blocked | unknown`) and shows a banner
++while blocked. Player-client switches, IPv6, proof-of-origin tokens, the nightly yt-dlp and Cloudflare
++WARP were all tried on 2026-09-18 and all refused; what works is a different network identity, set in
++`/etc/openvibe/tools.env` and followed by `sudo systemctl restart openvibe-tools-yt`:
++
++- `YT_PROXY=socks5://user:pass@host:port` — a residential or mobile proxy (http, https, socks4/5).
++- `YT_COOKIES_FILE=/etc/openvibe/yt-cookies.txt` — a Netscape cookies.txt exported from a throwaway
++ YouTube account, readable by the service user. Accounts used this way can be suspended.
++
## Dev quickstart
```bash
ddiff --git a/apps/yt/public/js/app.js b/apps/yt/public/js/app.js
iindex 9603713..5a6b9e8 100644
---- a/apps/yt/public/js/app.js
++++ b/apps/yt/public/js/app.js
@@@ -202,6 +202,23 @@
return d.innerHTML;
}
++ // --- Upstream status ---
++ // The server checks YouTube on a timer. When YouTube is refusing it, say so before anyone pastes a link.
++ (function upstreamBanner() {
++ fetch('/api/health', { cache: 'no-store' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (h) {
++ if (!h || h.youtube !== 'blocked' || document.getElementById('yt-upstream')) return;
++ var b = document.createElement('div'); b.id = 'yt-upstream'; b.setAttribute('role', 'status');
++ b.style.cssText = 'max-width:680px;margin:0 auto 16px;padding:12px 16px;border-radius:12px;border:1px solid color-mix(in srgb,var(--warning,#f59e0b) 45%,transparent);background:color-mix(in srgb,var(--warning,#f59e0b) 10%,transparent);color:var(--text-primary,#e6edf7);font-size:14px;line-height:1.5';
++ var t = document.createElement('b'); t.textContent = 'YouTube is refusing our server right now. ';
++ var m = document.createElement('span'); m.textContent = 'Downloads will fail until that clears. It is on YouTube\'s side and we re-check every 30 minutes. ';
++ var a = document.createElement('a'); a.href = 'https://openvibe.tools/audio-tools'; a.textContent = 'Audio tools'; a.style.color = 'var(--accent-light,#60a5fa)';
++ var a2 = document.createElement('a'); a2.href = 'https://openvibe.tools/'; a2.textContent = 'every other tool'; a2.style.color = 'var(--accent-light,#60a5fa)';
++ b.appendChild(t); b.appendChild(m); b.appendChild(a); b.appendChild(document.createTextNode(' and ')); b.appendChild(a2); b.appendChild(document.createTextNode(' work as usual.'));
++ var anchor = (typeof urlInput !== 'undefined' && urlInput && urlInput.closest('form, .input-row, .url-row, .search-box')) || document.querySelector('main') || document.body;
++ anchor.parentNode.insertBefore(b, anchor);
++ }).catch(function () {});
++ })();
++
// --- Start Download ---
// One reducer (download-state.js) turns SSE frames and polls into view state; render() paints it
// on the page and mirrors it into the navbar activity island when that module is present.
ddiff --git a/apps/yt/server/downloader.js b/apps/yt/server/downloader.js
iindex 115f411..5f62465 100644
---- a/apps/yt/server/downloader.js
++++ b/apps/yt/server/downloader.js
@@@ -382,6 +382,7 @@ function startDownload(url, quality = 'best', opts = {}) {
console.error(`[Download] ${id} (${quality}) failed: ${last}`);
entry.status = 'error';
entry.error = friendlyDownloadError(last);
++ if (/Sign in to confirm|not a bot/i.test(String(last))) noteUpstream('blocked');
removePartials(id);
return;
}
@@@ -573,8 +574,42 @@ function getStats() {
};
}
++// ── Upstream self-check ──────────────────────────────────────
++// YouTube sometimes refuses a whole server address ("confirm you're not a bot"). Rather than let
++// every visitor find out after a click, probe a known public video on a timer and say so up front.
++// state: 'ok' | 'blocked' | 'unknown' (GET /api/health → youtube, shown as a banner on the page)
++const PROBE_URL = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
++const PROBE_EVERY_MS = 30 * 60 * 1000;
++let upstream = { state: 'unknown', checkedAt: 0, detail: '' };
++let _probeTimer = null, _probing = false;
++
++function probeUpstream() {
++ if (_probing) return Promise.resolve(upstream);
++ _probing = true;
++ return new Promise((resolve) => {
++ const args = ['--no-playlist', ...identityArgs(), '--simulate', '--no-warnings', '--socket-timeout', '20', '-f', 'ba/b', '--print', 'id', PROBE_URL];
++ let err = '', out = '';
++ let child;
++ const done = (state, detail) => { _probing = false; upstream = { state, checkedAt: Date.now(), detail: String(detail || '').slice(0, 200) }; resolve(upstream); };
++ try { child = spawn(config.ytdlpPath, args, { stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return done('unknown', e.message); }
++ const killer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* */ } }, 60000);
++ child.stdout.on('data', d => { out += d; }); child.stderr.on('data', d => { err += d; });
++ child.on('error', e => { clearTimeout(killer); done('unknown', e.message); });
++ child.on('close', (code) => {
++ clearTimeout(killer);
++ if (code === 0 && out.trim()) return done('ok', '');
++ if (/Sign in to confirm|not a bot|HTTP Error 429|HTTP Error 403/i.test(err)) return done('blocked', 'YouTube is refusing requests from this server address');
++ done('unknown', err.split('\n').filter(Boolean).pop() || 'probe failed');
++ });
++ });
++}
++function startUpstreamProbe() { if (_probeTimer) return; setTimeout(() => probeUpstream().catch(() => {}), 5000).unref(); _probeTimer = setInterval(() => probeUpstream().catch(() => {}), PROBE_EVERY_MS); _probeTimer.unref(); }
++function getUpstream() { return upstream; }
++/** A real request just told us more than the timer knows. */
++function noteUpstream(state) { if (state === 'ok' || state === 'blocked') upstream = { state, checkedAt: Date.now(), detail: state === 'blocked' ? 'YouTube is refusing requests from this server address' : '' }; }
++
module.exports = {
getInfo, startDownload, cancelDownload, getStatus, getFile, removeFile,
sanitizeTitle, downloadFilename, contentDisposition, applyLine,
-- cleanup, startCleanup, stopCleanup, getStats, isValidUrl,
++ cleanup, startCleanup, stopCleanup, getStats, isValidUrl, probeUpstream, startUpstreamProbe, getUpstream,
};
ddiff --git a/apps/yt/server/index.js b/apps/yt/server/index.js
iindex 5a8c9a5..9e959f0 100644
---- a/apps/yt/server/index.js
++++ b/apps/yt/server/index.js
@@@ -98,7 +98,8 @@ app.use(hostGuard({ knows: seo.knowsHost, aliasOf: seo.aliasOf }));
// Health check
app.get('/api/health', (_req, res) => {
const stats = downloader.getStats();
-- res.json({ status: 'ok', service: 'openvibe-yt', version: '1.0.0', stats });
++ const up = downloader.getUpstream();
++ res.set('Cache-Control', 'no-store').json({ status: 'ok', service: 'openvibe-yt', version: '1.0.0', stats, youtube: up.state, youtubeCheckedAt: up.checkedAt ? new Date(up.checkedAt).toISOString() : null });
});
// Get video info
@@@ -256,6 +257,7 @@ app.use((req, res) => {
// ── Start ────────────────────────────────────────────────────
const server = app.listen(config.port, config.host, () => {
downloader.startCleanup();
++ downloader.startUpstreamProbe();
console.log(`\n╔═══════════════════════════════════════╗`);
console.log(`║ 📺 YT.OpenVibe — YouTube Downloader ║`);
console.log(`╠═══════════════════════════════════════╣`);