Spaces:
Sleeping
Sleeping
| /** | |
| * Full server.js | |
| * | |
| * - Robust /api/yt/source -> resolves YouTube via VidFly (configurable via VIDFLY_API env) | |
| * - Debug endpoint /api/vidfly/debug -> calls VidFly and returns the raw JSON for debugging | |
| * - Robust /api/yt/proxy -> proxies googlevideo redirector URLs (for browser CORS + Range support) | |
| * - Full Socket.IO room handlers (join, set_track, play/pause/seek/ended, requests, admin commands) | |
| * | |
| * Notes: | |
| * - Set VIDFLY_API to override the default VidFly endpoint (default: https://api.vidfly.ai/api/media/youtube/download) | |
| * - This file is self-contained; keep your existing jiosaavn.js module alongside (imported) | |
| * - Be mindful of bandwidth when proxying large files through this server. | |
| */ | |
| import express from 'express'; | |
| import http from 'http'; | |
| import cors from 'cors'; | |
| import path from 'path'; | |
| import { fileURLToPath } from 'url'; | |
| import axios from 'axios'; | |
| import { PassThrough } from 'stream'; | |
| import { Server as SocketIOServer } from 'socket.io'; | |
| import { | |
| searchUniversal, | |
| getSong, | |
| getAlbum, | |
| getPlaylist, | |
| getLyrics | |
| } from './jiosaavn.js'; // keep your module | |
| const __filename = fileURLToPath(import.meta.url); | |
| const __dirname = path.dirname(__filename); | |
| const app = express(); | |
| app.use(cors()); | |
| app.use(express.json()); | |
| function log(...args) { console.log(new Date().toISOString(), ...args); } | |
| function short(v, n = 400) { | |
| try { | |
| const s = typeof v === 'string' ? v : JSON.stringify(v); | |
| return s.length > n ? s.slice(0, n) + '…' : s; | |
| } catch { return String(v).slice(0, n) + '…'; } | |
| } | |
| // ---------- Room state ---------- | |
| /* | |
| room = { | |
| id, name, hostId, | |
| users: Map<socketId, { name, role: 'host'|'cohost'|'member', muted?: boolean }>, | |
| track: { url, title, meta, kind, thumb? }, | |
| isPlaying, anchor, anchorAt, | |
| queue: Array<track> | |
| } | |
| */ | |
| const rooms = new Map(); | |
| function ensureRoom(roomId) { | |
| if (!rooms.has(roomId)) { | |
| rooms.set(roomId, { | |
| id: roomId, | |
| name: null, | |
| hostId: null, | |
| users: new Map(), | |
| track: null, | |
| isPlaying: false, | |
| anchor: 0, | |
| anchorAt: 0, | |
| queue: [] | |
| }); | |
| } | |
| return rooms.get(roomId); | |
| } | |
| function currentState(room) { | |
| return { | |
| track: room.track, | |
| isPlaying: room.isPlaying, | |
| anchor: room.anchor, | |
| anchorAt: room.anchorAt, | |
| queue: room.queue | |
| }; | |
| } | |
| function membersPayload(room) { | |
| return [...room.users.entries()].map(([id, u]) => ({ | |
| id, | |
| name: u.name || 'Guest', | |
| role: u.role || 'member', | |
| muted: !!u.muted, | |
| isHost: id === room.hostId | |
| })); | |
| } | |
| function broadcastMembers(roomId) { | |
| const room = rooms.get(roomId); | |
| if (!room) return; | |
| const members = membersPayload(room); | |
| io.to(roomId).emit('members', { members, roomName: room.name || room.id }); | |
| } | |
| function requireHostOrCohost(room, socketId) { | |
| const u = room.users.get(socketId); | |
| return (socketId === room.hostId) || (u && (u.role === 'cohost')); | |
| } | |
| function normalizeThumb(meta) { | |
| const candidates = [ | |
| meta?.thumb, | |
| meta?.image, | |
| meta?.thumbnail, | |
| meta?.song_image, | |
| meta?.album_image, | |
| meta?.image_url, | |
| meta?.images?.cover, | |
| meta?.images?.[0] | |
| ].filter(Boolean); | |
| return candidates[0] || null; | |
| } | |
| function findUserByName(room, targetNameRaw) { | |
| if (!targetNameRaw) return null; | |
| const targetName = String(targetNameRaw).replace(/^@/, '').trim().toLowerCase(); | |
| let candidate = null; | |
| for (const [id, u] of room.users.entries()) { | |
| const n = (u.name || '').toLowerCase(); | |
| if (n === targetName) return { id, u }; | |
| if (!candidate && n.startsWith(targetName)) candidate = { id, u }; | |
| } | |
| if (candidate) return candidate; | |
| for (const [id, u] of room.users.entries()) { | |
| const n = (u.name || '').toLowerCase(); | |
| if (n.includes(targetName)) return { id, u }; | |
| } | |
| return null; | |
| } | |
| // Health | |
| app.get('/healthz', (_req, res) => res.send('OK')); | |
| // ---------- JioSaavn proxies ---------- | |
| app.get('/api/result', async (req, res) => { | |
| try { | |
| const q = req.query.q || ''; | |
| const data = await searchUniversal(q); | |
| res.json(data); | |
| } catch (e) { | |
| log('/api/result error', e?.message || short(e?.response?.data)); | |
| res.status(500).json({ error: e.message }); | |
| } | |
| }); | |
| app.get('/api/song', async (req, res) => { | |
| try { | |
| const q = req.query.q || ''; | |
| const data = await getSong(q); | |
| res.json(data); | |
| } catch (e) { log('/api/song error', e?.message || short(e?.response?.data)); res.status(500).json({ error: e.message }); } | |
| }); | |
| app.get('/api/album', async (req, res) => { | |
| try { | |
| const q = req.query.q || ''; | |
| const data = await getAlbum(q); | |
| res.json(data); | |
| } catch (e) { log('/api/album error', e?.message || short(e?.response?.data)); res.status(500).json({ error: e.message }); } | |
| }); | |
| app.get('/api/playlist', async (req, res) => { | |
| try { | |
| const q = req.query.q || ''; | |
| const data = await getPlaylist(q); | |
| res.json(data); | |
| } catch (e) { log('/api/playlist error', e?.message || short(e?.response?.data)); res.status(500).json({ error: e.message }); } | |
| }); | |
| app.get('/api/lyrics', async (req, res) => { | |
| try { | |
| const q = req.query.q || ''; | |
| const data = await getLyrics(q); | |
| res.json(data); | |
| } catch (e) { log('/api/lyrics error', e?.message || short(e?.response?.data)); res.status(500).json({ error: e.message }); } | |
| }); | |
| // ---------- YouTube search (optional) ---------- | |
| app.get('/api/ytsearch', async (req, res) => { | |
| try { | |
| const key = process.env.YT_API_KEY; | |
| if (!key) return res.status(501).json({ error: 'YouTube search unavailable: set YT_API_KEY env' }); | |
| const q = req.query.q || ''; | |
| const resp = await axios.get('https://www.googleapis.com/youtube/v3/search', { | |
| params: { key, q, part: 'snippet', type: 'video', maxResults: 12 }, | |
| timeout: 10000 | |
| }); | |
| const items = (resp.data.items || []).map(it => ({ | |
| videoId: it.id?.videoId, | |
| title: it.snippet?.title, | |
| channelTitle: it.snippet?.channelTitle, | |
| thumb: it.snippet?.thumbnails?.medium?.url || it.snippet?.thumbnails?.default?.url | |
| })).filter(x => x.videoId); | |
| res.json({ items }); | |
| } catch (e) { | |
| log('/api/ytsearch error', e?.message || short(e?.response?.data)); | |
| res.status(500).json({ error: e.message }); | |
| } | |
| }); | |
| // ---------- YouTube resolve via VidFly ---------- | |
| const VIDFLY_API = process.env.VIDFLY_API || 'https://api.vidfly.ai/api/media/youtube/download'; | |
| app.get('/api/vidfly/debug', async (req, res) => { | |
| // Debug endpoint: calls VidFly and returns raw response (JSON) for troubleshooting | |
| try { | |
| let raw = (req.query.url || '').trim(); | |
| if (!raw) return res.status(400).json({ error: 'Missing url' }); | |
| if (/^[a-zA-Z0-9_-]{11}$/.test(raw)) raw = `https://www.youtube.com/watch?v=${raw}`; | |
| const apiUrl = `${VIDFLY_API}?url=${encodeURIComponent(raw)}`; | |
| log('VIDFLY debug call ->', apiUrl); | |
| const resp = await axios.get(apiUrl, { timeout: 20000, validateStatus: () => true }); | |
| // Return status and body so caller can inspect | |
| res.status(200).json({ status: resp.status, headers: resp.headers, data: resp.data }); | |
| } catch (e) { | |
| log('/api/vidfly/debug error', e?.message ?? e); | |
| res.status(500).json({ error: e?.message ?? String(e) }); | |
| } | |
| }); | |
| app.get('/api/yt/source', async (req, res) => { | |
| // Primary resolver: call VidFly and return chosen playable url + metadata | |
| try { | |
| let raw = (req.query.url || '').trim(); | |
| if (!raw) return res.status(400).json({ error: 'Missing url' }); | |
| if (/^[a-zA-Z0-9_-]{11}$/.test(raw)) raw = `https://www.youtube.com/watch?v=${raw}`; | |
| const apiUrl = `${VIDFLY_API}?url=${encodeURIComponent(raw)}`; | |
| log('Calling VidFly API:', apiUrl); | |
| const resp = await axios.get(apiUrl, { timeout: 20000, validateStatus: () => true }); | |
| const data = resp.data; | |
| if (!data || typeof data !== 'object') { | |
| log('/api/yt/source vidfly non-object response:', short(data, 1000)); | |
| return res.status(502).json({ error: 'Invalid VidFly response', snippet: short(data, 1000) }); | |
| } | |
| if (Number(data.code) !== 0 || !data.data || !Array.isArray(data.data.items)) { | |
| // include vidfly response to aid debugging | |
| log('/api/yt/source vidfly returned error shape:', short(data, 1000)); | |
| return res.status(502).json({ error: 'Invalid VidFly API response', detail: data }); | |
| } | |
| const items = data.data.items; | |
| // prefer progressive (video with audio), otherwise highest-res video-only, otherwise any | |
| const progressive = items.filter(f => f.type === 'video_with_audio') | |
| .sort((a, b) => (Number(b.height || 0) - Number(a.height || 0)))[0]; | |
| const videoOnly = items.filter(f => f.type === 'video') | |
| .sort((a, b) => (Number(b.height || 0) - Number(a.height || 0)))[0]; | |
| const anyItem = items.find(f => f.url); | |
| const chosen = progressive || videoOnly || anyItem; | |
| if (!chosen || !chosen.url) { | |
| log('/api/yt/source no playable item found:', short(items, 1200)); | |
| return res.status(502).json({ error: 'No playable format found in VidFly API response', detail: data }); | |
| } | |
| res.json({ | |
| url: chosen.url, | |
| title: data.data.title || raw, | |
| thumbnail: data.data.cover || null, | |
| duration: data.data.duration || null, | |
| kind: chosen.type || 'video', | |
| format: { | |
| ext: chosen.ext || null, | |
| fps: chosen.fps || null, | |
| resolution: `${chosen.width || '?'}x${chosen.height || '?'}`, | |
| label: chosen.label || null | |
| } | |
| }); | |
| } catch (e) { | |
| log('/api/yt/source unexpected error:', e?.response?.data ?? e?.message ?? String(e)); | |
| res.status(500).json({ error: 'Failed to fetch from VidFly API', detail: e?.response?.data ?? e?.message ?? String(e) }); | |
| } | |
| }); | |
| /* | |
| /api/yt/proxy | |
| - Proxies direct signed googlevideo URLs (redirector.googlevideo.com / googlevideo.com). | |
| - Forwards Range header to support seeking. | |
| - Forwards upstream headers (content-type, content-length, content-range, accept-ranges). | |
| - Responds with CORS header. | |
| */ | |
| function isAllowedProxyHost(urlStr) { | |
| try { | |
| const u = new URL(urlStr); | |
| const host = u.hostname.toLowerCase(); | |
| return host.endsWith('googlevideo.com') || host.endsWith('redirector.googlevideo.com'); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| app.get('/api/yt/proxy', async (req, res) => { | |
| try { | |
| const raw = (req.query.url || '').trim(); | |
| if (!raw) return res.status(400).json({ error: 'Missing url' }); | |
| if (!isAllowedProxyHost(raw)) { | |
| return res.status(400).json({ error: 'Proxy to this host is not allowed by server policy' }); | |
| } | |
| const rangeHeader = req.headers.range || null; | |
| const requestHeaders = { | |
| 'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', | |
| 'Accept': '*/*', | |
| 'Referer': 'https://www.youtube.com/', | |
| ...(rangeHeader ? { Range: rangeHeader } : {}) | |
| }; | |
| log('/api/yt/proxy fetching', short(raw, 200), 'Range:', rangeHeader ? rangeHeader.slice(0,120) : 'none'); | |
| const resp = await axios.get(raw, { | |
| responseType: 'stream', | |
| timeout: 20000, | |
| headers: requestHeaders, | |
| maxRedirects: 5, | |
| validateStatus: () => true | |
| }); | |
| if (resp.status >= 400) { | |
| // try to capture a short snippet for debugging | |
| let snippet = ''; | |
| try { | |
| const reader = resp.data; | |
| const chunk = await new Promise((resolve) => { | |
| let done = false; | |
| const onData = (c) => { if (!done) { done = true; cleanup(); resolve(c); } }; | |
| const onEnd = () => { if (!done) { done = true; cleanup(); resolve(null); } }; | |
| const onErr = () => { if (!done) { done = true; cleanup(); resolve(null); } }; | |
| const cleanup = () => { reader.removeListener('data', onData); reader.removeListener('end', onEnd); reader.removeListener('error', onErr); }; | |
| reader.once('data', onData); | |
| reader.once('end', onEnd); | |
| reader.once('error', onErr); | |
| }); | |
| if (chunk) snippet = chunk.toString('utf8', 0, 800); | |
| } catch (xx) { /* ignore */ } | |
| log('/api/yt/proxy remote returned error status', resp.status, 'snippet:', short(snippet, 400)); | |
| return res.status(502).json({ error: 'Remote returned error', status: resp.status, snippet: short(snippet, 800) }); | |
| } | |
| // check content-type header for obvious non-media responses | |
| const ct = String(resp.headers['content-type'] || '').toLowerCase(); | |
| if (ct.includes('text/html') || ct.includes('application/json') || ct.includes('text/plain')) { | |
| // read a small snippet then return 502 | |
| let snippet = ''; | |
| try { | |
| const reader = resp.data; | |
| const chunk = await new Promise((resolve) => { | |
| let done = false; | |
| const onData = (c) => { if (!done) { done = true; cleanup(); resolve(c); } }; | |
| const onEnd = () => { if (!done) { done = true; cleanup(); resolve(null); } }; | |
| const onErr = () => { if (!done) { done = true; cleanup(); resolve(null); } }; | |
| const cleanup = () => { reader.removeListener('data', onData); reader.removeListener('end', onEnd); reader.removeListener('error', onErr); }; | |
| reader.once('data', onData); | |
| reader.once('end', onEnd); | |
| reader.once('error', onErr); | |
| }); | |
| if (chunk) snippet = chunk.toString('utf8', 0, 1600); | |
| } catch (xx) {} | |
| log('/api/yt/proxy remote returned non-media content-type', ct, short(snippet, 400)); | |
| return res.status(502).json({ error: 'Remote returned non-media content-type', contentType: ct, snippet: short(snippet, 800) }); | |
| } | |
| // forward useful headers | |
| const forwardable = ['content-type','content-length','content-range','accept-ranges','cache-control','last-modified','etag']; | |
| forwardable.forEach(h => { | |
| const v = resp.headers[h]; | |
| if (v !== undefined) res.setHeader(h, v); | |
| }); | |
| // CORS for browsers (already app.use(cors()) but explicitly set for stream) | |
| res.setHeader('Access-Control-Allow-Origin', '*'); | |
| res.setHeader('Access-Control-Allow-Headers', 'Range,Accept,Content-Type'); | |
| // status (206 or 200) | |
| res.status(resp.status); | |
| // pipe upstream stream directly to client (no peeking) | |
| const upstream = resp.data; | |
| upstream.on('error', (err) => { | |
| log('/api/yt/proxy upstream stream error:', err?.message || String(err)); | |
| try { | |
| if (!res.headersSent) res.status(502).json({ error: 'Upstream stream error', detail: err?.message || String(err) }); | |
| else res.destroy(err); | |
| } catch {} | |
| }); | |
| upstream.pipe(res).on('error', (err) => { | |
| log('/api/yt/proxy pipe error:', err?.message || String(err)); | |
| try { if (!res.headersSent) res.status(502).json({ error: 'Pipe error', detail: err?.message || String(err) }); } catch {} | |
| }); | |
| } catch (e) { | |
| log('/api/yt/proxy unexpected error:', e?.response?.status ?? e?.message ?? String(e)); | |
| const detail = e?.response?.data ?? e?.message ?? String(e); | |
| res.status(500).json({ error: 'Failed to proxy remote video', detail: short(detail, 800) }); | |
| } | |
| }); | |
| // ---------- Lobby ---------- | |
| app.get('/api/rooms', (_req, res) => { | |
| const data = [...rooms.values()] | |
| .filter(r => r.users.size > 0) | |
| .map(r => ({ | |
| id: r.id, | |
| name: r.name || r.id, | |
| members: r.users.size, | |
| isPlaying: r.isPlaying | |
| })); | |
| res.json({ rooms: data }); | |
| }); | |
| app.get('/api/ping', (_req, res) => res.json({ ok: true })); | |
| // ---------- Static client ---------- | |
| const clientDir = path.resolve(__dirname, '../client/dist'); | |
| app.use(express.static(clientDir)); | |
| app.get('*', (_req, res) => res.sendFile(path.join(clientDir, 'index.html'))); | |
| // ---------- Socket.IO ---------- | |
| const server = http.createServer(app); | |
| const io = new SocketIOServer(server, { | |
| cors: { origin: '*', methods: ['GET', 'POST'] } | |
| }); | |
| io.on('connection', (socket) => { | |
| let joinedRoom = null; | |
| socket.on('join_room', ({ roomId, name, asHost = false, roomName = null }, ack) => { | |
| const room = ensureRoom(roomId); | |
| if (asHost || !room.hostId) room.hostId = socket.id; | |
| if (!room.name && roomName) room.name = String(roomName).trim().slice(0, 60) || null; | |
| const role = socket.id === room.hostId ? 'host' : 'member'; | |
| const cleanName = String(name || 'Guest').slice(0, 40); | |
| room.users.set(socket.id, { name: cleanName, role }); | |
| socket.join(roomId); | |
| joinedRoom = roomId; | |
| ack?.({ | |
| roomId, | |
| isHost: socket.id === room.hostId, | |
| state: currentState(room), | |
| roomName: room.name || room.id | |
| }); | |
| // Single clean system message to everyone | |
| io.to(roomId).emit('system', { text: `System: ${cleanName} has joined the chat` }); | |
| broadcastMembers(roomId); | |
| }); | |
| socket.on('rename', ({ roomId, newName }) => { | |
| const room = rooms.get(roomId); | |
| if (!room) return; | |
| const u = room.users.get(socket.id); | |
| if (!u) return; | |
| u.name = String(newName || 'Guest').slice(0, 40); | |
| broadcastMembers(roomId); | |
| }); | |
| socket.on('chat_message', ({ roomId, name, text }) => { | |
| const room = rooms.get(roomId); | |
| if (!room) return; | |
| const u = room.users.get(socket.id); | |
| if (!u) return; | |
| if (u.muted) { | |
| socket.emit('system', { text: 'System: You are muted by the host' }); | |
| return; | |
| } | |
| socket.to(roomId).emit('chat_message', { name, text, at: Date.now() }); | |
| }); | |
| // /play request (user requests a song; host/cohost get notified) | |
| socket.on('song_request', ({ roomId, requester, query }) => { | |
| const room = rooms.get(roomId); | |
| if (!room) return; | |
| const payload = { requester, query, at: Date.now(), requestId: `${Date.now()}_${Math.random().toString(36).slice(2)}` }; | |
| for (const [id, usr] of room.users.entries()) { | |
| if (id === room.hostId || usr.role === 'cohost') io.to(id).emit('song_request', payload); | |
| } | |
| io.to(roomId).emit('system', { text: `System: ${requester} requested /play ${query}` }); | |
| }); | |
| // Host handles request action | |
| socket.on('song_request_action', ({ roomId, action, track }) => { | |
| const room = rooms.get(roomId); | |
| if (!room) return; | |
| if (!requireHostOrCohost(room, socket.id)) return; | |
| if ((action === 'accept' || action === 'queue') && track) { | |
| const t = { | |
| url: track.url, | |
| title: track.title || track.url, | |
| meta: track.meta || {}, | |
| kind: track.kind || 'audio', | |
| thumb: track.thumb || normalizeThumb(track.meta || {}) | |
| }; | |
| if (action === 'accept') { | |
| room.track = t; | |
| room.isPlaying = false; | |
| room.anchor = 0; | |
| room.anchorAt = Date.now(); | |
| io.to(roomId).emit('set_track', { track: room.track }); | |
| io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt }); | |
| io.to(roomId).emit('system', { text: `System: Now playing ${room.track.title}` }); | |
| } else { | |
| room.queue.push(t); | |
| io.to(roomId).emit('queue_update', { queue: room.queue }); | |
| io.to(roomId).emit('system', { text: `System: Queued ${t.title}` }); | |
| } | |
| } | |
| }); | |
| // Host/cohost controls | |
| socket.on('set_track', ({ roomId, track }) => { | |
| const room = rooms.get(roomId); | |
| if (!room || !requireHostOrCohost(room, socket.id)) return; | |
| const thumb = track.thumb || normalizeThumb(track.meta || {}); | |
| room.track = { ...track, thumb }; | |
| room.isPlaying = false; | |
| room.anchor = 0; | |
| room.anchorAt = Date.now(); | |
| io.to(roomId).emit('set_track', { track: room.track }); | |
| io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt }); | |
| io.to(roomId).emit('system', { text: `System: Selected ${room.track.title || room.track.url}` }); | |
| }); | |
| socket.on('play', ({ roomId }) => { | |
| const room = rooms.get(roomId); | |
| if (!room || !requireHostOrCohost(room, socket.id)) return; | |
| room.isPlaying = true; | |
| room.anchorAt = Date.now(); | |
| io.to(roomId).emit('play', { anchor: room.anchor, anchorAt: room.anchorAt }); | |
| }); | |
| socket.on('pause', ({ roomId }) => { | |
| const room = rooms.get(roomId); | |
| if (!room || !requireHostOrCohost(room, socket.id)) return; | |
| const elapsed = Math.max(0, Date.now() - room.anchorAt) / 1000; | |
| if (room.isPlaying) room.anchor += elapsed; | |
| room.isPlaying = false; | |
| room.anchorAt = Date.now(); | |
| io.to(roomId).emit('pause', { anchor: room.anchor, anchorAt: room.anchorAt }); | |
| }); | |
| socket.on('seek', ({ roomId, to }) => { | |
| const room = rooms.get(roomId); | |
| if (!room || !requireHostOrCohost(room, socket.id)) return; | |
| room.anchor = to; | |
| room.anchorAt = Date.now(); | |
| io.to(roomId).emit('seek', { anchor: room.anchor, anchorAt: room.anchorAt, isPlaying: room.isPlaying }); | |
| }); | |
| // Ended -> auto next | |
| socket.on('ended', ({ roomId }) => { | |
| const room = rooms.get(roomId); | |
| if (!room || !requireHostOrCohost(room, socket.id)) return; | |
| const next = room.queue.shift(); | |
| if (next) { | |
| const thumb = next.thumb || normalizeThumb(next.meta || {}); | |
| room.track = { ...next, thumb }; | |
| room.isPlaying = false; | |
| room.anchor = 0; | |
| room.anchorAt = Date.now(); | |
| io.to(roomId).emit('set_track', { track: room.track }); | |
| io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt }); | |
| io.to(roomId).emit('queue_update', { queue: room.queue }); | |
| io.to(roomId).emit('system', { text: `System: Now playing ${room.track.title}` }); | |
| } else { | |
| io.to(roomId).emit('system', { text: 'System: Queue ended' }); | |
| } | |
| }); | |
| // Admin commands: kick/promote/mute | |
| socket.on('admin_command', ({ roomId, cmd, targetName }) => { | |
| const room = rooms.get(roomId); | |
| if (!room) return; | |
| const actor = room.users.get(socket.id); | |
| if (!actor || !(socket.id === room.hostId || actor.role === 'cohost')) return; | |
| const found = findUserByName(room, targetName); | |
| if (!found) { | |
| io.to(socket.id).emit('system', { text: `System: User @${targetName} not found` }); | |
| return; | |
| } | |
| if (cmd === 'kick') { | |
| const { id } = found; | |
| io.to(id).emit('system', { text: 'System: You were kicked by the host' }); | |
| io.sockets.sockets.get(id)?.leave(roomId); | |
| room.users.delete(id); | |
| io.to(roomId).emit('system', { text: `System: ${found.u.name} was kicked` }); | |
| broadcastMembers(roomId); | |
| } else if (cmd === 'promote') { | |
| found.u.role = 'cohost'; | |
| io.to(roomId).emit('system', { text: `System: ${found.u.name} was promoted to co-host` }); | |
| broadcastMembers(roomId); | |
| } else if (cmd === 'mute') { | |
| found.u.muted = true; | |
| io.to(roomId).emit('system', { text: `System: ${found.u.name} was muted` }); | |
| broadcastMembers(roomId); | |
| } | |
| }); | |
| // Sync request: client asks for current room state | |
| socket.on('sync_request', ({ roomId }, ack) => { | |
| const room = rooms.get(roomId); | |
| if (!room) return; | |
| ack?.(currentState(room)); | |
| }); | |
| socket.on('disconnect', () => { | |
| if (!joinedRoom) return; | |
| const room = rooms.get(joinedRoom); | |
| if (!room) return; | |
| const leftUser = room.users.get(socket.id); | |
| room.users.delete(socket.id); | |
| if (socket.id === room.hostId) { | |
| const nextHost = [...room.users.keys()][0]; | |
| room.hostId = nextHost || null; | |
| if (room.hostId) { | |
| const u = room.users.get(room.hostId); | |
| if (u) u.role = 'host'; | |
| } | |
| io.to(joinedRoom).emit('host_changed', { hostId: room.hostId }); | |
| } | |
| if (leftUser) { | |
| const nm = leftUser.name || 'User'; | |
| io.to(joinedRoom).emit('system', { text: `System: ${nm} has left the chat` }); | |
| } | |
| if (room.users.size === 0) rooms.delete(joinedRoom); | |
| else broadcastMembers(joinedRoom); | |
| }); | |
| }); | |
| // start server | |
| const PORT = process.env.PORT || 3000; | |
| server.listen(PORT, () => { | |
| log(`Server running on port ${PORT}`); | |
| }); |