@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.
@@ -3,10 +3,10 @@ import {
3
3
  BaichuanRtspServer,
4
4
  ReolinkBaichuanApi,
5
5
  autoDetectDeviceType
6
- } from "../chunk-WDFKIHM5.js";
6
+ } from "../chunk-GKLOJJ34.js";
7
7
  import {
8
8
  __require
9
- } from "../chunk-DEOMUWBN.js";
9
+ } from "../chunk-TR3V5FTO.js";
10
10
 
11
11
  // src/cli/rtsp-server.ts
12
12
  function parseArgs() {
package/dist/index.cjs CHANGED
@@ -2178,6 +2178,19 @@ var init_BaichuanVideoStream = __esm({
2178
2178
  // Stateful AES decryptor for fragmented BcMedia packets (full_aes mode)
2179
2179
  // In CFB mode, continuation frames must use the cipher state from previous frames.
2180
2180
  aesStreamDecryptor = null;
2181
+ /**
2182
+ * Pending startup error stashed when emitSafeError is called before any
2183
+ * "error" listener is registered (e.g. camera returns 400 during start()).
2184
+ * The rfc4571-server's waitForKeyframe can consume this immediately instead
2185
+ * of waiting for the full keyframe timeout.
2186
+ */
2187
+ _pendingStartupError;
2188
+ /** Consume and clear any pending startup error. */
2189
+ consumePendingStartupError() {
2190
+ const err = this._pendingStartupError;
2191
+ this._pendingStartupError = void 0;
2192
+ return err;
2193
+ }
2181
2194
  emitSafeError(err) {
2182
2195
  if (!this.active) {
2183
2196
  this.logger?.warn?.(
@@ -2189,6 +2202,7 @@ var init_BaichuanVideoStream = __esm({
2189
2202
  this.logger?.warn?.(
2190
2203
  `[BaichuanVideoStream] Unhandled stream error: ${err.message}`
2191
2204
  );
2205
+ this._pendingStartupError = err;
2192
2206
  return;
2193
2207
  }
2194
2208
  this.emit("error", err);
@@ -8527,6 +8541,9 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
8527
8541
  resendTimer;
8528
8542
  hbTimer;
8529
8543
  discoveryTid;
8544
+ // Track discovery-phase timers so close() can cancel them even if
8545
+ // discovery is still in progress (prevents ERR_SOCKET_DGRAM_NOT_RUNNING).
8546
+ discoveryTimers = [];
8530
8547
  acceptSent = false;
8531
8548
  lastAcceptAtMs;
8532
8549
  ackScheduled = false;
@@ -8575,9 +8592,31 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
8575
8592
  });
8576
8593
  sock.on("error", (e) => this.emit("error", e));
8577
8594
  sock.on("close", () => this.emit("close"));
8578
- await new Promise(
8579
- (resolve) => sock.bind(0, "0.0.0.0", () => resolve())
8580
- );
8595
+ const portRange = Array.from({ length: 500 }, (_, i) => 53500 + i);
8596
+ for (let i = portRange.length - 1; i > 0; i--) {
8597
+ const j = Math.floor(Math.random() * (i + 1));
8598
+ [portRange[i], portRange[j]] = [portRange[j], portRange[i]];
8599
+ }
8600
+ let bound = false;
8601
+ for (const port of portRange) {
8602
+ try {
8603
+ await new Promise((resolve, reject) => {
8604
+ sock.once("error", reject);
8605
+ sock.bind(port, "0.0.0.0", () => {
8606
+ sock.removeListener("error", reject);
8607
+ resolve();
8608
+ });
8609
+ });
8610
+ bound = true;
8611
+ break;
8612
+ } catch {
8613
+ }
8614
+ }
8615
+ if (!bound) {
8616
+ await new Promise(
8617
+ (resolve) => sock.bind(0, "0.0.0.0", () => resolve())
8618
+ );
8619
+ }
8581
8620
  if (this.opts.mode === "direct") {
8582
8621
  this.remote = { host: this.opts.host, port: this.opts.port };
8583
8622
  this.clientId = this.opts.clientId;
@@ -8997,7 +9036,24 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
8997
9036
  BCUDP_DISCOVERY_PORT_LOCAL_ANY,
8998
9037
  BCUDP_DISCOVERY_PORT_LOCAL_UID
8999
9038
  ];
9000
- const broadcastHost = "255.255.255.255";
9039
+ const broadcastHosts = ["255.255.255.255"];
9040
+ const ifaces = (0, import_node_os.networkInterfaces)();
9041
+ for (const name of Object.keys(ifaces)) {
9042
+ const entries = ifaces[name];
9043
+ if (!entries) continue;
9044
+ for (const addr2 of entries) {
9045
+ if (addr2.family === "IPv4" && !addr2.internal && addr2.cidr) {
9046
+ const ipParts = addr2.address.split(".").map(Number);
9047
+ const maskParts = addr2.netmask.split(".").map(Number);
9048
+ if (ipParts.length === 4 && maskParts.length === 4) {
9049
+ const bcast = ipParts.map((octet, i) => octet | ~maskParts[i] & 255).join(".");
9050
+ if (!broadcastHosts.includes(bcast)) {
9051
+ broadcastHosts.push(bcast);
9052
+ }
9053
+ }
9054
+ }
9055
+ }
9056
+ }
9001
9057
  const directHost = (this.opts.directHost ?? "").trim();
9002
9058
  const localMode = opts?.localMode ?? "local-broadcast";
9003
9059
  const directFirstWindowMs = localMode === "local-direct" && directHost ? 3e3 : 0;
@@ -9024,6 +9080,7 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
9024
9080
  )
9025
9081
  );
9026
9082
  }, discoveryTimeout);
9083
+ this.discoveryTimers.push(timeout);
9027
9084
  let retryTimer;
9028
9085
  let retryCount = 0;
9029
9086
  let discoveredSid;
@@ -9200,11 +9257,11 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
9200
9257
  if (directHost) {
9201
9258
  if (directFirstWindowMs > 0 && elapsedMs < directFirstWindowMs)
9202
9259
  return [directHost];
9203
- return [directHost, broadcastHost];
9260
+ return [directHost, ...broadcastHosts];
9204
9261
  }
9205
- return [broadcastHost];
9262
+ return broadcastHosts;
9206
9263
  }
