Reaperxxxx commited on
Commit
d78564f
Β·
verified Β·
1 Parent(s): 261303a

Update server.js

Browse files
Files changed (1) hide show
  1. server.js +201 -56
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
- // ── If stream not in main pool, check constituent servers ─────────────────
1214
- if (!stream) {
1215
- try {
1216
- const constituents = await db.collection('constituents').find({ userId: streamId }).toArray();
1217
- for (const c of constituents) {
1218
- try {
1219
- const r = await axios.get(
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
- // ── If stream not in main pool, proxy to constituent ──────────────────────
1264
- if (!stream) {
1265
- try {
1266
- const constituents = await db.collection('constituents').find({ userId: streamId }).toArray();
1267
- for (const c of constituents) {
1268
- try {
1269
- const r = await axios.get(
1270
- `${c.spaceUrl}/constituent/queue/${streamId}`,
1271
- { headers: { 'x-constituent-secret': MAIN_SERVER_SECRET }, timeout: 5000 }
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) { user.lastHeartbeat = new Date().toISOString(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
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',