mirror of
https://github.com/fishtank-dashboard/fishtank-dashboard.git
synced 2026-04-30 10:42:02 -04:00
775 lines
29 KiB
JavaScript
775 lines
29 KiB
JavaScript
const http = require('http');
|
|
const https = require('https');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const url = require('url');
|
|
const zlib = require('zlib');
|
|
const os = require('os');
|
|
const { execFile } = require('child_process');
|
|
const WebSocket = require('ws');
|
|
|
|
const PORT = 3000;
|
|
const API_TARGET = 'api.fishtank.live';
|
|
const CAM_TARGET = 'epyc.goran.jetzt';
|
|
const CAM_PORT = 443;
|
|
|
|
// ── YouTube live stream proxy ────────────────────────────────
|
|
// Per-slug YouTube video IDs
|
|
const YT_VIDEO_IDS = {
|
|
'dirc-5': 'lYK5M3PCnNg',
|
|
'foyr-5': 'MvaKWOQRHkA',
|
|
'gsrm-5': 'TYPEH85q3JU',
|
|
'mrke-5': 'm8BoYX8MRxQ',
|
|
'mrke2-5': 'VKactnWtMLU',
|
|
'hwdn-5': 'PFGmM_L63O4',
|
|
'dmrm-5': 'TohEVS4CYn0',
|
|
'dmrm2-5': '_3EdEfoyhtI',
|
|
'dmcl-5': 'eWRFlT9m94c',
|
|
'jckz-5': 'q4C-ePNmuEU',
|
|
'brrr-5': 'RCqC9H10HsE',
|
|
'brrr2-5': '5NB7X9QJRtA',
|
|
'brpz-5': '8vacKvkKI0U',
|
|
'ktch-5': 'DfIYesgiGfY',
|
|
'dnrm-5': 's2xzD6V4mKI',
|
|
'hwup-5': 'k6MDn6y9Jjs',
|
|
'bbcl-5': 'bJpr7Ueutag',
|
|
'br4j-5': 'u1Zcyl5zrN4',
|
|
'bkny-5': 'JWJDpCQyNh8',
|
|
'codr-5': 'b1M083H1pis',
|
|
'cfsl-5': 'HB7KvlwP2Rs',
|
|
'bare-5': 'UcpOf1t-Sh0',
|
|
'jobb-5': 'hBJtFeLXOi4',
|
|
};
|
|
|
|
const ytManifests = {}; // slug -> current manifest URL
|
|
const ytRefreshing = {}; // slug -> bool
|
|
const ytTimers = {}; // slug -> refresh timer
|
|
|
|
function refreshYtSlug(slug, callback) {
|
|
const videoId = YT_VIDEO_IDS[slug];
|
|
if (!videoId) { if (callback) callback(new Error('no video ID for ' + slug)); return; }
|
|
if (ytRefreshing[slug]) { if (callback) callback(new Error('refresh in progress')); return; }
|
|
ytRefreshing[slug] = true;
|
|
execFile('yt-dlp', [
|
|
'--no-warnings',
|
|
'-f', '95/93/91/hls-1080/hls-720/hls-480/hls-360/best',
|
|
'--no-playlist',
|
|
'-g',
|
|
'https://www.youtube.com/watch?v=' + videoId
|
|
], { shell: true, timeout: 30000 }, (err, stdout) => {
|
|
ytRefreshing[slug] = false;
|
|
if (err) { if (callback) callback(err); return; }
|
|
const url = stdout.trim().split('\n')[0];
|
|
if (!url || !url.startsWith('http')) { if (callback) callback(new Error('no URL')); return; }
|
|
console.log('[YT]', slug, 'url type:', url.includes('.m3u8') ? 'HLS' : url.includes('manifest') ? 'manifest' : 'direct', url.slice(0, 80));
|
|
ytManifests[slug] = url;
|
|
if (ytTimers[slug]) clearTimeout(ytTimers[slug]);
|
|
ytTimers[slug] = setTimeout(() => refreshYtSlug(slug), 4 * 60 * 60 * 1000);
|
|
if (callback) callback(null, url);
|
|
});
|
|
}
|
|
|
|
function refreshAllYt() {
|
|
const slugs = Object.keys(YT_VIDEO_IDS);
|
|
console.log('[YT] Refreshing all', slugs.length, 'streams...');
|
|
let done = 0;
|
|
slugs.forEach(slug => {
|
|
refreshYtSlug(slug, (err) => {
|
|
done++;
|
|
if (!err) console.log('[YT] Ready:', slug);
|
|
const ready = slugs.filter(s => !!ytManifests[s]).length;
|
|
broadcast({ _ft: 'yt_status', ready, total: slugs.length });
|
|
});
|
|
});
|
|
}
|
|
|
|
function proxyYtUrl(targetUrl, res) {
|
|
let parsedUrl;
|
|
try { parsedUrl = new URL(targetUrl); } catch(e) { res.writeHead(400); res.end('bad url'); return; }
|
|
const opts = {
|
|
hostname: parsedUrl.hostname, port: 443,
|
|
path: parsedUrl.pathname + parsedUrl.search, method: 'GET',
|
|
headers: { 'user-agent': 'Mozilla/5.0', 'accept': '*/*' },
|
|
};
|
|
const req = https.request(opts, (upstream) => {
|
|
const ct = upstream.headers['content-type'] || '';
|
|
// Buffer first chunk to sniff if it's a playlist — don't rely on URL
|
|
const chunks = [];
|
|
let decided = false;
|
|
let isPlaylist = null;
|
|
|
|
function decide(firstChunk) {
|
|
if (decided) return;
|
|
decided = true;
|
|
// Check if content starts with #EXTM3U
|
|
isPlaylist = firstChunk.toString('utf8', 0, 7) === '#EXTM3U' || ct.includes('mpegurl');
|
|
if (!isPlaylist) {
|
|
// Stream as binary
|
|
const headers = { 'access-control-allow-origin': '*', 'cache-control': 'no-cache' };
|
|
if (ct) headers['content-type'] = ct;
|
|
res.writeHead(upstream.statusCode, headers);
|
|
for (const c of chunks) res.write(c);
|
|
upstream.pipe(res);
|
|
}
|
|
}
|
|
|
|
upstream.on('data', c => {
|
|
chunks.push(c);
|
|
if (!decided) decide(chunks[0]);
|
|
});
|
|
upstream.on('end', () => {
|
|
if (!decided) decide(chunks[0] || Buffer.alloc(0));
|
|
if (!isPlaylist) return; // already piped
|
|
// Rewrite playlist URLs
|
|
let text = Buffer.concat(chunks).toString('utf8');
|
|
text = text.split('\n').map(line => {
|
|
const t = line.trim();
|
|
if (!t || t.startsWith('#')) return line;
|
|
try {
|
|
const fullUrl = t.startsWith('http') ? t : new URL(t, targetUrl).href;
|
|
return 'http://localhost:' + PORT + '/yt-seg?url=' + encodeURIComponent(fullUrl);
|
|
} catch(e) { return line; }
|
|
}).join('\n');
|
|
res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl', 'access-control-allow-origin': '*', 'cache-control': 'no-cache' });
|
|
res.end(text);
|
|
});
|
|
});
|
|
req.on('error', err => { if (!res.headersSent) { res.writeHead(502); res.end(err.message); } });
|
|
req.end();
|
|
}
|
|
const FT_WS_URL = 'wss://ws.fishtank.live/socket.io/?EIO=4&transport=websocket';
|
|
|
|
// ── Local WS clients (dashboard connections) ────────────────
|
|
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) {
|
|
return;
|
|
}
|
|
sendBinary(buildAuthFrame(ftToken));
|
|
}
|
|
|
|
function sendSubscriptions() {
|
|
sendBinary(buildSubscribeFrame('chat:presence'));
|
|
sendBinary(buildSubscribeFrame('presence'));
|
|
}
|
|
|
|
// Presence is client-driven — must re-request every 30s to get updated counts
|
|
let presenceTimer = null;
|
|
|
|
function startPresencePolling() {
|
|
if (presenceTimer) clearInterval(presenceTimer);
|
|
presenceTimer = setInterval(() => {
|
|
if (ftSocket && ftSocket.readyState === 1) {
|
|
sendBinary(buildSubscribeFrame('presence'));
|
|
}
|
|
}, 30000);
|
|
}
|
|
|
|
function stopPresencePolling() {
|
|
if (presenceTimer) { clearInterval(presenceTimer); presenceTimer = null; }
|
|
}
|
|
|
|
// ── 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];
|
|
|
|
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();
|
|
startPresencePolling();
|
|
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') {
|
|
return;
|
|
}
|
|
|
|
if (eventName !== 'chat:message') {
|
|
}
|
|
broadcast({ _ft: 'event', event: eventName, data: eventPayload });
|
|
return;
|
|
}
|
|
|
|
// Anything else — log raw for debugging
|
|
|
|
} 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; }
|
|
|
|
});
|
|
|
|
ftSocket.on('close', (code, reason) => {
|
|
console.log(`[WS] Disconnected (${code} ${reason || ''}). Reconnecting in 5s...`);
|
|
broadcast({ _ft: 'ws_status', status: 'disconnected' });
|
|
namespaceReady = false;
|
|
stopPresencePolling();
|
|
reconnectTimer = setTimeout(() => connectFishtankWS(null), 5000);
|
|
});
|
|
|
|
ftSocket.on('error', (err) => {
|
|
console.log(`[WS] Error: ${err.message}`);
|
|
});
|
|
}
|
|
|
|
// ── 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',
|
|
headers: {
|
|
'host': hostname,
|
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
'accept': '*/*',
|
|
'origin': 'https://www.fishtank.live',
|
|
'referer': 'https://www.fishtank.live/',
|
|
},
|
|
}, (res) => {
|
|
const 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 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;
|
|
if (t.startsWith('http')) return t;
|
|
if (t.startsWith('/')) return `http://localhost:${PORT}/cam${t}`;
|
|
return `http://localhost:${PORT}/cam/${base}${t}`;
|
|
}).join('\n');
|
|
}
|
|
|
|
function proxyApi(req, res, targetPath) {
|
|
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 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (parsed.pathname === '/chat') {
|
|
const file = path.join(__dirname, 'chat-popout.html');
|
|
fs.readFile(file, (err, data) => {
|
|
if (err) { res.writeHead(404); res.end('Chat not found'); return; }
|
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
res.end(data);
|
|
}); return;
|
|
}
|
|
|
|
// YouTube stream endpoints
|
|
// /yt-refresh — trigger full refresh of all YT manifests
|
|
if (parsed.pathname === '/yt-refresh' && req.method === 'POST') {
|
|
refreshAllYt();
|
|
res.writeHead(200, { 'Content-Type': 'application/json', 'access-control-allow-origin': '*' });
|
|
res.end(JSON.stringify({ ok: true, slugs: Object.keys(YT_VIDEO_IDS).length }));
|
|
return;
|
|
}
|
|
|
|
// /yt-status — report which slugs have manifests ready
|
|
if (parsed.pathname === '/yt-status') {
|
|
const ready = Object.keys(YT_VIDEO_IDS).filter(s => !!ytManifests[s]);
|
|
res.writeHead(200, { 'Content-Type': 'application/json', 'access-control-allow-origin': '*' });
|
|
res.end(JSON.stringify({ ready: ready.length, total: Object.keys(YT_VIDEO_IDS).length, slugs: ready }));
|
|
return;
|
|
}
|
|
|
|
// /yt-stream/:slug — serve manifest for a specific camera
|
|
if (parsed.pathname.startsWith('/yt-stream/')) {
|
|
const slug = parsed.pathname.replace('/yt-stream/', '');
|
|
if (!ytManifests[slug]) {
|
|
// Try to fetch on demand if not cached yet
|
|
if (YT_VIDEO_IDS[slug] && !ytRefreshing[slug]) {
|
|
refreshYtSlug(slug, (err, url) => {
|
|
if (err || !url) { res.writeHead(503); res.end('YT stream not ready for ' + slug); return; }
|
|
proxyYtUrl(url, res);
|
|
});
|
|
} else {
|
|
res.writeHead(503); res.end('YT stream not ready for ' + slug);
|
|
}
|
|
return;
|
|
}
|
|
proxyYtUrl(ytManifests[slug], res);
|
|
return;
|
|
}
|
|
|
|
// /yt-debug/:slug — return the raw manifest URL for testing in VLC/browser
|
|
if (parsed.pathname.startsWith('/yt-debug/')) {
|
|
const slug = parsed.pathname.replace('/yt-debug/', '');
|
|
const manifest = ytManifests[slug];
|
|
const videoId = YT_VIDEO_IDS[slug];
|
|
res.writeHead(200, { 'Content-Type': 'text/plain', 'access-control-allow-origin': '*' });
|
|
const lines = [
|
|
'slug: ' + slug,
|
|
'videoId: ' + (videoId || 'unknown'),
|
|
'ytUrl: https://www.youtube.com/watch?v=' + (videoId || '?'),
|
|
'manifestUrl: ' + (manifest || 'NOT READY'),
|
|
'proxyUrl: http://localhost:3000/yt-stream/' + slug,
|
|
];
|
|
res.end(lines.join('\n'));
|
|
return;
|
|
}
|
|
|
|
// /yt-seg — proxy individual segments/playlists
|
|
if (parsed.pathname === '/yt-seg') {
|
|
let targetUrl;
|
|
try {
|
|
const fullParsed = new URL(req.url, 'http://localhost');
|
|
targetUrl = fullParsed.searchParams.get('url');
|
|
} catch(e) {}
|
|
if (!targetUrl) { res.writeHead(400); res.end('missing url'); return; }
|
|
proxyYtUrl(targetUrl, res);
|
|
return;
|
|
}
|
|
|
|
if (parsed.pathname === '/strip-audio') {
|
|
if (req.method === 'POST') {
|
|
// Accept webm upload, strip audio with ffmpeg, return result
|
|
const chunks = [];
|
|
req.on('data', c => chunks.push(c));
|
|
req.on('end', () => {
|
|
const inputBuf = Buffer.concat(chunks);
|
|
const tmpIn = path.join(os.tmpdir(), 'ft_strip_in_' + Date.now() + '.webm');
|
|
const tmpOut = path.join(os.tmpdir(), 'ft_strip_out_' + Date.now() + '.webm');
|
|
fs.writeFile(tmpIn, inputBuf, err => {
|
|
if (err) { res.writeHead(500); res.end('Write error'); return; }
|
|
// -an = no audio, -c:v copy = copy video stream without re-encoding
|
|
// Find ffmpeg — try PATH first, then common Windows locations
|
|
const ffmpegCandidates = [
|
|
'ffmpeg',
|
|
'ffmpeg.exe',
|
|
'C:\\ffmpeg\\bin\\ffmpeg.exe',
|
|
'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',
|
|
process.env.APPDATA + '\\ffmpeg\\bin\\ffmpeg.exe',
|
|
];
|
|
const ffmpegBin = ffmpegCandidates[0]; // will try with shell:true to use PATH
|
|
console.log('[STRIP] running ffmpeg:', tmpIn, '->', tmpOut);
|
|
execFile(ffmpegBin, ['-y', '-i', tmpIn, '-an', '-c:v', 'copy', tmpOut],
|
|
{ shell: true }, (err, stdout, stderr) => {
|
|
console.log('[STRIP] ffmpeg done. err:', err && err.code, 'stderr:', stderr && stderr.slice(0,300));
|
|
fs.unlink(tmpIn, () => {});
|
|
if (err) {
|
|
fs.unlink(tmpOut, () => {});
|
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
res.end('FFmpeg error: ' + (stderr || err.message || 'unknown'));
|
|
return;
|
|
}
|
|
fs.readFile(tmpOut, (err, data) => {
|
|
fs.unlink(tmpOut, () => {});
|
|
if (err) { res.writeHead(500); res.end('Read error'); return; }
|
|
res.writeHead(200, {
|
|
'Content-Type': 'video/webm',
|
|
'Content-Disposition': 'attachment; filename="noaudio.webm"',
|
|
'Content-Length': data.length,
|
|
'access-control-allow-origin': '*',
|
|
});
|
|
res.end(data);
|
|
});
|
|
});
|
|
});
|
|
}); return;
|
|
}
|
|
const file = path.join(__dirname, 'strip-audio.html');
|
|
fs.readFile(file, (err, data) => {
|
|
if (err) { res.writeHead(404); res.end('Tool not found'); return; }
|
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
res.end(data);
|
|
}); 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;
|
|
}
|
|
|
|
if (parsed.pathname.startsWith('/api/')) {
|
|
proxyApi(req, res, parsed.pathname.replace('/api', '') + (parsed.search || ''));
|
|
return;
|
|
}
|
|
|
|
if (parsed.pathname.startsWith('/cam/')) {
|
|
// 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('#'));
|
|
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;
|
|
}
|
|
|
|
// 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 = 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);
|
|
}); return;
|
|
}
|
|
|
|
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('\nPress Ctrl+C to stop\n');
|
|
connectFishtankWS(null);
|
|
refreshAllYt();
|
|
});
|