@zfadhli/tokrec 0.13.0 → 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,171 @@
1
+ export interface SigiState {
2
+ LiveRoom?: {
3
+ liveRoomStatus?: number
4
+ liveRoomUserInfo?: {
5
+ user?: {
6
+ id?: string | number
7
+ roomId?: string | number
8
+ }
9
+ liveRoom?: {
10
+ status?: number // 2 = live, 4 = offline
11
+ title?: string
12
+ startTime?: number
13
+ streamData?: {
14
+ pull_data?: {
15
+ stream_data?: string // JSON string with FLV/HLS URLs
16
+ }
17
+ }
18
+ }
19
+ }
20
+ }
21
+ UserModule?: {
22
+ users?: {
23
+ [username: string]: {
24
+ id?: string // numeric user ID
25
+ uniqueId?: string
26
+ roomId?: string | { id: string }
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ // ─── HTML parsing ──────────────────────────────────────────
33
+
34
+ /**
35
+ * @public Exported for testing.
36
+ */
37
+ export function extractSigiState(html: string): SigiState | null {
38
+ const match = html.match(/<script\s+id="SIGI_STATE"\s+type="application\/json">(.*?)<\/script>/)
39
+ if (!match?.[1]) return null
40
+ try {
41
+ return JSON.parse(match[1]) as SigiState
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Extract the numeric user ID and optional room ID from TikTok's new
49
+ * page structure (__UNIVERSAL_DATA_FOR_REHYDRATION__).
50
+ *
51
+ * The profile page (/@user) includes "webapp.user-detail" which
52
+ * contains the user's numeric ID. When the user is live, the roomId
53
+ * is also embedded here.
54
+ *
55
+ * Returns { userId, roomId } where userId is always set when the data
56
+ * is found, and roomId is set only when the user is currently live.
57
+ *
58
+ * @public Exported for testing and fixture capture.
59
+ */
60
+ export function extractUserFromUniversalData(html: string): {
61
+ userId: string | null
62
+ roomId: string | null
63
+ } {
64
+ const match = html.match(
65
+ /<script\s+id="__UNIVERSAL_DATA_FOR_REHYDRATION__"\s+type="application\/json">(.*?)<\/script>/,
66
+ )
67
+ if (!match?.[1]) return { userId: null, roomId: null }
68
+ try {
69
+ const data = JSON.parse(match[1])
70
+ const scope = (data as Record<string, unknown>).__DEFAULT_SCOPE__ ?? data
71
+ const userDetail = scope?.["webapp.user-detail"]
72
+ const user = userDetail?.userInfo?.user
73
+ const userId = user?.id ? String(user.id) : null
74
+ const roomId = user?.roomId ? String(user.roomId) : null
75
+ return { userId, roomId }
76
+ } catch {
77
+ return { userId: null, roomId: null }
78
+ }
79
+ }
80
+
81
+ // ─── Stream URL extraction ─────────────────────────────────
82
+
83
+ /**
84
+ * Fast-path: extract stream URL from the known SIGI_STATE location.
85
+ * This covers ~95% of cases without needing a full recursive search.
86
+ *
87
+ * Prefers FLV (lower latency) over HLS/other protocols.
88
+ */
89
+ export function extractStreamUrlFast(
90
+ liveRoom: NonNullable<NonNullable<SigiState["LiveRoom"]>["liveRoomUserInfo"]>["liveRoom"],
91
+ ): string | null {
92
+ try {
93
+ const raw = liveRoom?.streamData?.pull_data?.stream_data
94
+ if (!raw) return null
95
+ const parsed = JSON.parse(raw) as Record<string, unknown>
96
+ const data = (parsed as Record<string, unknown>)?.data as Record<string, unknown> | undefined
97
+ if (!data) return null
98
+
99
+ // Try all known quality keys, preferring higher quality.
100
+ // FLV is preferred (lower latency); HLS is the fallback.
101
+ const qualities = ["origin", "fhd", "uhd", "hd", "sd", "ld"]
102
+ for (const q of qualities) {
103
+ const entry = data[q] as { main?: { flv?: string; hls?: string } } | undefined
104
+ // Prefer FLV over HLS when both are available
105
+ const url = entry?.main?.flv ?? entry?.main?.hls
106
+ if (url) return url
107
+ }
108
+ return null
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Brute-force recursive search for a stream URL anywhere in a JSON value.
116
+ * Survives TikTok changing key names or nesting structure.
117
+ *
118
+ * Prefers FLV over HLS when both are present in the response.
119
+ *
120
+ * Inspired by PR #430 (Michele0303/tiktok-live-recorder).
121
+ */
122
+ export function findStreamUrlRecursively(obj: unknown): string | null {
123
+ // Collect all candidate URLs with their preference
124
+ const flvUrls: string[] = []
125
+ const m3u8Urls: string[] = []
126
+
127
+ function walk(value: unknown): void {
128
+ if (typeof value === "string") {
129
+ // Direct URL match
130
+ if (
131
+ (value.startsWith("http://") || value.startsWith("https://")) &&
132
+ (value.includes(".flv") || value.includes(".m3u8"))
133
+ ) {
134
+ if (value.includes(".flv")) {
135
+ flvUrls.push(value)
136
+ } else {
137
+ m3u8Urls.push(value)
138
+ }
139
+ return
140
+ }
141
+ // JSON-encoded string — parse and recurse
142
+ if (value.startsWith("{") || value.startsWith("[")) {
143
+ try {
144
+ walk(JSON.parse(value))
145
+ } catch {
146
+ // skip
147
+ }
148
+ }
149
+ return
150
+ }
151
+
152
+ if (value && typeof value === "object") {
153
+ if (Array.isArray(value)) {
154
+ for (const item of value) {
155
+ walk(item)
156
+ }
157
+ } else {
158
+ for (const val of Object.values(value as Record<string, unknown>)) {
159
+ walk(val)
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ walk(obj)
166
+
167
+ // Prefer FLV (lower latency) over HLS
168
+ if (flvUrls.length > 0) return flvUrls[0] as string
169
+ if (m3u8Urls.length > 0) return m3u8Urls[0] as string
170
+ return null
171
+ }
@@ -0,0 +1,162 @@
1
+ import type { RoomRef, StreamRef } from "../../core/verdict"
2
+ import type { HttpClient } from "../http"
3
+ import { findStreamUrlRecursively } from "./parse"
4
+
5
+ const WEBCAST_BASE = "https://webcast.tiktok.com"
6
+
7
+ /**
8
+ * Discriminated room/info result: refusals (notably status_code 4003110)
9
+ * and transport failures surface as `{ ok: false }` instead of null.
10
+ * `statusCode: 0` means the request threw before a status was read.
11
+ */
12
+ export type RoomApiResult<T> =
13
+ | { ok: true; value: T | null }
14
+ | { ok: false; statusCode: number; body: string }
15
+
16
+ /** Authoritative room/info facts: the room plus its stream URL when present. */
17
+ export interface RoomFacts {
18
+ readonly room: RoomRef
19
+ readonly stream: StreamRef | null
20
+ }
21
+
22
+ /** What discovery sources consume. `createWebcastApi` is the only implementation. */
23
+ export interface WebcastReader {
24
+ fetchFromRoomApi: <T>(
25
+ roomId: string,
26
+ extract: (data: Record<string, unknown>) => T | null,
27
+ ) => Promise<RoomApiResult<T>>
28
+ fetchCheckAlive: (roomId: string) => Promise<boolean>
29
+ fetchStreamUrlFromApi: (roomId: string) => Promise<RoomApiResult<string>>
30
+ /** room/info as domain facts; 4003110 comes back as `{ ok: false }`. */
31
+ roomInfo: (roomId: string) => Promise<RoomApiResult<RoomFacts>>
32
+ /** Resolve a 4003110 refusal: alive → restricted candidate, else offline. */
33
+ refusedRoom: (roomId: string, title: string | null) => Promise<RoomRef>
34
+ }
35
+
36
+ export function createWebcastApi(
37
+ http: HttpClient,
38
+ debug: (msg: string) => void,
39
+ signal?: AbortSignal,
40
+ ): WebcastReader {
41
+ /**
42
+ * Shared boilerplate for Webcast room/info API calls.
43
+ * Handles URL construction, HTTP GET, JSON parsing, and error handling.
44
+ * Room-gate refusals (status_code 4003110) are reported to the caller
45
+ * instead of being collapsed into null; callers map failures.
46
+ * The caller provides an extractor for the response data.
47
+ */
48
+ async function fetchFromRoomApi<T>(
49
+ roomId: string,
50
+ extract: (data: Record<string, unknown>) => T | null,
51
+ ): Promise<RoomApiResult<T>> {
52
+ try {
53
+ const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`
54
+ const res = await http.get(url, signal)
55
+ debug(`fetchFromRoomApi: roomId=${roomId} status=${res.status}, ok=${res.ok}`)
56
+ if (!res.ok) return { ok: false, statusCode: res.status, body: "" }
57
+
58
+ const text = await res.text()
59
+ debug(`fetchFromRoomApi: roomId=${roomId} returned ${text.length} bytes`)
60
+ if (text.length === 0) return { ok: true, value: null }
61
+
62
+ const data = JSON.parse(text) as Record<string, unknown>
63
+
64
+ // status_code 4003110 = age-protected/restricted room: report it
65
+ if ((data as { status_code?: unknown }).status_code === 4003110) {
66
+ return { ok: false, statusCode: 4003110, body: text }
67
+ }
68
+
69
+ return { ok: true, value: extract(data) }
70
+ } catch {
71
+ // statusCode 0 = transport/parse failure before a status was available.
72
+ return { ok: false, statusCode: 0, body: "" }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Check if a room is currently alive using the lightweight
78
+ * /webcast/room/check_alive/ endpoint.
79
+ *
80
+ * This is faster and cheaper than fetching the full room/info response.
81
+ * Returns `true` if the room is live, `false` otherwise (including errors).
82
+ *
83
+ * Endpoint: /webcast/room/check_alive/?aid=1988&region=CH&room_ids={id}&user_is_login=true
84
+ * Response: { "data": [{ "alive": true/false, "room_id": "..." }] }
85
+ */
86
+ async function fetchCheckAlive(roomId: string): Promise<boolean> {
87
+ try {
88
+ const url = `${WEBCAST_BASE}/webcast/room/check_alive/?aid=1988&region=CH&room_ids=${roomId}&user_is_login=true`
89
+ const res = await http.get(url, signal)
90
+ debug(`fetchCheckAlive: roomId=${roomId} status=${res.status}, ok=${res.ok}`)
91
+ if (!res.ok) return false
92
+
93
+ const text = await res.text()
94
+ debug(`fetchCheckAlive: roomId=${roomId} returned ${text.length} bytes`)
95
+ if (text.length === 0) return false
96
+
97
+ const data = JSON.parse(text) as Record<string, unknown>
98
+ const items = data?.data as Array<Record<string, unknown>> | undefined
99
+ const alive = items?.[0]?.alive === true
100
+ debug(`fetchCheckAlive: roomId=${roomId} alive=${alive}`)
101
+ return alive
102
+ } catch (err) {
103
+ debug(
104
+ `fetchCheckAlive: roomId=${roomId} error — ${err instanceof Error ? err.message : String(err)}`,
105
+ )
106
+ return false
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Resolve a 4003110 room-gate refusal via check_alive: the room is live
112
+ * (restricted) when alive, offline otherwise. Single home for this mapping.
113
+ */
114
+ async function refusedRoom(roomId: string, title: string | null): Promise<RoomRef> {
115
+ debug(`refusedRoom: room/info refused roomId=${roomId} (status_code 4003110)`)
116
+ const live = await fetchCheckAlive(roomId)
117
+ return { id: roomId, title, live }
118
+ }
119
+
120
+ /**
121
+ * Fetch room/info as domain facts. Requires a known roomId. A 4003110
122
+ * refusal stays `{ ok: false, statusCode: 4003110 }` for the caller to map
123
+ * via `refusedRoom`.
124
+ */
125
+ async function roomInfo(roomId: string): Promise<RoomApiResult<RoomFacts>> {
126
+ const res = await fetchFromRoomApi<Record<string, unknown>>(
127
+ roomId,
128
+ (data) => (data as { data?: Record<string, unknown> }).data ?? null,
129
+ )
130
+ if (!res.ok) return res
131
+ const roomData = res.value
132
+ if (!roomData) return { ok: true, value: null }
133
+
134
+ const title = (roomData.title as string | undefined) ?? null
135
+ const streamUrl = findStreamUrlRecursively(roomData)
136
+ return {
137
+ ok: true,
138
+ value: {
139
+ room: { id: roomId, title, live: (roomData.status ?? 4) === 2 },
140
+ stream: streamUrl ? { url: streamUrl } : null,
141
+ },
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Fetch stream URL from the Webcast API.
147
+ * Covers cases where SIGI_STATE doesn't include stream data.
148
+ * Refusals (4003110) and transport failures are reported so callers
149
+ * can distinguish restricted rooms from missing URLs.
150
+ */
151
+ async function fetchStreamUrlFromApi(roomId: string): Promise<RoomApiResult<string>> {
152
+ return fetchFromRoomApi(roomId, (data) => findStreamUrlRecursively(data))
153
+ }
154
+
155
+ return {
156
+ fetchFromRoomApi,
157
+ fetchCheckAlive,
158
+ fetchStreamUrlFromApi,
159
+ roomInfo,
160
+ refusedRoom,
161
+ }
162
+ }