9207
- return [broadcastHost];
9264
+ return broadcastHosts;
9208
9265
  })();
9209
9266
  for (const host of Array.from(new Set(hosts))) {
9210
9267
  for (const port of ports) {
@@ -9212,8 +9269,7 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
9212
9269
  sock.send(packet, port, host);
9213
9270
  retryCount++;
9214
9271
  this.emit("debug", "discovery_send", { retryCount, host, port });
9215
- } catch (e) {
9216
- this.emit("error", e instanceof Error ? e : new Error(String(e)));
9272
+ } catch {
9217
9273
  }
9218
9274
  }
9219
9275
  }
@@ -9222,6 +9278,7 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
9222
9278
  retryTimer = (0, import_node_timers.setInterval)(() => {
9223
9279
  sendDiscovery();
9224
9280
  }, retryInterval);
9281
+ this.discoveryTimers.push(retryTimer);
9225
9282
  });
9226
9283
  this.clientId = reply.cid;
9227
9284
  this.cameraId = reply.did;
@@ -9538,6 +9595,10 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
9538
9595
  this.ackTimer = void 0;
9539
9596
  this.resendTimer = void 0;
9540
9597
  this.hbTimer = void 0;
9598
+ for (const t of this.discoveryTimers) {
9599
+ clearInterval(t);
9600
+ }
9601
+ this.discoveryTimers = [];
9541
9602
  const s = this.sock;
9542
9603
  this.sock = void 0;
