test / server /server.js
akborana4's picture
Update server/server.js
3d1ea46 verified
raw
history blame
23.6 kB
/**
* Full server.js
*
* - Robust /api/yt/source -> resolves YouTube via VidFly (configurable via VIDFLY_API env)
* - Debug endpoint /api/vidfly/debug -> calls VidFly and returns the raw JSON for debugging
* - Robust /api/yt/proxy -> proxies googlevideo redirector URLs (for browser CORS + Range support)
* - Full Socket.IO room handlers (join, set_track, play/pause/seek/ended, requests, admin commands)
*
* Notes:
* - Set VIDFLY_API to override the default VidFly endpoint (default: https://api.vidfly.ai/api/media/youtube/download)
* - This file is self-contained; keep your existing jiosaavn.js module alongside (imported)
* - Be mindful of bandwidth when proxying large files through this server.
*/
import express from 'express';
import http from 'http';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import axios from 'axios';
import { PassThrough } from 'stream';
import { Server as SocketIOServer } from 'socket.io';
import {
searchUniversal,
getSong,
getAlbum,
getPlaylist,
getLyrics
} from './jiosaavn.js'; // keep your module
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
app.use(cors());
app.use(express.json());
function log(...args) { console.log(new Date().toISOString(), ...args); }
function short(v, n = 400) {
try {
const s = typeof v === 'string' ? v : JSON.stringify(v);
return s.length > n ? s.slice(0, n) + '…' : s;
} catch { return String(v).slice(0, n) + '…'; }
}
// ---------- Room state ----------
/*
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(socketId);
return (socketId === room.hostId) || (u && (u.role === 'cohost'));
}
function normalizeThumb(meta) {
const candidates = [
meta?.thumb,
meta?.image,
meta?.thumbnail,
meta?.song_image,
meta?.album_image,
meta?.image_url,
meta?.images?.cover,
meta?.images?.[0]
].filter(Boolean);
return candidates[0] || null;
}
function findUserByName(room, targetNameRaw) {
if (!targetNameRaw) return null;
const targetName = String(targetNameRaw).replace(/^@/, '').trim().toLowerCase();
let candidate = null;
for (const [id, u] of room.users.entries()) {
const n = (u.name || '').toLowerCase();
if (n === targetName) return { id, u };
if (!candidate && n.startsWith(targetName)) candidate = { id, u };
}
if (candidate) return candidate;
for (const [id, u] of room.users.entries()) {
const n = (u.name || '').toLowerCase();
if (n.includes(targetName)) return { id, u };
}
return null;
}
// Health
app.get('/healthz', (_req, res) => res.send('OK'));
// ---------- 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) {
log('/api/result error', e?.message || short(e?.response?.data));
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) { log('/api/song error', e?.message || short(e?.response?.data)); 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) { log('/api/album error', e?.message || short(e?.response?.data)); 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) { log('/api/playlist error', e?.message || short(e?.response?.data)); 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) { log('/api/lyrics error', e?.message || short(e?.response?.data)); res.status(500).json({ error: e.message }); }
});
// ---------- YouTube search (optional) ----------
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 env' });
const q = req.query.q || '';
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) {
log('/api/ytsearch error', e?.message || short(e?.response?.data));
res.status(500).json({ error: e.message });
}
});
// ---------- YouTube resolve via VidFly ----------
const VIDFLY_API = process.env.VIDFLY_API || 'https://api.vidfly.ai/api/media/youtube/download';
app.get('/api/vidfly/debug', async (req, res) => {
// Debug endpoint: calls VidFly and returns raw response (JSON) for troubleshooting
try {
let raw = (req.query.url || '').trim();
if (!raw) return res.status(400).json({ error: 'Missing url' });
if (/^[a-zA-Z0-9_-]{11}$/.test(raw)) raw = `https://www.youtube.com/watch?v=${raw}`;
const apiUrl = `${VIDFLY_API}?url=${encodeURIComponent(raw)}`;
log('VIDFLY debug call ->', apiUrl);
const resp = await axios.get(apiUrl, { timeout: 20000, validateStatus: () => true });
// Return status and body so caller can inspect
res.status(200).json({ status: resp.status, headers: resp.headers, data: resp.data });
} catch (e) {
log('/api/vidfly/debug error', e?.message ?? e);
res.status(500).json({ error: e?.message ?? String(e) });
}
});
app.get('/api/yt/source', async (req, res) => {
// Primary resolver: call VidFly and return chosen playable url + metadata
try {
let raw = (req.query.url || '').trim();
if (!raw) return res.status(400).json({ error: 'Missing url' });
if (/^[a-zA-Z0-9_-]{11}$/.test(raw)) raw = `https://www.youtube.com/watch?v=${raw}`;
const apiUrl = `${VIDFLY_API}?url=${encodeURIComponent(raw)}`;
log('Calling VidFly API:', apiUrl);
const resp = await axios.get(apiUrl, { timeout: 20000, validateStatus: () => true });
const data = resp.data;
if (!data || typeof data !== 'object') {
log('/api/yt/source vidfly non-object response:', short(data, 1000));
return res.status(502).json({ error: 'Invalid VidFly response', snippet: short(data, 1000) });
}
if (Number(data.code) !== 0 || !data.data || !Array.isArray(data.data.items)) {
// include vidfly response to aid debugging
log('/api/yt/source vidfly returned error shape:', short(data, 1000));
return res.status(502).json({ error: 'Invalid VidFly API response', detail: data });
}
const items = data.data.items;
// prefer progressive (video with audio), otherwise highest-res video-only, otherwise any
const progressive = items.filter(f => f.type === 'video_with_audio')
.sort((a, b) => (Number(b.height || 0) - Number(a.height || 0)))[0];
const videoOnly = items.filter(f => f.type === 'video')
.sort((a, b) => (Number(b.height || 0) - Number(a.height || 0)))[0];
const anyItem = items.find(f => f.url);
const chosen = progressive || videoOnly || anyItem;
if (!chosen || !chosen.url) {
log('/api/yt/source no playable item found:', short(items, 1200));
return res.status(502).json({ error: 'No playable format found in VidFly API response', detail: data });
}
res.json({
url: chosen.url,
title: data.data.title || raw,
thumbnail: data.data.cover || null,
duration: data.data.duration || null,
kind: chosen.type || 'video',
format: {
ext: chosen.ext || null,
fps: chosen.fps || null,
resolution: `${chosen.width || '?'}x${chosen.height || '?'}`,
label: chosen.label || null
}
});
} catch (e) {
log('/api/yt/source unexpected error:', e?.response?.data ?? e?.message ?? String(e));
res.status(500).json({ error: 'Failed to fetch from VidFly API', detail: e?.response?.data ?? e?.message ?? String(e) });
}
});
/*
/api/yt/proxy
- Proxies direct signed googlevideo URLs (redirector.googlevideo.com / googlevideo.com).
- Forwards Range header to support seeking.
- Forwards upstream headers (content-type, content-length, content-range, accept-ranges).
- Responds with CORS header.
*/
function isAllowedProxyHost(urlStr) {
try {
const u = new URL(urlStr);
const host = u.hostname.toLowerCase();
return host.endsWith('googlevideo.com') || host.endsWith('redirector.googlevideo.com');
} catch {
return false;
}
}
app.get('/api/yt/proxy', async (req, res) => {
try {
const raw = (req.query.url || '').trim();
if (!raw) return res.status(400).json({ error: 'Missing url' });
if (!isAllowedProxyHost(raw)) {
return res.status(400).json({ error: 'Proxy to this host is not allowed by server policy' });
}
const rangeHeader = req.headers.range || null;
const requestHeaders = {
'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Accept': '*/*',
'Referer': 'https://www.youtube.com/',
...(rangeHeader ? { Range: rangeHeader } : {})
};
log('/api/yt/proxy fetching', short(raw, 200), 'Range:', rangeHeader ? rangeHeader.slice(0,120) : 'none');
const resp = await axios.get(raw, {
responseType: 'stream',
timeout: 20000,
headers: requestHeaders,
maxRedirects: 5,
validateStatus: () => true
});
if (resp.status >= 400) {
// try to capture a short snippet for debugging
let snippet = '';
try {
const reader = resp.data;
const chunk = await new Promise((resolve) => {
let done = false;
const onData = (c) => { if (!done) { done = true; cleanup(); resolve(c); } };
const onEnd = () => { if (!done) { done = true; cleanup(); resolve(null); } };
const onErr = () => { if (!done) { done = true; cleanup(); resolve(null); } };
const cleanup = () => { reader.removeListener('data', onData); reader.removeListener('end', onEnd); reader.removeListener('error', onErr); };
reader.once('data', onData);
reader.once('end', onEnd);
reader.once('error', onErr);
});
if (chunk) snippet = chunk.toString('utf8', 0, 800);
} catch (xx) { /* ignore */ }
log('/api/yt/proxy remote returned error status', resp.status, 'snippet:', short(snippet, 400));
return res.status(502).json({ error: 'Remote returned error', status: resp.status, snippet: short(snippet, 800) });
}
// check content-type header for obvious non-media responses
const ct = String(resp.headers['content-type'] || '').toLowerCase();
if (ct.includes('text/html') || ct.includes('application/json') || ct.includes('text/plain')) {
// read a small snippet then return 502
let snippet = '';
try {
const reader = resp.data;
const chunk = await new Promise((resolve) => {
let done = false;
const onData = (c) => { if (!done) { done = true; cleanup(); resolve(c); } };
const onEnd = () => { if (!done) { done = true; cleanup(); resolve(null); } };
const onErr = () => { if (!done) { done = true; cleanup(); resolve(null); } };
const cleanup = () => { reader.removeListener('data', onData); reader.removeListener('end', onEnd); reader.removeListener('error', onErr); };
reader.once('data', onData);
reader.once('end', onEnd);
reader.once('error', onErr);
});
if (chunk) snippet = chunk.toString('utf8', 0, 1600);
} catch (xx) {}
log('/api/yt/proxy remote returned non-media content-type', ct, short(snippet, 400));
return res.status(502).json({ error: 'Remote returned non-media content-type', contentType: ct, snippet: short(snippet, 800) });
}
// forward useful headers
const forwardable = ['content-type','content-length','content-range','accept-ranges','cache-control','last-modified','etag'];
forwardable.forEach(h => {
const v = resp.headers[h];
if (v !== undefined) res.setHeader(h, v);
});
// CORS for browsers (already app.use(cors()) but explicitly set for stream)
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', 'Range,Accept,Content-Type');
// status (206 or 200)
res.status(resp.status);
// pipe upstream stream directly to client (no peeking)
const upstream = resp.data;
upstream.on('error', (err) => {
log('/api/yt/proxy upstream stream error:', err?.message || String(err));
try {
if (!res.headersSent) res.status(502).json({ error: 'Upstream stream error', detail: err?.message || String(err) });
else res.destroy(err);
} catch {}
});
upstream.pipe(res).on('error', (err) => {
log('/api/yt/proxy pipe error:', err?.message || String(err));
try { if (!res.headersSent) res.status(502).json({ error: 'Pipe error', detail: err?.message || String(err) }); } catch {}
});
} catch (e) {
log('/api/yt/proxy unexpected error:', e?.response?.status ?? e?.message ?? String(e));
const detail = e?.response?.data ?? e?.message ?? String(e);
res.status(500).json({ error: 'Failed to proxy remote video', detail: short(detail, 800) });
}
});
// ---------- 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 ----------
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
});
// Single clean system message to everyone
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() });
});
// /play request (user requests a song; host/cohost get notified)
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}` });
});
// Host handles request action
socket.on('song_request_action', ({ roomId, action, track }) => {
const room = rooms.get(roomId);
if (!room) return;
if (!requireHostOrCohost(room, socket.id)) 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}` });
}
}
});
// Host/cohost controls
socket.on('set_track', ({ roomId, track }) => {
const room = rooms.get(roomId);
if (!room || !requireHostOrCohost(room, socket.id)) 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);
if (!room || !requireHostOrCohost(room, socket.id)) 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);
if (!room || !requireHostOrCohost(room, socket.id)) 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);
if (!room || !requireHostOrCohost(room, socket.id)) return;
room.anchor = to;
room.anchorAt = Date.now();
io.to(roomId).emit('seek', { anchor: room.anchor, anchorAt: room.anchorAt, isPlaying: room.isPlaying });
});
// Ended -> auto next
socket.on('ended', ({ roomId }) => {
const room = rooms.get(roomId);
if (!room || !requireHostOrCohost(room, socket.id)) 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' });
}
});
// Admin commands: kick/promote/mute
socket.on('admin_command', ({ roomId, cmd, targetName }) => {
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;
const found = findUserByName(room, targetName);
if (!found) {
io.to(socket.id).emit('system', { text: `System: User @${targetName} not found` });
return;
}
if (cmd === 'kick') {
const { id } = found;
io.to(id).emit('system', { text: 'System: You were kicked by the host' });
io.sockets.sockets.get(id)?.leave(roomId);
room.users.delete(id);
io.to(roomId).emit('system', { text: `System: ${found.u.name} was kicked` });
broadcastMembers(roomId);
} else if (cmd === 'promote') {
found.u.role = 'cohost';
io.to(roomId).emit('system', { text: `System: ${found.u.name} was promoted to co-host` });
broadcastMembers(roomId);
} else if (cmd === 'mute') {
found.u.muted = true;
io.to(roomId).emit('system', { text: `System: ${found.u.name} was muted` });
broadcastMembers(roomId);
}
});
// Sync request: client asks for current room state
socket.on('sync_request', ({ roomId }, ack) => {
const room = rooms.get(roomId);
if (!room) return;
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];
room.hostId = nextHost || null;
if (room.hostId) {
const u = room.users.get(room.hostId);
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);
});
});
// start server
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
log(`Server running on port ${PORT}`);
});