@tiktool/live 2.12.2 → 2.13.0

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/README.md CHANGED
@@ -23,7 +23,7 @@ Real-time chat, gifts, viewers, **PK battles with MVP breakdown**, **x2/x3 boost
23
23
  npm install @tiktool/live
24
24
  ```
25
25
 
26
- Get your free API key at [tik.tools](https://tik.tools)
26
+ Get an API key at [tik.tools](https://tik.tools/pricing) (7-day free evaluation).
27
27
 
28
28
  ```typescript
29
29
  import { TikTokLive } from '@tiktool/live';
@@ -69,7 +69,7 @@ await live.connect();
69
69
 
70
70
  There are **two ways** to receive TikTok LIVE events from `tik.tools`. Pick based on scale and IP-exposure tolerance.
71
71
 
72
- ### Mode 1 — Direct (default, this SDK)
72
+ ### Mode 1 - Direct (default, this SDK)
73
73
 
74
74
  ```
75
75
  Your App ──signs URL via──▶ tik.tools ──returns signed URL──▶ Your App
@@ -78,7 +78,7 @@ There are **two ways** to receive TikTok LIVE events from `tik.tools`. Pick base
78
78
  ```
79
79
 
80
80
  - The signed WebSocket is opened from your runtime. TikTok sees the network identity of the host that runs the SDK.
81
- - Lowest per-stream cost — only the initial `ws_credentials` call is billed against your API key.
81
+ - Lowest per-stream cost - only the initial `ws_credentials` call is billed against your API key.
82
82
  - Suited to **low- to mid-volume** workloads from a single host.
83
83
  - High-volume setups should prefer Mode 2 for predictable, centrally-managed egress.
84
84
 
@@ -97,7 +97,7 @@ If you set the optional `proxy` field, install the standard agent:
97
97
  npm install https-proxy-agent
98
98
  ```
99
99
 
100
- ### Mode 2 — Relayed (via TikTools managed edge)
100
+ ### Mode 2 - Relayed (via TikTools managed edge)
101
101
 
102
102
  ```
103
103
  Your App ◀──── wss://api.tik.tools/?... ──── tik.tools
@@ -108,10 +108,10 @@ npm install https-proxy-agent
108
108
  ```
109
109
 
110
110
  - The TikTools service handles the upstream TikTok session and forwards decoded events over a single WebSocket to your client.
111
- - Single, stable egress — your application connects only to `api.tik.tools`.
111
+ - Single, stable egress - your application connects only to `api.tik.tools`.
112
112
  - Recommended for **production scale**, multi-tenant deployments, or any environment that already centralizes outbound traffic.
113
113
 
114
- **Easiest — same SDK, one option:**
114
+ **Easiest - same SDK, one option:**
115
115
 
116
116
  ```typescript
117
117
  import { TikTokLive } from '@tiktool/live';
@@ -131,7 +131,7 @@ await live.connect();
131
131
 
132
132
  Event names + payload shapes are **identical to Direct mode**. Switch back and forth by toggling `mode`.
133
133
 
134
- **Advanced — raw WebSocket (no SDK, your own client):**
134
+ **Advanced - raw WebSocket (no SDK, your own client):**
135
135
 
136
136
  ```typescript
137
137
  import WebSocket from 'ws';
@@ -216,7 +216,7 @@ live.on('event', (event) => {
216
216
  This SDK fully parses TikTok PK (Player-vs-Killer) battle protobufs, including
217
217
  **multi-guest** matches with up to 4 hosts per side and per-gifter MVP scores.
218
218
 
219
- ### `battleArmies` — score updates during a PK
219
+ ### `battleArmies` - score updates during a PK
220
220
 
221
221
  Emitted every few seconds while a PK is active. The new `hosts[]` array gives
222
222
  you the **per-host breakdown** with each gifter's contribution sorted **MVP
@@ -224,10 +224,10 @@ first** (highest score → lowest):
224
224
 
225
225
  ```typescript
