akborana4 commited on
Commit
b38ec5a
·
verified ·
1 Parent(s): 9427dfc

Update server/server.js

Browse files
Files changed (1) hide show
  1. server/server.js +174 -392
server/server.js CHANGED
@@ -1,7 +1,3 @@
1
- // server/server.js
2
- // Complete server with Vidfly.ai primary YouTube resolver, yt-dlp fallback, CORS media proxy,
3
- // JioSaavn routes, static client, and full Socket.IO room sync logic.
4
-
5
  import express from 'express';
6
  import http from 'http';
7
  import cors from 'cors';
@@ -28,368 +24,169 @@ const app = express();
28
  app.use(cors());
29
  app.use(express.json());
30
 
31
- // Health
32
- app.get('/healthz', (_req, res) => res.send('OK'));
33
-
34
- // Cache for YouTube resolution results (5 minutes)
35
  const formatCache = new NodeCache({ stdTTL: 300, checkperiod: 60 });
36
 
37
- // ========== Media proxy to bypass CORS on remote media URLs ==========
38
  app.get('/api/proxy', async (req, res) => {
 
 
39
  try {
40
- const target = req.query.url;
41
- if (!target) return res.status(400).send('Missing url');
42
- // Forward common streaming headers (Range is crucial for video seeking)
43
- const fwdHeaders = {};
44
- if (req.headers.range) fwdHeaders.Range = req.headers.range;
45
- if (req.headers['user-agent']) fwdHeaders['User-Agent'] = req.headers['user-agent'];
46
- if (req.headers['accept']) fwdHeaders['Accept'] = req.headers['accept'];
47
- if (req.headers['accept-encoding']) fwdHeaders['Accept-Encoding'] = req.headers['accept-encoding'];
48
- if (req.headers['accept-language']) fwdHeaders['Accept-Language'] = req.headers['accept-language'];
49
- if (req.headers['referer']) fwdHeaders['Referer'] = req.headers['referer'];
50
- // You can also set a generic Referer if some CDNs require it
51
  const upstream = await axios.get(target, {
52
  responseType: 'stream',
53
- headers: fwdHeaders,
54
- validateStatus: () => true, // pass-through non-200s
55
- maxRedirects: 5
56
  });
57
-
58
- // Mirror status and selected headers
59
  res.status(upstream.status);
60
- const passthroughHeaders = [
61
- 'content-type',
62
- 'content-length',
63
- 'content-range',
64
- 'accept-ranges',
65
- 'etag',
66
- 'last-modified',
67
- 'cache-control',
68
- 'expires',
69
- 'date',
70
- 'server'
71
- ];
72
- for (const h of passthroughHeaders) {
73
- const v = upstream.headers[h];
74
- if (v) res.setHeader(h, v);
75
  }
76
- // Stream body
77
  upstream.data.pipe(res);
78
- } catch (_e) {
79
  res.status(502).send('Proxy fetch failed');
80
  }
81
  });
82
 
