File size: 17,590 Bytes
4e4abd3
 
 
64331b8
4e4abd3
 
df2fb16
5e48a0e
a73ed1d
 
 
 
 
 
47281e6
4e4abd3
 
 
912c620
e83342c
499ee47
 
 
4e4abd3
64331b8
499ee47
 
 
 
 
5e48a0e
499ee47
 
5e48a0e
b38ec5a
 
499ee47
5e48a0e
499ee47
b38ec5a
 
499ee47
 
 
 
 
 
 
5e48a0e
 
b38ec5a
499ee47
 
 
5e48a0e
499ee47
 
 
 
 
 
5e48a0e
499ee47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e48a0e
499ee47
5e48a0e
499ee47
5e48a0e
 
 
 
499ee47
 
5e48a0e
499ee47
 
 
5e48a0e
499ee47
 
 
 
 
b38ec5a
499ee47
b38ec5a
499ee47
b38ec5a
499ee47
b38ec5a
 
 
 
 
499ee47
b38ec5a
499ee47
 
 
 
 
b38ec5a
499ee47
 
 
 
 
b38ec5a
499ee47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b38ec5a
499ee47
 
 
b38ec5a
 
499ee47
 
 
b38ec5a
499ee47
5e48a0e
499ee47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e48a0e
 
 
499ee47
5e48a0e
499ee47
 
 
 
 
5e48a0e
 
499ee47
 
 
 
 
5e48a0e
 
499ee47
 
 
 
 
5e48a0e
 
499ee47
 
 
 
 
5e48a0e
 
499ee47
 
 
 
 
5e48a0e
 
499ee47
 
 
 
 
 
 
 
 
 
1b7c80e
499ee47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4e4abd3
499ee47
4e4abd3
499ee47
 
 
 
 
 
 
 
 
 
 
 
a73ed1d
499ee47
 
64331b8
b38ec5a
4e4abd3
 
499ee47
e83342c
499ee47
e83342c
499ee47
 
 
e83342c
499ee47
4e4abd3
499ee47
 
4e4abd3
499ee47
4e4abd3
499ee47
7e2652b
df2fb16
499ee47
 
7e2652b
4e4abd3
 
7e2652b
4e4abd3
 
 
a73ed1d
499ee47
4e4abd3
7e2652b
499ee47
a73ed1d
4e4abd3
e83342c
a73ed1d
 
499ee47
 
 
 
 
a73ed1d
 
4e4abd3
df2fb16
499ee47
 
df2fb16
 
499ee47
 
df2fb16
4e4abd3
e83342c
 
df2fb16
 
 
499ee47
 
 
 
 
 
 
 
df2fb16
 
 
 
 
 
499ee47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df2fb16
 
 
4e4abd3
 
499ee47
 
 
 
4e4abd3
 
 
499ee47
4e4abd3
499ee47
4e4abd3
 
 
 
499ee47
 
4e4abd3
a73ed1d
4e4abd3
 
 
 
 
499ee47
 
 
4e4abd3
 
 
 
 
 
 
 
499ee47
 
4e4abd3
 
 
 
 
df2fb16
 
499ee47
 
df2fb16
 
499ee47
 
df2fb16
 
 
499ee47
df2fb16
 
499ee47
df2fb16
 
 
 
 
 
 
b38ec5a
df2fb16
8c64e05
 
b38ec5a
df2fb16
b38ec5a
df2fb16
b38ec5a
df2fb16
 
b38ec5a
df2fb16
 
b38ec5a
 
df2fb16
 
b38ec5a
 
df2fb16
 
 
 
4e4abd3
 
b38ec5a
4e4abd3
 
 
 
 
 
499ee47
4e4abd3
 
499ee47
 
 
 
 
 
 
e83342c
499ee47
 
 
df2fb16
ad51789
 
e83342c
 
 
4e4abd3
 
5e48a0e
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
import express from 'express';
import http from 'http';
import cors from 'cors';
import { Server as SocketIOServer } from 'socket.io';
import path from 'path';
import { fileURLToPath } from 'url';
import axios from 'axios';
import NodeCache from 'node-cache';
import {
  searchUniversal,
  getSong,
  getAlbum,
  getPlaylist,
  getLyrics
} from './jiosaavn.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const app = express();

// CORS for API endpoints (the client is same origin; this also helps external embeds)
app.use(cors({ origin: '*', credentials: true }));
app.use(express.json());

