diff --git a/server.js b/server.js new file mode 100644 index 0000000..94d12cd --- /dev/null +++ b/server.js @@ -0,0 +1,184 @@ +/** + * AeThex LIVE - Main Server + * Next.js + Socket.io + YouTube Live Detector + RTMP ingest + * + * Port 3000: HTTP (Next.js + Socket.io) + * Port 1935: RTMP ingest (node-media-server) + * Port 8000: HLS output (node-media-server) + */ + +const { createServer } = require('http'); +const { Server } = require('socket.io'); +const next = require('next'); +const NodeMediaServer = require('node-media-server'); +const path = require('path'); +const fs = require('fs'); +const https = require('https'); + +const dev = process.env.NODE_ENV !== 'production'; +const app = next({ dev }); +const handle = app.getRequestHandler(); + +// ── YOUTUBE LIVE DETECTOR ────────────────────────────────────────────────── +const YOUTUBE_CHANNEL_ID = 'UC22sQjrETb_YaTJqh36sxLQ'; +const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY || null; +const POLL_INTERVAL = 60000; // 60 seconds + +let currentScene = 'offline'; +let liveVideoId = null; +let io = null; + +function checkYouTubeLive() { + if (YOUTUBE_API_KEY) { + const url = `https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${YOUTUBE_CHANNEL_ID}&type=video&eventType=live&key=${YOUTUBE_API_KEY}`; + https.get(url, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + const json = JSON.parse(data); + const wasLive = currentScene === 'live'; + if (json.items && json.items.length > 0) { + liveVideoId = json.items[0].id.videoId; + if (!wasLive) { console.log(`[DETECTOR] MrPiglr\u00a0is LIVE: ${liveVideoId}`); setScene('live'); } + } else { + liveVideoId = null; + if (wasLive) { console.log('[DETECTOR] MrPiglr went offline'); setScene('offline'); } + } + } catch (e) { console.error('[DETECTOR] Parse error:', e.message); } + }); + }).on('error', err => console.error('[DETECTOR] Request error:', err.message)); + } else { + const options = { hostname: 'www.youtube.com', path: `/channel/${YOUTUBE_CHANNEL_ID}/live`, headers: { 'User-Agent': 'Mozilla/5.0' } }; + https.get(options, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + const wasLive = currentScene === 'live'; + const isLive = data.includes('"isLiveNow":true') || data.includes('"liveBroadcastContent":"live"'); + const vidMatch = data.match(/"videoId":"([^"]+)"/); + if (isLive) { + if (vidMatch) liveVideoId = vidMatch[1]; + if (!wasLive) { console.log('[DETECTOR] MrPiglr is LIVE (scraped)'); setScene('live'); } + } else { + liveVideoId = null; + if (wasLive) { console.log('[DETECTOR] MrPiglr went offline (scraped)'); setScene('offline'); } + } + }); + }).on('error', err => console.error('[DETECTOR] Scrape error:', err.message)); + } +} + +function setScene(scene, triggeredBy = 'system') { + const prev = currentScene; + currentScene = scene; + console.log(`[SCENE] ${prev} => ${scene} (by: ${triggeredBy})`); + if (io) io.emit('scene:change', { scene, liveVideoId, triggeredBy, timestamp: Date.now() }); +} + +function processCommand(msg, socket, username) { + if (!msg.startsWith('/')) return false; + const parts = msg.trim().split(' '); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1).join(' '); + const commands = { + '/music': () => setScene('music', username), + '/kael': () => { setScene('kael', username); return { kaelQuery: args }; }, + '/passport': () => { setScene('passport', username); return { passportUser: args }; }, + '/oakdale': () => setScene('oakdale', username), + '/forge': () => setScene('forge', username), + '/live': () => setScene('live', username), + '/offline': () => setScene('offline', username), + '/help': () => null, + }; + if (commands[cmd]) { + commands[cmd](); + if (cmd === '/kael' && args) io.emit('kael:query', { query: args, username }); + if (cmd === '/passport' && args) io.emit('passport:lookup', { username: args, requestedBy: username }); + io.emit('chat:message', { id: Date.now().toString(), username, message: msg, type: 'command', timestamp: Date.now() }); + return true; + } + return false; +} + +const mediaDir = path.join(__dirname, 'media'); +if (!fs.existsSync(mediaDir)) fs.mkdirSync(mediaDir, { recursive: true }); + +const nms = new NodeMediaServer({ + rtmp: { port: 1935, chunk_size: 60000, gop_cache: true, ping: 30, ping_timeout: 60 }, + http: { port: 8000, mediaroot: mediaDir, allow_origin: '*', api: true }, +}); + +nms.on('prePublish', (id, StreamPath) => { + console.log(`[RTMP] Stream started: ${StreamPath}`); + setScene('live', 'obs-push'); + if (io) io.emit('rtmp:started', { streamPath: StreamPath }); +}); + +nms.on('donePublish', (id, StreamPath) => { + console.log(`[RTMP] Stream ended: ${StreamPath}`); + setScene('offline', 'obs-end'); + if (io) io.emit('rtmp:ended', {}); +}); + +app.prepare().then(() => { + let viewerCount = 0; + + const httpServer = createServer((req, res) => { + if (req.method === 'POST' && req.url === '/_internal/scene') { + if (req.headers['x-internal'] !== 'true') { res.writeHead(403); res.end(); return; } + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const { scene } = JSON.parse(body); + setScene(scene, 'api'); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, scene })); + } catch { res.writeHead(400); res.end(); } + }); + return; + } + if (req.method === 'GET' && req.url === '/_internal/status') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ scene: currentScene, liveVideoId, viewers: viewerCount, uptime: process.uptime() })); + return; + } + handle(req, res); + }); + + io = new Server(httpServer, { cors: { origin: '*', methods: ['GET', 'POST'] } }); + + io.on('connection', (socket) => { + viewerCount++; + io.emit('viewers:update', viewerCount); + socket.emit('state:init', { scene: currentScene, liveVideoId, timestamp: Date.now() }); + socket.on('chat:send', ({ message, username }) => { + if (!message || !username) return; + if (!processCommand(message, socket, username)) { + io.emit('chat:message', { id: Date.now().toString() + Math.random(), username, message, type: 'chat', timestamp: Date.now() }); + } + }); + socket.on('scene:request', ({ scene, username }) => setScene(scene, username || 'viewer')); + socket.on('kael:response', (data) => io.emit('kael:response', data)); + socket.on('disconnect', () => { + viewerCount = Math.max(0, viewerCount - 1); + io.emit('viewers:update', viewerCount); + }); + }); + + nms.run(); + checkYouTubeLive(); + setInterval(checkYouTubeLive, POLL_INTERVAL); + + const PORT = process.env.PORT || 3000; + httpServer.listen(PORT, () => { + console.log('\n' + '═'.repeat(60)); + console.log(' 🔴 AeThex LIVE - Broadcast Network'); + console.log('═'.repeat(60)); + console.log(` HTTP: http://localhost:${PORT}`); + console.log(` RTMP: rtmp://localhost:1935/live`); + console.log(` Scene: ${currentScene}`); + console.log('═'.repeat(60) + '\n'); + }); +});