9543
9604
  if (!s) return;
@@ -18011,6 +18072,13 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18011
18072
  * - "replay:XXX" - dedicated per replay session
18012
18073
  */
18013
18074
  socketPool = /* @__PURE__ */ new Map();
18075
+ /**
18076
+ * Consecutive stream-start (cmdId=3) timeout counter per socket tag.
18077
+ * When a streaming socket has N consecutive timeouts, the socket is force-closed
18078
+ * so the next attempt creates a fresh connection. Resets on success.
18079
+ */
18080
+ consecutiveStreamTimeouts = /* @__PURE__ */ new Map();
18081
+ static MAX_CONSECUTIVE_STREAM_TIMEOUTS = 3;
18014
18082
  /** BaichuanClientOptions to use when creating new sockets */
18015
18083
  clientOptions;
18016
18084
  /**
@@ -18165,14 +18233,20 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18165
18233
  if (!xml) return;
18166
18234
  const channel = frame.header.channelId;
18167
18235
  const battery = this.parseBatteryInfoXml(xml, channel);
18168
- if (battery.batteryPercent !== void 0 || battery.chargeStatus !== void 0 || battery.adapterStatus !== void 0) {
18169
- this.dispatchSimpleEvent({
18170
- type: "battery",
18171
- channel,
18172
- timestamp: Date.now(),
18173
- battery
18174
- });
18236
+ if (battery.batteryPercent === void 0 && battery.chargeStatus === void 0 && battery.adapterStatus === void 0) {
18237
+ return;
18175
18238
  }
18239
+ const key = `${battery.batteryPercent ?? ""}|${battery.chargeStatus ?? ""}|${battery.adapterStatus ?? ""}`;
18240
+ if (this.lastBatteryPushKey.get(channel) === key) {
18241
+ return;
18242
+ }
18243
+ this.lastBatteryPushKey.set(channel, key);
18244
+ this.dispatchSimpleEvent({
18245
+ type: "battery",
18246
+ channel,
18247
+ timestamp: Date.now(),
18248
+ battery
18249
+ });
18176
18250
  } catch (e) {
18177
18251
  this.logger.debug?.(
18178
18252
  "[ReolinkBaichuanApi] Error parsing battery push",
@@ -18338,6 +18412,14 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18338
18412
  deviceCapabilitiesCache = /* @__PURE__ */ new Map();
18339
18413
  static CAPABILITIES_CACHE_TTL_MS = 5 * 60 * 1e3;
18340
18414
  // 5 minutes
18415
+ /**
18416
+ * Dedupe key for battery push events (cmd_id 252), per channel.
18417
+ * Cameras emit BatteryInfoList frequently while streaming (every few
18418
+ * seconds). We only forward an event when the meaningful fields change
18419
+ * (percent, chargeStatus, adapterStatus) to avoid flooding SSE/MQTT
18420
+ * consumers and the UI event log.
18421
+ */
18422
+ lastBatteryPushKey = /* @__PURE__ */ new Map();
18341
18423
  // ─────────────────────────────────────────────────────────────────────────────
18342
18424
  // SOCKET POOL CONSTANTS
18343
18425
  // ─────────────────────────────────────────────────────────────────────────────
@@ -18654,6 +18736,20 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18654
18736
  const prefix = basename.substring(0, 10).toUpperCase();
18655
18737
  return prefix.includes("S") ? "subStream" : "mainStream";
18656
18738
  }