226
226
  live.on('battleArmies', (e) => {
227
- console.log(`PK ${e.battleId} — ${e.secsRemaining}s remaining`);
227
+ console.log(`PK ${e.battleId} - ${e.secsRemaining}s remaining`);
228
228
 
229
229
  for (const host of e.hosts ?? []) {
230
- console.log(` Host ${host.hostUserId} — total ${host.teamTotalScore}`);
230
+ console.log(` Host ${host.hostUserId} - total ${host.teamTotalScore}`);
231
231
 
232
232
  // contributors[0] = MVP (highest gifter)
233
233
  for (const [i, c] of host.contributors.slice(0, 3).entries()) {
@@ -266,11 +266,11 @@ live.on('battleArmies', (e) => {
266
266
  | `durationSec` | `number` | Total battle duration |
267
267
  | `endTimeMs` | `number` | Battle end ms epoch (alias for `battleStartMs + duration*1000`) |
268
268
 
269
- ### `battleItemCard` — boosters / power-ups during a PK
269
+ ### `battleItemCard` - boosters / power-ups during a PK
270
270
 
271
271
  Fired when a gifter activates a special card: x2/x3 multipliers, gloves
272
272
  (crit), mist, thunder, extra-time, or match-guide. Includes the **card art**
273
- URL straight from TikTok's CDN — drop it into an OBS overlay as-is.
273
+ URL straight from TikTok's CDN - drop it into an OBS overlay as-is.
274
274
 
275
275
  ```typescript
276
276
  live.on('battleItemCard', (e) => {
@@ -280,7 +280,7 @@ live.on('battleItemCard', (e) => {
280
280
  console.log(` x${e.multiplier} multiplier for ${e.durationSec}s`);
281
281
  }
282
282
 
283
- // Use straight in an <img> tag — TikTok CDN
283
+ // Use straight in an <img> tag - TikTok CDN
284
284
  console.log(` icon: ${e.iconUrl}`);
285
285
  console.log(` accent: ${e.accentColor}`);
286
286
  });
@@ -307,7 +307,7 @@ live.on('battleItemCard', (e) => {
307
307
  | `iconKey` | `string` | Short id, e.g. `card_mist_v3`, `card_crit_v3`, `top3_buffer` |
308
308
  | `accentColor` | `string` | Hex color, e.g. `#BCD9E0` (mist blue), `#E0D4BC` (gloves tan) |
309
309
 
310
- ### `battle` — PK lifecycle events
310
+ ### `battle` - PK lifecycle events
311
311
 
312
312
  Fired on start / status change / end. Use for PK ON/OFF banners.
313
313
 
@@ -327,15 +327,15 @@ live.on('battle', (e) => {
327
327
 
328
328
  | Option | Type | Default | Description |
329
329
  |--------|------|---------|-------------|
330
- | `uniqueId` | `string` | — | TikTok username (without @) |
331
- | `apiKey` | `string` | — | **Required.** API key from [tik.tools](https://tik.tools) |
330
+ | `uniqueId` | `string` | - | TikTok username (without @) |
331
+ | `apiKey` | `string` | - | **Required.** API key from [tik.tools](https://tik.tools) |
332
332
  | `signServerUrl` | `string` | `https://api.tik.tools` | Sign server URL |
333
333
  | `autoReconnect` | `boolean` | `true` | Auto-reconnect on disconnect |
334
334
  | `maxReconnectAttempts` | `number` | `5` | Max reconnect attempts |
335
335
  | `heartbeatInterval` | `number` | `10000` | Heartbeat interval (ms) |
336
- | `roomId` | `string` | — | Pre-resolved room ID (skips page fetch when paired with `sessionId`) |
337
- | `sessionId` | `string` | — | Pre-resolved `ttwid` cookie (skips page fetch when paired with `roomId`) |
338
- | `proxy` | `string` | — | HTTP(S) proxy URL for Direct mode (e.g. `http://USER:PASS@host:port`). Requires `https-proxy-agent`. |
336
+ | `roomId` | `string` | - | Pre-resolved room ID (skips page fetch when paired with `sessionId`) |
337
+ | `sessionId` | `string` | - | Pre-resolved `ttwid` cookie (skips page fetch when paired with `roomId`) |
338
+ | `proxy` | `string` | - | HTTP(S) proxy URL for Direct mode (e.g. `http://USER:PASS@host:port`). Requires `https-proxy-agent`. |
339
339
  | `mode` | `'direct' \| 'relayed'` | `'direct'` | Connection mode. See [Connection Modes](#connection-modes). |
340
340
  | `debug` | `boolean` | `false` | Debug logging |
341
341
 
@@ -393,7 +393,7 @@ curl -H "X-Api-Key: YOUR_KEY" \
393
393
  "https://api.tik.tools/webcast/user_profile?unique_id=anyuser"
394
394
  ```
395
395
 
396
- Returns full profile JSON. Pro tier and above. Cached server-side for 24h — repeated lookups are free and instant.
396
+ Returns full profile JSON. Pro tier and above. Cached server-side for 24h - repeated lookups are free and instant.
397
397
 
398
398
  ---
399
399
 
@@ -403,16 +403,17 @@ All API requests require an API key. Get yours at [tik.tools](https://tik.tools)
403
403
 
404
404
  | Tier | Price | Requests/day | WS Connections | Notes |
405
405
  |------|-------|--------------|----------------|-------|
406
- | **Community** | Free forever | 2,500 | 15 (2h per WS) | Masked leaderboards. No datacenter proxies — calls run from your own IP. |
407
- | **Pro** | from $59/mo | 75,000 | 50 (8h) | Unmasked leaderboards · CAPTCHA Solver · Feed Discovery · 5 AI caption streams · priority routing |
408
- | **Ultra** | from $219/mo | 300,000 | 250 (8h) | Unmasked leagues · 20 AI caption streams · 99.5% uptime SLA · priority chat support |
409
- | **Global Agency** | $549/mo | 300,000 | 500 + Firehose | Everything in Ultra + **Live Gifter Firehose WS** (region/league/global + min-diamond filters) + VIP Telegram alerts + VIP Web Vault |
406
+ | **Sandbox** | Free, 7-day evaluation | 5,000 | 3 (2h per WS) | Masked leaderboards. No datacenter proxies - calls run from your own IP. Refused after 7 days from account creation (WS close 4401, REST 403). |
407
+ | **Basic** | $7/wk or $19/mo | 10,000 | 20 (8h per WS) | Datacenter proxies, bulk live check |
408
+ | **Pro** | $15/wk or $49/mo | 75,000 | 50 (12h per WS) | Unmasked leaderboards · CAPTCHA Solver · Feed Discovery · priority routing |
409
+ | **Ultra** | $45/wk or $149/mo | 300,000 | 250 (24h per WS) | Unmasked leagues · priority chat support |
410
+ | **Agency** | $119/wk or $399/mo | 1,000,000 | 500 (24h per WS) + Firehose | Everything in Ultra + **Live Gifter Firehose WS** (region/league/global + min-diamond filters) + VIP Telegram alerts + VIP Web Vault |
410
411
 
411
412
  The SDK calls the sign server **once per connection**, then stays connected
412
- via WebSocket. The free Community tier caps each WebSocket at 2 hours and
413
- is sufficient for most use cases.
413
+ via WebSocket. Sandbox, the free 7-day evaluation, caps each WebSocket at 2 hours;
414
+ paid plans allow 8 to 24 hours per connection.
414
415
 
415
- ### Live Gifter Firehose — Global Agency
416
+ ### Live Gifter Firehose - Agency
416
417
 
417
418
  Real-time gift event stream from Dragonfly fan-out. Filter by region, league,
418
419
  or globally; cap by minimum diamond threshold. Mid-stream filter updates
@@ -474,7 +475,7 @@ live.on('battleArmies', e => {
474
475
  for (const host of e.hosts ?? []) {
475
476
  const top = host.contributors[0];
476
477
  console.log(` ${host.hostUserId}: ${host.teamTotalScore} ` +
477
- `(MVP: ${top?.nickname || top?.userId || '—'} ${top?.score ?? 0})`);
478
+ `(MVP: ${top?.nickname || top?.userId || '-'} ${top?.score ?? 0})`);
478
479
  }
479
480
  });
480
481
 
@@ -570,10 +571,10 @@ live.on('battleItemCard', (event: BattleItemCardEvent) => {
570
571
 
571
572
  ### 2.8.0 (2026-05-19)
572
573
 
573
- - **NEW** `battleItemCard` event — x2/x3 boosters, gloves, mist, thunder,
574
+ - **NEW** `battleItemCard` event - x2/x3 boosters, gloves, mist, thunder,
574
575
  extra-time, match-guide. Includes `iconUrl`, `iconKey`, `accentColor` for
575
576
  drop-in overlay use.
576
- - **NEW** `BattleArmiesEvent.hosts[]` — multi-guest PK breakdown with per-host
577
+ - **NEW** `BattleArmiesEvent.hosts[]` - multi-guest PK breakdown with per-host
577
578
  `contributors[]` sorted **MVP first**.
578
579
  - **NEW** `matchId`, `sessionId`, `serverTsMs`, `sessionTag`, `secsRemaining`
579
580
  fields on `BattleArmiesEvent`.
package/dist/index.d.mts CHANGED
@@ -45,7 +45,18 @@ interface GiftEvent extends BaseEvent {
45
45
  repeatEnd: boolean;
46
46
  combo: boolean;
47
47
  giftType: number;
48
+ /**
49
+ * TikTok's combo group id: the same on every frame of one combo. Follow a
50
+ * combo with (user.id, giftId, groupId) and take its highest repeatCount.
51
+ */
48
52
  groupId: string;
53
+ /**
54
+ * Who received the gift, as a full-precision string: the host, or the
55
+ * guest in a multi-guest LIVE. '' when the frame did not state it.
56
+ */
57
+ toUserId: string;
58
+ /** Same as `toUserId`; matches the field name used by the relay events. */
59
+ receiverUserId: string;
49
60
  }
50
61
  interface SocialEvent extends BaseEvent {
51
62
  type: 'social';
@@ -55,6 +66,7 @@ interface SocialEvent extends BaseEvent {
55
66
  interface RoomUserSeqEvent extends BaseEvent {
56
67
  type: 'roomUserSeq';
57
68
  viewerCount: number;
69
+ /** TikTok's running viewer total for this LIVE; restarts with a new LIVE. */
58
70
  totalViewers: number;
59
71
  }
60
72
  interface BattleTeamUser {
@@ -230,6 +242,12 @@ interface TikTokLiveEvents {
230
242
  envelope: (event: EnvelopeEvent) => void;
231
243
  question: (event: QuestionEvent) => void;
232
244
  control: (event: ControlEvent) => void;
245
+ /** The host ended the LIVE (TikTok control action 3, or the relay's confirmed end). */
246
+ streamEnd: (event: {
247
+ type: 'streamEnd';
248
+ uniqueId: string;
249
+ reason: string;
250
+ }) => void;
233
251
  room: (event: RoomEvent) => void;
234
252
  liveIntro: (event: LiveIntroEvent) => void;
235
253
  rankUpdate: (event: RankUpdateEvent) => void;
@@ -444,6 +462,7 @@ declare class TikTokLive extends EventEmitter {
444
462
  * Resolve unknown host user IDs via the sign server API.
445
463
  * Caches results and re-emits the battleArmies event with enriched hostUser data.
446
464
  */
465
+ private _hostResolveUnavailable;
447
466
  private resolveHostUsers;
448
467
  private startHeartbeat;
449
468
  private stopHeartbeat;
package/dist/index.d.ts CHANGED
@@ -45,7 +45,18 @@ interface GiftEvent extends BaseEvent {
45
45
  repeatEnd: boolean;
46
46
  combo: boolean;
47
47
  giftType: number;
48
+ /**
49
+ * TikTok's combo group id: the same on every frame of one combo. Follow a
50
+ * combo with (user.id, giftId, groupId) and take its highest repeatCount.
51
+ */
48
52
  groupId: string;
53
+ /**
54
+ * Who received the gift, as a full-precision string: the host, or the
55
+ * guest in a multi-guest LIVE. '' when the frame did not state it.
56
+ */
57
+ toUserId: string;
58
+ /** Same as `toUserId`; matches the field name used by the relay events. */
59
+ receiverUserId: string;
49
60
  }
50
61
  interface SocialEvent extends BaseEvent {
51
62
  type: 'social';
@@ -55,6 +66,7 @@ interface SocialEvent extends BaseEvent {
55
66
  interface RoomUserSeqEvent extends BaseEvent {
56
67
  type: 'roomUserSeq';
57
68
  viewerCount: number;
69
+ /** TikTok's running viewer total for this LIVE; restarts with a new LIVE. */
58
70
  totalViewers: number;
59
71
  }
60
72
  interface BattleTeamUser {
@@ -230,6 +242,12 @@ interface TikTokLiveEvents {
230
242
  envelope: (event: EnvelopeEvent) => void;
231
243
  question: (event: QuestionEvent) => void;
232
244
  control: (event: ControlEvent) => void;
245
+ /** The host ended the LIVE (TikTok control action 3, or the relay's confirmed end). */
246
+ streamEnd: (event: {
247
+ type: 'streamEnd';
248
+ uniqueId: string;
249
+ reason: string;
250
+ }) => void;
233
251
  room: (event: RoomEvent) => void;
234
252
  liveIntro: (event: LiveIntroEvent) => void;
235
253
  rankUpdate: (event: RankUpdateEvent) => void;
@@ -444,6 +462,7 @@ declare class TikTokLive extends EventEmitter {
444
462
  * Resolve unknown host user IDs via the sign server API.
445
463
  * Caches results and re-emits the battleArmies event with enriched hostUser data.
446
464
  */
465
+ private _hostResolveUnavailable;
447
466
  private resolveHostUsers;
448
467
  private startHeartbeat;
449
468
  private stopHeartbeat;
package/dist/index.js CHANGED
@@ -496,6 +496,121 @@ var require_browser = __commonJS({
496
496
  }
497
497
  });
498
498
 
499
+ // ../../node_modules/has-flag/index.js
500
+ var require_has_flag = __commonJS({
501
+ "../../node_modules/has-flag/index.js"(exports2, module2) {
502
+ "use strict";
503
+ module2.exports = (flag, argv = process.argv) => {
504
+ const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--";
505
+ const position = argv.indexOf(prefix + flag);
506
+ const terminatorPosition = argv.indexOf("--");
507
+ return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition);
508
+ };
509
+ }
510
+ });
511
+
512
+ // ../../node_modules/supports-color/index.js
513
+ var require_supports_color = __commonJS({
514
+ "../../node_modules/supports-color/index.js"(exports2, module2) {
515
+ "use strict";
516
+ var os = require("os");
517
+ var tty = require("tty");
518
+ var hasFlag = require_has_flag();
519
+ var { env } = process;
520
+ var forceColor;
521
+ if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) {
522
+ forceColor = 0;
523
+ } else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) {
524
+ forceColor = 1;
525
+ }
526
+ if ("FORCE_COLOR" in env) {
527
+ if (env.FORCE_COLOR === "true") {
528
+ forceColor = 1;
529
+ } else if (env.FORCE_COLOR === "false") {
530
+ forceColor = 0;
531
+ } else {
532
+ forceColor = env.FORCE_COLOR.length === 0 ? 1 : Math.min(parseInt(env.FORCE_COLOR, 10), 3);
533
+ }
534
+ }
535
+ function translateLevel(level) {
536
+ if (level === 0) {
537
+ return false;
538
+ }
539
+ return {
540
+ level,
541
+ hasBasic: true,
542
+ has256: level >= 2,
543
+ has16m: level >= 3
544
+ };
545
+ }
546
+ function supportsColor(haveStream, streamIsTTY) {
547
+ if (forceColor === 0) {
548
+ return 0;
549
+ }
550
+ if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) {
551
+ return 3;
552
+ }
553
+ if (hasFlag("color=256")) {
554
+ return 2;
555
+ }
556
+ if (haveStream && !streamIsTTY && forceColor === void 0) {
557
+ return 0;
558
+ }
559
+ const min = forceColor || 0;
560
+ if (env.TERM === "dumb") {
561
+ return min;
562
+ }
563
+ if (process.platform === "win32") {
564
+ const osRelease = os.release().split(".");
565
+ if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) {
566
+ return Number(osRelease[2]) >= 14931 ? 3 : 2;
567
+ }
568
+ return 1;
569
+ }
570
+ if ("CI" in env) {
571
+ if (["TRAVIS", "CIRCLECI", "APPVEYOR", "GITLAB_CI", "GITHUB_ACTIONS", "BUILDKITE"].some((sign) => sign in env) || env.CI_NAME === "codeship") {
572
+ return 1;
573
+ }
574
+ return min;
575
+ }
576
+ if ("TEAMCITY_VERSION" in env) {
577
+ return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0;
578
+ }
579
+ if (env.COLORTERM === "truecolor") {
580
+ return 3;
581
+ }
582
+ if ("TERM_PROGRAM" in env) {
583
+ const version = parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
584
+ switch (env.TERM_PROGRAM) {
585
+ case "iTerm.app":
586
+ return version >= 3 ? 3 : 2;
587
+ case "Apple_Terminal":
588
+ return 2;
589
+ }
590
+ }
591
+ if (/-256(color)?$/i.test(env.TERM)) {
592
+ return 2;
593
+ }
594
+ if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) {
595
+ return 1;
596
+ }
597
+ if ("COLORTERM" in env) {
598
+ return 1;
599
+ }
600
+ return min;
601
+ }
602
+ function getSupportLevel(stream) {
603
+ const level = supportsColor(stream, stream && stream.isTTY);
604
+ return translateLevel(level);
605
+ }
606
+ module2.exports = {
607
+ supportsColor: getSupportLevel,
608
+ stdout: translateLevel(supportsColor(true, tty.isatty(1))),
609
+ stderr: translateLevel(supportsColor(true, tty.isatty(2)))
610
+ };
611
+ }
612
+ });
613
+
499
614
  // node_modules/debug/src/node.js
500
615
  var require_node = __commonJS({
501
616
  "node_modules/debug/src/node.js"(exports2, module2) {
@@ -515,7 +630,7 @@ var require_node = __commonJS({
515
630
  );
516
631
  exports2.colors = [6, 2, 3, 4, 5, 1];
517
632
  try {
518
- const supportsColor = require("supports-color");
633
+ const supportsColor = require_supports_color();
519
634
  if (supportsColor && (supportsColor.stderr || supportsColor).level >= 2) {
520
635
  exports2.colors = [
521
636
  20,
@@ -1511,12 +1626,18 @@ function parseWebcastMessage(method, payload) {
1511
1626
  }
1512
1627
  }
1513
1628
  let toUserId = "";
1629
+ const receiverBuf = getBytes(f, 8);
1630
+ if (receiverBuf) {
1631
+ const r = parseUser(receiverBuf);
1632
+ if (r.id && r.id !== "0") toUserId = r.id;
1633
+ }
1514
1634
  const extraBuf = getBytes(f, 23);
1515
- if (extraBuf) {
1516
- const ef = decodeProto(extraBuf);
1517
- toUserId = String(getInt(ef, 8));
1635
+ if (!toUserId && extraBuf) {
1636
+ const id = getIntStr(decodeProto(extraBuf), 8);
1637
+ if (id !== "0") toUserId = id;
1518
1638
  }
1519
- const groupId = toUserId || getStr(f, 11);
1639
+ const rawGroup = getIntStr(f, 11);
1640
+ const groupId = getStr(f, 11) || (rawGroup !== "0" ? rawGroup : "");
1520
1641
  return {
1521
1642
  ...base,
1522
1643
  type: "gift",
@@ -1528,7 +1649,9 @@ function parseWebcastMessage(method, payload) {
1528
1649
  repeatEnd,
1529
1650
  combo: repeatCount > 1 && !repeatEnd,
1530
1651
  giftType,
1531
- groupId
1652
+ groupId,
1653
+ toUserId,
1654
+ receiverUserId: toUserId
1532
1655
  };
1533
1656
  }
1534
1657
  // Proto: WebcastSocialMessage { User user=2, WebcastMessageEvent event=1 }
@@ -1554,7 +1677,7 @@ function parseWebcastMessage(method, payload) {
1554
1677
  // Proto: WebcastRoomUserSeqMessage { int32 viewerCount=3 }
1555
1678
  case "WebcastRoomUserSeqMessage": {
1556
1679
  const viewerCount = getInt(f, 3) || getInt(f, 2);
1557
- const totalViewers = getInt(f, 1) || viewerCount;
1680
+ const totalViewers = getInt(f, 7) || viewerCount;
1558
1681
  return { ...base, type: "roomUserSeq", totalViewers, viewerCount };
1559
1682
  }
1560
1683
  // Proto: WebcastLinkMicBattle { repeated WebcastLinkMicBattleItems battleUsers=10 }
@@ -2068,6 +2191,11 @@ var TikTokLive = class _TikTokLive extends import_events.EventEmitter {
2068
2191
  if (this._destroyed) throw new Error("Client has been destroyed. Create a new instance.");
2069
2192
  this.intentionalClose = false;
2070
2193
  if (this.mode === "relayed") {
2194
+ if (this._presetRoomId) {
2195
+ console.warn(
2196
+ `[TikTokLive] roomId=${this._presetRoomId} is ignored in mode:'relayed' - the managed relay resolves the room itself. Use mode:'direct' with roomId to pin an exact room (resolve it first via /webcast/bulk_live_check).`
2197
+ );
2198
+ }
2071
2199
  return this._connectRelayed();
2072
2200
  }
2073
2201
  let ttwid = this._presetSessionId;
@@ -2503,6 +2631,9 @@ var TikTokLive = class _TikTokLive extends import_events.EventEmitter {
2503
2631
  }
2504
2632
  this.emit("event", evt);
2505
2633
  this.emit(evt.type, evt);
2634
+ if (evt.type === "control" && evt.action === 3) {
2635
+ this.emit("streamEnd", { type: "streamEnd", uniqueId: this.uniqueId, reason: "creator_offline" });
2636
+ }
2506
2637
  }
2507
2638
  }
2508
2639
  } catch {
@@ -2512,8 +2643,12 @@ var TikTokLive = class _TikTokLive extends import_events.EventEmitter {
2512
2643
  * Resolve unknown host user IDs via the sign server API.
2513
2644
  * Caches results and re-emits the battleArmies event with enriched hostUser data.
2514
2645
  */
2646
+ // Set once the sign server refuses host resolution for this key (tier below
2647
+ // Basic answers 403). Before this, every battle frame retried and filled the
2648
+ // customer's API log with 403s they never asked for.
2649
+ _hostResolveUnavailable = false;
2515
2650
  async resolveHostUsers(userIds, originalEvt) {
2516
- const toResolve = userIds.filter((id) => !this._pendingHostResolves.has(id));
2651
+ const toResolve = this._hostResolveUnavailable ? [] : userIds.filter((id) => !this._pendingHostResolves.has(id));
2517
2652
  if (toResolve.length === 0) {
2518
2653
  this.emit("event", originalEvt);
2519
2654
  this.emit(originalEvt.type, originalEvt);
@@ -2531,7 +2666,9 @@ var TikTokLive = class _TikTokLive extends import_events.EventEmitter {
2531
2666
  body: JSON.stringify({ user_ids: toResolve }),
2532
2667
  signal: AbortSignal.timeout(5e3)
2533
2668
  });
2534
- if (res.ok) {
2669
+ if (res.status === 401 || res.status === 403) {
2670
+ this._hostResolveUnavailable = true;
2671
+ } else if (res.ok) {
2535
2672
  const json = await res.json();
2536
2673
  if (json.data) {
2537
2674
  for (const [uid, info] of Object.entries(json.data)) {