// ---------------- Cache ----------------
const vidflyCache = new NodeCache({ stdTTL: 300, checkperiod: 60 });

// ---------------- Health ----------------
app.get('/healthz', (_req, res) => res.send('OK'));

// ---------------- Media proxy (Range-aware, CORS-safe) ----------------
// Use this for all Vidfly media URLs to bypass CORS.
app.get('/api/proxy', async (req, res) => {
  const target = req.query.url;
  if (!target) return res.status(400).send('Missing url');

  try {
    // Forward Range and basic headers for streaming/seeking
    const headers = {};
    if (req.headers.range) headers.Range = req.headers.range;
    if (req.headers['user-agent']) headers['User-Agent'] = req.headers['user-agent'];
    if (req.headers['accept']) headers['Accept'] = req.headers['accept'];
    if (req.headers['accept-encoding']) headers['Accept-Encoding'] = req.headers['accept-encoding'];
    if (req.headers['accept-language']) headers['Accept-Language'] = req.headers['accept-language'];
    // Some CDNs check Referer; set a generic one if not present
    headers.Referer = req.headers.referer || 'https://www.youtube.com/';

    const upstream = await axios.get(target, {
      responseType: 'stream',
      headers,
      // We need to forward non-200 statuses for partial content
      validateStatus: () => true,
      maxRedirects: 5
    });

    // CORS headers for the proxied response (so <video> can consume it)
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Credentials', 'true');

    // Mirror status and key headers (content-range enables seeking)
    res.status(upstream.status);
    const passthroughHeaders = [
      'content-type',
      'content-length',
      'content-range',
      'accept-ranges',
      'etag',
      'last-modified',
      'cache-control',
      'expires',
      'date',
      'server'
    ];
    for (const h of passthroughHeaders) {
      const v = upstream.headers[h];
      if (v) res.setHeader(h, v);
    }

    upstream.data.pipe(res);
  } catch (e) {
    res.status(502).send('Proxy fetch failed');
  }
});

// ---------------- YouTube via Vidfly (no yt-dlp) ----------------
// GET /api/yt/source?url=<watchURL or 11-char ID>
app.get('/api/yt/source', async (req, res) => {
  try {
    const raw = (req.query.url || '').trim();
    if (!raw) return res.status(400).json({ error: 'Missing url' });

    // Normalize 11-char ID to full watch URL
    let watchUrl = raw;
    if (/^[A-Za-z0-9_-]{11}$/.test(raw)) {
      watchUrl = `https://www.youtube.com/watch?v=${raw}`;
    }

    // Cache
    const cacheKey = `vidfly:${watchUrl}`;
    let info = vidflyCache.get(cacheKey);
    if (!info) {
      // Vidfly API
      const resp = await axios.get('https://api.vidfly.ai/api/media/youtube/download', {
        params: { url: watchUrl },
        timeout: 20000
      });
      info = resp.data;
      vidflyCache.set(cacheKey, info);
    }

    // Parse Vidfly shape you provided:
    // { code: 0, data: { cover, duration, title?, items: [ { ext, type, height, label, url, ... }, ... ] } }
    if (info?.code !== 0 || !info?.data?.items?.length) {
      return res.status(502).json({ error: 'Vidfly: No playable stream' });
    }

    const data = info.data;
    const items = Array.isArray(data.items) ? data.items : [];

    // Prefer video_with_audio mp4 with highest height
    const muxed = items
      .filter(i => i?.type === 'video_with_audio' && i?.ext === 'mp4' && i?.url)
      .sort((a, b) => (b.height || 0) - (a.height || 0))[0];

    // Fallback: best "audio" (if provided)
    const bestAudio = items
      .filter(i => i?.type === 'audio' && i?.url)
      .sort((a, b) => {
        const ba = parseInt(String(a.bitrate || '').replace(/\D/g, '') || '0', 10);
        const bb = parseInt(String(b.bitrate || '').replace(/\D/g, '') || '0', 10);
        return bb - ba;
      })[0];

    const chosen = muxed || bestAudio;
    if (!chosen?.url) {
      return res.status(502).json({ error: 'Vidfly: No playable stream url' });
    }

    // Return the raw URL (client will route via /api/proxy automatically on error)
    return res.json({
      url: chosen.url,
      title: data.title || watchUrl,
      thumbnail: data.cover || null,
      duration: data.duration || null,
      kind: muxed ? 'video' : 'audio',
      source: 'vidfly'
    });
  } catch (e) {
    return res.status(500).json({ error: e.message || 'Vidfly resolve failed' });
  }
});

