@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.
- package/dist/app/abort.mjs +14 -0
- package/dist/app/abort.mjs.map +1 -0
- package/dist/{lib → app}/config.d.mts +73 -71
- package/dist/app/config.d.mts.map +1 -0
- package/dist/app/config.mjs +26 -0
- package/dist/app/config.mjs.map +1 -0
- package/dist/app/events.mjs +36 -0
- package/dist/app/events.mjs.map +1 -0
- package/dist/app/job.mjs +190 -0
- package/dist/app/job.mjs.map +1 -0
- package/dist/{lib → app}/logger.mjs +1 -1
- package/dist/app/logger.mjs.map +1 -0
- package/dist/{core/monitor.mjs → app/loop.mjs} +19 -9
- package/dist/app/loop.mjs.map +1 -0
- package/dist/app/recorder.d.mts +7 -0
- package/dist/app/recorder.d.mts.map +1 -0
- package/dist/app/recorder.mjs +233 -0
- package/dist/app/recorder.mjs.map +1 -0
- package/dist/core/errors.d.mts +16 -0
- package/dist/core/errors.d.mts.map +1 -0
- package/dist/core/errors.mjs +15 -0
- package/dist/core/errors.mjs.map +1 -0
- package/dist/core/format.d.mts +12 -0
- package/dist/core/format.d.mts.map +1 -0
- package/dist/{lib/utils.mjs → core/format.mjs} +6 -41
- package/dist/core/format.mjs.map +1 -0
- package/dist/core/plan.mjs +52 -0
- package/dist/core/plan.mjs.map +1 -0
- package/dist/core/verdict.d.mts +44 -0
- package/dist/core/verdict.d.mts.map +1 -0
- package/dist/core/verdict.mjs +47 -0
- package/dist/core/verdict.mjs.map +1 -0
- package/dist/index.d.mts +6 -3
- package/dist/index.mjs +5 -2
- package/dist/{core/api/client.mjs → io/http.mjs} +38 -10
- package/dist/io/http.mjs.map +1 -0
- package/dist/io/media/convert.mjs +60 -0
- package/dist/io/media/convert.mjs.map +1 -0
- package/dist/{core/recorder → io/media}/download.mjs +8 -7
- package/dist/io/media/download.mjs.map +1 -0
- package/dist/{core/recorder/ffmpeg-utils.mjs → io/media/ffmpeg.mjs} +45 -5
- package/dist/io/media/ffmpeg.mjs.map +1 -0
- package/dist/{core/recorder → io/media}/normalize.mjs +1 -1
- package/dist/io/media/normalize.mjs.map +1 -0
- package/dist/io/tiktok/client.mjs +77 -0
- package/dist/io/tiktok/client.mjs.map +1 -0
- package/dist/io/tiktok/discovery/chain.mjs +117 -0
- package/dist/io/tiktok/discovery/chain.mjs.map +1 -0
- package/dist/io/tiktok/discovery/fallback.mjs +64 -0
- package/dist/io/tiktok/discovery/fallback.mjs.map +1 -0
- package/dist/io/tiktok/discovery/page.mjs +102 -0
- package/dist/io/tiktok/discovery/page.mjs.map +1 -0
- package/dist/io/tiktok/page.mjs +34 -0
- package/dist/io/tiktok/page.mjs.map +1 -0
- package/dist/io/tiktok/parse.mjs +110 -0
- package/dist/io/tiktok/parse.mjs.map +1 -0
- package/dist/io/tiktok/webcast.mjs +132 -0
- package/dist/io/tiktok/webcast.mjs.map +1 -0
- package/package.json +5 -26
- package/src/app/abort.ts +18 -0
- package/src/app/config.ts +178 -0
- package/src/app/events.ts +46 -0
- package/src/app/job.ts +228 -0
- package/src/app/logger.ts +133 -0
- package/src/app/loop.ts +84 -0
- package/src/app/recorder.ts +288 -0
- package/src/core/errors.ts +24 -0
- package/src/core/format.ts +50 -0
- package/src/core/log.ts +13 -0
- package/src/core/plan.ts +49 -0
- package/src/core/verdict.ts +79 -0
- package/src/index.ts +38 -0
- package/src/io/http.ts +167 -0
- package/src/io/media/convert.ts +69 -0
- package/src/io/media/download.ts +120 -0
- package/src/io/media/ffmpeg.ts +201 -0
- package/src/io/media/normalize.ts +64 -0
- package/src/io/tiktok/client.ts +114 -0
- package/src/io/tiktok/discovery/chain.ts +146 -0
- package/src/io/tiktok/discovery/fallback.ts +88 -0
- package/src/io/tiktok/discovery/page.ts +142 -0
- package/src/io/tiktok/page.ts +42 -0
- package/src/io/tiktok/parse.ts +171 -0
- package/src/io/tiktok/webcast.ts +162 -0
- package/README.md +0 -430
- package/dist/bin.d.mts +0 -1
- package/dist/bin.mjs +0 -215
- package/dist/bin.mjs.map +0 -1
- package/dist/core/api/client.mjs.map +0 -1
- package/dist/core/api/rate-limiter.mjs +0 -29
- package/dist/core/api/rate-limiter.mjs.map +0 -1
- package/dist/core/api/tiktok.mjs +0 -394
- package/dist/core/api/tiktok.mjs.map +0 -1
- package/dist/core/monitor.mjs.map +0 -1
- package/dist/core/recorder/convert.mjs +0 -55
- package/dist/core/recorder/convert.mjs.map +0 -1
- package/dist/core/recorder/download.mjs.map +0 -1
- package/dist/core/recorder/ffmpeg-utils.mjs.map +0 -1
- package/dist/core/recorder/index.d.mts +0 -7
- package/dist/core/recorder/index.d.mts.map +0 -1
- package/dist/core/recorder/index.mjs +0 -205
- package/dist/core/recorder/index.mjs.map +0 -1
- package/dist/core/recorder/normalize.mjs.map +0 -1
- package/dist/core/recorder/post-processing.mjs +0 -154
- package/dist/core/recorder/post-processing.mjs.map +0 -1
- package/dist/core/recorder/recorder-events.mjs +0 -34
- package/dist/core/recorder/recorder-events.mjs.map +0 -1
- package/dist/core/recorder/recorder-state.mjs +0 -38
- package/dist/core/recorder/recorder-state.mjs.map +0 -1
- package/dist/core/recorder/stream.mjs +0 -25
- package/dist/core/recorder/stream.mjs.map +0 -1
- package/dist/lib/browser-cookies.mjs +0 -113
- package/dist/lib/browser-cookies.mjs.map +0 -1
- package/dist/lib/cli.mjs +0 -137
- package/dist/lib/cli.mjs.map +0 -1
- package/dist/lib/config.d.mts.map +0 -1
- package/dist/lib/config.mjs +0 -47
- package/dist/lib/config.mjs.map +0 -1
- package/dist/lib/logger.mjs.map +0 -1
- package/dist/lib/tmux.mjs +0 -84
- package/dist/lib/tmux.mjs.map +0 -1
- package/dist/lib/ui.mjs +0 -136
- package/dist/lib/ui.mjs.map +0 -1
- package/dist/lib/utils.mjs.map +0 -1
- package/dist/package.mjs +0 -6
- package/dist/package.mjs.map +0 -1
package/src/io/http.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP — the transport layer in one file: the wreq-js session (TLS fingerprint
|
|
3
|
+
* impersonation, cookie jar, proxy) and the token-bucket rate limiter that
|
|
4
|
+
* keeps polling from tripping TikTok's WAF.
|
|
5
|
+
*
|
|
6
|
+
* This file owns the `HttpClient` contract: it is the seam every adapter and
|
|
7
|
+
* `test/utils/mock-http.ts` implement. There is no second definition.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createSession, type Session } from "wreq-js"
|
|
11
|
+
import type { RecorderConfig } from "../app/config"
|
|
12
|
+
|
|
13
|
+
// BodyInit is only available inside declare module "bun" in bun-types, not as a global
|
|
14
|
+
// under the ES2022 lib. We explicitly import it to keep core/ focused on ES2022.
|
|
15
|
+
export type HttpBody = import("bun").BodyInit
|
|
16
|
+
|
|
17
|
+
export interface HttpClient {
|
|
18
|
+
get: (url: string, signal?: AbortSignal) => Promise<Response>
|
|
19
|
+
post: (
|
|
20
|
+
url: string,
|
|
21
|
+
body?: HttpBody,
|
|
22
|
+
headers?: Record<string, string>,
|
|
23
|
+
signal?: AbortSignal,
|
|
24
|
+
) => Promise<Response>
|
|
25
|
+
close: () => Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const REQUEST_TIMEOUT_MS = 15_000
|
|
29
|
+
|
|
30
|
+
/** Standard browser headers to avoid TikTok WAF detection. */
|
|
31
|
+
const BROWSER_HEADERS: Record<string, string> = {
|
|
32
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
|
33
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
34
|
+
"Accept-Language": "en-US,en;q=0.5",
|
|
35
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
36
|
+
"Upgrade-Insecure-Requests": "1",
|
|
37
|
+
"Sec-Fetch-Dest": "document",
|
|
38
|
+
"Sec-Fetch-Mode": "navigate",
|
|
39
|
+
"Sec-Fetch-Site": "none",
|
|
40
|
+
"Sec-Fetch-User": "?1",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function createHttpClient(config: RecorderConfig): Promise<HttpClient> {
|
|
44
|
+
const session: Session = await createSession({
|
|
45
|
+
browser: "firefox_149",
|
|
46
|
+
os: "windows",
|
|
47
|
+
proxy: config.proxy,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Seed cookies into the session jar so all subsequent requests are authenticated.
|
|
51
|
+
// NOTE: Using a Cookie header does NOT populate wreq-js's cookie jar —
|
|
52
|
+
// we must use session.setCookie() instead.
|
|
53
|
+
// Seed for all TikTok domains since cookies may not propagate across
|
|
54
|
+
// subdomains automatically in wreq-js's cookie jar.
|
|
55
|
+
if (config.cookies && "sessionid_ss" in config.cookies) {
|
|
56
|
+
const domains = ["https://www.tiktok.com", "https://webcast.tiktok.com", "https://m.tiktok.com"]
|
|
57
|
+
for (const domain of domains) {
|
|
58
|
+
for (const [name, value] of Object.entries(config.cookies)) {
|
|
59
|
+
session.setCookie(name, value, domain)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const rateLimiter = createRateLimiter(config.ratePerSecond ?? 5)
|
|
65
|
+
|
|
66
|
+
async function rateLimitedFetch(
|
|
67
|
+
url: string,
|
|
68
|
+
options?: { method?: string; body?: unknown; headers?: Record<string, string> },
|
|
69
|
+
external?: AbortSignal,
|
|
70
|
+
): Promise<Response> {
|
|
71
|
+
const controller = new AbortController()
|
|
72
|
+
const onAbort = () => controller.abort(external?.reason)
|
|
73
|
+
if (external?.aborted) controller.abort(external.reason)
|
|
74
|
+
else external?.addEventListener("abort", onAbort, { once: true })
|
|
75
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
|
76
|
+
try {
|
|
77
|
+
await rateLimiter.acquire(controller.signal)
|
|
78
|
+
const res = await session.fetch(url, {
|
|
79
|
+
method: options?.method,
|
|
80
|
+
body: options?.body as never,
|
|
81
|
+
headers: {
|
|
82
|
+
...BROWSER_HEADERS,
|
|
83
|
+
...options?.headers,
|
|
84
|
+
},
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
})
|
|
87
|
+
// SAFETY: wreq-js returns its own Response-compatible object; the port only
|
|
88
|
+
// needs the standard `status`, `ok`, and `text()` surface.
|
|
89
|
+
return res as unknown as Response
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(timeout)
|
|
92
|
+
external?.removeEventListener("abort", onAbort)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
get: async (url: string, signal?: AbortSignal) => rateLimitedFetch(url, undefined, signal),
|
|
98
|
+
post: async (
|
|
99
|
+
url: string,
|
|
100
|
+
body?: HttpBody,
|
|
101
|
+
headers?: Record<string, string>,
|
|
102
|
+
signal?: AbortSignal,
|
|
103
|
+
) =>
|
|
104
|
+
rateLimitedFetch(
|
|
105
|
+
url,
|
|
106
|
+
{
|
|
107
|
+
method: "POST",
|
|
108
|
+
body: body as never,
|
|
109
|
+
headers: {
|
|
110
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
111
|
+
...headers,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
signal,
|
|
115
|
+
),
|
|
116
|
+
close: async () => {
|
|
117
|
+
await session.close()
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── rate limiting ─────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
interface RateLimiter {
|
|
125
|
+
/** Wait until a rate-limit token is available, then consume it. */
|
|
126
|
+
acquire(signal?: AbortSignal): Promise<void>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Token bucket: a minimum interval between requests. `0` or less is unlimited.
|
|
131
|
+
* Abort-aware, so a stop does not wait out a pending token.
|
|
132
|
+
*/
|
|
133
|
+
function createRateLimiter(tokensPerSecond: number): RateLimiter {
|
|
134
|
+
if (tokensPerSecond <= 0) {
|
|
135
|
+
return { acquire: async () => {} }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const MIN_INTERVAL_MS = 1000 / tokensPerSecond
|
|
139
|
+
let lastTokenTime = 0
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
async acquire(signal?: AbortSignal): Promise<void> {
|
|
143
|
+
if (signal?.aborted) return
|
|
144
|
+
|
|
145
|
+
const now = Date.now()
|
|
146
|
+
const waitTime = Math.max(0, lastTokenTime + MIN_INTERVAL_MS - now)
|
|
147
|
+
|
|
148
|
+
if (waitTime > 0) {
|
|
149
|
+
await new Promise<void>((resolve) => {
|
|
150
|
+
const timer = setTimeout(resolve, waitTime)
|
|
151
|
+
if (signal) {
|
|
152
|
+
signal.addEventListener(
|
|
153
|
+
"abort",
|
|
154
|
+
() => {
|
|
155
|
+
clearTimeout(timer)
|
|
156
|
+
resolve()
|
|
157
|
+
},
|
|
158
|
+
{ once: true },
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
lastTokenTime = Date.now()
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converter — the media-processing capability `RecordingJob` depends on.
|
|
3
|
+
*
|
|
4
|
+
* Remux (TS → MP4) and segment both delegate to `ffmpeg-utils.runFfmpeg`, so
|
|
5
|
+
* the job never builds an FFmpeg command line.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { unlinkSync } from "node:fs"
|
|
9
|
+
import type { Logger } from "../../core/log"
|
|
10
|
+
import { runFfmpeg } from "./ffmpeg"
|
|
11
|
+
|
|
12
|
+
export interface Converter {
|
|
13
|
+
/** Convert a TS file to MP4 (stream copy). Deletes the original on success. */
|
|
14
|
+
convert: (input: string) => Promise<string>
|
|
15
|
+
/** Split a file into `<name>_partN.mp4`; returns FFmpeg's CSV segment listing. */
|
|
16
|
+
segment: (input: string, segmentSeconds: number, outputPattern: string) => Promise<string>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createConverter(logger?: Logger, signal?: AbortSignal): Converter {
|
|
20
|
+
async function convert(input: string): Promise<string> {
|
|
21
|
+
const output = input.replace(/\.(flv|ts)$/i, ".mp4")
|
|
22
|
+
|
|
23
|
+
logger?.info(`Converting: ${input} → ${output}`)
|
|
24
|
+
|
|
25
|
+
await runFfmpeg(["-i", input, "-c", "copy", "-y", output], signal)
|
|
26
|
+
|
|
27
|
+
// Delete original TS
|
|
28
|
+
try {
|
|
29
|
+
unlinkSync(input)
|
|
30
|
+
logger?.info(`Deleted original: ${input}`)
|
|
31
|
+
} catch {
|
|
32
|
+
logger?.warn(`Could not delete original: ${input}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
logger?.info(`Converted: ${output}`)
|
|
36
|
+
return output
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function segment(
|
|
40
|
+
input: string,
|
|
41
|
+
segmentSeconds: number,
|
|
42
|
+
outputPattern: string,
|
|
43
|
+
): Promise<string> {
|
|
44
|
+
return runFfmpeg(
|
|
45
|
+
[
|
|
46
|
+
"-i",
|
|
47
|
+
input,
|
|
48
|
+
"-c",
|
|
49
|
+
"copy",
|
|
50
|
+
"-f",
|
|
51
|
+
"segment",
|
|
52
|
+
"-segment_time",
|
|
53
|
+
String(segmentSeconds),
|
|
54
|
+
"-reset_timestamps",
|
|
55
|
+
"1",
|
|
56
|
+
"-segment_start_number",
|
|
57
|
+
"1",
|
|
58
|
+
"-segment_list",
|
|
59
|
+
"pipe:1",
|
|
60
|
+
"-segment_list_type",
|
|
61
|
+
"csv",
|
|
62
|
+
outputPattern,
|
|
63
|
+
],
|
|
64
|
+
signal,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { convert, segment }
|
|
69
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live stream downloader — downloads a live stream via FFmpeg stdout pipe.
|
|
3
|
+
*
|
|
4
|
+
* Outputs MPEG-TS (.ts) format, which is crash-safe and append-friendly.
|
|
5
|
+
* FFmpeg handles HTTP reconnection via -reconnect flags. When TikTok's
|
|
6
|
+
* short-lived stream URL expires, FFmpeg exits and the outer loop fetches
|
|
7
|
+
* a fresh URL via getNextUrl.
|
|
8
|
+
*
|
|
9
|
+
* Both FLV and HLS streams are handled by the same download function —
|
|
10
|
+
* the only difference is an optional label in the log messages.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createWriteStream, mkdirSync, statSync } from "node:fs"
|
|
14
|
+
import { join } from "node:path"
|
|
15
|
+
import type { DownloadProgress, RecordedFile } from "../../app/config"
|
|
16
|
+
import { TikTokError } from "../../core/errors"
|
|
17
|
+
import { bytesToHuman, formatDuration, formatFilename } from "../../core/format"
|
|
18
|
+
import type { Logger } from "../../core/log"
|
|
19
|
+
import { findFfmpegPath, pipeFfmpegSegment } from "./ffmpeg"
|
|
20
|
+
|
|
21
|
+
export async function downloadStream(
|
|
22
|
+
liveUrl: string,
|
|
23
|
+
user: string,
|
|
24
|
+
outputDir: string,
|
|
25
|
+
maxDuration: number,
|
|
26
|
+
onProgress: ((info: DownloadProgress) => void) | undefined,
|
|
27
|
+
getNextUrl: (() => Promise<string | null>) | undefined,
|
|
28
|
+
signal: AbortSignal,
|
|
29
|
+
logger?: Logger,
|
|
30
|
+
cookies?: Record<string, string>,
|
|
31
|
+
): Promise<RecordedFile> {
|
|
32
|
+
mkdirSync(outputDir, { recursive: true })
|
|
33
|
+
const filename = formatFilename(user, "ts")
|
|
34
|
+
const filepath = join(outputDir, filename)
|
|
35
|
+
// FLV is the default container — an .m3u8 URL means the CDN served HLS.
|
|
36
|
+
const prefix = liveUrl.includes(".m3u8") ? " (HLS)" : ""
|
|
37
|
+
logger?.info(`Recording${prefix}: ${filename}`)
|
|
38
|
+
|
|
39
|
+
const startTime = Date.now()
|
|
40
|
+
|
|
41
|
+
/** File size read directly from disk — authoritative, avoids in-memory counter drift. */
|
|
42
|
+
let fileSize = 0
|
|
43
|
+
let lastProgressTime = 0
|
|
44
|
+
|
|
45
|
+
const ffmpegPath = findFfmpegPath()
|
|
46
|
+
if (!ffmpegPath) {
|
|
47
|
+
throw new TikTokError(
|
|
48
|
+
"ffmpeg-not-found",
|
|
49
|
+
"FFmpeg not found — required for stream download. Install it:\n" +
|
|
50
|
+
" Linux: apt install ffmpeg\n macOS: brew install ffmpeg",
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function elapsed(): number {
|
|
55
|
+
return (Date.now() - startTime) / 1000
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const writer = createWriteStream(filepath)
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
while (!signal.aborted) {
|
|
62
|
+
const remaining = maxDuration > 0 ? Math.max(1, maxDuration - elapsed()) : undefined
|
|
63
|
+
await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining, cookies)
|
|
64
|
+
|
|
65
|
+
// Read actual file size from disk — always authoritative
|
|
66
|
+
try {
|
|
67
|
+
fileSize = statSync(filepath).size
|
|
68
|
+
} catch {
|
|
69
|
+
// File may not be accessible; leave fileSize at last known value
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Report progress after each segment
|
|
73
|
+
const now = Date.now()
|
|
74
|
+
const e = elapsed()
|
|
75
|
+
if (onProgress && now - lastProgressTime >= 1000) {
|
|
76
|
+
lastProgressTime = now
|
|
77
|
+
const speed = e > 0 ? fileSize / e : 0
|
|
78
|
+
onProgress({ bytes: fileSize, elapsed: e, speed })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (signal.aborted) break
|
|
82
|
+
if (maxDuration > 0 && e >= maxDuration) {
|
|
83
|
+
logger?.info(`Duration limit reached (${maxDuration}s)`)
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
if (!getNextUrl) break
|
|
87
|
+
|
|
88
|
+
const nextUrl = await getNextUrl()
|
|
89
|
+
if (!nextUrl) {
|
|
90
|
+
logger?.info("No new stream URL — recording finished")
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
liveUrl = nextUrl
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)
|
|
97
|
+
} finally {
|
|
98
|
+
await new Promise<void>((resolve) => writer.end(resolve))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const duration = elapsed()
|
|
102
|
+
// Final authoritative size after flush
|
|
103
|
+
try {
|
|
104
|
+
fileSize = statSync(filepath).size
|
|
105
|
+
} catch {
|
|
106
|
+
// keep last known value
|
|
107
|
+
}
|
|
108
|
+
if (onProgress) {
|
|
109
|
+
onProgress({
|
|
110
|
+
bytes: fileSize,
|
|
111
|
+
elapsed: duration,
|
|
112
|
+
speed: duration > 0 ? fileSize / duration : 0,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
logger?.info(
|
|
116
|
+
`Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(fileSize)})`,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return { file: filepath, duration, size: fileSize }
|
|
120
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FFmpeg — the single home for process invocation.
|
|
3
|
+
*
|
|
4
|
+
* Two modes: `pipeFfmpegSegment` streams stdout into a `WriteStream` (live
|
|
5
|
+
* capture), `runFfmpeg` runs to completion and returns stdout (remux,
|
|
6
|
+
* segment). Locating the binary lives here too, so no caller reimplements a
|
|
7
|
+
* PATH lookup.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type ChildProcess, spawn } from "node:child_process"
|
|
11
|
+
import type { WriteStream } from "node:fs"
|
|
12
|
+
import { statSync } from "node:fs"
|
|
13
|
+
import { execa } from "execa"
|
|
14
|
+
import { TikTokError } from "../../core/errors"
|
|
15
|
+
|
|
16
|
+
/** Kill FFmpeg if no output is received within this window after spawn. */
|
|
17
|
+
export const FFMPEG_STARTUP_TIMEOUT = 30_000
|
|
18
|
+
|
|
19
|
+
/** Find the FFmpeg binary via `Bun.which()` or a PATH search. */
|
|
20
|
+
export function findFfmpegPath(): string | null {
|
|
21
|
+
const bunWhich = (globalThis as { Bun?: { which?: (cmd: string) => string | null } }).Bun?.which
|
|
22
|
+
if (typeof bunWhich === "function") {
|
|
23
|
+
return (bunWhich("ffmpeg") as string) ?? null
|
|
24
|
+
}
|
|
25
|
+
const paths = process.env.PATH?.split(":") ?? []
|
|
26
|
+
for (const dir of paths) {
|
|
27
|
+
try {
|
|
28
|
+
const full = `${dir}/ffmpeg`
|
|
29
|
+
statSync(full)
|
|
30
|
+
return full
|
|
31
|
+
} catch {
|
|
32
|
+
// not found in this directory
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run FFmpeg to completion and return stdout — for output that is a file
|
|
40
|
+
* rather than a pipe (the segment muxer's `-segment_list pipe:1`).
|
|
41
|
+
*/
|
|
42
|
+
export async function runFfmpeg(args: string[], signal?: AbortSignal): Promise<string> {
|
|
43
|
+
const ffmpegPath = findFfmpegPath()
|
|
44
|
+
if (!ffmpegPath) {
|
|
45
|
+
throw new TikTokError(
|
|
46
|
+
"ffmpeg-not-found",
|
|
47
|
+
"FFmpeg not found. Install it:\n" +
|
|
48
|
+
" Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\n" +
|
|
49
|
+
" macOS: brew install ffmpeg\n" +
|
|
50
|
+
" Windows: choco install ffmpeg",
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const result = await execa(ffmpegPath, args, {
|
|
55
|
+
cancelSignal: signal,
|
|
56
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
57
|
+
})
|
|
58
|
+
return result.stdout ?? ""
|
|
59
|
+
} catch (err: unknown) {
|
|
60
|
+
if (signal?.aborted) {
|
|
61
|
+
throw new TikTokError("aborted", "Aborted by user")
|
|
62
|
+
}
|
|
63
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
64
|
+
throw new TikTokError("ffmpeg-error", `FFmpeg failed: ${message}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build FFmpeg HTTP option arguments for cookies and headers.
|
|
70
|
+
* TikTok's CDN requires session cookies and a Referer to serve the stream.
|
|
71
|
+
*/
|
|
72
|
+
function buildHttpArgs(cookies?: Record<string, string>): string[] {
|
|
73
|
+
const args: string[] = []
|
|
74
|
+
|
|
75
|
+
// ponytail: user-agent header, CDNs tend to be picky
|
|
76
|
+
args.push(
|
|
77
|
+
"-user_agent",
|
|
78
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
|
79
|
+
)
|
|
80
|
+
args.push("-headers", "Referer: https://www.tiktok.com/\r\n")
|
|
81
|
+
|
|
82
|
+
if (cookies && Object.keys(cookies).length > 0) {
|
|
83
|
+
const cookieStr = Object.entries(cookies)
|
|
84
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
85
|
+
.join("\r\n")
|
|
86
|
+
args.push("-cookies", cookieStr)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return args
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Standard FFmpeg reconnect + mpegts pipe arguments. */
|
|
93
|
+
const FFMPEG_BASE_ARGS = [
|
|
94
|
+
"-reconnect",
|
|
95
|
+
"1",
|
|
96
|
+
"-reconnect_at_eof",
|
|
97
|
+
"1",
|
|
98
|
+
"-reconnect_streamed",
|
|
99
|
+
"1",
|
|
100
|
+
"-reconnect_delay_max",
|
|
101
|
+
"5",
|
|
102
|
+
"-c",
|
|
103
|
+
"copy",
|
|
104
|
+
"-f",
|
|
105
|
+
"mpegts",
|
|
106
|
+
"pipe:1",
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Spawn an FFmpeg process to download from `url` and pipe its stdout to
|
|
111
|
+
* `writer` with backpressure handling.
|
|
112
|
+
*
|
|
113
|
+
* Uses child_process.spawn directly (not execa) because execa v9 defaults
|
|
114
|
+
* to encoding stdout as UTF-8, which corrupts binary MPEG-TS data.
|
|
115
|
+
*/
|
|
116
|
+
export async function pipeFfmpegSegment(
|
|
117
|
+
ffmpegPath: string,
|
|
118
|
+
url: string,
|
|
119
|
+
writer: WriteStream,
|
|
120
|
+
signal: AbortSignal,
|
|
121
|
+
maxDuration?: number,
|
|
122
|
+
cookies?: Record<string, string>,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
const durationArgs = maxDuration ? ["-t", String(maxDuration)] : []
|
|
125
|
+
const httpArgs = buildHttpArgs(cookies)
|
|
126
|
+
// ponytail: input options (-headers, -cookies, -user_agent) must come before -i
|
|
127
|
+
const args = [...httpArgs, "-i", url, ...durationArgs, ...FFMPEG_BASE_ARGS]
|
|
128
|
+
const proc: ChildProcess = spawn(ffmpegPath, args, {
|
|
129
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
let stderr = ""
|
|
133
|
+
let firstDataTimer: ReturnType<typeof setTimeout> | null = null
|
|
134
|
+
|
|
135
|
+
const clearStartupTimer = () => {
|
|
136
|
+
if (firstDataTimer) {
|
|
137
|
+
clearTimeout(firstDataTimer)
|
|
138
|
+
firstDataTimer = null
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Forward abort signal to process
|
|
143
|
+
const onAbort = () => {
|
|
144
|
+
if (!proc.killed) proc.kill("SIGTERM")
|
|
145
|
+
}
|
|
146
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
147
|
+
|
|
148
|
+
proc.stderr?.on("data", (chunk: Buffer) => {
|
|
149
|
+
clearStartupTimer()
|
|
150
|
+
stderr += chunk.toString()
|
|
151
|
+
if (stderr.length > 10000) stderr = stderr.slice(-5000)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
proc.stdout?.on("data", (chunk: Buffer) => {
|
|
155
|
+
clearStartupTimer()
|
|
156
|
+
const canContinue = writer.write(chunk)
|
|
157
|
+
if (!canContinue) {
|
|
158
|
+
proc.stdout?.pause()
|
|
159
|
+
writer.once("drain", () => proc.stdout?.resume())
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
await new Promise<void>((resolve, reject) => {
|
|
164
|
+
firstDataTimer = setTimeout(() => {
|
|
165
|
+
if (!proc.killed) {
|
|
166
|
+
proc.kill("SIGTERM")
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
if (!proc.killed) proc.kill("SIGKILL")
|
|
169
|
+
}, 2000)
|
|
170
|
+
}
|
|
171
|
+
reject(
|
|
172
|
+
new TikTokError(
|
|
173
|
+
"ffmpeg-error",
|
|
174
|
+
`FFmpeg startup timed out after ${FFMPEG_STARTUP_TIMEOUT / 1000}s\n${stderr.slice(-500)}`,
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
}, FFMPEG_STARTUP_TIMEOUT)
|
|
178
|
+
|
|
179
|
+
proc.on("close", (code) => {
|
|
180
|
+
clearStartupTimer()
|
|
181
|
+
signal.removeEventListener("abort", onAbort)
|
|
182
|
+
if (signal.aborted) {
|
|
183
|
+
resolve()
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
if (code === 0 || code === null) {
|
|
187
|
+
resolve()
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
reject(
|
|
191
|
+
new TikTokError("ffmpeg-error", `FFmpeg exited with code ${code}\n${stderr.slice(-500)}`),
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
proc.on("error", (err) => {
|
|
196
|
+
clearStartupTimer()
|
|
197
|
+
signal.removeEventListener("abort", onAbort)
|
|
198
|
+
reject(err)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio normalizer — wraps peaknorm's EBU R128 loudness normalization.
|
|
3
|
+
* Runs after conversion/segmenting to normalize the final MP4 audio.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { NormalizeOptions, NormalizeResult } from "peaknorm"
|
|
7
|
+
import { normalizeFile } from "peaknorm"
|
|
8
|
+
import type { Logger } from "../../core/log"
|
|
9
|
+
|
|
10
|
+
export interface AudioNormalizer {
|
|
11
|
+
/** Normalize a single audio/video file. Returns the normalize result. */
|
|
12
|
+
normalize(file: string): Promise<NormalizeResult>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AudioNormalizerOptions {
|
|
16
|
+
/** Target loudness in LUFS (default: -14). */
|
|
17
|
+
loudness: number
|
|
18
|
+
/** Audio codec for output (default: peaknorm's default, "libopus"). */
|
|
19
|
+
audioCodec?: string
|
|
20
|
+
/** Audio bitrate for output (default: peaknorm's default, "96k"). */
|
|
21
|
+
audioBitrate?: string
|
|
22
|
+
/** Called when a file starts normalization. */
|
|
23
|
+
onStart?: (file: string) => void
|
|
24
|
+
/** Called with progress updates (percent 0-100, phase "analyzing"|"normalizing"). */
|
|
25
|
+
onProgress?: (file: string, percent: number, phase: string) => void
|
|
26
|
+
/** Called when a file completes normalization successfully. */
|
|
27
|
+
onComplete?: (result: NormalizeResult) => void
|
|
28
|
+
/** Called when a file errors during normalization. */
|
|
29
|
+
onError?: (file: string, error: Error) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createAudioNormalizer(
|
|
33
|
+
opts: AudioNormalizerOptions,
|
|
34
|
+
logger?: Logger,
|
|
35
|
+
): AudioNormalizer {
|
|
36
|
+
return {
|
|
37
|
+
async normalize(file: string): Promise<NormalizeResult> {
|
|
38
|
+
logger?.info(`Normalizing audio: ${file}`)
|
|
39
|
+
opts.onStart?.(file)
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const normalizeOpts: NormalizeOptions = {
|
|
43
|
+
loudness: opts.loudness,
|
|
44
|
+
backup: false,
|
|
45
|
+
onFileProgress: (f, percent, phase) => {
|
|
46
|
+
opts.onProgress?.(f, percent, phase)
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
if (opts.audioCodec !== undefined) normalizeOpts.audioCodec = opts.audioCodec
|
|
50
|
+
if (opts.audioBitrate !== undefined) normalizeOpts.audioBitrate = opts.audioBitrate
|
|
51
|
+
const result = await normalizeFile(file, normalizeOpts)
|
|
52
|
+
|
|
53
|
+
logger?.info(`Audio normalized: ${result.output}`)
|
|
54
|
+
opts.onComplete?.(result)
|
|
55
|
+
return result
|
|
56
|
+
} catch (err) {
|
|
57
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
58
|
+
logger?.error(`Audio normalization failed: ${error.message}`)
|
|
59
|
+
opts.onError?.(file, error)
|
|
60
|
+
throw error
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|