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.
@@ -0,0 +1,150 @@
1
+ import type { PathLike } from "node:fs";
2
+ import { createWriteStream } from "node:fs";
3
+ import { Impit } from "impit";
4
+ import { AbortError, DownloadFailedError } from "../errors.js";
5
+ import type { DownloadStats, StreamQualityKey } from "../types.js";
6
+
7
+ export interface RawHttpDownloadOptions {
8
+ url: string;
9
+ outputPath: PathLike;
10
+ quality: StreamQualityKey;
11
+ signal?: AbortSignal;
12
+ onProgress?: (stats: DownloadStats) => void;
13
+ maxDuration?: number;
14
+ }
15
+
16
+ /**
17
+ * Download a livestream via raw HTTP FLV streaming.
18
+ *
19
+ * Writes the incoming FLV data directly to disk without transcoding.
20
+ * No ffmpeg required.
21
+ */
22
+ export async function downloadRawHttp(
23
+ options: RawHttpDownloadOptions,
24
+ ): Promise<{
25
+ sizeBytes: number;
26
+ duration: number;
27
+ format: "flv";
28
+ }> {
29
+ const { url, outputPath, quality, signal, onProgress, maxDuration } = options;
30
+
31
+ const impIt = new Impit({
32
+ browser: "chrome",
33
+ });
34
+
35
+ const response = await impIt.fetch(url, {
36
+ signal,
37
+ });
38
+
39
+ if (!response.ok) {
40
+ throw new DownloadFailedError(
41
+ `HTTP ${response.status}: ${response.statusText}`,
42
+ );
43
+ }
44
+
45
+ const bodyReader = response.body?.getReader();
46
+ if (!bodyReader) {
47
+ throw new DownloadFailedError("No response body stream");
48
+ }
49
+
50
+ const fileStream = createWriteStream(outputPath);
51
+ const startTime = Date.now();
52
+ let downloadedBytes = 0;
53
+ let lastProgressTime = startTime;
54
+ let lastProgressBytes = 0;
55
+ let aborted = false;
56
+
57
+ // Narrowed const for use in closures
58
+ const reader: ReadableStreamDefaultReader<Uint8Array> = bodyReader;
59
+
60
+ return new Promise((resolve, reject) => {
61
+ const abortHandler = () => {
62
+ aborted = true;
63
+ reader.cancel().catch(() => {});
64
+ fileStream.close();
65
+ reject(new AbortError());
66
+ };
67
+
68
+ signal?.addEventListener("abort", abortHandler, { once: true });
69
+
70
+ // Duration limit timer
71
+ let durationTimer: ReturnType<typeof setTimeout> | null = null;
72
+ if (maxDuration && maxDuration > 0 && maxDuration < Infinity) {
73
+ durationTimer = setTimeout(() => {
74
+ aborted = true;
75
+ reader.cancel().catch(() => {});
76
+ fileStream.close();
77
+ resolve({
78
+ sizeBytes: downloadedBytes,
79
+ duration: (Date.now() - startTime) / 1000,
80
+ format: "flv",
81
+ });
82
+ }, maxDuration * 1000);
83
+ }
84
+
85
+ function pump(): void {
86
+ reader
87
+ .read()
88
+ .then(({ done, value }) => {
89
+ if (done || aborted) {
90
+ fileStream.close();
91
+ if (!aborted) {
92
+ resolve({
93
+ sizeBytes: downloadedBytes,
94
+ duration: (Date.now() - startTime) / 1000,
95
+ format: "flv",
96
+ });
97
+ }
98
+ if (durationTimer) clearTimeout(durationTimer);
99
+ signal?.removeEventListener("abort", abortHandler);
100
+ return;
101
+ }
102
+
103
+ downloadedBytes += value.byteLength;
104
+ fileStream.write(value);
105
+
106
+ // Emit progress every ~second
107
+ const now = Date.now();
108
+ const elapsed = now - lastProgressTime;
109
+ if (elapsed >= 1000) {
110
+ const bytesSinceLast = downloadedBytes - lastProgressBytes;
111
+ const speed = bytesSinceLast / (elapsed / 1000);
112
+ lastProgressTime = now;
113
+ lastProgressBytes = downloadedBytes;
114
+
115
+ onProgress?.({
116
+ downloadedBytes,
117
+ downloadedMB: downloadedBytes / (1024 * 1024),
118
+ duration: (now - startTime) / 1000,
119
+ speed,
120
+ speedMBps: speed / (1024 * 1024),
121
+ quality,
122
+ state: "recording",
123
+ });
124
+ }
125
+
126
+ pump();
127
+ })
128
+ .catch((err) => {
129
+ if (aborted) return;
130
+ fileStream.close();
131
+ if (durationTimer) clearTimeout(durationTimer);
132
+ signal?.removeEventListener("abort", abortHandler);
133
+ reject(
134
+ err instanceof Error
135
+ ? new DownloadFailedError("Stream read error", err)
136
+ : new DownloadFailedError("Stream read error"),
137
+ );
138
+ });
139
+ }
140
+
141
+ fileStream.on("error", (err) => {
142
+ if (aborted) return;
143
+ if (durationTimer) clearTimeout(durationTimer);
144
+ signal?.removeEventListener("abort", abortHandler);
145
+ reject(new DownloadFailedError("File write error", err));
146
+ });
147
+
148
+ pump();
149
+ });
150
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,58 @@
1
+ export class TikTokLiveError extends Error {
2
+ override name = "TikTokLiveError";
3
+ }
4
+
5
+ export class UserOfflineError extends TikTokLiveError {
6
+ override name = "UserOfflineError";
7
+ constructor(username: string) {
8
+ super(`User "${username}" is not currently live`);
9
+ }
10
+ }
11
+
12
+ export class UserNotFoundError extends TikTokLiveError {
13
+ override name = "UserNotFoundError";
14
+ constructor(username: string) {
15
+ super(`User "${username}" does not exist on TikTok`);
16
+ }
17
+ }
18
+
19
+ export class RoomResolveError extends TikTokLiveError {
20
+ override name = "RoomResolveError";
21
+ constructor(username: string, cause?: unknown) {
22
+ super(
23
+ `Failed to resolve room ID for "${username}"${cause ? `: ${cause}` : ""}`,
24
+ );
25
+ }
26
+ }
27
+
28
+ export class StreamFetchError extends TikTokLiveError {
29
+ override name = "StreamFetchError";
30
+ constructor(roomId: string, cause?: unknown) {
31
+ super(
32
+ `Failed to fetch stream URL for room ${roomId}${cause ? `: ${cause}` : ""}`,
33
+ );
34
+ }
35
+ }
36
+
37
+ export class DownloadFailedError extends TikTokLiveError {
38
+ override name = "DownloadFailedError";
39
+ constructor(message: string, cause?: unknown) {
40
+ super(`${message}${cause ? `: ${cause}` : ""}`);
41
+ }
42
+ }
43
+
44
+ export class FfmpegError extends TikTokLiveError {
45
+ override name = "FfmpegError";
46
+ constructor(message: string, exitCode?: number | null) {
47
+ super(
48
+ `FFmpeg error: ${message}${exitCode != null ? ` (exit code ${exitCode})` : ""}`,
49
+ );
50
+ }
51
+ }
52
+
53
+ export class AbortError extends TikTokLiveError {
54
+ override name = "AbortError";
55
+ constructor() {
56
+ super("Download was aborted");
57
+ }
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ // ─── Main class & functional API ──────────────────────────
2
+
3
+ export type { CreateClientOptions } from "./api/client.js";
4
+ export { createClient } from "./api/client.js";
5
+ export type { RoomResolveOptions } from "./api/room.js";
6
+ // ─── API utilities (mid-level) ────────────────────────────
7
+ export { resolveRoomId } from "./api/room.js";
8
+ export type { StreamInfoOptions } from "./api/stream.js";
9
+ export { fetchStreamInfo } from "./api/stream.js";
10
+ // ─── Error classes ────────────────────────────────────────
11
+ export {
12
+ AbortError,
13
+ DownloadFailedError,
14
+ FfmpegError,
15
+ RoomResolveError,
16
+ StreamFetchError,
17
+ TikTokLiveError,
18
+ UserNotFoundError,
19
+ UserOfflineError,
20
+ } from "./errors.js";
21
+ export type { DownloadFunctionOptions } from "./TikTokLiveDownloader.js";
22
+ export { download, TikTokLiveDownloader } from "./TikTokLiveDownloader.js";
23
+ // ─── Types ────────────────────────────────────────────────
24
+ export type {
25
+ CookieJarLike,
26
+ DownloaderState,
27
+ DownloadResult,
28
+ DownloadStats,
29
+ OutputFormat,
30
+ QualityOption,
31
+ StreamInfo,
32
+ StreamQualityKey,
33
+ TikTokLiveDownloaderEvents,
34
+ TikTokLiveDownloaderOptions,
35
+ } from "./types.js";
36
+
37
+ // ─── Quality utilities ────────────────────────────────────
38
+ export {
39
+ buildQualityOption,
40
+ parseQualities,
41
+ selectQuality,
42
+ } from "./utils/quality.js";
43
+
44
+ // ─── Template ─────────────────────────────────────────────
45
+ export { renderFilename } from "./utils/template.js";
package/src/types.ts ADDED
@@ -0,0 +1,155 @@
1
+ import type { Browser } from "impit";
2
+
3
+ // ─── Quality ──────────────────────────────────────────────
4
+
5
+ export type StreamQualityKey = "fullhd1" | "hd1" | "sd2" | "sd1";
6
+
7
+ export interface QualityOption {
8
+ key: StreamQualityKey;
9
+ label: string;
10
+ level: number;
11
+ flv: string;
12
+ hls: string;
13
+ }
14
+
15
+ // ─── Stream info ──────────────────────────────────────────
16
+
17
+ export interface StreamInfo {
18
+ roomId: string;
19
+ username: string;
20
+ title: string;
21
+ qualities: QualityOption[];
22
+ selectedQuality: QualityOption;
23
+ streamUrl: string;
24
+ viewerCount: number;
25
+ startedAt: Date;
26
+ }
27
+
28
+ // ─── Download stats (live) ────────────────────────────────
29
+
30
+ export interface DownloadStats {
31
+ downloadedBytes: number;
32
+ downloadedMB: number;
33
+ duration: number; // seconds elapsed
34
+ speed: number; // bytes/sec
35
+ speedMBps: number;
36
+ quality: StreamQualityKey;
37
+ state: DownloaderState;
38
+ }
39
+
40
+ // ─── Download result ──────────────────────────────────────
41
+
42
+ export interface DownloadResult {
43
+ filePath: string;
44
+ sizeBytes: number;
45
+ sizeMB: number;
46
+ duration: number; // seconds of content
47
+ username: string;
48
+ roomId: string;
49
+ quality: StreamQualityKey;
50
+ format: OutputFormat;
51
+ startedAt: Date;
52
+ endedAt: Date;
53
+ }
54
+
55
+ // ─── States ───────────────────────────────────────────────
56
+
57
+ export type DownloaderState =
58
+ | "idle"
59
+ | "waiting"
60
+ | "recording"
61
+ | "stopping"
62
+ | "done";
63
+ export type OutputFormat = "mp4" | "mkv" | "ts" | "flv";
64
+
65
+ // ─── Options ──────────────────────────────────────────────
66
+
67
+ export interface TikTokLiveDownloaderOptions {
68
+ /** Output directory (default: process.cwd()) */
69
+ output?: string;
70
+ /** Filename template. Variables: {username}, {date}, {time}, {title} (default: "{username}={date}_{time}") */
71
+ filename?: string;
72
+ /** Quality preference (default: "best") */
73
+ quality?: "best" | "worst" | StreamQualityKey;
74
+ /** Output container format (default: "mp4" when ffmpeg available, "flv" otherwise) */
75
+ format?: OutputFormat;
76
+ /** Use ffmpeg for download + remux. Auto-detects system ffmpeg on PATH. */
77
+ useFfmpeg?: boolean;
78
+ /** Custom ffmpeg binary path. Overrides auto-detected system ffmpeg. */
79
+ ffmpegPath?: string;
80
+ /** Extra ffmpeg output arguments (default: ["-c", "copy"]). Set to override. */
81
+ ffmpegArgs?: string[];
82
+ /** Re-encode bitrate (e.g. "1M", "1000k"). Default: copy streams (no re-encode). */
83
+ bitrate?: string;
84
+ /** Max recording duration in seconds (default: Infinity). */
85
+ maxDuration?: number;
86
+ /** Split recording into segments of this many seconds (default: Infinity = single file). */
87
+ maxSegmentDuration?: number;
88
+ /** Polling interval in ms when waiting for live (default: 30_000). */
89
+ checkInterval?: number;
90
+ /** HTTP proxy URL (supports http, https, socks4, socks5). */
91
+ proxyUrl?: string;
92
+ /** Tough-cookie compatible cookie jar for authenticated sessions. */
93
+ cookieJar?: CookieJarLike;
94
+ /** Browser to emulate (default: "chrome"). */
95
+ browser?: Browser;
96
+ /** Request timeout in ms (default: 30_000). */
97
+ timeout?: number;
98
+ /** Extra headers to send with every request. */
99
+ headers?: Record<string, string>;
100
+ /** Progress callback (functional shorthand). */
101
+ onProgress?: (stats: DownloadStats) => void;
102
+ /** Start callback (functional shorthand). */
103
+ onStart?: (info: StreamInfo) => void;
104
+ /** Error callback (functional shorthand). */
105
+ onError?: (err: Error) => void;
106
+ /** AbortSignal for cancellation. */
107
+ signal?: AbortSignal;
108
+ }
109
+
110
+ export interface CookieJarLike {
111
+ setCookie(raw: string, url: string): Promise<void>;
112
+ getCookieString(url: string): Promise<string>;
113
+ }
114
+
115
+ // ─── Events ───────────────────────────────────────────────
116
+
117
+ export interface TikTokLiveDownloaderEvents {
118
+ /** Emitted when a live stream is detected and we're about to start recording. */
119
+ start: [info: StreamInfo];
120
+ /** Emitted periodically (every ~1s) during recording with current stats. */
121
+ progress: [stats: DownloadStats];
122
+ /** Emitted when each segment completes. Only fires when maxSegmentDuration is set. */
123
+ segment: [result: DownloadResult, partNumber: number];
124
+ /** Emitted when all segments are done and the stream has ended. */
125
+ complete: [results: DownloadResult[]];
126
+ /** Emitted on any error. The downloader will also throw. */
127
+ error: [err: Error];
128
+ /** Emitted when stop() is called and cleanup is done. */
129
+ stop: [];
130
+ }
131
+
132
+ // ─── Internal resolved options ────────────────────────────
133
+
134
+ export interface ResolvedOptions {
135
+ output: string;
136
+ filename: string;
137
+ quality: "best" | "worst" | StreamQualityKey;
138
+ format: OutputFormat;
139
+ useFfmpeg: boolean;
140
+ ffmpegPath: string | null;
141
+ ffmpegArgs: string[];
142
+ bitrate: string | null;
143
+ maxDuration: number;
144
+ maxSegmentDuration: number;
145
+ checkInterval: number;
146
+ proxyUrl: string | null;
147
+ cookieJar: CookieJarLike | null;
148
+ browser: Browser;
149
+ timeout: number;
150
+ headers: Record<string, string>;
151
+ onProgress: ((stats: DownloadStats) => void) | null;
152
+ onStart: ((info: StreamInfo) => void) | null;
153
+ onError: ((err: Error) => void) | null;
154
+ signal: AbortSignal | null;
155
+ }
@@ -0,0 +1,171 @@
1
+ import type { QualityOption, StreamQualityKey } from "../types.js";
2
+
3
+ /**
4
+ * Quality ladder sorted from highest to lowest.
5
+ */
6
+ const QUALITY_ORDER: StreamQualityKey[] = ["fullhd1", "hd1", "sd2", "sd1"];
7
+
8
+ const QUALITY_LABELS: Record<StreamQualityKey, string> = {
9
+ fullhd1: "1080p",
10
+ hd1: "720p",
11
+ sd2: "540p",
12
+ sd1: "360p",
13
+ };
14
+
15
+ const QUALITY_LEVELS: Record<StreamQualityKey, number> = {
16
+ fullhd1: 4,
17
+ hd1: 3,
18
+ sd2: 2,
19
+ sd1: 1,
20
+ };
21
+
22
+ /**
23
+ * Build a QualityOption from a raw url object.
24
+ */
25
+ export function buildQualityOption(
26
+ key: StreamQualityKey,
27
+ flv: string,
28
+ hls?: string,
29
+ ): QualityOption {
30
+ return {
31
+ key,
32
+ label: QUALITY_LABELS[key],
33
+ level: QUALITY_LEVELS[key],
34
+ flv,
35
+ hls: hls ?? "",
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Select a quality from available options.
41
+ *
42
+ * @param qualities - Available quality options to choose from.
43
+ * @param preference - "best" (highest available), "worst" (lowest), or a specific key.
44
+ * @returns The selected quality option.
45
+ */
46
+ export function selectQuality(
47
+ qualities: QualityOption[],
48
+ preference: "best" | "worst" | StreamQualityKey,
49
+ ): QualityOption {
50
+ if (qualities.length === 0) {
51
+ throw new Error("No stream qualities available");
52
+ }
53
+
54
+ if (preference === "best") {
55
+ // biome-ignore lint/style/noNonNullAssertion: length checked above
56
+ return qualities[0]!;
57
+ }
58
+
59
+ if (preference === "worst") {
60
+ // biome-ignore lint/style/noNonNullAssertion: length checked above
61
+ return qualities[qualities.length - 1]!;
62
+ }
63
+
64
+ // Specific key requested - find it or fall back to best
65
+ const found = qualities.find((q) => q.key === preference);
66
+ // biome-ignore lint/style/noNonNullAssertion: length checked above
67
+ return found ?? qualities[0]!;
68
+ }
69
+
70
+ /**
71
+ * Parse quality options from the TikTok room/info response.
72
+ *
73
+ * Supports both the legacy `flv_pull_url` format and the newer
74
+ * `live_core_sdk_data` format.
75
+ */
76
+ export function parseQualities(data: unknown): QualityOption[] {
77
+ if (!data || typeof data !== "object") {
78
+ return [];
79
+ }
80
+
81
+ const d = data as Record<string, unknown>;
82
+
83
+ // Try new SDK format first
84
+ const sdkQualities = parseSdkQualities(d);
85
+ if (sdkQualities.length > 0) {
86
+ return sdkQualities;
87
+ }
88
+
89
+ // Fall back to legacy flv_pull_url format
90
+ return parseLegacyQualities(d);
91
+ }
92
+
93
+ function parseSdkQualities(data: Record<string, unknown>): QualityOption[] {
94
+ try {
95
+ const streamUrl = data.stream_url as Record<string, unknown> | undefined;
96
+ if (!streamUrl) return [];
97
+
98
+ const sdkData = streamUrl.live_core_sdk_data as
99
+ | Record<string, unknown>
100
+ | undefined;
101
+ if (!sdkData) return [];
102
+
103
+ const pullData = sdkData.pull_data as Record<string, unknown> | undefined;
104
+ if (!pullData) return [];
105
+
106
+ const streamDataStr = pullData.stream_data;
107
+ if (typeof streamDataStr !== "string") return [];
108
+
109
+ const streamData = JSON.parse(streamDataStr);
110
+ const innerData = (streamData as Record<string, unknown>)?.data as
111
+ | Record<string, unknown>
112
+ | undefined;
113
+ if (!innerData) return [];
114
+
115
+ const qualities: QualityOption[] = [];
116
+
117
+ for (const key of QUALITY_ORDER) {
118
+ const q = innerData[key] as Record<string, unknown> | undefined;
119
+ if (!q) continue;
120
+
121
+ const main = q.main as Record<string, unknown> | undefined;
122
+ if (!main) continue;
123
+
124
+ const flv = main.flv;
125
+ if (typeof flv !== "string" || flv.length === 0) continue;
126
+
127
+ const hls = typeof main.hls === "string" ? main.hls : undefined;
128
+ qualities.push(buildQualityOption(key, flv, hls));
129
+ }
130
+
131
+ return qualities;
132
+ } catch {
133
+ return [];
134
+ }
135
+ }
136
+
137
+ function parseLegacyQualities(data: Record<string, unknown>): QualityOption[] {
138
+ try {
139
+ const streamUrl = data.stream_url as Record<string, unknown> | undefined;
140
+ if (!streamUrl) return [];
141
+
142
+ const flvPullUrl = streamUrl.flv_pull_url as
143
+ | Record<string, unknown>
144
+ | undefined;
145
+ if (!flvPullUrl) return [];
146
+
147
+ const qualities: QualityOption[] = [];
148
+
149
+ // Map TikTok keys to our normalized keys
150
+ const keyMap: Record<string, StreamQualityKey> = {
151
+ FULL_HD1: "fullhd1",
152
+ HD1: "hd1",
153
+ SD2: "sd2",
154
+ SD1: "sd1",
155
+ };
156
+
157
+ for (const [tikTokKey, url] of Object.entries(flvPullUrl)) {
158
+ const key = keyMap[tikTokKey];
159
+ if (key && typeof url === "string" && url.length > 0) {
160
+ qualities.push(buildQualityOption(key, url));
161
+ }
162
+ }
163
+
164
+ // Sort by level descending
165
+ qualities.sort((a, b) => b.level - a.level);
166
+
167
+ return qualities;
168
+ } catch {
169
+ return [];
170
+ }
171
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Render a filename template with runtime values.
3
+ *
4
+ * Available variables:
5
+ * - {username} - TikTok username
6
+ * - {date} - Current date in YYYYMMDD format
7
+ * - {time} - Current time in HHmmss format
8
+ * - {title} - Stream title (sanitized)
9
+ *
10
+ * Default template: "{username}={date}_{time}"
11
+ */
12
+ export function renderFilename(
13
+ template: string,
14
+ values: {
15
+ username: string;
16
+ title?: string;
17
+ date?: string;
18
+ time?: string;
19
+ part?: number;
20
+ },
21
+ ): string {
22
+ const now = new Date();
23
+ const date = values.date ?? formatDate(now);
24
+ const time = values.time ?? formatTime(now);
25
+
26
+ let result = template;
27
+ result = result.replaceAll("{username}", values.username);
28
+ result = result.replaceAll("{date}", date);
29
+ result = result.replaceAll("{time}", time);
30
+
31
+ if (values.title) {
32
+ result = result.replaceAll("{title}", sanitize(values.title));
33
+ }
34
+
35
+ if (values.part !== undefined) {
36
+ result = result.replaceAll("{part}", String(values.part));
37
+ }
38
+
39
+ return result;
40
+ }
41
+
42
+ function formatDate(date: Date): string {
43
+ const y = date.getFullYear();
44
+ const m = String(date.getMonth() + 1).padStart(2, "0");
45
+ const d = String(date.getDate()).padStart(2, "0");
46
+ return `${y}${m}${d}`;
47
+ }
48
+
49
+ function formatTime(date: Date): string {
50
+ const h = String(date.getHours()).padStart(2, "0");
51
+ const m = String(date.getMinutes()).padStart(2, "0");
52
+ const s = String(date.getSeconds()).padStart(2, "0");
53
+ return `${h}${m}${s}`;
54
+ }
55
+
56
+ /**
57
+ * Sanitize a string for use in filenames.
58
+ * Replaces characters that are problematic across OSes.
59
+ */
60
+ function sanitize(input: string): string {
61
+ return input
62
+ .replaceAll(/[/\\?%*:|"<>]/g, "_")
63
+ .replaceAll(/\s+/g, "_")
64
+ .replaceAll(/_+/g, "_")
65
+ .replace(/^_+|_+$/g, "")
66
+ .slice(0, 64);
67
+ }