18739
+ /**
18740
+ * Stream profiles that the device explicitly rejected (response_code 400).
18741
+ * Keyed by `"ch:profile"` (e.g. `"0:ext"`). Once a profile is in this set
18742
+ * it is excluded from `buildVideoStreamOptions()` results and no further
18743
+ * start attempts are made until the API instance is recreated.
18744
+ */
18745
+ _rejectedStreamProfiles = /* @__PURE__ */ new Set();
18746
+ /**
18747
+ * Check whether a stream profile was rejected by the device at runtime
18748
+ * (e.g. ext returned response_code 400).
18749
+ */
18750
+ isStreamProfileRejected(channel, profile) {
18751
+ return this._rejectedStreamProfiles.has(`${channel}:${profile}`);
18752
+ }
18657
18753
  /**
18658
18754
  * Cache for buildVideoStreamOptions.
18659
18755
  *
@@ -23778,6 +23874,16 @@ ${stderr}`)
23778
23874
  }
23779
23875
  if (!frame) frame = await targetClient.sendFrame(baseParams);
23780
23876
  if (frame.header.responseCode !== 200) {
23877
+ if (frame.header.responseCode === 400) {
23878
+ const rejKey = `${ch}:${profile}`;
23879
+ if (!this._rejectedStreamProfiles.has(rejKey)) {
23880
+ this._rejectedStreamProfiles.add(rejKey);
23881
+ this.videoStreamOptionsCache.clear();
23882
+ this.logger?.warn?.(
23883
+ `[ReolinkBaichuanApi] Stream profile rejected by device: channel=${ch} profile=${profile} (response_code 400). This profile will be excluded from available streams. The camera may not support this stream profile with the current firmware.`
23884
+ );
23885
+ }
23886
+ }
23781
23887
  throw new Error(
23782
23888
  `Video stream request rejected (response_code ${frame.header.responseCode}). Expected response_code 200, camera returned ${frame.header.responseCode}`
23783
23889
  );
@@ -23786,6 +23892,7 @@ ${stderr}`)
23786
23892
  `${ch}:${profile}:${variant}`,
23787
23893
  frame.header.msgNum
23788
23894
  );
23895
+ this.resetStreamTimeoutCounter(targetClient);
23789
23896
  return;
23790
23897
  } catch (error) {
23791
23898
  lastError = error;
@@ -23800,6 +23907,10 @@ ${stderr}`)
23800
23907
  }
23801
23908
  }
23802
23909
  }
23910
+ const isTimeout = lastError instanceof Error && lastError.message?.includes("timeout");
23911
+ if (isTimeout) {
23912
+ this.trackStreamTimeout(targetClient);
23913
+ }
23803
23914
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
23804
23915
  }
23805
23916
  /**
@@ -24259,6 +24370,18 @@ ${stderr}`)
24259
24370
  notifyD2cDisc() {
24260
24371
  const now = Date.now();
24261
24372
  this.lastD2cDiscAtMs = now;
24373
+ const streamingTags = Array.from(this.socketPool.keys()).filter(
24374
+ (tag) => tag.startsWith("streaming:")
24375
+ );
24376
+ if (streamingTags.length > 0) {
24377
+ this.logger?.log?.(
24378
+ `[D2C_DISC] Force-closing ${streamingTags.length} streaming socket(s): ${streamingTags.join(", ")}`
24379
+ );
24380
+ for (const tag of streamingTags) {
24381
+ this.forceClosePooledSocket(tag, this.logger).catch(() => {
24382
+ });
24383
+ }
24384
+ }
24262
24385
  const immediateCooldownUntil = now + _ReolinkBaichuanApi.D2C_DISC_IMMEDIATE_COOLDOWN_MS;
24263
24386
  const existing = this.socketPoolCooldowns.get(this.host);
24264
24387
  if (!existing || existing.cooldownUntil < immediateCooldownUntil) {
@@ -24291,6 +24414,43 @@ ${stderr}`)
24291
24414
  }
24292
24415
  }
24293
24416
  }
24417
+ /**
24418
+ * Find the socket pool tag for a given BaichuanClient instance.
24419
+ * Returns undefined if the client is not in the pool (e.g. it's the general socket used directly).
24420
+ */
24421
+ findSocketTagForClient(client) {
24422
+ for (const [tag, entry] of this.socketPool) {
24423
+ if (entry.client === client) return tag;
24424
+ }
24425
+ return void 0;
24426
+ }
24427
+ /**
24428
+ * Reset the consecutive stream-start timeout counter for a streaming socket.
24429
+ * Called on successful stream start.
24430
+ */
24431
+ resetStreamTimeoutCounter(client) {
24432
+ const tag = this.findSocketTagForClient(client);
24433
+ if (tag) this.consecutiveStreamTimeouts.delete(tag);
24434
+ }
24435
+ /**
24436
+ * Track a stream-start timeout on a streaming socket.
24437
+ * After MAX_CONSECUTIVE_STREAM_TIMEOUTS consecutive timeouts, force-close the
24438
+ * socket so the next attempt creates a fresh connection.
24439
+ */
24440
+ trackStreamTimeout(client) {
24441
+ const tag = this.findSocketTagForClient(client);
24442
+ if (!tag || !tag.startsWith("streaming:")) return;
24443
+ const count = (this.consecutiveStreamTimeouts.get(tag) ?? 0) + 1;
24444
+ this.consecutiveStreamTimeouts.set(tag, count);
24445
+ if (count >= _ReolinkBaichuanApi.MAX_CONSECUTIVE_STREAM_TIMEOUTS) {
24446
+ this.logger?.warn?.(
24447
+ `[SocketPool] ${count} consecutive stream timeouts on tag=${tag}, force-closing socket`
24448
+ );
24449
+ this.consecutiveStreamTimeouts.delete(tag);
24450
+ this.forceClosePooledSocket(tag, this.logger).catch(() => {
24451
+ });
24452
+ }
24453
+ }
24294
24454
  /**
24295
24455
  * Best-effort sleeping inference for battery/BCUDP cameras.
24296
24456
  *
@@ -25799,6 +25959,8 @@ ${xml}`
25799
25959
  for (const metadata of params.metadatas) {
25800
25960
  const profile = metadata.profile;
25801
25961
  if (isMultiFocal && profile === "ext") continue;
25962
+ if (this._rejectedStreamProfiles.has(`${params.channel}:${profile}`))
25963
+ continue;
25802
25964
  if (params.includeRtsp && profile !== "ext") {
25803
25965
  const streamName = profile === "main" ? "main" : "sub";
25804
25966
  pushRtsp({
@@ -32289,6 +32451,11 @@ async function createRfc4571TcpServerInternal(options) {
32289
32451
  "videoAccessUnit",
32290
32452
  onAu
32291
32453
  );
32454
+ const pendingErr = videoStream.consumePendingStartupError?.();
32455
+ if (pendingErr) {
32456
+ cleanup();
32457
+ reject(pendingErr);
32458
+ }
32292
32459
  });
32293
32460
  }
32294
32461
  };
@@ -32300,24 +32467,32 @@ async function createRfc4571TcpServerInternal(options) {
32300
32467
  await videoStream.stop();
32301
32468
  } catch {
32302
32469
  }
32303
- if (closeApiOnTeardown) {
32304
- await Promise.allSettled(
32305
- Array.from(apisToClose).map(async (a) => {
32470
+ if (dedicatedSession) {
32471
+ try {
32472
+ await dedicatedSession.release();
32473
+ } catch {
32474
+ }
32475
+ }
32476
+ if (!dedicatedSession) {
32477
+ if (closeApiOnTeardown) {
32478
+ await Promise.allSettled(
32479
+ Array.from(apisToClose).map(async (a) => {
32480
+ try {
32481
+ await a.close();
32482
+ } catch {
32483
+ }
32484
+ })
32485
+ );
32486
+ } else {
32487
+ const graceMs = isComposite ? 5e3 : 0;
32488
+ for (const a of Array.from(apisToClose)) {
32306
32489
  try {
32307
- await a.close();
32490
+ a?.client?.requestIdleDisconnectSoon?.(
32491
+ "rfc4571_teardown",
32492
+ graceMs
32493
+ );
32308
32494
  } catch {
32309
32495
  }
32310
- })
32311
- );
32312
- } else {
32313
- const graceMs = isComposite ? 5e3 : 0;
32314
- for (const a of Array.from(apisToClose)) {
32315
- try {
32316
- a?.client?.requestIdleDisconnectSoon?.(
32317
- "rfc4571_teardown",
32318
- graceMs
32319
- );
32320
- } catch {
32321
32496
  }
32322
32497
  }
32323
32498
  }
@@ -32573,7 +32748,7 @@ async function createRfc4571TcpServerInternal(options) {
32573
32748
  } catch {
32574
32749
  }
32575
32750
  }
32576
- if (closeApiOnTeardown) {
32751
+ if (closeApiOnTeardown && !dedicatedSession) {
32577
32752
  await Promise.allSettled(
32578
32753
  Array.from(apisToClose).map(async (a) => {
32579
32754
  try {
@@ -33540,6 +33715,7 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
33540
33715
  gracePeriodMs;
33541
33716
  prebufferMaxMs;
33542
33717
  maxBufferBytes;
33718
+ streamTimeoutMs;
33543
33719
  prestartStream;
33544
33720
  active = false;
33545
33721
  server;
@@ -33553,6 +33729,11 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
33553
33729
  connectedClients = /* @__PURE__ */ new Set();
