| require('dotenv').config(); |
|
|
| const express = require('express'); |
| const WebSocket = require('ws'); |
| const { spawn } = require('child_process'); |
| const http = require('http'); |
|
|
| |
| const PORT = process.env.PORT || 7860; |
|
|
| const STREAM_KEY = process.env.STREAM_KEY; |
|
|
| const AUTH_TOKEN = process.env.AUTH_TOKEN; |
|
|
| let rawWeb = |
| process.env.WEB || |
| 'webbb-production-b681.up.railway.app'; |
|
|
| const ALLOWED_ORIGIN_HOST = rawWeb |
| .replace(/^https?:\/\//, '') |
| .replace(/\/$/, ''); |
|
|
| const RTMP_URL = |
| `rtmp://a.rtmp.youtube.com/live2/${STREAM_KEY}`; |
|
|
| if (!STREAM_KEY || !AUTH_TOKEN) { |
|
|
| console.error( |
| 'β Missing STREAM_KEY or AUTH_TOKEN' |
| ); |
|
|
| process.exit(1); |
| } |
|
|
| |
| |
|
|
| const QUALITY_LEVELS = [ |
|
|
| { |
| name: '1080p', |
| width: 1920, |
| height: 1080, |
| bitrate: 3500, |
| audio: 128 |
| }, |
|
|
| { |
| name: '720p', |
| width: 1280, |
| height: 720, |
| bitrate: 2200, |
| audio: 128 |
| }, |
|
|
| { |
| name: '480p', |
| width: 854, |
| height: 480, |
| bitrate: 1000, |
| audio: 96 |
| } |
| ]; |
|
|
| |
| const state = { |
|
|
| ffmpeg: null, |
|
|
| activeClient: null, |
|
|
| isStreaming: false, |
|
|
| lastDataTime: 0, |
|
|
| startTime: 0, |
|
|
| restarting: false, |
|
|
| cleaning: false, |
|
|
| |
| qualityIndex: 1, |
|
|
| |
| bufferWarnings: 0 |
| }; |
|
|
| function getQuality() { |
|
|
| return QUALITY_LEVELS[ |
| state.qualityIndex |
| ]; |
| } |
|
|
| const app = express(); |
|
|
| const server = http.createServer(app); |
|
|
| |
| app.get('/', (_, res) => { |
|
|
| res.send( |
| 'π Titan Engine Online' |
| ); |
| }); |
|
|
| app.get('/health', (_, res) => { |
|
|
| const q = getQuality(); |
|
|
| res.json({ |
|
|
| streaming: state.isStreaming, |
|
|
| quality: q.name, |
|
|
| uptime: state.startTime |
| ? Math.floor( |
| ( |
| Date.now() - |
| state.startTime |
| ) / 1000 |
| ) |
| : 0, |
|
|
| ffmpeg: !!state.ffmpeg |
| }); |
| }); |
|
|
| |
| async function deepCleanup() { |
|
|
| if (state.cleaning) return; |
|
|
| state.cleaning = true; |
|
|
| console.log( |
| 'π§Ή Cleaning session...' |
| ); |
|
|
| try { |
|
|
| state.isStreaming = false; |
|
|
| if (state.activeClient) { |
|
|
| try { |
|
|
| state.activeClient.terminate(); |
|
|
| } catch {} |
| } |
|
|
| state.activeClient = null; |
|
|
| if (state.ffmpeg) { |
|
|
| try { |
|
|
| if (state.ffmpeg.stdin) { |
|
|
| state.ffmpeg.stdin.destroy(); |
| } |
|
|
| } catch {} |
|
|
| try { |
|
|
| if ( |
| process.platform === 'win32' |
| ) { |
|
|
| state.ffmpeg.kill( |
| 'SIGKILL' |
| ); |
|
|
| } else { |
|
|
| process.kill( |
| -state.ffmpeg.pid, |
| 'SIGKILL' |
| ); |
| } |
|
|
| } catch { |
|
|
| try { |
|
|
| state.ffmpeg.kill( |
| 'SIGKILL' |
| ); |
|
|
| } catch {} |
| } |
|
|
| state.ffmpeg = null; |
| } |
|
|
| console.log( |
| 'β
Session cleaned' |
| ); |
|
|
| } finally { |
|
|
| state.cleaning = false; |
| } |
| } |
|
|
| |
| function safeRestartFFmpeg() { |
|
|
| if ( |
| state.restarting || |
| state.cleaning |
| ) return; |
|
|
| state.restarting = true; |
|
|
| console.log( |
| 'π Restarting FFmpeg...' |
| ); |
|
|
| setTimeout(() => { |
|
|
| if (state.isStreaming) { |
|
|
| startFFmpeg(); |
| } |
|
|
| state.restarting = false; |
|
|
| }, 2000); |
| } |
|
|
| |
| async function checkAdaptiveQuality() { |
|
|
| if ( |
| !state.ffmpeg || |
| !state.ffmpeg.stdin |
| ) return; |
|
|
| const buffer = |
| state.ffmpeg.stdin.writableLength || 0; |
|
|
| |
| if (buffer > 2 * 1024 * 1024) { |
|
|
| state.bufferWarnings++; |
|
|
| } else { |
|
|
| state.bufferWarnings = 0; |
| } |
|
|
| |
| if (state.bufferWarnings >= 3) { |
|
|
| if ( |
| state.qualityIndex < |
| QUALITY_LEVELS.length - 1 |
| ) { |
|
|
| state.qualityIndex++; |
|
|
| console.log( |
| `π Weak internet β ${getQuality().name}` |
| ); |
|
|
| await restartEncoder(); |
| } |
|
|
| state.bufferWarnings = 0; |
| } |
|
|
| |
| if ( |
| buffer < 300 * 1024 && |
| state.qualityIndex > 0 |
| ) { |
|
|
| state.qualityIndex--; |
|
|
| console.log( |
| `π Strong internet β ${getQuality().name}` |
| ); |
|
|
| await restartEncoder(); |
| } |
| } |
|
|
| async function restartEncoder() { |
|
|
| if (state.ffmpeg) { |
|
|
| try { |
|
|
| state.ffmpeg.stdin.destroy(); |
|
|
| } catch {} |
|
|
| try { |
|
|
| if ( |
| process.platform === 'win32' |
| ) { |
|
|
| state.ffmpeg.kill( |
| 'SIGKILL' |
| ); |
|
|
| } else { |
|
|
| process.kill( |
| -state.ffmpeg.pid, |
| 'SIGKILL' |
| ); |
| } |
|
|
| } catch {} |
| } |
|
|
| safeRestartFFmpeg(); |
| } |
|
|
| |
| function startFFmpeg() { |
|
|
| const q = getQuality(); |
|
|
| console.log( |
| `π¬ Starting ${q.name}` |
| ); |
|
|
| const args = [ |
|
|
| '-loglevel', 'error', |
|
|
| |
| '-fflags', 'nobuffer', |
|
|
| '-flags', 'low_delay', |
|
|
| '-strict', 'experimental', |
|
|
| '-max_muxing_queue_size', '1024', |
|
|
| '-i', 'pipe:0', |
|
|
| |
|
|
| '-c:v', 'libx264', |
|
|
| |
| '-preset', 'ultrafast', |
|
|
| '-tune', 'zerolatency', |
|
|
| |
| '-profile:v', 'main', |
|
|
| '-threads', '1', |
|
|
| '-pix_fmt', 'yuv420p', |
|
|
| '-vf', |
| `scale=${q.width}:${q.height}`, |
|
|
| '-r', '30', |
|
|
| '-g', '60', |
|
|
| '-keyint_min', '60', |
|
|
| '-sc_threshold', '0', |
|
|
| '-b:v', |
| `${q.bitrate}k`, |
|
|
| '-maxrate', |
| `${q.bitrate}k`, |
|
|
| '-bufsize', |
| `${q.bitrate * 2}k`, |
|
|
| |
|
|
| '-c:a', 'aac', |
|
|
| '-b:a', |
| `${q.audio}k`, |
|
|
| '-ar', '44100', |
|
|
| '-ac', '2', |
|
|
| |
|
|
| '-f', 'flv', |
|
|
| RTMP_URL |
| ]; |
|
|
| state.ffmpeg = spawn( |
| 'ffmpeg', |
| args, |
| { |
| detached: |
| process.platform !== 'win32' |
| } |
| ); |
|
|
| state.ffmpeg.stdin.on( |
| 'error', |
| () => {} |
| ); |
|
|
| state.ffmpeg.stderr.on( |
| 'data', |
| () => {} |
| ); |
|
|
| state.ffmpeg.on( |
| 'close', |
| code => { |
|
|
| console.log( |
| `π‘ FFmpeg closed (${code})` |
| ); |
|
|
| if ( |
| state.isStreaming && |
| !state.restarting && |
| !state.cleaning |
| ) { |
|
|
| safeRestartFFmpeg(); |
| } |
| } |
| ); |
| } |
|
|
| |
| const wss = new WebSocket.Server({ |
|
|
| noServer: true, |
|
|
| maxPayload: 1024 * 1024 |
| }); |
|
|
| server.on( |
| 'upgrade', |
| (req, socket, head) => { |
|
|
| const url = new URL( |
| req.url, |
| `http://${req.headers.host}` |
| ); |
|
|
| const token = |
| url.searchParams.get('token'); |
|
|
| if (token !== AUTH_TOKEN) { |
|
|
| console.log( |
| 'π« Invalid token' |
| ); |
|
|
| socket.write( |
| 'HTTP/1.1 401 Unauthorized\r\n\r\n' |
| ); |
|
|
| socket.destroy(); |
|
|
| return; |
| } |
|
|
| const origin = |
| req.headers.origin || ''; |
|
|
| let hostname = ''; |
|
|
| try { |
|
|
| hostname = |
| new URL(origin).hostname; |
|
|
| } catch {} |
|
|
| const isLocal = |
| hostname === 'localhost' || |
| hostname === '127.0.0.1'; |
|
|
| if ( |
| origin && |
| hostname !== |
| ALLOWED_ORIGIN_HOST && |
| !isLocal |
| ) { |
|
|
| console.log( |
| `π« Blocked origin: ${hostname}` |
| ); |
|
|
| socket.write( |
| 'HTTP/1.1 403 Forbidden\r\n\r\n' |
| ); |
|
|
| socket.destroy(); |
|
|
| return; |
| } |
|
|
| wss.handleUpgrade( |
| req, |
| socket, |
| head, |
| ws => wss.emit( |
| 'connection', |
| ws |
| ) |
| ); |
| } |
| ); |
|
|
| |
| wss.on( |
| 'connection', |
| async ws => { |
|
|
| console.log( |
| 'β
Teacher connected' |
| ); |
|
|
| if (state.isStreaming) { |
|
|
| console.log( |
| 'π§Ή Replacing old session' |
| ); |
|
|
| await deepCleanup(); |
| } |
|
|
| state.activeClient = ws; |
|
|
| state.isStreaming = true; |
|
|
| state.startTime = Date.now(); |
|
|
| state.lastDataTime = Date.now(); |
|
|
| ws.isAlive = true; |
|
|
| startFFmpeg(); |
|
|
| ws.on( |
| 'pong', |
| () => { |
|
|
| ws.isAlive = true; |
| } |
| ); |
|
|
| ws.on( |
| 'message', |
| data => { |
|
|
| if ( |
| !state.ffmpeg || |
| !state.ffmpeg.stdin || |
| !state.ffmpeg.stdin.writable |
| ) { |
| return; |
| } |
|
|
| state.lastDataTime = |
| Date.now(); |
|
|
| |
| if ( |
| state.ffmpeg.stdin |
| .writableLength > |
| 3 * 1024 * 1024 |
| ) { |
|
|
| console.log( |
| 'β οΈ Buffer pressure' |
| ); |
|
|
| return; |
| } |
|
|
| const ok = |
| state.ffmpeg.stdin |
| .write(data); |
|
|
| |
| if ( |
| !ok && |
| ws._socket |
| ) { |
|
|
| ws._socket.pause(); |
|
|
| state.ffmpeg.stdin.once( |
| 'drain', |
| () => { |
|
|
| if ( |
| ws.readyState === |
| WebSocket.OPEN && |
| ws._socket |
| ) { |
|
|
| ws._socket.resume(); |
| } |
| } |
| ); |
| } |
| } |
| ); |
|
|
| ws.on( |
| 'close', |
| async () => { |
|
|
| console.log( |
| 'π Teacher disconnected' |
| ); |
|
|
| await deepCleanup(); |
| } |
| ); |
|
|
| ws.on( |
| 'error', |
| async err => { |
|
|
| console.log( |
| 'β WS Error:', |
| err.message |
| ); |
|
|
| await deepCleanup(); |
| } |
| ); |
| } |
| ); |
|
|
| |
|
|
| |
| setInterval( |
| async () => { |
|
|
| if ( |
| state.isStreaming && |
| Date.now() - |
| state.lastDataTime > |
| 20000 |
| ) { |
|
|
| console.log( |
| 'π Idle timeout' |
| ); |
|
|
| await deepCleanup(); |
| } |
|
|
| }, |
| 10000 |
| ); |
|
|
| |
| setInterval( |
| () => { |
|
|
| wss.clients.forEach( |
| ws => { |
|
|
| if (!ws.isAlive) { |
|
|
| try { |
|
|
| ws.terminate(); |
|
|
| } catch {} |
|
|
| return; |
| } |
|
|
| ws.isAlive = false; |
|
|
| try { |
|
|
| ws.ping(); |
|
|
| } catch {} |
| } |
| ); |
|
|
| }, |
| 15000 |
| ); |
|
|
| |
| setInterval( |
| async () => { |
|
|
| if (!state.isStreaming) return; |
|
|
| await checkAdaptiveQuality(); |
|
|
| }, |
| 12000 |
| ); |
|
|
| |
| setInterval( |
| () => { |
|
|
| if (!state.isStreaming) return; |
|
|
| const q = getQuality(); |
|
|
| const uptime = |
| Math.floor( |
| ( |
| Date.now() - |
| state.startTime |
| ) / 1000 |
| ); |
|
|
| const buffer = |
| state.ffmpeg?.stdin |
| ?.writableLength || 0; |
|
|
| console.log( |
|
|
| `π LIVE | ` + |
| `${q.name} | ` + |
| `${q.bitrate}kbps | ` + |
| `${uptime}s | ` + |
| `${(buffer / 1024).toFixed(0)}KB` |
| ); |
|
|
| }, |
| 30000 |
| ); |
|
|
| |
| process.on( |
| 'uncaughtException', |
| err => { |
|
|
| console.error( |
| 'π₯ Error:', |
| err.message |
| ); |
| } |
| ); |
|
|
| process.on( |
| 'SIGINT', |
| async () => { |
|
|
| await deepCleanup(); |
|
|
| process.exit(0); |
| } |
| ); |
|
|
| process.on( |
| 'SIGTERM', |
| async () => { |
|
|
| await deepCleanup(); |
|
|
| process.exit(0); |
| } |
| ); |
|
|
| |
| server.listen( |
| PORT, |
| () => { |
|
|
| console.log( |
| `π Titan Engine running on ${PORT}` |
| ); |
|
|
| console.log( |
| `π‘ YouTube Live Ready` |
| ); |
|
|
| console.log( |
| `π₯ Default Quality: ${getQuality().name}` |
| ); |
| } |
| ); |