Spaces:
Sleeping
Sleeping
Update server/server.js
Browse files- server/server.js +63 -102
server/server.js
CHANGED
|
@@ -21,7 +21,7 @@ const app = express();
|
|
| 21 |
app.use(cors());
|
| 22 |
app.use(express.json());
|
| 23 |
|
| 24 |
-
// ---
|
| 25 |
function short(v, n = 400) {
|
| 26 |
try {
|
| 27 |
const s = typeof v === 'string' ? v : JSON.stringify(v);
|
|
@@ -30,11 +30,22 @@ function short(v, n = 400) {
|
|
| 30 |
return String(v).slice(0, n) + '…';
|
| 31 |
}
|
| 32 |
}
|
| 33 |
-
|
| 34 |
function log(...args) {
|
| 35 |
console.log(new Date().toISOString(), ...args);
|
| 36 |
}
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
function ensureRoom(roomId) {
|
| 39 |
if (!rooms.has(roomId)) {
|
| 40 |
rooms.set(roomId, {
|
|
@@ -52,21 +63,6 @@ function ensureRoom(roomId) {
|
|
| 52 |
return rooms.get(roomId);
|
| 53 |
}
|
| 54 |
|
| 55 |
-
// Health check
|
| 56 |
-
app.get('/healthz', (_req, res) => res.send('OK'));
|
| 57 |
-
|
| 58 |
-
// ---------- Room state ----------
|
| 59 |
-
/*
|
| 60 |
-
room = {
|
| 61 |
-
id, name, hostId,
|
| 62 |
-
users: Map<socketId, { name, role: 'host'|'cohost'|'member', muted?: boolean }>,
|
| 63 |
-
track: { url, title, meta, kind, thumb? },
|
| 64 |
-
isPlaying, anchor, anchorAt,
|
| 65 |
-
queue: Array<track>
|
| 66 |
-
}
|
| 67 |
-
*/
|
| 68 |
-
const rooms = new Map();
|
| 69 |
-
|
| 70 |
function currentState(room) {
|
| 71 |
return {
|
| 72 |
track: room.track,
|
|
@@ -87,6 +83,7 @@ function membersPayload(room) {
|
|
| 87 |
}));
|
| 88 |
}
|
| 89 |
|
|
|
|
| 90 |
function broadcastMembers(roomId) {
|
| 91 |
const room = rooms.get(roomId);
|
| 92 |
if (!room) return;
|
|
@@ -130,6 +127,9 @@ function findUserByName(room, targetNameRaw) {
|
|
| 130 |
return null;
|
| 131 |
}
|
| 132 |
|
|
|
|
|
|
|
|
|
|
| 133 |
// ---------- JioSaavn proxies ----------
|
| 134 |
app.get('/api/result', async (req, res) => {
|
| 135 |
try {
|
|
@@ -137,7 +137,7 @@ app.get('/api/result', async (req, res) => {
|
|
| 137 |
const data = await searchUniversal(q);
|
| 138 |
res.json(data);
|
| 139 |
} catch (e) {
|
| 140 |
-
log('/api/result error', e?.message || e);
|
| 141 |
res.status(500).json({ error: e.message });
|
| 142 |
}
|
| 143 |
});
|
|
@@ -146,28 +146,28 @@ app.get('/api/song', async (req, res) => {
|
|
| 146 |
const q = req.query.q || '';
|
| 147 |
const data = await getSong(q);
|
| 148 |
res.json(data);
|
| 149 |
-
} catch (e) { log('/api/song error', e?.message || e); res.status(500).json({ error: e.message }); }
|
| 150 |
});
|
| 151 |
app.get('/api/album', async (req, res) => {
|
| 152 |
try {
|
| 153 |
const q = req.query.q || '';
|
| 154 |
const data = await getAlbum(q);
|
| 155 |
res.json(data);
|
| 156 |
-
} catch (e) { log('/api/album error', e?.message || e); res.status(500).json({ error: e.message }); }
|
| 157 |
});
|
| 158 |
app.get('/api/playlist', async (req, res) => {
|
| 159 |
try {
|
| 160 |
const q = req.query.q || '';
|
| 161 |
const data = await getPlaylist(q);
|
| 162 |
res.json(data);
|
| 163 |
-
} catch (e) { log('/api/playlist error', e?.message || e); res.status(500).json({ error: e.message }); }
|
| 164 |
});
|
| 165 |
app.get('/api/lyrics', async (req, res) => {
|
| 166 |
try {
|
| 167 |
const q = req.query.q || '';
|
| 168 |
const data = await getLyrics(q);
|
| 169 |
res.json(data);
|
| 170 |
-
} catch (e) { log('/api/lyrics error', e?.message || e); res.status(500).json({ error: e.message }); }
|
| 171 |
});
|
| 172 |
|
| 173 |
// ---------- YouTube search (YouTube Data API v3; optional) ----------
|
|
@@ -193,108 +193,75 @@ app.get('/api/ytsearch', async (req, res) => {
|
|
| 193 |
}
|
| 194 |
});
|
| 195 |
|
| 196 |
-
// ---------- YouTube resolve via
|
| 197 |
/*
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
- YTDL_API_BASE (optional) base host for ytdl.php, e.g. https://akborana.serv00.net
|
| 201 |
*/
|
| 202 |
app.get('/api/yt/source', async (req, res) => {
|
| 203 |
try {
|
| 204 |
-
const key = process.env.YTDL_API_KEY;
|
| 205 |
-
if (!key) {
|
| 206 |
-
// Allow it to proceed if you want to use a ytdl endpoint that doesn't require a key;
|
| 207 |
-
// but prefer to warn the user.
|
| 208 |
-
log('Warning: YTDL_API_KEY not set. If your ytdl.php requires a key, requests will fail.');
|
| 209 |
-
}
|
| 210 |
-
|
| 211 |
let raw = (req.query.url || '').trim();
|
| 212 |
if (!raw) return res.status(400).json({ error: 'Missing url' });
|
| 213 |
|
| 214 |
-
// If
|
| 215 |
if (/^[a-zA-Z0-9_-]{11}$/.test(raw)) {
|
| 216 |
raw = `https://www.youtube.com/watch?v=${raw}`;
|
| 217 |
}
|
| 218 |
|
| 219 |
-
const
|
| 220 |
-
|
| 221 |
-
const apiUrl = `${base.replace(/\/+$/, '')}${apiPath}?mode=formats&url=${encodeURIComponent(raw)}${key ? `&key=${encodeURIComponent(key)}` : ''}`;
|
| 222 |
|
| 223 |
-
|
|
|
|
| 224 |
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
| 226 |
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
if (typeof data === 'string') {
|
| 231 |
-
// try parse JSON if it is JSON-like
|
| 232 |
-
try { data = JSON.parse(data); } catch (err) {
|
| 233 |
-
log('/api/yt/source got non-JSON response snippet:', short(data, 800));
|
| 234 |
-
return res.status(502).json({ error: 'ytdl.php returned non-JSON response', snippet: short(data, 800) });
|
| 235 |
-
}
|
| 236 |
}
|
| 237 |
|
| 238 |
-
|
| 239 |
-
const formats = Array.isArray(data) ? data :
|
| 240 |
-
Array.isArray(data.formats) ? data.formats :
|
| 241 |
-
Array.isArray(data.result?.formats) ? data.result.formats :
|
| 242 |
-
Array.isArray(data.data?.formats) ? data.data.formats : null;
|
| 243 |
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
-
|
| 250 |
-
const progressive = formats
|
| 251 |
-
.filter(f => f.vcodec !== 'none' && f.acodec !== 'none')
|
| 252 |
-
// prefer mp4 container (but don't filter out other containers)
|
| 253 |
-
.sort((a, b) => {
|
| 254 |
-
const szA = Number(a.filesize || a.filesizeBytes || 0);
|
| 255 |
-
const szB = Number(b.filesize || b.filesizeBytes || 0);
|
| 256 |
-
// prefer mp4 slightly
|
| 257 |
-
const prefA = (String(a.ext || a.container || '').toLowerCase().includes('mp4')) ? 1 : 0;
|
| 258 |
-
const prefB = (String(b.ext || b.container || '').toLowerCase().includes('mp4')) ? 1 : 0;
|
| 259 |
-
return (prefB - prefA) || (szB - szA);
|
| 260 |
-
})[0];
|
| 261 |
-
|
| 262 |
-
const audioOnly = formats
|
| 263 |
-
.filter(f => f.vcodec === 'none' && (String(f.ext || '').toLowerCase() === 'm4a' || String(f.ext || '').toLowerCase() === 'webm' || String(f.container || '').toLowerCase() === 'm4a'))
|
| 264 |
-
.sort((a, b) => (Number(b.filesize || 0) - Number(a.filesize || 0)))[0];
|
| 265 |
-
|
| 266 |
-
const chosen = progressive || audioOnly;
|
| 267 |
|
| 268 |
if (!chosen || !chosen.url) {
|
| 269 |
-
log('/api/yt/source no playable
|
| 270 |
-
return res.status(502).json({ error: 'No playable format found in
|
| 271 |
}
|
| 272 |
|
| 273 |
-
const title = data.title || data.name || data.videoTitle || raw;
|
| 274 |
-
const thumbnail = data.thumbnail || data.thumb || data.thumbnail_url || data.thumbnailUrl || null;
|
| 275 |
-
const duration = data.duration || data.length || null;
|
| 276 |
-
const kind = progressive ? 'video' : 'audio';
|
| 277 |
-
|
| 278 |
-
// Return useful info back to client
|
| 279 |
res.json({
|
| 280 |
url: chosen.url,
|
| 281 |
-
title,
|
| 282 |
-
thumbnail,
|
| 283 |
-
duration,
|
| 284 |
-
kind,
|
| 285 |
-
// small helpful debug info
|
| 286 |
format: {
|
| 287 |
-
ext: chosen.ext ||
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
}
|
| 292 |
});
|
| 293 |
} catch (e) {
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
log('/api/yt/source error', short(remoteData, 1000));
|
| 297 |
-
res.status(500).json({ error: 'Failed to fetch ytdl.php data', detail: short(remoteData, 1000) });
|
| 298 |
}
|
| 299 |
});
|
| 300 |
|
|
@@ -310,7 +277,6 @@ app.get('/api/rooms', (_req, res) => {
|
|
| 310 |
}));
|
| 311 |
res.json({ rooms: data });
|
| 312 |
});
|
| 313 |
-
|
| 314 |
app.get('/api/ping', (_req, res) => res.json({ ok: true }));
|
| 315 |
|
| 316 |
// ---------- Static client ----------
|
|
@@ -347,7 +313,6 @@ io.on('connection', (socket) => {
|
|
| 347 |
roomName: room.name || room.id
|
| 348 |
});
|
| 349 |
|
| 350 |
-
// Single clean system message to everyone
|
| 351 |
io.to(roomId).emit('system', { text: `System: ${cleanName} has joined the chat` });
|
| 352 |
broadcastMembers(roomId);
|
| 353 |
});
|
|
@@ -384,7 +349,6 @@ io.on('connection', (socket) => {
|
|
| 384 |
io.to(roomId).emit('system', { text: `System: ${requester} requested /play ${query}` });
|
| 385 |
});
|
| 386 |
|
| 387 |
-
// Host handles request action
|
| 388 |
socket.on('song_request_action', ({ roomId, action, track }) => {
|
| 389 |
const room = rooms.get(roomId);
|
| 390 |
if (!room) return;
|
|
@@ -413,7 +377,6 @@ io.on('connection', (socket) => {
|
|
| 413 |
}
|
| 414 |
});
|
| 415 |
|
| 416 |
-
// Host/cohost controls
|
| 417 |
socket.on('set_track', ({ roomId, track }) => {
|
| 418 |
const room = rooms.get(roomId);
|
| 419 |
if (!room || !requireHostOrCohost(room, socket.id)) return;
|
|
@@ -453,7 +416,6 @@ io.on('connection', (socket) => {
|
|
| 453 |
io.to(roomId).emit('seek', { anchor: room.anchor, anchorAt: room.anchorAt, isPlaying: room.isPlaying });
|
| 454 |
});
|
| 455 |
|
| 456 |
-
// Ended -> auto next
|
| 457 |
socket.on('ended', ({ roomId }) => {
|
| 458 |
const room = rooms.get(roomId);
|
| 459 |
if (!room || !requireHostOrCohost(room, socket.id)) return;
|
|
@@ -473,7 +435,6 @@ io.on('connection', (socket) => {
|
|
| 473 |
}
|
| 474 |
});
|
| 475 |
|
| 476 |
-
// Admin commands
|
| 477 |
socket.on('admin_command', ({ roomId, cmd, targetName }) => {
|
| 478 |
const room = rooms.get(roomId);
|
| 479 |
if (!room) return;
|
|
|
|
| 21 |
app.use(cors());
|
| 22 |
app.use(express.json());
|
| 23 |
|
| 24 |
+
// --- small utils ---
|
| 25 |
function short(v, n = 400) {
|
| 26 |
try {
|
| 27 |
const s = typeof v === 'string' ? v : JSON.stringify(v);
|
|
|
|
| 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 ----------
|
| 38 |
+
/*
|
| 39 |
+
room = {
|
| 40 |
+
id, name, hostId,
|
| 41 |
+
users: Map<socketId, { name, role: 'host'|'cohost'|'member', muted?: boolean }>,
|
| 42 |
+
track: { url, title, meta, kind, thumb? },
|
| 43 |
+
isPlaying, anchor, anchorAt,
|
| 44 |
+
queue: Array<track>
|
| 45 |
+
}
|
| 46 |
+
*/
|
| 47 |
+
const rooms = new Map();
|
| 48 |
+
|
| 49 |
function ensureRoom(roomId) {
|
| 50 |
if (!rooms.has(roomId)) {
|
| 51 |
rooms.set(roomId, {
|
|
|
|
| 63 |
return rooms.get(roomId);
|
| 64 |
}
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
function currentState(room) {
|
| 67 |
return {
|
| 68 |
track: room.track,
|
|
|
|
| 83 |
}));
|
| 84 |
}
|
| 85 |
|
| 86 |
+
// broadcastMembers uses `io` at runtime (ok because called after io is created)
|
| 87 |
function broadcastMembers(roomId) {
|
| 88 |
const room = rooms.get(roomId);
|
| 89 |
if (!room) return;
|
|
|
|
| 127 |
return null;
|
| 128 |
}
|
| 129 |
|
| 130 |
+
// Health check
|
| 131 |
+
app.get('/healthz', (_req, res) => res.send('OK'));
|
| 132 |
+
|
| 133 |
// ---------- JioSaavn proxies ----------
|
| 134 |
app.get('/api/result', async (req, res) => {
|
| 135 |
try {
|
|
|
|
| 137 |
const data = await searchUniversal(q);
|
| 138 |
res.json(data);
|
| 139 |
} catch (e) {
|
| 140 |
+
log('/api/result error', e?.message || short(e?.response?.data));
|
| 141 |
res.status(500).json({ error: e.message });
|
| 142 |
}
|
| 143 |
});
|
|
|
|
| 146 |
const q = req.query.q || '';
|
| 147 |
const data = await getSong(q);
|
| 148 |
res.json(data);
|
| 149 |
+
} catch (e) { log('/api/song error', e?.message || short(e?.response?.data)); res.status(500).json({ error: e.message }); }
|
| 150 |
});
|
| 151 |
app.get('/api/album', async (req, res) => {
|
| 152 |
try {
|
| 153 |
const q = req.query.q || '';
|
| 154 |
const data = await getAlbum(q);
|
| 155 |
res.json(data);
|
| 156 |
+
} catch (e) { log('/api/album error', e?.message || short(e?.response?.data)); res.status(500).json({ error: e.message }); }
|
| 157 |
});
|
| 158 |
app.get('/api/playlist', async (req, res) => {
|
| 159 |
try {
|
| 160 |
const q = req.query.q || '';
|
| 161 |
const data = await getPlaylist(q);
|
| 162 |
res.json(data);
|
| 163 |
+
} catch (e) { log('/api/playlist error', e?.message || short(e?.response?.data)); res.status(500).json({ error: e.message }); }
|
| 164 |
});
|
| 165 |
app.get('/api/lyrics', async (req, res) => {
|
| 166 |
try {
|
| 167 |
const q = req.query.q || '';
|
| 168 |
const data = await getLyrics(q);
|
| 169 |
res.json(data);
|
| 170 |
+
} catch (e) { log('/api/lyrics error', e?.message || short(e?.response?.data)); res.status(500).json({ error: e.message }); }
|
| 171 |
});
|
| 172 |
|
| 173 |
// ---------- YouTube search (YouTube Data API v3; optional) ----------
|
|
|
|
| 193 |
}
|
| 194 |
});
|
| 195 |
|
| 196 |
+
// ---------- YouTube resolve via VidFly API ----------
|
| 197 |
/*
|
| 198 |
+
This route uses api.vidfly.ai -> expects response shape like:
|
| 199 |
+
{ code: 0, data: { title, cover, duration, items: [ { ext, fps, height, width, label, type, url } ] } }
|
|
|
|
| 200 |
*/
|
| 201 |
app.get('/api/yt/source', async (req, res) => {
|
| 202 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
let raw = (req.query.url || '').trim();
|
| 204 |
if (!raw) return res.status(400).json({ error: 'Missing url' });
|
| 205 |
|
| 206 |
+
// If input is just a video id, convert
|
| 207 |
if (/^[a-zA-Z0-9_-]{11}$/.test(raw)) {
|
| 208 |
raw = `https://www.youtube.com/watch?v=${raw}`;
|
| 209 |
}
|
| 210 |
|
| 211 |
+
const vidflyUrl = `https://api.vidfly.ai/api/media/youtube/download?url=${encodeURIComponent(raw)}`;
|
| 212 |
+
log('Calling VidFly API:', vidflyUrl);
|
|
|
|
| 213 |
|
| 214 |
+
const resp = await axios.get(vidflyUrl, { timeout: 20000, validateStatus: () => true });
|
| 215 |
+
const data = resp.data;
|
| 216 |
|
| 217 |
+
if (!data || typeof data !== 'object') {
|
| 218 |
+
log('/api/yt/source vidfly non-object response:', short(data, 1000));
|
| 219 |
+
return res.status(502).json({ error: 'Invalid VidFly response', snippet: short(data, 1000) });
|
| 220 |
+
}
|
| 221 |
|
| 222 |
+
if (Number(data.code) !== 0 || !data.data || !Array.isArray(data.data.items)) {
|
| 223 |
+
log('/api/yt/source vidfly returned error shape:', short(data, 1000));
|
| 224 |
+
return res.status(502).json({ error: 'Invalid VidFly API response', detail: data });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
}
|
| 226 |
|
| 227 |
+
const items = data.data.items;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
+
// Prefer video_with_audio, pick highest resolution (height) available
|
| 230 |
+
const progressive = items
|
| 231 |
+
.filter(f => f.type === 'video_with_audio')
|
| 232 |
+
.sort((a, b) => (Number(b.height || 0) - Number(a.height || 0)))[0];
|
| 233 |
+
|
| 234 |
+
// Fallback: video-only highest resolution
|
| 235 |
+
const videoOnly = items
|
| 236 |
+
.filter(f => f.type === 'video')
|
| 237 |
+
.sort((a, b) => (Number(b.height || 0) - Number(a.height || 0)))[0];
|
| 238 |
+
|
| 239 |
+
// Fallback: any item with a URL
|
| 240 |
+
const anyItem = items.find(f => f.url);
|
| 241 |
|
| 242 |
+
const chosen = progressive || videoOnly || anyItem;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
if (!chosen || !chosen.url) {
|
| 245 |
+
log('/api/yt/source no playable item found:', short(items, 1200));
|
| 246 |
+
return res.status(502).json({ error: 'No playable format found in VidFly API response' });
|
| 247 |
}
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
res.json({
|
| 250 |
url: chosen.url,
|
| 251 |
+
title: data.data.title || raw,
|
| 252 |
+
thumbnail: data.data.cover || null,
|
| 253 |
+
duration: data.data.duration || null,
|
| 254 |
+
kind: chosen.type || 'video',
|
|
|
|
| 255 |
format: {
|
| 256 |
+
ext: chosen.ext || null,
|
| 257 |
+
fps: chosen.fps || null,
|
| 258 |
+
resolution: `${chosen.width || '?'}x${chosen.height || '?'}`,
|
| 259 |
+
label: chosen.label || null
|
| 260 |
}
|
| 261 |
});
|
| 262 |
} catch (e) {
|
| 263 |
+
log('/api/yt/source unexpected error:', e?.response?.data ?? e?.message ?? String(e));
|
| 264 |
+
res.status(500).json({ error: 'Failed to fetch from VidFly API', detail: e?.response?.data ?? e?.message ?? String(e) });
|
|
|
|
|
|
|
| 265 |
}
|
| 266 |
});
|
| 267 |
|
|
|
|
| 277 |
}));
|
| 278 |
res.json({ rooms: data });
|
| 279 |
});
|
|
|
|
| 280 |
app.get('/api/ping', (_req, res) => res.json({ ok: true }));
|
| 281 |
|
| 282 |
// ---------- Static client ----------
|
|
|
|
| 313 |
roomName: room.name || room.id
|
| 314 |
});
|
| 315 |
|
|
|
|
| 316 |
io.to(roomId).emit('system', { text: `System: ${cleanName} has joined the chat` });
|
| 317 |
broadcastMembers(roomId);
|
| 318 |
});
|
|
|
|
| 349 |
io.to(roomId).emit('system', { text: `System: ${requester} requested /play ${query}` });
|
| 350 |
});
|
| 351 |
|
|
|
|
| 352 |
socket.on('song_request_action', ({ roomId, action, track }) => {
|
| 353 |
const room = rooms.get(roomId);
|
| 354 |
if (!room) return;
|
|
|
|
| 377 |
}
|
| 378 |
});
|
| 379 |
|
|
|
|
| 380 |
socket.on('set_track', ({ roomId, track }) => {
|
| 381 |
const room = rooms.get(roomId);
|
| 382 |
if (!room || !requireHostOrCohost(room, socket.id)) return;
|
|
|
|
| 416 |
io.to(roomId).emit('seek', { anchor: room.anchor, anchorAt: room.anchorAt, isPlaying: room.isPlaying });
|
| 417 |
});
|
| 418 |
|
|
|
|
| 419 |
socket.on('ended', ({ roomId }) => {
|
| 420 |
const room = rooms.get(roomId);
|
| 421 |
if (!room || !requireHostOrCohost(room, socket.id)) return;
|
|
|
|
| 435 |
}
|
| 436 |
});
|
| 437 |
|
|
|
|
| 438 |
socket.on('admin_command', ({ roomId, cmd, targetName }) => {
|
| 439 |
const room = rooms.get(roomId);
|
| 440 |
if (!room) return;
|