// ---------------- YouTube search (Data API v3) ----------------
// Set env YT_API_KEY to enable
app.get('/api/ytsearch', async (req, res) => {
  try {
    const key = process.env.YT_API_KEY;
    if (!key) return res.status(501).json({ error: 'YouTube search unavailable: set YT_API_KEY' });
    const q = String(req.query.q || '').trim();
    if (!q) return res.json({ items: [] });

    const resp = await axios.get('https://www.googleapis.com/youtube/v3/search', {
      params: { key, q, part: 'snippet', type: 'video', maxResults: 12 },
      timeout: 10000
    });
    const items = (resp.data.items || [])
      .map(it => ({
        videoId: it.id?.videoId,
        title: it.snippet?.title,
        channelTitle: it.snippet?.channelTitle,
        thumb: it.snippet?.thumbnails?.medium?.url || it.snippet?.thumbnails?.default?.url
      }))
      .filter(x => x.videoId);
    res.json({ items });
  } catch (e) {
    res.status(500).json({ error: e.message || 'YouTube search failed' });
  }
});

// ---------------- JioSaavn proxies ----------------
app.get('/api/result', async (req, res) => {
  try {
    const q = req.query.q || '';
    const data = await searchUniversal(q);
    res.json(data);
  } catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/song', async (req, res) => {
  try {
    const q = req.query.q || '';
    const data = await getSong(q);
    res.json(data);
  } catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/album', async (req, res) => {
  try {
    const q = req.query.q || '';
    const data = await getAlbum(q);
    res.json(data);
  } catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/playlist', async (req, res) => {
  try {
    const q = req.query.q || '';
    const data = await getPlaylist(q);
    res.json(data);
  } catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/lyrics', async (req, res) => {
  try {
    const q = req.query.q || '';
    const data = await getLyrics(q);
    res.json(data);
  } catch (e) { res.status(500).json({ error: e.message }); }
});

// ---------------- Rooms, lobby, and sync ----------------
/*
room = {
  id, name, hostId,
  users: Map<socketId, { name, role: 'host'|'cohost'|'member', muted?: boolean }>,
  track: { url, title, meta, kind, thumb? },
  isPlaying, anchor, anchorAt,
  queue: Array<track>
}
*/
const rooms = new Map();

function ensureRoom(roomId) {
  if (!rooms.has(roomId)) {
    rooms.set(roomId, {
      id: roomId,
      name: null,
      hostId: null,
      users: new Map(),
      track: null,
      isPlaying: false,
      anchor: 0,
      anchorAt: 0,
      queue: []
    });
  }
  return rooms.get(roomId);
}

function currentState(room) {
  return {
    track: room.track,
    isPlaying: room.isPlaying,
    anchor: room.anchor,
    anchorAt: room.anchorAt,
    queue: room.queue
  };
}

function membersPayload(room) {
  return [...room.users.entries()].map(([id, u]) => ({
    id,
    name: u.name || 'Guest',
    role: u.role || 'member',
    muted: !!u.muted,
    isHost: id === room.hostId
  }));
}

function broadcastMembers(roomId) {
  const room = rooms.get(roomId);
  if (!room) return;
  const members = membersPayload(room);
  io.to(roomId).emit('members', { members, roomName: room.name || room.id });
}

function requireHostOrCohost(room, socketId) {
  const u = room.users.get(socket.id);
  return (socketId === room.hostId) || (u && u.role === 'cohost');
}

function normalizeThumb(meta) {
  return meta?.thumb || meta?.image || meta?.thumbnail || null;
}

function findUserByName(room, targetNameRaw) {
  if (!targetNameRaw) return null;
  const targetName = String(targetNameRaw).replace(/^@/, '').trim().toLowerCase();
  for (const [id, u] of room.users.entries()) {
    if ((u.name || '').toLowerCase() === targetName) return { id, u };
  }
  return null;
}

// Lobby
app.get('/api/rooms', (_req, res) => {
  const data = [...rooms.values()]
    .filter(r => r.users.size > 0)
    .map(r => ({
      id: r.id,
      name: r.name || r.id,
      members: r.users.size,
      isPlaying: r.isPlaying
    }));
  res.json({ rooms: data });
});