33554
33730
  clientSockets = /* @__PURE__ */ new Map();
33555
33731
  stopGraceTimer;
33732
+ // Stream health monitoring
33733
+ lastFrameAt = 0;
33734
+ streamHealthTimer;
33735
+ totalFramesReceived = 0;
33736
+ totalVideoFramesWritten = 0;
33556
33737
  // Prebuffer
33557
33738
  prebuffer = [];
33558
33739
  constructor(options) {
@@ -33568,6 +33749,7 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
33568
33749
  this.gracePeriodMs = options.gracePeriodMs ?? 3e4;
33569
33750
  this.prebufferMaxMs = options.prebufferMs ?? 3e3;
33570
33751
  this.maxBufferBytes = options.maxBufferBytes ?? 1e8;
33752
+ this.streamTimeoutMs = options.streamTimeoutMs ?? 15e3;
33571
33753
  this.prestartStream = options.prestartStream ?? true;
33572
33754
  }
33573
33755
  // -----------------------------------------------------------------------
@@ -33606,6 +33788,7 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
33606
33788
  if (!this.active) return;
33607
33789
  this.active = false;
33608
33790
  clearTimeout(this.stopGraceTimer);
33791
+ this.stopStreamHealthMonitor();
33609
33792
  for (const [id, sock] of this.clientSockets) {
33610
33793
  sock.destroy();
33611
33794
  this.connectedClients.delete(id);
@@ -33659,12 +33842,12 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
33659
33842
  `[Go2rtcTcpServer] feedClient error id=${clientId}: ${err}`
33660
33843
  );
