Update server/server.js

#3
by akborana4 - opened
Files changed (1) hide show
  1. server/server.js +314 -157
server/server.js CHANGED
@@ -6,8 +6,6 @@ import path from 'path';
6
  import { fileURLToPath } from 'url';
7
  import axios from 'axios';
8
  import NodeCache from 'node-cache';
9
- import { exec as cpExec } from 'child_process';
10
- import { promisify } from 'util';
11
  import {
12
  searchUniversal,
13
  getSong,
@@ -16,177 +14,319 @@ import {
16
  getLyrics
17
  } from './jiosaavn.js';
18
 
19
- const exec = promisify(cpExec);
20
  const __filename = fileURLToPath(import.meta.url);
21
  const __dirname = path.dirname(__filename);
22
 
23
  const app = express();
24
- app.use(cors());
 
 
25
  app.use(express.json());
26
 
27
- // Cache for Vidfly results
28
- const formatCache = new NodeCache({ stdTTL: 300, checkperiod: 60 });
 
 
 
29
 
30
- // Proxy endpoint with Range support
 
31
  app.get('/api/proxy', async (req, res) => {
32
  const target = req.query.url;
33
  if (!target) return res.status(400).send('Missing url');
 
34
  try {
 
35
  const headers = {};
36
  if (req.headers.range) headers.Range = req.headers.range;
 
 
 
 
 
 
 
37
  const upstream = await axios.get(target, {
38
  responseType: 'stream',
39
  headers,
40
- validateStatus: () => true
 
 
41
  });
 
 
 
 
 
 
42
  res.status(upstream.status);
43
- for (const h of ['content-type','content-length','content-range','accept-ranges']) {
44
- if (upstream.headers[h]) res.setHeader(h, upstream.headers[h]);
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  }
 
46
  upstream.data.pipe(res);
47
- } catch {
48
  res.status(502).send('Proxy fetch failed');
49
  }
50
  });
51
 
52
- // YouTube resolver
 
53
  app.get('/api/yt/source', async (req, res) => {
54
- const raw = (req.query.url || '').trim();
55
- const apiPref = (req.query.api || '').trim().toLowerCase();
56
- if (!raw) return res.status(400).json({ error: 'Missing url' });
57
 
58
- let watchUrl = raw;
59
- if (/^[A-Za-z0-9_-]{11}$/.test(raw)) {
60
- watchUrl = `https://www.youtube.com/watch?v=${raw}`;
61
- }
 
62
 
63
- const tryVidfly = async () => {
64
  const cacheKey = `vidfly:${watchUrl}`;
65
- let info = formatCache.get(cacheKey);
66
  if (!info) {
 
67
  const resp = await axios.get('https://api.vidfly.ai/api/media/youtube/download', {
68
  params: { url: watchUrl },
69
  timeout: 20000
70
  });
71
  info = resp.data;
72
- formatCache.set(cacheKey, info);
73
  }
74
- if (info.code !== 0 || !info.data?.items?.length) {
75
- throw new Error('Vidfly: No playable stream');
 
 
 
76
  }
77
- const items = info.data.items;
78
- // Prefer video_with_audio mp4
 
 
 
79
  const muxed = items
80
- .filter(i => i.type === 'video_with_audio' && i.ext === 'mp4')
81
- .sort((a,b) => (b.height||0) - (a.height||0))[0];
82
- const audio = items
83
- .filter(i => i.type === 'audio')
84
- .sort((a,b) => (parseInt(b.bitrate||'0') - parseInt(a.bitrate||'0')))[0];
85
- const chosen = muxed || audio;
86
- if (!chosen?.url) throw new Error('Vidfly: No playable stream');
87
- return {
 
 
 
 
 
 
 
 
 
 
 
88
  url: chosen.url,
89
- title: info.data.title || watchUrl,
90
- thumbnail: info.data.cover || null,
91
- duration: info.data.duration || null,
92
  kind: muxed ? 'video' : 'audio',
93
  source: 'vidfly'
94
- };
95
- };
96
-
97
- const tryYtDlp = async () => {
98
- const { stdout } = await exec(`yt-dlp -f best -g "${watchUrl}"`);
99
- const lines = stdout.trim().split('\n').filter(Boolean);
100
- const direct = lines[0];
101
- if (!direct) throw new Error('yt-dlp: No URL output');
102
- return {
103
- url: direct,
104
- title: watchUrl,
105
- thumbnail: null,
106
- duration: null,
107
- kind: 'video',
108
- source: 'yt-dlp'
109
- };
110
- };
111
-
112
- if (apiPref === 'vidfly') {
113
- try { return res.json(await tryVidfly()); }
114
- catch (e) { return res.status(502).json({ error: e.message, source: 'vidfly' }); }
115
- }
116
- if (apiPref === 'yt-dlp') {
117
- try { return res.json(await tryYtDlp()); }
118
- catch (e) { return res.status(502).json({ error: e.message, source: 'yt-dlp' }); }
119
  }
 
120
 
121
- try { return res.json(await tryVidfly()); }
122
- catch {}
123
- try { return res.json(await tryYtDlp()); }
124
- catch (e) {
125
- return res.status(500).json({ error: 'Both Vidfly.ai and yt-dlp failed', details: e.message });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  }
127
  });
128
 
129
- // JioSaavn routes
130
  app.get('/api/result', async (req, res) => {
131
- try { res.json(await searchUniversal(req.query.q || '')); }
132
- catch (e) { res.status(500).json({ error: e.message }); }
 
 
 
133
  });
134
  app.get('/api/song', async (req, res) => {
135
- try { res.json(await getSong(req.query.q || '')); }
136
- catch (e) { res.status(500).json({ error: e.message }); }
 
 
 
137
  });
138
  app.get('/api/album', async (req, res) => {
139
- try { res.json(await getAlbum(req.query.q || '')); }
140
- catch (e) { res.status(500).json({ error: e.message }); }
 
 
 
141
  });
142
  app.get('/api/playlist', async (req, res) => {
143
- try { res.json(await getPlaylist(req.query.q || '')); }
144
- catch (e) { res.status(500).json({ error: e.message }); }
 
 
 
145
  });
146
  app.get('/api/lyrics', async (req, res) => {
147
- try { res.json(await getLyrics(req.query.q || '')); }
148
- catch (e) { res.status(500).json({ error: e.message }); }
 
 
 
149
  });
150
 
151
- // Lobby
 
 
 
 
 
 
 
 
 
152
  const rooms = new Map();
153
- function ensureRoom(id) {
154
- if (!rooms.has(id)) {
155
- rooms.set(id, { id, name: null, hostId: null, users: new Map(), track: null, isPlaying: false, anchor: 0, anchorAt: 0, queue: [] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  }
157
- return rooms.get(id);
158
  }
159
- function currentState(r) { return { track: r.track, isPlaying: r.isPlaying, anchor: r.anchor, anchorAt: r.anchorAt, queue: r.queue }; }
160
- function membersPayload(r) { return [...r.users.entries()].map(([id,u]) => ({ id, name: u.name, role: u.role, muted: !!u.muted, isHost: id===r.hostId })); }
161
- function broadcastMembers(id) { const r=rooms.get(id); if(r) io.to(id).emit('members',{members:membersPayload(r),roomName:r.name||r.id}); }
162
- function requireHostOrCohost(r,sid){const u=r.users.get(sid);return sid===r.hostId||(u&&u.role==='cohost');}
163
- function normalizeThumb(meta){return meta?.thumb||meta?.image||null;}
164
- function findUserByName(r,n){if(!n)return null;const t=n.replace(/^@/,'').toLowerCase();for(const [id,u] of r.users){if(u.name.toLowerCase()===t)return{id,u};}return null;}
165
-
166
- app.get('/api/rooms', (_req,res) => {
167
- res.json({ rooms: [...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})) });
 
 
 
168
  });
169
- app.get('/api/ping', (_req,res) => res.json({ ok:true }));
 
170
 
171
  // Static client
172
  const clientDir = path.resolve(__dirname, '../client/dist');
173
  app.use(express.static(clientDir));
174
- app.get('*', (_req,res) => res.sendFile(path.join(clientDir, 'index.html')));
175
 
176
- // Socket.IO
177
  const server = http.createServer(app);
178
- const io = new SocketIOServer(server, { cors: { origin: '*', methods: ['GET','POST'] } });
 
 
179
 
180
- io.on('connection', socket => {
181
  let joinedRoom = null;
182
- socket.on('join_room', ({ roomId, name, asHost=false, roomName=null }, ack) => {
 
183
  const room = ensureRoom(roomId);
 
184
  if (asHost || !room.hostId) room.hostId = socket.id;
185
- if (!room.name && roomName) room.name = roomName;
186
-
187
 
188
  const role = socket.id === room.hostId ? 'host' : 'member';
189
- room.users.set(socket.id, { name: name || 'Guest', role });
 
190
 
191
  socket.join(roomId);
192
  joinedRoom = roomId;
@@ -195,28 +335,30 @@ io.on('connection', socket => {
195
  roomId,
196
  isHost: socket.id === room.hostId,
197
  state: currentState(room),
198
- roomName: room.name
199
  });
200
 
201
- io.to(roomId).emit('system', { text: `System: ${name} has joined the chat` });
202
  broadcastMembers(roomId);
203
  });
204
 
205
  socket.on('rename', ({ roomId, newName }) => {
206
  const room = rooms.get(roomId);
207
- const u = room?.users.get(socket.id);
208
- if (u) {
209
- u.name = newName || 'Guest';
210
- broadcastMembers(roomId);
211
- }
212
  });
213
 
214
  socket.on('chat_message', ({ roomId, name, text }) => {
215
  const room = rooms.get(roomId);
216
- const u = room?.users.get(socket.id);
 
217
  if (!u) return;
218
  if (u.muted) {
219
- return socket.emit('system', { text: 'System: You are muted' });
 
220
  }
221
  socket.to(roomId).emit('chat_message', { name, text, at: Date.now() });
222
  });
@@ -224,58 +366,65 @@ io.on('connection', socket => {
224
  socket.on('song_request', ({ roomId, requester, query }) => {
225
  const room = rooms.get(roomId);
226
  if (!room) return;
227
- const requestId = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
228
- const payload = { requester, query, at: Date.now(), requestId };
229
- for (const [id, u] of room.users.entries()) {
230
- if (id === room.hostId || u.role === 'cohost') {
231
- io.to(id).emit('song_request', payload);
232
- }
 
 
233
  }
234
  io.to(roomId).emit('system', { text: `System: ${requester} requested /play ${query}` });
235
  });
236
 
237
  socket.on('song_request_action', ({ roomId, action, track }) => {
238
  const room = rooms.get(roomId);
239
- if (!room || !requireHostOrCohost(room, socket.id) || !track) return;
240
- const t = {
241
- url: track.url,
242
- title: track.title || track.url,
243
- meta: track.meta || {},
244
- kind: track.kind || 'audio',
245
- thumb: track.thumb || normalizeThumb(track.meta)
246
- };
247
- if (action === 'accept') {
248
- room.track = t;
249
- room.isPlaying = false;
250
- room.anchor = 0;
251
- room.anchorAt = Date.now();
252
- io.to(roomId).emit('set_track', { track: t });
253
- io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt });
254
- io.to(roomId).emit('system', { text: `System: Now playing ${t.title}` });
255
- } else if (action === 'queue') {
256
- room.queue.push(t);
257
- io.to(roomId).emit('queue_update', { queue: room.queue });
258
- io.to(roomId).emit('system', { text: `System: Queued ${t.title}` });
 
 
 
 
259
  }
260
  });
261
 
262
  socket.on('set_track', ({ roomId, track }) => {
263
  const room = rooms.get(roomId);
264
- if (!room || !requireHostOrCohost(room, socket.id)) return;
265
- const thumb = track.thumb || normalizeThumb(track.meta);
266
- const t = { ...track, thumb };
267
- room.track = t;
268
  room.isPlaying = false;
269
  room.anchor = 0;
270
  room.anchorAt = Date.now();
271
- io.to(roomId).emit('set_track', { track: t });
272
  io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt });
273
- io.to(roomId).emit('system', { text: `System: Selected ${t.title}` });
274
  });
275
 
276
  socket.on('play', ({ roomId }) => {
277
  const room = rooms.get(roomId);
278
- if (!room || !requireHostOrCohost(room, socket.id)) return;
 
279
  room.isPlaying = true;
280
  room.anchorAt = Date.now();
281
  io.to(roomId).emit('play', { anchor: room.anchor, anchorAt: room.anchorAt });
@@ -283,8 +432,9 @@ io.on('connection', socket => {
283
 
284
  socket.on('pause', ({ roomId }) => {
285
  const room = rooms.get(roomId);
286
- if (!room || !requireHostOrCohost(room, socket.id)) return;
287
- const elapsed = (Date.now() - room.anchorAt) / 1000;
 
288
  if (room.isPlaying) room.anchor += elapsed;
289
  room.isPlaying = false;
290
  room.anchorAt = Date.now();
@@ -293,7 +443,8 @@ io.on('connection', socket => {
293
 
294
  socket.on('seek', ({ roomId, to }) => {
295
  const room = rooms.get(roomId);
296
- if (!room || !requireHostOrCohost(room, socket.id)) return;
 
297
  room.anchor = to;
298
  room.anchorAt = Date.now();
299
  io.to(roomId).emit('seek', { anchor: room.anchor, anchorAt: room.anchorAt, isPlaying: room.isPlaying });
@@ -301,17 +452,19 @@ io.on('connection', socket => {
301
 
302
  socket.on('ended', ({ roomId }) => {
303
  const room = rooms.get(roomId);
304
- if (!room || !requireHostOrCohost(room, socket.id)) return;
 
305
  const next = room.queue.shift();
306
  if (next) {
307
- room.track = next;
 
308
  room.isPlaying = false;
309
  room.anchor = 0;
310
  room.anchorAt = Date.now();
311
- io.to(roomId).emit('set_track', { track: next });
312
  io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt });
313
  io.to(roomId).emit('queue_update', { queue: room.queue });
314
- io.to(roomId).emit('system', { text: `System: Now playing ${next.title}` });
315
  } else {
316
  io.to(roomId).emit('system', { text: 'System: Queue ended' });
317
  }
@@ -352,16 +505,20 @@ io.on('connection', socket => {
352
  if (!joinedRoom) return;
353
  const room = rooms.get(joinedRoom);
354
  if (!room) return;
355
- const u = room.users.get(socket.id);
356
  room.users.delete(socket.id);
357
  if (socket.id === room.hostId) {
358
- const next = [...room.users.keys()][0] || null;
359
- room.hostId = next;
360
- if (next) room.users.get(next).role = 'host';
361
- io.to(joinedRoom).emit('host_changed', { hostId: next });
 
 
 
362
  }
363
- if (u) {
364
- io.to(joinedRoom).emit('system', { text: `System: ${u.name} has left` });
 
365
  }
366
  if (room.users.size === 0) rooms.delete(joinedRoom);
367
  else broadcastMembers(joinedRoom);
 
6
  import { fileURLToPath } from 'url';
7
  import axios from 'axios';
8
  import NodeCache from 'node-cache';
 
 
9
  import {
10
  searchUniversal,
11
  getSong,
 
14
  getLyrics
15
  } from './jiosaavn.js';
16
 
 
17
  const __filename = fileURLToPath(import.meta.url);
18
  const __dirname = path.dirname(__filename);
19
 
20
  const app = express();
21
+
22
+ // CORS for API endpoints (the client is same origin; this also helps external embeds)
23
+ app.use(cors({ origin: '*', credentials: true }));
24
  app.use(express.json());
25
 
26
+ // ---------------- Cache ----------------
27
+ const vidflyCache = new NodeCache({ stdTTL: 300, checkperiod: 60 });
28
+
29
+ // ---------------- Health ----------------
30
+ app.get('/healthz', (_req, res) => res.send('OK'));
31
 
32
+ // ---------------- Media proxy (Range-aware, CORS-safe) ----------------
33
+ // Use this for all Vidfly media URLs to bypass CORS.
34
  app.get('/api/proxy', async (req, res) => {
35
  const target = req.query.url;
36
  if (!target) return res.status(400).send('Missing url');
37
+
38
  try {
39
+ // Forward Range and basic headers for streaming/seeking
40
  const headers = {};
41
  if (req.headers.range) headers.Range = req.headers.range;
42
+ if (req.headers['user-agent']) headers['User-Agent'] = req.headers['user-agent'];
43
+ if (req.headers['accept']) headers['Accept'] = req.headers['accept'];
44
+ if (req.headers['accept-encoding']) headers['Accept-Encoding'] = req.headers['accept-encoding'];
45
+ if (req.headers['accept-language']) headers['Accept-Language'] = req.headers['accept-language'];
46
+ // Some CDNs check Referer; set a generic one if not present
47
+ headers.Referer = req.headers.referer || 'https://www.youtube.com/';
48
+
49
  const upstream = await axios.get(target, {
50
  responseType: 'stream',
51
  headers,
52
+ // We need to forward non-200 statuses for partial content
53
+ validateStatus: () => true,
54
+ maxRedirects: 5
55
  });
56
+
57
+ // CORS headers for the proxied response (so <video> can consume it)
58
+ res.setHeader('Access-Control-Allow-Origin', '*');
59
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
60
+
61
+ // Mirror status and key headers (content-range enables seeking)
62
  res.status(upstream.status);
63
+ const passthroughHeaders = [
64
+ 'content-type',
65
+ 'content-length',
66
+ 'content-range',
67
+ 'accept-ranges',
68
+ 'etag',
69
+ 'last-modified',
70
+ 'cache-control',
71
+ 'expires',
72
+ 'date',
73
+ 'server'
74
+ ];
75
+ for (const h of passthroughHeaders) {
76
+ const v = upstream.headers[h];
77
+ if (v) res.setHeader(h, v);
78
  }
79
+
80
  upstream.data.pipe(res);
81
+ } catch (e) {
82
  res.status(502).send('Proxy fetch failed');
83
  }
84
  });
85
 
86
+ // ---------------- YouTube via Vidfly (no yt-dlp) ----------------
87
+ // GET /api/yt/source?url=<watchURL or 11-char ID>
88
  app.get('/api/yt/source', async (req, res) => {
89
+ try {
90
+ const raw = (req.query.url || '').trim();
91
+ if (!raw) return res.status(400).json({ error: 'Missing url' });
92
 
93
+ // Normalize 11-char ID to full watch URL
94
+ let watchUrl = raw;
95
+ if (/^[A-Za-z0-9_-]{11}$/.test(raw)) {
96
+ watchUrl = `https://www.youtube.com/watch?v=${raw}`;
97
+ }
98
 
99
+ // Cache
100
  const cacheKey = `vidfly:${watchUrl}`;
101
+ let info = vidflyCache.get(cacheKey);
102
  if (!info) {
103
+ // Vidfly API
104
  const resp = await axios.get('https://api.vidfly.ai/api/media/youtube/download', {
105
  params: { url: watchUrl },
106
  timeout: 20000
107
  });
108
  info = resp.data;
109
+ vidflyCache.set(cacheKey, info);
110
  }
111
+
112
+ // Parse Vidfly shape you provided:
113
+ // { code: 0, data: { cover, duration, title?, items: [ { ext, type, height, label, url, ... }, ... ] } }
114
+ if (info?.code !== 0 || !info?.data?.items?.length) {
115
+ return res.status(502).json({ error: 'Vidfly: No playable stream' });
116
  }
117
+
118
+ const data = info.data;
119
+ const items = Array.isArray(data.items) ? data.items : [];
120
+
121
+ // Prefer video_with_audio mp4 with highest height
122
  const muxed = items
123
+ .filter(i => i?.type === 'video_with_audio' && i?.ext === 'mp4' && i?.url)
124
+ .sort((a, b) => (b.height || 0) - (a.height || 0))[0];
125
+
126
+ // Fallback: best "audio" (if provided)
127
+ const bestAudio = items
128
+ .filter(i => i?.type === 'audio' && i?.url)
129
+ .sort((a, b) => {
130
+ const ba = parseInt(String(a.bitrate || '').replace(/\D/g, '') || '0', 10);
131
+ const bb = parseInt(String(b.bitrate || '').replace(/\D/g, '') || '0', 10);
132
+ return bb - ba;
133
+ })[0];
134
+
135
+ const chosen = muxed || bestAudio;
136
+ if (!chosen?.url) {
137
+ return res.status(502).json({ error: 'Vidfly: No playable stream url' });
138
+ }
139
+
140
+ // Return the raw URL (client will route via /api/proxy automatically on error)
141
+ return res.json({
142
  url: chosen.url,
143
+ title: data.title || watchUrl,
144
+ thumbnail: data.cover || null,
145
+ duration: data.duration || null,
146
  kind: muxed ? 'video' : 'audio',
147
  source: 'vidfly'
148
+ });
149
+ } catch (e) {
150
+ return res.status(500).json({ error: e.message || 'Vidfly resolve failed' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  }
152
+ });
153
 
154
+ // ---------------- YouTube search (Data API v3) ----------------
155
+ // Set env YT_API_KEY to enable
156
+ app.get('/api/ytsearch', async (req, res) => {
157
+ try {
158
+ const key = process.env.YT_API_KEY;
159
+ if (!key) return res.status(501).json({ error: 'YouTube search unavailable: set YT_API_KEY' });
160
+ const q = String(req.query.q || '').trim();
161
+ if (!q) return res.json({ items: [] });
162
+
163
+ const resp = await axios.get('https://www.googleapis.com/youtube/v3/search', {
164
+ params: { key, q, part: 'snippet', type: 'video', maxResults: 12 },
165
+ timeout: 10000
166
+ });
167
+ const items = (resp.data.items || [])
168
+ .map(it => ({
169
+ videoId: it.id?.videoId,
170
+ title: it.snippet?.title,
171
+ channelTitle: it.snippet?.channelTitle,
172
+ thumb: it.snippet?.thumbnails?.medium?.url || it.snippet?.thumbnails?.default?.url
173
+ }))
174
+ .filter(x => x.videoId);
175
+ res.json({ items });
176
+ } catch (e) {
177
+ res.status(500).json({ error: e.message || 'YouTube search failed' });
178
  }
179
  });
180
 
181
+ // ---------------- JioSaavn proxies ----------------
182
  app.get('/api/result', async (req, res) => {
183
+ try {
184
+ const q = req.query.q || '';
185
+ const data = await searchUniversal(q);
186
+ res.json(data);
187
+ } catch (e) { res.status(500).json({ error: e.message }); }
188
  });
189
  app.get('/api/song', async (req, res) => {
190
+ try {
191
+ const q = req.query.q || '';
192
+ const data = await getSong(q);
193
+ res.json(data);
194
+ } catch (e) { res.status(500).json({ error: e.message }); }
195
  });
196
  app.get('/api/album', async (req, res) => {
197
+ try {
198
+ const q = req.query.q || '';
199
+ const data = await getAlbum(q);
200
+ res.json(data);
201
+ } catch (e) { res.status(500).json({ error: e.message }); }
202
  });
203
  app.get('/api/playlist', async (req, res) => {
204
+ try {
205
+ const q = req.query.q || '';
206
+ const data = await getPlaylist(q);
207
+ res.json(data);
208
+ } catch (e) { res.status(500).json({ error: e.message }); }
209
  });
210
  app.get('/api/lyrics', async (req, res) => {
211
+ try {
212
+ const q = req.query.q || '';
213
+ const data = await getLyrics(q);
214
+ res.json(data);
215
+ } catch (e) { res.status(500).json({ error: e.message }); }
216
  });
217
 
218
+ // ---------------- Rooms, lobby, and sync ----------------
219
+ /*
220
+ room = {
221
+ id, name, hostId,
222
+ users: Map<socketId, { name, role: 'host'|'cohost'|'member', muted?: boolean }>,
223
+ track: { url, title, meta, kind, thumb? },
224
+ isPlaying, anchor, anchorAt,
225
+ queue: Array<track>
226
+ }
227
+ */
228
  const rooms = new Map();
229
+
230
+ function ensureRoom(roomId) {
231
+ if (!rooms.has(roomId)) {
232
+ rooms.set(roomId, {
233
+ id: roomId,
234
+ name: null,
235
+ hostId: null,
236
+ users: new Map(),
237
+ track: null,
238
+ isPlaying: false,
239
+ anchor: 0,
240
+ anchorAt: 0,
241
+ queue: []
242
+ });
243
+ }
244
+ return rooms.get(roomId);
245
+ }
246
+
247
+ function currentState(room) {
248
+ return {
249
+ track: room.track,
250
+ isPlaying: room.isPlaying,
251
+ anchor: room.anchor,
252
+ anchorAt: room.anchorAt,
253
+ queue: room.queue
254
+ };
255
+ }
256
+
257
+ function membersPayload(room) {
258
+ return [...room.users.entries()].map(([id, u]) => ({
259
+ id,
260
+ name: u.name || 'Guest',
261
+ role: u.role || 'member',
262
+ muted: !!u.muted,
263
+ isHost: id === room.hostId
264
+ }));
265
+ }
266
+
267
+ function broadcastMembers(roomId) {
268
+ const room = rooms.get(roomId);
269
+ if (!room) return;
270
+ const members = membersPayload(room);
271
+ io.to(roomId).emit('members', { members, roomName: room.name || room.id });
272
+ }
273
+
274
+ function requireHostOrCohost(room, socketId) {
275
+ const u = room.users.get(socket.id);
276
+ return (socketId === room.hostId) || (u && u.role === 'cohost');
277
+ }
278
+
279
+ function normalizeThumb(meta) {
280
+ return meta?.thumb || meta?.image || meta?.thumbnail || null;
281
+ }
282
+
283
+ function findUserByName(room, targetNameRaw) {
284
+ if (!targetNameRaw) return null;
285
+ const targetName = String(targetNameRaw).replace(/^@/, '').trim().toLowerCase();
286
+ for (const [id, u] of room.users.entries()) {
287
+ if ((u.name || '').toLowerCase() === targetName) return { id, u };
288
  }
289
+ return null;
290
  }
291
+
292
+ // Lobby
293
+ app.get('/api/rooms', (_req, res) => {
294
+ const data = [...rooms.values()]
295
+ .filter(r => r.users.size > 0)
296
+ .map(r => ({
297
+ id: r.id,
298
+ name: r.name || r.id,
299
+ members: r.users.size,
300
+ isPlaying: r.isPlaying
301
+ }));
302
+ res.json({ rooms: data });
303
  });
304
+
305
+ app.get('/api/ping', (_req, res) => res.json({ ok: true }));
306
 
307
  // Static client
308
  const clientDir = path.resolve(__dirname, '../client/dist');
309
  app.use(express.static(clientDir));
310
+ app.get('*', (_req, res) => res.sendFile(path.join(clientDir, 'index.html')));
311
 
312
+ // Socket.IO realtime
313
  const server = http.createServer(app);
314
+ const io = new SocketIOServer(server, {
315
+ cors: { origin: '*', methods: ['GET', 'POST'] }
316
+ });
317
 
318
+ io.on('connection', (socket) => {
319
  let joinedRoom = null;
320
+
321
+ socket.on('join_room', ({ roomId, name, asHost = false, roomName = null }, ack) => {
322
  const room = ensureRoom(roomId);
323
+
324
  if (asHost || !room.hostId) room.hostId = socket.id;
325
+ if (!room.name && roomName) room.name = String(roomName).trim().slice(0, 60) || null;
 
326
 
327
  const role = socket.id === room.hostId ? 'host' : 'member';
328
+ const cleanName = String(name || 'Guest').slice(0, 40);
329
+ room.users.set(socket.id, { name: cleanName, role });
330
 
331
  socket.join(roomId);
332
  joinedRoom = roomId;
 
335
  roomId,
336
  isHost: socket.id === room.hostId,
337
  state: currentState(room),
338
+ roomName: room.name || room.id
339
  });
340
 
341
+ io.to(roomId).emit('system', { text: `System: ${cleanName} has joined the chat` });
342
  broadcastMembers(roomId);
343
  });
344
 
345
  socket.on('rename', ({ roomId, newName }) => {
346
  const room = rooms.get(roomId);
347
+ if (!room) return;
348
+ const u = room.users.get(socket.id);
349
+ if (!u) return;
350
+ u.name = String(newName || 'Guest').slice(0, 40);
351
+ broadcastMembers(roomId);
352
  });
353
 
354
  socket.on('chat_message', ({ roomId, name, text }) => {
355
  const room = rooms.get(roomId);
356
+ if (!room) return;
357
+ const u = room.users.get(socket.id);
358
  if (!u) return;
359
  if (u.muted) {
360
+ socket.emit('system', { text: 'System: You are muted by the host' });
361
+ return;
362
  }
363
  socket.to(roomId).emit('chat_message', { name, text, at: Date.now() });
364
  });
 
366
  socket.on('song_request', ({ roomId, requester, query }) => {
367
  const room = rooms.get(roomId);
368
  if (!room) return;
369
+ const payload = {
370
+ requester,
371
+ query,
372
+ at: Date.now(),
373
+ requestId: `${Date.now()}_${Math.random().toString(36).slice(2)}`
374
+ };
375
+ for (const [id, usr] of room.users.entries()) {
376
+ if (id === room.hostId || usr.role === 'cohost') io.to(id).emit('song_request', payload);
377
  }
378
  io.to(roomId).emit('system', { text: `System: ${requester} requested /play ${query}` });
379
  });
380
 
381
  socket.on('song_request_action', ({ roomId, action, track }) => {
382
  const room = rooms.get(roomId);
383
+ if (!room) return;
384
+ const actor = room.users.get(socket.id);
385
+ if (!actor || !(socket.id === room.hostId || actor.role === 'cohost')) return;
386
+ if ((action === 'accept' || action === 'queue') && track) {
387
+ const t = {
388
+ url: track.url,
389
+ title: track.title || track.url,
390
+ meta: track.meta || {},
391
+ kind: track.kind || 'audio',
392
+ thumb: track.thumb || normalizeThumb(track.meta || {})
393
+ };
394
+ if (action === 'accept') {
395
+ room.track = t;
396
+ room.isPlaying = false;
397
+ room.anchor = 0;
398
+ room.anchorAt = Date.now();
399
+ io.to(roomId).emit('set_track', { track: room.track });
400
+ io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt });
401
+ io.to(roomId).emit('system', { text: `System: Now playing ${room.track.title}` });
402
+ } else {
403
+ room.queue.push(t);
404
+ io.to(roomId).emit('queue_update', { queue: room.queue });
405
+ io.to(roomId).emit('system', { text: `System: Queued ${t.title}` });
406
+ }
407
  }
408
  });
409
 
410
  socket.on('set_track', ({ roomId, track }) => {
411
  const room = rooms.get(roomId);
412
+ const actor = room.users.get(socket.id);
413
+ if (!room || !(socket.id === room.hostId || actor?.role === 'cohost')) return;
414
+ const thumb = track.thumb || normalizeThumb(track.meta || {});
415
+ room.track = { ...track, thumb };
416
  room.isPlaying = false;
417
  room.anchor = 0;
418
  room.anchorAt = Date.now();
419
+ io.to(roomId).emit('set_track', { track: room.track });
420
  io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt });
421
+ io.to(roomId).emit('system', { text: `System: Selected ${room.track.title || room.track.url}` });
422
  });
423
 
424
  socket.on('play', ({ roomId }) => {
425
  const room = rooms.get(roomId);
426
+ const actor = room.users.get(socket.id);
427
+ if (!room || !(socket.id === room.hostId || actor?.role === 'cohost')) return;
428
  room.isPlaying = true;
429
  room.anchorAt = Date.now();
430
  io.to(roomId).emit('play', { anchor: room.anchor, anchorAt: room.anchorAt });
 
432
 
433
  socket.on('pause', ({ roomId }) => {
434
  const room = rooms.get(roomId);
435
+ const actor = room.users.get(socket.id);
436
+ if (!room || !(socket.id === room.hostId || actor?.role === 'cohost')) return;
437
+ const elapsed = Math.max(0, Date.now() - room.anchorAt) / 1000;
438
  if (room.isPlaying) room.anchor += elapsed;
439
  room.isPlaying = false;
440
  room.anchorAt = Date.now();
 
443
 
444
  socket.on('seek', ({ roomId, to }) => {
445
  const room = rooms.get(roomId);
446
+ const actor = room.users.get(socket.id);
447
+ if (!room || !(socket.id === room.hostId || actor?.role === 'cohost')) return;
448
  room.anchor = to;
449
  room.anchorAt = Date.now();
450
  io.to(roomId).emit('seek', { anchor: room.anchor, anchorAt: room.anchorAt, isPlaying: room.isPlaying });
 
452
 
453
  socket.on('ended', ({ roomId }) => {
454
  const room = rooms.get(roomId);
455
+ const actor = room.users.get(socket.id);
456
+ if (!room || !(socket.id === room.hostId || actor?.role === 'cohost')) return;
457
  const next = room.queue.shift();
458
  if (next) {
459
+ const thumb = next.thumb || normalizeThumb(next.meta || {});
460
+ room.track = { ...next, thumb };
461
  room.isPlaying = false;
462
  room.anchor = 0;
463
  room.anchorAt = Date.now();
464
+ io.to(roomId).emit('set_track', { track: room.track });
465
  io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt });
466
  io.to(roomId).emit('queue_update', { queue: room.queue });
467
+ io.to(roomId).emit('system', { text: `System: Now playing ${room.track.title}` });
468
  } else {
469
  io.to(roomId).emit('system', { text: 'System: Queue ended' });
470
  }
 
505
  if (!joinedRoom) return;
506
  const room = rooms.get(joinedRoom);
507
  if (!room) return;
508
+ const leftUser = room.users.get(socket.id);
509
  room.users.delete(socket.id);
510
  if (socket.id === room.hostId) {
511
+ const nextHost = [...room.users.keys()][0] || null;
512
+ room.hostId = nextHost;
513
+ if (nextHost) {
514
+ const u = room.users.get(nextHost);
515
+ if (u) u.role = 'host';
516
+ }
517
+ io.to(joinedRoom).emit('host_changed', { hostId: room.hostId });
518
  }
519
+ if (leftUser) {
520
+ const nm = leftUser.name || 'User';
521
+ io.to(joinedRoom).emit('system', { text: `System: ${nm} has left the chat` });
522
  }
523
  if (room.users.size === 0) rooms.delete(joinedRoom);
524
  else broadcastMembers(joinedRoom);