Update server/server.js

#2
by akborana4 - opened
Files changed (1) hide show
  1. server/server.js +119 -109
server/server.js CHANGED
@@ -1,19 +1,33 @@
1
- // server.js
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import express from 'express';
3
  import http from 'http';
4
  import cors from 'cors';
5
- import { Server as SocketIOServer } from 'socket.io';
6
  import path from 'path';
7
  import { fileURLToPath } from 'url';
8
  import axios from 'axios';
9
  import { PassThrough } from 'stream';
 
 
10
  import {
11
  searchUniversal,
12
  getSong,
13
  getAlbum,
14
  getPlaylist,
15
  getLyrics
16
- } from './jiosaavn.js';
17
 
18
  const __filename = fileURLToPath(import.meta.url);
19
  const __dirname = path.dirname(__filename);
@@ -22,19 +36,24 @@ const app = express();
22
  app.use(cors());
23
  app.use(express.json());
24
 
 
25
  function short(v, n = 400) {
26
  try {
27
  const s = typeof v === 'string' ? v : JSON.stringify(v);
28
  return s.length > n ? s.slice(0, n) + '…' : s;
29
- } catch {
30
- return String(v).slice(0, n) + '…';
31
- }
32
- }
33
- function log(...args) {
34
- console.log(new Date().toISOString(), ...args);
35
  }
36
 
37
- // ---------- Room state (same as before) ----------
 
 
 
 
 
 
 
 
 
38
  const rooms = new Map();
39
  function ensureRoom(roomId) {
40
  if (!rooms.has(roomId)) {
@@ -70,8 +89,6 @@ function membersPayload(room) {
70
  isHost: id === room.hostId
71
  }));
72
  }
73
-
74
- // helpers used by socket handlers later
75
  function broadcastMembers(roomId) {
76
  const room = rooms.get(roomId);
77
  if (!room) return;
@@ -155,7 +172,7 @@ app.get('/api/lyrics', async (req, res) => {
155
  } catch (e) { log('/api/lyrics error', e?.message || short(e?.response?.data)); res.status(500).json({ error: e.message }); }
156
  });
157
 
