Spaces:
Sleeping
Sleeping
Update server/server.js
Browse files- server/server.js +114 -39
server/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import express from 'express';
|
| 2 |
import http from 'http';
|
| 3 |
import cors from 'cors';
|
|
@@ -20,20 +21,19 @@ const app = express();
|
|
| 20 |
app.use(cors());
|
| 21 |
app.use(express.json());
|
| 22 |
|
| 23 |
-
//
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
room = {
|
| 29 |
-
id, name, hostId,
|
| 30 |
-
users: Map<socketId, { name, role: 'host'|'cohost'|'member', muted?: boolean }>,
|
| 31 |
-
track: { url, title, meta, kind, thumb? },
|
| 32 |
-
isPlaying, anchor, anchorAt,
|
| 33 |
-
queue: Array<track>
|
| 34 |
}
|
| 35 |
-
*/
|
| 36 |
-
const rooms = new Map();
|
| 37 |
|
| 38 |
function ensureRoom(roomId) {
|
| 39 |
if (!rooms.has(roomId)) {
|
|
@@ -52,6 +52,21 @@ function ensureRoom(roomId) {
|
|
| 52 |
return rooms.get(roomId);
|
| 53 |
}
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
function currentState(room) {
|
| 56 |
return {
|
| 57 |
track: room.track,
|
|
@@ -122,6 +137,7 @@ app.get('/api/result', async (req, res) => {
|
|
| 122 |
const data = await searchUniversal(q);
|
| 123 |
res.json(data);
|
| 124 |
} catch (e) {
|
|
|
|
| 125 |
res.status(500).json({ error: e.message });
|
| 126 |
}
|
| 127 |
});
|
|
@@ -130,28 +146,28 @@ app.get('/api/song', async (req, res) => {
|
|
| 130 |
const q = req.query.q || '';
|
| 131 |
const data = await getSong(q);
|
| 132 |
res.json(data);
|
| 133 |
-
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 134 |
});
|
| 135 |
app.get('/api/album', async (req, res) => {
|
| 136 |
try {
|
| 137 |
const q = req.query.q || '';
|
| 138 |
const data = await getAlbum(q);
|
| 139 |
res.json(data);
|
| 140 |
-
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 141 |
});
|
| 142 |
app.get('/api/playlist', async (req, res) => {
|
| 143 |
try {
|
| 144 |
const q = req.query.q || '';
|
| 145 |
const data = await getPlaylist(q);
|
| 146 |
res.json(data);
|
| 147 |
-
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 148 |
});
|
| 149 |
app.get('/api/lyrics', async (req, res) => {
|
| 150 |
try {
|
| 151 |
const q = req.query.q || '';
|
| 152 |
const data = await getLyrics(q);
|
| 153 |
res.json(data);
|
| 154 |
-
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 155 |
});
|
| 156 |
|
| 157 |
// ---------- YouTube search (YouTube Data API v3; optional) ----------
|
|
@@ -172,57 +188,116 @@ app.get('/api/ytsearch', async (req, res) => {
|
|
| 172 |
})).filter(x => x.videoId);
|
| 173 |
res.json({ items });
|
| 174 |
} catch (e) {
|
|
|
|
| 175 |
res.status(500).json({ error: e.message });
|
| 176 |
}
|
| 177 |
});
|
| 178 |
|
| 179 |
// ---------- YouTube resolve via your ytdl.php API ----------
|
| 180 |
-
/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
app.get('/api/yt/source', async (req, res) => {
|
| 182 |
try {
|
| 183 |
const key = process.env.YTDL_API_KEY;
|
| 184 |
-
if (!key)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
let raw = (req.query.url || '').trim();
|
| 187 |
if (!raw) return res.status(400).json({ error: 'Missing url' });
|
| 188 |
|
| 189 |
-
// If it's just an 11-char
|
| 190 |
if (/^[a-zA-Z0-9_-]{11}$/.test(raw)) {
|
| 191 |
raw = `https://www.youtube.com/watch?v=${raw}`;
|
| 192 |
}
|
| 193 |
|
| 194 |
-
const
|
| 195 |
-
const
|
| 196 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
}
|
| 201 |
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
const chosen = progressive || audioOnly;
|
| 211 |
-
if (!chosen) return res.status(502).json({ error: 'No playable format found' });
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
res.json({
|
| 214 |
url: chosen.url,
|
| 215 |
-
title
|
| 216 |
-
thumbnail
|
| 217 |
-
duration
|
| 218 |
-
kind
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
});
|
| 220 |
} catch (e) {
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
| 222 |
}
|
| 223 |
});
|
| 224 |
|
| 225 |
-
|
| 226 |
// ---------- Lobby ----------
|
| 227 |
app.get('/api/rooms', (_req, res) => {
|
| 228 |
const data = [...rooms.values()]
|
|
@@ -461,5 +536,5 @@ io.on('connection', (socket) => {
|
|
| 461 |
|
| 462 |
const PORT = process.env.PORT || 3000;
|
| 463 |
server.listen(PORT, () => {
|
| 464 |
-
|
| 465 |
-
});
|
|
|
|
| 1 |
+
// server.js
|
| 2 |
import express from 'express';
|
| 3 |
import http from 'http';
|
| 4 |
import cors from 'cors';
|
|
|
|
| 21 |
app.use(cors());
|
| 22 |
app.use(express.json());
|
| 23 |
|
| 24 |
+
// --- Helpful small utils ---
|
| 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 |
|
| 34 |
+
function log(...args) {
|
| 35 |
+
console.log(new Date().toISOString(), ...args);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
|
|
|
|
|
|
| 37 |
|
| 38 |
function ensureRoom(roomId) {
|
| 39 |
if (!rooms.has(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,
|
|
|
|
| 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 |
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) ----------
|
|
|
|
| 188 |
})).filter(x => x.videoId);
|
| 189 |
res.json({ items });
|
| 190 |
} catch (e) {
|
| 191 |
+
log('/api/ytsearch error', e?.message || short(e?.response?.data));
|
| 192 |
res.status(500).json({ error: e.message });
|
| 193 |
}
|
| 194 |
});
|
| 195 |
|
| 196 |
// ---------- YouTube resolve via your ytdl.php API ----------
|
| 197 |
+
/*
|
| 198 |
+
Env variables:
|
| 199 |
+
- YTDL_API_KEY (required to call remote ytdl.php if you need to)
|
| 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 it's just an 11-char YouTube id, convert to full watch URL:
|
| 215 |
if (/^[a-zA-Z0-9_-]{11}$/.test(raw)) {
|
| 216 |
raw = `https://www.youtube.com/watch?v=${raw}`;
|
| 217 |
}
|
| 218 |
|
| 219 |
+
const base = process.env.YTDL_API_BASE || 'https://akborana.serv00.net';
|
| 220 |
+
const apiPath = process.env.YTDL_API_PATH || '/ytdl.php';
|
| 221 |
+
const apiUrl = `${base.replace(/\/+$/, '')}${apiPath}?mode=formats&url=${encodeURIComponent(raw)}${key ? `&key=${encodeURIComponent(key)}` : ''}`;
|
| 222 |
+
|
| 223 |
+
log('Calling ytdl.php at', apiUrl);
|
| 224 |
+
|
| 225 |
+
const resp = await axios.get(apiUrl, { timeout: 20000, validateStatus: () => true });
|
| 226 |
|
| 227 |
+
// normalize response
|
| 228 |
+
let data = resp.data;
|
| 229 |
+
// If we received HTML (common when HF space errors), show helpful snippet
|
| 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 |
+
// Some ytdl wrappers return { formats: [...] } while others nest deeper.
|
| 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 |
+
if (!formats || !Array.isArray(formats)) {
|
| 245 |
+
log('/api/yt/source invalid formats from ytdl.php:', short(data, 800));
|
| 246 |
+
return res.status(502).json({ error: 'Invalid formats response from ytdl.php', snippet: short(data, 800) });
|
| 247 |
+
}
|
| 248 |
|
| 249 |
+
// Find best progressive (video+audio) MP4/WebM preferably, fallback to best audio only
|
| 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 format; formats snippet:', short(formats, 1000));
|
| 270 |
+
return res.status(502).json({ error: 'No playable format found in ytdl.php response', snippet: short(formats, 1000) });
|
| 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 || chosen.container || null,
|
| 288 |
+
vcodec: chosen.vcodec || null,
|
| 289 |
+
acodec: chosen.acodec || null,
|
| 290 |
+
filesize: chosen.filesize || chosen.filesizeBytes || null
|
| 291 |
+
}
|
| 292 |
});
|
| 293 |
} catch (e) {
|
| 294 |
+
// extract useful debug info if remote responded non-200
|
| 295 |
+
const remoteData = e?.response?.data ?? e?.message ?? String(e);
|
| 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 |
|
|
|
|
| 301 |
// ---------- Lobby ----------
|
| 302 |
app.get('/api/rooms', (_req, res) => {
|
| 303 |
const data = [...rooms.values()]
|
|
|
|
| 536 |
|
| 537 |
const PORT = process.env.PORT || 3000;
|
| 538 |
server.listen(PORT, () => {
|
| 539 |
+
log(`Server running on port ${PORT}`);
|
| 540 |
+
});
|