83
- // ========== YouTube resolver: Vidfly.ai primary, yt-dlp fallback ==========
84
- // Supports ?url=<id|full-url>&api=vidfly|yt-dlp (api param optional; default is Vidfly then fallback)
85
  app.get('/api/yt/source', async (req, res) => {
86
- try {
87
- const raw = (req.query.url || '').trim();
88
- const apiPref = (req.query.api || '').trim().toLowerCase(); // optional: 'vidfly' or 'yt-dlp'
89
- if (!raw) return res.status(400).json({ error: 'Missing url' });
90
-
91
- // Normalize 11-char YouTube ID to full watch URL
92
- let watchUrl = raw;
93
- if (/^[A-Za-z0-9_-]{11}$/.test(raw)) {
94
- watchUrl = `https://www.youtube.com/watch?v=${raw}`;
95
- }
96
 
97
- // Helper: respond from Vidfly.ai
98
- const tryVidfly = async () => {
99
- const cacheKey = `vidfly:${watchUrl}`;
100
- let info = formatCache.get(cacheKey);
101
- if (!info) {
102
- const resp = await axios.get('https://api.vidfly.ai/api/media/youtube/download', {
103
- params: { url: watchUrl },
104
- timeout: 25000
105
- });
106
- info = resp.data;
107
- formatCache.set(cacheKey, info);
108
- }
109
- // Adjust parsing to Vidfly.ai response shape if needed
110
- // Expected generic shape assumption:
111
- // { title, thumbnail, duration, video: [{quality, url, mimeType}], audio: [{bitrate, url, mimeType}] }
112
- const videos = Array.isArray(info?.video) ? info.video : [];
113
- const audios = Array.isArray(info?.audio) ? info.audio : [];
114
-
115
- // Prefer MP4 progressive-like URLs for browser playback
116
- const pickVideo = videos
117
- .filter(f => f?.url && /mp4/i.test(f?.mimeType || '') || (typeof f?.url === 'string' && f.url.endsWith('.mp4')))
118
- .sort((a, b) => {
119
- const qa = parseInt(String(a.quality || '').replace(/\D/g, '') || '0', 10);
120
- const qb = parseInt(String(b.quality || '').replace(/\D/g, '') || '0', 10);
121
- return qb - qa;
122
- })[0];
123
-
124
- const pickAudio = audios
125
- .filter(f => f?.url)
126
- .sort((a, b) => {
127
- const ba = parseInt(String(a.bitrate || '').replace(/\D/g, '') || '0', 10);
128
- const bb = parseInt(String(b.bitrate || '').replace(/\D/g, '') || '0', 10);
129
- return bb - ba;
130
- })[0];
131
-
132
- const chosen = pickVideo || pickAudio;
133
- if (!chosen?.url) throw new Error('Vidfly: No playable stream');
134
-
135
- return {
136
- url: chosen.url,
137
- title: info?.title || watchUrl,
138
- thumbnail: info?.thumbnail || null,
139
- duration: info?.duration || null,
140
- kind: pickVideo ? 'video' : 'audio',
141
- source: 'vidfly'
142
- };
143
  };
 
144
 
145
- // Helper: respond from local yt-dlp CLI
146
- const tryYtDlp = async () => {
147
- // -g prints direct URL(s)
148
- // -f best first tries progressive best; if not, it may output separate URLs (video, audio)
149
- const { stdout } = await exec(`yt-dlp -f best -g "${watchUrl}"`);
150
- const lines = stdout.trim().split('\n').filter(Boolean);
151
- // If two lines, first is video-only URL then audio-only; we'll prefer the first
152
- const direct = lines[0];
153
- if (!direct) throw new Error('yt-dlp: No URL output');
154
- return {
155
- url: direct,
156
- title: watchUrl,
157
- thumbnail: null,
158
- duration: null,
159
- kind: 'video',
160
- source: 'yt-dlp'
161
- };
162
  };
 
163
 
164
- // Route based on api preference or attempt Vidfly then fallback
165
- if (apiPref === 'vidfly') {
166
- try {
167
- const r = await tryVidfly();
168
- return res.json(r);
169
- } catch (e) {
170
- return res.status(502).json({ error: String(e.message || e), source: 'vidfly' });
171
- }
172
- }
173
- if (apiPref === 'yt-dlp') {
174
- try {
175
- const r = await tryYtDlp();
176
- return res.json(r);
177
- } catch (e) {
178
- return res.status(502).json({ error: String(e.message || e), source: 'yt-dlp' });
179
- }
180
- }
181
 
182
- // Default behavior: Vidfly first, then yt-dlp fallback
183
- try {
184
- const r = await tryVidfly();
185
- return res.json(r);
186
- } catch (_e) {
187
- // swallow and try fallback
188
- }
189
- try {
190
- const r = await tryYtDlp();
191
- return res.json(r);
192
- } catch (e) {
193
- return res.status(500).json({
194
- error: 'Both Vidfly.ai and yt-dlp fallback failed',
195
- details: String(e.message || e)
196
- });
197
- }
198
- } catch (e) {
199
- return res.status(500).json({ error: String(e.message || e) });
200
  }
201
  });
202
 