33661
33844
  });
33662
- const cleanup = () => {
33663
- this.removeClient(clientId);
33845
+ const cleanup = (reason) => {
33846
+ this.removeClient(clientId, reason);
33664
33847
  socket.destroy();
33665
33848
  };
33666
- socket.on("error", cleanup);
33667
- socket.on("close", cleanup);
33849
+ socket.on("error", (err) => cleanup(`error: ${err.message}`));
33850
+ socket.on("close", (hadError) => cleanup(hadError ? "close (with error)" : "close (clean)"));
33668
33851
  }
33669
33852
  async feedClient(clientId, socket) {
33670
33853
  const fanoutDeadline = Date.now() + 3e4;
@@ -33725,6 +33908,7 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
33725
33908
  }
33726
33909
  socket.write(annexB);
33727
33910
  liveVideoWritten++;
33911
+ this.totalVideoFramesWritten++;
33728
33912
  if (Date.now() - lastLogAt > 1e4) {
33729
33913
  this.logger.info?.(
33730
33914
  `[Go2rtcTcpServer] live stats client=${clientId} received=${liveFrameCount} written=${liveVideoWritten} bufLen=${socket.writableLength}`
@@ -33851,6 +34035,8 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
33851
34035
  ...dedicatedClient ? { client: dedicatedClient } : {}
33852
34036
  }),
