@livedesk/hub 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js CHANGED
@@ -1542,11 +1542,11 @@ function sendJson(ws, payload) {
1542
1542
  return safeWebSocketSend(ws, JSON.stringify(payload));
1543
1543
  }
1544
1544
 
1545
- function forgetWebSocketClient(ws) {
1546
- unregisterFrameClient(ws);
1547
- frameClients.delete(ws);
1548
- audioClients.delete(ws);
1549
- inputClients.delete(ws);
1545
+ function forgetWebSocketClient(ws) {
1546
+ unregisterFrameClient(ws);
1547
+ frameClients.delete(ws);
1548
+ releaseAudioClient(ws, 'websocket-forgotten');
1549
+ inputClients.delete(ws);
1550
1550
  try {
1551
1551
  ws.terminate?.();
1552
1552
  } catch {
@@ -1647,14 +1647,34 @@ function scheduleFrameStreamStop(deviceId, streamId, streamPurpose) {
1647
1647
  if (hasOtherFrameStreamOwner(null, deviceId, streamId, streamPurpose)) {
1648
1648
  return;
1649
1649
  }
1650
- const result = remoteHub.stopLiveStream(deviceId, {
1651
- streamId,
1652
- streamPurpose,
1653
- reason: 'frame-client-closed'
1654
- });
1655
- if (result?.ok && !result.alreadyStopped) {
1656
- console.log(`[LiveDesk Hub] stopped ${streamPurpose} stream after frame client closed device=${deviceId} stream=${streamId}`);
1657
- }
1650
+ const result = remoteHub.stopLiveStream(deviceId, {
1651
+ streamId,
1652
+ streamPurpose,
1653
+ reason: 'frame-client-closed'
1654
+ });
1655
+ if (result?.stopPromise) {
1656
+ void result.stopPromise.then(confirmation => {
1657
+ if (confirmation?.captureStopConfirmed === true) {
1658
+ console.log(`[LiveDesk Hub] confirmed ${streamPurpose} capture stopped after frame client closed device=${deviceId} stream=${streamId}`);
1659
+ return;
1660
+ }
1661
+ if (!hasOtherFrameStreamOwner(null, deviceId, streamId, streamPurpose)) {
1662
+ console.warn(
1663
+ `[LiveDesk Hub] ${streamPurpose} capture stop was not confirmed device=${deviceId} `
1664
+ + `stream=${streamId}: ${confirmation?.error || 'capture-stop-unconfirmed'}. `
1665
+ + 'Disconnecting the Agent so its final capture drain runs before reconnect.'
1666
+ );
1667
+ remoteHub.disconnectDevice(deviceId, 'frame-capture-stop-unconfirmed');
1668
+ }
1669
+ }).catch(error => {
1670
+ if (!hasOtherFrameStreamOwner(null, deviceId, streamId, streamPurpose)) {
1671
+ console.warn(`[LiveDesk Hub] ${streamPurpose} capture stop confirmation failed device=${deviceId}: ${error instanceof Error ? error.message : String(error)}`);
1672
+ remoteHub.disconnectDevice(deviceId, 'frame-capture-stop-confirmation-failed');
1673
+ }
1674
+ });
1675
+ } else if (result?.ok && !result.alreadyStopped) {
1676
+ console.log(`[LiveDesk Hub] queued ${streamPurpose} capture stop after frame client closed device=${deviceId} stream=${streamId}`);
1677
+ }
1658
1678
  }, frameStreamStopGraceMs);
1659
1679
  pendingFrameStreamStops.set(key, { timer });
1660
1680
  }
@@ -2003,10 +2023,19 @@ function restartFrameSubscriptionLive(ws, payload = {}) {
2003
2023
  }
2004
2024
  }
2005
2025
 