203
- // ========== JioSaavn routes ==========
204
  app.get('/api/result', async (req, res) => {
205
- try {
206
- const q = req.query.q || '';
207
- res.json(await searchUniversal(q));
208
- } catch (e) {
209
- res.status(500).json({ error: e.message });
210
- }
211
  });
212
  app.get('/api/song', async (req, res) => {
213
- try {
214
- res.json(await getSong(req.query.q || ''));
215
- } catch (e) {
216
- res.status(500).json({ error: e.message });
217
- }
218
  });
219
  app.get('/api/album', async (req, res) => {
220
- try {
221
- res.json(await getAlbum(req.query.q || ''));
222
- } catch (e) {
223
- res.status(500).json({ error: e.message });
224
- }
225
  });
226
  app.get('/api/playlist', async (req, res) => {
227
- try {
228
- res.json(await getPlaylist(req.query.q || ''));
229
- } catch (e) {
230
- res.status(500).json({ error: e.message });
231
- }
232
  });
233
  app.get('/api/lyrics', async (req, res) => {
234
- try {
235
- res.json(await getLyrics(req.query.q || ''));
236
- } catch (e) {
237
- res.status(500).json({ error: e.message });
238
- }
239
  });
240
 
241
- // ========== Optional YouTube Data API search (set YT_API_KEY to enable) ==========
242
- app.get('/api/ytsearch', async (req, res) => {
243
- try {
244
- const key = process.env.YT_API_KEY;
245
- if (!key) return res.status(501).json({ error: 'YouTube search unavailable: set YT_API_KEY' });
246
- const q = req.query.q || '';
247
- const resp = await axios.get('https://www.googleapis.com/youtube/v3/search', {
248
- params: { key, q, part: 'snippet', type: 'video', maxResults: 12 },
249
- timeout: 12000
250
- });
251
- const items = (resp.data.items || []).map(it => ({
252
- videoId: it.id?.videoId,
253
- title: it.snippet?.title,
254
- channelTitle: it.snippet?.channelTitle,
255
- thumb: it.snippet?.thumbnails?.medium?.url || it.snippet?.thumbnails?.default?.url
256
- })).filter(Boolean);
257
- res.json({ items });
258
- } catch (e) {
259
- res.status(500).json({ error: e.message });
260
- }
261
- });
262
-
263
- // ========== In-memory room state and helpers ==========
264
- /*
265
- room = {
266
- id, name, hostId,
267
- users: Map<socketId, { name, role: 'host'|'cohost'|'member', muted?: boolean }>,
268
- track: { url, title, meta, kind, thumb? },
269
- isPlaying, anchor, anchorAt,
270
- queue: Array<track>
271
- }
272
- */
273
  const rooms = new Map();
274
-
275
- function ensureRoom(roomId) {
276
- if (!rooms.has(roomId)) {
277
- rooms.set(roomId, {
278
- id: roomId,
279
- name: null,
280
- hostId: null,
281
- users: new Map(),
282
- track: null,
283
- isPlaying: false,
284
- anchor: 0,
285
- anchorAt: 0,
286
- queue: []
287
- });
288
  }
289
- return rooms.get(roomId);
290
  }
