@zfadhli/tokrec 0.12.2 → 0.15.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.
Files changed (126) hide show
  1. package/dist/app/abort.mjs +14 -0
  2. package/dist/app/abort.mjs.map +1 -0
  3. package/dist/{lib → app}/config.d.mts +73 -71
  4. package/dist/app/config.d.mts.map +1 -0
  5. package/dist/app/config.mjs +26 -0
  6. package/dist/app/config.mjs.map +1 -0
  7. package/dist/app/events.mjs +36 -0
  8. package/dist/app/events.mjs.map +1 -0
  9. package/dist/app/job.mjs +190 -0
  10. package/dist/app/job.mjs.map +1 -0
  11. package/dist/{lib → app}/logger.mjs +1 -1
  12. package/dist/app/logger.mjs.map +1 -0
  13. package/dist/{core/monitor.mjs → app/loop.mjs} +19 -9
  14. package/dist/app/loop.mjs.map +1 -0
  15. package/dist/app/recorder.d.mts +7 -0
  16. package/dist/app/recorder.d.mts.map +1 -0
  17. package/dist/app/recorder.mjs +233 -0
  18. package/dist/app/recorder.mjs.map +1 -0
  19. package/dist/core/errors.d.mts +16 -0
  20. package/dist/core/errors.d.mts.map +1 -0
  21. package/dist/core/errors.mjs +15 -0
  22. package/dist/core/errors.mjs.map +1 -0
  23. package/dist/core/format.d.mts +12 -0
  24. package/dist/core/format.d.mts.map +1 -0
  25. package/dist/{lib/utils.mjs → core/format.mjs} +6 -41
  26. package/dist/core/format.mjs.map +1 -0
  27. package/dist/core/plan.mjs +52 -0
  28. package/dist/core/plan.mjs.map +1 -0
  29. package/dist/core/verdict.d.mts +44 -0
  30. package/dist/core/verdict.d.mts.map +1 -0
  31. package/dist/core/verdict.mjs +47 -0
  32. package/dist/core/verdict.mjs.map +1 -0
  33. package/dist/index.d.mts +6 -3
  34. package/dist/index.mjs +5 -2
  35. package/dist/{core/api/client.mjs → io/http.mjs} +38 -10
  36. package/dist/io/http.mjs.map +1 -0
  37. package/dist/io/media/convert.mjs +60 -0
  38. package/dist/io/media/convert.mjs.map +1 -0
  39. package/dist/{core/recorder → io/media}/download.mjs +8 -7
  40. package/dist/io/media/download.mjs.map +1 -0
  41. package/dist/{core/recorder/ffmpeg-utils.mjs → io/media/ffmpeg.mjs} +45 -5
  42. package/dist/io/media/ffmpeg.mjs.map +1 -0
  43. package/dist/{core/recorder → io/media}/normalize.mjs +1 -1
  44. package/dist/io/media/normalize.mjs.map +1 -0
  45. package/dist/io/tiktok/client.mjs +77 -0
  46. package/dist/io/tiktok/client.mjs.map +1 -0
  47. package/dist/io/tiktok/discovery/chain.mjs +117 -0
  48. package/dist/io/tiktok/discovery/chain.mjs.map +1 -0
  49. package/dist/io/tiktok/discovery/fallback.mjs +64 -0
  50. package/dist/io/tiktok/discovery/fallback.mjs.map +1 -0
  51. package/dist/io/tiktok/discovery/page.mjs +102 -0
  52. package/dist/io/tiktok/discovery/page.mjs.map +1 -0
  53. package/dist/io/tiktok/page.mjs +34 -0
  54. package/dist/io/tiktok/page.mjs.map +1 -0
  55. package/dist/io/tiktok/parse.mjs +110 -0
  56. package/dist/io/tiktok/parse.mjs.map +1 -0
  57. package/dist/io/tiktok/webcast.mjs +132 -0
  58. package/dist/io/tiktok/webcast.mjs.map +1 -0
  59. package/package.json +5 -26
  60. package/src/app/abort.ts +18 -0
  61. package/src/app/config.ts +178 -0
  62. package/src/app/events.ts +46 -0
  63. package/src/app/job.ts +228 -0
  64. package/src/app/logger.ts +133 -0
  65. package/src/app/loop.ts +84 -0
  66. package/src/app/recorder.ts +288 -0
  67. package/src/core/errors.ts +24 -0
  68. package/src/core/format.ts +50 -0
  69. package/src/core/log.ts +13 -0
  70. package/src/core/plan.ts +49 -0
  71. package/src/core/verdict.ts +79 -0
  72. package/src/index.ts +38 -0
  73. package/src/io/http.ts +167 -0
  74. package/src/io/media/convert.ts +69 -0
  75. package/src/io/media/download.ts +120 -0
  76. package/src/io/media/ffmpeg.ts +201 -0
  77. package/src/io/media/normalize.ts +64 -0
  78. package/src/io/tiktok/client.ts +114 -0
  79. package/src/io/tiktok/discovery/chain.ts +146 -0
  80. package/src/io/tiktok/discovery/fallback.ts +88 -0
  81. package/src/io/tiktok/discovery/page.ts +142 -0
  82. package/src/io/tiktok/page.ts +42 -0
  83. package/src/io/tiktok/parse.ts +171 -0
  84. package/src/io/tiktok/webcast.ts +162 -0
  85. package/README.md +0 -430
  86. package/dist/bin.d.mts +0 -1
  87. package/dist/bin.mjs +0 -215
  88. package/dist/bin.mjs.map +0 -1
  89. package/dist/core/api/client.mjs.map +0 -1
  90. package/dist/core/api/rate-limiter.mjs +0 -29
  91. package/dist/core/api/rate-limiter.mjs.map +0 -1
  92. package/dist/core/api/tiktok.mjs +0 -394
  93. package/dist/core/api/tiktok.mjs.map +0 -1
  94. package/dist/core/monitor.mjs.map +0 -1
  95. package/dist/core/recorder/convert.mjs +0 -55
  96. package/dist/core/recorder/convert.mjs.map +0 -1
  97. package/dist/core/recorder/download.mjs.map +0 -1
  98. package/dist/core/recorder/ffmpeg-utils.mjs.map +0 -1
  99. package/dist/core/recorder/index.d.mts +0 -7
  100. package/dist/core/recorder/index.d.mts.map +0 -1
  101. package/dist/core/recorder/index.mjs +0 -205
  102. package/dist/core/recorder/index.mjs.map +0 -1
  103. package/dist/core/recorder/normalize.mjs.map +0 -1
  104. package/dist/core/recorder/post-processing.mjs +0 -154
  105. package/dist/core/recorder/post-processing.mjs.map +0 -1
  106. package/dist/core/recorder/recorder-events.mjs +0 -34
  107. package/dist/core/recorder/recorder-events.mjs.map +0 -1
  108. package/dist/core/recorder/recorder-state.mjs +0 -38
  109. package/dist/core/recorder/recorder-state.mjs.map +0 -1
  110. package/dist/core/recorder/stream.mjs +0 -25
  111. package/dist/core/recorder/stream.mjs.map +0 -1
  112. package/dist/lib/browser-cookies.mjs +0 -113
  113. package/dist/lib/browser-cookies.mjs.map +0 -1
  114. package/dist/lib/cli.mjs +0 -137
  115. package/dist/lib/cli.mjs.map +0 -1
  116. package/dist/lib/config.d.mts.map +0 -1
  117. package/dist/lib/config.mjs +0 -47
  118. package/dist/lib/config.mjs.map +0 -1
  119. package/dist/lib/logger.mjs.map +0 -1
  120. package/dist/lib/tmux.mjs +0 -84
  121. package/dist/lib/tmux.mjs.map +0 -1
  122. package/dist/lib/ui.mjs +0 -136
  123. package/dist/lib/ui.mjs.map +0 -1
  124. package/dist/lib/utils.mjs.map +0 -1
  125. package/dist/package.mjs +0 -6
  126. package/dist/package.mjs.map +0 -1