158
- // ---------- YouTube search ----------
159
  app.get('/api/ytsearch', async (req, res) => {
160
  try {
161
  const key = process.env.YT_API_KEY;
@@ -178,37 +195,63 @@ app.get('/api/ytsearch', async (req, res) => {
178
  }
179
  });
180
 
181
- // ---------- YouTube resolve via VidFly API ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  app.get('/api/yt/source', async (req, res) => {
 
183
  try {
184
  let raw = (req.query.url || '').trim();
185
  if (!raw) return res.status(400).json({ error: 'Missing url' });
186
  if (/^[a-zA-Z0-9_-]{11}$/.test(raw)) raw = `https://www.youtube.com/watch?v=${raw}`;
187
- const vidflyUrl = `https://api.vidfly.ai/api/media/youtube/download?url=${encodeURIComponent(raw)}`;
188
- log('Calling VidFly API:', vidflyUrl);
189
- const resp = await axios.get(vidflyUrl, { timeout: 20000, validateStatus: () => true });
 
190
  const data = resp.data;
 
191
  if (!data || typeof data !== 'object') {
192
  log('/api/yt/source vidfly non-object response:', short(data, 1000));
193
  return res.status(502).json({ error: 'Invalid VidFly response', snippet: short(data, 1000) });
194
  }
195
  if (Number(data.code) !== 0 || !data.data || !Array.isArray(data.data.items)) {
 
196
  log('/api/yt/source vidfly returned error shape:', short(data, 1000));
197
  return res.status(502).json({ error: 'Invalid VidFly API response', detail: data });
198
  }
 
199
  const items = data.data.items;
200
- const progressive = items
201
- .filter(f => f.type === 'video_with_audio')
202
  .sort((a, b) => (Number(b.height || 0) - Number(a.height || 0)))[0];
203
- const videoOnly = items
204
- .filter(f => f.type === 'video')
205
  .sort((a, b) => (Number(b.height || 0) - Number(a.height || 0)))[0];
206
  const anyItem = items.find(f => f.url);
207
  const chosen = progressive || videoOnly || anyItem;
 
208
  if (!chosen || !chosen.url) {
209
  log('/api/yt/source no playable item found:', short(items, 1200));
210
- return res.status(502).json({ error: 'No playable format found in VidFly API response' });
211
  }
 
212
  res.json({
213
  url: chosen.url,
214
  title: data.data.title || raw,
@@ -229,18 +272,16 @@ app.get('/api/yt/source', async (req, res) => {
229
  });
230
 
231
  /*
232
- Robust /api/yt/proxy implementation
233
- - Proxies only allowed hosts (default: googlevideo.com)
234
- - Forwards Range header
235
- - Sets a user-agent and referer to reduce upstream blocking
236
- - Peeks first chunk and rejects HTML/text responses (common when link expired)
237
- - Forwards content-type, content-length, content-range, accept-ranges, cache-control, etag, last-modified
238
  */
239
  function isAllowedProxyHost(urlStr) {
240
  try {
241
  const u = new URL(urlStr);
242
  const host = u.hostname.toLowerCase();
243
- // allow googlevideo redirector hosts (expand as needed)
244
  return host.endsWith('googlevideo.com') || host.endsWith('redirector.googlevideo.com');
245
  } catch {
246
  return false;
@@ -256,18 +297,15 @@ app.get('/api/yt/proxy', async (req, res) => {
256
  return res.status(400).json({ error: 'Proxy to this host is not allowed by server policy' });
257
  }
258
 
259
- // Forward client's Range if present
260
  const rangeHeader = req.headers.range || null;
261
  const requestHeaders = {
262
- // make upstream think this is a real browser request
263
  'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
264
- 'Accept': 'video/*,*/*;q=0.9',
265
  'Referer': 'https://www.youtube.com/',
266
- // pass Range if client requested it (seek)
267
  ...(rangeHeader ? { Range: rangeHeader } : {})
268
  };
269
 
270
- log('/api/yt/proxy fetching remote', short(raw, 200), 'Range:', rangeHeader ? rangeHeader.slice(0,120) : 'none');
271
 
272
  const resp = await axios.get(raw, {
273
  responseType: 'stream',
@@ -277,119 +315,78 @@ app.get('/api/yt/proxy', async (req, res) => {
277
  validateStatus: () => true
278
  });
279
 
280
- // If remote responded with an error status, try to capture a small snippet and return 502
281
  if (resp.status >= 400) {
 
282
  let snippet = '';
283
  try {
284
- // try to read a short snippet from the stream (if any)
285
- const streamReader = resp.data;
286
  const chunk = await new Promise((resolve) => {
287
  let done = false;
288
  const onData = (c) => { if (!done) { done = true; cleanup(); resolve(c); } };
289
  const onEnd = () => { if (!done) { done = true; cleanup(); resolve(null); } };
290
  const onErr = () => { if (!done) { done = true; cleanup(); resolve(null); } };
291
- const cleanup = () => { streamReader.removeListener('data', onData); streamReader.removeListener('end', onEnd); streamReader.removeListener('error', onErr); };
292
- streamReader.once('data', onData);
293
- streamReader.once('end', onEnd);
294
- streamReader.once('error', onErr);
295
  });
296
  if (chunk) snippet = chunk.toString('utf8', 0, 800);
297
  } catch (xx) { /* ignore */ }
 
298
  log('/api/yt/proxy remote returned error status', resp.status, 'snippet:', short(snippet, 400));
299
  return res.status(502).json({ error: 'Remote returned error', status: resp.status, snippet: short(snippet, 800) });
300
  }
301
 
302
- // If content-type looks like html/text/json, the signed URL probably expired or upstream blocked — reject.
303
  const ct = String(resp.headers['content-type'] || '').toLowerCase();
304
  if (ct.includes('text/html') || ct.includes('application/json') || ct.includes('text/plain')) {
305
- // read a small snippet from stream to give debug info then abort
306
  let snippet = '';
307
  try {
308
- const streamReader = resp.data;
309
  const chunk = await new Promise((resolve) => {
310
  let done = false;
311
  const onData = (c) => { if (!done) { done = true; cleanup(); resolve(c); } };
312
  const onEnd = () => { if (!done) { done = true; cleanup(); resolve(null); } };
313
  const onErr = () => { if (!done) { done = true; cleanup(); resolve(null); } };
314
- const cleanup = () => { streamReader.removeListener('data', onData); streamReader.removeListener('end', onEnd); streamReader.removeListener('error', onErr); };
315
- streamReader.once('data', onData);
316
- streamReader.once('end', onEnd);
317
- streamReader.once('error', onErr);
318
  });
319
  if (chunk) snippet = chunk.toString('utf8', 0, 1600);
320
  } catch (xx) {}
321
- log('/api/yt/proxy remote returned text/html or json; rejecting. ct=', ct, 'snippet=', short(snippet, 800));
322
- return res.status(502).json({ error: 'Remote returned unexpected content-type', contentType: ct, snippet: short(snippet, 800) });
323
- }
324
 
325
- // Now we will peek first data chunk to ensure we didn't get HTML disguised as video,
326
- // then pass the full stream to client via PassThrough.
327
- const remoteStream = resp.data; // Readable
328
- const pass = new PassThrough();
329
-
330
- let firstChunkChecked = false;
331
- let firstChunkBuffer = null;
332
- const onRemoteData = (chunk) => {
333
- if (!firstChunkChecked) {
334
- firstChunkChecked = true;
335
- firstChunkBuffer = chunk;
336
- const s = chunk.toString('utf8', 0, Math.min(chunk.length, 1024)).trim();
337
- // if snippet looks like HTML or JSON, abort (remote probably returned an error page)
338
- if (s.startsWith('<!doctype') || s.startsWith('<html') || s.startsWith('{') || s.includes('<html') || s.includes('Sign in to')) {
339
- // drain remote and return 502
340
- try { remoteStream.pause(); } catch {}
341
- const snippet = s.slice(0, 1600);
342
- log('/api/yt/proxy detected HTML/JSON in first chunk; aborting. snippet:', short(snippet, 800));
343
- // cleanup listeners
344
- remoteStream.removeListener('data', onRemoteData);
345
- remoteStream.removeAllListeners();
346
- pass.end(); // end passthrough
347
- // respond with 502 and snippet
348
- return res.status(502).json({ error: 'Remote returned non-media content (HTML/JSON)', snippet: short(snippet, 1000) });
349
- } else {
350
- // write the first chunk into pass and then pipe remainder
351
- pass.write(chunk);
352
- // allow rest of remote stream to be piped into pass
353
- remoteStream.pipe(pass, { end: true });
354
- // remove this listener (since we piped)
355
- remoteStream.removeListener('data', onRemoteData);
356
- }
357
- }
358
- };
359
-
360
- // attach the onRemoteData listener
361
- remoteStream.on('data', onRemoteData);
362
 
363
- // Forward key headers from remote to client
364
  const forwardable = ['content-type','content-length','content-range','accept-ranges','cache-control','last-modified','etag'];
365
  forwardable.forEach(h => {
366
  const v = resp.headers[h];
367
  if (v !== undefined) res.setHeader(h, v);
368
  });
369
 
370
- // Set explicit CORS
371
  res.setHeader('Access-Control-Allow-Origin', '*');
372
  res.setHeader('Access-Control-Allow-Headers', 'Range,Accept,Content-Type');
373
- // set status (206 if partial content)
374
- res.status(resp.status);
375
 
376
- // If remote hasn't emitted any data yet (rare), pipe anyway (the onRemoteData handles first chunk)
377
- // finally pipe the pass-through to the client response
378
- pass.pipe(res);
379
 
380
- // handle remote stream errors
381
- remoteStream.on('error', (err) => {
382
- log('/api/yt/proxy remote stream error:', err?.message || String(err));
 
383
  try {
384
- if (!res.headersSent) res.status(502).json({ error: 'Remote stream error', detail: err?.message || String(err) });
385
  else res.destroy(err);
386
  } catch {}
387
  });
388
-
389
- // handle pass errors
390
- pass.on('error', (err) => {
391
- log('/api/yt/proxy pass error:', err?.message || String(err));
392
- try { if (!res.headersSent) res.status(502).json({ error: 'PassThrough error', detail: err?.message || String(err) }); } catch {}
393
  });
394
 
395
  } catch (e) {
@@ -399,10 +396,7 @@ app.get('/api/yt/proxy', async (req, res) => {
399
  }
400
  });
401
 
402
- // ---------- Lobby / static / Socket.IO ----------
403
- // (The rest of your server remains the same — socket handlers, static client, etc.)
404
- // For completeness, include the socket setup and handlers (copied from your previous file).
405
-
406
  app.get('/api/rooms', (_req, res) => {
407
  const data = [...rooms.values()]
408
  .filter(r => r.users.size > 0)
@@ -416,34 +410,41 @@ app.get('/api/rooms', (_req, res) => {
416
  });
417
  app.get('/api/ping', (_req, res) => res.json({ ok: true }));
418
 
 
419
  const clientDir = path.resolve(__dirname, '../client/dist');
420
  app.use(express.static(clientDir));
421
  app.get('*', (_req, res) => res.sendFile(path.join(clientDir, 'index.html')));
422
 
 
423
  const server = http.createServer(app);
424
  const io = new SocketIOServer(server, {
425
  cors: { origin: '*', methods: ['GET', 'POST'] }
426
  });
427
 
428
- // Socket handlers (unchanged logic) — simplified copy from your previous code:
429
  io.on('connection', (socket) => {
430
  let joinedRoom = null;
431
 
432
  socket.on('join_room', ({ roomId, name, asHost = false, roomName = null }, ack) => {
433
  const room = ensureRoom(roomId);
 
434
  if (asHost || !room.hostId) room.hostId = socket.id;
435
  if (!room.name && roomName) room.name = String(roomName).trim().slice(0, 60) || null;
 
436
  const role = socket.id === room.hostId ? 'host' : 'member';
437
  const cleanName = String(name || 'Guest').slice(0, 40);
438
  room.users.set(socket.id, { name: cleanName, role });
 
439
  socket.join(roomId);
440
  joinedRoom = roomId;
 
441
  ack?.({
442
  roomId,
443
  isHost: socket.id === room.hostId,
444
  state: currentState(room),
445
  roomName: room.name || room.id
446
  });
 
 
447
  io.to(roomId).emit('system', { text: `System: ${cleanName} has joined the chat` });
448
  broadcastMembers(roomId);
449
  });
@@ -469,6 +470,7 @@ io.on('connection', (socket) => {
469
  socket.to(roomId).emit('chat_message', { name, text, at: Date.now() });
470
  });
471
 
 
472
  socket.on('song_request', ({ roomId, requester, query }) => {
473
  const room = rooms.get(roomId);
474
  if (!room) return;
@@ -479,6 +481,7 @@ io.on('connection', (socket) => {
479
  io.to(roomId).emit('system', { text: `System: ${requester} requested /play ${query}` });
480
  });
481
 
 
482
  socket.on('song_request_action', ({ roomId, action, track }) => {
483
  const room = rooms.get(roomId);
484
  if (!room) return;
@@ -507,6 +510,7 @@ io.on('connection', (socket) => {
507
  }
508
  });
509
 
 
510
  socket.on('set_track', ({ roomId, track }) => {
511
  const room = rooms.get(roomId);
512
  if (!room || !requireHostOrCohost(room, socket.id)) return;
@@ -546,6 +550,7 @@ io.on('connection', (socket) => {
546
  io.to(roomId).emit('seek', { anchor: room.anchor, anchorAt: room.anchorAt, isPlaying: room.isPlaying });
547
  });
548
 
 
549
  socket.on('ended', ({ roomId }) => {
550
  const room = rooms.get(roomId);
551
  if (!room || !requireHostOrCohost(room, socket.id)) return;
@@ -565,16 +570,19 @@ io.on('connection', (socket) => {
565
  }
566
  });
567
 
 
568
  socket.on('admin_command', ({ roomId, cmd, targetName }) => {
569
  const room = rooms.get(roomId);
570
  if (!room) return;
571
  const actor = room.users.get(socket.id);
572
  if (!actor || !(socket.id === room.hostId || actor.role === 'cohost')) return;
 
573
  const found = findUserByName(room, targetName);
574
  if (!found) {
575
  io.to(socket.id).emit('system', { text: `System: User @${targetName} not found` });
576
  return;
577
  }
 
578
  if (cmd === 'kick') {
579
  const { id } = found;
580
  io.to(id).emit('system', { text: 'System: You were kicked by the host' });
@@ -593,6 +601,7 @@ io.on('connection', (socket) => {
593
  }
594
  });
595
 
 
596
  socket.on('sync_request', ({ roomId }, ack) => {
597
  const room = rooms.get(roomId);
598
  if (!room) return;
@@ -623,6 +632,7 @@ io.on('connection', (socket) => {
623
  });
624
  });
625
 
 
626
  const PORT = process.env.PORT || 3000;
627
  server.listen(PORT, () => {
628
  log(`Server running on port ${PORT}`);
 
1
+ /**
2
+ * Full server.js
3
+ *
4
+ * - Robust /api/yt/source -> resolves YouTube via VidFly (configurable via VIDFLY_API env)
5
+ * - Debug endpoint /api/vidfly/debug -> calls VidFly and returns the raw JSON for debugging
6
+ * - Robust /api/yt/proxy -> proxies googlevideo redirector URLs (for browser CORS + Range support)
7
+ * - Full Socket.IO room handlers (join, set_track, play/pause/seek/ended, requests, admin commands)
8
+ *
9
+ * Notes:
10
+ * - Set VIDFLY_API to override the default VidFly endpoint (default: https://api.vidfly.ai/api/media/youtube/download)
11
+ * - This file is self-contained; keep your existing jiosaavn.js module alongside (imported)
12
+ * - Be mindful of bandwidth when proxying large files through this server.
13
+ */
14
+
15
  import express from 'express';
16
  import http from 'http';
17
  import cors from 'cors';
 
18
  import path from 'path';
19
  import { fileURLToPath } from 'url';
20
  import axios from 'axios';
21
  import { PassThrough } from 'stream';
22
+ import { Server as SocketIOServer } from 'socket.io';
23
+
24
  import {
25
  searchUniversal,
26
  getSong,
27
  getAlbum,
28
  getPlaylist,
29
  getLyrics
30
+ } from './jiosaavn.js'; // keep your module
31
 
32
  const __filename = fileURLToPath(import.meta.url);
33
  const __dirname = path.dirname(__filename);
 
36
  app.use(cors());
37
  app.use(express.json());
38
 
39
+ function log(...args) { console.log(new Date().toISOString(), ...args); }
40
  function short(v, n = 400) {
41
  try {
42
  const s = typeof v === 'string' ? v : JSON.stringify(v);
43
  return s.length > n ? s.slice(0, n) + '…' : s;
44
+ } catch { return String(v).slice(0, n) + '…'; }
 
 
 
 
 
45
  }
46
 
47
+ // ---------- Room state ----------
48
+ /*
49
+ room = {
50
+ id, name, hostId,
51
+ users: Map<socketId, { name, role: 'host'|'cohost'|'member', muted?: boolean }>,
52
+ track: { url, title, meta, kind, thumb? },
53
+ isPlaying, anchor, anchorAt,
54
+ queue: Array<track>
55
+ }
56
+ */
57
  const rooms = new Map();
58
  function ensureRoom(roomId) {
59
  if (!rooms.has(roomId)) {
 
89
  isHost: id === room.hostId
90
  }));
91
  }
 
 
92
  function broadcastMembers(roomId) {
93
  const room = rooms.get(roomId);
94
  if (!room) return;
 
172
  } catch (e) { log('/api/lyrics error', e?.message || short(e?.response?.data)); res.status(500).json({ error: e.message }); }
173
  });
174
 
175
+ // ---------- YouTube search (optional) ----------
176
  app.get('/api/ytsearch', async (req, res) => {
177
  try {
178
  const key = process.env.YT_API_KEY;
 
195
  }
196
  });
197
 
198
+ // ---------- YouTube resolve via VidFly ----------
199
+ const VIDFLY_API = process.env.VIDFLY_API || 'https://api.vidfly.ai/api/media/youtube/download';
200
+
201
+ app.get('/api/vidfly/debug', async (req, res) => {
202
+ // Debug endpoint: calls VidFly and returns raw response (JSON) for troubleshooting
203
+ try {
204
+ let raw = (req.query.url || '').trim();
205
+ if (!raw) return res.status(400).json({ error: 'Missing url' });
206
+ if (/^[a-zA-Z0-9_-]{11}$/.test(raw)) raw = `https://www.youtube.com/watch?v=${raw}`;
207
+
208
+ const apiUrl = `${VIDFLY_API}?url=${encodeURIComponent(raw)}`;
209
+ log('VIDFLY debug call ->', apiUrl);
210
+ const resp = await axios.get(apiUrl, { timeout: 20000, validateStatus: () => true });
211
+ // Return status and body so caller can inspect
212
+ res.status(200).json({ status: resp.status, headers: resp.headers, data: resp.data });
213
+ } catch (e) {
214
+ log('/api/vidfly/debug error', e?.message ?? e);
215
+ res.status(500).json({ error: e?.message ?? String(e) });
216
+ }
217
+ });
218
+
219
  app.get('/api/yt/source', async (req, res) => {
220
+ // Primary resolver: call VidFly and return chosen playable url + metadata
221
  try {
222
  let raw = (req.query.url || '').trim();
223
  if (!raw) return res.status(400).json({ error: 'Missing url' });
224
  if (/^[a-zA-Z0-9_-]{11}$/.test(raw)) raw = `https://www.youtube.com/watch?v=${raw}`;
225
+
226
+ const apiUrl = `${VIDFLY_API}?url=${encodeURIComponent(raw)}`;
227
+ log('Calling VidFly API:', apiUrl);
228
+ const resp = await axios.get(apiUrl, { timeout: 20000, validateStatus: () => true });
229
  const data = resp.data;
230
+
231
  if (!data || typeof data !== 'object') {
232
  log('/api/yt/source vidfly non-object response:', short(data, 1000));
233
  return res.status(502).json({ error: 'Invalid VidFly response', snippet: short(data, 1000) });
234
  }
235
  if (Number(data.code) !== 0 || !data.data || !Array.isArray(data.data.items)) {
236
+ // include vidfly response to aid debugging
237
  log('/api/yt/source vidfly returned error shape:', short(data, 1000));
238
  return res.status(502).json({ error: 'Invalid VidFly API response', detail: data });
239
  }
240
+
241
  const items = data.data.items;
242
+ // prefer progressive (video with audio), otherwise highest-res video-only, otherwise any
243
+ const progressive = items.filter(f => f.type === 'video_with_audio')
244
  .sort((a, b) => (Number(b.height || 0) - Number(a.height || 0)))[0];
245
+ const videoOnly = items.filter(f => f.type === 'video')
 
246
  .sort((a, b) => (Number(b.height || 0) - Number(a.height || 0)))[0];
247
  const anyItem = items.find(f => f.url);
248
  const chosen = progressive || videoOnly || anyItem;
249
+
250
  if (!chosen || !chosen.url) {
251
  log('/api/yt/source no playable item found:', short(items, 1200));
252
+ return res.status(502).json({ error: 'No playable format found in VidFly API response', detail: data });
253
  }
254
+
255
  res.json({
256
  url: chosen.url,
257
  title: data.data.title || raw,
 
272
  });
273
 
274
  /*
275
+ /api/yt/proxy
276
+ - Proxies direct signed googlevideo URLs (redirector.googlevideo.com / googlevideo.com).
277
+ - Forwards Range header to support seeking.
278
+ - Forwards upstream headers (content-type, content-length, content-range, accept-ranges).
279
+ - Responds with CORS header.
 
280
  */
281
  function isAllowedProxyHost(urlStr) {
282
  try {
283
  const u = new URL(urlStr);
284
  const host = u.hostname.toLowerCase();
 
285
  return host.endsWith('googlevideo.com') || host.endsWith('redirector.googlevideo.com');
286
  } catch {
287
  return false;
 
297
  return res.status(400).json({ error: 'Proxy to this host is not allowed by server policy' });
298
  }
299
 
 
300
  const rangeHeader = req.headers.range || null;
301
  const requestHeaders = {
 
302
  'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
303
+ 'Accept': '*/*',
304
  'Referer': 'https://www.youtube.com/',
 
305
  ...(rangeHeader ? { Range: rangeHeader } : {})
306
  };
307
 
308
+ log('/api/yt/proxy fetching', short(raw, 200), 'Range:', rangeHeader ? rangeHeader.slice(0,120) : 'none');
309
 
310
  const resp = await axios.get(raw, {
311
  responseType: 'stream',
 
315
  validateStatus: () => true
316
  });
317
 
 
318
  if (resp.status >= 400) {
319
+ // try to capture a short snippet for debugging
320
  let snippet = '';
321
  try {
322
+ const reader = resp.data;
 
323
  const chunk = await new Promise((resolve) => {
324
  let done = false;
325
  const onData = (c) => { if (!done) { done = true; cleanup(); resolve(c); } };
326
  const onEnd = () => { if (!done) { done = true; cleanup(); resolve(null); } };
327
  const onErr = () => { if (!done) { done = true; cleanup(); resolve(null); } };
328
+ const cleanup = () => { reader.removeListener('data', onData); reader.removeListener('end', onEnd); reader.removeListener('error', onErr); };
329
+ reader.once('data', onData);
330
+ reader.once('end', onEnd);
331
+ reader.once('error', onErr);
332
  });
333
  if (chunk) snippet = chunk.toString('utf8', 0, 800);
334
  } catch (xx) { /* ignore */ }
335
+
336
  log('/api/yt/proxy remote returned error status', resp.status, 'snippet:', short(snippet, 400));
337
  return res.status(502).json({ error: 'Remote returned error', status: resp.status, snippet: short(snippet, 800) });
338
  }
339
 
340
+ // check content-type header for obvious non-media responses
341
  const ct = String(resp.headers['content-type'] || '').toLowerCase();
342
  if (ct.includes('text/html') || ct.includes('application/json') || ct.includes('text/plain')) {
343
+ // read a small snippet then return 502
344
  let snippet = '';
345
  try {
346
+ const reader = resp.data;
347
  const chunk = await new Promise((resolve) => {
348
  let done = false;
349
  const onData = (c) => { if (!done) { done = true; cleanup(); resolve(c); } };
350
  const onEnd = () => { if (!done) { done = true; cleanup(); resolve(null); } };
351
  const onErr = () => { if (!done) { done = true; cleanup(); resolve(null); } };
352
+ const cleanup = () => { reader.removeListener('data', onData); reader.removeListener('end', onEnd); reader.removeListener('error', onErr); };
353
+ reader.once('data', onData);
354
+ reader.once('end', onEnd);
355
+ reader.once('error', onErr);
356
  });
357
  if (chunk) snippet = chunk.toString('utf8', 0, 1600);
358
  } catch (xx) {}
 
 
 
359
 
360
+ log('/api/yt/proxy remote returned non-media content-type', ct, short(snippet, 400));
361
+ return res.status(502).json({ error: 'Remote returned non-media content-type', contentType: ct, snippet: short(snippet, 800) });
362
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
 
364
+ // forward useful headers
365
  const forwardable = ['content-type','content-length','content-range','accept-ranges','cache-control','last-modified','etag'];
366
  forwardable.forEach(h => {
367
  const v = resp.headers[h];
368
  if (v !== undefined) res.setHeader(h, v);
369
  });
370
 
371
+ // CORS for browsers (already app.use(cors()) but explicitly set for stream)
372
  res.setHeader('Access-Control-Allow-Origin', '*');
373
  res.setHeader('Access-Control-Allow-Headers', 'Range,Accept,Content-Type');
 
 
374
 
375
+ // status (206 or 200)
376
+ res.status(resp.status);
 
377
 
378
+ // pipe upstream stream directly to client (no peeking)
379
+ const upstream = resp.data;
380
+ upstream.on('error', (err) => {
381
+ log('/api/yt/proxy upstream stream error:', err?.message || String(err));
382
  try {
383
+ if (!res.headersSent) res.status(502).json({ error: 'Upstream stream error', detail: err?.message || String(err) });
384
  else res.destroy(err);
385
  } catch {}
386
  });
387
+ upstream.pipe(res).on('error', (err) => {
388
+ log('/api/yt/proxy pipe error:', err?.message || String(err));
389
+ try { if (!res.headersSent) res.status(502).json({ error: 'Pipe error', detail: err?.message || String(err) }); } catch {}
 
 
390
  });
391
 
392
  } catch (e) {
 
396
  }
397
  });
398
 
399
+ // ---------- Lobby ----------
 
 
 
400
  app.get('/api/rooms', (_req, res) => {
401
  const data = [...rooms.values()]
402
  .filter(r => r.users.size > 0)
 
410
  });
411
  app.get('/api/ping', (_req, res) => res.json({ ok: true }));
412
 
413
+ // ---------- Static client ----------
414
  const clientDir = path.resolve(__dirname, '../client/dist');
415
  app.use(express.static(clientDir));
416
  app.get('*', (_req, res) => res.sendFile(path.join(clientDir, 'index.html')));
417
 
418
+ // ---------- Socket.IO ----------
419
  const server = http.createServer(app);
420
  const io = new SocketIOServer(server, {
421
  cors: { origin: '*', methods: ['GET', 'POST'] }
422
  });
423
 
 
424
  io.on('connection', (socket) => {
425
  let joinedRoom = null;
426
 
427
  socket.on('join_room', ({ roomId, name, asHost = false, roomName = null }, ack) => {
428
  const room = ensureRoom(roomId);
429
+
430
  if (asHost || !room.hostId) room.hostId = socket.id;
431
  if (!room.name && roomName) room.name = String(roomName).trim().slice(0, 60) || null;
432
+
433
  const role = socket.id === room.hostId ? 'host' : 'member';
434
  const cleanName = String(name || 'Guest').slice(0, 40);
435
  room.users.set(socket.id, { name: cleanName, role });
436
+
437
  socket.join(roomId);
438
  joinedRoom = roomId;
439
+
440
  ack?.({
441
  roomId,
442
  isHost: socket.id === room.hostId,
443
  state: currentState(room),
444
  roomName: room.name || room.id
445
  });
446
+
447
+ // Single clean system message to everyone
448
  io.to(roomId).emit('system', { text: `System: ${cleanName} has joined the chat` });
449
  broadcastMembers(roomId);
450
  });
 
470
  socket.to(roomId).emit('chat_message', { name, text, at: Date.now() });
471
  });
472
 
473
+ // /play request (user requests a song; host/cohost get notified)
474
  socket.on('song_request', ({ roomId, requester, query }) => {
475
  const room = rooms.get(roomId);
476
  if (!room) return;
 
481
  io.to(roomId).emit('system', { text: `System: ${requester} requested /play ${query}` });
482
  });
483
 
484
+ // Host handles request action
485
  socket.on('song_request_action', ({ roomId, action, track }) => {
486
  const room = rooms.get(roomId);
487
  if (!room) return;
 
510
  }
511
  });
512
 
513
+ // Host/cohost controls
514
  socket.on('set_track', ({ roomId, track }) => {
515
  const room = rooms.get(roomId);
516
  if (!room || !requireHostOrCohost(room, socket.id)) return;
 
550
  io.to(roomId).emit('seek', { anchor: room.anchor, anchorAt: room.anchorAt, isPlaying: room.isPlaying });
551
  });
552
 
553
+ // Ended -> auto next
554
  socket.on('ended', ({ roomId }) => {
555
  const room = rooms.get(roomId);
556
  if (!room || !requireHostOrCohost(room, socket.id)) return;
 
570
  }
571
  });
572
 
573
+ // Admin commands: kick/promote/mute
574
  socket.on('admin_command', ({ roomId, cmd, targetName }) => {
575
  const room = rooms.get(roomId);
576
  if (!room) return;
577
  const actor = room.users.get(socket.id);
578
  if (!actor || !(socket.id === room.hostId || actor.role === 'cohost')) return;
579
+
580
  const found = findUserByName(room, targetName);
581
  if (!found) {
582
  io.to(socket.id).emit('system', { text: `System: User @${targetName} not found` });
583
  return;
584
  }
585
+
586
  if (cmd === 'kick') {
587
  const { id } = found;
588
  io.to(id).emit('system', { text: 'System: You were kicked by the host' });
 
601
  }
602
  });
603
 
604
+ // Sync request: client asks for current room state
605
  socket.on('sync_request', ({ roomId }, ack) => {
606
  const room = rooms.get(roomId);
607
  if (!room) return;
 
632
  });
633
  });
634
 
635
+ // start server
636
  const PORT = process.env.PORT || 3000;
637
  server.listen(PORT, () => {
638
  log(`Server running on port ${PORT}`);