291
-
292
- function currentState(room) {
293
- return {
294
- track: room.track,
295
- isPlaying: room.isPlaying,
296
- anchor: room.anchor,
297
- anchorAt: room.anchorAt,
298
- queue: room.queue
299
- };
300
- }
301
-
302
- function membersPayload(room) {
303
- return [...room.users.entries()].map(([id, u]) => ({
304
- id,
305
- name: u.name || 'Guest',
306
- role: u.role || 'member',
307
- muted: !!u.muted,
308
- isHost: id === room.hostId
309
- }));
310
- }
311
-
312
- function broadcastMembers(roomId) {
313
- const room = rooms.get(roomId);
314
- if (!room) return;
315
- const members = membersPayload(room);
316
- io.to(roomId).emit('members', { members, roomName: room.name || room.id });
317
- }
318
-
319
- function requireHostOrCohost(room, socketId) {
320
- const u = room.users.get(socketId);
321
- return (socketId === room.hostId) || (u && u.role === 'cohost');
322
- }
323
-
324
- function normalizeThumb(meta) {
325
- const candidates = [
326
- meta?.thumb,
327
- meta?.image,
328
- meta?.thumbnail,
329
- meta?.song_image,
330
- meta?.album_image,
331
- meta?.image_url,
332
- meta?.images?.cover,
333
- meta?.images?.[0]
334
- ].filter(Boolean);
335
- return candidates[0] || null;
336
- }
337
-
338
- function findUserByName(room, targetNameRaw) {
339
- if (!targetNameRaw) return null;
340
- const targetName = String(targetNameRaw).replace(/^@/, '').trim().toLowerCase();
341
- let candidate = null;
342
- for (const [id, u] of room.users.entries()) {
343
- const n = (u.name || '').toLowerCase();
344
- if (n === targetName) return { id, u };
345
- if (!candidate && n.startsWith(targetName)) candidate = { id, u };
346
- }
347
- if (candidate) return candidate;
348
- for (const [id, u] of room.users.entries()) {
349
- const n = (u.name || '').toLowerCase();
350
- if (n.includes(targetName)) return { id, u };
351
- }
352
- return null;
353
- }
354
-
355
- // ========== Lobby ==========
356
- app.get('/api/rooms', (_req, res) => {
357
- const data = [...rooms.values()]
358
- .filter(r => r.users.size > 0)
359
- .map(r => ({
360
- id: r.id,
361
- name: r.name || r.id,
362
- members: r.users.size,
363
- isPlaying: r.isPlaying
364
- }));
365
- res.json({ rooms: data });
366
  });
 
367
 
368
- app.get('/api/ping', (_req, res) => res.json({ ok: true }));
369
-
370
- // ========== Static client ==========
371
  const clientDir = path.resolve(__dirname, '../client/dist');
372
  app.use(express.static(clientDir));
373
- app.get('*', (_req, res) => res.sendFile(path.join(clientDir, 'index.html')));
374
 
375
- // ========== Socket.IO realtime ==========
376
  const server = http.createServer(app);
377
- const io = new SocketIOServer(server, {
378
- cors: { origin: '*', methods: ['GET', 'POST'] }
379
- });
380
 
381
- io.on('connection', (socket) => {
382
  let joinedRoom = null;
383
-
384
- socket.on('join_room', ({ roomId, name, asHost = false, roomName = null }, ack) => {
385
  const room = ensureRoom(roomId);
386
-
387
  if (asHost || !room.hostId) room.hostId = socket.id;
388
- if (!room.name && roomName) room.name = String(roomName).trim().slice(0, 60) || null;
 
389
 
390
  const role = socket.id === room.hostId ? 'host' : 'member';
391
- const cleanName = String(name || 'Guest').slice(0, 40);
392
- room.users.set(socket.id, { name: cleanName, role });
393
 
394
  socket.join(roomId);
395
  joinedRoom = roomId;
@@ -398,86 +195,82 @@ io.on('connection', (socket) => {
398
  roomId,
399
  isHost: socket.id === room.hostId,
400
  state: currentState(room),
401
- roomName: room.name || room.id
402
  });
403
 
404
- io.to(roomId).emit('system', { text: `System: ${cleanName} has joined the chat` });
405
  broadcastMembers(roomId);
406
  });
407
 
408
  socket.on('rename', ({ roomId, newName }) => {
409
  const room = rooms.get(roomId);
410
- if (!room) return;
411
- const u = room.users.get(socket.id);
412
- if (!u) return;
413
- u.name = String(newName || 'Guest').slice(0, 40);
414
- broadcastMembers(roomId);
415
  });
416
 
417
  socket.on('chat_message', ({ roomId, name, text }) => {
418
  const room = rooms.get(roomId);
419
- if (!room) return;
420
- const u = room.users.get(socket.id);
421
  if (!u) return;
422
  if (u.muted) {
423
- socket.emit('system', { text: 'System: You are muted by the host' });
424
- return;
425
  }
426
  socket.to(roomId).emit('chat_message', { name, text, at: Date.now() });
427
  });
428
 
429
- // Requests: user asks host to play something
430
  socket.on('song_request', ({ roomId, requester, query }) => {
431
  const room = rooms.get(roomId);
432
  if (!room) return;
433
- const payload = { requester, query, at: Date.now(), requestId: `${Date.now()}_${Math.random().toString(36).slice(2)}` };
434
- for (const [id, usr] of room.users.entries()) {
435
- if (id === room.hostId || usr.role === 'cohost') io.to(id).emit('song_request', payload);
 
 
 
436
  }
437
  io.to(roomId).emit('system', { text: `System: ${requester} requested /play ${query}` });
438
  });
439
 
440
- // Host handles request
441
  socket.on('song_request_action', ({ roomId, action, track }) => {
442
  const room = rooms.get(roomId);
443
- if (!room) return;
444
- if (!requireHostOrCohost(room, socket.id)) return;
445
- if ((action === 'accept' || action === 'queue') && track) {
446
- const t = {
447
- url: track.url,
448
- title: track.title || track.url,
449
- meta: track.meta || {},
450
- kind: track.kind || 'audio',
451
- thumb: track.thumb || normalizeThumb(track.meta || {})
452
- };
453
- if (action === 'accept') {
454
- room.track = t;
455
- room.isPlaying = false;
456
- room.anchor = 0;
457
- room.anchorAt = Date.now();
458
- io.to(roomId).emit('set_track', { track: room.track });
459
- io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt });
460
- io.to(roomId).emit('system', { text: `System: Now playing ${room.track.title}` });
461
- } else {
462
- room.queue.push(t);
463
- io.to(roomId).emit('queue_update', { queue: room.queue });
464
- io.to(roomId).emit('system', { text: `System: Queued ${t.title}` });
465
- }
466
  }
467
  });
