mirror of
https://github.com/fishtank-dashboard/fishtank-dashboard.git
synced 2026-04-30 09:12:04 -04:00
switched to yt source
This commit is contained in:
committed by
GitHub
parent
ddf9d71386
commit
5d52b6bb11
@@ -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
191
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user