Spaces:
Runtime error
Runtime error
Update server.js
Browse files
server.js
CHANGED
|
@@ -72,6 +72,11 @@ async function connectDB() {
|
|
| 72 |
{ unique: true }
|
| 73 |
);
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
// Rooms, notifications, social indexes
|
| 76 |
try {
|
| 77 |
await db.collection('rooms').createIndex({ id: 1 }, { unique: true });
|
|
@@ -1201,6 +1206,54 @@ async function resolveStreamId(id) {
|
|
| 1201 |
return id;
|
| 1202 |
}
|
| 1203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1204 |
// Viewer joined
|
| 1205 |
// Body: { name, id } where id is userId, tempId (guest), passed explicitly
|
| 1206 |
streamApp.post('/joined/:streamId', async (req, res) => {
|
|
@@ -1208,37 +1261,17 @@ streamApp.post('/joined/:streamId', async (req, res) => {
|
|
| 1208 |
const streamId = await resolveStreamId(rawId);
|
| 1209 |
const { name, id } = req.body;
|
| 1210 |
if (!name || !id) return res.status(400).json({ error: 'Name and id are required' });
|
| 1211 |
-
const stream = streams[streamId];
|
| 1212 |
|
| 1213 |
-
//
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
| 1220 |
-
`${c.spaceUrl}/constituent/queue/${streamId}`,
|
| 1221 |
-
{ headers: { 'x-constituent-secret': MAIN_SERVER_SECRET }, timeout: 5000 }
|
| 1222 |
-
);
|
| 1223 |
-
if (r.data && r.data.success) {
|
| 1224 |
-
// Constituent has this stream β proxy the /joined call to it
|
| 1225 |
-
const proxyRes = await axios.post(
|
| 1226 |
-
`${c.spaceUrl}/joined/${streamId}`,
|
| 1227 |
-
{ name, id, isGuest: req.body.isGuest },
|
| 1228 |
-
{ headers: { 'x-constituent-secret': MAIN_SERVER_SECRET }, timeout: 5000 }
|
| 1229 |
-
);
|
| 1230 |
-
return res.json({ ...proxyRes.data, proxiedToConstituent: c.spaceName });
|
| 1231 |
-
}
|
| 1232 |
-
} catch {
|
| 1233 |
-
// This constituent unreachable or doesn't have the stream β try next
|
| 1234 |
-
}
|
| 1235 |
-
}
|
| 1236 |
-
} catch (err) {
|
| 1237 |
-
console.error('/joined constituent fallback error:', err.message);
|
| 1238 |
-
}
|
| 1239 |
-
return res.status(404).json({ error: 'Stream not found' });
|
| 1240 |
-
}
|
| 1241 |
|
|
|
|
|
|
|
| 1242 |
if (!stream.users) stream.users = new Map();
|
| 1243 |
stream.lastActivity = Date.now();
|
| 1244 |
const isGuest = id.startsWith('g_');
|
|
@@ -1258,33 +1291,16 @@ streamApp.post('/left/:streamId', async (req, res) => {
|
|
| 1258 |
const streamId = await resolveStreamId(rawId);
|
| 1259 |
const { id } = req.body;
|
| 1260 |
if (!id) return res.status(400).json({ error: 'User id is required' });
|
| 1261 |
-
const stream = streams[streamId];
|
| 1262 |
|
| 1263 |
-
|
| 1264 |
-
|
| 1265 |
-
|
| 1266 |
-
|
| 1267 |
-
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
|
| 1271 |
-
|
| 1272 |
-
);
|
| 1273 |
-
if (r.data && r.data.success) {
|
| 1274 |
-
const proxyRes = await axios.post(
|
| 1275 |
-
`${c.spaceUrl}/left/${streamId}`,
|
| 1276 |
-
{ id },
|
| 1277 |
-
{ headers: { 'x-constituent-secret': MAIN_SERVER_SECRET }, timeout: 5000 }
|
| 1278 |
-
);
|
| 1279 |
-
return res.json({ ...proxyRes.data, proxiedToConstituent: c.spaceName });
|
| 1280 |
-
}
|
| 1281 |
-
} catch { /* try next */ }
|
| 1282 |
-
}
|
| 1283 |
-
} catch (err) {
|
| 1284 |
-
console.error('/left constituent fallback error:', err.message);
|
| 1285 |
-
}
|
| 1286 |
-
return res.status(404).json({ error: 'Stream not found' });
|
| 1287 |
-
}
|
| 1288 |
if (!stream.users) stream.users = new Map();
|
| 1289 |
const user = stream.users.get(id);
|
| 1290 |
if (!user) return res.status(404).json({ error: 'User not found in stream' });
|
|
@@ -1304,6 +1320,14 @@ streamApp.post('/left/:streamId', async (req, res) => {
|
|
| 1304 |
// GET /listeners/:streamId β used by frontend watch page to show who's watching
|
| 1305 |
streamApp.get('/listeners/:streamId', async (req, res) => {
|
| 1306 |
const streamId = await resolveStreamId(req.params.streamId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1307 |
const stream = streams[streamId];
|
| 1308 |
if (!stream) return res.json({ success: true, listeners: [] });
|
| 1309 |
const list = stream.users
|
|
@@ -1316,6 +1340,14 @@ streamApp.get('/listeners/:streamId', async (req, res) => {
|
|
| 1316 |
streamApp.get('/list/:streamId', async (req, res) => {
|
| 1317 |
const rawId = req.params.streamId;
|
| 1318 |
const streamId = await resolveStreamId(rawId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1319 |
const stream = streams[streamId];
|
| 1320 |
if (!stream) return res.status(404).json({ error: 'Stream not found' });
|
| 1321 |
if (!stream.users) stream.users = new Map();
|
|
@@ -1323,23 +1355,48 @@ streamApp.get('/list/:streamId', async (req, res) => {
|
|
| 1323 |
});
|
| 1324 |
|
| 1325 |
// Heartbeat β accepts userId (registered) or tempId (guest)
|
|
|
|
|
|
|
| 1326 |
streamApp.post('/heartbeat/:streamId', async (req, res) => {
|
| 1327 |
const rawId = req.params.streamId;
|
| 1328 |
const streamId = await resolveStreamId(rawId);
|
| 1329 |
const { userId } = req.body;
|
| 1330 |
if (!userId) return res.status(400).json({ error: 'userId is required' });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1331 |
const stream = streams[streamId];
|
| 1332 |
if (!stream) return res.json({ success: true }); // stream not initialized yet β no-op
|
| 1333 |
if (!stream.users) stream.users = new Map();
|
| 1334 |
stream.lastActivity = Date.now();
|
|
|
|
| 1335 |
const isGuest = userId.startsWith('g_');
|
| 1336 |
if (isGuest) {
|
| 1337 |
db.collection('guests').updateOne({ guestId: userId }, { $set: { lastSeen: new Date() } }).catch(console.error);
|
| 1338 |
} else {
|
| 1339 |
activeViewers[userId] = streamId;
|
| 1340 |
}
|
|
|
|
| 1341 |
const user = stream.users.get(userId);
|
| 1342 |
-
if (user) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1343 |
// Return success regardless β constituent-stream viewers aren't in this map
|
| 1344 |
res.json({ success: true });
|
| 1345 |
});
|
|
@@ -1347,6 +1404,14 @@ streamApp.post('/heartbeat/:streamId', async (req, res) => {
|
|
| 1347 |
// Current track info
|
| 1348 |
streamApp.get('/stream/:streamId/currentTrack', async (req, res) => {
|
| 1349 |
const streamId = await resolveStreamId(req.params.streamId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1350 |
const stream = streams[streamId];
|
| 1351 |
if (!stream) return res.status(404).json({ error: 'Stream not found' });
|
| 1352 |
const current = stream.queue[0];
|
|
@@ -1372,6 +1437,14 @@ streamApp.get('/stream/:streamId/currentTrack', async (req, res) => {
|
|
| 1372 |
// HLS status
|
| 1373 |
streamApp.get('/stream/:streamId/hlsStatus', async (req, res) => {
|
| 1374 |
const streamId = await resolveStreamId(req.params.streamId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1375 |
const stream = streams[streamId];
|
| 1376 |
if (!stream) return res.status(404).json({ error: 'Stream not found' });
|
| 1377 |
const state = hlsState[streamId];
|
|
@@ -1721,6 +1794,7 @@ streamApp.post('/api/play', requireRegistered, async (req, res) => {
|
|
| 1721 |
|
| 1722 |
const songInfo = { fileName, meta: { title: songData.title, thumbnail: songData.thumbnail || DEFAULT_ARTWORK, duration: audioMeta.duration, views: songData.views, published: songData.published, source: songData.video_url } };
|
| 1723 |
const { position, started } = enqueueToStream(streamId, songInfo, streamId);
|
|
|
|
| 1724 |
|
| 1725 |
res.json({ success: true, started, position, title: songData.title, duration: audioMeta.duration, thumbnail: songData.thumbnail || DEFAULT_ARTWORK, hlsUrl: `/stream-hls/${streamId}/live.m3u8` });
|
| 1726 |
} catch (err) {
|
|
@@ -1758,6 +1832,7 @@ streamApp.post('/api/vplay', requireRegistered, async (req, res) => {
|
|
| 1758 |
|
| 1759 |
const songInfo = { fileName: dlResult.fileName, meta: { title: videoData.title, thumbnail: videoData.thumbnail, duration: mediaMeta.duration, views: videoData.views, published: videoData.published, source: videoData.videoUrl, videoUrl: videoData.videoUrl } };
|
| 1760 |
const { position, started } = enqueueToStream(streamId, songInfo, streamId);
|
|
|
|
| 1761 |
|
| 1762 |
res.json({ success: true, started, position, title: videoData.title, duration: mediaMeta.duration, thumbnail: videoData.thumbnail, hlsUrl: `/stream-hls/${streamId}/live.m3u8` });
|
| 1763 |
} catch (err) {
|
|
@@ -1977,6 +2052,8 @@ async function showplayEnqueueLink(streamId, pendingLink, pendingTitle, thumbnai
|
|
| 1977 |
enqueueToStream(streamId, songInfo, streamId);
|
| 1978 |
// Persist to DB after _sid is assigned by enqueueToStream
|
| 1979 |
saveQueueItem(streamId, songInfo).catch(console.error);
|
|
|
|
|
|
|
| 1980 |
|
| 1981 |
// Decrement AFTER enqueue so the inactivity cleanup never sees a momentary
|
| 1982 |
// zero-count window between "file ready" and "item in queue".
|
|
@@ -2207,6 +2284,14 @@ streamApp.post('/api/skip', requireRegistered, (req, res) => {
|
|
| 2207 |
// GET /api/queue/:streamId β full enriched queue for a stream (resolves mirrors)
|
| 2208 |
streamApp.get('/api/queue/:streamId', async (req, res) => {
|
| 2209 |
const streamId = await resolveStreamId(req.params.streamId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2210 |
const stream = streams[streamId];
|
| 2211 |
if (!stream) return res.status(404).json({ success: false, error: 'Stream not found' });
|
| 2212 |
|
|
@@ -2256,6 +2341,60 @@ streamApp.post('/api/internal/track-song', async (req, res) => {
|
|
| 2256 |
}
|
| 2257 |
});
|
| 2258 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2259 |
// GET /api/profile β listening stats for the authenticated user
|
| 2260 |
streamApp.get('/api/profile', requireRegistered, async (req, res) => {
|
| 2261 |
const userId = req.user.userId;
|
|
@@ -3055,6 +3194,9 @@ streamApp.post('/api/constituent/add-movie', requireRegistered, async (req, res)
|
|
| 3055 |
{ headers: { 'x-constituent-secret': MAIN_SERVER_SECRET, 'Content-Type': 'application/json' }, timeout: 15000 }
|
| 3056 |
);
|
| 3057 |
|
|
|
|
|
|
|
|
|
|
| 3058 |
res.json({
|
| 3059 |
success: true,
|
| 3060 |
message: proxyRes.data.message || 'Movie queued on constituent',
|
|
@@ -3101,6 +3243,9 @@ streamApp.post('/api/constituent/add-song', requireRegistered, async (req, res)
|
|
| 3101 |
{ headers: { 'x-constituent-secret': MAIN_SERVER_SECRET, 'Content-Type': 'application/json' }, timeout: 15000 }
|
| 3102 |
);
|
| 3103 |
|
|
|
|
|
|
|
|
|
|
| 3104 |
res.json({
|
| 3105 |
success: true,
|
| 3106 |
message: proxyRes.data.message || 'Song queued on constituent',
|
|
|
|
| 72 |
{ unique: true }
|
| 73 |
);
|
| 74 |
|
| 75 |
+
// Stream location registry β tracks where each userId's stream is hosted
|
| 76 |
+
// source: 'pool' | 'constituent'
|
| 77 |
+
const streamLoc = db.collection('stream_locations');
|
| 78 |
+
await streamLoc.createIndex({ userId: 1 }, { unique: true });
|
| 79 |
+
|
| 80 |
// Rooms, notifications, social indexes
|
| 81 |
try {
|
| 82 |
await db.collection('rooms').createIndex({ id: 1 }, { unique: true });
|
|
|
|
| 1206 |
return id;
|
| 1207 |
}
|
| 1208 |
|
| 1209 |
+
// ββ Stream location registry ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1210 |
+
// Records where a user's stream is hosted so viewer-facing endpoints can proxy
|
| 1211 |
+
// correctly when the stream lives on a constituent rather than the main pool.
|
| 1212 |
+
//
|
| 1213 |
+
// source : 'pool' | 'constituent'
|
| 1214 |
+
// spaceUrl / spaceName are set only for constituent sources.
|
| 1215 |
+
|
| 1216 |
+
async function setStreamLocation(userId, source, spaceUrl = null, spaceName = null) {
|
| 1217 |
+
try {
|
| 1218 |
+
await db.collection('stream_locations').updateOne(
|
| 1219 |
+
{ userId },
|
| 1220 |
+
{ $set: { userId, source, spaceUrl, spaceName, updatedAt: new Date() } },
|
| 1221 |
+
{ upsert: true }
|
| 1222 |
+
);
|
| 1223 |
+
} catch (err) {
|
| 1224 |
+
console.error('setStreamLocation error:', err.message);
|
| 1225 |
+
}
|
| 1226 |
+
}
|
| 1227 |
+
|
| 1228 |
+
// Returns the location record or null.
|
| 1229 |
+
// { source:'pool' } or { source:'constituent', spaceUrl, spaceName }
|
| 1230 |
+
async function getStreamLocation(userId) {
|
| 1231 |
+
try {
|
| 1232 |
+
return await db.collection('stream_locations').findOne({ userId });
|
| 1233 |
+
} catch { return null; }
|
| 1234 |
+
}
|
| 1235 |
+
|
| 1236 |
+
// For viewer-facing endpoints that receive a *stream owner's* streamId:
|
| 1237 |
+
// If the stream is not in the local pool, attempt to proxy to its constituent.
|
| 1238 |
+
// `proxyFn` receives `(spaceUrl, streamId)` and should call the constituent API
|
| 1239 |
+
// and settle `res` itself. Returns true if proxied, false if not applicable.
|
| 1240 |
+
async function proxyToConstituentIfNeeded(streamId, res, proxyFn) {
|
| 1241 |
+
if (streams[streamId]) return false; // present in local pool β caller handles it
|
| 1242 |
+
|
| 1243 |
+
const loc = await getStreamLocation(streamId);
|
| 1244 |
+
if (!loc || loc.source !== 'constituent' || !loc.spaceUrl) return false;
|
| 1245 |
+
|
| 1246 |
+
try {
|
| 1247 |
+
await proxyFn(loc.spaceUrl, streamId);
|
| 1248 |
+
return true;
|
| 1249 |
+
} catch (err) {
|
| 1250 |
+
const body = err.response?.data;
|
| 1251 |
+
const status = err.response?.status || 502;
|
| 1252 |
+
res.status(status).json(body || { error: err.message });
|
| 1253 |
+
return true;
|
| 1254 |
+
}
|
| 1255 |
+
}
|
| 1256 |
+
|
| 1257 |
// Viewer joined
|
| 1258 |
// Body: { name, id } where id is userId, tempId (guest), passed explicitly
|
| 1259 |
streamApp.post('/joined/:streamId', async (req, res) => {
|
|
|
|
| 1261 |
const streamId = await resolveStreamId(rawId);
|
| 1262 |
const { name, id } = req.body;
|
| 1263 |
if (!name || !id) return res.status(400).json({ error: 'Name and id are required' });
|
|
|
|
| 1264 |
|
| 1265 |
+
// If stream not in local pool, proxy to its constituent
|
| 1266 |
+
const proxied = await proxyToConstituentIfNeeded(streamId, res, async (spaceUrl) => {
|
| 1267 |
+
const r = await axios.post(`${spaceUrl}/joined/${streamId}`, { name, id },
|
| 1268 |
+
{ headers: { 'Content-Type': 'application/json' }, timeout: 8000 });
|
| 1269 |
+
res.json(r.data);
|
| 1270 |
+
});
|
| 1271 |
+
if (proxied) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1272 |
|
| 1273 |
+
const stream = streams[streamId];
|
| 1274 |
+
if (!stream) return res.status(404).json({ error: 'Stream not found' });
|
| 1275 |
if (!stream.users) stream.users = new Map();
|
| 1276 |
stream.lastActivity = Date.now();
|
| 1277 |
const isGuest = id.startsWith('g_');
|
|
|
|
| 1291 |
const streamId = await resolveStreamId(rawId);
|
| 1292 |
const { id } = req.body;
|
| 1293 |
if (!id) return res.status(400).json({ error: 'User id is required' });
|
|
|
|
| 1294 |
|
| 1295 |
+
const proxied = await proxyToConstituentIfNeeded(streamId, res, async (spaceUrl) => {
|
| 1296 |
+
const r = await axios.post(`${spaceUrl}/left/${streamId}`, { id },
|
| 1297 |
+
{ headers: { 'Content-Type': 'application/json' }, timeout: 8000 });
|
| 1298 |
+
res.json(r.data);
|
| 1299 |
+
});
|
| 1300 |
+
if (proxied) return;
|
| 1301 |
+
|
| 1302 |
+
const stream = streams[streamId];
|
| 1303 |
+
if (!stream) return res.status(404).json({ error: 'Stream not found' });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1304 |
if (!stream.users) stream.users = new Map();
|
| 1305 |
const user = stream.users.get(id);
|
| 1306 |
if (!user) return res.status(404).json({ error: 'User not found in stream' });
|
|
|
|
| 1320 |
// GET /listeners/:streamId β used by frontend watch page to show who's watching
|
| 1321 |
streamApp.get('/listeners/:streamId', async (req, res) => {
|
| 1322 |
const streamId = await resolveStreamId(req.params.streamId);
|
| 1323 |
+
|
| 1324 |
+
const proxied = await proxyToConstituentIfNeeded(streamId, res, async (spaceUrl) => {
|
| 1325 |
+
const r = await axios.get(`${spaceUrl}/listeners/${streamId}`,
|
| 1326 |
+
{ headers: { 'x-constituent-secret': MAIN_SERVER_SECRET }, timeout: 8000 });
|
| 1327 |
+
res.json(r.data);
|
| 1328 |
+
});
|
| 1329 |
+
if (proxied) return;
|
| 1330 |
+
|
| 1331 |
const stream = streams[streamId];
|
| 1332 |
if (!stream) return res.json({ success: true, listeners: [] });
|
| 1333 |
const list = stream.users
|
|
|
|
| 1340 |
streamApp.get('/list/:streamId', async (req, res) => {
|
| 1341 |
const rawId = req.params.streamId;
|
| 1342 |
const streamId = await resolveStreamId(rawId);
|
| 1343 |
+
|
| 1344 |
+
const proxied = await proxyToConstituentIfNeeded(streamId, res, async (spaceUrl) => {
|
| 1345 |
+
const r = await axios.get(`${spaceUrl}/list/${streamId}`,
|
| 1346 |
+
{ headers: { 'x-constituent-secret': MAIN_SERVER_SECRET }, timeout: 8000 });
|
| 1347 |
+
res.json(r.data);
|
| 1348 |
+
});
|
| 1349 |
+
if (proxied) return;
|
| 1350 |
+
|
| 1351 |
const stream = streams[streamId];
|
| 1352 |
if (!stream) return res.status(404).json({ error: 'Stream not found' });
|
| 1353 |
if (!stream.users) stream.users = new Map();
|
|
|
|
| 1355 |
});
|
| 1356 |
|
| 1357 |
// Heartbeat β accepts userId (registered) or tempId (guest)
|
| 1358 |
+
// For registered users on the main pool: tracks listening session time so
|
| 1359 |
+
// stats stay current without waiting for an explicit /left event.
|
| 1360 |
streamApp.post('/heartbeat/:streamId', async (req, res) => {
|
| 1361 |
const rawId = req.params.streamId;
|
| 1362 |
const streamId = await resolveStreamId(rawId);
|
| 1363 |
const { userId } = req.body;
|
| 1364 |
if (!userId) return res.status(400).json({ error: 'userId is required' });
|
| 1365 |
+
|
| 1366 |
+
const proxied = await proxyToConstituentIfNeeded(streamId, res, async (spaceUrl) => {
|
| 1367 |
+
const r = await axios.post(`${spaceUrl}/heartbeat/${streamId}`, { userId },
|
| 1368 |
+
{ headers: { 'Content-Type': 'application/json' }, timeout: 8000 });
|
| 1369 |
+
res.json(r.data);
|
| 1370 |
+
});
|
| 1371 |
+
if (proxied) return;
|
| 1372 |
+
|
| 1373 |
const stream = streams[streamId];
|
| 1374 |
if (!stream) return res.json({ success: true }); // stream not initialized yet β no-op
|
| 1375 |
if (!stream.users) stream.users = new Map();
|
| 1376 |
stream.lastActivity = Date.now();
|
| 1377 |
+
|
| 1378 |
const isGuest = userId.startsWith('g_');
|
| 1379 |
if (isGuest) {
|
| 1380 |
db.collection('guests').updateOne({ guestId: userId }, { $set: { lastSeen: new Date() } }).catch(console.error);
|
| 1381 |
} else {
|
| 1382 |
activeViewers[userId] = streamId;
|
| 1383 |
}
|
| 1384 |
+
|
| 1385 |
const user = stream.users.get(userId);
|
| 1386 |
+
if (user) {
|
| 1387 |
+
const now = new Date();
|
| 1388 |
+
const prev = new Date(user.lastHeartbeat || user.joinedAt);
|
| 1389 |
+
const elapsed = Math.max(0, (now - prev) / 1000); // seconds since last heartbeat
|
| 1390 |
+
|
| 1391 |
+
user.lastHeartbeat = now.toISOString();
|
| 1392 |
+
|
| 1393 |
+
// Accumulate listening time on every heartbeat for registered users.
|
| 1394 |
+
// Clamp to 120 s to avoid crediting huge gaps from reconnections.
|
| 1395 |
+
if (!isGuest && elapsed > 0 && elapsed < 120) {
|
| 1396 |
+
trackListeningSession(userId, elapsed).catch(console.error);
|
| 1397 |
+
}
|
| 1398 |
+
}
|
| 1399 |
+
|
| 1400 |
// Return success regardless β constituent-stream viewers aren't in this map
|
| 1401 |
res.json({ success: true });
|
| 1402 |
});
|
|
|
|
| 1404 |
// Current track info
|
| 1405 |
streamApp.get('/stream/:streamId/currentTrack', async (req, res) => {
|
| 1406 |
const streamId = await resolveStreamId(req.params.streamId);
|
| 1407 |
+
|
| 1408 |
+
const proxied = await proxyToConstituentIfNeeded(streamId, res, async (spaceUrl) => {
|
| 1409 |
+
const r = await axios.get(`${spaceUrl}/stream/${streamId}/currentTrack`,
|
| 1410 |
+
{ headers: { 'x-constituent-secret': MAIN_SERVER_SECRET }, timeout: 8000 });
|
| 1411 |
+
res.json(r.data);
|
| 1412 |
+
});
|
| 1413 |
+
if (proxied) return;
|
| 1414 |
+
|
| 1415 |
const stream = streams[streamId];
|
| 1416 |
if (!stream) return res.status(404).json({ error: 'Stream not found' });
|
| 1417 |
const current = stream.queue[0];
|
|
|
|
| 1437 |
// HLS status
|
| 1438 |
streamApp.get('/stream/:streamId/hlsStatus', async (req, res) => {
|
| 1439 |
const streamId = await resolveStreamId(req.params.streamId);
|
| 1440 |
+
|
| 1441 |
+
const proxied = await proxyToConstituentIfNeeded(streamId, res, async (spaceUrl) => {
|
| 1442 |
+
const r = await axios.get(`${spaceUrl}/stream/${streamId}/hlsStatus`,
|
| 1443 |
+
{ headers: { 'x-constituent-secret': MAIN_SERVER_SECRET }, timeout: 8000 });
|
| 1444 |
+
res.json(r.data);
|
| 1445 |
+
});
|
| 1446 |
+
if (proxied) return;
|
| 1447 |
+
|
| 1448 |
const stream = streams[streamId];
|
| 1449 |
if (!stream) return res.status(404).json({ error: 'Stream not found' });
|
| 1450 |
const state = hlsState[streamId];
|
|
|
|
| 1794 |
|
| 1795 |
const songInfo = { fileName, meta: { title: songData.title, thumbnail: songData.thumbnail || DEFAULT_ARTWORK, duration: audioMeta.duration, views: songData.views, published: songData.published, source: songData.video_url } };
|
| 1796 |
const { position, started } = enqueueToStream(streamId, songInfo, streamId);
|
| 1797 |
+
setStreamLocation(streamId, 'pool').catch(console.error);
|
| 1798 |
|
| 1799 |
res.json({ success: true, started, position, title: songData.title, duration: audioMeta.duration, thumbnail: songData.thumbnail || DEFAULT_ARTWORK, hlsUrl: `/stream-hls/${streamId}/live.m3u8` });
|
| 1800 |
} catch (err) {
|
|
|
|
| 1832 |
|
| 1833 |
const songInfo = { fileName: dlResult.fileName, meta: { title: videoData.title, thumbnail: videoData.thumbnail, duration: mediaMeta.duration, views: videoData.views, published: videoData.published, source: videoData.videoUrl, videoUrl: videoData.videoUrl } };
|
| 1834 |
const { position, started } = enqueueToStream(streamId, songInfo, streamId);
|
| 1835 |
+
setStreamLocation(streamId, 'pool').catch(console.error);
|
| 1836 |
|
| 1837 |
res.json({ success: true, started, position, title: videoData.title, duration: mediaMeta.duration, thumbnail: videoData.thumbnail, hlsUrl: `/stream-hls/${streamId}/live.m3u8` });
|
| 1838 |
} catch (err) {
|
|
|
|
| 2052 |
enqueueToStream(streamId, songInfo, streamId);
|
| 2053 |
// Persist to DB after _sid is assigned by enqueueToStream
|
| 2054 |
saveQueueItem(streamId, songInfo).catch(console.error);
|
| 2055 |
+
// Record that this stream is on the main pool (not a constituent)
|
| 2056 |
+
if (!fromConstituent) setStreamLocation(streamId, 'pool').catch(console.error);
|
| 2057 |
|
| 2058 |
// Decrement AFTER enqueue so the inactivity cleanup never sees a momentary
|
| 2059 |
// zero-count window between "file ready" and "item in queue".
|
|
|
|
| 2284 |
// GET /api/queue/:streamId β full enriched queue for a stream (resolves mirrors)
|
| 2285 |
streamApp.get('/api/queue/:streamId', async (req, res) => {
|
| 2286 |
const streamId = await resolveStreamId(req.params.streamId);
|
| 2287 |
+
|
| 2288 |
+
const proxied = await proxyToConstituentIfNeeded(streamId, res, async (spaceUrl) => {
|
| 2289 |
+
const r = await axios.get(`${spaceUrl}/api/queue/${streamId}`,
|
| 2290 |
+
{ headers: { 'x-constituent-secret': MAIN_SERVER_SECRET }, timeout: 8000 });
|
| 2291 |
+
res.json(r.data);
|
| 2292 |
+
});
|
| 2293 |
+
if (proxied) return;
|
| 2294 |
+
|
| 2295 |
const stream = streams[streamId];
|
| 2296 |
if (!stream) return res.status(404).json({ success: false, error: 'Stream not found' });
|
| 2297 |
|
|
|
|
| 2341 |
}
|
| 2342 |
});
|
| 2343 |
|
| 2344 |
+
// POST /api/internal/track-listening
|
| 2345 |
+
// Called by constituent servers when a viewer fires /left so the main server
|
| 2346 |
+
// can persist the listening session duration to the user's stats.
|
| 2347 |
+
streamApp.post('/api/internal/track-listening', async (req, res) => {
|
| 2348 |
+
const secret = req.headers['x-constituent-secret'];
|
| 2349 |
+
if (!secret || secret !== MAIN_SERVER_SECRET) {
|
| 2350 |
+
return res.status(403).json({ success: false, error: 'Forbidden' });
|
| 2351 |
+
}
|
| 2352 |
+
const { userId, duration } = req.body;
|
| 2353 |
+
if (!userId || duration == null) return res.status(400).json({ success: false, error: 'userId and duration are required' });
|
| 2354 |
+
try {
|
| 2355 |
+
await trackListeningSession(userId, duration);
|
| 2356 |
+
res.json({ success: true });
|
| 2357 |
+
} catch (err) {
|
| 2358 |
+
res.status(500).json({ success: false, error: err.message });
|
| 2359 |
+
}
|
| 2360 |
+
});
|
| 2361 |
+
|
| 2362 |
+
// POST /api/internal/track-heartbeat
|
| 2363 |
+
// Called by constituent servers on each viewer heartbeat so the main server
|
| 2364 |
+
// can accumulate listening time incrementally via trackListeningSession.
|
| 2365 |
+
// The constituent passes the elapsed seconds since the last heartbeat.
|
| 2366 |
+
streamApp.post('/api/internal/track-heartbeat', async (req, res) => {
|
| 2367 |
+
const secret = req.headers['x-constituent-secret'];
|
| 2368 |
+
if (!secret || secret !== MAIN_SERVER_SECRET) {
|
| 2369 |
+
return res.status(403).json({ success: false, error: 'Forbidden' });
|
| 2370 |
+
}
|
| 2371 |
+
const { userId, streamId } = req.body;
|
| 2372 |
+
if (!userId) return res.status(400).json({ success: false, error: 'userId is required' });
|
| 2373 |
+
|
| 2374 |
+
const isGuest = (userId || '').startsWith('g_');
|
| 2375 |
+
if (isGuest) return res.json({ success: true }); // no stats for guests
|
| 2376 |
+
|
| 2377 |
+
try {
|
| 2378 |
+
// We use a small in-memory tracker keyed by userId to measure the exact
|
| 2379 |
+
// delta since the constituent last pinged us, then record it as a session.
|
| 2380 |
+
if (!constituentHeartbeatTs) constituentHeartbeatTs = {};
|
| 2381 |
+
const now = Date.now();
|
| 2382 |
+
const prev = constituentHeartbeatTs[userId] || now;
|
| 2383 |
+
const elapsed = Math.max(0, (now - prev) / 1000);
|
| 2384 |
+
constituentHeartbeatTs[userId] = now;
|
| 2385 |
+
|
| 2386 |
+
if (elapsed > 0 && elapsed < 120) {
|
| 2387 |
+
await trackListeningSession(userId, elapsed);
|
| 2388 |
+
}
|
| 2389 |
+
res.json({ success: true });
|
| 2390 |
+
} catch (err) {
|
| 2391 |
+
res.status(500).json({ success: false, error: err.message });
|
| 2392 |
+
}
|
| 2393 |
+
});
|
| 2394 |
+
|
| 2395 |
+
// In-memory map to track the last heartbeat timestamp per userId coming from constituents
|
| 2396 |
+
const constituentHeartbeatTs = {};
|
| 2397 |
+
|
| 2398 |
// GET /api/profile β listening stats for the authenticated user
|
| 2399 |
streamApp.get('/api/profile', requireRegistered, async (req, res) => {
|
| 2400 |
const userId = req.user.userId;
|
|
|
|
| 3194 |
{ headers: { 'x-constituent-secret': MAIN_SERVER_SECRET, 'Content-Type': 'application/json' }, timeout: 15000 }
|
| 3195 |
);
|
| 3196 |
|
| 3197 |
+
// Record that this user's stream is now hosted on this constituent
|
| 3198 |
+
setStreamLocation(userId, 'constituent', constituent.spaceUrl, spaceName).catch(console.error);
|
| 3199 |
+
|
| 3200 |
res.json({
|
| 3201 |
success: true,
|
| 3202 |
message: proxyRes.data.message || 'Movie queued on constituent',
|
|
|
|
| 3243 |
{ headers: { 'x-constituent-secret': MAIN_SERVER_SECRET, 'Content-Type': 'application/json' }, timeout: 15000 }
|
| 3244 |
);
|
| 3245 |
|
| 3246 |
+
// Record that this user's stream is now hosted on this constituent
|
| 3247 |
+
setStreamLocation(userId, 'constituent', constituent.spaceUrl, spaceName).catch(console.error);
|
| 3248 |
+
|
| 3249 |
res.json({
|
| 3250 |
success: true,
|
| 3251 |
message: proxyRes.data.message || 'Song queued on constituent',
|