468
 
469
- // Host/co-host selects a track directly
470
  socket.on('set_track', ({ roomId, track }) => {
471
  const room = rooms.get(roomId);
472
  if (!room || !requireHostOrCohost(room, socket.id)) return;
473
- const thumb = track.thumb || normalizeThumb(track.meta || {});
474
- room.track = { ...track, thumb };
 
475
  room.isPlaying = false;
476
  room.anchor = 0;
477
  room.anchorAt = Date.now();
478
- io.to(roomId).emit('set_track', { track: room.track });
479
  io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt });
480
- io.to(roomId).emit('system', { text: `System: Selected ${room.track.title || room.track.url}` });
481
  });
482
 
483
  socket.on('play', ({ roomId }) => {
@@ -491,7 +284,7 @@ io.on('connection', (socket) => {
491
  socket.on('pause', ({ roomId }) => {
492
  const room = rooms.get(roomId);
493
  if (!room || !requireHostOrCohost(room, socket.id)) return;
494
- const elapsed = Math.max(0, Date.now() - room.anchorAt) / 1000;
495
  if (room.isPlaying) room.anchor += elapsed;
496
  room.isPlaying = false;
497
  room.anchorAt = Date.now();
@@ -511,75 +304,64 @@ io.on('connection', (socket) => {
511
  if (!room || !requireHostOrCohost(room, socket.id)) return;
512
  const next = room.queue.shift();
513
  if (next) {
514
- const thumb = next.thumb || normalizeThumb(next.meta || {});
515
- room.track = { ...next, thumb };
516
  room.isPlaying = false;
517
  room.anchor = 0;
518
  room.anchorAt = Date.now();
519
- io.to(roomId).emit('set_track', { track: room.track });
520
  io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt });
521
  io.to(roomId).emit('queue_update', { queue: room.queue });
522
- io.to(roomId).emit('system', { text: `System: Now playing ${room.track.title}` });
523
  } else {
524
  io.to(roomId).emit('system', { text: 'System: Queue ended' });
525
  }
526
  });
527
 
528
- // Admin commands: kick, promote, mute
529
  socket.on('admin_command', ({ roomId, cmd, targetName }) => {
530
  const room = rooms.get(roomId);
531
- if (!room) return;
532
- const actor = room.users.get(socket.id);
533
  if (!actor || !(socket.id === room.hostId || actor.role === 'cohost')) return;
534
-
535
  const found = findUserByName(room, targetName);
536
  if (!found) {
537
- io.to(socket.id).emit('system', { text: `System: User @${targetName} not found` });
538
- return;
539
  }
540
-
541
  if (cmd === 'kick') {
542
- const { id } = found;
543
- io.to(id).emit('system', { text: 'System: You were kicked by the host' });
544
  io.sockets.sockets.get(id)?.leave(roomId);
545
  room.users.delete(id);
546
- io.to(roomId).emit('system', { text: `System: ${found.u.name} was kicked` });
547
  broadcastMembers(roomId);
548
  } else if (cmd === 'promote') {
549
- found.u.role = 'cohost';
550
- io.to(roomId).emit('system', { text: `System: ${found.u.name} was promoted to co-host` });
551
  broadcastMembers(roomId);
552
  } else if (cmd === 'mute') {
553
- found.u.muted = true;
554
- io.to(roomId).emit('system', { text: `System: ${found.u.name} was muted` });
555
  broadcastMembers(roomId);
556
  }
557
  });
558
 
559
  socket.on('sync_request', ({ roomId }, ack) => {
560
  const room = rooms.get(roomId);
561
- if (!room) return;
562
- ack?.(currentState(room));
563
  });
564
 
565
  socket.on('disconnect', () => {
566
  if (!joinedRoom) return;
567
  const room = rooms.get(joinedRoom);
568
  if (!room) return;
569
- const leftUser = room.users.get(socket.id);
570
  room.users.delete(socket.id);
571
  if (socket.id === room.hostId) {
572
- const nextHost = [...room.users.keys()][0] || null;
573
- room.hostId = nextHost;
574
- if (nextHost) {
575
- const u = room.users.get(nextHost);
576
- if (u) u.role = 'host';
577
- }
578
- io.to(joinedRoom).emit('host_changed', { hostId: room.hostId });
579
  }
580
- if (leftUser) {
581
- const nm = leftUser.name || 'User';
582
- io.to(joinedRoom).emit('system', { text: `System: ${nm} has left the chat` });
583
  }
584
  if (room.users.size === 0) rooms.delete(joinedRoom);
585
  else broadcastMembers(joinedRoom);
 
 
 
 
 
1
  import express from 'express';
2
  import http from 'http';
3
  import cors from 'cors';
 
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
  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
  });
223
 
 
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 }) => {
 
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();
 
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
  }
318
  });
