@bilibili-notify/live 0.1.0-alpha.4 → 0.1.0-alpha.5

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/lib/index.cjs CHANGED
@@ -185,6 +185,8 @@ var RoomContextBase = class {
185
185
  listenerRecord = {};
186
186
  livePushTimerManager = /* @__PURE__ */ new Map();
187
187
  disposed = false;
188
+ /** stopMonitoring 主动关闭 listener 时置位;RoomSession.onClose 消费后不做自愈重连。 */
189
+ intentionalCloseRooms = /* @__PURE__ */ new Set();
188
190
  /** Cached protobuf type for INTERACT_WORD_V2 decoding (lazy-loaded). */
189
191
  interactWord;
190
192
  /**
@@ -222,6 +224,11 @@ var RoomContextBase = class {
222
224
  emitViewers(uid, viewers) {
223
225
  this._emitViewers?.(uid, viewers);
224
226
  }
227
+ consumeIntentionalClose(roomId) {
228
+ const hit = this.intentionalCloseRooms.has(roomId);
229
+ this.intentionalCloseRooms.delete(roomId);
230
+ return hit;
231
+ }
225
232
  /** 受 `config.imageEnabled` 门控的渲染器视图;关闭时返回 null。 */
226
233
  get imageRenderer() {
227
234
  return this.config.imageEnabled === false ? null : this._getImageRenderer();
@@ -253,10 +260,12 @@ var RoomContextBase = class {
253
260
  closeListener(roomId) {
254
261
  const listener = this.listenerRecord[roomId];
255
262
  if (!listener) {
263
+ this.intentionalCloseRooms.delete(roomId);
256
264
  this.logger.debug(`[conn] 直播间 [${roomId}] 连接不存在,跳过关闭`);
257
265
  return;
258
266
  }
259
267
  if (listener.closed) {
268
+ this.intentionalCloseRooms.delete(roomId);
260
269
  this.logger.debug(`[conn] 直播间 [${roomId}] 连接已被远端断开`);
261
270
  delete this.listenerRecord[roomId];
262
271
  return;
@@ -280,6 +289,7 @@ var RoomContextBase = class {
280
289
  stopMonitoring(reason, roomId) {
281
290
  if (roomId) {
282
291
  this.logger.error(`[conn] [${roomId}] ${reason},已停止该房间的监测`);
292
+ this.intentionalCloseRooms.add(roomId);
283
293
  this.closeListener(roomId);
284
294
  const timer = this.livePushTimerManager.get(roomId);
285
295
  if (timer) {
@@ -337,6 +347,7 @@ var RoomContext = class extends RoomContextBase {
337
347
  this.logger.warn(`[conn] 直播间 [${roomId}] 连接已存在,跳过创建`);
338
348
  return true;
339
349
  }
350
+ this.consumeIntentionalClose(roomId);
340
351
  const cookiesStr = this.api.getCookiesHeader();
341
352
  let mySelfInfo;
342
353
  try {
@@ -711,14 +722,17 @@ var RoomSessionBase = class {
711
722
  async bootstrap() {
712
723
  if (!await this.ctx.startLiveRoomListener(this.sub.roomId, this.buildHandler())) {
713
724
  await this.ctx.push.sendPrivateMsg(`直播间 [${this.sub.roomId}] 弹幕连接建立失败,已停止该房间监测`);
725
+ this.onMonitoringStopped();
714
726
  this.ctx.closeListener(this.sub.roomId);
715
727
  return;
716
728
  }
717
729
  if (!await this.useLiveRoomInfo(4) || !await this.useMasterInfo(4) || !this.liveRoomInfo || !this.masterInfo) {
718
730
  await this.ctx.push.sendPrivateMsg("获取直播间信息失败,启动直播间弹幕检测失败");
731
+ this.onMonitoringStopped();
719
732
  this.ctx.closeListener(this.sub.roomId);
720
733
  return;
721
734
  }
735
+ this.onListenerStarted();
722
736
  this.ctx.logger.debug(`[stat] 当前粉丝数:${this.masterInfo.liveOpenFollowerNum}`);
723
737
  if (this.liveRoomInfo.live_status === 1) {
724
738
  this.liveTime = this.liveRoomInfo.live_time;
@@ -747,6 +761,10 @@ var RoomSessionBase = class {
747
761
  this.armPeriodicTimer();
748
762
  }
749
763
  }
764
+ /** Hook for subclass-owned connection-health bookkeeping after listener bootstrap succeeds. */
765
+ onListenerStarted() {}
766
+ /** Hook for subclass-owned cleanup before this session intentionally stops monitoring. */
767
+ onMonitoringStopped() {}
750
768
  async useLiveRoomInfo(liveType) {
751
769
  const data = await this.ctx.getLiveRoomInfo(this.sub.roomId);
752
770
  if (!data?.uid) return false;
@@ -796,6 +814,7 @@ var RoomSessionBase = class {
796
814
  /** Periodic "正在直播" tick (callback for `setInterval`). */
797
815
  async tickPushAtTime() {
798
816
  if (!await this.useLiveRoomInfo(2) || !this.liveRoomInfo) {
817
+ this.onMonitoringStopped();
799
818
  this.ctx.stopMonitoring("获取直播间信息失败,推送直播卡片失败", this.sub.roomId);
800
819
  return;
801
820
  }
@@ -846,6 +865,7 @@ var RoomSessionBase = class {
846
865
  this.setLiveStatus(false);
847
866
  this.ctx.danmakuCollector.clear(this.sub.roomId);
848
867
  if (this.ctx.isDisposed()) return;
868
+ this.onMonitoringStopped();
849
869
  this.ctx.stopMonitoring("获取直播间信息失败,推送直播下播卡片失败", this.sub.roomId);
850
870
  return;
851
871
  }
@@ -930,6 +950,8 @@ const RECONNECT_BACKOFF_MS = [
930
950
  8e3,
931
951
  16e3
932
952
  ];
953
+ /** B 站 live WS 静默自愈:每分钟检查一次,3 分钟无 heartbeat/消息即主动重连。 */
954
+ const LIVE_WS_WATCHDOG_INTERVAL_MS = 6e4;
933
955
  var RoomSession = class extends RoomSessionBase {
934
956
  lastViewersEmitMs = 0;
935
957
  /**
@@ -948,10 +970,15 @@ var RoomSession = class extends RoomSessionBase {
948
970
  /** L3:退避 sleep 的 Disposable + 唤醒句柄,cancel/teardown 时清掉,不留回调到 expiry。 */
949
971
  reconnectTimer;
950
972
  reconnectWake;
951
- /** 外层主动停止 listener 时调用,阻止 onError 触发重连。 */
973
+ lastLiveWsActivityAt = 0;
974
+ lastLiveWsActivityReason = "connected";
975
+ watchdogTimer;
976
+ watchdogReconnectCount = 0;
977
+ /** 外层主动停止 listener 时调用,阻止 onError/onClose/watchdog 触发重连。 */
952
978
  cancel() {
953
979
  this.cancelled = true;
954
980
  this.reconnecting = false;
981
+ this.stopLiveWsWatchdog();
955
982
  this.clearReconnectSleep();
956
983
  }
957
984
  /** L3:dispose 退避定时器并唤醒重连循环,使其立刻重校 cancelled/disposed 后退出。 */
@@ -961,12 +988,62 @@ var RoomSession = class extends RoomSessionBase {
961
988
  this.reconnectWake?.();
962
989
  this.reconnectWake = void 0;
963
990
  }
991
+ onListenerStarted() {
992
+ this.markLiveWsActivity("connected");
993
+ this.startLiveWsWatchdog();
994
+ }
995
+ onMonitoringStopped() {
996
+ this.cancel();
997
+ }
998
+ getWsHealthSnapshot() {
999
+ return {
1000
+ lastActivityAt: this.lastLiveWsActivityAt,
1001
+ lastActivityReason: this.lastLiveWsActivityReason,
1002
+ watchdogReconnectCount: this.watchdogReconnectCount
1003
+ };
1004
+ }
1005
+ markLiveWsActivity(reason) {
1006
+ this.lastLiveWsActivityAt = Date.now();
1007
+ this.lastLiveWsActivityReason = reason;
1008
+ }
1009
+ startLiveWsWatchdog() {
1010
+ if (this.watchdogTimer || this.cancelled || this.ctx.isDisposed()) return;
1011
+ this.watchdogTimer = this.ctx.serviceCtx.setInterval(() => this.checkLiveWsWatchdog(), LIVE_WS_WATCHDOG_INTERVAL_MS);
1012
+ }
1013
+ stopLiveWsWatchdog() {
1014
+ this.watchdogTimer?.dispose();
1015
+ this.watchdogTimer = void 0;
1016
+ }
1017
+ checkLiveWsWatchdog() {
1018
+ if (this.cancelled || this.ctx.isDisposed() || this.reconnecting) return;
1019
+ if (this.lastLiveWsActivityAt <= 0) return;
1020
+ const staleMs = Date.now() - this.lastLiveWsActivityAt;
1021
+ if (staleMs < 18e4) return;
1022
+ this.watchdogReconnectCount++;
1023
+ this.reconnect("watchdog", `${Math.floor(staleMs / 1e3)}s 无 heartbeat/消息(last=${this.lastLiveWsActivityReason},watchdog=${this.watchdogReconnectCount})`);
1024
+ }
964
1025
  buildHandler() {
965
1026
  const base = {
1027
+ onOpen: () => this.markLiveWsActivity("open"),
1028
+ onStartListen: () => this.markLiveWsActivity("start-listen"),
1029
+ onClose: () => {
1030
+ if (this.cancelled || this.ctx.isDisposed()) return;
1031
+ if (this.ctx.consumeIntentionalClose(this.sub.roomId)) return;
1032
+ this.markLiveWsActivity("close");
1033
+ this.reconnect("close");
1034
+ },
966
1035
  onError: () => this.onError(),
967
- onIncomeDanmu: ({ body }) => this.onIncomeDanmu(body),
968
- onIncomeSuperChat: ({ body }) => this.onIncomeSuperChat(body),
1036
+ onAttentionChange: () => this.markLiveWsActivity("heartbeat"),
1037
+ onIncomeDanmu: ({ body }) => {
1038
+ this.markLiveWsActivity("danmu");
1039
+ this.onIncomeDanmu(body);
1040
+ },
1041
+ onIncomeSuperChat: ({ body }) => {
1042
+ this.markLiveWsActivity("superchat");
1043
+ return this.onIncomeSuperChat(body);
1044
+ },
969
1045
  onWatchedChange: ({ body }) => {
1046
+ this.markLiveWsActivity("watched");
970
1047
  this.liveData.watchedNum = body.text_small;
971
1048
  const now = Date.now();
972
1049
  if (now - this.lastViewersEmitMs >= VIEWERS_EMIT_THROTTLE_MS) {
@@ -975,42 +1052,61 @@ var RoomSession = class extends RoomSessionBase {
975
1052
  }
976
1053
  },
977
1054
  onLikedChange: ({ body }) => {
1055
+ this.markLiveWsActivity("liked");
978
1056
  this.liveData.likedNum = body.count;
979
1057
  },
980
- onGuardBuy: ({ body }) => this.onGuardBuy(body),
981
- onLiveStart: () => this.onLiveStart(),
982
- onLiveEnd: () => this.onLiveEnd()
1058
+ onGuardBuy: ({ body }) => {
1059
+ this.markLiveWsActivity("guard");
1060
+ return this.onGuardBuy(body);
1061
+ },
1062
+ onLiveStart: () => {
1063
+ this.markLiveWsActivity("live-start");
1064
+ return this.onLiveStart();
1065
+ },
1066
+ onLiveEnd: () => {
1067
+ this.markLiveWsActivity("live-end");
1068
+ return this.onLiveEnd();
1069
+ }
983
1070
  };
984
1071
  if (!this.sub.customSpecialUsersEnterTheRoom.enable) return base;
985
1072
  return {
986
1073
  ...base,
987
- raw: { INTERACT_WORD_V2: (msg) => this.onInteractWordV2(msg) }
1074
+ raw: { INTERACT_WORD_V2: (msg) => {
1075
+ this.markLiveWsActivity("interact");
1076
+ return this.onInteractWordV2(msg);
1077
+ } }
988
1078
  };
989
1079
  }
990
- async onError() {
1080
+ onError() {
1081
+ return this.reconnect("error");
1082
+ }
1083
+ async reconnect(reason, detail) {
991
1084
  if (this.cancelled || this.ctx.isDisposed()) return;
992
1085
  if (this.reconnecting) return;
993
1086
  this.reconnecting = true;
994
1087
  try {
995
- await this.reconnectLoop();
1088
+ await this.reconnectLoop(reason, detail);
996
1089
  } finally {
997
1090
  this.reconnecting = false;
998
1091
  }
999
1092
  }
1000
1093
  /**
1001
- * 退避重连循环(单飞,由 onError 持有)。`while` 取代旧的 `setTimeout(0)`
1094
+ * 退避重连循环(单飞,由 reconnect 持有)。`while` 取代旧的 `setTimeout(0)`
1002
1095
  * 递归续链 —— 杜绝深栈递归 + 每步都丢弃的定时器 Disposable;每次 sleep 后
1003
1096
  * 重校 cancelled/disposed,sleep 自身可被 cancel/teardown dispose。
1004
1097
  */
1005
- async reconnectLoop() {
1098
+ async reconnectLoop(reason, detail) {
1006
1099
  while (this.reconnectAttempts < RECONNECT_BACKOFF_MS.length) {
1007
1100
  if (this.cancelled || this.ctx.isDisposed()) return;
1008
- this.setLiveStatus(false);
1009
- this.cancelPeriodicTimer();
1101
+ if (reason === "error") {
1102
+ this.setLiveStatus(false);
1103
+ this.cancelPeriodicTimer();
1104
+ }
1010
1105
  this.ctx.closeListener(this.sub.roomId);
1011
1106
  const delay = RECONNECT_BACKOFF_MS[this.reconnectAttempts];
1012
1107
  this.reconnectAttempts++;
1013
- this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] 连接错误,${delay / 1e3}s 后重连( ${this.reconnectAttempts}/${RECONNECT_BACKOFF_MS.length} 次)`);
1108
+ const reasonText = this.describeReconnectReason(reason, detail);
1109
+ this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] ${reasonText},${delay / 1e3}s 后重连(第 ${this.reconnectAttempts}/${RECONNECT_BACKOFF_MS.length} 次)`);
1014
1110
  await this.sleepReconnect(delay);
1015
1111
  if (this.cancelled || this.ctx.isDisposed()) return;
1016
1112
  let ok = false;
@@ -1024,6 +1120,7 @@ var RoomSession = class extends RoomSessionBase {
1024
1120
  return;
1025
1121
  }
1026
1122
  if (ok) {
1123
+ this.onListenerStarted();
1027
1124
  this.ctx.logger.info(`[conn] 直播间 [${this.sub.roomId}] 重连成功`);
1028
1125
  this.reconnectAttempts = 0;
1029
1126
  return;
@@ -1031,9 +1128,15 @@ var RoomSession = class extends RoomSessionBase {
1031
1128
  this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] 重连未成功,继续退避`);
1032
1129
  }
1033
1130
  this.reconnectAttempts = 0;
1034
- const msg = `直播间 [${this.sub.roomId}] 连接持续失败,重试 ${RECONNECT_BACKOFF_MS.length} 次后放弃监听`;
1131
+ const msg = `直播间 [${this.sub.roomId}] ${this.describeReconnectReason(reason, detail)}后连接持续失败,重试 ${RECONNECT_BACKOFF_MS.length} 次后放弃监听`;
1035
1132
  this.ctx.logger.error(`[conn] ${msg}`);
1036
1133
  this.ctx.emitEngineError(msg);
1134
+ this.cancel();
1135
+ }
1136
+ describeReconnectReason(reason, detail) {
1137
+ if (reason === "error") return "连接错误";
1138
+ if (reason === "close") return "连接关闭";
1139
+ return detail ? `连接静默(${detail})` : "连接静默";
1037
1140
  }
1038
1141
  /**
1039
1142
  * L3:可被 {@link clearReconnectSleep} 取消的退避 sleep。dispose 时立即
@@ -1156,6 +1259,7 @@ var RoomSession = class extends RoomSessionBase {
1156
1259
  if (!await this.useLiveRoomInfo(1) || !await this.useMasterInfo(1) || !this.liveRoomInfo || !this.masterInfo) {
1157
1260
  this.setLiveStatus(false);
1158
1261
  if (this.ctx.isDisposed()) return;
1262
+ this.onMonitoringStopped();
1159
1263
  this.ctx.stopMonitoring("获取直播间信息失败,推送直播开播卡片失败", this.sub.roomId);
1160
1264
  return;
1161
1265
  }
@@ -1697,9 +1801,7 @@ var LiveEngine = class {
1697
1801
  /** Tear down all listeners + per-room state, leaving the engine instance reusable. */
1698
1802
  teardown() {
1699
1803
  this.logger.info("[live] 关闭所有直播间监听");
1700
- this.listener.clearPushTimers();
1701
- this.listener.clearListeners();
1702
- this.danmakuCollector.clearAll();
1804
+ this.listener.disposeAll();
1703
1805
  }
1704
1806
  /** Full rebootstrap. Used after auth-restored. */
1705
1807
  rebuildFromSubs(subs) {
package/lib/index.d.cts CHANGED
@@ -572,6 +572,8 @@ declare class RoomContextBase {
572
572
  readonly listenerRecord: Record<string, MessageListener>;
573
573
  readonly livePushTimerManager: Map<string, () => void>;
574
574
  private disposed;
575
+ /** stopMonitoring 主动关闭 listener 时置位;RoomSession.onClose 消费后不做自愈重连。 */
576
+ private readonly intentionalCloseRooms;
575
577
  /** Cached protobuf type for INTERACT_WORD_V2 decoding (lazy-loaded). */
576
578
  protected interactWord?: protobuf.Type;
577
579
  /**
@@ -590,6 +592,7 @@ declare class RoomContextBase {
590
592
  * 同型 no-op 安全调用方。room-session 已做 per-UID 节流,这里只是分发。
591
593
  */
592
594
  emitViewers(uid: string, viewers: string): void;
595
+ consumeIntentionalClose(roomId: string): boolean;
593
596
  /** 受 `config.imageEnabled` 门控的渲染器视图;关闭时返回 null。 */
594
597
  get imageRenderer(): ImageRenderer | null;
595
598
  updateConfig(config: ListenerManagerConfig): void;
@@ -737,6 +740,10 @@ declare abstract class RoomSessionBase {
737
740
  bootstrap(): Promise<void>;
738
741
  /** Build the platform-specific {@link MsgHandler}; provided by the subclass. */
739
742
  protected abstract buildHandler(): MsgHandler;
743
+ /** Hook for subclass-owned connection-health bookkeeping after listener bootstrap succeeds. */
744
+ protected onListenerStarted(): void;
745
+ /** Hook for subclass-owned cleanup before this session intentionally stops monitoring. */
746
+ protected onMonitoringStopped(): void;
740
747
  protected useLiveRoomInfo(liveType: LiveType): Promise<boolean>;
741
748
  protected useMasterInfo(liveType: LiveType): Promise<boolean>;
742
749
  /**
@@ -766,6 +773,7 @@ declare abstract class RoomSessionBase {
766
773
  }
767
774
  //#endregion
768
775
  //#region src/room-session.d.ts
776
+ type LiveWsActivityReason = "connected" | "open" | "start-listen" | "heartbeat" | "danmu" | "superchat" | "watched" | "liked" | "guard" | "live-start" | "live-end" | "interact" | "close";
769
777
  declare class RoomSession extends RoomSessionBase {
770
778
  private lastViewersEmitMs;
771
779
  /**
@@ -784,18 +792,35 @@ declare class RoomSession extends RoomSessionBase {
784
792
  /** L3:退避 sleep 的 Disposable + 唤醒句柄,cancel/teardown 时清掉,不留回调到 expiry。 */
785
793
  private reconnectTimer?;
786
794
  private reconnectWake?;
787
- /** 外层主动停止 listener 时调用,阻止 onError 触发重连。 */
795
+ private lastLiveWsActivityAt;
796
+ private lastLiveWsActivityReason;
797
+ private watchdogTimer?;
798
+ private watchdogReconnectCount;
799
+ /** 外层主动停止 listener 时调用,阻止 onError/onClose/watchdog 触发重连。 */
788
800
  cancel(): void;
789
801
  /** L3:dispose 退避定时器并唤醒重连循环,使其立刻重校 cancelled/disposed 后退出。 */
790
802
  private clearReconnectSleep;
803
+ protected onListenerStarted(): void;
804
+ protected onMonitoringStopped(): void;
805
+ getWsHealthSnapshot(): {
806
+ lastActivityAt: number;
807
+ lastActivityReason: LiveWsActivityReason;
808
+ watchdogReconnectCount: number;
809
+ };
810
+ private markLiveWsActivity;
811
+ private startLiveWsWatchdog;
812
+ private stopLiveWsWatchdog;
813
+ private checkLiveWsWatchdog;
791
814
  protected buildHandler(): MsgHandler;
792
815
  private onError;
816
+ private reconnect;
793
817
  /**
794
- * 退避重连循环(单飞,由 onError 持有)。`while` 取代旧的 `setTimeout(0)`
818
+ * 退避重连循环(单飞,由 reconnect 持有)。`while` 取代旧的 `setTimeout(0)`
795
819
  * 递归续链 —— 杜绝深栈递归 + 每步都丢弃的定时器 Disposable;每次 sleep 后
796
820
  * 重校 cancelled/disposed,sleep 自身可被 cancel/teardown dispose。
797
821
  */
798
822
  private reconnectLoop;
823
+ private describeReconnectReason;
799
824
  /**
800
825
  * L3:可被 {@link clearReconnectSleep} 取消的退避 sleep。dispose 时立即
801
826
  * resolve,让 reconnectLoop 醒来重校 cancelled/disposed 后退出 —— 不再留
package/lib/index.d.mts CHANGED
@@ -572,6 +572,8 @@ declare class RoomContextBase {
572
572
  readonly listenerRecord: Record<string, MessageListener>;
573
573
  readonly livePushTimerManager: Map<string, () => void>;
574
574
  private disposed;
575
+ /** stopMonitoring 主动关闭 listener 时置位;RoomSession.onClose 消费后不做自愈重连。 */
576
+ private readonly intentionalCloseRooms;
575
577
  /** Cached protobuf type for INTERACT_WORD_V2 decoding (lazy-loaded). */
576
578
  protected interactWord?: protobuf.Type;
577
579
  /**
@@ -590,6 +592,7 @@ declare class RoomContextBase {
590
592
  * 同型 no-op 安全调用方。room-session 已做 per-UID 节流,这里只是分发。
591
593
  */
592
594
  emitViewers(uid: string, viewers: string): void;
595
+ consumeIntentionalClose(roomId: string): boolean;
593
596
  /** 受 `config.imageEnabled` 门控的渲染器视图;关闭时返回 null。 */
594
597
  get imageRenderer(): ImageRenderer | null;
595
598
  updateConfig(config: ListenerManagerConfig): void;
@@ -737,6 +740,10 @@ declare abstract class RoomSessionBase {
737
740
  bootstrap(): Promise<void>;
738
741
  /** Build the platform-specific {@link MsgHandler}; provided by the subclass. */
739
742
  protected abstract buildHandler(): MsgHandler;
743
+ /** Hook for subclass-owned connection-health bookkeeping after listener bootstrap succeeds. */
744
+ protected onListenerStarted(): void;
745
+ /** Hook for subclass-owned cleanup before this session intentionally stops monitoring. */
746
+ protected onMonitoringStopped(): void;
740
747
  protected useLiveRoomInfo(liveType: LiveType): Promise<boolean>;
741
748
  protected useMasterInfo(liveType: LiveType): Promise<boolean>;
742
749
  /**
@@ -766,6 +773,7 @@ declare abstract class RoomSessionBase {
766
773
  }
767
774
  //#endregion
768
775
  //#region src/room-session.d.ts
776
+ type LiveWsActivityReason = "connected" | "open" | "start-listen" | "heartbeat" | "danmu" | "superchat" | "watched" | "liked" | "guard" | "live-start" | "live-end" | "interact" | "close";
769
777
  declare class RoomSession extends RoomSessionBase {
770
778
  private lastViewersEmitMs;
771
779
  /**
@@ -784,18 +792,35 @@ declare class RoomSession extends RoomSessionBase {
784
792
  /** L3:退避 sleep 的 Disposable + 唤醒句柄,cancel/teardown 时清掉,不留回调到 expiry。 */
785
793
  private reconnectTimer?;
786
794
  private reconnectWake?;
787
- /** 外层主动停止 listener 时调用,阻止 onError 触发重连。 */
795
+ private lastLiveWsActivityAt;
796
+ private lastLiveWsActivityReason;
797
+ private watchdogTimer?;
798
+ private watchdogReconnectCount;
799
+ /** 外层主动停止 listener 时调用,阻止 onError/onClose/watchdog 触发重连。 */
788
800
  cancel(): void;
789
801
  /** L3:dispose 退避定时器并唤醒重连循环,使其立刻重校 cancelled/disposed 后退出。 */
790
802
  private clearReconnectSleep;
803
+ protected onListenerStarted(): void;
804
+ protected onMonitoringStopped(): void;
805
+ getWsHealthSnapshot(): {
806
+ lastActivityAt: number;
807
+ lastActivityReason: LiveWsActivityReason;
808
+ watchdogReconnectCount: number;
809
+ };
810
+ private markLiveWsActivity;
811
+ private startLiveWsWatchdog;
812
+ private stopLiveWsWatchdog;
813
+ private checkLiveWsWatchdog;
791
814
  protected buildHandler(): MsgHandler;
792
815
  private onError;
816
+ private reconnect;
793
817
  /**
794
- * 退避重连循环(单飞,由 onError 持有)。`while` 取代旧的 `setTimeout(0)`
818
+ * 退避重连循环(单飞,由 reconnect 持有)。`while` 取代旧的 `setTimeout(0)`
795
819
  * 递归续链 —— 杜绝深栈递归 + 每步都丢弃的定时器 Disposable;每次 sleep 后
796
820
  * 重校 cancelled/disposed,sleep 自身可被 cancel/teardown dispose。
797
821
  */
798
822
  private reconnectLoop;
823
+ private describeReconnectReason;
799
824
  /**
800
825
  * L3:可被 {@link clearReconnectSleep} 取消的退避 sleep。dispose 时立即
801
826
  * resolve,让 reconnectLoop 醒来重校 cancelled/disposed 后退出 —— 不再留
package/lib/index.mjs CHANGED
@@ -161,6 +161,8 @@ var RoomContextBase = class {
161
161
  listenerRecord = {};
162
162
  livePushTimerManager = /* @__PURE__ */ new Map();
163
163
  disposed = false;
164
+ /** stopMonitoring 主动关闭 listener 时置位;RoomSession.onClose 消费后不做自愈重连。 */
165
+ intentionalCloseRooms = /* @__PURE__ */ new Set();
164
166
  /** Cached protobuf type for INTERACT_WORD_V2 decoding (lazy-loaded). */
165
167
  interactWord;
166
168
  /**
@@ -198,6 +200,11 @@ var RoomContextBase = class {
198
200
  emitViewers(uid, viewers) {
199
201
  this._emitViewers?.(uid, viewers);
200
202
  }
203
+ consumeIntentionalClose(roomId) {
204
+ const hit = this.intentionalCloseRooms.has(roomId);
205
+ this.intentionalCloseRooms.delete(roomId);
206
+ return hit;
207
+ }
201
208
  /** 受 `config.imageEnabled` 门控的渲染器视图;关闭时返回 null。 */
202
209
  get imageRenderer() {
203
210
  return this.config.imageEnabled === false ? null : this._getImageRenderer();
@@ -229,10 +236,12 @@ var RoomContextBase = class {
229
236
  closeListener(roomId) {
230
237
  const listener = this.listenerRecord[roomId];
231
238
  if (!listener) {
239
+ this.intentionalCloseRooms.delete(roomId);
232
240
  this.logger.debug(`[conn] 直播间 [${roomId}] 连接不存在,跳过关闭`);
233
241
  return;
234
242
  }
235
243
  if (listener.closed) {
244
+ this.intentionalCloseRooms.delete(roomId);
236
245
  this.logger.debug(`[conn] 直播间 [${roomId}] 连接已被远端断开`);
237
246
  delete this.listenerRecord[roomId];
238
247
  return;
@@ -256,6 +265,7 @@ var RoomContextBase = class {
256
265
  stopMonitoring(reason, roomId) {
257
266
  if (roomId) {
258
267
  this.logger.error(`[conn] [${roomId}] ${reason},已停止该房间的监测`);
268
+ this.intentionalCloseRooms.add(roomId);
259
269
  this.closeListener(roomId);
260
270
  const timer = this.livePushTimerManager.get(roomId);
261
271
  if (timer) {
@@ -313,6 +323,7 @@ var RoomContext = class extends RoomContextBase {
313
323
  this.logger.warn(`[conn] 直播间 [${roomId}] 连接已存在,跳过创建`);
314
324
  return true;
315
325
  }
326
+ this.consumeIntentionalClose(roomId);
316
327
  const cookiesStr = this.api.getCookiesHeader();
317
328
  let mySelfInfo;
318
329
  try {
@@ -687,14 +698,17 @@ var RoomSessionBase = class {
687
698
  async bootstrap() {
688
699
  if (!await this.ctx.startLiveRoomListener(this.sub.roomId, this.buildHandler())) {
689
700
  await this.ctx.push.sendPrivateMsg(`直播间 [${this.sub.roomId}] 弹幕连接建立失败,已停止该房间监测`);
701
+ this.onMonitoringStopped();
690
702
  this.ctx.closeListener(this.sub.roomId);
691
703
  return;
692
704
  }
693
705
  if (!await this.useLiveRoomInfo(4) || !await this.useMasterInfo(4) || !this.liveRoomInfo || !this.masterInfo) {
694
706
  await this.ctx.push.sendPrivateMsg("获取直播间信息失败,启动直播间弹幕检测失败");
707
+ this.onMonitoringStopped();
695
708
  this.ctx.closeListener(this.sub.roomId);
696
709
  return;
697
710
  }
711
+ this.onListenerStarted();
698
712
  this.ctx.logger.debug(`[stat] 当前粉丝数:${this.masterInfo.liveOpenFollowerNum}`);
699
713
  if (this.liveRoomInfo.live_status === 1) {
700
714
  this.liveTime = this.liveRoomInfo.live_time;
@@ -723,6 +737,10 @@ var RoomSessionBase = class {
723
737
  this.armPeriodicTimer();
724
738
  }
725
739
  }
740
+ /** Hook for subclass-owned connection-health bookkeeping after listener bootstrap succeeds. */
741
+ onListenerStarted() {}
742
+ /** Hook for subclass-owned cleanup before this session intentionally stops monitoring. */
743
+ onMonitoringStopped() {}
726
744
  async useLiveRoomInfo(liveType) {
727
745
  const data = await this.ctx.getLiveRoomInfo(this.sub.roomId);
728
746
  if (!data?.uid) return false;
@@ -772,6 +790,7 @@ var RoomSessionBase = class {
772
790
  /** Periodic "正在直播" tick (callback for `setInterval`). */
773
791
  async tickPushAtTime() {
774
792
  if (!await this.useLiveRoomInfo(2) || !this.liveRoomInfo) {
793
+ this.onMonitoringStopped();
775
794
  this.ctx.stopMonitoring("获取直播间信息失败,推送直播卡片失败", this.sub.roomId);
776
795
  return;
777
796
  }
@@ -822,6 +841,7 @@ var RoomSessionBase = class {
822
841
  this.setLiveStatus(false);
823
842
  this.ctx.danmakuCollector.clear(this.sub.roomId);
824
843
  if (this.ctx.isDisposed()) return;
844
+ this.onMonitoringStopped();
825
845
  this.ctx.stopMonitoring("获取直播间信息失败,推送直播下播卡片失败", this.sub.roomId);
826
846
  return;
827
847
  }
@@ -906,6 +926,8 @@ const RECONNECT_BACKOFF_MS = [
906
926
  8e3,
907
927
  16e3
908
928
  ];
929
+ /** B 站 live WS 静默自愈:每分钟检查一次,3 分钟无 heartbeat/消息即主动重连。 */
930
+ const LIVE_WS_WATCHDOG_INTERVAL_MS = 6e4;
909
931
  var RoomSession = class extends RoomSessionBase {
910
932
  lastViewersEmitMs = 0;
911
933
  /**
@@ -924,10 +946,15 @@ var RoomSession = class extends RoomSessionBase {
924
946
  /** L3:退避 sleep 的 Disposable + 唤醒句柄,cancel/teardown 时清掉,不留回调到 expiry。 */
925
947
  reconnectTimer;
926
948
  reconnectWake;
927
- /** 外层主动停止 listener 时调用,阻止 onError 触发重连。 */
949
+ lastLiveWsActivityAt = 0;
950
+ lastLiveWsActivityReason = "connected";
951
+ watchdogTimer;
952
+ watchdogReconnectCount = 0;
953
+ /** 外层主动停止 listener 时调用,阻止 onError/onClose/watchdog 触发重连。 */
928
954
  cancel() {
929
955
  this.cancelled = true;
930
956
  this.reconnecting = false;
957
+ this.stopLiveWsWatchdog();
931
958
  this.clearReconnectSleep();
932
959
  }
933
960
  /** L3:dispose 退避定时器并唤醒重连循环,使其立刻重校 cancelled/disposed 后退出。 */
@@ -937,12 +964,62 @@ var RoomSession = class extends RoomSessionBase {
937
964
  this.reconnectWake?.();
938
965
  this.reconnectWake = void 0;
939
966
  }
967
+ onListenerStarted() {
968
+ this.markLiveWsActivity("connected");
969
+ this.startLiveWsWatchdog();
970
+ }
971
+ onMonitoringStopped() {
972
+ this.cancel();
973
+ }
974
+ getWsHealthSnapshot() {
975
+ return {
976
+ lastActivityAt: this.lastLiveWsActivityAt,
977
+ lastActivityReason: this.lastLiveWsActivityReason,
978
+ watchdogReconnectCount: this.watchdogReconnectCount
979
+ };
980
+ }
981
+ markLiveWsActivity(reason) {
982
+ this.lastLiveWsActivityAt = Date.now();
983
+ this.lastLiveWsActivityReason = reason;
984
+ }
985
+ startLiveWsWatchdog() {
986
+ if (this.watchdogTimer || this.cancelled || this.ctx.isDisposed()) return;
987
+ this.watchdogTimer = this.ctx.serviceCtx.setInterval(() => this.checkLiveWsWatchdog(), LIVE_WS_WATCHDOG_INTERVAL_MS);
988
+ }
989
+ stopLiveWsWatchdog() {
990
+ this.watchdogTimer?.dispose();
991
+ this.watchdogTimer = void 0;
992
+ }
993
+ checkLiveWsWatchdog() {
994
+ if (this.cancelled || this.ctx.isDisposed() || this.reconnecting) return;
995
+ if (this.lastLiveWsActivityAt <= 0) return;
996
+ const staleMs = Date.now() - this.lastLiveWsActivityAt;
997
+ if (staleMs < 18e4) return;
998
+ this.watchdogReconnectCount++;
999
+ this.reconnect("watchdog", `${Math.floor(staleMs / 1e3)}s 无 heartbeat/消息(last=${this.lastLiveWsActivityReason},watchdog=${this.watchdogReconnectCount})`);
1000
+ }
940
1001
  buildHandler() {
941
1002
  const base = {
1003
+ onOpen: () => this.markLiveWsActivity("open"),
1004
+ onStartListen: () => this.markLiveWsActivity("start-listen"),
1005
+ onClose: () => {
1006
+ if (this.cancelled || this.ctx.isDisposed()) return;
1007
+ if (this.ctx.consumeIntentionalClose(this.sub.roomId)) return;
1008
+ this.markLiveWsActivity("close");
1009
+ this.reconnect("close");
1010
+ },
942
1011
  onError: () => this.onError(),
943
- onIncomeDanmu: ({ body }) => this.onIncomeDanmu(body),
944
- onIncomeSuperChat: ({ body }) => this.onIncomeSuperChat(body),
1012
+ onAttentionChange: () => this.markLiveWsActivity("heartbeat"),
1013
+ onIncomeDanmu: ({ body }) => {
1014
+ this.markLiveWsActivity("danmu");
1015
+ this.onIncomeDanmu(body);
1016
+ },
1017
+ onIncomeSuperChat: ({ body }) => {
1018
+ this.markLiveWsActivity("superchat");
1019
+ return this.onIncomeSuperChat(body);
1020
+ },
945
1021
  onWatchedChange: ({ body }) => {
1022
+ this.markLiveWsActivity("watched");
946
1023
  this.liveData.watchedNum = body.text_small;
947
1024
  const now = Date.now();
948
1025
  if (now - this.lastViewersEmitMs >= VIEWERS_EMIT_THROTTLE_MS) {
@@ -951,42 +1028,61 @@ var RoomSession = class extends RoomSessionBase {
951
1028
  }
952
1029
  },
953
1030
  onLikedChange: ({ body }) => {
1031
+ this.markLiveWsActivity("liked");
954
1032
  this.liveData.likedNum = body.count;
955
1033
  },
956
- onGuardBuy: ({ body }) => this.onGuardBuy(body),
957
- onLiveStart: () => this.onLiveStart(),
958
- onLiveEnd: () => this.onLiveEnd()
1034
+ onGuardBuy: ({ body }) => {
1035
+ this.markLiveWsActivity("guard");
1036
+ return this.onGuardBuy(body);
1037
+ },
1038
+ onLiveStart: () => {
1039
+ this.markLiveWsActivity("live-start");
1040
+ return this.onLiveStart();
1041
+ },
1042
+ onLiveEnd: () => {
1043
+ this.markLiveWsActivity("live-end");
1044
+ return this.onLiveEnd();
1045
+ }
959
1046
  };
960
1047
  if (!this.sub.customSpecialUsersEnterTheRoom.enable) return base;
961
1048
  return {
962
1049
  ...base,
963
- raw: { INTERACT_WORD_V2: (msg) => this.onInteractWordV2(msg) }
1050
+ raw: { INTERACT_WORD_V2: (msg) => {
1051
+ this.markLiveWsActivity("interact");
1052
+ return this.onInteractWordV2(msg);
1053
+ } }
964
1054
  };
965
1055
  }
966
- async onError() {
1056
+ onError() {
1057
+ return this.reconnect("error");
1058
+ }
1059
+ async reconnect(reason, detail) {
967
1060
  if (this.cancelled || this.ctx.isDisposed()) return;
968
1061
  if (this.reconnecting) return;
969
1062
  this.reconnecting = true;
970
1063
  try {
971
- await this.reconnectLoop();
1064
+ await this.reconnectLoop(reason, detail);
972
1065
  } finally {
973
1066
  this.reconnecting = false;
974
1067
  }
975
1068
  }
976
1069
  /**
977
- * 退避重连循环(单飞,由 onError 持有)。`while` 取代旧的 `setTimeout(0)`
1070
+ * 退避重连循环(单飞,由 reconnect 持有)。`while` 取代旧的 `setTimeout(0)`
978
1071
  * 递归续链 —— 杜绝深栈递归 + 每步都丢弃的定时器 Disposable;每次 sleep 后
979
1072
  * 重校 cancelled/disposed,sleep 自身可被 cancel/teardown dispose。
980
1073
  */
981
- async reconnectLoop() {
1074
+ async reconnectLoop(reason, detail) {
982
1075
  while (this.reconnectAttempts < RECONNECT_BACKOFF_MS.length) {
983
1076
  if (this.cancelled || this.ctx.isDisposed()) return;
984
- this.setLiveStatus(false);
985
- this.cancelPeriodicTimer();
1077
+ if (reason === "error") {
1078
+ this.setLiveStatus(false);
1079
+ this.cancelPeriodicTimer();
1080
+ }
986
1081
  this.ctx.closeListener(this.sub.roomId);
987
1082
  const delay = RECONNECT_BACKOFF_MS[this.reconnectAttempts];
988
1083
  this.reconnectAttempts++;
989
- this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] 连接错误,${delay / 1e3}s 后重连( ${this.reconnectAttempts}/${RECONNECT_BACKOFF_MS.length} 次)`);
1084
+ const reasonText = this.describeReconnectReason(reason, detail);
1085
+ this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] ${reasonText},${delay / 1e3}s 后重连(第 ${this.reconnectAttempts}/${RECONNECT_BACKOFF_MS.length} 次)`);
990
1086
  await this.sleepReconnect(delay);
991
1087
  if (this.cancelled || this.ctx.isDisposed()) return;
992
1088
  let ok = false;
@@ -1000,6 +1096,7 @@ var RoomSession = class extends RoomSessionBase {
1000
1096
  return;
1001
1097
  }
1002
1098
  if (ok) {
1099
+ this.onListenerStarted();
1003
1100
  this.ctx.logger.info(`[conn] 直播间 [${this.sub.roomId}] 重连成功`);
1004
1101
  this.reconnectAttempts = 0;
1005
1102
  return;
@@ -1007,9 +1104,15 @@ var RoomSession = class extends RoomSessionBase {
1007
1104
  this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] 重连未成功,继续退避`);
1008
1105
  }
1009
1106
  this.reconnectAttempts = 0;
1010
- const msg = `直播间 [${this.sub.roomId}] 连接持续失败,重试 ${RECONNECT_BACKOFF_MS.length} 次后放弃监听`;
1107
+ const msg = `直播间 [${this.sub.roomId}] ${this.describeReconnectReason(reason, detail)}后连接持续失败,重试 ${RECONNECT_BACKOFF_MS.length} 次后放弃监听`;
1011
1108
  this.ctx.logger.error(`[conn] ${msg}`);
1012
1109
  this.ctx.emitEngineError(msg);
1110
+ this.cancel();
1111
+ }
1112
+ describeReconnectReason(reason, detail) {
1113
+ if (reason === "error") return "连接错误";
1114
+ if (reason === "close") return "连接关闭";
1115
+ return detail ? `连接静默(${detail})` : "连接静默";
1013
1116
  }
1014
1117
  /**
1015
1118
  * L3:可被 {@link clearReconnectSleep} 取消的退避 sleep。dispose 时立即
@@ -1132,6 +1235,7 @@ var RoomSession = class extends RoomSessionBase {
1132
1235
  if (!await this.useLiveRoomInfo(1) || !await this.useMasterInfo(1) || !this.liveRoomInfo || !this.masterInfo) {
1133
1236
  this.setLiveStatus(false);
1134
1237
  if (this.ctx.isDisposed()) return;
1238
+ this.onMonitoringStopped();
1135
1239
  this.ctx.stopMonitoring("获取直播间信息失败,推送直播开播卡片失败", this.sub.roomId);
1136
1240
  return;
1137
1241
  }
@@ -1673,9 +1777,7 @@ var LiveEngine = class {
1673
1777
  /** Tear down all listeners + per-room state, leaving the engine instance reusable. */
1674
1778
  teardown() {
1675
1779
  this.logger.info("[live] 关闭所有直播间监听");
1676
- this.listener.clearPushTimers();
1677
- this.listener.clearListeners();
1678
- this.danmakuCollector.clearAll();
1780
+ this.listener.disposeAll();
1679
1781
  }
1680
1782
  /** Full rebootstrap. Used after auth-restored. */
1681
1783
  rebuildFromSubs(subs) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bilibili-notify/live",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/Akokk0/bilibili-notify"
@@ -32,8 +32,8 @@
32
32
  "protobufjs": "^7.4.0",
33
33
  "@bilibili-notify/ai": "^0.0.1-alpha.1",
34
34
  "@bilibili-notify/image": "^0.0.1-alpha.2",
35
- "@bilibili-notify/api": "^0.2.0-alpha.2",
36
- "@bilibili-notify/internal": "^0.1.0-alpha.3"
35
+ "@bilibili-notify/internal": "^0.1.0-alpha.4",
36
+ "@bilibili-notify/api": "^0.2.0-alpha.2"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/luxon": "^3.4.2",