mirror of
https://github.com/fishtank-dashboard/fishtank-dashboard.git
synced 2026-05-13 09:02:42 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8696a57213 | |||
| 5d52b6bb11 | |||
| ddf9d71386 | |||
| 7276886cfd | |||
| b5ad1669de | |||
| 3d98da66cc | |||
| 55882b2ed4 | |||
| 4f3624dd33 | |||
| e398ad69c3 | |||
| 46e2c58ba8 | |||
| 2852918a3f |
@@ -46,6 +46,12 @@ You only need to do this once. It creates a `node_modules` folder in the same di
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
Clipping now requires ffmpeg installed, on windows:
|
||||||
|
```
|
||||||
|
winget install ffmpeg
|
||||||
|
```
|
||||||
|
Or download the installer from ffmpeg.org.
|
||||||
|
|
||||||
## Step 4 — Start the Server
|
## Step 4 — Start the Server
|
||||||
|
|
||||||
**On Windows:**
|
**On Windows:**
|
||||||
@@ -165,7 +171,7 @@ If you closed the terminal window and need to stop it:
|
|||||||
|
|
||||||
### 📷 Capture Tools (in the camera panel header)
|
### 📷 Capture Tools (in the camera panel header)
|
||||||
- **📷 SNAP** — takes a full resolution PNG screenshot of the current featured camera
|
- **📷 SNAP** — takes a full resolution PNG screenshot of the current featured camera
|
||||||
- **✂ CLIP** — saves the last ~60 seconds of buffered footage as a WebM file
|
- **✂ CLIP** — opens a trimming selection window showing the last ~60secs. Drag sliders and save a trimmed clip or save the entire buffer.
|
||||||
- **⏺ REC / ⏹ STOP** — records forward from when you click. A live file size counter shows how large the recording is getting
|
- **⏺ REC / ⏹ STOP** — records forward from when you click. A live file size counter shows how large the recording is getting
|
||||||
- **3.9MB LIMIT toggle** — when enabled, recording auto-stops before hitting 4MB (useful for sites with upload limits)
|
- **3.9MB LIMIT toggle** — when enabled, recording auto-stops before hitting 4MB (useful for sites with upload limits)
|
||||||
- **🔇 CLIPS MUTED / 🔊 AUDIO toggle** — controls whether clips and recordings include audio
|
- **🔇 CLIPS MUTED / 🔊 AUDIO toggle** — controls whether clips and recordings include audio
|
||||||
|
|||||||
@@ -1,383 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>FISHTANK // CHAT</title>
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Bebas+Neue&family=DM+Sans:wght@300;400;500&display=swap');
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #080b0f;
|
|
||||||
--panel: #0d1117;
|
|
||||||
--border: #1a2332;
|
|
||||||
--accent: #00e5ff;
|
|
||||||
--accent2: #ff3d71;
|
|
||||||
--accent3: #ffe600;
|
|
||||||
--text: #c9d4e0;
|
|
||||||
--muted: #4a5568;
|
|
||||||
--green: #00ff88;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: 'DM Sans', sans-serif;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
background: var(--panel);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-family: 'Bebas Neue', sans-serif;
|
|
||||||
font-size: 18px;
|
|
||||||
letter-spacing: 3px;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
.logo span { color: var(--accent2); }
|
|
||||||
|
|
||||||
.status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-family: 'Share Tech Mono', monospace;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 7px; height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--muted);
|
|
||||||
}
|
|
||||||
.dot.live {
|
|
||||||
background: var(--green);
|
|
||||||
box-shadow: 0 0 8px var(--green);
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
.dot.error { background: var(--accent2); }
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.3; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-count {
|
|
||||||
font-family: 'Share Tech Mono', monospace;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
#chatFeed {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#chatFeed::-webkit-scrollbar { width: 4px; }
|
|
||||||
#chatFeed::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
#chatFeed::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
||||||
|
|
||||||
.msg {
|
|
||||||
padding: 7px 14px;
|
|
||||||
border-bottom: 1px solid rgba(26,35,50,0.5);
|
|
||||||
animation: slideIn 0.2s ease;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg:hover { background: rgba(255,255,255,0.02); }
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from { opacity: 0; transform: translateY(-4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 7px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-user {
|
|
||||||
font-family: 'Share Tech Mono', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: bold;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-clan {
|
|
||||||
font-family: 'Share Tech Mono', monospace;
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--muted);
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
padding: 1px 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-medals {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-endorsement {
|
|
||||||
font-family: 'Share Tech Mono', monospace;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: bold;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-time {
|
|
||||||
font-family: 'Share Tech Mono', monospace;
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-left: auto;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-text {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-text .mention {
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg.fish { border-left: 2px solid var(--accent3); background: rgba(255,230,0,0.04); }
|
|
||||||
.msg.admin { border-left: 2px solid var(--accent2); background: rgba(255,61,113,0.06); }
|
|
||||||
.msg.mod { border-left: 2px solid var(--green); background: rgba(0,255,136,0.05); }
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-family: 'Share Tech Mono', monospace;
|
|
||||||
font-size: 8px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: 2px;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.badge.admin { background: rgba(255,61,113,0.2); color: var(--accent2); border: 1px solid rgba(255,61,113,0.4); }
|
|
||||||
.badge.mod { background: rgba(0,255,136,0.1); color: var(--green); border: 1px solid rgba(0,255,136,0.3); }
|
|
||||||
.badge.fish { background: rgba(255,230,0,0.1); color: var(--accent3); border: 1px solid rgba(255,230,0,0.3); }
|
|
||||||
.badge.gm { background: rgba(255,61,113,0.15); color: var(--accent2); border: 1px solid rgba(255,61,113,0.3); }
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: 'Share Tech Mono', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.empty-icon { font-size: 32px; opacity: 0.3; }
|
|
||||||
|
|
||||||
.scroll-btn {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 12px;
|
|
||||||
right: 12px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--bg);
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: 'Share Tech Mono', monospace;
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
.scroll-btn.visible { opacity: 1; pointer-events: auto; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<div class="logo">FISH<span>TANK</span> // CHAT</div>
|
|
||||||
<div class="status">
|
|
||||||
<div class="dot" id="wsDot"></div>
|
|
||||||
<span id="wsStatus">CONNECTING</span>
|
|
||||||
</div>
|
|
||||||
<div class="msg-count" id="msgCount">0 messages</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div id="chatFeed">
|
|
||||||
<div class="empty">
|
|
||||||
<div class="empty-icon">💬</div>
|
|
||||||
<span>Waiting for messages...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="scroll-btn" id="scrollBtn" onclick="scrollToBottom()">▼ NEW MESSAGES</button>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const feed = document.getElementById('chatFeed');
|
|
||||||
const scrollBtn = document.getElementById('scrollBtn');
|
|
||||||
let msgCount = 0;
|
|
||||||
let autoScroll = true;
|
|
||||||
let ws = null;
|
|
||||||
let reconnectTimer = null;
|
|
||||||
|
|
||||||
feed.addEventListener('scroll', () => {
|
|
||||||
const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60;
|
|
||||||
autoScroll = atBottom;
|
|
||||||
scrollBtn.classList.toggle('visible', !atBottom);
|
|
||||||
});
|
|
||||||
|
|
||||||
function scrollToBottom() {
|
|
||||||
feed.scrollTop = feed.scrollHeight;
|
|
||||||
autoScroll = true;
|
|
||||||
scrollBtn.classList.remove('visible');
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(state) {
|
|
||||||
const dot = document.getElementById('wsDot');
|
|
||||||
const label = document.getElementById('wsStatus');
|
|
||||||
const map = {
|
|
||||||
connected: { cls: 'live', text: 'LIVE' },
|
|
||||||
disconnected: { cls: 'error', text: 'OFFLINE' },
|
|
||||||
connecting: { cls: '', text: 'CONNECTING' },
|
|
||||||
};
|
|
||||||
const s = map[state] || map.connecting;
|
|
||||||
dot.className = 'dot ' + s.cls;
|
|
||||||
label.textContent = s.text;
|
|
||||||
label.style.color = s.cls === 'live' ? 'var(--green)' : s.cls === 'error' ? 'var(--accent2)' : 'var(--muted)';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(ts) {
|
|
||||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRealChatMessage(msg) {
|
|
||||||
// Filter out TTS messages (user.id === 'tts')
|
|
||||||
if (msg.user && msg.user.id === 'tts') return false;
|
|
||||||
// Filter out item use messages (metadata.type === 'item')
|
|
||||||
if (msg.metadata && msg.metadata.type === 'item') return false;
|
|
||||||
// Must have a real message string
|
|
||||||
if (!msg.message || typeof msg.message !== 'string') return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addMessage(msg) {
|
|
||||||
const empty = feed.querySelector('.empty');
|
|
||||||
if (empty) empty.remove();
|
|
||||||
|
|
||||||
const user = msg.user || {};
|
|
||||||
const meta = msg.metadata || {};
|
|
||||||
const isFish = meta.isFish || false;
|
|
||||||
const isGM = meta.isGrandMarshall || false;
|
|
||||||
const isEpic = meta.isEpic || false;
|
|
||||||
const isAdmin = meta.isAdmin || false;
|
|
||||||
const isMod = meta.isMod || false;
|
|
||||||
|
|
||||||
const cls = ['msg',
|
|
||||||
isAdmin ? 'admin' : '',
|
|
||||||
isMod ? 'mod' : '',
|
|
||||||
isFish ? 'fish' : '',
|
|
||||||
isGM ? 'grand-marshall' : '',
|
|
||||||
isEpic ? 'epic' : '',
|
|
||||||
].filter(Boolean).join(' ');
|
|
||||||
|
|
||||||
// Username color: custom > endorsement color > default
|
|
||||||
let nameStyle = '';
|
|
||||||
if (user.customUsernameColor) {
|
|
||||||
nameStyle = `style="color:${user.customUsernameColor}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Endorsement tag (replaces medals)
|
|
||||||
const endorsement = user.endorsement
|
|
||||||
? `<span class="msg-endorsement" style="color:${user.endorsementColor || '#888'}">${user.endorsement}</span>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Role badges for special users only
|
|
||||||
const badges = [
|
|
||||||
isAdmin ? '<span class="badge admin">ADMIN</span>' : '',
|
|
||||||
isMod ? '<span class="badge mod">MOD</span>' : '',
|
|
||||||
isFish ? '<span class="badge fish">FISH</span>' : '',
|
|
||||||
isGM ? '<span class="badge gm">GM</span>' : '',
|
|
||||||
].filter(Boolean).join('');
|
|
||||||
|
|
||||||
const clan = user.clan ? `<span class="msg-clan">[${user.clan}]</span>` : '';
|
|
||||||
const ts = msg.timestamp ? formatTime(msg.timestamp) : '';
|
|
||||||
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = cls;
|
|
||||||
div.innerHTML = `
|
|
||||||
<div class="msg-header">
|
|
||||||
<span class="msg-user" ${nameStyle}>${user.displayName || 'unknown'}</span>
|
|
||||||
${clan}
|
|
||||||
${endorsement}
|
|
||||||
${badges}
|
|
||||||
${ts ? `<span class="msg-time">${ts}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="msg-text">${escapeHtml(String(msg.message))}</div>`;
|
|
||||||
|
|
||||||
feed.appendChild(div);
|
|
||||||
msgCount++;
|
|
||||||
document.getElementById('msgCount').textContent = msgCount.toLocaleString() + ' messages';
|
|
||||||
if (autoScroll) feed.scrollTop = feed.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
if (ws) { ws.close(); ws = null; }
|
|
||||||
setStatus('connecting');
|
|
||||||
ws = new WebSocket('ws://localhost:3000');
|
|
||||||
|
|
||||||
ws.addEventListener('open', () => setStatus('connected'));
|
|
||||||
|
|
||||||
ws.addEventListener('message', e => {
|
|
||||||
let msg;
|
|
||||||
try { msg = JSON.parse(e.data); } catch { return; }
|
|
||||||
if (msg._ft === 'ws_status') {
|
|
||||||
setStatus(msg.status === 'connected' ? 'connected' : 'disconnected');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg._ft === 'event' && msg.event === 'chat:message') {
|
|
||||||
const data = msg.data;
|
|
||||||
const messages = Array.isArray(data) ? data : [data];
|
|
||||||
messages.forEach(m => { if (isRealChatMessage(m)) addMessage(m); });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.addEventListener('close', () => {
|
|
||||||
setStatus('disconnected');
|
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
||||||
reconnectTimer = setTimeout(connect, 3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.addEventListener('error', () => setStatus('disconnected'));
|
|
||||||
}
|
|
||||||
|
|
||||||
connect();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+1876
-470
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,140 @@ 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 zlib = require('zlib');
|
||||||
|
const os = require('os');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
const WebSocket = require('ws');
|
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 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';
|
const FT_WS_URL = 'wss://ws.fishtank.live/socket.io/?EIO=4&transport=websocket';
|
||||||
|
|
||||||
// ── Local WS clients (dashboard connections) ────────────────
|
// ── Local WS clients (dashboard connections) ────────────────
|
||||||
@@ -304,6 +433,51 @@ function connectFishtankWS(token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── HTTP helpers ─────────────────────────────────────────────
|
// ── 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) {
|
function fetchRemote(hostname, targetPath, callback) {
|
||||||
const req = https.request({
|
const req = https.request({
|
||||||
hostname, port: 443, path: targetPath, method: 'GET',
|
hostname, port: 443, path: targetPath, method: 'GET',
|
||||||
@@ -324,14 +498,15 @@ function fetchRemote(hostname, targetPath, callback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rewriteM3u8(body, basePath) {
|
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 => {
|
return body.split('\n').map(line => {
|
||||||
const t = line.trim();
|
const t = line.trim();
|
||||||
if (!t || t.startsWith('#')) {
|
if (!t || t.startsWith('#')) return t;
|
||||||
return t.replace(/URI="([^"]+)"/g, (m, uri) =>
|
|
||||||
uri.startsWith('http') ? m : `URI="http://localhost:${PORT}/cam${basePath}${uri}"`);
|
|
||||||
}
|
|
||||||
if (t.startsWith('http')) return t;
|
if (t.startsWith('http')) return t;
|
||||||
return `http://localhost:${PORT}/cam${basePath}${t}`;
|
if (t.startsWith('/')) return `http://localhost:${PORT}/cam${t}`;
|
||||||
|
return `http://localhost:${PORT}/cam/${base}${t}`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +551,126 @@ const server = http.createServer((req, res) => {
|
|||||||
}); return;
|
}); 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
|
// Token registration from dashboard
|
||||||
if (parsed.pathname === '/ws-token' && req.method === 'POST') {
|
if (parsed.pathname === '/ws-token' && req.method === 'POST') {
|
||||||
let body = '';
|
let body = '';
|
||||||
@@ -399,18 +694,59 @@ const server = http.createServer((req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.pathname.startsWith('/cam/')) {
|
if (parsed.pathname.startsWith('/cam/')) {
|
||||||
const targetPath = parsed.pathname.replace('/cam', '') + (parsed.search || '');
|
// Extract slug from any cam path variant
|
||||||
if (!targetPath.includes('.m3u8')) {
|
const camPart = parsed.pathname.replace('/cam', '').replace('/hls', '').replace(/^\//, '');
|
||||||
fetchRemote(CAM_TARGET, targetPath, (err, remoteRes, body) => {
|
const parts = camPart.split('/');
|
||||||
if (err) { res.writeHead(502); res.end(err.message); return; }
|
const slug = parts[0];
|
||||||
res.writeHead(remoteRes.statusCode, { ...remoteRes.headers, 'access-control-allow-origin': '*' });
|
|
||||||
res.end(body);
|
// Is this a media segment request? e.g. /cam/dirc-5/0_1/seg001.ts
|
||||||
}); return;
|
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;
|
||||||
}
|
}
|
||||||
fetchRemote(CAM_TARGET, targetPath, (err, remoteRes, body) => {
|
|
||||||
|
// 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 (err) { res.writeHead(502); res.end(err.message); return; }
|
||||||
if (remoteRes.statusCode !== 200) { res.writeHead(remoteRes.statusCode); res.end(body); return; }
|
if (remoteRes.statusCode !== 200) { res.writeHead(remoteRes.statusCode); res.end(body); return; }
|
||||||
const basePath = targetPath.substring(0, targetPath.lastIndexOf('/') + 1);
|
const basePath = slug + '/';
|
||||||
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' });
|
res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl', 'access-control-allow-origin': '*', 'cache-control': 'no-cache' });
|
||||||
res.end(rewritten);
|
res.end(rewritten);
|
||||||
@@ -434,4 +770,5 @@ server.listen(PORT, () => {
|
|||||||
console.log('✓ Dashboard → http://localhost:' + PORT);
|
console.log('✓ Dashboard → http://localhost:' + PORT);
|
||||||
console.log('\nPress Ctrl+C to stop\n');
|
console.log('\nPress Ctrl+C to stop\n');
|
||||||
connectFishtankWS(null);
|
connectFishtankWS(null);
|
||||||
|
refreshAllYt();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user