@apocaliss92/nodelink-js 0.4.5 → 0.4.7

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/dist/index.d.cts CHANGED
@@ -2434,6 +2434,7 @@ declare class BcUdpStream extends EventEmitter<{
2434
2434
  private resendTimer;
2435
2435
  private hbTimer;
2436
2436
  private discoveryTid;
2437
+ private discoveryTimers;
2437
2438
  private acceptSent;
2438
2439
  private lastAcceptAtMs;
2439
2440
  private ackScheduled;
@@ -3655,6 +3656,15 @@ declare class BaichuanVideoStream extends EventEmitter<{
3655
3656
  private lastPpsH265;
3656
3657
  private lastPrependedParamSetsH265;
3657
3658
  private aesStreamDecryptor;
3659
+ /**
3660
+ * Pending startup error stashed when emitSafeError is called before any
3661
+ * "error" listener is registered (e.g. camera returns 400 during start()).
3662
+ * The rfc4571-server's waitForKeyframe can consume this immediately instead
3663
+ * of waiting for the full keyframe timeout.
3664
+ */
3665
+ private _pendingStartupError;
3666
+ /** Consume and clear any pending startup error. */
3667
+ consumePendingStartupError(): Error | undefined;
3658
3668
  private emitSafeError;
3659
3669
  private lastMediaAtMs;
3660
3670
  private watchdogTimer;
@@ -4229,6 +4239,13 @@ declare class ReolinkBaichuanApi {
4229
4239
  * - "replay:XXX" - dedicated per replay session
4230
4240
  */
4231
4241
  private readonly socketPool;
4242
+ /**
4243
+ * Consecutive stream-start (cmdId=3) timeout counter per socket tag.
4244
+ * When a streaming socket has N consecutive timeouts, the socket is force-closed
4245
+ * so the next attempt creates a fresh connection. Resets on success.
4246
+ */
4247
+ private readonly consecutiveStreamTimeouts;
4248
+ private static readonly MAX_CONSECUTIVE_STREAM_TIMEOUTS;
4232
4249
  /** BaichuanClientOptions to use when creating new sockets */
4233
4250
  private readonly clientOptions;
4234
4251
  /**
@@ -4373,6 +4390,14 @@ declare class ReolinkBaichuanApi {
4373
4390
  */
4374
4391
  private readonly deviceCapabilitiesCache;
4375
4392
  private static readonly CAPABILITIES_CACHE_TTL_MS;
4393
+ /**
4394
+ * Dedupe key for battery push events (cmd_id 252), per channel.
4395
+ * Cameras emit BatteryInfoList frequently while streaming (every few
4396
+ * seconds). We only forward an event when the meaningful fields change
4397
+ * (percent, chargeStatus, adapterStatus) to avoid flooding SSE/MQTT
4398
+ * consumers and the UI event log.
4399
+ */
4400
+ private readonly lastBatteryPushKey;
4376
4401
  /** Keep replay/streaming sockets warm briefly to reduce clip switch latency. */
4377
4402
  private static readonly SOCKET_POOL_KEEPALIVE_MS;
4378
4403
  /**
@@ -4505,6 +4530,18 @@ declare class ReolinkBaichuanApi {
4505
4530
  * which indicates subStream (e.g., RecS03_, RecS_).
4506
4531
  */
4507
4532
  private determineStreamTypeFromFileName;
4533
+ /**
4534
+ * Stream profiles that the device explicitly rejected (response_code 400).
4535
+ * Keyed by `"ch:profile"` (e.g. `"0:ext"`). Once a profile is in this set
4536
+ * it is excluded from `buildVideoStreamOptions()` results and no further
4537
+ * start attempts are made until the API instance is recreated.
4538
+ */
4539
+ private readonly _rejectedStreamProfiles;
4540
+ /**
4541
+ * Check whether a stream profile was rejected by the device at runtime
4542
+ * (e.g. ext returned response_code 400).
4543
+ */
4544
+ isStreamProfileRejected(channel: number, profile: StreamProfile): boolean;
4508
4545
  /**
4509
4546
  * Cache for buildVideoStreamOptions.
4510
4547
  *
@@ -5700,6 +5737,22 @@ declare class ReolinkBaichuanApi {
5700
5737
  * 2. **Storm**: ≥3 D2C_DISCs within 60 s triggers extended cooldown (120 s).
5701
5738
  */
5702
5739
  private notifyD2cDisc;
5740
+ /**
5741
+ * Find the socket pool tag for a given BaichuanClient instance.
5742
+ * Returns undefined if the client is not in the pool (e.g. it's the general socket used directly).
5743
+ */
5744
+ private findSocketTagForClient;
5745
+ /**
5746
+ * Reset the consecutive stream-start timeout counter for a streaming socket.
5747
+ * Called on successful stream start.
5748
+ */
5749
+ private resetStreamTimeoutCounter;
5750
+ /**
5751
+ * Track a stream-start timeout on a streaming socket.
5752
+ * After MAX_CONSECUTIVE_STREAM_TIMEOUTS consecutive timeouts, force-close the
5753
+ * socket so the next attempt creates a fresh connection.
5754
+ */
5755
+ private trackStreamTimeout;
5703
5756
  /**
5704
5757
  * Best-effort sleeping inference for battery/BCUDP cameras.
5705
5758
  *
@@ -8024,6 +8077,13 @@ interface Go2rtcTcpServerOptions {
8024
8077
  prebufferMs?: number;
8025
8078
  /** Maximum write buffer per client before dropping the connection (default: 100 MB). */
8026
8079
  maxBufferBytes?: number;
8080
+ /**
8081
+ * Stream inactivity timeout in ms. If no frames arrive from the native
8082
+ * stream for this duration, the stream is considered dead and will be
8083
+ * torn down + restarted (similar to go2rtc's per-packet read deadline).
8084
+ * Default: 15 000 (15s). Set to 0 to disable.
8085
+ */
8086
+ streamTimeoutMs?: number;
8027
8087
  /**
8028
8088
  * When true, the native camera stream is started immediately on start()
8029
8089
  * rather than waiting for the first TCP client. This ensures frames are
@@ -8053,6 +8113,7 @@ declare class Go2rtcTcpServer extends EventEmitter<{
8053
8113
  private readonly gracePeriodMs;
8054
8114
  private readonly prebufferMaxMs;
8055
8115
  private readonly maxBufferBytes;
8116
+ private readonly streamTimeoutMs;
8056
8117
  private readonly prestartStream;
8057
8118
  private active;
8058
8119
  private server;
@@ -8064,6 +8125,10 @@ declare class Go2rtcTcpServer extends EventEmitter<{
8064
8125
  private connectedClients;
8065
8126
  private clientSockets;
8066
8127
  private stopGraceTimer;
8128
+ private lastFrameAt;
8129
+ private streamHealthTimer;
8130
+ private totalFramesReceived;
8131
+ private totalVideoFramesWritten;
8067
8132
  private prebuffer;
8068
8133
  constructor(options: Go2rtcTcpServerOptions);
8069
8134
  /** Start listening. Resolves once the TCP server is bound. */
@@ -8090,6 +8155,8 @@ declare class Go2rtcTcpServer extends EventEmitter<{
8090
8155
  private static splitAnnexBNals;
8091
8156
  private startNativeStream;
8092
8157
  private stopNativeStream;
8158
+ private startStreamHealthMonitor;
8159
+ private stopStreamHealthMonitor;
8093
8160
  private removeClient;
8094
8161
  private scheduleStop;
8095
8162
  }
package/dist/index.d.ts CHANGED
@@ -1647,6 +1647,15 @@ export declare class BaichuanVideoStream extends EventEmitter<{
1647
1647
  private lastPpsH265;
1648
1648
  private lastPrependedParamSetsH265;
1649
1649
  private aesStreamDecryptor;
1650
+ /**
1651
+ * Pending startup error stashed when emitSafeError is called before any
1652
+ * "error" listener is registered (e.g. camera returns 400 during start()).
1653
+ * The rfc4571-server's waitForKeyframe can consume this immediately instead
1654
+ * of waiting for the full keyframe timeout.
1655
+ */
1656
+ private _pendingStartupError;
1657
+ /** Consume and clear any pending startup error. */
1658
+ consumePendingStartupError(): Error | undefined;
1650
1659
  private emitSafeError;
1651
1660
  private lastMediaAtMs;
1652
1661
  private watchdogTimer;
@@ -2337,6 +2346,7 @@ export declare class BcUdpStream extends EventEmitter<{
2337
2346
  private resendTimer;
2338
2347
  private hbTimer;
2339
2348
  private discoveryTid;
2349
+ private discoveryTimers;
2340
2350
  private acceptSent;
2341
2351
  private lastAcceptAtMs;
2342
2352
  private ackScheduled;
@@ -4190,6 +4200,7 @@ export declare class Go2rtcTcpServer extends EventEmitter<{
4190
4200
  private readonly gracePeriodMs;
4191
4201
  private readonly prebufferMaxMs;
4192
4202
  private readonly maxBufferBytes;
4203
+ private readonly streamTimeoutMs;
4193
4204
  private readonly prestartStream;
4194
4205
  private active;
4195
4206
  private server;
@@ -4201,6 +4212,10 @@ export declare class Go2rtcTcpServer extends EventEmitter<{
4201
4212
  private connectedClients;
4202
4213
  private clientSockets;
4203
4214
  private stopGraceTimer;
4215
+ private lastFrameAt;
4216
+ private streamHealthTimer;
4217
+ private totalFramesReceived;
4218
+ private totalVideoFramesWritten;
4204
4219
  private prebuffer;
4205
4220
  constructor(options: Go2rtcTcpServerOptions);
4206
4221
  /** Start listening. Resolves once the TCP server is bound. */
@@ -4227,6 +4242,8 @@ export declare class Go2rtcTcpServer extends EventEmitter<{
4227
4242
  private static splitAnnexBNals;
4228
4243
  private startNativeStream;
4229
4244
  private stopNativeStream;
4245
+ private startStreamHealthMonitor;
4246
+ private stopStreamHealthMonitor;
4230
4247
  private removeClient;
4231
4248
  private scheduleStop;
4232
4249
  }
@@ -4258,6 +4275,13 @@ export declare interface Go2rtcTcpServerOptions {
4258
4275
  prebufferMs?: number;
4259
4276
  /** Maximum write buffer per client before dropping the connection (default: 100 MB). */
4260
4277
  maxBufferBytes?: number;
4278
+ /**
4279
+ * Stream inactivity timeout in ms. If no frames arrive from the native
4280
+ * stream for this duration, the stream is considered dead and will be
4281
+ * torn down + restarted (similar to go2rtc's per-packet read deadline).
4282
+ * Default: 15 000 (15s). Set to 0 to disable.
4283
+ */
4284
+ streamTimeoutMs?: number;
4261
4285
  /**
4262
4286
  * When true, the native camera stream is started immediately on start()
4263
4287
  * rather than waiting for the first TCP client. This ensures frames are
@@ -5072,6 +5096,13 @@ export declare class ReolinkBaichuanApi {
5072
5096
  * - "replay:XXX" - dedicated per replay session
5073
5097
  */
5074
5098
  private readonly socketPool;
5099
+ /**
5100
+ * Consecutive stream-start (cmdId=3) timeout counter per socket tag.
5101
+ * When a streaming socket has N consecutive timeouts, the socket is force-closed
5102
+ * so the next attempt creates a fresh connection. Resets on success.
5103
+ */
5104
+ private readonly consecutiveStreamTimeouts;
5105
+ private static readonly MAX_CONSECUTIVE_STREAM_TIMEOUTS;
5075
5106
  /** BaichuanClientOptions to use when creating new sockets */
5076
5107
  private readonly clientOptions;
5077
5108
  /**
@@ -5216,6 +5247,14 @@ export declare class ReolinkBaichuanApi {
5216
5247
  */
5217
5248
  private readonly deviceCapabilitiesCache;
5218
5249
  private static readonly CAPABILITIES_CACHE_TTL_MS;
5250
+ /**
5251
+ * Dedupe key for battery push events (cmd_id 252), per channel.
5252
+ * Cameras emit BatteryInfoList frequently while streaming (every few
5253
+ * seconds). We only forward an event when the meaningful fields change
5254
+ * (percent, chargeStatus, adapterStatus) to avoid flooding SSE/MQTT
5255
+ * consumers and the UI event log.
5256
+ */
5257
+ private readonly lastBatteryPushKey;
5219
5258
  /** Keep replay/streaming sockets warm briefly to reduce clip switch latency. */
5220
5259
  private static readonly SOCKET_POOL_KEEPALIVE_MS;
5221
5260
  /**
@@ -5348,6 +5387,18 @@ export declare class ReolinkBaichuanApi {
5348
5387
  * which indicates subStream (e.g., RecS03_, RecS_).
5349
5388
  */
5350
5389
  private determineStreamTypeFromFileName;
5390
+ /**
5391
+ * Stream profiles that the device explicitly rejected (response_code 400).
5392
+ * Keyed by `"ch:profile"` (e.g. `"0:ext"`). Once a profile is in this set
5393
+ * it is excluded from `buildVideoStreamOptions()` results and no further
5394
+ * start attempts are made until the API instance is recreated.
5395
+ */
5396
+ private readonly _rejectedStreamProfiles;
5397
+ /**
5398
+ * Check whether a stream profile was rejected by the device at runtime
5399
+ * (e.g. ext returned response_code 400).
5400
+ */
5401
+ isStreamProfileRejected(channel: number, profile: StreamProfile): boolean;
5351
5402
  /**
5352
5403
  * Cache for buildVideoStreamOptions.
5353
5404
  *
@@ -6543,6 +6594,22 @@ export declare class ReolinkBaichuanApi {
6543
6594
  * 2. **Storm**: ≥3 D2C_DISCs within 60 s triggers extended cooldown (120 s).
6544
6595
  */
6545
6596
  private notifyD2cDisc;
6597
+ /**
6598
+ * Find the socket pool tag for a given BaichuanClient instance.
6599
+ * Returns undefined if the client is not in the pool (e.g. it's the general socket used directly).
6600
+ */
6601
+ private findSocketTagForClient;
6602
+ /**
6603
+ * Reset the consecutive stream-start timeout counter for a streaming socket.
6604
+ * Called on successful stream start.
6605
+ */
6606
+ private resetStreamTimeoutCounter;
6607
+ /**
6608
+ * Track a stream-start timeout on a streaming socket.
6609
+ * After MAX_CONSECUTIVE_STREAM_TIMEOUTS consecutive timeouts, force-close the
6610
+ * socket so the next attempt creates a fresh connection.
6611
+ */
6612
+ private trackStreamTimeout;
6546
6613
  /**
6547
6614
  * Best-effort sleeping inference for battery/BCUDP cameras.
6548
6615
  *
package/dist/index.js CHANGED
@@ -43,7 +43,7 @@ import {
43
43
  parseSupportXml,
44
44
  setGlobalLogger,
45
45
  xmlIndicatesFloodlight
46
- } from "./chunk-WDFKIHM5.js";
46
+ } from "./chunk-GKLOJJ34.js";
47
47
  import {
48
48
  AesStreamDecryptor,
49
49
  BC_AES_IV,
@@ -223,7 +223,7 @@ import {
223
223
  testChannelStreams,
224
224
  xmlEscape,
225
225
  zipDirectory
226
- } from "./chunk-DEOMUWBN.js";
226
+ } from "./chunk-TR3V5FTO.js";
227
227
 
228
228
  // src/reolink/baichuan/HlsSessionManager.ts
229
229
  var withTimeout = async (p, ms, label) => {
@@ -3466,6 +3466,11 @@ async function createRfc4571TcpServerInternal(options) {
3466
3466
  "videoAccessUnit",
3467
3467
  onAu
3468
3468
  );
3469
+ const pendingErr = videoStream.consumePendingStartupError?.();
3470
+ if (pendingErr) {
3471
+ cleanup();
3472
+ reject(pendingErr);
3473
+ }
3469
3474
  });
3470
3475
  }
3471
3476
  };
@@ -3477,24 +3482,32 @@ async function createRfc4571TcpServerInternal(options) {
3477
3482
  await videoStream.stop();
3478
3483
  } catch {
3479
3484
  }
3480
- if (closeApiOnTeardown) {
3481
- await Promise.allSettled(
3482
- Array.from(apisToClose).map(async (a) => {
3485
+ if (dedicatedSession) {
3486
+ try {
3487
+ await dedicatedSession.release();
3488
+ } catch {
3489
+ }
3490
+ }
3491
+ if (!dedicatedSession) {
3492
+ if (closeApiOnTeardown) {
3493
+ await Promise.allSettled(
3494
+ Array.from(apisToClose).map(async (a) => {
3495
+ try {
3496
+ await a.close();
3497
+ } catch {
3498
+ }
3499
+ })
3500
+ );
3501
+ } else {
3502
+ const graceMs = isComposite ? 5e3 : 0;
3503
+ for (const a of Array.from(apisToClose)) {
3483
3504
  try {
3484
- await a.close();
3505
+ a?.client?.requestIdleDisconnectSoon?.(
3506
+ "rfc4571_teardown",
3507
+ graceMs
3508
+ );
3485
3509
  } catch {
3486
3510
  }
3487
- })
3488
- );
3489
- } else {
3490
- const graceMs = isComposite ? 5e3 : 0;
3491
- for (const a of Array.from(apisToClose)) {
3492
- try {
3493
- a?.client?.requestIdleDisconnectSoon?.(
3494
- "rfc4571_teardown",
3495
- graceMs
3496
- );
3497
- } catch {
3498
3511
  }
3499
3512
  }
3500
3513
  }
@@ -3750,7 +3763,7 @@ async function createRfc4571TcpServerInternal(options) {
3750
3763
  } catch {
3751
3764
  }
3752
3765
  }
3753
- if (closeApiOnTeardown) {
3766
+ if (closeApiOnTeardown && !dedicatedSession) {
3754
3767
  await Promise.allSettled(
3755
3768
  Array.from(apisToClose).map(async (a) => {
3756
3769
  try {
@@ -4712,6 +4725,7 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends EventEmitter2 {
4712
4725
  gracePeriodMs;
4713
4726
  prebufferMaxMs;
4714
4727
  maxBufferBytes;
4728
+ streamTimeoutMs;
4715
4729
  prestartStream;
4716
4730
  active = false;
4717
4731
  server;
@@ -4725,6 +4739,11 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends EventEmitter2 {
4725
4739
  connectedClients = /* @__PURE__ */ new Set();
4726
4740
  clientSockets = /* @__PURE__ */ new Map();
4727
4741
  stopGraceTimer;
4742
+ // Stream health monitoring
4743
+ lastFrameAt = 0;
4744
+ streamHealthTimer;
4745
+ totalFramesReceived = 0;
4746
+ totalVideoFramesWritten = 0;
4728
4747
  // Prebuffer
4729
4748
  prebuffer = [];
4730
4749
  constructor(options) {
@@ -4740,6 +4759,7 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends EventEmitter2 {
4740
4759
  this.gracePeriodMs = options.gracePeriodMs ?? 3e4;
4741
4760
  this.prebufferMaxMs = options.prebufferMs ?? 3e3;
4742
4761
  this.maxBufferBytes = options.maxBufferBytes ?? 1e8;
4762
+ this.streamTimeoutMs = options.streamTimeoutMs ?? 15e3;
4743
4763
  this.prestartStream = options.prestartStream ?? true;
4744
4764
  }
4745
4765
  // -----------------------------------------------------------------------
@@ -4778,6 +4798,7 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends EventEmitter2 {
4778
4798
  if (!this.active) return;
4779
4799
  this.active = false;
4780
4800
  clearTimeout(this.stopGraceTimer);
4801
+ this.stopStreamHealthMonitor();
4781
4802
  for (const [id, sock] of this.clientSockets) {
4782
4803
  sock.destroy();
4783
4804
  this.connectedClients.delete(id);
@@ -4831,12 +4852,12 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends EventEmitter2 {
4831
4852
  `[Go2rtcTcpServer] feedClient error id=${clientId}: ${err}`
4832
4853
  );
4833
4854
  });
4834
- const cleanup = () => {
4835
- this.removeClient(clientId);
4855
+ const cleanup = (reason) => {
4856
+ this.removeClient(clientId, reason);
4836
4857
  socket.destroy();
4837
4858
  };
4838
- socket.on("error", cleanup);
4839
- socket.on("close", cleanup);
4859
+ socket.on("error", (err) => cleanup(`error: ${err.message}`));
4860
+ socket.on("close", (hadError) => cleanup(hadError ? "close (with error)" : "close (clean)"));
4840
4861
  }
4841
4862
  async feedClient(clientId, socket) {
4842
4863
  const fanoutDeadline = Date.now() + 3e4;
@@ -4897,6 +4918,7 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends EventEmitter2 {
4897
4918
  }
4898
4919
  socket.write(annexB);
4899
4920
  liveVideoWritten++;
4921
+ this.totalVideoFramesWritten++;
4900
4922
  if (Date.now() - lastLogAt > 1e4) {
4901
4923
  this.logger.info?.(
4902
4924
  `[Go2rtcTcpServer] live stats client=${clientId} received=${liveFrameCount} written=${liveVideoWritten} bufLen=${socket.writableLength}`
@@ -5023,6 +5045,8 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends EventEmitter2 {
5023
5045
  ...dedicatedClient ? { client: dedicatedClient } : {}
5024
5046
  }),
5025
5047
  onFrame: (frame) => {
5048
+ this.lastFrameAt = Date.now();
5049
+ this.totalFramesReceived++;
5026
5050
  if (!frame.audio && (frame.videoType === "H264" || frame.videoType === "H265")) {
5027
5051
  this.detectedVideoType = frame.videoType;
5028
5052
  }
@@ -5049,6 +5073,12 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends EventEmitter2 {
5049
5073
  if (!this.nativeStreamActive) return;
5050
5074
  this.nativeStreamActive = false;
5051
5075
  this.nativeFanout = null;
5076
+ this.stopStreamHealthMonitor();
5077
+ const silenceMs = this.lastFrameAt > 0 ? Date.now() - this.lastFrameAt : -1;
5078
+ const diagnosis = silenceMs > this.streamTimeoutMs ? "camera stopped sending frames" : silenceMs >= 0 ? "stream source closed" : "no frames were ever received";
5079
+ this.logger.warn?.(
5080
+ `[Go2rtcTcpServer] native stream ended diagnosis="${diagnosis}" lastFrame=${silenceMs >= 0 ? `${(silenceMs / 1e3).toFixed(1)}s ago` : "never"} totalRx=${this.totalFramesReceived} clients=${this.connectedClients.size}`
5081
+ );
5052
5082
  if (this.dedicatedSessionRelease) {
5053
5083
  this.dedicatedSessionRelease().catch(() => {
5054
5084
  });
@@ -5056,16 +5086,18 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends EventEmitter2 {
5056
5086
  }
5057
5087
  if (this.active && (this.connectedClients.size > 0 || this.prestartStream)) {
5058
5088
  this.logger.info?.(
5059
- `[Go2rtcTcpServer] native stream ended, restarting (clients=${this.connectedClients.size}, prestart=${this.prestartStream})`
5089
+ `[Go2rtcTcpServer] restarting native stream (clients=${this.connectedClients.size}, prestart=${this.prestartStream})`
5060
5090
  );
5061
5091
  this.startNativeStream();
5062
5092
  }
5063
5093
  }
5064
5094
  });
5065
5095
  this.nativeFanout.start();
5096
+ this.startStreamHealthMonitor();
5066
5097
  }
5067
5098
  async stopNativeStream() {
5068
5099
  this.nativeStreamActive = false;
5100
+ this.stopStreamHealthMonitor();
5069
5101
  const fanout = this.nativeFanout;
5070
5102
  this.nativeFanout = null;
5071
5103
  if (fanout) {
@@ -5079,14 +5111,50 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends EventEmitter2 {
5079
5111
  }
5080
5112
  }
5081
5113
  // -----------------------------------------------------------------------
5114
+ // Stream health monitoring
5115
+ // -----------------------------------------------------------------------
5116
+ startStreamHealthMonitor() {
5117
+ this.stopStreamHealthMonitor();
5118
+ if (this.streamTimeoutMs <= 0) return;
5119
+ this.lastFrameAt = Date.now();
5120
+ this.streamHealthTimer = setInterval(() => {
5121
+ if (!this.nativeStreamActive || !this.active) {
5122
+ this.stopStreamHealthMonitor();
5123
+ return;
5124
+ }
5125
+ const silenceMs = Date.now() - this.lastFrameAt;
5126
+ if (silenceMs > this.streamTimeoutMs) {
5127
+ this.logger.warn?.(
5128
+ `[Go2rtcTcpServer] stream inactivity timeout: no frames for ${(silenceMs / 1e3).toFixed(1)}s (threshold=${this.streamTimeoutMs}ms), totalReceived=${this.totalFramesReceived} clients=${this.connectedClients.size} \u2014 forcing stream restart`
5129
+ );
5130
+ this.stopStreamHealthMonitor();
5131
+ const fanout = this.nativeFanout;
5132
+ if (fanout) {
5133
+ this.nativeStreamActive = false;
5134
+ this.nativeFanout = null;
5135
+ fanout.stop().catch(() => {
5136
+ });
5137
+ }
5138
+ }
5139
+ }, Math.min(this.streamTimeoutMs / 2, 5e3));
5140
+ }
5141
+ stopStreamHealthMonitor() {
5142
+ if (this.streamHealthTimer) {
5143
+ clearInterval(this.streamHealthTimer);
5144
+ this.streamHealthTimer = void 0;
5145
+ }
5146
+ }
5147
+ // -----------------------------------------------------------------------
5082
5148
  // Client lifecycle
5083
5149
  // -----------------------------------------------------------------------
5084
- removeClient(clientId) {
5150
+ removeClient(clientId, reason) {
5085
5151
  if (!this.connectedClients.has(clientId)) return;
5086
5152
  this.connectedClients.delete(clientId);
5087
5153
  this.clientSockets.delete(clientId);
5154
+ const silenceMs = this.lastFrameAt > 0 ? Date.now() - this.lastFrameAt : -1;
5155
+ const silenceInfo = silenceMs >= 0 ? ` lastFrame=${(silenceMs / 1e3).toFixed(1)}s ago` : "";
5088
5156
  this.logger.info?.(
5089
- `[Go2rtcTcpServer] client disconnected id=${clientId} remaining=${this.connectedClients.size}`
5157
+ `[Go2rtcTcpServer] client disconnected id=${clientId} reason=${reason ?? "unknown"} remaining=${this.connectedClients.size} totalRx=${this.totalFramesReceived} totalTx=${this.totalVideoFramesWritten}${silenceInfo}`
5090
5158
  );
5091
5159
  this.emit("clientDisconnected", clientId);
5092
5160
  if (this.connectedClients.size === 0 && !this.prestartStream) {