319
 
 
320
  socket.on('admin_command', ({ roomId, cmd, targetName }) => {
321
  const room = rooms.get(roomId);
322
+ const actor = room?.users.get(socket.id);
 
323
  if (!actor || !(socket.id === room.hostId || actor.role === 'cohost')) return;
 
324
  const found = findUserByName(room, targetName);
325
  if (!found) {
326
+ return io.to(socket.id).emit('system', { text: `System: @${targetName} not found` });
 
327
  }
328
+ const { id, u } = found;
329
  if (cmd === 'kick') {
330
+ io.to(id).emit('system', { text: 'System: You were kicked' });
 
331
  io.sockets.sockets.get(id)?.leave(roomId);
332
  room.users.delete(id);
333
+ io.to(roomId).emit('system', { text: `System: ${u.name} was kicked` });
334
  broadcastMembers(roomId);
335
  } else if (cmd === 'promote') {
336
+ u.role = 'cohost';
337
+ io.to(roomId).emit('system', { text: `System: ${u.name} promoted to co-host` });
338
  broadcastMembers(roomId);
339
  } else if (cmd === 'mute') {
340
+ u.muted = true;
341
+ io.to(roomId).emit('system', { text: `System: ${u.name} was muted` });
342
  broadcastMembers(roomId);
343
  }
344
  });
345
 
346
  socket.on('sync_request', ({ roomId }, ack) => {
347
  const room = rooms.get(roomId);
348
+ if (room) ack(currentState(room));
 
349
  });
350
 
351
  socket.on('disconnect', () => {
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);