Spaces:
Sleeping
Sleeping
Update server/server.js
Browse files- server/server.js +43 -34
server/server.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import express from 'express';
|
| 2 |
import http from 'http';
|
| 3 |
import cors from 'cors';
|
|
@@ -19,7 +23,7 @@ const __dirname = path.dirname(__filename);
|
|
| 19 |
|
| 20 |
const app = express();
|
| 21 |
|
| 22 |
-
// CORS for API
|
| 23 |
app.use(cors({ origin: '*', credentials: true }));
|
| 24 |
app.use(express.json());
|
| 25 |
|
|
@@ -30,35 +34,45 @@ const vidflyCache = new NodeCache({ stdTTL: 300, checkperiod: 60 });
|
|
| 30 |
app.get('/healthz', (_req, res) => res.send('OK'));
|
| 31 |
|
| 32 |
// ---------------- Media proxy (Range-aware, CORS-safe) ----------------
|
| 33 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 40 |
const headers = {};
|
| 41 |
if (req.headers.range) headers.Range = req.headers.range;
|
| 42 |
-
|
| 43 |
-
|
| 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 |
-
|
| 47 |
-
headers.Referer = req.headers.referer || 'https://www.youtube.com/';
|
| 48 |
|
| 49 |
const upstream = await axios.get(target, {
|
| 50 |
responseType: 'stream',
|
| 51 |
headers,
|
| 52 |
-
//
|
| 53 |
validateStatus: () => true,
|
| 54 |
maxRedirects: 5
|
| 55 |
});
|
| 56 |
|
| 57 |
-
// CORS
|
| 58 |
res.setHeader('Access-Control-Allow-Origin', '*');
|
| 59 |
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
// Mirror status and key headers
|
| 62 |
res.status(upstream.status);
|
| 63 |
const passthroughHeaders = [
|
| 64 |
'content-type',
|
|
@@ -77,14 +91,17 @@ app.get('/api/proxy', async (req, res) => {
|
|
| 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 (
|
| 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();
|
|
@@ -96,11 +113,10 @@ app.get('/api/yt/source', async (req, res) => {
|
|
| 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
|
|
@@ -109,8 +125,8 @@ app.get('/api/yt/source', async (req, res) => {
|
|
| 109 |
vidflyCache.set(cacheKey, info);
|
| 110 |
}
|
| 111 |
|
| 112 |
-
//
|
| 113 |
-
// { code: 0, data: { cover, duration,
|
| 114 |
if (info?.code !== 0 || !info?.data?.items?.length) {
|
| 115 |
return res.status(502).json({ error: 'Vidfly: No playable stream' });
|
| 116 |
}
|
|
@@ -118,12 +134,12 @@ app.get('/api/yt/source', async (req, res) => {
|
|
| 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
|
| 127 |
const bestAudio = items
|
| 128 |
.filter(i => i?.type === 'audio' && i?.url)
|
| 129 |
.sort((a, b) => {
|
|
@@ -137,7 +153,7 @@ app.get('/api/yt/source', async (req, res) => {
|
|
| 137 |
return res.status(502).json({ error: 'Vidfly: No playable stream url' });
|
| 138 |
}
|
| 139 |
|
| 140 |
-
// Return
|
| 141 |
return res.json({
|
| 142 |
url: chosen.url,
|
| 143 |
title: data.title || watchUrl,
|
|
@@ -152,7 +168,7 @@ app.get('/api/yt/source', async (req, res) => {
|
|
| 152 |
});
|
| 153 |
|
| 154 |
// ---------------- YouTube search (Data API v3) ----------------
|
| 155 |
-
//
|
| 156 |
app.get('/api/ytsearch', async (req, res) => {
|
| 157 |
try {
|
| 158 |
const key = process.env.YT_API_KEY;
|
|
@@ -267,12 +283,11 @@ function membersPayload(room) {
|
|
| 267 |
function broadcastMembers(roomId) {
|
| 268 |
const room = rooms.get(roomId);
|
| 269 |
if (!room) return;
|
| 270 |
-
|
| 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(
|
| 276 |
return (socketId === room.hostId) || (u && u.role === 'cohost');
|
| 277 |
}
|
| 278 |
|
|
@@ -381,8 +396,7 @@ io.on('connection', (socket) => {
|
|
| 381 |
socket.on('song_request_action', ({ roomId, action, track }) => {
|
| 382 |
const room = rooms.get(roomId);
|
| 383 |
if (!room) return;
|
| 384 |
-
|
| 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,
|
|
@@ -409,8 +423,7 @@ io.on('connection', (socket) => {
|
|
| 409 |
|
| 410 |
socket.on('set_track', ({ roomId, track }) => {
|
| 411 |
const room = rooms.get(roomId);
|
| 412 |
-
|
| 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;
|
|
@@ -423,8 +436,7 @@ io.on('connection', (socket) => {
|
|
| 423 |
|
| 424 |
socket.on('play', ({ roomId }) => {
|
| 425 |
const room = rooms.get(roomId);
|
| 426 |
-
|
| 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,8 +444,7 @@ io.on('connection', (socket) => {
|
|
| 432 |
|
| 433 |
socket.on('pause', ({ roomId }) => {
|
| 434 |
const room = rooms.get(roomId);
|
| 435 |
-
|
| 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;
|
|
@@ -443,8 +454,7 @@ io.on('connection', (socket) => {
|
|
| 443 |
|
| 444 |
socket.on('seek', ({ roomId, to }) => {
|
| 445 |
const room = rooms.get(roomId);
|
| 446 |
-
|
| 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,8 +462,7 @@ io.on('connection', (socket) => {
|
|
| 452 |
|
| 453 |
socket.on('ended', ({ roomId }) => {
|
| 454 |
const room = rooms.get(roomId);
|
| 455 |
-
|
| 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 || {});
|
|
|
|
| 1 |
+
// server/server.js
|
| 2 |
+
// Complete server using Vidfly-only YouTube resolution + robust Range-aware proxy to bypass CORS,
|
| 3 |
+
// plus JioSaavn endpoints, YouTube v3 search, lobby, and full Socket.IO sync.
|
| 4 |
+
|
| 5 |
import express from 'express';
|
| 6 |
import http from 'http';
|
| 7 |
import cors from 'cors';
|
|
|
|
| 23 |
|
| 24 |
const app = express();
|
| 25 |
|
| 26 |
+
// Broad CORS for API routes (static assets are same-origin)
|
| 27 |
app.use(cors({ origin: '*', credentials: true }));
|
| 28 |
app.use(express.json());
|
| 29 |
|
|
|
|
| 34 |
app.get('/healthz', (_req, res) => res.send('OK'));
|
| 35 |
|
| 36 |
// ---------------- Media proxy (Range-aware, CORS-safe) ----------------
|
| 37 |
+
// Always route browser playback through this to bypass CORS on googlevideo.com.
|
| 38 |
+
// Supports Range for seeking, forwards useful headers, sets permissive CORS on response.
|
| 39 |
+
app.options('/api/proxy', (_req, res) => {
|
| 40 |
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
| 41 |
+
res.setHeader('Access-Control-Allow-Headers', 'Range, Content-Type, Accept, Accept-Encoding, Accept-Language');
|
| 42 |
+
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
| 43 |
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
| 44 |
+
res.status(204).end();
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
app.get('/api/proxy', async (req, res) => {
|
| 48 |
const target = req.query.url;
|
| 49 |
if (!target) return res.status(400).send('Missing url');
|
| 50 |
|
| 51 |
try {
|
| 52 |
+
// Forward Range and some common headers that CDNs sometimes care about
|
| 53 |
const headers = {};
|
| 54 |
if (req.headers.range) headers.Range = req.headers.range;
|
| 55 |
+
headers['User-Agent'] = req.headers['user-agent'] || 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/116 Safari/537.36';
|
| 56 |
+
headers['Accept'] = req.headers['accept'] || '*/*';
|
| 57 |
if (req.headers['accept-encoding']) headers['Accept-Encoding'] = req.headers['accept-encoding'];
|
| 58 |
if (req.headers['accept-language']) headers['Accept-Language'] = req.headers['accept-language'];
|
| 59 |
+
headers['Referer'] = req.headers.referer || 'https://www.youtube.com/';
|
|
|
|
| 60 |
|
| 61 |
const upstream = await axios.get(target, {
|
| 62 |
responseType: 'stream',
|
| 63 |
headers,
|
| 64 |
+
// Important: allow 206/416/etc to pass through
|
| 65 |
validateStatus: () => true,
|
| 66 |
maxRedirects: 5
|
| 67 |
});
|
| 68 |
|
| 69 |
+
// Set permissive CORS on the proxied response
|
| 70 |
res.setHeader('Access-Control-Allow-Origin', '*');
|
| 71 |
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
| 72 |
+
// Expose content-range so browsers can read seek info if needed
|
| 73 |
+
res.setHeader('Access-Control-Expose-Headers', 'Content-Range, Accept-Ranges');
|
| 74 |
|
| 75 |
+
// Mirror status and key headers necessary for media playback/seek
|
| 76 |
res.status(upstream.status);
|
| 77 |
const passthroughHeaders = [
|
| 78 |
'content-type',
|
|
|
|
| 91 |
if (v) res.setHeader(h, v);
|
| 92 |
}
|
| 93 |
|
| 94 |
+
// Pipe body
|
| 95 |
upstream.data.pipe(res);
|
| 96 |
} catch (e) {
|
| 97 |
+
// Avoid leaking internal details
|
| 98 |
res.status(502).send('Proxy fetch failed');
|
| 99 |
}
|
| 100 |
});
|
| 101 |
|
| 102 |
+
// ---------------- YouTube via Vidfly (yt-dlp removed) ----------------
|
| 103 |
// GET /api/yt/source?url=<watchURL or 11-char ID>
|
| 104 |
+
// Returns direct stream URL from Vidfly; client will auto-route via /api/proxy if CORS blocks direct.
|
| 105 |
app.get('/api/yt/source', async (req, res) => {
|
| 106 |
try {
|
| 107 |
const raw = (req.query.url || '').trim();
|
|
|
|
| 113 |
watchUrl = `https://www.youtube.com/watch?v=${raw}`;
|
| 114 |
}
|
| 115 |
|
| 116 |
+
// Cache Vidfly results
|
| 117 |
const cacheKey = `vidfly:${watchUrl}`;
|
| 118 |
let info = vidflyCache.get(cacheKey);
|
| 119 |
if (!info) {
|
|
|
|
| 120 |
const resp = await axios.get('https://api.vidfly.ai/api/media/youtube/download', {
|
| 121 |
params: { url: watchUrl },
|
| 122 |
timeout: 20000
|
|
|
|
| 125 |
vidflyCache.set(cacheKey, info);
|
| 126 |
}
|
| 127 |
|
| 128 |
+
// Expected shape:
|
| 129 |
+
// { code: 0, data: { title?, cover, duration, items: [ { ext, type, height, label, url, ... }, ... ] } }
|
| 130 |
if (info?.code !== 0 || !info?.data?.items?.length) {
|
| 131 |
return res.status(502).json({ error: 'Vidfly: No playable stream' });
|
| 132 |
}
|
|
|
|
| 134 |
const data = info.data;
|
| 135 |
const items = Array.isArray(data.items) ? data.items : [];
|
| 136 |
|
| 137 |
+
// Prefer 'video_with_audio' mp4 with highest height
|
| 138 |
const muxed = items
|
| 139 |
.filter(i => i?.type === 'video_with_audio' && i?.ext === 'mp4' && i?.url)
|
| 140 |
.sort((a, b) => (b.height || 0) - (a.height || 0))[0];
|
| 141 |
|
| 142 |
+
// Fallback: best audio (if provided)
|
| 143 |
const bestAudio = items
|
| 144 |
.filter(i => i?.type === 'audio' && i?.url)
|
| 145 |
.sort((a, b) => {
|
|
|
|
| 153 |
return res.status(502).json({ error: 'Vidfly: No playable stream url' });
|
| 154 |
}
|
| 155 |
|
| 156 |
+
// Return raw URL; client will use direct src and auto-switch to /api/proxy on CORS error
|
| 157 |
return res.json({
|
| 158 |
url: chosen.url,
|
| 159 |
title: data.title || watchUrl,
|
|
|
|
| 168 |
});
|
| 169 |
|
| 170 |
// ---------------- YouTube search (Data API v3) ----------------
|
| 171 |
+
// Enable by setting env YT_API_KEY
|
| 172 |
app.get('/api/ytsearch', async (req, res) => {
|
| 173 |
try {
|
| 174 |
const key = process.env.YT_API_KEY;
|
|
|
|
| 283 |
function broadcastMembers(roomId) {
|
| 284 |
const room = rooms.get(roomId);
|
| 285 |
if (!room) return;
|
| 286 |
+
io.to(roomId).emit('members', { members: membersPayload(room), roomName: room.name || room.id });
|
|
|
|
| 287 |
}
|
| 288 |
|
| 289 |
function requireHostOrCohost(room, socketId) {
|
| 290 |
+
const u = room.users.get(socketId);
|
| 291 |
return (socketId === room.hostId) || (u && u.role === 'cohost');
|
| 292 |
}
|
| 293 |
|
|
|
|
| 396 |
socket.on('song_request_action', ({ roomId, action, track }) => {
|
| 397 |
const room = rooms.get(roomId);
|
| 398 |
if (!room) return;
|
| 399 |
+
if (!requireHostOrCohost(room, socket.id)) return;
|
|
|
|
| 400 |
if ((action === 'accept' || action === 'queue') && track) {
|
| 401 |
const t = {
|
| 402 |
url: track.url,
|
|
|
|
| 423 |
|
| 424 |
socket.on('set_track', ({ roomId, track }) => {
|
| 425 |
const room = rooms.get(roomId);
|
| 426 |
+
if (!room || !requireHostOrCohost(room, socket.id)) return;
|
|
|
|
| 427 |
const thumb = track.thumb || normalizeThumb(track.meta || {});
|
| 428 |
room.track = { ...track, thumb };
|
| 429 |
room.isPlaying = false;
|
|
|
|
| 436 |
|
| 437 |
socket.on('play', ({ roomId }) => {
|
| 438 |
const room = rooms.get(roomId);
|
| 439 |
+
if (!room || !requireHostOrCohost(room, socket.id)) return;
|
|
|
|
| 440 |
room.isPlaying = true;
|
| 441 |
room.anchorAt = Date.now();
|
| 442 |
io.to(roomId).emit('play', { anchor: room.anchor, anchorAt: room.anchorAt });
|
|
|
|
| 444 |
|
| 445 |
socket.on('pause', ({ roomId }) => {
|
| 446 |
const room = rooms.get(roomId);
|
| 447 |
+
if (!room || !requireHostOrCohost(room, socket.id)) return;
|
|
|
|
| 448 |
const elapsed = Math.max(0, Date.now() - room.anchorAt) / 1000;
|
| 449 |
if (room.isPlaying) room.anchor += elapsed;
|
| 450 |
room.isPlaying = false;
|
|
|
|
| 454 |
|
| 455 |
socket.on('seek', ({ roomId, to }) => {
|
| 456 |
const room = rooms.get(roomId);
|
| 457 |
+
if (!room || !requireHostOrCohost(room, socket.id)) return;
|
|
|
|
| 458 |
room.anchor = to;
|
| 459 |
room.anchorAt = Date.now();
|
| 460 |
io.to(roomId).emit('seek', { anchor: room.anchor, anchorAt: room.anchorAt, isPlaying: room.isPlaying });
|
|
|
|
| 462 |
|
| 463 |
socket.on('ended', ({ roomId }) => {
|
| 464 |
const room = rooms.get(roomId);
|
| 465 |
+
if (!room || !requireHostOrCohost(room, socket.id)) return;
|
|
|
|
| 466 |
const next = room.queue.shift();
|
| 467 |
if (next) {
|
| 468 |
const thumb = next.thumb || normalizeThumb(next.meta || {});
|