websocket update, fishtracker added

This commit is contained in:
fishtank-dashboard
2026-03-17 14:52:14 -07:00
committed by GitHub
parent a2984e6edf
commit 2f50144fe7
3 changed files with 1117 additions and 187 deletions

View File

@@ -1,6 +1,6 @@
# Fishtank Monitor # 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:** **On Windows:**
1. Open the folder where you put the files 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: You should see:
``` ```
✓ Dashboard → http://localhost:3000 ✓ Dashboard → http://localhost:3000
✓ API proxy → http://localhost:3000/api/v1/...
✓ Cam proxy → http://localhost:3000/cam/<stream>/index.m3u8
``` ```
**Leave this window open** — closing it stops the dashboard. **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: 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:** **To get your token:**
1. Go to **fishtank.live** and log in 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 1. Click the **LINK API** button in the top-left of the dashboard
2. Paste the value you copied into the input field 2. Paste the value you copied into the input field
3. Press Enter or click **CONNECT** 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. 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 - **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 - **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 ### 📊 Polls
- Shows the current active poll with live vote bars - Shows the current active poll with live vote bars
- Displays the winner of the last poll - 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:** **"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. 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:** **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. 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.

File diff suppressed because it is too large Load Diff

430
server.js
View File

@@ -3,17 +3,301 @@ const https = require('https');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const url = require('url'); const url = require('url');
const WebSocket = require('ws');
const PORT = 3000; const PORT = 3000;
const API_TARGET = 'api.fishtank.live'; const API_TARGET = 'api.fishtank.live';
const CAM_TARGET = 'epyc.goran.jetzt'; 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: "<jwt>" }, 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: ['<channel>'], 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) { function fetchRemote(hostname, targetPath, callback) {
const options = { const req = https.request({
hostname, hostname, port: 443, path: targetPath, method: 'GET',
port: 443,
path: targetPath,
method: 'GET',
headers: { headers: {
'host': hostname, 'host': hostname,
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', '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', 'origin': 'https://www.fishtank.live',
'referer': 'https://www.fishtank.live/', 'referer': 'https://www.fishtank.live/',
}, },
}; }, (res) => {
const req = https.request(options, (remoteRes) => {
const chunks = []; const chunks = [];
remoteRes.on('data', chunk => chunks.push(chunk)); res.on('data', c => chunks.push(c));
remoteRes.on('end', () => callback(null, remoteRes, Buffer.concat(chunks))); res.on('end', () => callback(null, res, Buffer.concat(chunks)));
}); });
req.on('error', err => callback(err)); req.on('error', err => callback(err));
req.end(); req.end();
} }
function rewriteM3u8(body, basePath) { function rewriteM3u8(body, basePath) {
// basePath = e.g. /dirc-5/
return body.split('\n').map(line => { return body.split('\n').map(line => {
const trimmed = line.trim(); const t = line.trim();
if (!trimmed || trimmed.startsWith('#')) { if (!t || t.startsWith('#')) {
// Rewrite URI= attributes inside tag lines e.g. URI="tracks-a1/index.fmp4.m3u8" return t.replace(/URI="([^"]+)"/g, (m, uri) =>
return trimmed.replace(/URI="([^"]+)"/g, (match, uri) => { uri.startsWith('http') ? m : `URI="http://localhost:${PORT}/cam${basePath}${uri}"`);
if (uri.startsWith('http')) return match;
return `URI="http://localhost:${PORT}/cam${basePath}${uri}"`;
});
} }
if (trimmed.startsWith('http')) return trimmed; if (t.startsWith('http')) return t;
return `http://localhost:${PORT}/cam${basePath}${trimmed}`; return `http://localhost:${PORT}/cam${basePath}${t}`;
}).join('\n'); }).join('\n');
} }
function proxyApi(req, res, targetPath) { function proxyApi(req, res, targetPath) {
const options = { const opts = {
hostname: API_TARGET, hostname: API_TARGET, port: 443, path: targetPath, method: req.method,
port: 443, headers: { ...req.headers, host: API_TARGET, origin: 'https://www.fishtank.live', referer: 'https://www.fishtank.live/' },
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']; delete opts.headers['accept-encoding'];
const proxy = https.request(opts, (apiRes) => {
const proxy = https.request(options, (apiRes) => { res.writeHead(apiRes.statusCode, { ...apiRes.headers, 'access-control-allow-origin': '*', 'access-control-allow-headers': 'Authorization, Content-Type' });
res.writeHead(apiRes.statusCode, {
...apiRes.headers,
'access-control-allow-origin': '*',
'access-control-allow-headers': 'Authorization, Content-Type',
});
apiRes.pipe(res); apiRes.pipe(res);
}); });
proxy.on('error', err => { res.writeHead(502); res.end(err.message); }); proxy.on('error', err => { res.writeHead(502); res.end(err.message); });
req.pipe(proxy); req.pipe(proxy);
} }
// ── HTTP server ──────────────────────────────────────────────
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const parsed = url.parse(req.url); const parsed = url.parse(req.url);
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
res.writeHead(204, { res.writeHead(204, { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-headers': 'Authorization, Content-Type' });
'access-control-allow-origin': '*', res.end(); return;
'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') { if (parsed.pathname === '/' || parsed.pathname === '/dashboard') {
const file = path.join(__dirname, 'fishtank-dashboard.html'); const file = path.join(__dirname, 'fishtank-dashboard.html');
fs.readFile(file, (err, data) => { fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404); res.end('Dashboard not found'); return; } if (err) { res.writeHead(404); res.end('Dashboard not found'); return; }
res.writeHead(200, { 'Content-Type': 'text/html' }); res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(data); 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/')) { if (parsed.pathname.startsWith('/api/')) {
proxyApi(req, res, parsed.pathname.replace('/api', '') + (parsed.search || '')); proxyApi(req, res, parsed.pathname.replace('/api', '') + (parsed.search || ''));
return; return;
} }
// Camera proxy
if (parsed.pathname.startsWith('/cam/')) { if (parsed.pathname.startsWith('/cam/')) {
const targetPath = parsed.pathname.replace('/cam', '') + (parsed.search || ''); const targetPath = parsed.pathname.replace('/cam', '') + (parsed.search || '');
const isM3u8 = targetPath.includes('.m3u8'); if (!targetPath.includes('.m3u8')) {
if (!isM3u8) {
// Raw segment — pipe directly
fetchRemote(CAM_TARGET, targetPath, (err, remoteRes, body) => { fetchRemote(CAM_TARGET, targetPath, (err, remoteRes, body) => {
if (err) { res.writeHead(502); res.end(err.message); return; } if (err) { res.writeHead(502); res.end(err.message); return; }
res.writeHead(remoteRes.statusCode, { res.writeHead(remoteRes.statusCode, { ...remoteRes.headers, 'access-control-allow-origin': '*' });
...remoteRes.headers,
'access-control-allow-origin': '*',
});
res.end(body); res.end(body);
}); }); return;
return;
} }
// M3u8 — fetch and rewrite relative URLs
fetchRemote(CAM_TARGET, targetPath, (err, remoteRes, body) => { fetchRemote(CAM_TARGET, targetPath, (err, remoteRes, body) => {
if (err) { res.writeHead(502); res.end(err.message); return; } if (err) { res.writeHead(502); res.end(err.message); return; }
if (remoteRes.statusCode !== 200) { if (remoteRes.statusCode !== 200) { res.writeHead(remoteRes.statusCode); res.end(body); return; }
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/
const basePath = targetPath.substring(0, targetPath.lastIndexOf('/') + 1); const basePath = targetPath.substring(0, targetPath.lastIndexOf('/') + 1);
const rewritten = rewriteM3u8(body.toString('utf8'), basePath); const rewritten = rewriteM3u8(body.toString('utf8'), basePath);
res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl', 'access-control-allow-origin': '*', 'cache-control': 'no-cache' });
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.end(rewritten); res.end(rewritten);
}); }); return;
return;
} }
res.writeHead(404); res.writeHead(404); res.end('Not found');
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, () => { server.listen(PORT, () => {
console.log('✓ Dashboard → http://localhost:' + 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\n');
console.log('\nPress Ctrl+C to stop'); connectFishtankWS(null);
}); });
// This line intentionally left blank