@tiktool/live 2.6.2 → 2.6.4

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.js CHANGED
@@ -32,6 +32,8 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  TikTokCaptions: () => TikTokCaptions,
34
34
  TikTokLive: () => TikTokLive,
35
+ fetchFeed: () => fetchFeed,
36
+ getLiveFeed: () => getLiveFeed,
35
37
  getRanklist: () => getRanklist,
36
38
  getRegionalRanklist: () => getRegionalRanklist
37
39
  });
@@ -1173,6 +1175,86 @@ var TikTokLive = class extends import_events.EventEmitter {
1173
1175
  // src/captions.ts
1174
1176
  var import_events2 = require("events");
1175
1177
  var import_ws2 = __toESM(require("ws"));
1178
+ var FLV_TAG_AUDIO = 8;
1179
+ var FLV_HEADER_SIZE = 9;
1180
+ var FLV_PREV_TAG_SIZE = 4;
1181
+ var FlvAudioExtractor = class {
1182
+ buffer = new Uint8Array(0);
1183
+ headerParsed = false;
1184
+ onAudio;
1185
+ aacProfile = 2;
1186
+ sampleRateIndex = 4;
1187
+ channelConfig = 2;
1188
+ ascParsed = false;
1189
+ constructor(onAudio) {
1190
+ this.onAudio = onAudio;
1191
+ }
1192
+ parseASC(asc) {
1193
+ if (asc.length < 2) return;
1194
+ this.aacProfile = asc[0] >> 3 & 31;
1195
+ this.sampleRateIndex = (asc[0] & 7) << 1 | asc[1] >> 7 & 1;
1196
+ this.channelConfig = asc[1] >> 3 & 15;
1197
+ this.ascParsed = true;
1198
+ }
1199
+ buildAdtsHeader(frameLength) {
1200
+ const adts = new Uint8Array(7);
1201
+ const fullLength = frameLength + 7;
1202
+ const profile = this.aacProfile - 1;
1203
+ adts[0] = 255;
1204
+ adts[1] = 241;
1205
+ adts[2] = (profile & 3) << 6 | (this.sampleRateIndex & 15) << 2 | this.channelConfig >> 2 & 1;
1206
+ adts[3] = (this.channelConfig & 3) << 6 | fullLength >> 11 & 3;
1207
+ adts[4] = fullLength >> 3 & 255;
1208
+ adts[5] = (fullLength & 7) << 5 | 31;
1209
+ adts[6] = 252;
1210
+ return adts;
1211
+ }
1212
+ push(chunk) {
1213
+ const newBuf = new Uint8Array(this.buffer.length + chunk.length);
1214
+ newBuf.set(this.buffer, 0);
1215
+ newBuf.set(chunk, this.buffer.length);
1216
+ this.buffer = newBuf;
1217
+ if (!this.headerParsed) {
1218
+ if (this.buffer.length < FLV_HEADER_SIZE + FLV_PREV_TAG_SIZE) return;
1219
+ if (this.buffer[0] !== 70 || this.buffer[1] !== 76 || this.buffer[2] !== 86) return;
1220
+ const dv = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
1221
+ const dataOffset = dv.getUint32(5);
1222
+ this.buffer = this.buffer.subarray(dataOffset + FLV_PREV_TAG_SIZE);
1223
+ this.headerParsed = true;
1224
+ }
1225
+ while (this.buffer.length >= 11) {
1226
+ const tagType = this.buffer[0] & 31;
1227
+ const dataSize = this.buffer[1] << 16 | this.buffer[2] << 8 | this.buffer[3];
1228
+ const totalTagSize = 11 + dataSize + FLV_PREV_TAG_SIZE;
1229
+ if (this.buffer.length < totalTagSize) break;
1230
+ if (tagType === FLV_TAG_AUDIO) {
1231
+ const audioData = this.buffer.subarray(11, 11 + dataSize);
1232
+ if (audioData.length > 0) {
1233
+ const soundFormat = audioData[0] >> 4 & 15;
1234
+ if (soundFormat === 10 && audioData.length > 2) {
1235
+ const aacPacketType = audioData[1];
1236
+ if (aacPacketType === 0) {
1237
+ this.parseASC(audioData.subarray(2));
1238
+ } else if (aacPacketType === 1 && this.ascParsed) {
1239
+ const rawFrame = audioData.subarray(2);
1240
+ const adtsHeader = this.buildAdtsHeader(rawFrame.length);
1241
+ const adtsFrame = new Uint8Array(adtsHeader.length + rawFrame.length);
1242
+ adtsFrame.set(adtsHeader, 0);
1243
+ adtsFrame.set(rawFrame, adtsHeader.length);
1244
+ this.onAudio(adtsFrame);
1245
+ }
1246
+ }
1247
+ }
1248
+ }
1249
+ this.buffer = this.buffer.subarray(totalTagSize);
1250
+ }
1251
+ }
1252
+ reset() {
1253
+ this.buffer = new Uint8Array(0);
1254
+ this.headerParsed = false;
1255
+ this.ascParsed = false;
1256
+ }
1257
+ };
1176
1258
  var DEFAULT_CAPTIONS_SERVER = "wss://api.tik.tools";
1177
1259
  var TikTokCaptions = class extends import_events2.EventEmitter {
1178
1260
  ws = null;
@@ -1189,6 +1271,9 @@ var TikTokCaptions = class extends import_events2.EventEmitter {
1189
1271
  _diarization;
1190
1272
  _maxDurationMinutes;
1191
1273
  _language;
1274
+ streamAbortController = null;
1275
+ flvExtractor = null;
1276
+ streamUrl = null;
1192
1277
  constructor(options) {
1193
1278
  super();
1194
1279
  this.uniqueId = options.uniqueId.replace(/^@/, "");
@@ -1247,6 +1332,14 @@ var TikTokCaptions = class extends import_events2.EventEmitter {
1247
1332
  */
1248
1333
  stop() {
1249
1334
  this.intentionalClose = true;
1335
+ if (this.streamAbortController) {
1336
+ this.streamAbortController.abort();
1337
+ this.streamAbortController = null;
1338
+ }
1339
+ if (this.flvExtractor) {
1340
+ this.flvExtractor.reset();
1341
+ this.flvExtractor = null;
1342
+ }
1250
1343
  if (this.ws) {
1251
1344
  this.send({ action: "stop" });
1252
1345
  this.ws.close(1e3);
@@ -1309,6 +1402,10 @@ var TikTokCaptions = class extends import_events2.EventEmitter {
1309
1402
  try {
1310
1403
  const msg = JSON.parse(raw);
1311
1404
  switch (msg.type) {
1405
+ case "stream_info":
1406
+ if (this.debug) console.log(`[Captions] Received stream_info: flv=${!!msg.flvUrl}, hls=${!!msg.hlsUrl}, ao=${!!msg.audioOnlyUrl}`);
1407
+ this.connectToStream(msg);
1408
+ break;
1312
1409
  case "caption":
1313
1410
  this.emit("caption", {
1314
1411
  text: msg.text,
@@ -1359,15 +1456,161 @@ var TikTokCaptions = class extends import_events2.EventEmitter {
1359
1456
  message: msg.message
1360
1457
  });
1361
1458
  break;
1459
+ // Handle interim/final captions from server (sentence-level accumulation)
1460
+ case "interim":
1461
+ this.emit("caption", {
1462
+ text: msg.text,
1463
+ language: msg.language,
1464
+ isFinal: false,
1465
+ confidence: msg.confidence || 0,
1466
+ speaker: msg.speaker
1467
+ });
1468
+ break;
1469
+ case "final":
1470
+ this.emit("caption", {
1471
+ text: msg.text,
1472
+ language: msg.language,
1473
+ isFinal: true,
1474
+ confidence: msg.confidence || 0,
1475
+ speaker: msg.speaker
1476
+ });
1477
+ break;
1478
+ case "translation_interim":
1479
+ this.emit("translation", {
1480
+ text: msg.text,
1481
+ language: msg.language,
1482
+ isFinal: false,
1483
+ confidence: msg.confidence || 0,
1484
+ speaker: msg.speaker
1485
+ });
1486
+ break;
1487
+ case "translation_final":
1488
+ this.emit("translation", {
1489
+ text: msg.text,
1490
+ language: msg.language,
1491
+ isFinal: true,
1492
+ confidence: msg.confidence || 0,
1493
+ speaker: msg.speaker
1494
+ });
1495
+ break;
1362
1496
  default:
1363
1497
  if (this.debug) {
1364
- console.log(`[Captions] Unknown message type: ${msg.type}`, msg);
1498
+ if (this.debug) console.log(`[Captions] Unknown message type: ${msg.type}`, msg);
1365
1499
  }
1366
1500
  }
1367
1501
  } catch {
1368
1502
  if (this.debug) console.error("[Captions] Failed to parse message:", raw);
1369
1503
  }
1370
1504
  }
1505
+ /**
1506
+ * Connect to the TikTok FLV stream and extract audio.
1507
+ * Sends binary audio buffers to the server via WebSocket.
1508
+ */
1509
+ async connectToStream(streamInfo) {
1510
+ const url = streamInfo.audioOnlyUrl || streamInfo.flvUrl;
1511
+ if (!url) {
1512
+ this.emit("error", { code: "NO_STREAM_URL", message: "Server did not provide a usable stream URL" });
1513
+ return;
1514
+ }
1515
+ this.streamUrl = url;
1516
+ if (this.debug) console.log(`[Captions] connectToStream: URL selected: ${url.substring(0, 80)}...`);
1517
+ if (this.streamAbortController) {
1518
+ this.streamAbortController.abort();
1519
+ }
1520
+ this.streamAbortController = new AbortController();
1521
+ let audioFramesSent = 0;
1522
+ let audioBytesSent = 0;
1523
+ this.flvExtractor = new FlvAudioExtractor((adtsFrame) => {
1524
+ if (this.ws?.readyState === import_ws2.default.OPEN) {
1525
+ this.ws.send(adtsFrame);
1526
+ audioFramesSent++;
1527
+ audioBytesSent += adtsFrame.length;
1528
+ if (this.debug && (audioFramesSent <= 3 || audioFramesSent % 100 === 0)) {
1529
+ if (this.debug) console.log(`[Captions] Audio frame #${audioFramesSent}: ${adtsFrame.length}b (total: ${audioBytesSent}b)`);
1530
+ }
1531
+ } else if (this.debug && audioFramesSent === 0) {
1532
+ if (this.debug) console.log(`[Captions] WARNING: WS not open (readyState=${this.ws?.readyState}), cannot send audio`);
1533
+ }
1534
+ });
1535
+ try {
1536
+ if (this.debug) console.log(`[Captions] connectToStream: calling fetch()...`);
1537
+ const resp = await fetch(url, {
1538
+ signal: this.streamAbortController.signal,
1539
+ headers: {
1540
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
1541
+ }
1542
+ });
1543
+ if (this.debug) console.log(`[Captions] connectToStream: fetch returned status=${resp.status}, hasBody=${!!resp.body}`);
1544
+ if (!resp.ok || !resp.body) {
1545
+ throw new Error(`FLV stream HTTP ${resp.status}`);
1546
+ }
1547
+ if (this.debug) console.log(`[Captions] FLV stream connected (${resp.status})`);
1548
+ const reader = resp.body.getReader ? resp.body.getReader() : null;
1549
+ if (this.debug) console.log(`[Captions] connectToStream: hasReader=${!!reader}, hasAsyncIterator=${typeof resp.body[Symbol.asyncIterator] === "function"}`);
1550
+ if (reader) {
1551
+ const processStream = async () => {
1552
+ let chunks = 0;
1553
+ try {
1554
+ while (true) {
1555
+ const { done, value } = await reader.read();
1556
+ if (done || this.intentionalClose) {
1557
+ if (this.debug) console.log(`[Captions] FLV stream ended (done=${done}, intentionalClose=${this.intentionalClose}), chunks=${chunks}, audioFrames=${audioFramesSent}`);
1558
+ break;
1559
+ }
1560
+ chunks++;
1561
+ if (value && this.flvExtractor) {
1562
+ this.flvExtractor.push(value);
1563
+ }
1564
+ if (this.debug && chunks <= 3) {
1565
+ if (this.debug) console.log(`[Captions] FLV chunk #${chunks}: ${value?.length || 0}b`);
1566
+ }
1567
+ }
1568
+ } catch (err) {
1569
+ if (err.name !== "AbortError" && !this.intentionalClose) {
1570
+ if (this.debug) console.error("[Captions] FLV stream read error:", err.message);
1571
+ this.emit("error", { code: "STREAM_READ_ERROR", message: err.message });
1572
+ } else if (this.debug) {
1573
+ if (this.debug) console.log(`[Captions] FLV stream aborted after ${chunks} chunks, ${audioFramesSent} audio frames`);
1574
+ }
1575
+ }
1576
+ };
1577
+ processStream();
1578
+ } else if (typeof resp.body[Symbol.asyncIterator] === "function") {
1579
+ const processNodeStream = async () => {
1580
+ let chunks = 0;
1581
+ try {
1582
+ for await (const chunk of resp.body) {
1583
+ if (this.intentionalClose) break;
1584
+ chunks++;
1585
+ const u8 = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
1586
+ if (this.flvExtractor) {
1587
+ this.flvExtractor.push(u8);
1588
+ }
1589
+ if (this.debug && chunks <= 3) {
1590
+ if (this.debug) console.log(`[Captions] FLV chunk #${chunks}: ${u8.length}b`);
1591
+ }
1592
+ }
1593
+ if (this.debug) console.log(`[Captions] Node stream ended, chunks=${chunks}, audioFrames=${audioFramesSent}`);
1594
+ } catch (err) {
1595
+ if (err.name !== "AbortError" && !this.intentionalClose) {
1596
+ if (this.debug) console.error("[Captions] FLV stream read error:", err.message);
1597
+ this.emit("error", { code: "STREAM_READ_ERROR", message: err.message });
1598
+ } else if (this.debug) {
1599
+ if (this.debug) console.log(`[Captions] FLV node stream aborted after ${chunks} chunks, ${audioFramesSent} audio frames`);
1600
+ }
1601
+ }
1602
+ };
1603
+ processNodeStream();
1604
+ } else {
1605
+ if (this.debug) console.error(`[Captions] ERROR: resp.body has no getReader() and no asyncIterator!`);
1606
+ }
1607
+ } catch (err) {
1608
+ if (err.name !== "AbortError" && !this.intentionalClose) {
1609
+ if (this.debug) console.error("[Captions] FLV stream connect error:", err.message);
1610
+ this.emit("error", { code: "STREAM_CONNECT_ERROR", message: err.message });
1611
+ }
1612
+ }
1613
+ }
1371
1614
  };
1372
1615
 
1373
1616
  // src/api.ts
@@ -1425,10 +1668,47 @@ async function getRegionalRanklist(opts) {
1425
1668
  }
1426
1669
  return data;
1427
1670
  }
1671
+ async function getLiveFeed(opts) {
1672
+ const base = (opts.serverUrl || DEFAULT_SIGN_SERVER2).replace(/\/$/, "");
1673
+ const params = new URLSearchParams();
1674
+ params.set("apiKey", opts.apiKey);
1675
+ if (opts.region) params.set("region", opts.region);
1676
+ if (opts.channelId) params.set("channel_id", opts.channelId);
1677
+ if (opts.count !== void 0) params.set("count", String(Math.min(opts.count, 50)));
1678
+ if (opts.maxTime) params.set("max_time", opts.maxTime);
1679
+ if (opts.sessionId) params.set("session_id", opts.sessionId);
1680
+ if (opts.ttwid) params.set("ttwid", opts.ttwid);
1681
+ if (opts.msToken) params.set("ms_token", opts.msToken);
1682
+ const resp = await fetch(`${base}/webcast/feed?${params.toString()}`);
1683
+ const data = await resp.json();
1684
+ if (resp.status === 429) {
1685
+ throw new Error(data.error || "Feed daily limit reached. Upgrade your plan for more calls.");
1686
+ }
1687
+ if (!resp.ok) {
1688
+ throw new Error(data.error || `Feed request failed (HTTP ${resp.status})`);
1689
+ }
1690
+ return data;
1691
+ }
1692
+ async function fetchFeed(opts) {
1693
+ const signed = await getLiveFeed(opts);
1694
+ const headers = { ...signed.headers || {} };
1695
+ if (signed.cookies) {
1696
+ headers["Cookie"] = signed.cookies;
1697
+ }
1698
+ const resp = await fetch(signed.signed_url, { headers, redirect: "follow" });
1699
+ const text = await resp.text();
1700
+ try {
1701
+ return JSON.parse(text);
1702
+ } catch {
1703
+ return null;
1704
+ }
1705
+ }
1428
1706
  // Annotate the CommonJS export names for ESM import in node:
1429
1707
  0 && (module.exports = {
1430
1708
  TikTokCaptions,
1431
1709
  TikTokLive,
1710
+ fetchFeed,
1711
+ getLiveFeed,
1432
1712
  getRanklist,
1433
1713
  getRegionalRanklist
1434
1714
  });