tokwatchr 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +368 -0
- package/dist/index.d.mts +327 -0
- package/dist/index.mjs +1146 -0
- package/package.json +55 -0
- package/src/TikTokLiveDownloader.ts +794 -0
- package/src/api/client.ts +46 -0
- package/src/api/room.ts +193 -0
- package/src/api/stream.ts +76 -0
- package/src/download/ffmpeg.ts +303 -0
- package/src/download/raw-http.ts +150 -0
- package/src/errors.ts +58 -0
- package/src/index.ts +45 -0
- package/src/types.ts +155 -0
- package/src/utils/quality.ts +171 -0
- package/src/utils/template.ts +67 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Browser, ImpitOptions } from "impit";
|
|
2
|
+
import { Impit } from "impit";
|
|
3
|
+
import type { CookieJarLike } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export interface CreateClientOptions {
|
|
6
|
+
browser?: Browser;
|
|
7
|
+
proxyUrl?: string | null;
|
|
8
|
+
timeout?: number;
|
|
9
|
+
headers?: Record<string, string>;
|
|
10
|
+
cookieJar?: CookieJarLike | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create an `Impit` HTTP client configured for TikTok.
|
|
15
|
+
*
|
|
16
|
+
* Uses browser TLS fingerprint emulation to bypass bot detection.
|
|
17
|
+
*/
|
|
18
|
+
export function createClient(options: CreateClientOptions = {}): Impit {
|
|
19
|
+
const impitOptions: ImpitOptions = {
|
|
20
|
+
browser: options.browser ?? "chrome",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
if (options.proxyUrl) {
|
|
24
|
+
impitOptions.proxyUrl = options.proxyUrl;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (options.timeout) {
|
|
28
|
+
impitOptions.timeout = options.timeout;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (options.headers && Object.keys(options.headers).length > 0) {
|
|
32
|
+
impitOptions.headers = options.headers;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (options.cookieJar) {
|
|
36
|
+
const jar = options.cookieJar;
|
|
37
|
+
impitOptions.cookieJar = {
|
|
38
|
+
setCookie: (cookie: string, url: string) =>
|
|
39
|
+
Promise.resolve(jar.setCookie(cookie, url)),
|
|
40
|
+
getCookieString: (url: string) =>
|
|
41
|
+
Promise.resolve(jar.getCookieString(url)),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return new Impit(impitOptions);
|
|
46
|
+
}
|
package/src/api/room.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { Impit } from "impit";
|
|
2
|
+
import { RoomResolveError, UserNotFoundError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
export interface RoomResolveOptions {
|
|
5
|
+
impIt?: Impit;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* TikTok API endpoint patterns for room ID resolution.
|
|
11
|
+
*/
|
|
12
|
+
const TIKTOK_LIVE_URL = "https://www.tiktok.com/@{username}/live";
|
|
13
|
+
const TIKTOK_PROFILE_URL = "https://www.tiktok.com/@{username}";
|
|
14
|
+
const TIKTOK_API_ROOM_URL =
|
|
15
|
+
"https://www.tiktok.com/api-live/user/room/?aid=1988&uniqueId={username}&sourceType=54";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check whether a TikTok username exists by hitting the profile page.
|
|
19
|
+
* Throws UserNotFoundError if the account doesn't exist.
|
|
20
|
+
*/
|
|
21
|
+
async function checkUserExists(
|
|
22
|
+
username: string,
|
|
23
|
+
impIt: Impit,
|
|
24
|
+
signal?: AbortSignal,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const url = TIKTOK_PROFILE_URL.replace("{username}", username);
|
|
27
|
+
const response = await impIt.fetch(url, {
|
|
28
|
+
headers: {
|
|
29
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
30
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
31
|
+
},
|
|
32
|
+
signal,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (response.status === 404) {
|
|
36
|
+
throw new UserNotFoundError(username);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a TikTok username to a live room ID.
|
|
42
|
+
*
|
|
43
|
+
* Strategy:
|
|
44
|
+
* 0. Verify the user exists (profile page check).
|
|
45
|
+
* 1. Scrape the user's live page HTML for the room ID in SIGI_STATE.
|
|
46
|
+
* 2. Fall back to the TikTok API.
|
|
47
|
+
* 3. Throw if neither works.
|
|
48
|
+
*/
|
|
49
|
+
export async function resolveRoomId(
|
|
50
|
+
username: string,
|
|
51
|
+
impIt: Impit,
|
|
52
|
+
options: RoomResolveOptions = {},
|
|
53
|
+
): Promise<string> {
|
|
54
|
+
const cleanUsername = username.replace(/^@/, "").trim();
|
|
55
|
+
|
|
56
|
+
// Strategy 0: Verify the user exists
|
|
57
|
+
await checkUserExists(cleanUsername, impIt, options.signal);
|
|
58
|
+
|
|
59
|
+
// Strategy 1: Scrape the live page
|
|
60
|
+
const roomId = await tryScrapeRoomId(cleanUsername, impIt, options.signal);
|
|
61
|
+
if (roomId) {
|
|
62
|
+
return roomId;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Strategy 2: Use the API
|
|
66
|
+
const apiRoomId = await tryApiRoomId(cleanUsername, impIt, options.signal);
|
|
67
|
+
if (apiRoomId) {
|
|
68
|
+
return apiRoomId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new RoomResolveError(cleanUsername);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Try to extract room ID from the TikTok live page HTML.
|
|
76
|
+
*/
|
|
77
|
+
async function tryScrapeRoomId(
|
|
78
|
+
username: string,
|
|
79
|
+
impIt: Impit,
|
|
80
|
+
signal?: AbortSignal,
|
|
81
|
+
): Promise<string | null> {
|
|
82
|
+
try {
|
|
83
|
+
const url = TIKTOK_LIVE_URL.replace("{username}", username);
|
|
84
|
+
const response = await impIt.fetch(url, {
|
|
85
|
+
headers: {
|
|
86
|
+
Accept:
|
|
87
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
88
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
89
|
+
},
|
|
90
|
+
signal,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const html = await response.text();
|
|
98
|
+
|
|
99
|
+
// Try SIGI_STATE JSON first
|
|
100
|
+
const roomId = extractRoomIdFromSigiState(html);
|
|
101
|
+
if (roomId) return roomId;
|
|
102
|
+
|
|
103
|
+
// Fallback: regex for "roomId":"<digits>"
|
|
104
|
+
const roomIdMatch = html.match(/"roomId"\s*:\s*"(\d+)"/);
|
|
105
|
+
if (roomIdMatch?.[1]) return roomIdMatch[1];
|
|
106
|
+
|
|
107
|
+
// Fallback: regex for room_id=<digits>
|
|
108
|
+
const roomIdParamMatch = html.match(/room_id=(\d+)/);
|
|
109
|
+
if (roomIdParamMatch?.[1]) return roomIdParamMatch[1];
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Try to extract room ID from SIGI_STATE embedded JSON.
|
|
119
|
+
*/
|
|
120
|
+
function extractRoomIdFromSigiState(html: string): string | null {
|
|
121
|
+
try {
|
|
122
|
+
const match = html.match(
|
|
123
|
+
/<script[^>]*id=["']SIGI_STATE["'][^>]*type=["']application\/json["'][^>]*>([\s\S]*?)<\/script>/i,
|
|
124
|
+
);
|
|
125
|
+
if (!match) return null;
|
|
126
|
+
|
|
127
|
+
const sigiState = JSON.parse(match[1] as string);
|
|
128
|
+
|
|
129
|
+
// Traverse potential paths
|
|
130
|
+
const liveRoom = sigiState?.LiveRoom?.liveRoomInfo;
|
|
131
|
+
if (liveRoom?.roomId) {
|
|
132
|
+
return String(liveRoom.roomId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Alternate path
|
|
136
|
+
const liveRoom2 = sigiState?.LiveRoom?.liveRoomInfo?.user?.roomId;
|
|
137
|
+
if (liveRoom2) {
|
|
138
|
+
return String(liveRoom2);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return null;
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Try to resolve room ID via the TikTok API.
|
|
149
|
+
*/
|
|
150
|
+
async function tryApiRoomId(
|
|
151
|
+
username: string,
|
|
152
|
+
impIt: Impit,
|
|
153
|
+
signal?: AbortSignal,
|
|
154
|
+
): Promise<string | null> {
|
|
155
|
+
try {
|
|
156
|
+
const url = TIKTOK_API_ROOM_URL.replace(
|
|
157
|
+
"{username}",
|
|
158
|
+
encodeURIComponent(username),
|
|
159
|
+
);
|
|
160
|
+
const response = await impIt.fetch(url, {
|
|
161
|
+
headers: {
|
|
162
|
+
Accept: "application/json, text/plain, */*",
|
|
163
|
+
Referer: `https://www.tiktok.com/@${username}`,
|
|
164
|
+
},
|
|
165
|
+
signal,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
173
|
+
const dataObj = data.data as Record<string, unknown> | undefined;
|
|
174
|
+
|
|
175
|
+
if (!dataObj) return null;
|
|
176
|
+
|
|
177
|
+
// Check status - 2 means live
|
|
178
|
+
const liveRoom = dataObj.liveRoom as Record<string, unknown> | undefined;
|
|
179
|
+
if (liveRoom?.status !== 2) return null;
|
|
180
|
+
|
|
181
|
+
const user = dataObj.user as Record<string, unknown> | undefined;
|
|
182
|
+
if (!user) return null;
|
|
183
|
+
|
|
184
|
+
const roomId = user.roomId;
|
|
185
|
+
if (roomId) {
|
|
186
|
+
return String(roomId);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return null;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Impit } from "impit";
|
|
2
|
+
import { StreamFetchError } from "../errors.js";
|
|
3
|
+
import type { StreamInfo, StreamQualityKey } from "../types.js";
|
|
4
|
+
import { parseQualities, selectQuality } from "../utils/quality.js";
|
|
5
|
+
|
|
6
|
+
export interface StreamInfoOptions {
|
|
7
|
+
quality?: "best" | "worst" | StreamQualityKey;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fetch stream info for a given room ID.
|
|
13
|
+
*
|
|
14
|
+
* Calls the TikTok webcast room/info API and parses the response
|
|
15
|
+
* to extract stream URLs and quality options.
|
|
16
|
+
*/
|
|
17
|
+
export async function fetchStreamInfo(
|
|
18
|
+
roomId: string,
|
|
19
|
+
username: string,
|
|
20
|
+
impIt: Impit,
|
|
21
|
+
options: StreamInfoOptions = {},
|
|
22
|
+
): Promise<StreamInfo> {
|
|
23
|
+
const url = `https://webcast.tiktok.com/webcast/room/info/?aid=1988&room_id=${encodeURIComponent(roomId)}`;
|
|
24
|
+
|
|
25
|
+
const response = await impIt.fetch(url, {
|
|
26
|
+
headers: {
|
|
27
|
+
Accept: "application/json, text/plain, */*",
|
|
28
|
+
Referer: `https://www.tiktok.com/@${username}/live`,
|
|
29
|
+
},
|
|
30
|
+
signal: options.signal,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
throw new StreamFetchError(
|
|
35
|
+
roomId,
|
|
36
|
+
`HTTP ${response.status}: ${response.statusText}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const json = (await response.json()) as Record<string, unknown>;
|
|
41
|
+
const data = json.data as Record<string, unknown> | undefined;
|
|
42
|
+
|
|
43
|
+
if (!data) {
|
|
44
|
+
throw new StreamFetchError(roomId, "No data in response");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const status = data.status;
|
|
48
|
+
if (status !== 2) {
|
|
49
|
+
throw new StreamFetchError(roomId, `Room is not live (status: ${status})`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const qualities = parseQualities(data);
|
|
53
|
+
if (qualities.length === 0) {
|
|
54
|
+
throw new StreamFetchError(roomId, "No stream qualities found");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const selectedQuality = selectQuality(qualities, options.quality ?? "best");
|
|
58
|
+
|
|
59
|
+
const title = typeof data.title === "string" ? data.title : "";
|
|
60
|
+
const viewerCount =
|
|
61
|
+
typeof (data.stats as Record<string, unknown> | undefined)?.viewer_count ===
|
|
62
|
+
"number"
|
|
63
|
+
? ((data.stats as Record<string, unknown>).viewer_count as number)
|
|
64
|
+
: 0;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
roomId,
|
|
68
|
+
username,
|
|
69
|
+
title,
|
|
70
|
+
qualities,
|
|
71
|
+
selectedQuality,
|
|
72
|
+
streamUrl: selectedQuality.flv,
|
|
73
|
+
viewerCount,
|
|
74
|
+
startedAt: new Date(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { FfmpegError } from "../errors.js";
|
|
3
|
+
import type { DownloadStats, StreamQualityKey } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export interface FfmpegDownloadOptions {
|
|
6
|
+
ffmpegPath: string;
|
|
7
|
+
url: string;
|
|
8
|
+
outputPath: string;
|
|
9
|
+
quality: StreamQualityKey;
|
|
10
|
+
args?: string[];
|
|
11
|
+
bitrate?: string | null;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
onProgress?: (stats: DownloadStats) => void;
|
|
14
|
+
maxDuration?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Startup timeout in ms. If ffmpeg produces no stderr output within
|
|
17
|
+
* this window, it is killed and the promise rejects. Prevents
|
|
18
|
+
* indefinite hangs on bad/ stalled stream URLs.
|
|
19
|
+
* @default 30_000
|
|
20
|
+
*/
|
|
21
|
+
timeout?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse ffmpeg stderr for duration and time progress.
|
|
26
|
+
*
|
|
27
|
+
* ffmpeg outputs lines like:
|
|
28
|
+
* frame= 123 fps= 30 q=28.0 size= 1024kB time=00:01:23.45 ...
|
|
29
|
+
*/
|
|
30
|
+
function parseFfmpegProgress(
|
|
31
|
+
line: string,
|
|
32
|
+
): { duration?: number; sizeBytes?: number } | null {
|
|
33
|
+
const result: { duration?: number; sizeBytes?: number } = {};
|
|
34
|
+
|
|
35
|
+
// Parse time=HH:MM:SS.MS
|
|
36
|
+
const timeMatch = line.match(/time=(\d+):(\d+):(\d+)\.(\d+)/);
|
|
37
|
+
if (timeMatch) {
|
|
38
|
+
const hours = Number(timeMatch[1]);
|
|
39
|
+
const minutes = Number(timeMatch[2]);
|
|
40
|
+
const seconds = Number(timeMatch[3]);
|
|
41
|
+
result.duration = hours * 3600 + minutes * 60 + seconds;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Parse size= 1024kB
|
|
45
|
+
const sizeMatch = line.match(/size=\s*(\d+)(\w+)B/);
|
|
46
|
+
if (sizeMatch && sizeMatch.length >= 3) {
|
|
47
|
+
const value = Number(sizeMatch[1]);
|
|
48
|
+
// biome-ignore lint/style/noNonNullAssertion: length >= 3 guarantees index 2
|
|
49
|
+
const unit = sizeMatch[2]!;
|
|
50
|
+
if (unit === "k" || unit === "K") {
|
|
51
|
+
result.sizeBytes = value * 1024;
|
|
52
|
+
} else if (unit === "m" || unit === "M") {
|
|
53
|
+
result.sizeBytes = value * 1024 * 1024;
|
|
54
|
+
} else {
|
|
55
|
+
result.sizeBytes = value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result.sizeBytes !== undefined || result.duration !== undefined
|
|
60
|
+
? result
|
|
61
|
+
: null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Download a livestream via ffmpeg.
|
|
66
|
+
*
|
|
67
|
+
* Spawns ffmpeg as a subprocess, pipes the stream URL as input,
|
|
68
|
+
* and writes the transcoded/remuxed output to the specified path.
|
|
69
|
+
*
|
|
70
|
+
* Advantages over raw HTTP:
|
|
71
|
+
* - Proper container format (mp4/mkv)
|
|
72
|
+
* - Stream copy (fast) or re-encode
|
|
73
|
+
* - Better handling of stream interruptions
|
|
74
|
+
*/
|
|
75
|
+
export async function downloadWithFfmpeg(
|
|
76
|
+
options: FfmpegDownloadOptions,
|
|
77
|
+
): Promise<{
|
|
78
|
+
sizeBytes: number;
|
|
79
|
+
duration: number;
|
|
80
|
+
format: "mp4" | "mkv";
|
|
81
|
+
}> {
|
|
82
|
+
const {
|
|
83
|
+
ffmpegPath,
|
|
84
|
+
url,
|
|
85
|
+
outputPath,
|
|
86
|
+
quality,
|
|
87
|
+
args: extraArgs,
|
|
88
|
+
bitrate,
|
|
89
|
+
signal,
|
|
90
|
+
onProgress,
|
|
91
|
+
maxDuration,
|
|
92
|
+
timeout = 30_000,
|
|
93
|
+
} = options;
|
|
94
|
+
|
|
95
|
+
const ffmpegArgs: string[] = [
|
|
96
|
+
"-y", // overwrite output
|
|
97
|
+
"-i",
|
|
98
|
+
url, // input URL
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
if (maxDuration && maxDuration > 0 && maxDuration < Infinity) {
|
|
102
|
+
ffmpegArgs.push("-t", String(maxDuration));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (bitrate) {
|
|
106
|
+
ffmpegArgs.push("-c:v", "libx264", "-b:v", bitrate, "-c:a", "copy");
|
|
107
|
+
} else if (extraArgs && extraArgs.length > 0) {
|
|
108
|
+
ffmpegArgs.push(...extraArgs);
|
|
109
|
+
} else {
|
|
110
|
+
ffmpegArgs.push("-c", "copy");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
ffmpegArgs.push(outputPath);
|
|
114
|
+
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const proc = spawn(ffmpegPath, ffmpegArgs, {
|
|
117
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
118
|
+
signal,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
let stderrBuffer = "";
|
|
122
|
+
let sizeBytes = 0;
|
|
123
|
+
let duration = 0;
|
|
124
|
+
|
|
125
|
+
const abortHandler = () => {
|
|
126
|
+
// SIGTERM tells ffmpeg to flush its output (moov atom, etc.)
|
|
127
|
+
// and exit cleanly. This produces a playable file even when
|
|
128
|
+
// stopped mid-recording.
|
|
129
|
+
proc.kill("SIGTERM");
|
|
130
|
+
// Safety net: if ffmpeg doesn't respond, force-kill after 15s.
|
|
131
|
+
// The close handler will still fire and resolve with partial data.
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
if (proc.exitCode === null) {
|
|
134
|
+
proc.kill("SIGKILL");
|
|
135
|
+
}
|
|
136
|
+
}, 15_000);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
let aborted = false;
|
|
140
|
+
const onAbort = () => {
|
|
141
|
+
aborted = true;
|
|
142
|
+
abortHandler();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
146
|
+
|
|
147
|
+
// Startup timeout — if ffmpeg produces no stderr output within
|
|
148
|
+
// `timeout` ms, it likely stalled on a bad URL. Kill it so the
|
|
149
|
+
// caller doesn't hang indefinitely.
|
|
150
|
+
let firstDataTimer: ReturnType<typeof setTimeout> | null = setTimeout(
|
|
151
|
+
() => {
|
|
152
|
+
firstDataTimer = null;
|
|
153
|
+
signal?.removeEventListener("abort", onAbort);
|
|
154
|
+
proc.kill("SIGTERM");
|
|
155
|
+
reject(
|
|
156
|
+
new FfmpegError(
|
|
157
|
+
`ffmpeg produced no output within ${timeout}ms — check the stream URL`,
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
timeout,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Parse stderr for progress
|
|
165
|
+
proc.stderr?.on("data", (chunk: Buffer | string) => {
|
|
166
|
+
// Clear first-data timer on first output — ffmpeg is working
|
|
167
|
+
if (firstDataTimer) {
|
|
168
|
+
clearTimeout(firstDataTimer);
|
|
169
|
+
firstDataTimer = null;
|
|
170
|
+
}
|
|
171
|
+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
172
|
+
stderrBuffer += text;
|
|
173
|
+
|
|
174
|
+
const lines = text.split("\n");
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
const parsed = parseFfmpegProgress(line);
|
|
177
|
+
if (parsed) {
|
|
178
|
+
if (parsed.duration !== undefined) {
|
|
179
|
+
duration = parsed.duration;
|
|
180
|
+
}
|
|
181
|
+
if (parsed.sizeBytes !== undefined) {
|
|
182
|
+
sizeBytes = parsed.sizeBytes;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
onProgress?.({
|
|
186
|
+
downloadedBytes: sizeBytes,
|
|
187
|
+
downloadedMB: sizeBytes / (1024 * 1024),
|
|
188
|
+
duration,
|
|
189
|
+
speed: duration > 0 ? sizeBytes / duration : 0,
|
|
190
|
+
speedMBps: duration > 0 ? sizeBytes / duration / (1024 * 1024) : 0,
|
|
191
|
+
quality,
|
|
192
|
+
state: "recording",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
proc.on("error", (err) => {
|
|
199
|
+
if (firstDataTimer) {
|
|
200
|
+
clearTimeout(firstDataTimer);
|
|
201
|
+
firstDataTimer = null;
|
|
202
|
+
}
|
|
203
|
+
signal?.removeEventListener("abort", onAbort);
|
|
204
|
+
reject(new FfmpegError(err.message));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
proc.on("close", (code) => {
|
|
208
|
+
if (firstDataTimer) {
|
|
209
|
+
clearTimeout(firstDataTimer);
|
|
210
|
+
firstDataTimer = null;
|
|
211
|
+
}
|
|
212
|
+
signal?.removeEventListener("abort", onAbort);
|
|
213
|
+
|
|
214
|
+
// When the user aborted, resolve with whatever we got.
|
|
215
|
+
// ffmpeg receives SIGTERM first, which tells it to flush
|
|
216
|
+
// its output and exit cleanly, producing a playable file.
|
|
217
|
+
if (aborted) {
|
|
218
|
+
resolve({
|
|
219
|
+
sizeBytes,
|
|
220
|
+
duration,
|
|
221
|
+
format: outputPath.endsWith(".mkv") ? "mkv" : "mp4",
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (code === 0) {
|
|
227
|
+
// Clean exit — check we actually got data
|
|
228
|
+
if (sizeBytes > 0) {
|
|
229
|
+
resolve({
|
|
230
|
+
sizeBytes,
|
|
231
|
+
duration,
|
|
232
|
+
format: outputPath.endsWith(".mkv") ? "mkv" : "mp4",
|
|
233
|
+
});
|
|
234
|
+
} else {
|
|
235
|
+
reject(
|
|
236
|
+
new FfmpegError(
|
|
237
|
+
extractFfmpegError(stderrBuffer) || "Stream was empty",
|
|
238
|
+
code,
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
} else if (code === null) {
|
|
243
|
+
// Process killed by signal (SIGTERM, SIGKILL, etc.)
|
|
244
|
+
if (sizeBytes > 0) {
|
|
245
|
+
resolve({
|
|
246
|
+
sizeBytes,
|
|
247
|
+
duration,
|
|
248
|
+
format: outputPath.endsWith(".mkv") ? "mkv" : "mp4",
|
|
249
|
+
});
|
|
250
|
+
} else {
|
|
251
|
+
reject(
|
|
252
|
+
new FfmpegError(
|
|
253
|
+
"Process was killed before any data was received",
|
|
254
|
+
code,
|
|
255
|
+
),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
} else if (code === 1 || code === 255) {
|
|
259
|
+
// ffmpeg exit codes for interruption / end-of-stream
|
|
260
|
+
if (sizeBytes > 0) {
|
|
261
|
+
resolve({
|
|
262
|
+
sizeBytes,
|
|
263
|
+
duration,
|
|
264
|
+
format: outputPath.endsWith(".mkv") ? "mkv" : "mp4",
|
|
265
|
+
});
|
|
266
|
+
} else {
|
|
267
|
+
const errorMsg = extractFfmpegError(stderrBuffer);
|
|
268
|
+
reject(
|
|
269
|
+
new FfmpegError(
|
|
270
|
+
errorMsg || `FFmpeg exited with code ${code} and no data`,
|
|
271
|
+
code,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
// Extract error from stderr
|
|
277
|
+
const errorMsg = extractFfmpegError(stderrBuffer);
|
|
278
|
+
reject(new FfmpegError(errorMsg, code));
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Extract a meaningful error message from ffmpeg stderr output.
|
|
286
|
+
*/
|
|
287
|
+
function extractFfmpegError(stderr: string): string {
|
|
288
|
+
const lines = stderr.split("\n").filter((l) => l.trim().length > 0);
|
|
289
|
+
const errorLines = lines.filter(
|
|
290
|
+
(l) =>
|
|
291
|
+
l.toLowerCase().includes("error") ||
|
|
292
|
+
l.toLowerCase().includes("invalid") ||
|
|
293
|
+
l.toLowerCase().includes("cannot"),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
if (errorLines.length > 0) {
|
|
297
|
+
// biome-ignore lint/style/noNonNullAssertion: length > 0 guarantees index 0
|
|
298
|
+
return errorLines[0]!.trim();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Return last meaningful line
|
|
302
|
+
return lines[lines.length - 1]?.trim() ?? "Unknown ffmpeg error";
|
|
303
|
+
}
|