33853
34037
  onFrame: (frame) => {
34038
+ this.lastFrameAt = Date.now();
34039
+ this.totalFramesReceived++;
33854
34040
  if (!frame.audio && (frame.videoType === "H264" || frame.videoType === "H265")) {
33855
34041
  this.detectedVideoType = frame.videoType;
33856
34042
  }
@@ -33877,6 +34063,12 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
33877
34063
  if (!this.nativeStreamActive) return;
33878
34064
  this.nativeStreamActive = false;
33879
34065
  this.nativeFanout = null;
34066
+ this.stopStreamHealthMonitor();
34067
+ const silenceMs = this.lastFrameAt > 0 ? Date.now() - this.lastFrameAt : -1;
34068
+ const diagnosis = silenceMs > this.streamTimeoutMs ? "camera stopped sending frames" : silenceMs >= 0 ? "stream source closed" : "no frames were ever received";
34069
+ this.logger.warn?.(
34070
+ `[Go2rtcTcpServer] native stream ended diagnosis="${diagnosis}" lastFrame=${silenceMs >= 0 ? `${(silenceMs / 1e3).toFixed(1)}s ago` : "never"} totalRx=${this.totalFramesReceived} clients=${this.connectedClients.size}`
34071
+ );
33880
34072
  if (this.dedicatedSessionRelease) {
33881
34073
  this.dedicatedSessionRelease().catch(() => {
33882
34074
  });
@@ -33884,16 +34076,18 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
33884
34076
  }
33885
34077
  if (this.active && (this.connectedClients.size > 0 || this.prestartStream)) {
33886
34078
  this.logger.info?.(
33887
- `[Go2rtcTcpServer] native stream ended, restarting (clients=${this.connectedClients.size}, prestart=${this.prestartStream})`
34079
+ `[Go2rtcTcpServer] restarting native stream (clients=${this.connectedClients.size}, prestart=${this.prestartStream})`
33888
34080
  );
33889
34081
  this.startNativeStream();
33890
34082
  }
33891
34083
  }
33892
34084
  });
33893
34085
  this.nativeFanout.start();
34086
+ this.startStreamHealthMonitor();
33894
34087
  }
33895
34088
  async stopNativeStream() {
33896
34089
  this.nativeStreamActive = false;
34090
+ this.stopStreamHealthMonitor();
33897
34091
  const fanout = this.nativeFanout;
33898
34092
  this.nativeFanout = null;
33899
34093
  if (fanout) {
@@ -33907,14 +34101,50 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
33907
34101
  }
33908
34102
  }
33909
34103
  // -----------------------------------------------------------------------
34104
+ // Stream health monitoring
34105
+ // -----------------------------------------------------------------------
34106
+ startStreamHealthMonitor() {
34107
+ this.stopStreamHealthMonitor();
34108
+ if (this.streamTimeoutMs <= 0) return;
34109
+ this.lastFrameAt = Date.now();
34110
+ this.streamHealthTimer = setInterval(() => {
34111
+ if (!this.nativeStreamActive || !this.active) {
34112
+ this.stopStreamHealthMonitor();
34113
+ return;
34114
+ }
34115
+ const silenceMs = Date.now() - this.lastFrameAt;
34116
+ if (silenceMs > this.streamTimeoutMs) {
34117
+ this.logger.warn?.(
34118
+ `[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`
34119
+ );
34120
+ this.stopStreamHealthMonitor();
34121
+ const fanout = this.nativeFanout;
34122
+ if (fanout) {
34123
+ this.nativeStreamActive = false;
34124
+ this.nativeFanout = null;
34125
+ fanout.stop().catch(() => {
34126
+ });
34127
+ }
34128
+ }
34129
+ }, Math.min(this.streamTimeoutMs / 2, 5e3));
34130
+ }
34131
+ stopStreamHealthMonitor() {
34132
+ if (this.streamHealthTimer) {
34133
+ clearInterval(this.streamHealthTimer);
34134
+ this.streamHealthTimer = void 0;
34135
+ }
34136
+ }
34137
+ // -----------------------------------------------------------------------
33910
34138
  // Client lifecycle
