From e398ad69c35f2e365273e51bc283cd0a329f8ff4 Mon Sep 17 00:00:00 2001 From: fishtank-dashboard Date: Thu, 19 Mar 2026 14:59:40 -0700 Subject: [PATCH] minor bug fixes --- fishtank-dashboard.html | 54 +++++++++--------- server.js | 118 +++++++++++++++++++++++++++++++++++----- 2 files changed, 131 insertions(+), 41 deletions(-) diff --git a/fishtank-dashboard.html b/fishtank-dashboard.html index 1150120..4e4e0b0 100644 --- a/fishtank-dashboard.html +++ b/fishtank-dashboard.html @@ -1330,11 +1330,11 @@ function initCamerman() { const video = document.getElementById('cammanVideo'); if (!video) return; - const idx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5'); + const idx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); if (typeof Hls !== 'undefined' && Hls.isSupported()) { if (hlsInstances['camman']) hlsInstances['camman'].destroy(); const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); - hls.loadSource('http://localhost:3000/cam/cameraman-5/index.m3u8'); + hls.loadSource('http://localhost:3000/cam/cameraman2-5/index.m3u8'); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {})); hls.on(Hls.Events.ERROR, (e, d) => { @@ -1349,7 +1349,7 @@ }); hlsInstances['camman'] = hls; } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - video.src = 'http://localhost:3000/cam/cameraman-5/index.m3u8'; + video.src = 'http://localhost:3000/cam/cameraman2-5/index.m3u8'; video.play().catch(() => {}); } } @@ -1360,7 +1360,7 @@ _origSetFeatured(i); // If cameraman is now in featured, show director in camman panel // If cameraman is what's being swapped back, restore its own stream - const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5'); + const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); const cammanVideo = document.getElementById('cammanVideo'); const cammanLabel = document.getElementById('cammanLabel'); if (!cammanVideo) return; @@ -1381,7 +1381,7 @@ // Restore camman panel to cameraman stream if (hlsInstances['camman']) hlsInstances['camman'].destroy(); const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); - hls.loadSource('http://localhost:3000/cam/cameraman-5/index.m3u8'); + hls.loadSource('http://localhost:3000/cam/cameraman2-5/index.m3u8'); hls.attachMedia(cammanVideo); hls.on(Hls.Events.MANIFEST_PARSED, () => { cammanVideo.muted = true; cammanVideo.play().catch(() => {}); }); hlsInstances['camman'] = hls; @@ -1419,11 +1419,11 @@ function initCamerman() { const video = document.getElementById('cammanVideo'); if (!video) return; - const idx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5'); + const idx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); if (typeof Hls !== 'undefined' && Hls.isSupported()) { if (hlsInstances['camman']) hlsInstances['camman'].destroy(); const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); - hls.loadSource('http://localhost:3000/cam/cameraman-5/index.m3u8'); + hls.loadSource('http://localhost:3000/cam/cameraman2-5/index.m3u8'); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {})); hls.on(Hls.Events.ERROR, (e, d) => { @@ -1438,7 +1438,7 @@ }); hlsInstances['camman'] = hls; } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - video.src = 'http://localhost:3000/cam/cameraman-5/index.m3u8'; + video.src = 'http://localhost:3000/cam/cameraman2-5/index.m3u8'; video.play().catch(() => {}); } } @@ -1449,7 +1449,7 @@ _origSetFeatured(i); // If cameraman is now in featured, show director in camman panel // If cameraman is what's being swapped back, restore its own stream - const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5'); + const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); const cammanVideo = document.getElementById('cammanVideo'); const cammanLabel = document.getElementById('cammanLabel'); if (!cammanVideo) return; @@ -1470,7 +1470,7 @@ // Restore camman panel to cameraman stream if (hlsInstances['camman']) hlsInstances['camman'].destroy(); const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); - hls.loadSource('http://localhost:3000/cam/cameraman-5/index.m3u8'); + hls.loadSource('http://localhost:3000/cam/cameraman2-5/index.m3u8'); hls.attachMedia(cammanVideo); hls.on(Hls.Events.MANIFEST_PARSED, () => { cammanVideo.muted = true; cammanVideo.play().catch(() => {}); }); hlsInstances['camman'] = hls; @@ -1556,7 +1556,7 @@ -
+
CAMERAMAN
@@ -2148,7 +2148,7 @@ ["Hallway Down", "hwdn-5"], ["Hallway Up", "hwup-5"], ["Jungle Room", "br4j-5"], - ["Cameraman", "cameraman-5"], + ["Cameraman", "cameraman2-5"], ]; const ROOM_NAMES = { @@ -2160,7 +2160,7 @@ "dnrm-5": "Dining Room", "mrke-5": "Market", "hwdn-5": "Hallway Down", "hwup-5": "Hallway Up", "br4j-5": "Jungle Room", - "cameraman-5": "Cameraman", "site": "Site-wide", + "cameraman2-5": "Cameraman", "site": "Site-wide", }; const DEFAULT_IDX = 1; // Director Mode @@ -2854,7 +2854,7 @@ if (thumbMode) { // Switch back to thumbnail mode — destroy all live thumb streams, refresh canvases - const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5'); + const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); CAMERAS.forEach(([name, slug], i) => { if (i === DEFAULT_IDX || i === cammanIdx) return; if (hlsInstances[i]) { hlsInstances[i].destroy(); delete hlsInstances[i]; } @@ -2875,7 +2875,7 @@ } else { // Switch to live mode — replace canvases with live video streams if (window._thumbInterval) { clearInterval(window._thumbInterval); window._thumbInterval = null; } - const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5'); + const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); CAMERAS.forEach(([name, slug], i) => { if (i === DEFAULT_IDX || i === cammanIdx) return; const cell = document.getElementById('cam-' + i); @@ -2928,7 +2928,7 @@ } async function refreshAllThumbnails() { - const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5'); + const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); const tasks = CAMERAS.map(([name, slug], i) => { if (i === DEFAULT_IDX || i === cammanIdx) return null; if (slug === CAMERAS[featuredIdx][1]) return null; @@ -2995,7 +2995,7 @@ featVideo.addEventListener('canplay', () => { const s = document.getElementById('featuredVolume'); if (s) featVideo.volume = parseFloat(s.value); }, { once: true }); // Build thumb grid — skip Director Mode and Cameraman - const cammanIdx = CAMERAS.findIndex(([,s]) => s === "cameraman-5"); + const cammanIdx = CAMERAS.findIndex(([,s]) => s === "cameraman2-5"); CAMERAS.forEach(([name, slug], i) => { if (i === DEFAULT_IDX || i === cammanIdx) return; const cell = document.createElement('div'); @@ -3122,11 +3122,11 @@ function initCamerman() { const video = document.getElementById('cammanVideo'); if (!video) return; - const idx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5'); + const idx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); if (typeof Hls !== 'undefined' && Hls.isSupported()) { if (hlsInstances['camman']) hlsInstances['camman'].destroy(); const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); - hls.loadSource('http://localhost:3000/cam/cameraman-5/index.m3u8'); + hls.loadSource('http://localhost:3000/cam/cameraman2-5/index.m3u8'); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {})); hls.on(Hls.Events.ERROR, (e, d) => { @@ -3141,7 +3141,7 @@ }); hlsInstances['camman'] = hls; } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - video.src = 'http://localhost:3000/cam/cameraman-5/index.m3u8'; + video.src = 'http://localhost:3000/cam/cameraman2-5/index.m3u8'; video.play().catch(() => {}); } } @@ -3152,7 +3152,7 @@ _origSetFeatured(i); // If cameraman is now in featured, show director in camman panel // If cameraman is what's being swapped back, restore its own stream - const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5'); + const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); const cammanVideo = document.getElementById('cammanVideo'); const cammanLabel = document.getElementById('cammanLabel'); if (!cammanVideo) return; @@ -3173,7 +3173,7 @@ // Restore camman panel to cameraman stream if (hlsInstances['camman']) hlsInstances['camman'].destroy(); const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); - hls.loadSource('http://localhost:3000/cam/cameraman-5/index.m3u8'); + hls.loadSource('http://localhost:3000/cam/cameraman2-5/index.m3u8'); hls.attachMedia(cammanVideo); hls.on(Hls.Events.MANIFEST_PARSED, () => { cammanVideo.muted = true; cammanVideo.play().catch(() => {}); }); hlsInstances['camman'] = hls; @@ -3400,7 +3400,7 @@ function autoDiscoverCameras(presenceData) { // presence keys are camera slugs (plus "total") - const knownSpecial = new Set(['total', 'cameraman-5']); + const knownSpecial = new Set(['total', 'cameraman2-5']); let added = false; Object.keys(presenceData).forEach(slug => { @@ -3411,7 +3411,7 @@ console.log('[CAM] Auto-discovered new camera:', slug); KNOWN_CAMERA_SLUGS.add(slug); const label = slugToLabel(slug); - const cammanIdx = CAMERAS.findIndex(([, s]) => s === 'cameraman-5'); + const cammanIdx = CAMERAS.findIndex(([, s]) => s === 'cameraman2-5'); CAMERAS.splice(cammanIdx, 0, [label, slug]); added = true; }); @@ -3420,7 +3420,7 @@ if (added) { // Add new cells to the grid without destroying existing streams const grid = document.getElementById('cameraGrid'); - const cammanIdx = CAMERAS.findIndex(([, s]) => s === 'cameraman-5'); + const cammanIdx = CAMERAS.findIndex(([, s]) => s === 'cameraman2-5'); CAMERAS.forEach(([name, slug], i) => { if (i === DEFAULT_IDX || i === cammanIdx) return; if (document.getElementById('cam-' + i)) return; // already exists @@ -3447,7 +3447,7 @@ // ── Viewer counts ──────────────────────────────────────────── window.updateViewerCounts = function(data) { - const cammanIdx = typeof CAMERAS !== 'undefined' ? CAMERAS.findIndex(([,s]) => s === 'cameraman-5') : -1; + const cammanIdx = typeof CAMERAS !== 'undefined' ? CAMERAS.findIndex(([,s]) => s === 'cameraman2-5') : -1; if (typeof CAMERAS !== 'undefined') { CAMERAS.forEach(([name, slug], i) => { if (i === (typeof DEFAULT_IDX !== 'undefined' ? DEFAULT_IDX : 1)) return; @@ -3506,7 +3506,7 @@ } // Update every camera cell in the grid - const cammanIdx = typeof CAMERAS !== 'undefined' ? CAMERAS.findIndex(([,s]) => s === 'cameraman-5') : -1; + const cammanIdx = typeof CAMERAS !== 'undefined' ? CAMERAS.findIndex(([,s]) => s === 'cameraman2-5') : -1; if (typeof CAMERAS !== 'undefined') { CAMERAS.forEach(([name, slug], i) => { if (i === (typeof DEFAULT_IDX !== 'undefined' ? DEFAULT_IDX : 1)) return; diff --git a/server.js b/server.js index 019d76a..880b3ce 100644 --- a/server.js +++ b/server.js @@ -3,11 +3,13 @@ const https = require('https'); const fs = require('fs'); const path = require('path'); const url = require('url'); +const zlib = require('zlib'); const WebSocket = require('ws'); const PORT = 3000; const API_TARGET = 'api.fishtank.live'; const CAM_TARGET = 'epyc.goran.jetzt'; +const CAM_PORT = 443; const FT_WS_URL = 'wss://ws.fishtank.live/socket.io/?EIO=4&transport=websocket'; // ── Local WS clients (dashboard connections) ──────────────── @@ -304,6 +306,51 @@ function connectFishtankWS(token) { } // ── HTTP helpers ───────────────────────────────────────────── +// Per-slug cookie store — tkn cookie from port 444 responses +const camCookies = {}; + +function fetchCam(slug, subPath, callback) { + // Step 1: request from port 443 (follows redirect to 444 manually) + const path443 = `/hls/${slug}/${subPath}`; + const cookie = camCookies[slug] ? `tkn=${camCookies[slug]}` : ''; + + const doRequest = (port, reqPath, reqCookie) => { + const opts = { + hostname: CAM_TARGET, port, path: reqPath, method: 'GET', + headers: { + 'host': port === 443 ? CAM_TARGET : `${CAM_TARGET}:444`, + 'user-agent': 'VLC/3.0.20 LibVLC/3.0.20', + 'accept': '*/*', + ...(reqCookie ? { 'cookie': reqCookie } : {}), + }, + }; + const req = https.request(opts, (res) => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => { + const body = Buffer.concat(chunks); + // Store cookie from Set-Cookie header + const setCookie = res.headers['set-cookie']; + if (setCookie) { + const match = (Array.isArray(setCookie) ? setCookie.join(';') : setCookie).match(/tkn=(\d+)/); + if (match) camCookies[slug] = match[1]; + } + // Follow redirect to port 444 + if (res.statusCode === 302 && res.headers.location) { + const loc = res.headers.location; + const m = loc.match(/epyc\.goran\.jetzt:444(\/.*)/); + if (m) { doRequest(444, m[1], camCookies[slug] ? `tkn=${camCookies[slug]}` : ''); return; } + } + callback(null, res, body); + }); + }); + req.on('error', err => callback(err)); + req.end(); + }; + + doRequest(443, path443, cookie); +} + function fetchRemote(hostname, targetPath, callback) { const req = https.request({ hostname, port: 443, path: targetPath, method: 'GET', @@ -324,14 +371,15 @@ function fetchRemote(hostname, targetPath, callback) { } function rewriteM3u8(body, basePath) { + // basePath examples: "dirc-5/" or "dirc-5/0_1/" + // Never starts with slash — strip it just in case + const base = basePath.replace(/^\//, ''); return body.split('\n').map(line => { const t = line.trim(); - if (!t || t.startsWith('#')) { - return t.replace(/URI="([^"]+)"/g, (m, uri) => - uri.startsWith('http') ? m : `URI="http://localhost:${PORT}/cam${basePath}${uri}"`); - } + if (!t || t.startsWith('#')) return t; if (t.startsWith('http')) return t; - return `http://localhost:${PORT}/cam${basePath}${t}`; + if (t.startsWith('/')) return `http://localhost:${PORT}/cam${t}`; + return `http://localhost:${PORT}/cam/${base}${t}`; }).join('\n'); } @@ -399,18 +447,60 @@ const server = http.createServer((req, res) => { } if (parsed.pathname.startsWith('/cam/')) { - const targetPath = parsed.pathname.replace('/cam', '') + (parsed.search || ''); - if (!targetPath.includes('.m3u8')) { - fetchRemote(CAM_TARGET, targetPath, (err, remoteRes, body) => { - if (err) { res.writeHead(502); res.end(err.message); return; } - res.writeHead(remoteRes.statusCode, { ...remoteRes.headers, 'access-control-allow-origin': '*' }); - res.end(body); - }); return; + // Extract slug from any cam path variant + const camPart = parsed.pathname.replace('/cam', '').replace('/hls', '').replace(/^\//, ''); + const parts = camPart.split('/'); + const slug = parts[0]; + + // Is this a media segment request? e.g. /cam/dirc-5/0_1/seg001.ts + const isSegment = parsed.pathname.match(/\.(ts|fmp4|mp4|m4s)(\?|$)/i); + const isSubPlaylist = parts.length > 2 && !isSegment; // e.g. dirc-5/0_1/index.m3u8 + + if (isSegment || isSubPlaylist) { + // Sub-playlist or segment — reconstruct path with cookie token + let subPath = parts.slice(1).join('/'); + const tkn = camCookies[slug]; + if (tkn) subPath += (subPath.includes('?') ? '&' : '?') + 'tkn=' + tkn; + const opts = { + hostname: CAM_TARGET, port: 444, + path: '/hls/' + slug + '/' + subPath, method: 'GET', + headers: { + 'host': CAM_TARGET + ':444', + 'user-agent': 'VLC/3.0.20 LibVLC/3.0.20', + 'accept': '*/*', + ...(tkn ? { 'cookie': 'tkn=' + tkn } : {}), + }, + }; + const preq = https.request(opts, (pres) => { + const chunks = []; + pres.on('data', c => chunks.push(c)); + pres.on('end', () => { + const body = Buffer.concat(chunks); + if (!isSegment && pres.statusCode === 200) { + const rawText = body.toString('utf8'); + // Log first segment line to debug path format + const firstSeg = rawText.split('\n').find(l => l.trim() && !l.startsWith('#')); + console.log('[CAM] sub-playlist first line:', JSON.stringify(firstSeg)); + const basePath = slug + '/' + parts.slice(1, -1).join('/') + '/'; + const rewritten = rewriteM3u8(rawText, basePath); + res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl', 'access-control-allow-origin': '*', 'cache-control': 'no-cache' }); + res.end(rewritten); + } else { + res.writeHead(pres.statusCode, { ...pres.headers, 'access-control-allow-origin': '*' }); + res.end(body); + } + }); + }); + preq.on('error', err => { res.writeHead(502); res.end(err.message); }); + preq.end(); + return; } - fetchRemote(CAM_TARGET, targetPath, (err, remoteRes, body) => { + + // Master playlist — use fetchCam which handles redirect + cookie storage + fetchCam(slug, 'index.m3u8', (err, remoteRes, body) => { if (err) { res.writeHead(502); res.end(err.message); return; } if (remoteRes.statusCode !== 200) { res.writeHead(remoteRes.statusCode); res.end(body); return; } - const basePath = targetPath.substring(0, targetPath.lastIndexOf('/') + 1); + const basePath = slug + '/'; const rewritten = rewriteM3u8(body.toString('utf8'), basePath); res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl', 'access-control-allow-origin': '*', 'cache-control': 'no-cache' }); res.end(rewritten);