2006
- function updateAudioSubscription(ws, payload = {}) {
2007
- const deviceIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId);
2008
- ws.liveDeskAudioDeviceIds = new Set(deviceIds);
2009
- sendJson(ws, {
2026
+ function updateAudioSubscription(ws, payload = {}) {
2027
+ const previousDeviceIds = ws.liveDeskAudioDeviceIds instanceof Set
2028
+ ? new Set(ws.liveDeskAudioDeviceIds)
2029
+ : null;
2030
+ const deviceIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId);
2031
+ ws.liveDeskAudioDeviceIds = new Set(deviceIds);
2032
+ if (previousDeviceIds) {
2033
+ const releasedDeviceIds = previousDeviceIds.size === 0
2034
+ ? activeRemoteAudioDeviceIds().filter(deviceId => !ws.liveDeskAudioDeviceIds.has(deviceId))
2035
+ : [...previousDeviceIds].filter(deviceId => !ws.liveDeskAudioDeviceIds.has(deviceId));
2036
+ stopUnsubscribedAudioDevices(releasedDeviceIds, 'audio-subscription-changed');
2037
+ }
2038
+ sendJson(ws, {
2010
2039
  type: 'RemoteAudioSubscription',
2011
2040
  timestamp: new Date().toISOString(),
2012
2041
  deviceIds
@@ -2469,6 +2498,90 @@ function startMode4AtlasInput(ws, deviceId, reason = 'atlas-configure', { forceR
2469
2498
  restartReason: reason
2470
2499
  });
2471
2500
  }
2501
+
2502
+ function activeRemoteAudioDeviceIds() {
2503
+ return remoteHub.listDevices({ includeDataUrl: false })
2504
+ .filter(device => device?.activeAudioStream?.active === true)
2505
+ .map(device => String(device.deviceId || '').trim())
2506
+ .filter(Boolean);
2507
+ }
2508
+
2509
+ function hasRemoteAudioSubscriber(deviceId) {
2510
+ for (const client of audioClients) {
2511
+ if (client.readyState !== 1) {
2512
+ continue;
2513
+ }
2514
+ const subscribedDeviceIds = client.liveDeskAudioDeviceIds;
2515
+ if (!(subscribedDeviceIds instanceof Set)
2516
+ || subscribedDeviceIds.size === 0
2517
+ || subscribedDeviceIds.has(deviceId)) {
2518
+ return true;
2519
+ }
2520
+ }
2521
+ return false;
2522
+ }
2523
+
2524
+ function stopUnsubscribedAudioDevices(deviceIds, reason) {
2525
+ const activeDevices = new Map(
2526
+ remoteHub.listDevices({ includeDataUrl: false })
2527
+ .filter(device => device?.activeAudioStream?.active === true)
2528
+ .map(device => [String(device.deviceId || '').trim(), device])
2529
+ );
2530
+ for (const deviceId of new Set(normalizeDeviceIds(deviceIds))) {
2531
+ if (!deviceId || hasRemoteAudioSubscriber(deviceId)) {
2532
+ continue;
2533
+ }
2534
+ const device = activeDevices.get(deviceId);
2535
+ if (!device) {
2536
+ continue;
2537
+ }
2538
+ const result = remoteHub.stopAudioStream(deviceId, {
2539
+ streamId: String(device.activeAudioStream?.streamId || ''),
2540
+ reason
2541
+ });
2542
+ if (result?.stopPromise) {
2543
+ void result.stopPromise.then(confirmation => {
2544
+ if (confirmation?.captureStopConfirmed === true) {
2545
+ return;
2546
+ }
2547
+ if (!hasRemoteAudioSubscriber(deviceId)) {
2548
+ console.warn(
2549
+ `[LiveDesk Hub] audio capture stop was not confirmed device=${deviceId} reason=${reason}: `
2550
+ + `${confirmation?.error || 'audio-stop-unconfirmed'}. Disconnecting the Agent for a final drain.`
2551
+ );
2552
+ remoteHub.disconnectDevice(deviceId, 'audio-capture-stop-unconfirmed');
2553
+ }
2554
+ }).catch(error => {
2555
+ if (!hasRemoteAudioSubscriber(deviceId)) {
2556
+ console.warn(`[LiveDesk Hub] audio capture stop confirmation failed device=${deviceId}: ${error instanceof Error ? error.message : String(error)}`);
2557
+ remoteHub.disconnectDevice(deviceId, 'audio-capture-stop-confirmation-failed');
2558
+ }
2559
+ });
2560
+ }
2561
+ if (result?.ok === false
2562
+ && !['device-not-found', 'device-not-connected', 'device-audio-unavailable'].includes(String(result.error || ''))) {
2563
+ console.warn(`[LiveDesk Hub] Could not stop unused remote audio capture device=${deviceId} reason=${reason}: ${result.error || 'unknown-error'}`);
2564
+ }
2565
+ }
2566
+ }
2567
+
2568
+ function releaseAudioClient(ws, reason = 'audio-subscriber-closed') {
2569
+ if (!ws
2570
+ || (!audioClients.has(ws) && !ws.liveDeskAudioClientId)
2571
+ || ws.liveDeskAudioCleanupComplete === true) {
2572
+ return;
2573
+ }
2574
+ ws.liveDeskAudioCleanupComplete = true;
2575
+ const subscribedDeviceIds = ws.liveDeskAudioDeviceIds instanceof Set
2576
+ ? ws.liveDeskAudioDeviceIds
2577
+ : new Set();
2578
+ const releasedDeviceIds = subscribedDeviceIds.size === 0
2579
+ ? activeRemoteAudioDeviceIds()
2580
+ : [...subscribedDeviceIds];
2581
+ audioClients.delete(ws);
2582
+ ws.liveDeskAudioDeviceIds = new Set();
2583
+ stopUnsubscribedAudioDevices(releasedDeviceIds, reason);
2584
+ }
2472
2585
 
2473
2586
  function hasOtherAtlasInputOwner(ws, deviceId) {
2474
2587
  for (const candidate of atlasClients.keys()) {
@@ -4087,9 +4200,32 @@ app.post('/api/remote/devices/:deviceId/live/start', requireHubFeatureAccess, (r
4087
4200
  res.json(remoteHub.startLiveStream(req.params.deviceId, req.body || {}));
4088
4201
  });
4089
4202
 
4090
- app.post('/api/remote/devices/:deviceId/live/stop', (req, res) => {
4203
+ async function resolveConfirmedMediaStop(result, kind) {
4204
+ if (!result?.stopPromise) {
4205
+ return result;
4206
+ }
4207
+ const confirmation = await result.stopPromise;
4208
+ const confirmed = confirmation?.captureStopConfirmed === true;
4209
+ return {
4210
+ ...result,
4211
+ ok: confirmed,
4212
+ captureStopConfirmed: confirmed,
4213
+ stopPending: false,
4214
+ stopError: confirmed ? '' : String(confirmation?.error || `${kind}-capture-stop-unconfirmed`),
4215
+ error: confirmed ? undefined : `${kind.toUpperCase()}_CAPTURE_STOP_UNCONFIRMED`
4216
+ };
4217
+ }
4218
+
4219
+ app.post('/api/remote/devices/:deviceId/live/stop', async (req, res, next) => {
4091
4220
  noStore(res);
4092
- res.json(remoteHub.stopLiveStream(req.params.deviceId, req.body || {}));
4221
+ try {
4222
+ res.json(await resolveConfirmedMediaStop(
4223
+ remoteHub.stopLiveStream(req.params.deviceId, req.body || {}),
4224
+ 'video'
4225
+ ));
4226
+ } catch (error) {
4227
+ next(error);
4228
+ }
4093
4229
  });
4094
4230
 
4095
4231
  app.post('/api/remote/devices/:deviceId/live/pause', async (req, res, next) => {
@@ -4111,10 +4247,17 @@ app.post('/api/remote/devices/:deviceId/audio/start', requireHubFeatureAccess, (
4111
4247
  res.json(remoteHub.startAudioStream(req.params.deviceId, req.body || {}));
4112
4248
  });
4113
4249
 
4114
- app.post('/api/remote/devices/:deviceId/audio/stop', (req, res) => {
4115
- noStore(res);
4116
- res.json(remoteHub.stopAudioStream(req.params.deviceId, req.body || {}));
4117
- });
4250
+ app.post('/api/remote/devices/:deviceId/audio/stop', async (req, res, next) => {
4251
+ noStore(res);
4252
+ try {
4253
+ res.json(await resolveConfirmedMediaStop(
4254
+ remoteHub.stopAudioStream(req.params.deviceId, req.body || {}),
4255
+ 'audio'
4256
+ ));
4257
+ } catch (error) {
4258
+ next(error);
4259
+ }
4260
+ });
4118
4261
 
4119
4262
  if (existsSync(webIndexPath)) {
4120
4263
  app.use(express.static(webDistPath, {
@@ -4241,9 +4384,10 @@ atlasWss.on('connection', ws => {
4241
4384
  });
4242
4385
  });
4243
4386
 
4244
- audioWss.on('connection', (ws, req) => {
4245
- audioClients.add(ws);
4246
- ws.liveDeskAudioClientId = `raws-${++audioClientSeq}`;
4387
+ audioWss.on('connection', (ws, req) => {
4388
+ audioClients.add(ws);
4389
+ ws.liveDeskAudioClientId = `raws-${++audioClientSeq}`;
4390
+ ws.liveDeskAudioCleanupComplete = false;
4247
4391
  try {
4248
4392
  const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
4249
4393
  updateAudioSubscription(ws, { deviceIds: parsed.searchParams.get('devices') || '' });
@@ -4260,8 +4404,9 @@ audioWss.on('connection', (ws, req) => {
4260
4404
  sendJson(ws, { type: 'RemoteAudioSubscriptionError', error: 'invalid-message' });
4261
4405
  }
4262
4406
  });
4263
- ws.on('close', () => audioClients.delete(ws));
4264
- ws.on('error', () => audioClients.delete(ws));
4407
+ const cleanup = () => releaseAudioClient(ws);
4408
+ ws.on('close', cleanup);
4409
+ ws.on('error', cleanup);
4265
4410
  sendJson(ws, {
4266
4411
  type: 'RemoteAudioSocketReady',
4267
4412
  protocol: 'livedesk.remote.audio.binary.v1',