33911
34139
  // -----------------------------------------------------------------------
33912
- removeClient(clientId) {
34140
+ removeClient(clientId, reason) {
33913
34141
  if (!this.connectedClients.has(clientId)) return;
33914
34142
  this.connectedClients.delete(clientId);
33915
34143
  this.clientSockets.delete(clientId);
34144
+ const silenceMs = this.lastFrameAt > 0 ? Date.now() - this.lastFrameAt : -1;
34145
+ const silenceInfo = silenceMs >= 0 ? ` lastFrame=${(silenceMs / 1e3).toFixed(1)}s ago` : "";
33916
34146
  this.logger.info?.(
33917
- `[Go2rtcTcpServer] client disconnected id=${clientId} remaining=${this.connectedClients.size}`
34147
+ `[Go2rtcTcpServer] client disconnected id=${clientId} reason=${reason ?? "unknown"} remaining=${this.connectedClients.size} totalRx=${this.totalFramesReceived} totalTx=${this.totalVideoFramesWritten}${silenceInfo}`
33918
34148
  );
33919
34149
  this.emit("clientDisconnected", clientId);
33920
34150
  if (this.connectedClients.size === 0 && !this.prestartStream) {
@@ -36249,16 +36479,16 @@ function isTcpFailureThatShouldFallbackToUdp(e) {
36249
36479
  return message.includes("ECONNREFUSED") || message.includes("ETIMEDOUT") || message.includes("EHOSTUNREACH") || message.includes("ENETUNREACH") || message.includes("socket hang up") || message.includes("TCP connection timeout") || message.includes("Baichuan socket closed") || message.includes("timeout waiting for nonce") || message.includes("expected encryption info") || message.includes("ECONNRESET") || message.includes("EPIPE");
36250
36480
  }
36251
36481
  async function pingHost(host, timeoutMs = 3e3) {
36482
+ const { exec } = await import("child_process");
36483
+ const platform2 = process.platform;
36484
+ const pingCmd = platform2 === "win32" ? `ping -n 1 -w ${timeoutMs} ${host}` : platform2 === "darwin" ? (
36485
+ // macOS: -W is in milliseconds (Linux: seconds)
36486
+ `ping -c 1 -W ${timeoutMs} ${host}`
36487
+ ) : (
36488
+ // Linux/BSD-ish: -W is in seconds on most distros
36489
+ `ping -c 1 -W ${Math.max(1, Math.floor(timeoutMs / 1e3))} ${host}`
36490
+ );
36252
36491
  return new Promise((resolve) => {
36253
- const { exec } = require("child_process");
36254
- const platform2 = process.platform;
36255
- const pingCmd = platform2 === "win32" ? `ping -n 1 -w ${timeoutMs} ${host}` : platform2 === "darwin" ? (
36256
- // macOS: -W is in milliseconds (Linux: seconds)
36257
- `ping -c 1 -W ${timeoutMs} ${host}`
36258
- ) : (
36259
- // Linux/BSD-ish: -W is in seconds on most distros
36260
- `ping -c 1 -W ${Math.max(1, Math.floor(timeoutMs / 1e3))} ${host}`
36261
- );
36262
36492
  exec(pingCmd, (error) => {
36263
36493
  resolve(!error);
36264
36494
  });