mirror of
https://github.com/fishtank-dashboard/fishtank-dashboard.git
synced 2026-04-30 09:12:04 -04:00
websocket update, fishtracker added
This commit is contained in:
committed by
GitHub
parent
a2984e6edf
commit
2f50144fe7
48
README.md
48
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/<stream>/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.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
430
server.js
430
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: "<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) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user