From 5d52b6bb112a0f2ed86df256f677c5a786638fe1 Mon Sep 17 00:00:00 2001 From: fishtank-dashboard Date: Fri, 27 Mar 2026 16:32:51 -0700 Subject: [PATCH] switched to yt source --- fishtank-dashboard.html | 95 +++++++++++++++++++- server.js | 191 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+), 3 deletions(-) diff --git a/fishtank-dashboard.html b/fishtank-dashboard.html index 0d2e0e3..c0793ba 100644 --- a/fishtank-dashboard.html +++ b/fishtank-dashboard.html @@ -2175,6 +2175,7 @@ +
@@ -2873,8 +2874,9 @@ ["West Wing", "hwup-5"], ["Jungle Room", "br4j-5"], ["Computer Lab", "bbcl-5"], - ["Cameraman", "cameraman2-5"], ["Job Board", "jobb-5"], + ["Arena", "bare-5"], + ["Cameraman", "cameraman2-5"], ]; // Navigation polygons per camera slug @@ -2988,6 +2990,8 @@ const DEFAULT_IDX = 1; // Director Mode const hlsInstances = {}; let featuredIdx = DEFAULT_IDX; + let ytSourceActive = true; + let ytReadyCount = 0; // directorCell = which grid index currently shows the Director stream (null = director is in featured) let directorCell = null; @@ -3019,7 +3023,15 @@ fragLoadingMaxRetry: 3, manifestLoadingMaxRetry: 3, }); - hls.loadSource('http://localhost:3000/cam/' + slug + '/index.m3u8'); + const isYt = ytSourceActive && slug !== '__epyc__'; + if (isYt) { + hls.config.startPosition = -1; + hls.config.liveSyncDurationCount = 2; + } + const hlsSource = isYt + ? 'http://localhost:3000/yt-stream/' + slug + : 'http://localhost:3000/cam/' + slug + '/index.m3u8'; + hls.loadSource(hlsSource); hls.attachMedia(video); let reconnectOverlayTimer = null; @@ -3878,7 +3890,14 @@ }; const hls = new Hls({ maxBufferLength: 2, maxMaxBufferLength: 4 }); - hls.loadSource('http://localhost:3000/cam/' + slug + '/index.m3u8'); + const ytSlugsSet = new Set(['dirc-5','foyr-5','gsrm-5','mrke-5','mrke2-5','hwdn-5', + 'dmrm-5','dmrm2-5','dmcl-5','jckz-5','brrr-5','brrr2-5','brpz-5','ktch-5', + 'dnrm-5','hwup-5','bbcl-5','br4j-5','bkny-5','codr-5','cfsl-5','jobb-5','bare-5']); + if (ytSourceActive && !ytSlugsSet.has(slug)) { hls.destroy(); resolve(); return; } + const thumbUrl = ytSourceActive + ? 'http://localhost:3000/yt-stream/' + slug + : 'http://localhost:3000/cam/' + slug + '/index.m3u8'; + hls.loadSource(thumbUrl); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {})); video.addEventListener('timeupdate', capture, { once: true }); @@ -3924,6 +3943,7 @@ const featVideo = document.createElement('video'); featVideo.playsInline = true; featVideo.autoplay = true; + featVideo.crossOrigin = 'anonymous'; const featLabel = document.createElement('div'); featLabel.className = 'cam-label'; featLabel.textContent = CAMERAS[DEFAULT_IDX][0].toUpperCase(); @@ -3962,6 +3982,7 @@ cell.className = 'cam-cell'; cell.id = 'cam-' + i; cell.dataset.slug = slug; + cell.dataset.idx = i; const canvas = document.createElement('canvas'); canvas.style.cssText = 'width:100%;height:100%;display:block;object-fit:cover;'; canvas.id = 'canvas-' + i; @@ -4228,6 +4249,12 @@ } } + if (msg._ft === 'yt_status') { + ytReadyCount = msg.ready || 0; + updateYtBtn(); + return; + } + if (msg._ft === 'raw') { console.log('[FT-WS RAW]', msg.data); } @@ -4400,6 +4427,67 @@ if (inlineChatAutoScroll) inlineFeed.scrollTop = inlineFeed.scrollHeight; } + // ── YouTube source toggle ──────────────────────────────────── + fetch('http://localhost:3000/yt-status').then(r => r.json()).then(d => { + ytReadyCount = d.ready || 0; + updateYtBtn(); + }).catch(() => {}); + + function updateYtBtn() { + const btn = document.getElementById('ytToggleBtn'); + if (!btn) return; + if (ytSourceActive) { + btn.style.borderColor = '#e74c3c'; + btn.style.color = '#e74c3c'; + btn.textContent = '▶ YT LIVE'; + } else if (ytReadyCount > 0) { + btn.style.borderColor = 'var(--green)'; + btn.style.color = 'var(--green)'; + btn.textContent = '▶ YT (' + ytReadyCount + ')'; + } else { + btn.style.borderColor = 'var(--muted)'; + btn.style.color = 'var(--muted)'; + btn.textContent = '▶ YT SOURCE'; + } + } + + window.toggleYtSource = function() { + ytSourceActive = !ytSourceActive; + updateYtBtn(); + + // Switch featured cam — makeHls now reads ytSourceActive + const wrap = document.getElementById('camFeaturedWrap'); + const featVideo = wrap && wrap.querySelector('video'); + const featSlug = CAMERAS[featuredIdx] && CAMERAS[featuredIdx][1]; + if (featVideo && featSlug) { + if (hlsInstances['featured']) hlsInstances['featured'].destroy(); + hlsInstances['featured'] = makeHls(featSlug, featVideo, false); + } + + // Dim grid cells that have no YT source + const NO_YT = new Set(['cameraman2-5','br3g-5']); + const ytSlugs = new Set(['dirc-5','foyr-5','gsrm-5','mrke-5','mrke2-5','hwdn-5', + 'dmrm-5','dmrm2-5','dmcl-5','jckz-5','brrr-5','brrr2-5','brpz-5','ktch-5', + 'dnrm-5','hwup-5','bbcl-5','br4j-5','bkny-5','codr-5','cfsl-5','bare-5','jobb-5']); + const grid = document.getElementById('cameraGrid'); + if (grid) { + grid.querySelectorAll('.cam-cell').forEach(cell => { + const slug = cell.dataset.slug; + if (ytSourceActive && slug && !ytSlugs.has(slug)) { + cell.style.opacity = '0.25'; + cell.style.pointerEvents = 'none'; + } else { + cell.style.opacity = ''; + cell.style.pointerEvents = ''; + } + }); + } + + // Thumbnails are canvas-based — just refresh them from new source + refreshAllThumbnails(); + }; + // ── End YouTube source toggle ───────────────────────────────── + // ── Floor plan ─────────────────────────────────────────────── const FLOOR_ROOMS = { down: [ @@ -4553,6 +4641,7 @@ cell.className = 'cam-cell'; cell.id = 'cam-' + i; cell.dataset.slug = slug; + cell.dataset.idx = i; const canvas = document.createElement('canvas'); canvas.style.cssText = 'width:100%;height:100%;display:block;object-fit:cover;'; canvas.id = 'canvas-' + i; diff --git a/server.js b/server.js index 07c284c..e4fd096 100644 --- a/server.js +++ b/server.js @@ -12,6 +12,131 @@ const PORT = 3000; const API_TARGET = 'api.fishtank.live'; const CAM_TARGET = 'epyc.goran.jetzt'; const CAM_PORT = 443; + +// ── YouTube live stream proxy ──────────────────────────────── +// Per-slug YouTube video IDs +const YT_VIDEO_IDS = { + 'dirc-5': 'lYK5M3PCnNg', + 'foyr-5': 'MvaKWOQRHkA', + 'gsrm-5': 'TYPEH85q3JU', + 'mrke-5': 'm8BoYX8MRxQ', + 'mrke2-5': 'VKactnWtMLU', + 'hwdn-5': 'PFGmM_L63O4', + 'dmrm-5': 'TohEVS4CYn0', + 'dmrm2-5': '_3EdEfoyhtI', + 'dmcl-5': 'eWRFlT9m94c', + 'jckz-5': 'q4C-ePNmuEU', + 'brrr-5': 'RCqC9H10HsE', + 'brrr2-5': '5NB7X9QJRtA', + 'brpz-5': '8vacKvkKI0U', + 'ktch-5': 'DfIYesgiGfY', + 'dnrm-5': 's2xzD6V4mKI', + 'hwup-5': 'k6MDn6y9Jjs', + 'bbcl-5': 'bJpr7Ueutag', + 'br4j-5': 'u1Zcyl5zrN4', + 'bkny-5': 'JWJDpCQyNh8', + 'codr-5': 'b1M083H1pis', + 'cfsl-5': 'HB7KvlwP2Rs', + 'bare-5': 'UcpOf1t-Sh0', + 'jobb-5': 'hBJtFeLXOi4', +}; + +const ytManifests = {}; // slug -> current manifest URL +const ytRefreshing = {}; // slug -> bool +const ytTimers = {}; // slug -> refresh timer + +function refreshYtSlug(slug, callback) { + const videoId = YT_VIDEO_IDS[slug]; + if (!videoId) { if (callback) callback(new Error('no video ID for ' + slug)); return; } + if (ytRefreshing[slug]) { if (callback) callback(new Error('refresh in progress')); return; } + ytRefreshing[slug] = true; + execFile('yt-dlp', [ + '--no-warnings', + '-f', '95/93/91/hls-1080/hls-720/hls-480/hls-360/best', + '--no-playlist', + '-g', + 'https://www.youtube.com/watch?v=' + videoId + ], { shell: true, timeout: 30000 }, (err, stdout) => { + ytRefreshing[slug] = false; + if (err) { if (callback) callback(err); return; } + const url = stdout.trim().split('\n')[0]; + if (!url || !url.startsWith('http')) { if (callback) callback(new Error('no URL')); return; } + console.log('[YT]', slug, 'url type:', url.includes('.m3u8') ? 'HLS' : url.includes('manifest') ? 'manifest' : 'direct', url.slice(0, 80)); + ytManifests[slug] = url; + if (ytTimers[slug]) clearTimeout(ytTimers[slug]); + ytTimers[slug] = setTimeout(() => refreshYtSlug(slug), 4 * 60 * 60 * 1000); + if (callback) callback(null, url); + }); +} + +function refreshAllYt() { + const slugs = Object.keys(YT_VIDEO_IDS); + console.log('[YT] Refreshing all', slugs.length, 'streams...'); + let done = 0; + slugs.forEach(slug => { + refreshYtSlug(slug, (err) => { + done++; + if (!err) console.log('[YT] Ready:', slug); + const ready = slugs.filter(s => !!ytManifests[s]).length; + broadcast({ _ft: 'yt_status', ready, total: slugs.length }); + }); + }); +} + +function proxyYtUrl(targetUrl, res) { + let parsedUrl; + try { parsedUrl = new URL(targetUrl); } catch(e) { res.writeHead(400); res.end('bad url'); return; } + const opts = { + hostname: parsedUrl.hostname, port: 443, + path: parsedUrl.pathname + parsedUrl.search, method: 'GET', + headers: { 'user-agent': 'Mozilla/5.0', 'accept': '*/*' }, + }; + const req = https.request(opts, (upstream) => { + const ct = upstream.headers['content-type'] || ''; + // Buffer first chunk to sniff if it's a playlist — don't rely on URL + const chunks = []; + let decided = false; + let isPlaylist = null; + + function decide(firstChunk) { + if (decided) return; + decided = true; + // Check if content starts with #EXTM3U + isPlaylist = firstChunk.toString('utf8', 0, 7) === '#EXTM3U' || ct.includes('mpegurl'); + if (!isPlaylist) { + // Stream as binary + const headers = { 'access-control-allow-origin': '*', 'cache-control': 'no-cache' }; + if (ct) headers['content-type'] = ct; + res.writeHead(upstream.statusCode, headers); + for (const c of chunks) res.write(c); + upstream.pipe(res); + } + } + + upstream.on('data', c => { + chunks.push(c); + if (!decided) decide(chunks[0]); + }); + upstream.on('end', () => { + if (!decided) decide(chunks[0] || Buffer.alloc(0)); + if (!isPlaylist) return; // already piped + // Rewrite playlist URLs + let text = Buffer.concat(chunks).toString('utf8'); + text = text.split('\n').map(line => { + const t = line.trim(); + if (!t || t.startsWith('#')) return line; + try { + const fullUrl = t.startsWith('http') ? t : new URL(t, targetUrl).href; + return 'http://localhost:' + PORT + '/yt-seg?url=' + encodeURIComponent(fullUrl); + } catch(e) { return line; } + }).join('\n'); + res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl', 'access-control-allow-origin': '*', 'cache-control': 'no-cache' }); + res.end(text); + }); + }); + req.on('error', err => { if (!res.headersSent) { res.writeHead(502); res.end(err.message); } }); + req.end(); +} const FT_WS_URL = 'wss://ws.fishtank.live/socket.io/?EIO=4&transport=websocket'; // ── Local WS clients (dashboard connections) ──────────────── @@ -426,6 +551,71 @@ const server = http.createServer((req, res) => { }); return; } + // YouTube stream endpoints + // /yt-refresh — trigger full refresh of all YT manifests + if (parsed.pathname === '/yt-refresh' && req.method === 'POST') { + refreshAllYt(); + res.writeHead(200, { 'Content-Type': 'application/json', 'access-control-allow-origin': '*' }); + res.end(JSON.stringify({ ok: true, slugs: Object.keys(YT_VIDEO_IDS).length })); + return; + } + + // /yt-status — report which slugs have manifests ready + if (parsed.pathname === '/yt-status') { + const ready = Object.keys(YT_VIDEO_IDS).filter(s => !!ytManifests[s]); + res.writeHead(200, { 'Content-Type': 'application/json', 'access-control-allow-origin': '*' }); + res.end(JSON.stringify({ ready: ready.length, total: Object.keys(YT_VIDEO_IDS).length, slugs: ready })); + return; + } + + // /yt-stream/:slug — serve manifest for a specific camera + if (parsed.pathname.startsWith('/yt-stream/')) { + const slug = parsed.pathname.replace('/yt-stream/', ''); + if (!ytManifests[slug]) { + // Try to fetch on demand if not cached yet + if (YT_VIDEO_IDS[slug] && !ytRefreshing[slug]) { + refreshYtSlug(slug, (err, url) => { + if (err || !url) { res.writeHead(503); res.end('YT stream not ready for ' + slug); return; } + proxyYtUrl(url, res); + }); + } else { + res.writeHead(503); res.end('YT stream not ready for ' + slug); + } + return; + } + proxyYtUrl(ytManifests[slug], res); + return; + } + + // /yt-debug/:slug — return the raw manifest URL for testing in VLC/browser + if (parsed.pathname.startsWith('/yt-debug/')) { + const slug = parsed.pathname.replace('/yt-debug/', ''); + const manifest = ytManifests[slug]; + const videoId = YT_VIDEO_IDS[slug]; + res.writeHead(200, { 'Content-Type': 'text/plain', 'access-control-allow-origin': '*' }); + const lines = [ + 'slug: ' + slug, + 'videoId: ' + (videoId || 'unknown'), + 'ytUrl: https://www.youtube.com/watch?v=' + (videoId || '?'), + 'manifestUrl: ' + (manifest || 'NOT READY'), + 'proxyUrl: http://localhost:3000/yt-stream/' + slug, + ]; + res.end(lines.join('\n')); + return; + } + + // /yt-seg — proxy individual segments/playlists + if (parsed.pathname === '/yt-seg') { + let targetUrl; + try { + const fullParsed = new URL(req.url, 'http://localhost'); + targetUrl = fullParsed.searchParams.get('url'); + } catch(e) {} + if (!targetUrl) { res.writeHead(400); res.end('missing url'); return; } + proxyYtUrl(targetUrl, res); + return; + } + if (parsed.pathname === '/strip-audio') { if (req.method === 'POST') { // Accept webm upload, strip audio with ffmpeg, return result @@ -580,4 +770,5 @@ server.listen(PORT, () => { console.log('✓ Dashboard → http://localhost:' + PORT); console.log('\nPress Ctrl+C to stop\n'); connectFishtankWS(null); + refreshAllYt(); });