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': 'rJXjNHA6mQ4', 'gsrm-5': 'ycEacFkkYJ0', 'mrke-5': 'U-SQGRjhG4M', 'mrke2-5': '0P_wjoBzFxg', 'hwdn-5': 'Sh_dMTwG9mI', 'dmrm-5': '4dDNVY34qZg', 'dmrm2-5': 'vju6U-bAC_E', 'dmcl-5': '24JoqBE-6qs', 'jckz-5': 'BHYt-4ELhr8', 'brrr-5': 'Sk_UkVCAbJs', 'brrr2-5': '7BIe5c0vMT4', 'brpz-5': 'Vzxm1w2j9z8', 'ktch-5': 'lLI2unNuado', 'dnrm-5': 'r0Ejn5L-g4E', 'hwup-5': 'k6MDn6y9Jjs', 'bbcl-5': 'bLTVQo4mmSc', 'br4j-5': 'jgR-hTS301I', 'bkny-5': 'isxrbll0eKQ', 'codr-5': 'tgSMY6TQ-tc', 'cfsl-5': 'tjDX_RnBI1U', 'bare-5': 'UcpOf1t-Sh0', 'jobb-5': 'BTys0Rjs4Is', }; 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: "" }, 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) { 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(); });