switched to yt source

This commit is contained in:
fishtank-dashboard
2026-03-27 16:32:51 -07:00
committed by GitHub
parent ddf9d71386
commit 5d52b6bb11
2 changed files with 283 additions and 3 deletions

View File

@@ -2175,6 +2175,7 @@
<button class="stocks-collapse-btn" onclick="toggleStocks()" id="stocksCollapseBtn">▲ STOCKS</button>
<button class="stocks-collapse-btn" id="chatCollapseBtn" onclick="toggleChat()" title="Hide chat panel">💬 CHAT</button>
<button class="stocks-collapse-btn" id="notifLogBtn" onclick="toggleNotifLog()" title="Notification log" style="position:relative;">🔔 NOTIFS<span class="notif-log-badge" id="notifLogBadge">0</span></button>
<button class="stocks-collapse-btn" id="ytToggleBtn" onclick="toggleYtSource()" title="Switch between fishtank.rip and YouTube" style="border-color:var(--muted);color:var(--muted);">▶ YT SOURCE</button>
</div>
<div id="apiControl" style="display:flex;align-items:center;gap:8px;">
@@ -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;

191
server.js
View File

@@ -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();
});