@@ -0,0 +1,102 @@
1
+ import { fromRoomInfo, miss, withStream } from "./chain.mjs";
2
+ import { extractSigiState, extractStreamUrlFast, extractUserFromUniversalData, findStreamUrlRecursively } from "../parse.mjs";
3
+ //#region src/io/tiktok/discovery/page.ts
4
+ /**
5
+ * Page source: server-rendered SIGI_STATE first, then the async-loaded legacy
6
+ * module, then `__UNIVERSAL_DATA_FOR_REHYDRATION__`. Throws propagate to the
7
+ * chain, which records them and moves on — never a dead end.
8
+ */
9
+ function createPageSource() {
10
+ return {
11
+ id: "page",
12
+ async find(ctx) {
13
+ let sigi = null;
14
+ let html = "";
15
+ let failure = null;
16
+ const take = (candidate) => {
17
+ if (candidate.kind === "room" || candidate.kind === "denied") return candidate;
18
+ if (candidate.kind === "failed") failure ??= candidate;
19
+ return null;
20
+ };
21
+ const page = await ctx.fetchPage(ctx.user, "/live");
22
+ if (!page) {
23
+ ctx.debug("fetchLiveInfo: all domains blocked for /live page");
24
+ html = "";
25
+ } else {
26
+ html = page.html;
27
+ ctx.debug(`fetchLiveInfo: /live returned ${html.length} bytes (${page.baseUrl}), first 300: ${html.slice(0, 300).replace(/\n/g, "\\n")}`);
28
+ sigi = extractSigiState(html);
29
+ ctx.debug(`fetchLiveInfo: SIGI_STATE ${sigi ? "found" : "not found"}`);
30
+ if (sigi) {
31
+ const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom;
32
+ const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user;
33
+ ctx.debug(`fetchLiveInfo: liveRoom=${!!liveRoom}, userInfo=${!!userInfo}, userInfo.roomId=${userInfo?.roomId ?? "null"}`);
34
+ if (liveRoom && userInfo?.roomId) {
35
+ const room = {
36
+ id: String(userInfo.roomId),
37
+ title: liveRoom.title ?? null,
38
+ live: liveRoom.status === 2
39
+ };
40
+ ctx.debug(`fetchLiveInfo: primary path — roomId=${room.id}, status=${liveRoom.status}, live=${room.live}`);
41
+ if (!room.live) return {
42
+ kind: "room",
43
+ room,
44
+ stream: null,
45
+ source: "sigi"
46
+ };
47
+ return withStream(ctx, room, "sigi", extractStreamUrlFast(liveRoom) ?? findStreamUrlRecursively(sigi));
48
+ }
49
+ if (userInfo?.roomId) {
50
+ const roomId = String(userInfo.roomId);
51
+ ctx.debug(`fetchLiveInfo: userInfo fallback — roomId=${roomId}, querying Webcast API`);
52
+ const found = take(await fromRoomInfo(ctx, roomId, "room-info"));
53
+ ctx.debug(`fetchLiveInfo: Webcast API ${found ? "returned room info" : "returned null"}`);
54
+ if (found) return found;
55
+ }
56
+ ctx.debug("fetchLiveInfo: trying legacy fallback (UserModule)");
57
+ const legacy = take(await sigiLegacy(sigi, ctx));
58
+ ctx.debug(`fetchLiveInfo: legacy fallback ${legacy ? "succeeded" : "returned null"}`);
59
+ if (legacy) return legacy;
60
+ }
61
+ }
62
+ ctx.debug(`fetchLiveInfo: trying universal data fallback (html length ${html.length})`);
63
+ const { roomId } = extractUserFromUniversalData(html);
64
+ ctx.debug(`fetchLiveInfo: universal data roomId=${roomId ?? "null"}`);
65
+ if (roomId) {
66
+ const found = take(await fromRoomInfo(ctx, roomId, "room-info"));
67
+ ctx.debug(`fetchLiveInfo: universal data Webcast API ${found ? "succeeded" : "returned null"}`);
68
+ if (found) return found;
69
+ }
70
+ return failure ?? miss("page: no room id");
71
+ }
72
+ };
73
+ }
74
+ /**
75
+ * Secondary live-info extraction when SIGI_STATE's LiveRoom data is
76
+ * async-loaded. Uses LiveRoom.liveRoomStatus for the live flag and
77
+ * UserModule.users[user].roomId for the room ID. Stream URL comes from the
78
+ * Webcast API (room/info/) as a final fallback.
79
+ */
80
+ async function sigiLegacy(sigi, ctx) {
81
+ const isLive = sigi.LiveRoom?.liveRoomStatus === 2;
82
+ const userModule = sigi.UserModule?.users?.[ctx.user];
83
+ let roomId = null;
84
+ if (userModule?.roomId) roomId = typeof userModule.roomId === "object" ? String(userModule.roomId.id ?? "") : String(userModule.roomId);
85
+ if (!roomId) return miss("sigi-legacy: no roomId");
86
+ const room = {
87
+ id: roomId,
88
+ title: null,
89
+ live: isLive
90
+ };
91
+ if (!isLive) return {
92
+ kind: "room",
93
+ room,
94
+ stream: null,
95
+ source: "sigi"
96
+ };
97
+ return withStream(ctx, room, "sigi", null);
98
+ }
99
+ //#endregion
100
+ export { createPageSource };
101
+
102
+ //# sourceMappingURL=page.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page.mjs","names":[],"sources":["../../../../src/io/tiktok/discovery/page.ts"],"sourcesContent":["import type { RoomRef } from \"../../../core/verdict\"\nimport type { SigiState } from \"../parse\"\nimport {\n extractSigiState,\n extractStreamUrlFast,\n extractUserFromUniversalData,\n findStreamUrlRecursively,\n} from \"../parse\"\nimport {\n type Candidate,\n type DiscoverySource,\n fromRoomInfo,\n miss,\n type ResolveContext,\n withStream,\n} from \"./chain\"\n\n/**\n * Page source: server-rendered SIGI_STATE first, then the async-loaded legacy\n * module, then `__UNIVERSAL_DATA_FOR_REHYDRATION__`. Throws propagate to the\n * chain, which records them and moves on — never a dead end.\n */\nexport function createPageSource(): DiscoverySource {\n return {\n id: \"page\",\n async find(ctx: ResolveContext): Promise<Candidate> {\n let sigi: SigiState | null = null\n let html = \"\"\n // A `failed` sub-step is not terminal: legacy + universal-data fallbacks\n // still get their chance. It is returned only if nothing else hit.\n let failure: Extract<Candidate, { kind: \"failed\" }> | null = null\n const take = (candidate: Candidate): Candidate | null => {\n if (candidate.kind === \"room\" || candidate.kind === \"denied\") return candidate\n if (candidate.kind === \"failed\") failure ??= candidate\n return null\n }\n\n const page = await ctx.fetchPage(ctx.user, \"/live\")\n if (!page) {\n ctx.debug(\"fetchLiveInfo: all domains blocked for /live page\")\n // Fall through to universal data / profile page fallback\n html = \"\"\n } else {\n html = page.html\n ctx.debug(\n `fetchLiveInfo: /live returned ${html.length} bytes (${page.baseUrl}), first 300: ${html.slice(0, 300).replace(/\\n/g, \"\\\\n\")}`,\n )\n\n sigi = extractSigiState(html)\n ctx.debug(`fetchLiveInfo: SIGI_STATE ${sigi ? \"found\" : \"not found\"}`)\n\n if (sigi) {\n const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom\n const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user\n\n ctx.debug(\n `fetchLiveInfo: liveRoom=${!!liveRoom}, userInfo=${!!userInfo}, userInfo.roomId=${userInfo?.roomId ?? \"null\"}`,\n )\n\n // ── Primary path: liveRoom is populated (server-rendered) ──\n if (liveRoom && userInfo?.roomId) {\n const room: RoomRef = {\n id: String(userInfo.roomId),\n title: liveRoom.title ?? null,\n live: liveRoom.status === 2, // 2 = live, 4 = offline\n }\n ctx.debug(\n `fetchLiveInfo: primary path — roomId=${room.id}, status=${liveRoom.status}, live=${room.live}`,\n )\n if (!room.live) return { kind: \"room\", room, stream: null, source: \"sigi\" }\n\n // Extract stream URL: try SIGI_STATE first, then Webcast API fallback\n const knownUrl = extractStreamUrlFast(liveRoom) ?? findStreamUrlRecursively(sigi)\n return withStream(ctx, room, \"sigi\", knownUrl)\n }\n\n // ── Fallback: userInfo has roomId, but liveRoom is async-loaded ──\n // TikTok may omit liveRoom from the server-rendered page and load it\n // via JavaScript. Query the Webcast API directly — it's authoritative.\n if (userInfo?.roomId) {\n const roomId = String(userInfo.roomId)\n ctx.debug(`fetchLiveInfo: userInfo fallback — roomId=${roomId}, querying Webcast API`)\n const found = take(await fromRoomInfo(ctx, roomId, \"room-info\"))\n ctx.debug(\n `fetchLiveInfo: Webcast API ${found ? \"returned room info\" : \"returned null\"}`,\n )\n if (found) return found\n }\n\n // ── Legacy fallback: async-loaded SIGI_STATE ──\n ctx.debug(\"fetchLiveInfo: trying legacy fallback (UserModule)\")\n const legacy = take(await sigiLegacy(sigi, ctx))\n ctx.debug(`fetchLiveInfo: legacy fallback ${legacy ? \"succeeded\" : \"returned null\"}`)\n if (legacy) return legacy\n } // end if (sigi)\n } // end else (page loaded)\n\n // ── Universal data from the already-fetched /live html ──\n // TikTok migrated from SIGI_STATE to __UNIVERSAL_DATA_FOR_REHYDRATION__.\n // The /live page may not have it, so try the main profile page.\n ctx.debug(`fetchLiveInfo: trying universal data fallback (html length ${html.length})`)\n const { roomId } = extractUserFromUniversalData(html)\n ctx.debug(`fetchLiveInfo: universal data roomId=${roomId ?? \"null\"}`)\n if (roomId) {\n const found = take(await fromRoomInfo(ctx, roomId, \"room-info\"))\n ctx.debug(\n `fetchLiveInfo: universal data Webcast API ${found ? \"succeeded\" : \"returned null\"}`,\n )\n if (found) return found\n }\n return failure ?? miss(\"page: no room id\")\n },\n }\n}\n\n/**\n * Secondary live-info extraction when SIGI_STATE's LiveRoom data is\n * async-loaded. Uses LiveRoom.liveRoomStatus for the live flag and\n * UserModule.users[user].roomId for the room ID. Stream URL comes from the\n * Webcast API (room/info/) as a final fallback.\n */\nasync function sigiLegacy(sigi: SigiState, ctx: ResolveContext): Promise<Candidate> {\n const isLive = sigi.LiveRoom?.liveRoomStatus === 2 // 2 = live\n\n // Try to get roomId from UserModule (usually populated even when LiveRoom is not)\n const userModule = sigi.UserModule?.users?.[ctx.user]\n let roomId: string | null = null\n\n if (userModule?.roomId) {\n roomId =\n typeof userModule.roomId === \"object\"\n ? String((userModule.roomId as { id: string }).id ?? \"\")\n : String(userModule.roomId)\n }\n\n // No roomId available — treat as offline (room/enter was unreliable).\n if (!roomId) return miss(\"sigi-legacy: no roomId\")\n\n const room: RoomRef = { id: roomId, title: null, live: isLive }\n if (!isLive) return { kind: \"room\", room, stream: null, source: \"sigi\" }\n return withStream(ctx, room, \"sigi\", null)\n}\n"],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ //#region src/io/tiktok/page.ts
2
+ const TIKTOK_BASES = ["https://www.tiktok.com", "https://m.tiktok.com"];
3
+ function createPageFetcher(http, debug, signal) {
4
+ /**
5
+ * Try to fetch a TikTok user page from multiple domains.
6
+ * www.tiktok.com is increasingly blocked by Slardar WAF; m.tiktok.com
7
+ * often has different bot protection and may succeed.
8
+ * Returns the HTML and the base URL that worked, or null if all fail.
9
+ */
10
+ async function tryFetchPage(user, path) {
11
+ for (const base of TIKTOK_BASES) {
12
+ const url = `${base}/@${user}${path}`;
13
+ const res = await http.get(url, signal);
14
+ debug(`tryFetchPage: ${url} status=${res.status}, ok=${res.ok}`);
15
+ if (!res.ok) continue;
16
+ const html = await res.text();
17
+ if (html.length < 5e3 || html.includes("SlardarWAF")) {
18
+ debug(`tryFetchPage: ${url} WAF blocked (${html.length} bytes, slardar=${html.includes("SlardarWAF")})`);
19
+ continue;
20
+ }
21
+ debug(`tryFetchPage: ${url} succeeded (${html.length} bytes)`);
22
+ return {
23
+ html,
24
+ baseUrl: base
25
+ };
26
+ }
27
+ return null;
28
+ }
29
+ return tryFetchPage;
30
+ }
31
+ //#endregion
32
+ export { createPageFetcher };
33
+
34
+ //# sourceMappingURL=page.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page.mjs","names":[],"sources":["../../../src/io/tiktok/page.ts"],"sourcesContent":["import type { HttpClient } from \"../http\"\n\nconst TIKTOK_BASES = [\"https://www.tiktok.com\", \"https://m.tiktok.com\"]\n\nexport function createPageFetcher(\n http: HttpClient,\n debug: (msg: string) => void,\n signal?: AbortSignal,\n): (user: string, path: string) => Promise<{ html: string; baseUrl: string } | null> {\n /**\n * Try to fetch a TikTok user page from multiple domains.\n * www.tiktok.com is increasingly blocked by Slardar WAF; m.tiktok.com\n * often has different bot protection and may succeed.\n * Returns the HTML and the base URL that worked, or null if all fail.\n */\n async function tryFetchPage(\n user: string,\n path: string,\n ): Promise<{ html: string; baseUrl: string } | null> {\n for (const base of TIKTOK_BASES) {\n const url = `${base}/@${user}${path}`\n const res = await http.get(url, signal)\n debug(`tryFetchPage: ${url} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) continue\n\n const html = await res.text()\n // Reject Slardar WAF challenge pages (small + keyword)\n if (html.length < 5000 || html.includes(\"SlardarWAF\")) {\n debug(\n `tryFetchPage: ${url} WAF blocked (${html.length} bytes, slardar=${html.includes(\"SlardarWAF\")})`,\n )\n continue\n }\n\n debug(`tryFetchPage: ${url} succeeded (${html.length} bytes)`)\n return { html, baseUrl: base }\n }\n return null\n }\n\n return tryFetchPage\n}\n"],"mappings":";AAEA,MAAM,eAAe,CAAC,0BAA0B,sBAAsB;AAEtE,SAAgB,kBACd,MACA,OACA,QACmF;;;;;;;CAOnF,eAAe,aACb,MACA,MACmD;EACnD,KAAK,MAAM,QAAQ,cAAc;GAC/B,MAAM,MAAM,GAAG,KAAK,IAAI,OAAO;GAC/B,MAAM,MAAM,MAAM,KAAK,IAAI,KAAK,MAAM;GACtC,MAAM,iBAAiB,IAAI,UAAU,IAAI,OAAO,OAAO,IAAI,IAAI;GAC/D,IAAI,CAAC,IAAI,IAAI;GAEb,MAAM,OAAO,MAAM,IAAI,KAAK;GAE5B,IAAI,KAAK,SAAS,OAAQ,KAAK,SAAS,YAAY,GAAG;IACrD,MACE,iBAAiB,IAAI,gBAAgB,KAAK,OAAO,kBAAkB,KAAK,SAAS,YAAY,EAAE,EACjG;IACA;GACF;GAEA,MAAM,iBAAiB,IAAI,cAAc,KAAK,OAAO,QAAQ;GAC7D,OAAO;IAAE;IAAM,SAAS;GAAK;EAC/B;EACA,OAAO;CACT;CAEA,OAAO;AACT"}
@@ -0,0 +1,110 @@
1
+ //#region src/io/tiktok/parse.ts
2
+ /**
3
+ * @public Exported for testing.
4
+ */
5
+ function extractSigiState(html) {
6
+ const match = html.match(/<script\s+id="SIGI_STATE"\s+type="application\/json">(.*?)<\/script>/);
7
+ if (!match?.[1]) return null;
8
+ try {
9
+ return JSON.parse(match[1]);
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+ /**
15
+ * Extract the numeric user ID and optional room ID from TikTok's new
16
+ * page structure (__UNIVERSAL_DATA_FOR_REHYDRATION__).
17
+ *
18
+ * The profile page (/@user) includes "webapp.user-detail" which
19
+ * contains the user's numeric ID. When the user is live, the roomId
20
+ * is also embedded here.
21
+ *
22
+ * Returns { userId, roomId } where userId is always set when the data
23
+ * is found, and roomId is set only when the user is currently live.
24
+ *
25
+ * @public Exported for testing and fixture capture.
26
+ */
27
+ function extractUserFromUniversalData(html) {
28
+ const match = html.match(/<script\s+id="__UNIVERSAL_DATA_FOR_REHYDRATION__"\s+type="application\/json">(.*?)<\/script>/);
29
+ if (!match?.[1]) return {
30
+ userId: null,
31
+ roomId: null
32
+ };
33
+ try {
34
+ const data = JSON.parse(match[1]);
35
+ const user = ((data.__DEFAULT_SCOPE__ ?? data)?.["webapp.user-detail"])?.userInfo?.user;
36
+ return {
37
+ userId: user?.id ? String(user.id) : null,
38
+ roomId: user?.roomId ? String(user.roomId) : null
39
+ };
40
+ } catch {
41
+ return {
42
+ userId: null,
43
+ roomId: null
44
+ };
45
+ }
46
+ }
47
+ /**
48
+ * Fast-path: extract stream URL from the known SIGI_STATE location.
49
+ * This covers ~95% of cases without needing a full recursive search.
50
+ *
51
+ * Prefers FLV (lower latency) over HLS/other protocols.
52
+ */
53
+ function extractStreamUrlFast(liveRoom) {
54
+ try {
55
+ const raw = liveRoom?.streamData?.pull_data?.stream_data;
56
+ if (!raw) return null;
57
+ const data = JSON.parse(raw)?.data;
58
+ if (!data) return null;
59
+ for (const q of [
60
+ "origin",
61
+ "fhd",
62
+ "uhd",
63
+ "hd",
64
+ "sd",
65
+ "ld"
66
+ ]) {
67
+ const entry = data[q];
68
+ const url = entry?.main?.flv ?? entry?.main?.hls;
69
+ if (url) return url;
70
+ }
71
+ return null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+ /**
77
+ * Brute-force recursive search for a stream URL anywhere in a JSON value.
78
+ * Survives TikTok changing key names or nesting structure.
79
+ *
80
+ * Prefers FLV over HLS when both are present in the response.
81
+ *
82
+ * Inspired by PR #430 (Michele0303/tiktok-live-recorder).
83
+ */
84
+ function findStreamUrlRecursively(obj) {
85
+ const flvUrls = [];
86
+ const m3u8Urls = [];
87
+ function walk(value) {
88
+ if (typeof value === "string") {
89
+ if ((value.startsWith("http://") || value.startsWith("https://")) && (value.includes(".flv") || value.includes(".m3u8"))) {
90
+ if (value.includes(".flv")) flvUrls.push(value);
91
+ else m3u8Urls.push(value);
92
+ return;
93
+ }
94
+ if (value.startsWith("{") || value.startsWith("[")) try {
95
+ walk(JSON.parse(value));
96
+ } catch {}
97
+ return;
98
+ }
99
+ if (value && typeof value === "object") if (Array.isArray(value)) for (const item of value) walk(item);
100
+ else for (const val of Object.values(value)) walk(val);
101
+ }
102
+ walk(obj);
103
+ if (flvUrls.length > 0) return flvUrls[0];
104
+ if (m3u8Urls.length > 0) return m3u8Urls[0];
105
+ return null;
106
+ }
107
+ //#endregion
108
+ export { extractSigiState, extractStreamUrlFast, extractUserFromUniversalData, findStreamUrlRecursively };
109
+
110
+ //# sourceMappingURL=parse.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parse.mjs","names":[],"sources":["../../../src/io/tiktok/parse.ts"],"sourcesContent":["export interface SigiState {\n LiveRoom?: {\n liveRoomStatus?: number\n liveRoomUserInfo?: {\n user?: {\n id?: string | number\n roomId?: string | number\n }\n liveRoom?: {\n status?: number // 2 = live, 4 = offline\n title?: string\n startTime?: number\n streamData?: {\n pull_data?: {\n stream_data?: string // JSON string with FLV/HLS URLs\n }\n }\n }\n }\n }\n UserModule?: {\n users?: {\n [username: string]: {\n id?: string // numeric user ID\n uniqueId?: string\n roomId?: string | { id: string }\n }\n }\n }\n}\n\n// ─── HTML parsing ──────────────────────────────────────────\n\n/**\n * @public Exported for testing.\n */\nexport function extractSigiState(html: string): SigiState | null {\n const match = html.match(/<script\\s+id=\"SIGI_STATE\"\\s+type=\"application\\/json\">(.*?)<\\/script>/)\n if (!match?.[1]) return null\n try {\n return JSON.parse(match[1]) as SigiState\n } catch {\n return null\n }\n}\n\n/**\n * Extract the numeric user ID and optional room ID from TikTok's new\n * page structure (__UNIVERSAL_DATA_FOR_REHYDRATION__).\n *\n * The profile page (/@user) includes \"webapp.user-detail\" which\n * contains the user's numeric ID. When the user is live, the roomId\n * is also embedded here.\n *\n * Returns { userId, roomId } where userId is always set when the data\n * is found, and roomId is set only when the user is currently live.\n *\n * @public Exported for testing and fixture capture.\n */\nexport function extractUserFromUniversalData(html: string): {\n userId: string | null\n roomId: string | null\n} {\n const match = html.match(\n /<script\\s+id=\"__UNIVERSAL_DATA_FOR_REHYDRATION__\"\\s+type=\"application\\/json\">(.*?)<\\/script>/,\n )\n if (!match?.[1]) return { userId: null, roomId: null }\n try {\n const data = JSON.parse(match[1])\n const scope = (data as Record<string, unknown>).__DEFAULT_SCOPE__ ?? data\n const userDetail = scope?.[\"webapp.user-detail\"]\n const user = userDetail?.userInfo?.user\n const userId = user?.id ? String(user.id) : null\n const roomId = user?.roomId ? String(user.roomId) : null\n return { userId, roomId }\n } catch {\n return { userId: null, roomId: null }\n }\n}\n\n// ─── Stream URL extraction ─────────────────────────────────\n\n/**\n * Fast-path: extract stream URL from the known SIGI_STATE location.\n * This covers ~95% of cases without needing a full recursive search.\n *\n * Prefers FLV (lower latency) over HLS/other protocols.\n */\nexport function extractStreamUrlFast(\n liveRoom: NonNullable<NonNullable<SigiState[\"LiveRoom\"]>[\"liveRoomUserInfo\"]>[\"liveRoom\"],\n): string | null {\n try {\n const raw = liveRoom?.streamData?.pull_data?.stream_data\n if (!raw) return null\n const parsed = JSON.parse(raw) as Record<string, unknown>\n const data = (parsed as Record<string, unknown>)?.data as Record<string, unknown> | undefined\n if (!data) return null\n\n // Try all known quality keys, preferring higher quality.\n // FLV is preferred (lower latency); HLS is the fallback.\n const qualities = [\"origin\", \"fhd\", \"uhd\", \"hd\", \"sd\", \"ld\"]\n for (const q of qualities) {\n const entry = data[q] as { main?: { flv?: string; hls?: string } } | undefined\n // Prefer FLV over HLS when both are available\n const url = entry?.main?.flv ?? entry?.main?.hls\n if (url) return url\n }\n return null\n } catch {\n return null\n }\n}\n\n/**\n * Brute-force recursive search for a stream URL anywhere in a JSON value.\n * Survives TikTok changing key names or nesting structure.\n *\n * Prefers FLV over HLS when both are present in the response.\n *\n * Inspired by PR #430 (Michele0303/tiktok-live-recorder).\n */\nexport function findStreamUrlRecursively(obj: unknown): string | null {\n // Collect all candidate URLs with their preference\n const flvUrls: string[] = []\n const m3u8Urls: string[] = []\n\n function walk(value: unknown): void {\n if (typeof value === \"string\") {\n // Direct URL match\n if (\n (value.startsWith(\"http://\") || value.startsWith(\"https://\")) &&\n (value.includes(\".flv\") || value.includes(\".m3u8\"))\n ) {\n if (value.includes(\".flv\")) {\n flvUrls.push(value)\n } else {\n m3u8Urls.push(value)\n }\n return\n }\n // JSON-encoded string — parse and recurse\n if (value.startsWith(\"{\") || value.startsWith(\"[\")) {\n try {\n walk(JSON.parse(value))\n } catch {\n // skip\n }\n }\n return\n }\n\n if (value && typeof value === \"object\") {\n if (Array.isArray(value)) {\n for (const item of value) {\n walk(item)\n }\n } else {\n for (const val of Object.values(value as Record<string, unknown>)) {\n walk(val)\n }\n }\n }\n }\n\n walk(obj)\n\n // Prefer FLV (lower latency) over HLS\n if (flvUrls.length > 0) return flvUrls[0] as string\n if (m3u8Urls.length > 0) return m3u8Urls[0] as string\n return null\n}\n"],"mappings":";;;;AAoCA,SAAgB,iBAAiB,MAAgC;CAC/D,MAAM,QAAQ,KAAK,MAAM,sEAAsE;CAC/F,IAAI,CAAC,QAAQ,IAAI,OAAO;CACxB,IAAI;EACF,OAAO,KAAK,MAAM,MAAM,EAAE;CAC5B,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;;;;;AAeA,SAAgB,6BAA6B,MAG3C;CACA,MAAM,QAAQ,KAAK,MACjB,8FACF;CACA,IAAI,CAAC,QAAQ,IAAI,OAAO;EAAE,QAAQ;EAAM,QAAQ;CAAK;CACrD,IAAI;EACF,MAAM,OAAO,KAAK,MAAM,MAAM,EAAE;EAGhC,MAAM,SAFS,KAAiC,qBAAqB,KAAA,GAC1C,sBAAA,EACF,UAAU;EAGnC,OAAO;GAAE,QAFM,MAAM,KAAK,OAAO,KAAK,EAAE,IAAI;GAE3B,QADF,MAAM,SAAS,OAAO,KAAK,MAAM,IAAI;EAC5B;CAC1B,QAAQ;EACN,OAAO;GAAE,QAAQ;GAAM,QAAQ;EAAK;CACtC;AACF;;;;;;;AAUA,SAAgB,qBACd,UACe;CACf,IAAI;EACF,MAAM,MAAM,UAAU,YAAY,WAAW;EAC7C,IAAI,CAAC,KAAK,OAAO;EAEjB,MAAM,OADS,KAAK,MAAM,GACP,CAAC,EAA8B;EAClD,IAAI,CAAC,MAAM,OAAO;EAKlB,KAAK,MAAM,KAAK;GADG;GAAU;GAAO;GAAO;GAAM;GAAM;EAC/B,GAAG;GACzB,MAAM,QAAQ,KAAK;GAEnB,MAAM,MAAM,OAAO,MAAM,OAAO,OAAO,MAAM;GAC7C,IAAI,KAAK,OAAO;EAClB;EACA,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;AAUA,SAAgB,yBAAyB,KAA6B;CAEpE,MAAM,UAAoB,CAAC;CAC3B,MAAM,WAAqB,CAAC;CAE5B,SAAS,KAAK,OAAsB;EAClC,IAAI,OAAO,UAAU,UAAU;GAE7B,KACG,MAAM,WAAW,SAAS,KAAK,MAAM,WAAW,UAAU,OAC1D,MAAM,SAAS,MAAM,KAAK,MAAM,SAAS,OAAO,IACjD;IACA,IAAI,MAAM,SAAS,MAAM,GACvB,QAAQ,KAAK,KAAK;SAElB,SAAS,KAAK,KAAK;IAErB;GACF;GAEA,IAAI,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,GAAG,GAC/C,IAAI;IACF,KAAK,KAAK,MAAM,KAAK,CAAC;GACxB,QAAQ,CAER;GAEF;EACF;EAEA,IAAI,SAAS,OAAO,UAAU,UAC5B,IAAI,MAAM,QAAQ,KAAK,GACrB,KAAK,MAAM,QAAQ,OACjB,KAAK,IAAI;OAGX,KAAK,MAAM,OAAO,OAAO,OAAO,KAAgC,GAC9D,KAAK,GAAG;CAIhB;CAEA,KAAK,GAAG;CAGR,IAAI,QAAQ,SAAS,GAAG,OAAO,QAAQ;CACvC,IAAI,SAAS,SAAS,GAAG,OAAO,SAAS;CACzC,OAAO;AACT"}
@@ -0,0 +1,132 @@
1
+ import { findStreamUrlRecursively } from "./parse.mjs";
2
+ //#region src/io/tiktok/webcast.ts
3
+ const WEBCAST_BASE = "https://webcast.tiktok.com";
4
+ function createWebcastApi(http, debug, signal) {
5
+ /**
6
+ * Shared boilerplate for Webcast room/info API calls.
7
+ * Handles URL construction, HTTP GET, JSON parsing, and error handling.
8
+ * Room-gate refusals (status_code 4003110) are reported to the caller
9
+ * instead of being collapsed into null; callers map failures.
10
+ * The caller provides an extractor for the response data.
11
+ */
12
+ async function fetchFromRoomApi(roomId, extract) {
13
+ try {
14
+ const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`;
15
+ const res = await http.get(url, signal);
16
+ debug(`fetchFromRoomApi: roomId=${roomId} status=${res.status}, ok=${res.ok}`);
17
+ if (!res.ok) return {
18
+ ok: false,
19
+ statusCode: res.status,
20
+ body: ""
21
+ };
22
+ const text = await res.text();
23
+ debug(`fetchFromRoomApi: roomId=${roomId} returned ${text.length} bytes`);
24
+ if (text.length === 0) return {
25
+ ok: true,
26
+ value: null
27
+ };
28
+ const data = JSON.parse(text);
29
+ if (data.status_code === 4003110) return {
30
+ ok: false,
31
+ statusCode: 4003110,
32
+ body: text
33
+ };
34
+ return {
35
+ ok: true,
36
+ value: extract(data)
37
+ };
38
+ } catch {
39
+ return {
40
+ ok: false,
41
+ statusCode: 0,
42
+ body: ""
43
+ };
44
+ }
45
+ }
46
+ /**
47
+ * Check if a room is currently alive using the lightweight
48
+ * /webcast/room/check_alive/ endpoint.
49
+ *
50
+ * This is faster and cheaper than fetching the full room/info response.
51
+ * Returns `true` if the room is live, `false` otherwise (including errors).
52
+ *
53
+ * Endpoint: /webcast/room/check_alive/?aid=1988&region=CH&room_ids={id}&user_is_login=true
54
+ * Response: { "data": [{ "alive": true/false, "room_id": "..." }] }
55
+ */
56
+ async function fetchCheckAlive(roomId) {
57
+ try {
58
+ const url = `${WEBCAST_BASE}/webcast/room/check_alive/?aid=1988&region=CH&room_ids=${roomId}&user_is_login=true`;
59
+ const res = await http.get(url, signal);
60
+ debug(`fetchCheckAlive: roomId=${roomId} status=${res.status}, ok=${res.ok}`);
61
+ if (!res.ok) return false;
62
+ const text = await res.text();
63
+ debug(`fetchCheckAlive: roomId=${roomId} returned ${text.length} bytes`);
64
+ if (text.length === 0) return false;
65
+ const alive = (JSON.parse(text)?.data)?.[0]?.alive === true;
66
+ debug(`fetchCheckAlive: roomId=${roomId} alive=${alive}`);
67
+ return alive;
68
+ } catch (err) {
69
+ debug(`fetchCheckAlive: roomId=${roomId} error — ${err instanceof Error ? err.message : String(err)}`);
70
+ return false;
71
+ }
72
+ }
73
+ /**
74
+ * Resolve a 4003110 room-gate refusal via check_alive: the room is live
75
+ * (restricted) when alive, offline otherwise. Single home for this mapping.
76
+ */
77
+ async function refusedRoom(roomId, title) {
78
+ debug(`refusedRoom: room/info refused roomId=${roomId} (status_code 4003110)`);
79
+ return {
80
+ id: roomId,
81
+ title,
82
+ live: await fetchCheckAlive(roomId)
83
+ };
84
+ }
85
+ /**
86
+ * Fetch room/info as domain facts. Requires a known roomId. A 4003110
87
+ * refusal stays `{ ok: false, statusCode: 4003110 }` for the caller to map
88
+ * via `refusedRoom`.
89
+ */
90
+ async function roomInfo(roomId) {
91
+ const res = await fetchFromRoomApi(roomId, (data) => data.data ?? null);
92
+ if (!res.ok) return res;
93
+ const roomData = res.value;
94
+ if (!roomData) return {
95
+ ok: true,
96
+ value: null
97
+ };
98
+ const title = roomData.title ?? null;
99
+ const streamUrl = findStreamUrlRecursively(roomData);
100
+ return {
101
+ ok: true,
102
+ value: {
103
+ room: {
104
+ id: roomId,
105
+ title,
106
+ live: (roomData.status ?? 4) === 2
107
+ },
108
+ stream: streamUrl ? { url: streamUrl } : null
109
+ }
110
+ };
111
+ }
112
+ /**
113
+ * Fetch stream URL from the Webcast API.
114
+ * Covers cases where SIGI_STATE doesn't include stream data.
115
+ * Refusals (4003110) and transport failures are reported so callers
116
+ * can distinguish restricted rooms from missing URLs.
117
+ */
118
+ async function fetchStreamUrlFromApi(roomId) {
119
+ return fetchFromRoomApi(roomId, (data) => findStreamUrlRecursively(data));
120
+ }
121
+ return {
122
+ fetchFromRoomApi,
123
+ fetchCheckAlive,
124
+ fetchStreamUrlFromApi,
125
+ roomInfo,
126
+ refusedRoom
127
+ };
128
+ }
129
+ //#endregion
130
+ export { createWebcastApi };
131
+
132
+ //# sourceMappingURL=webcast.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webcast.mjs","names":[],"sources":["../../../src/io/tiktok/webcast.ts"],"sourcesContent":["import type { RoomRef, StreamRef } from \"../../core/verdict\"\nimport type { HttpClient } from \"../http\"\nimport { findStreamUrlRecursively } from \"./parse\"\n\nconst WEBCAST_BASE = \"https://webcast.tiktok.com\"\n\n/**\n * Discriminated room/info result: refusals (notably status_code 4003110)\n * and transport failures surface as `{ ok: false }` instead of null.\n * `statusCode: 0` means the request threw before a status was read.\n */\nexport type RoomApiResult<T> =\n | { ok: true; value: T | null }\n | { ok: false; statusCode: number; body: string }\n\n/** Authoritative room/info facts: the room plus its stream URL when present. */\nexport interface RoomFacts {\n readonly room: RoomRef\n readonly stream: StreamRef | null\n}\n\n/** What discovery sources consume. `createWebcastApi` is the only implementation. */\nexport interface WebcastReader {\n fetchFromRoomApi: <T>(\n roomId: string,\n extract: (data: Record<string, unknown>) => T | null,\n ) => Promise<RoomApiResult<T>>\n fetchCheckAlive: (roomId: string) => Promise<boolean>\n fetchStreamUrlFromApi: (roomId: string) => Promise<RoomApiResult<string>>\n /** room/info as domain facts; 4003110 comes back as `{ ok: false }`. */\n roomInfo: (roomId: string) => Promise<RoomApiResult<RoomFacts>>\n /** Resolve a 4003110 refusal: alive → restricted candidate, else offline. */\n refusedRoom: (roomId: string, title: string | null) => Promise<RoomRef>\n}\n\nexport function createWebcastApi(\n http: HttpClient,\n debug: (msg: string) => void,\n signal?: AbortSignal,\n): WebcastReader {\n /**\n * Shared boilerplate for Webcast room/info API calls.\n * Handles URL construction, HTTP GET, JSON parsing, and error handling.\n * Room-gate refusals (status_code 4003110) are reported to the caller\n * instead of being collapsed into null; callers map failures.\n * The caller provides an extractor for the response data.\n */\n async function fetchFromRoomApi<T>(\n roomId: string,\n extract: (data: Record<string, unknown>) => T | null,\n ): Promise<RoomApiResult<T>> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`\n const res = await http.get(url, signal)\n debug(`fetchFromRoomApi: roomId=${roomId} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) return { ok: false, statusCode: res.status, body: \"\" }\n\n const text = await res.text()\n debug(`fetchFromRoomApi: roomId=${roomId} returned ${text.length} bytes`)\n if (text.length === 0) return { ok: true, value: null }\n\n const data = JSON.parse(text) as Record<string, unknown>\n\n // status_code 4003110 = age-protected/restricted room: report it\n if ((data as { status_code?: unknown }).status_code === 4003110) {\n return { ok: false, statusCode: 4003110, body: text }\n }\n\n return { ok: true, value: extract(data) }\n } catch {\n // statusCode 0 = transport/parse failure before a status was available.\n return { ok: false, statusCode: 0, body: \"\" }\n }\n }\n\n /**\n * Check if a room is currently alive using the lightweight\n * /webcast/room/check_alive/ endpoint.\n *\n * This is faster and cheaper than fetching the full room/info response.\n * Returns `true` if the room is live, `false` otherwise (including errors).\n *\n * Endpoint: /webcast/room/check_alive/?aid=1988&region=CH&room_ids={id}&user_is_login=true\n * Response: { \"data\": [{ \"alive\": true/false, \"room_id\": \"...\" }] }\n */\n async function fetchCheckAlive(roomId: string): Promise<boolean> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/check_alive/?aid=1988&region=CH&room_ids=${roomId}&user_is_login=true`\n const res = await http.get(url, signal)\n debug(`fetchCheckAlive: roomId=${roomId} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) return false\n\n const text = await res.text()\n debug(`fetchCheckAlive: roomId=${roomId} returned ${text.length} bytes`)\n if (text.length === 0) return false\n\n const data = JSON.parse(text) as Record<string, unknown>\n const items = data?.data as Array<Record<string, unknown>> | undefined\n const alive = items?.[0]?.alive === true\n debug(`fetchCheckAlive: roomId=${roomId} alive=${alive}`)\n return alive\n } catch (err) {\n debug(\n `fetchCheckAlive: roomId=${roomId} error — ${err instanceof Error ? err.message : String(err)}`,\n )\n return false\n }\n }\n\n /**\n * Resolve a 4003110 room-gate refusal via check_alive: the room is live\n * (restricted) when alive, offline otherwise. Single home for this mapping.\n */\n async function refusedRoom(roomId: string, title: string | null): Promise<RoomRef> {\n debug(`refusedRoom: room/info refused roomId=${roomId} (status_code 4003110)`)\n const live = await fetchCheckAlive(roomId)\n return { id: roomId, title, live }\n }\n\n /**\n * Fetch room/info as domain facts. Requires a known roomId. A 4003110\n * refusal stays `{ ok: false, statusCode: 4003110 }` for the caller to map\n * via `refusedRoom`.\n */\n async function roomInfo(roomId: string): Promise<RoomApiResult<RoomFacts>> {\n const res = await fetchFromRoomApi<Record<string, unknown>>(\n roomId,\n (data) => (data as { data?: Record<string, unknown> }).data ?? null,\n )\n if (!res.ok) return res\n const roomData = res.value\n if (!roomData) return { ok: true, value: null }\n\n const title = (roomData.title as string | undefined) ?? null\n const streamUrl = findStreamUrlRecursively(roomData)\n return {\n ok: true,\n value: {\n room: { id: roomId, title, live: (roomData.status ?? 4) === 2 },\n stream: streamUrl ? { url: streamUrl } : null,\n },\n }\n }\n\n /**\n * Fetch stream URL from the Webcast API.\n * Covers cases where SIGI_STATE doesn't include stream data.\n * Refusals (4003110) and transport failures are reported so callers\n * can distinguish restricted rooms from missing URLs.\n */\n async function fetchStreamUrlFromApi(roomId: string): Promise<RoomApiResult<string>> {\n return fetchFromRoomApi(roomId, (data) => findStreamUrlRecursively(data))\n }\n\n return {\n fetchFromRoomApi,\n fetchCheckAlive,\n fetchStreamUrlFromApi,\n roomInfo,\n refusedRoom,\n }\n}\n"],"mappings":";;AAIA,MAAM,eAAe;AA+BrB,SAAgB,iBACd,MACA,OACA,QACe;;;;;;;;CAQf,eAAe,iBACb,QACA,SAC2B;EAC3B,IAAI;GACF,MAAM,MAAM,GAAG,aAAa,uCAAuC,OAAO;GAC1E,MAAM,MAAM,MAAM,KAAK,IAAI,KAAK,MAAM;GACtC,MAAM,4BAA4B,OAAO,UAAU,IAAI,OAAO,OAAO,IAAI,IAAI;GAC7E,IAAI,CAAC,IAAI,IAAI,OAAO;IAAE,IAAI;IAAO,YAAY,IAAI;IAAQ,MAAM;GAAG;GAElE,MAAM,OAAO,MAAM,IAAI,KAAK;GAC5B,MAAM,4BAA4B,OAAO,YAAY,KAAK,OAAO,OAAO;GACxE,IAAI,KAAK,WAAW,GAAG,OAAO;IAAE,IAAI;IAAM,OAAO;GAAK;GAEtD,MAAM,OAAO,KAAK,MAAM,IAAI;GAG5B,IAAK,KAAmC,gBAAgB,SACtD,OAAO;IAAE,IAAI;IAAO,YAAY;IAAS,MAAM;GAAK;GAGtD,OAAO;IAAE,IAAI;IAAM,OAAO,QAAQ,IAAI;GAAE;EAC1C,QAAQ;GAEN,OAAO;IAAE,IAAI;IAAO,YAAY;IAAG,MAAM;GAAG;EAC9C;CACF;;;;;;;;;;;CAYA,eAAe,gBAAgB,QAAkC;EAC/D,IAAI;GACF,MAAM,MAAM,GAAG,aAAa,yDAAyD,OAAO;GAC5F,MAAM,MAAM,MAAM,KAAK,IAAI,KAAK,MAAM;GACtC,MAAM,2BAA2B,OAAO,UAAU,IAAI,OAAO,OAAO,IAAI,IAAI;GAC5E,IAAI,CAAC,IAAI,IAAI,OAAO;GAEpB,MAAM,OAAO,MAAM,IAAI,KAAK;GAC5B,MAAM,2BAA2B,OAAO,YAAY,KAAK,OAAO,OAAO;GACvE,IAAI,KAAK,WAAW,GAAG,OAAO;GAI9B,MAAM,SAFO,KAAK,MAAM,IACP,CAAC,EAAE,KAAA,GACE,EAAE,EAAE,UAAU;GACpC,MAAM,2BAA2B,OAAO,SAAS,OAAO;GACxD,OAAO;EACT,SAAS,KAAK;GACZ,MACE,2BAA2B,OAAO,WAAW,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GAC9F;GACA,OAAO;EACT;CACF;;;;;CAMA,eAAe,YAAY,QAAgB,OAAwC;EACjF,MAAM,yCAAyC,OAAO,uBAAuB;EAE7E,OAAO;GAAE,IAAI;GAAQ;GAAO,MAAA,MADT,gBAAgB,MAAM;EACR;CACnC;;;;;;CAOA,eAAe,SAAS,QAAmD;EACzE,MAAM,MAAM,MAAM,iBAChB,SACC,SAAU,KAA4C,QAAQ,IACjE;EACA,IAAI,CAAC,IAAI,IAAI,OAAO;EACpB,MAAM,WAAW,IAAI;EACrB,IAAI,CAAC,UAAU,OAAO;GAAE,IAAI;GAAM,OAAO;EAAK;EAE9C,MAAM,QAAS,SAAS,SAAgC;EACxD,MAAM,YAAY,yBAAyB,QAAQ;EACnD,OAAO;GACL,IAAI;GACJ,OAAO;IACL,MAAM;KAAE,IAAI;KAAQ;KAAO,OAAO,SAAS,UAAU,OAAO;IAAE;IAC9D,QAAQ,YAAY,EAAE,KAAK,UAAU,IAAI;GAC3C;EACF;CACF;;;;;;;CAQA,eAAe,sBAAsB,QAAgD;EACnF,OAAO,iBAAiB,SAAS,SAAS,yBAAyB,IAAI,CAAC;CAC1E;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA;CACF;AACF"}
package/package.json CHANGED
@@ -1,51 +1,30 @@
1
1
  {
2
2
  "name": "@zfadhli/tokrec",
3
- "version": "0.12.2",
3
+ "version": "0.15.0",
4
4
  "description": "Minimal TikTok live stream recorder — Bun + TypeScript",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
7
7
  "types": "./dist/index.d.mts",
8
8
  "exports": {
9
9
  ".": {
10
+ "bun": "./src/index.ts",
10
11
  "types": "./dist/index.d.mts",
11
12
  "default": "./dist/index.mjs"
12
- },
13
- "./cli": {
14
- "types": "./dist/bin.d.mts",
15
- "default": "./dist/bin.mjs"
16
13
  }
17
14
  },
18
- "bin": {
19
- "tokrec": "./dist/bin.mjs"
20
- },
21
15
  "files": [
22
- "dist"
16
+ "dist",
17
+ "src"
23
18
  ],
24
19
  "scripts": {
25
- "dev": "bun run src/bin.ts",
26
20
  "build": "tsdown",
27
- "typecheck": "tsc --noEmit",
28
- "test": "bun test",
29
- "lint": "biome check src/",
30
- "lint:fix": "biome check --write src/",
31
- "format": "biome format --write src/"
21
+ "typecheck": "tsc --noEmit"
32
22
  },
33
23
  "dependencies": {
34
- "@zfadhli/koko-cli": "^0.2.1",
35
24
  "execa": "^9.6.1",
36
25
  "peaknorm": "^0.7.0",
37
26
  "wreq-js": "^2.3.1"
38
27
  },
39
- "devDependencies": {
40
- "@biomejs/biome": "^2.5.0",
41
- "@types/bun": "latest",
42
- "lefthook": "^2.1.9",
43
- "tsdown": "^0.22.3",
44
- "typescript": "^6.0.3"
45
- },
46
- "engines": {
47
- "bun": ">=1.2"
48
- },
49
28
  "license": "MIT",
50
29
  "repository": {
51
30
  "type": "git",
@@ -0,0 +1,18 @@
1
+ /**
2
+ * One cancellation scope per recorder run. Everything that can block accepts
3
+ * `scope.signal`; `close()` aborts in-flight work immediately.
4
+ */
5
+
6
+ export interface AbortScope {
7
+ readonly signal: AbortSignal
8
+ close(reason?: string): void
9
+ }
10
+
11
+ export function abortScope(parent?: AbortSignal): AbortScope {
12
+ const controller = new AbortController()
13
+ if (parent) {
14
+ if (parent.aborted) controller.abort(parent.reason)
15
+ else parent.addEventListener("abort", () => controller.abort(parent.reason), { once: true })
16
+ }
17
+ return { signal: controller.signal, close: (reason) => controller.abort(reason) }
18
+ }