app.get('/api/ping', (_req, res) => res.json({ ok: true }));

// Static client
const clientDir = path.resolve(__dirname, '../client/dist');
app.use(express.static(clientDir));
app.get('*', (_req, res) => res.sendFile(path.join(clientDir, 'index.html')));

// Socket.IO realtime
const server = http.createServer(app);
const io = new SocketIOServer(server, {
  cors: { origin: '*', methods: ['GET', 'POST'] }
});

io.on('connection', (socket) => {
  let joinedRoom = null;

  socket.on('join_room', ({ roomId, name, asHost = false, roomName = null }, ack) => {
    const room = ensureRoom(roomId);

    if (asHost || !room.hostId) room.hostId = socket.id;
    if (!room.name && roomName) room.name = String(roomName).trim().slice(0, 60) || null;

    const role = socket.id === room.hostId ? 'host' : 'member';
    const cleanName = String(name || 'Guest').slice(0, 40);
    room.users.set(socket.id, { name: cleanName, role });

    socket.join(roomId);
    joinedRoom = roomId;

    ack?.({
      roomId,
      isHost: socket.id === room.hostId,
      state: currentState(room),
      roomName: room.name || room.id
    });

    io.to(roomId).emit('system', { text: `System: ${cleanName} has joined the chat` });
    broadcastMembers(roomId);
  });

  socket.on('rename', ({ roomId, newName }) => {
    const room = rooms.get(roomId);
    if (!room) return;
    const u = room.users.get(socket.id);
    if (!u) return;
    u.name = String(newName || 'Guest').slice(0, 40);
    broadcastMembers(roomId);
  });

  socket.on('chat_message', ({ roomId, name, text }) => {
    const room = rooms.get(roomId);
    if (!room) return;
    const u = room.users.get(socket.id);
    if (!u) return;
    if (u.muted) {
      socket.emit('system', { text: 'System: You are muted by the host' });
      return;
    }
    socket.to(roomId).emit('chat_message', { name, text, at: Date.now() });
  });

  socket.on('song_request', ({ roomId, requester, query }) => {
    const room = rooms.get(roomId);
    if (!room) return;
    const payload = {
      requester,
      query,
      at: Date.now(),
      requestId: `${Date.now()}_${Math.random().toString(36).slice(2)}`
    };
    for (const [id, usr] of room.users.entries()) {
      if (id === room.hostId || usr.role === 'cohost') io.to(id).emit('song_request', payload);
    }
    io.to(roomId).emit('system', { text: `System: ${requester} requested /play ${query}` });
  });

  socket.on('song_request_action', ({ roomId, action, track }) => {
    const room = rooms.get(roomId);
    if (!room) return;
    const actor = room.users.get(socket.id);
    if (!actor || !(socket.id === room.hostId || actor.role === 'cohost')) return;
    if ((action === 'accept' || action === 'queue') && track) {
      const t = {
        url: track.url,
        title: track.title || track.url,
        meta: track.meta || {},
        kind: track.kind || 'audio',
        thumb: track.thumb || normalizeThumb(track.meta || {})
      };
      if (action === 'accept') {
        room.track = t;
        room.isPlaying = false;
        room.anchor = 0;
        room.anchorAt = Date.now();
        io.to(roomId).emit('set_track', { track: room.track });
        io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt });
        io.to(roomId).emit('system', { text: `System: Now playing ${room.track.title}` });
      } else {
        room.queue.push(t);
        io.to(roomId).emit('queue_update', { queue: room.queue });
        io.to(roomId).emit('system', { text: `System: Queued ${t.title}` });
      }
    }
  });

  socket.on('set_track', ({ roomId, track }) => {
    const room = rooms.get(roomId);
    const actor = room.users.get(socket.id);
    if (!room || !(socket.id === room.hostId || actor?.role === 'cohost')) return;
    const thumb = track.thumb || normalizeThumb(track.meta || {});
    room.track = { ...track, thumb };
    room.isPlaying = false;
    room.anchor = 0;
    room.anchorAt = Date.now();
    io.to(roomId).emit('set_track', { track: room.track });
    io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt });
    io.to(roomId).emit('system', { text: `System: Selected ${room.track.title || room.track.url}` });
  });

  socket.on('play', ({ roomId }) => {
    const room = rooms.get(roomId);
    const actor = room.users.get(socket.id);
    if (!room || !(socket.id === room.hostId || actor?.role === 'cohost')) return;
    room.isPlaying = true;
    room.anchorAt = Date.now();
    io.to(roomId).emit('play', { anchor: room.anchor, anchorAt: room.anchorAt });
  });

  socket.on('pause', ({ roomId }) => {
    const room = rooms.get(roomId);
    const actor = room.users.get(socket.id);
    if (!room || !(socket.id === room.hostId || actor?.role === 'cohost')) return;
    const elapsed = Math.max(0, Date.now() - room.anchorAt) / 1000;
    if (room.isPlaying) room.anchor += elapsed;
    room.isPlaying = false;
    room.anchorAt = Date.now();
    io.to(roomId).emit('pause', { anchor: room.anchor, anchorAt: room.anchorAt });
  });

  socket.on('seek', ({ roomId, to }) => {
    const room = rooms.get(roomId);
    const actor = room.users.get(socket.id);
    if (!room || !(socket.id === room.hostId || actor?.role === 'cohost')) return;
    room.anchor = to;
    room.anchorAt = Date.now();
    io.to(roomId).emit('seek', { anchor: room.anchor, anchorAt: room.anchorAt, isPlaying: room.isPlaying });
  });

  socket.on('ended', ({ roomId }) => {
    const room = rooms.get(roomId);
    const actor = room.users.get(socket.id);
    if (!room || !(socket.id === room.hostId || actor?.role === 'cohost')) return;
    const next = room.queue.shift();
    if (next) {
      const thumb = next.thumb || normalizeThumb(next.meta || {});
      room.track = { ...next, thumb };
      room.isPlaying = false;
      room.anchor = 0;
      room.anchorAt = Date.now();
      io.to(roomId).emit('set_track', { track: room.track });
      io.to(roomId).emit('pause', { anchor: 0, anchorAt: room.anchorAt });
      io.to(roomId).emit('queue_update', { queue: room.queue });
      io.to(roomId).emit('system', { text: `System: Now playing ${room.track.title}` });
    } else {
      io.to(roomId).emit('system', { text: 'System: Queue ended' });
    }
  });

  socket.on('admin_command', ({ roomId, cmd, targetName }) => {
    const room = rooms.get(roomId);
    const actor = room?.users.get(socket.id);
    if (!actor || !(socket.id === room.hostId || actor.role === 'cohost')) return;
    const found = findUserByName(room, targetName);
    if (!found) {
      return io.to(socket.id).emit('system', { text: `System: @${targetName} not found` });
    }
    const { id, u } = found;
    if (cmd === 'kick') {
      io.to(id).emit('system', { text: 'System: You were kicked' });
      io.sockets.sockets.get(id)?.leave(roomId);
      room.users.delete(id);
      io.to(roomId).emit('system', { text: `System: ${u.name} was kicked` });
      broadcastMembers(roomId);
    } else if (cmd === 'promote') {
      u.role = 'cohost';
      io.to(roomId).emit('system', { text: `System: ${u.name} promoted to co-host` });
      broadcastMembers(roomId);
    } else if (cmd === 'mute') {
      u.muted = true;
      io.to(roomId).emit('system', { text: `System: ${u.name} was muted` });
      broadcastMembers(roomId);
    }
  });

  socket.on('sync_request', ({ roomId }, ack) => {
    const room = rooms.get(roomId);
    if (room) ack(currentState(room));
  });

  socket.on('disconnect', () => {
    if (!joinedRoom) return;
    const room = rooms.get(joinedRoom);
    if (!room) return;
    const leftUser = room.users.get(socket.id);
    room.users.delete(socket.id);
    if (socket.id === room.hostId) {
      const nextHost = [...room.users.keys()][0] || null;
      room.hostId = nextHost;
      if (nextHost) {
        const u = room.users.get(nextHost);
        if (u) u.role = 'host';
      }
      io.to(joinedRoom).emit('host_changed', { hostId: room.hostId });
    }
    if (leftUser) {
      const nm = leftUser.name || 'User';
      io.to(joinedRoom).emit('system', { text: `System: ${nm} has left the chat` });
    }
    if (room.users.size === 0) rooms.delete(joinedRoom);
    else broadcastMembers(joinedRoom);
  });
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});