From 2f50144fe7844fdd6533acde0f22eb64e9d6beb0 Mon Sep 17 00:00:00 2001 From: fishtank-dashboard Date: Tue, 17 Mar 2026 14:52:14 -0700 Subject: [PATCH] websocket update, fishtracker added --- README.md | 48 ++- fishtank-dashboard.html | 826 +++++++++++++++++++++++++++++++++++----- server.js | 430 ++++++++++++++++----- 3 files changed, 1117 insertions(+), 187 deletions(-) diff --git a/README.md b/README.md index 0c7b642..be1deb9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fishtank Monitor -A local dashboard for fishtank.live that shows live camera feeds, polls, TTS messages, stock prices, and more — all in one place without needing the main site open. +A local dashboard for fishtank.live that shows live camera feeds, polls, TTS messages, stock prices, real-time notifications, and more — all in one place without needing the main site open. --- @@ -34,7 +34,19 @@ Put both files in the **same folder** anywhere on your computer (e.g. your Deskt --- -## Step 3 — Start the Server +## Step 3 — Install Dependencies + +The server requires one npm package. Open a terminal in your folder (see Step 4 for how to do that) and run: + +``` +npm install ws +``` + +You only need to do this once. It creates a `node_modules` folder in the same directory — leave it there. + +--- + +## Step 4 — Start the Server **On Windows:** 1. Open the folder where you put the files @@ -54,16 +66,14 @@ Put both files in the **same folder** anywhere on your computer (e.g. your Deskt You should see: ``` -✓ Dashboard → http://localhost:3000 -✓ API proxy → http://localhost:3000/api/v1/... -✓ Cam proxy → http://localhost:3000/cam//index.m3u8 +✓ Dashboard → http://localhost:3000 ``` **Leave this window open** — closing it stops the dashboard. --- -## Step 4 — Open the Dashboard +## Step 5 — Open the Dashboard Open your browser and go to: @@ -75,9 +85,9 @@ The dashboard will load with camera feeds starting automatically. --- -## Step 5 — Link Your Account (Optional) +## Step 6 — Link Your Account -Linking your fishtank.live account unlocks polls, TTS messages, stock prices, and other live data. The cameras work without it. +Linking your fishtank.live account unlocks polls, TTS messages, stock prices, live notifications, contestant locations, and other live data. The cameras work without it. **To get your token:** 1. Go to **fishtank.live** and log in @@ -91,7 +101,8 @@ Linking your fishtank.live account unlocks polls, TTS messages, stock prices, an 1. Click the **LINK API** button in the top-left of the dashboard 2. Paste the value you copied into the input field 3. Press Enter or click **CONNECT** -4. The dot turns green and shows **API LIVE** when connected +4. The API dot turns green and shows **API LIVE** +5. The WS dot (next to the clock) also turns green and shows **WS LIVE** — this is the live WebSocket connection to fishtank Your token is saved automatically — you won't need to do this again unless you log out or clear your browser data. The dashboard also refreshes the token automatically so it stays connected. @@ -122,6 +133,19 @@ If you closed the terminal window and need to stop it: - **Cameraman** feed displayed in its own panel next to the stocks chart, same switching behaviour - **THUMBS / LIVE toggle** — Thumbs mode refreshes a snapshot every 30 seconds (saves bandwidth). Live mode streams all cameras simultaneously +### 🧑‍🤝‍🧑 Contestant Locations +- Contestant avatar icons appear **directly on the camera thumbnails** showing who is currently in each room +- Updated in real time via facial recognition data from the fishtank WebSocket feed +- Hover over an avatar to see the contestant's name, current action (sitting, standing, etc.), and mood +- The featured camera also shows avatars, slightly larger +- Contestants whose location is unknown (off-camera) show no icon + +### 🔔 Live Notifications +- **Production announcements** (evictions, challenges, events, etc.) appear as a **popup overlay** in the top-center of the dashboard +- A notification sound plays when a new one arrives +- Auto-dismisses after 8 seconds, or click ✕ to close manually +- If a new notification arrives before the previous one dismisses, it replaces it immediately + ### 📊 Polls - Shows the current active poll with live vote bars - Displays the winner of the last poll @@ -161,6 +185,12 @@ Make sure the server is running (`node server.js`) and you're visiting `http://l **"LINK API" not working:** Make sure you copied the full cookie value starting with `%5B%22eyJ`. Just the token alone (starting with `eyJ`) also works but won't enable auto-refresh. +**WS dot stays red / no notifications or contestant locations:** +Make sure you've linked your account — the WebSocket connection requires auth. If it was already linked, try logging out and back in to refresh the token. + +**Server won't start — "Cannot find module 'ws'":** +You need to install the ws package. Run `npm install ws` in the folder containing `server.js`, then try again. + **Server won't start:** Make sure Node.js is installed. Open a terminal and type `node --version` — if it prints a version number, Node is installed. If not, go back to Step 1. diff --git a/fishtank-dashboard.html b/fishtank-dashboard.html index 50beae8..aee86f5 100644 --- a/fishtank-dashboard.html +++ b/fishtank-dashboard.html @@ -160,8 +160,49 @@ gap: 1px; height: calc(100vh - 73px); background: var(--border); + transition: grid-template-rows 0.3s ease, grid-template-columns 0.3s ease; } + .main.stocks-collapsed { + grid-template-columns: 320px 1fr 0px; + grid-template-areas: "poll cameras cameras" "tts cameras cameras"; + } + .main.stocks-collapsed .stocks-hide { + display: none; + } + + .main.left-collapsed { + grid-template-columns: 0px 1fr 320px; + grid-template-areas: "stocks stocks camman" "cameras cameras camman"; + } + .main.left-collapsed .left-hide { + display: none; + } + + .main.left-collapsed.stocks-collapsed { + grid-template-columns: 0px 1fr 0px; + grid-template-areas: "cameras cameras cameras" "cameras cameras cameras"; + } + .main.left-collapsed.stocks-collapsed .stocks-hide { + display: none; + } + + .stocks-collapse-btn { + background: transparent; + border: none; + color: var(--muted); + font-family: 'Share Tech Mono', monospace; + font-size: 11px; + cursor: pointer; + padding: 2px 6px; + border-radius: 3px; + letter-spacing: 1px; + transition: color 0.2s; + flex-shrink: 0; + } + + .stocks-collapse-btn:hover { color: var(--accent); } + .panel { background: var(--panel); display: flex; @@ -258,6 +299,7 @@ max-height: 65%; overflow: hidden; min-height: 0; + position: relative; } .cam-featured-wrap .cam-cell { @@ -692,12 +734,199 @@ font-family: 'Share Tech Mono', monospace; font-size: 12px; } + + /* Viewer count badge */ + .cam-viewers { + position: absolute; + top: 4px; + right: 4px; + background: rgba(0,0,0,0.65); + color: #fff; + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + padding: 2px 5px; + border-radius: 3px; + pointer-events: none; + letter-spacing: 0.5px; + display: flex; + align-items: center; + gap: 3px; + } + + .cam-viewers::before { + content: ''; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--accent2); + flex-shrink: 0; + } + + .cam-featured-wrap .cam-viewers { + top: 8px; + right: 8px; + font-size: 11px; + padding: 3px 8px; + } + + /* Featured cam play button overlay */ + .cam-play-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.45); + cursor: pointer; + z-index: 10; + transition: opacity 0.2s; + } + + .cam-play-overlay.hidden { + display: none; + } + + .cam-play-btn { + width: 64px; + height: 64px; + border-radius: 50%; + background: rgba(0,229,255,0.15); + border: 2px solid var(--accent); + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s, transform 0.2s; + } + + .cam-play-overlay:hover .cam-play-btn { + background: rgba(0,229,255,0.3); + transform: scale(1.08); + } + + .cam-play-btn svg { + width: 28px; + height: 28px; + fill: var(--accent); + margin-left: 4px; + } + + /* Contestant cam overlays */ + .cam-contestants { + position: absolute; + bottom: 22px; + left: 4px; + right: 4px; + display: flex; + flex-wrap: wrap; + gap: 2px; + pointer-events: none; + } + + .cam-avatar { + width: 22px; + height: 22px; + border-radius: 50%; + border: 1.5px solid rgba(0,0,0,0.6); + object-fit: cover; + background: var(--panel); + pointer-events: auto; + cursor: default; + flex-shrink: 0; + } + + .cam-avatar:hover::after { + content: attr(title); + } + + /* Featured cam — bigger avatars */ + .cam-featured-wrap .cam-contestants { + bottom: 28px; + left: 8px; + gap: 4px; + } + + .cam-featured-wrap .cam-avatar { + width: 32px; + height: 32px; + border-width: 2px; + } + + /* Global notification popup */ + #notifPopup { + position: fixed; + top: 80px; + left: 50%; + transform: translateX(-50%) translateY(-20px); + z-index: 9999; + background: var(--panel); + border: 1px solid var(--accent); + border-left: 4px solid var(--accent); + box-shadow: 0 0 30px rgba(0,229,255,0.2); + padding: 14px 24px 14px 20px; + max-width: 560px; + min-width: 300px; + border-radius: 4px; + display: flex; + align-items: flex-start; + gap: 14px; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease, transform 0.25s ease; + } + + #notifPopup.show { + opacity: 1; + pointer-events: auto; + transform: translateX(-50%) translateY(0); + } + + .notif-icon { + font-size: 18px; + flex-shrink: 0; + margin-top: 1px; + } + + .notif-body { flex: 1; } + + .notif-label { + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 2px; + color: var(--accent); + margin-bottom: 5px; + } + + .notif-message { + font-family: 'DM Sans', sans-serif; + font-size: 14px; + color: #fff; + line-height: 1.4; + } + + .notif-subtitle { + font-family: 'Share Tech Mono', monospace; + font-size: 11px; + color: var(--muted); + margin-top: 4px; + } + + .notif-close { + background: none; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 16px; + padding: 0; + flex-shrink: 0; + line-height: 1; + margin-top: 1px; + } + .notif-close:hover { color: var(--text); } + + diff --git a/server.js b/server.js index addece0..d0d9a74 100644 --- a/server.js +++ b/server.js @@ -3,17 +3,301 @@ const https = require('https'); const fs = require('fs'); const path = require('path'); const url = require('url'); +const WebSocket = require('ws'); const PORT = 3000; const API_TARGET = 'api.fishtank.live'; const CAM_TARGET = 'epyc.goran.jetzt'; +const FT_WS_URL = 'wss://ws.fishtank.live/socket.io/?EIO=4&transport=websocket'; +// ── Local WS clients (dashboard connections) ──────────────── +const localClients = new Set(); + +// ── Fishtank WS state ──────────────────────────────────────── +let ftSocket = null; +let ftToken = null; // raw JWT (access token only) +let reconnectTimer = null; +let namespaceReady = false; + +function broadcast(msg) { + const data = typeof msg === 'string' ? msg : JSON.stringify(msg); + for (const client of localClients) { + if (client.readyState === WebSocket.OPEN) client.send(data); + } +} + +// ── msgpack helpers ────────────────────────────────────────── +function mpStr(s) { + const b = Buffer.from(s, 'utf8'); + const n = b.length; + if (n <= 31) return Buffer.concat([Buffer.from([0xa0 | n]), b]); + if (n <= 255) return Buffer.concat([Buffer.from([0xd9, n]), b]); + return Buffer.concat([Buffer.from([0xda, n >> 8, n & 0xff]), b]); +} +function mpBool(v) { return Buffer.from([v ? 0xc3 : 0xc2]); } +function mpMap(pairs) { + const n = pairs.length; + const header = n <= 15 ? Buffer.from([0x80 | n]) : Buffer.from([0xde, n >> 8, n & 0xff]); + return Buffer.concat([header, ...pairs.map(([k, v]) => Buffer.concat([k, v]))]); +} + +// Auth frame: { type: 0, data: { token: "" }, nsp: "/" } +// type=0 is integer CONNECT, data is a nested map containing the token +function buildAuthFrame(token) { + const dataMap = mpMap([[mpStr('token'), mpStr(token)]]); + return mpMap([ + [mpStr('type'), Buffer.from([0x00])], // integer 0 = CONNECT + [mpStr('data'), dataMap], + [mpStr('nsp'), mpStr('/')], + ]); +} + +// Subscribe frame: { type: 2, data: [''], options: {compress:true}, nsp: "/" } +function mpArray(items) { + const result = [Buffer.from([0x90 | items.length])]; + for (const v of items) result.push(v); + return Buffer.concat(result); +} +function buildSubscribeFrame(channel) { + const opts = mpMap([[mpStr('compress'), mpBool(true)]]); + return mpMap([ + [mpStr('type'), Buffer.from([0x02])], // integer 2 = EVENT + [mpStr('data'), mpArray([mpStr(channel)])], // array wrapping channel name + [mpStr('options'), opts], + [mpStr('nsp'), mpStr('/')], + ]); +} + +function sendBinary(buf) { + if (ftSocket && ftSocket.readyState === WebSocket.OPEN) { + ftSocket.send(buf, { binary: true }); + } +} + +function sendAuthFrame() { + if (!ftToken) { + console.log('[WS] No token — connecting unauthenticated (limited events)'); + return; + } + console.log('[WS] Sending auth frame with token'); + sendBinary(buildAuthFrame(ftToken)); +} + +function sendSubscriptions() { + console.log('[WS] Subscribing to chat:presence and presence'); + sendBinary(buildSubscribeFrame('chat:presence')); + sendBinary(buildSubscribeFrame('presence')); +} + +// ── Simple msgpack decoder (enough for fishtank events) ───── +function mpDecode(buf, offset = 0) { + if (offset >= buf.length) return [null, offset]; + const b = buf[offset++]; + + // Positive fixint + if ((b & 0x80) === 0) return [b, offset]; + // Fixmap + if ((b & 0xf0) === 0x80) { + const n = b & 0x0f; + const obj = {}; + for (let i = 0; i < n; i++) { + const [k, o1] = mpDecode(buf, offset); offset = o1; + const [v, o2] = mpDecode(buf, offset); offset = o2; + obj[k] = v; + } + return [obj, offset]; + } + // Fixarray + if ((b & 0xf0) === 0x90) { + const n = b & 0x0f; + const arr = []; + for (let i = 0; i < n; i++) { + const [v, o] = mpDecode(buf, offset); offset = o; + arr.push(v); + } + return [arr, offset]; + } + // Fixstr + if ((b & 0xe0) === 0xa0) { + const n = b & 0x1f; + return [buf.slice(offset, offset + n).toString('utf8'), offset + n]; + } + // nil + if (b === 0xc0) return [null, offset]; + // false/true + if (b === 0xc2) return [false, offset]; + if (b === 0xc3) return [true, offset]; + // str8 + if (b === 0xd9) { const n = buf[offset++]; return [buf.slice(offset, offset + n).toString('utf8'), offset + n]; } + // str16 + if (b === 0xda) { const n = (buf[offset] << 8) | buf[offset+1]; offset += 2; return [buf.slice(offset, offset + n).toString('utf8'), offset + n]; } + // str32 + if (b === 0xdb) { const n = (buf[offset] << 16) | (buf[offset+1] << 8) | buf[offset+2] << 8 | buf[offset+3]; offset += 4; return [buf.slice(offset, offset + n).toString('utf8'), offset + n]; } + // uint8 + if (b === 0xcc) return [buf[offset++], offset]; + // uint16 + if (b === 0xcd) { const n = (buf[offset] << 8) | buf[offset+1]; return [n, offset + 2]; } + // uint32 + if (b === 0xce) { const n = buf.readUInt32BE(offset); return [n, offset + 4]; } + // uint64 — read as Number (may lose precision for huge values but fine for timestamps) + if (b === 0xcf) { const hi = buf.readUInt32BE(offset); const lo = buf.readUInt32BE(offset+4); return [hi * 4294967296 + lo, offset + 8]; } + // int8 + if (b === 0xd0) return [buf.readInt8(offset), offset + 1]; + // int16 + if (b === 0xd1) return [buf.readInt16BE(offset), offset + 2]; + // int32 + if (b === 0xd2) return [buf.readInt32BE(offset), offset + 4]; + // int64 + if (b === 0xd3) { const hi = buf.readInt32BE(offset); const lo = buf.readUInt32BE(offset+4); return [hi * 4294967296 + lo, offset + 8]; } + // map16 + if (b === 0xde) { + const n = (buf[offset] << 8) | buf[offset+1]; offset += 2; + const obj = {}; + for (let i = 0; i < n; i++) { + const [k, o1] = mpDecode(buf, offset); offset = o1; + const [v, o2] = mpDecode(buf, offset); offset = o2; + obj[k] = v; + } + return [obj, offset]; + } + // array16 + if (b === 0xdc) { + const n = (buf[offset] << 8) | buf[offset+1]; offset += 2; + const arr = []; + for (let i = 0; i < n; i++) { + const [v, o] = mpDecode(buf, offset); offset = o; + arr.push(v); + } + return [arr, offset]; + } + // fixext 1,2,4,8,16 — skip type byte + data bytes + if (b === 0xd4) { offset += 2; return [null, offset]; } // fixext 1 + if (b === 0xd5) { offset += 3; return [null, offset]; } // fixext 2 + if (b === 0xd6) { offset += 5; return [null, offset]; } // fixext 4 + if (b === 0xd7) { offset += 9; return [null, offset]; } // fixext 8 + if (b === 0xd8) { offset += 17; return [null, offset]; } // fixext 16 + // ext8, ext16, ext32 + if (b === 0xc7) { const n = buf[offset++]; offset += 1 + n; return [null, offset]; } + if (b === 0xc8) { const n = (buf[offset] << 8) | buf[offset+1]; offset += 2 + 1 + n; return [null, offset]; } + if (b === 0xc9) { const n = buf.readUInt32BE(offset); offset += 4 + 1 + n; return [null, offset]; } + // bin8, bin16, bin32 + if (b === 0xc4) { const n = buf[offset++]; return [buf.slice(offset, offset+n), offset+n]; } + if (b === 0xc5) { const n = (buf[offset] << 8) | buf[offset+1]; offset += 2; return [buf.slice(offset, offset+n), offset+n]; } + if (b === 0xc6) { const n = buf.readUInt32BE(offset); offset += 4; return [buf.slice(offset, offset+n), offset+n]; } + // negative fixint + if ((b & 0xe0) === 0xe0) return [b - 256, offset]; + + console.log(`[MSGPACK] Unknown type byte: 0x${b.toString(16)} at offset ${offset-1}`); + return [null, offset]; +} + +function handleBinaryFrame(buf) { + try { + const [packet] = mpDecode(buf); + if (!packet || typeof packet !== 'object') return; + + const type = packet.type; + const data = packet.data; + + // type=0 = CONNECT ack: { type: 0, data: { sid, pid }, nsp: "/" } + if (type === 0 && data && typeof data === 'object' && data.sid) { + console.log(`[WS] Namespace connected — sid=${data.sid}`); + namespaceReady = true; + broadcast({ _ft: 'ws_status', status: 'connected' }); + sendSubscriptions(); + return; + } + + // type=2 = EVENT: { type: 2, data: [eventName, ...payloads], nsp: "/" } + if (type === 2 && Array.isArray(data) && data.length >= 1) { + const eventName = data[0]; + const eventPayload = data.length === 2 ? data[1] : data.slice(1); + + // Skip internal room/presence bookkeeping + if (eventName === 'chat:room') { + console.log(`[WS] Room assigned: ${JSON.stringify(eventPayload)}`); + return; + } + + if (eventName !== 'chat:message') { + console.log(` +[WS EVENT] "${eventName}" ${JSON.stringify(eventPayload).slice(0, 160)}`); + } + broadcast({ _ft: 'event', event: eventName, data: eventPayload }); + return; + } + + // Anything else — log raw for debugging + console.log(`[WS PACKET] type=${type} data=${JSON.stringify(data).slice(0, 200)}`); + + } catch(e) { + console.log('[WS] Binary decode error:', e.message, e.stack); + } +} + +// ── Connect to fishtank ────────────────────────────────────── +function connectFishtankWS(token) { + if (ftSocket) { ftSocket.removeAllListeners(); ftSocket.terminate(); ftSocket = null; } + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + namespaceReady = false; + if (token) ftToken = token; + + console.log('\n[WS] Connecting to fishtank...'); + + ftSocket = new WebSocket(FT_WS_URL, { + headers: { + 'origin': 'https://www.fishtank.live', + 'host': 'ws.fishtank.live', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0', + } + }); + + ftSocket.on('open', () => { + console.log('[WS] Connected to ws.fishtank.live'); + }); + + ftSocket.on('message', (data, isBinary) => { + if (isBinary) { + handleBinaryFrame(Buffer.isBuffer(data) ? data : Buffer.from(data)); + return; + } + + const msg = (Buffer.isBuffer(data) ? data : Buffer.from(data)).toString('utf8'); + + // Engine.IO handshake + if (msg.startsWith('0')) { + try { + const info = JSON.parse(msg.slice(1)); + console.log(`[WS] Handshake: sid=${info.sid} pingInterval=${info.pingInterval}ms`); + broadcast({ _ft: 'ws_status', status: 'connecting' }); + } catch(e) {} + // Send auth frame right after handshake + sendAuthFrame(); + return; + } + + // Server ping — pong back + if (msg === '2') { ftSocket.send('3'); return; } + + console.log(`[WS TEXT] ${msg.slice(0, 200)}`); + }); + + ftSocket.on('close', (code, reason) => { + console.log(`[WS] Disconnected (${code} ${reason || ''}). Reconnecting in 5s...`); + broadcast({ _ft: 'ws_status', status: 'disconnected' }); + namespaceReady = false; + reconnectTimer = setTimeout(() => connectFishtankWS(null), 5000); + }); + + ftSocket.on('error', (err) => { + console.log(`[WS] Error: ${err.message}`); + }); +} + +// ── HTTP helpers ───────────────────────────────────────────── function fetchRemote(hostname, targetPath, callback) { - const options = { - hostname, - port: 443, - path: targetPath, - method: 'GET', + const req = https.request({ + hostname, port: 443, path: targetPath, method: 'GET', headers: { 'host': hostname, 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', @@ -21,141 +305,115 @@ function fetchRemote(hostname, targetPath, callback) { 'origin': 'https://www.fishtank.live', 'referer': 'https://www.fishtank.live/', }, - }; - - const req = https.request(options, (remoteRes) => { + }, (res) => { const chunks = []; - remoteRes.on('data', chunk => chunks.push(chunk)); - remoteRes.on('end', () => callback(null, remoteRes, Buffer.concat(chunks))); + res.on('data', c => chunks.push(c)); + res.on('end', () => callback(null, res, Buffer.concat(chunks))); }); req.on('error', err => callback(err)); req.end(); } function rewriteM3u8(body, basePath) { - // basePath = e.g. /dirc-5/ return body.split('\n').map(line => { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) { - // Rewrite URI= attributes inside tag lines e.g. URI="tracks-a1/index.fmp4.m3u8" - return trimmed.replace(/URI="([^"]+)"/g, (match, uri) => { - if (uri.startsWith('http')) return match; - return `URI="http://localhost:${PORT}/cam${basePath}${uri}"`; - }); + 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 (trimmed.startsWith('http')) return trimmed; - return `http://localhost:${PORT}/cam${basePath}${trimmed}`; + if (t.startsWith('http')) return t; + return `http://localhost:${PORT}/cam${basePath}${t}`; }).join('\n'); } function proxyApi(req, res, targetPath) { - const options = { - hostname: API_TARGET, - port: 443, - path: targetPath, - method: req.method, - headers: { - ...req.headers, - host: API_TARGET, - origin: 'https://www.fishtank.live', - referer: 'https://www.fishtank.live/', - }, + const opts = { + hostname: API_TARGET, port: 443, path: targetPath, method: req.method, + headers: { ...req.headers, host: API_TARGET, origin: 'https://www.fishtank.live', referer: 'https://www.fishtank.live/' }, }; - delete options.headers['accept-encoding']; - - const proxy = https.request(options, (apiRes) => { - res.writeHead(apiRes.statusCode, { - ...apiRes.headers, - 'access-control-allow-origin': '*', - 'access-control-allow-headers': 'Authorization, Content-Type', - }); + delete opts.headers['accept-encoding']; + const proxy = https.request(opts, (apiRes) => { + res.writeHead(apiRes.statusCode, { ...apiRes.headers, 'access-control-allow-origin': '*', 'access-control-allow-headers': 'Authorization, Content-Type' }); apiRes.pipe(res); }); proxy.on('error', err => { res.writeHead(502); res.end(err.message); }); req.pipe(proxy); } +// ── HTTP server ────────────────────────────────────────────── const server = http.createServer((req, res) => { const parsed = url.parse(req.url); if (req.method === 'OPTIONS') { - res.writeHead(204, { - 'access-control-allow-origin': '*', - 'access-control-allow-methods': 'GET, POST, OPTIONS', - 'access-control-allow-headers': 'Authorization, Content-Type', - }); - res.end(); - return; + res.writeHead(204, { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-headers': 'Authorization, Content-Type' }); + res.end(); return; } - // Serve dashboard if (parsed.pathname === '/' || parsed.pathname === '/dashboard') { const file = path.join(__dirname, 'fishtank-dashboard.html'); fs.readFile(file, (err, data) => { if (err) { res.writeHead(404); res.end('Dashboard not found'); return; } res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(data); - }); - return; + }); return; + } + + // Token registration from dashboard + if (parsed.pathname === '/ws-token' && req.method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const { token } = JSON.parse(body); + if (token && token !== ftToken) { + console.log('[WS] Token received, reconnecting with auth...'); + connectFishtankWS(token); + } + } catch(e) {} + res.writeHead(200, { 'Content-Type': 'application/json', 'access-control-allow-origin': '*' }); + res.end(JSON.stringify({ ok: true })); + }); return; } - // API proxy if (parsed.pathname.startsWith('/api/')) { proxyApi(req, res, parsed.pathname.replace('/api', '') + (parsed.search || '')); return; } - // Camera proxy if (parsed.pathname.startsWith('/cam/')) { const targetPath = parsed.pathname.replace('/cam', '') + (parsed.search || ''); - const isM3u8 = targetPath.includes('.m3u8'); - - if (!isM3u8) { - // Raw segment — pipe directly + 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.writeHead(remoteRes.statusCode, { ...remoteRes.headers, 'access-control-allow-origin': '*' }); res.end(body); - }); - return; + }); return; } - - // M3u8 — fetch and rewrite relative URLs fetchRemote(CAM_TARGET, targetPath, (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; - } - - // Base path = directory containing this m3u8 - // e.g. /dirc-5/tracks-v2/index.fmp4.m3u8 → /dirc-5/tracks-v2/ + if (remoteRes.statusCode !== 200) { res.writeHead(remoteRes.statusCode); res.end(body); return; } const basePath = targetPath.substring(0, targetPath.lastIndexOf('/') + 1); const rewritten = rewriteM3u8(body.toString('utf8'), basePath); - - console.log(`✓ m3u8 ${targetPath} (${rewritten.split('\n').length} lines)`); - - res.writeHead(200, { - 'content-type': 'application/vnd.apple.mpegurl', - 'access-control-allow-origin': '*', - 'cache-control': 'no-cache', - }); + res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl', 'access-control-allow-origin': '*', 'cache-control': 'no-cache' }); res.end(rewritten); - }); - return; + }); return; } - res.writeHead(404); - res.end('Not found'); + res.writeHead(404); res.end('Not found'); }); +// ── Local WebSocket server ─────────────────────────────────── +const wss = new WebSocket.Server({ server }); +wss.on('connection', (ws) => { + localClients.add(ws); + ws.send(JSON.stringify({ _ft: 'ws_status', status: namespaceReady ? 'connected' : 'disconnected' })); + ws.on('close', () => localClients.delete(ws)); + ws.on('error', () => localClients.delete(ws)); +}); + +// ── Start ──────────────────────────────────────────────────── server.listen(PORT, () => { console.log('✓ Dashboard → http://localhost:' + PORT); - console.log('✓ Test cam → http://localhost:' + PORT + '/cam/dirc-5/index.m3u8'); - console.log('\nPress Ctrl+C to stop'); + console.log('\nPress Ctrl+C to stop\n'); + connectFishtankWS(null); }); -// This line intentionally left blank