@zfadhli/tokrec 0.13.0 → 0.15.2
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 +99 -403
- 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/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
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { createRateLimiter } from "./rate-limiter.mjs";
|
|
2
1
|
import { createSession } from "wreq-js";
|
|
3
|
-
//#region src/
|
|
2
|
+
//#region src/io/http.ts
|
|
4
3
|
/**
|
|
5
|
-
* HTTP
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* HTTP — the transport layer in one file: the wreq-js session (TLS fingerprint
|
|
5
|
+
* impersonation, cookie jar, proxy) and the token-bucket rate limiter that
|
|
6
|
+
* keeps polling from tripping TikTok's WAF.
|
|
7
|
+
*
|
|
8
|
+
* This file owns the `HttpClient` contract: it is the seam every adapter and
|
|
9
|
+
* `test/utils/mock-http.ts` implement. There is no second definition.
|
|
8
10
|
*/
|
|
9
11
|
const REQUEST_TIMEOUT_MS = 15e3;
|
|
10
12
|
/** Standard browser headers to avoid TikTok WAF detection. */
|
|
@@ -31,8 +33,11 @@ async function createHttpClient(config) {
|
|
|
31
33
|
"https://m.tiktok.com"
|
|
32
34
|
]) for (const [name, value] of Object.entries(config.cookies)) session.setCookie(name, value, domain);
|
|
33
35
|
const rateLimiter = createRateLimiter(config.ratePerSecond ?? 5);
|
|
34
|
-
async function rateLimitedFetch(url, options) {
|
|
36
|
+
async function rateLimitedFetch(url, options, external) {
|
|
35
37
|
const controller = new AbortController();
|
|
38
|
+
const onAbort = () => controller.abort(external?.reason);
|
|
39
|
+
if (external?.aborted) controller.abort(external.reason);
|
|
40
|
+
else external?.addEventListener("abort", onAbort, { once: true });
|
|
36
41
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
37
42
|
try {
|
|
38
43
|
await rateLimiter.acquire(controller.signal);
|
|
@@ -47,24 +52,47 @@ async function createHttpClient(config) {
|
|
|
47
52
|
});
|
|
48
53
|
} finally {
|
|
49
54
|
clearTimeout(timeout);
|
|
55
|
+
external?.removeEventListener("abort", onAbort);
|
|
50
56
|
}
|
|
51
57
|
}
|
|
52
58
|
return {
|
|
53
|
-
get: async (url) => rateLimitedFetch(url),
|
|
54
|
-
post: async (url, body, headers) => rateLimitedFetch(url, {
|
|
59
|
+
get: async (url, signal) => rateLimitedFetch(url, void 0, signal),
|
|
60
|
+
post: async (url, body, headers, signal) => rateLimitedFetch(url, {
|
|
55
61
|
method: "POST",
|
|
56
62
|
body,
|
|
57
63
|
headers: {
|
|
58
64
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
59
65
|
...headers
|
|
60
66
|
}
|
|
61
|
-
}),
|
|
67
|
+
}, signal),
|
|
62
68
|
close: async () => {
|
|
63
69
|
await session.close();
|
|
64
70
|
}
|
|
65
71
|
};
|
|
66
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Token bucket: a minimum interval between requests. `0` or less is unlimited.
|
|
75
|
+
* Abort-aware, so a stop does not wait out a pending token.
|
|
76
|
+
*/
|
|
77
|
+
function createRateLimiter(tokensPerSecond) {
|
|
78
|
+
if (tokensPerSecond <= 0) return { acquire: async () => {} };
|
|
79
|
+
const MIN_INTERVAL_MS = 1e3 / tokensPerSecond;
|
|
80
|
+
let lastTokenTime = 0;
|
|
81
|
+
return { async acquire(signal) {
|
|
82
|
+
if (signal?.aborted) return;
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const waitTime = Math.max(0, lastTokenTime + MIN_INTERVAL_MS - now);
|
|
85
|
+
if (waitTime > 0) await new Promise((resolve) => {
|
|
86
|
+
const timer = setTimeout(resolve, waitTime);
|
|
87
|
+
if (signal) signal.addEventListener("abort", () => {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
resolve();
|
|
90
|
+
}, { once: true });
|
|
91
|
+
});
|
|
92
|
+
lastTokenTime = Date.now();
|
|
93
|
+
} };
|
|
94
|
+
}
|
|
67
95
|
//#endregion
|
|
68
96
|
export { createHttpClient };
|
|
69
97
|
|
|
70
|
-
//# sourceMappingURL=
|
|
98
|
+
//# sourceMappingURL=http.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.mjs","names":[],"sources":["../../src/io/http.ts"],"sourcesContent":["/**\n * HTTP — the transport layer in one file: the wreq-js session (TLS fingerprint\n * impersonation, cookie jar, proxy) and the token-bucket rate limiter that\n * keeps polling from tripping TikTok's WAF.\n *\n * This file owns the `HttpClient` contract: it is the seam every adapter and\n * `test/utils/mock-http.ts` implement. There is no second definition.\n */\n\nimport { createSession, type Session } from \"wreq-js\"\nimport type { RecorderConfig } from \"../app/config\"\n\n// BodyInit is only available inside declare module \"bun\" in bun-types, not as a global\n// under the ES2022 lib. We explicitly import it to keep core/ focused on ES2022.\nexport type HttpBody = import(\"bun\").BodyInit\n\nexport interface HttpClient {\n get: (url: string, signal?: AbortSignal) => Promise<Response>\n post: (\n url: string,\n body?: HttpBody,\n headers?: Record<string, string>,\n signal?: AbortSignal,\n ) => Promise<Response>\n close: () => Promise<void>\n}\n\nconst REQUEST_TIMEOUT_MS = 15_000\n\n/** Standard browser headers to avoid TikTok WAF detection. */\nconst BROWSER_HEADERS: Record<string, string> = {\n \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0\",\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8\",\n \"Accept-Language\": \"en-US,en;q=0.5\",\n \"Accept-Encoding\": \"gzip, deflate, br\",\n \"Upgrade-Insecure-Requests\": \"1\",\n \"Sec-Fetch-Dest\": \"document\",\n \"Sec-Fetch-Mode\": \"navigate\",\n \"Sec-Fetch-Site\": \"none\",\n \"Sec-Fetch-User\": \"?1\",\n}\n\nexport async function createHttpClient(config: RecorderConfig): Promise<HttpClient> {\n const session: Session = await createSession({\n browser: \"firefox_149\",\n os: \"windows\",\n proxy: config.proxy,\n })\n\n // Seed cookies into the session jar so all subsequent requests are authenticated.\n // NOTE: Using a Cookie header does NOT populate wreq-js's cookie jar —\n // we must use session.setCookie() instead.\n // Seed for all TikTok domains since cookies may not propagate across\n // subdomains automatically in wreq-js's cookie jar.\n if (config.cookies && \"sessionid_ss\" in config.cookies) {\n const domains = [\"https://www.tiktok.com\", \"https://webcast.tiktok.com\", \"https://m.tiktok.com\"]\n for (const domain of domains) {\n for (const [name, value] of Object.entries(config.cookies)) {\n session.setCookie(name, value, domain)\n }\n }\n }\n\n const rateLimiter = createRateLimiter(config.ratePerSecond ?? 5)\n\n async function rateLimitedFetch(\n url: string,\n options?: { method?: string; body?: unknown; headers?: Record<string, string> },\n external?: AbortSignal,\n ): Promise<Response> {\n const controller = new AbortController()\n const onAbort = () => controller.abort(external?.reason)\n if (external?.aborted) controller.abort(external.reason)\n else external?.addEventListener(\"abort\", onAbort, { once: true })\n const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)\n try {\n await rateLimiter.acquire(controller.signal)\n const res = await session.fetch(url, {\n method: options?.method,\n body: options?.body as never,\n headers: {\n ...BROWSER_HEADERS,\n ...options?.headers,\n },\n signal: controller.signal,\n })\n // SAFETY: wreq-js returns its own Response-compatible object; the port only\n // needs the standard `status`, `ok`, and `text()` surface.\n return res as unknown as Response\n } finally {\n clearTimeout(timeout)\n external?.removeEventListener(\"abort\", onAbort)\n }\n }\n\n return {\n get: async (url: string, signal?: AbortSignal) => rateLimitedFetch(url, undefined, signal),\n post: async (\n url: string,\n body?: HttpBody,\n headers?: Record<string, string>,\n signal?: AbortSignal,\n ) =>\n rateLimitedFetch(\n url,\n {\n method: \"POST\",\n body: body as never,\n headers: {\n \"Content-Type\": \"application/x-www-form-urlencoded; charset=UTF-8\",\n ...headers,\n },\n },\n signal,\n ),\n close: async () => {\n await session.close()\n },\n }\n}\n\n// ─── rate limiting ─────────────────────────────────────────\n\ninterface RateLimiter {\n /** Wait until a rate-limit token is available, then consume it. */\n acquire(signal?: AbortSignal): Promise<void>\n}\n\n/**\n * Token bucket: a minimum interval between requests. `0` or less is unlimited.\n * Abort-aware, so a stop does not wait out a pending token.\n */\nfunction createRateLimiter(tokensPerSecond: number): RateLimiter {\n if (tokensPerSecond <= 0) {\n return { acquire: async () => {} }\n }\n\n const MIN_INTERVAL_MS = 1000 / tokensPerSecond\n let lastTokenTime = 0\n\n return {\n async acquire(signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) return\n\n const now = Date.now()\n const waitTime = Math.max(0, lastTokenTime + MIN_INTERVAL_MS - now)\n\n if (waitTime > 0) {\n await new Promise<void>((resolve) => {\n const timer = setTimeout(resolve, waitTime)\n if (signal) {\n signal.addEventListener(\n \"abort\",\n () => {\n clearTimeout(timer)\n resolve()\n },\n { once: true },\n )\n }\n })\n }\n\n lastTokenTime = Date.now()\n },\n }\n}\n"],"mappings":";;;;;;;;;;AA2BA,MAAM,qBAAqB;;AAG3B,MAAM,kBAA0C;CAC9C,cAAc;CACd,QAAQ;CACR,mBAAmB;CACnB,mBAAmB;CACnB,6BAA6B;CAC7B,kBAAkB;CAClB,kBAAkB;CAClB,kBAAkB;CAClB,kBAAkB;AACpB;AAEA,eAAsB,iBAAiB,QAA6C;CAClF,MAAM,UAAmB,MAAM,cAAc;EAC3C,SAAS;EACT,IAAI;EACJ,OAAO,OAAO;CAChB,CAAC;CAOD,IAAI,OAAO,WAAW,kBAAkB,OAAO,SAE7C,KAAK,MAAM,UAAU;EADJ;EAA0B;EAA8B;CAC9C,GACzB,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,OAAO,OAAO,GACvD,QAAQ,UAAU,MAAM,OAAO,MAAM;CAK3C,MAAM,cAAc,kBAAkB,OAAO,iBAAiB,CAAC;CAE/D,eAAe,iBACb,KACA,SACA,UACmB;EACnB,MAAM,aAAa,IAAI,gBAAgB;EACvC,MAAM,gBAAgB,WAAW,MAAM,UAAU,MAAM;EACvD,IAAI,UAAU,SAAS,WAAW,MAAM,SAAS,MAAM;OAClD,UAAU,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;EAChE,MAAM,UAAU,iBAAiB,WAAW,MAAM,GAAG,kBAAkB;EACvE,IAAI;GACF,MAAM,YAAY,QAAQ,WAAW,MAAM;GAY3C,OAAO,MAXW,QAAQ,MAAM,KAAK;IACnC,QAAQ,SAAS;IACjB,MAAM,SAAS;IACf,SAAS;KACP,GAAG;KACH,GAAG,SAAS;IACd;IACA,QAAQ,WAAW;GACrB,CAAC;EAIH,UAAU;GACR,aAAa,OAAO;GACpB,UAAU,oBAAoB,SAAS,OAAO;EAChD;CACF;CAEA,OAAO;EACL,KAAK,OAAO,KAAa,WAAyB,iBAAiB,KAAK,KAAA,GAAW,MAAM;EACzF,MAAM,OACJ,KACA,MACA,SACA,WAEA,iBACE,KACA;GACE,QAAQ;GACF;GACN,SAAS;IACP,gBAAgB;IAChB,GAAG;GACL;EACF,GACA,MACF;EACF,OAAO,YAAY;GACjB,MAAM,QAAQ,MAAM;EACtB;CACF;AACF;;;;;AAaA,SAAS,kBAAkB,iBAAsC;CAC/D,IAAI,mBAAmB,GACrB,OAAO,EAAE,SAAS,YAAY,CAAC,EAAE;CAGnC,MAAM,kBAAkB,MAAO;CAC/B,IAAI,gBAAgB;CAEpB,OAAO,EACL,MAAM,QAAQ,QAAqC;EACjD,IAAI,QAAQ,SAAS;EAErB,MAAM,MAAM,KAAK,IAAI;EACrB,MAAM,WAAW,KAAK,IAAI,GAAG,gBAAgB,kBAAkB,GAAG;EAElE,IAAI,WAAW,GACb,MAAM,IAAI,SAAe,YAAY;GACnC,MAAM,QAAQ,WAAW,SAAS,QAAQ;GAC1C,IAAI,QACF,OAAO,iBACL,eACM;IACJ,aAAa,KAAK;IAClB,QAAQ;GACV,GACA,EAAE,MAAM,KAAK,CACf;EAEJ,CAAC;EAGH,gBAAgB,KAAK,IAAI;CAC3B,EACF;AACF"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { runFfmpeg } from "./ffmpeg.mjs";
|
|
2
|
+
import { unlinkSync } from "node:fs";
|
|
3
|
+
//#region src/io/media/convert.ts
|
|
4
|
+
/**
|
|
5
|
+
* Converter — the media-processing capability `RecordingJob` depends on.
|
|
6
|
+
*
|
|
7
|
+
* Remux (TS → MP4) and segment both delegate to `ffmpeg-utils.runFfmpeg`, so
|
|
8
|
+
* the job never builds an FFmpeg command line.
|
|
9
|
+
*/
|
|
10
|
+
function createConverter(logger, signal) {
|
|
11
|
+
async function convert(input) {
|
|
12
|
+
const output = input.replace(/\.(flv|ts)$/i, ".mp4");
|
|
13
|
+
logger?.info(`Converting: ${input} → ${output}`);
|
|
14
|
+
await runFfmpeg([
|
|
15
|
+
"-i",
|
|
16
|
+
input,
|
|
17
|
+
"-c",
|
|
18
|
+
"copy",
|
|
19
|
+
"-y",
|
|
20
|
+
output
|
|
21
|
+
], signal);
|
|
22
|
+
try {
|
|
23
|
+
unlinkSync(input);
|
|
24
|
+
logger?.info(`Deleted original: ${input}`);
|
|
25
|
+
} catch {
|
|
26
|
+
logger?.warn(`Could not delete original: ${input}`);
|
|
27
|
+
}
|
|
28
|
+
logger?.info(`Converted: ${output}`);
|
|
29
|
+
return output;
|
|
30
|
+
}
|
|
31
|
+
async function segment(input, segmentSeconds, outputPattern) {
|
|
32
|
+
return runFfmpeg([
|
|
33
|
+
"-i",
|
|
34
|
+
input,
|
|
35
|
+
"-c",
|
|
36
|
+
"copy",
|
|
37
|
+
"-f",
|
|
38
|
+
"segment",
|
|
39
|
+
"-segment_time",
|
|
40
|
+
String(segmentSeconds),
|
|
41
|
+
"-reset_timestamps",
|
|
42
|
+
"1",
|
|
43
|
+
"-segment_start_number",
|
|
44
|
+
"1",
|
|
45
|
+
"-segment_list",
|
|
46
|
+
"pipe:1",
|
|
47
|
+
"-segment_list_type",
|
|
48
|
+
"csv",
|
|
49
|
+
outputPattern
|
|
50
|
+
], signal);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
convert,
|
|
54
|
+
segment
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
export { createConverter };
|
|
59
|
+
|
|
60
|
+
//# sourceMappingURL=convert.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"convert.mjs","names":[],"sources":["../../../src/io/media/convert.ts"],"sourcesContent":["/**\n * Converter — the media-processing capability `RecordingJob` depends on.\n *\n * Remux (TS → MP4) and segment both delegate to `ffmpeg-utils.runFfmpeg`, so\n * the job never builds an FFmpeg command line.\n */\n\nimport { unlinkSync } from \"node:fs\"\nimport type { Logger } from \"../../core/log\"\nimport { runFfmpeg } from \"./ffmpeg\"\n\nexport interface Converter {\n /** Convert a TS file to MP4 (stream copy). Deletes the original on success. */\n convert: (input: string) => Promise<string>\n /** Split a file into `<name>_partN.mp4`; returns FFmpeg's CSV segment listing. */\n segment: (input: string, segmentSeconds: number, outputPattern: string) => Promise<string>\n}\n\nexport function createConverter(logger?: Logger, signal?: AbortSignal): Converter {\n async function convert(input: string): Promise<string> {\n const output = input.replace(/\\.(flv|ts)$/i, \".mp4\")\n\n logger?.info(`Converting: ${input} → ${output}`)\n\n await runFfmpeg([\"-i\", input, \"-c\", \"copy\", \"-y\", output], signal)\n\n // Delete original TS\n try {\n unlinkSync(input)\n logger?.info(`Deleted original: ${input}`)\n } catch {\n logger?.warn(`Could not delete original: ${input}`)\n }\n\n logger?.info(`Converted: ${output}`)\n return output\n }\n\n async function segment(\n input: string,\n segmentSeconds: number,\n outputPattern: string,\n ): Promise<string> {\n return runFfmpeg(\n [\n \"-i\",\n input,\n \"-c\",\n \"copy\",\n \"-f\",\n \"segment\",\n \"-segment_time\",\n String(segmentSeconds),\n \"-reset_timestamps\",\n \"1\",\n \"-segment_start_number\",\n \"1\",\n \"-segment_list\",\n \"pipe:1\",\n \"-segment_list_type\",\n \"csv\",\n outputPattern,\n ],\n signal,\n )\n }\n\n return { convert, segment }\n}\n"],"mappings":";;;;;;;;;AAkBA,SAAgB,gBAAgB,QAAiB,QAAiC;CAChF,eAAe,QAAQ,OAAgC;EACrD,MAAM,SAAS,MAAM,QAAQ,gBAAgB,MAAM;EAEnD,QAAQ,KAAK,eAAe,MAAM,KAAK,QAAQ;EAE/C,MAAM,UAAU;GAAC;GAAM;GAAO;GAAM;GAAQ;GAAM;EAAM,GAAG,MAAM;EAGjE,IAAI;GACF,WAAW,KAAK;GAChB,QAAQ,KAAK,qBAAqB,OAAO;EAC3C,QAAQ;GACN,QAAQ,KAAK,8BAA8B,OAAO;EACpD;EAEA,QAAQ,KAAK,cAAc,QAAQ;EACnC,OAAO;CACT;CAEA,eAAe,QACb,OACA,gBACA,eACiB;EACjB,OAAO,UACL;GACE;GACA;GACA;GACA;GACA;GACA;GACA;GACA,OAAO,cAAc;GACrB;GACA;GACA;GACA;GACA;GACA;GACA;GACA;GACA;EACF,GACA,MACF;CACF;CAEA,OAAO;EAAE;EAAS;CAAQ;AAC5B"}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { TikTokError } from "../../
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { createWriteStream, statSync } from "node:fs";
|
|
1
|
+
import { TikTokError } from "../../core/errors.mjs";
|
|
2
|
+
import { findFfmpegPath, pipeFfmpegSegment } from "./ffmpeg.mjs";
|
|
3
|
+
import { bytesToHuman, formatDuration, formatFilename } from "../../core/format.mjs";
|
|
4
|
+
import { createWriteStream, mkdirSync, statSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
//#region src/
|
|
6
|
+
//#region src/io/media/download.ts
|
|
7
7
|
/**
|
|
8
8
|
* Live stream downloader — downloads a live stream via FFmpeg stdout pipe.
|
|
9
9
|
*
|
|
@@ -15,10 +15,11 @@ import { join } from "node:path";
|
|
|
15
15
|
* Both FLV and HLS streams are handled by the same download function —
|
|
16
16
|
* the only difference is an optional label in the log messages.
|
|
17
17
|
*/
|
|
18
|
-
async function downloadStream(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl, signal, logger,
|
|
18
|
+
async function downloadStream(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl, signal, logger, cookies) {
|
|
19
|
+
mkdirSync(outputDir, { recursive: true });
|
|
19
20
|
const filename = formatFilename(user, "ts");
|
|
20
21
|
const filepath = join(outputDir, filename);
|
|
21
|
-
const prefix =
|
|
22
|
+
const prefix = liveUrl.includes(".m3u8") ? " (HLS)" : "";
|
|
22
23
|
logger?.info(`Recording${prefix}: ${filename}`);
|
|
23
24
|
const startTime = Date.now();
|
|
24
25
|
/** File size read directly from disk — authoritative, avoids in-memory counter drift. */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"download.mjs","names":[],"sources":["../../../src/io/media/download.ts"],"sourcesContent":["/**\n * Live stream downloader — downloads a live stream via FFmpeg stdout pipe.\n *\n * Outputs MPEG-TS (.ts) format, which is crash-safe and append-friendly.\n * FFmpeg handles HTTP reconnection via -reconnect flags. When TikTok's\n * short-lived stream URL expires, FFmpeg exits and the outer loop fetches\n * a fresh URL via getNextUrl.\n *\n * Both FLV and HLS streams are handled by the same download function —\n * the only difference is an optional label in the log messages.\n */\n\nimport { createWriteStream, mkdirSync, statSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport type { DownloadProgress, RecordedFile } from \"../../app/config\"\nimport { TikTokError } from \"../../core/errors\"\nimport { bytesToHuman, formatDuration, formatFilename } from \"../../core/format\"\nimport type { Logger } from \"../../core/log\"\nimport { findFfmpegPath, pipeFfmpegSegment } from \"./ffmpeg\"\n\nexport async function downloadStream(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress: ((info: DownloadProgress) => void) | undefined,\n getNextUrl: (() => Promise<string | null>) | undefined,\n signal: AbortSignal,\n logger?: Logger,\n cookies?: Record<string, string>,\n): Promise<RecordedFile> {\n mkdirSync(outputDir, { recursive: true })\n const filename = formatFilename(user, \"ts\")\n const filepath = join(outputDir, filename)\n // FLV is the default container — an .m3u8 URL means the CDN served HLS.\n const prefix = liveUrl.includes(\".m3u8\") ? \" (HLS)\" : \"\"\n logger?.info(`Recording${prefix}: ${filename}`)\n\n const startTime = Date.now()\n\n /** File size read directly from disk — authoritative, avoids in-memory counter drift. */\n let fileSize = 0\n let lastProgressTime = 0\n\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found — required for stream download. Install it:\\n\" +\n \" Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n function elapsed(): number {\n return (Date.now() - startTime) / 1000\n }\n\n const writer = createWriteStream(filepath)\n\n try {\n while (!signal.aborted) {\n const remaining = maxDuration > 0 ? Math.max(1, maxDuration - elapsed()) : undefined\n await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining, cookies)\n\n // Read actual file size from disk — always authoritative\n try {\n fileSize = statSync(filepath).size\n } catch {\n // File may not be accessible; leave fileSize at last known value\n }\n\n // Report progress after each segment\n const now = Date.now()\n const e = elapsed()\n if (onProgress && now - lastProgressTime >= 1000) {\n lastProgressTime = now\n const speed = e > 0 ? fileSize / e : 0\n onProgress({ bytes: fileSize, elapsed: e, speed })\n }\n\n if (signal.aborted) break\n if (maxDuration > 0 && e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`)\n break\n }\n if (!getNextUrl) break\n\n const nextUrl = await getNextUrl()\n if (!nextUrl) {\n logger?.info(\"No new stream URL — recording finished\")\n break\n }\n liveUrl = nextUrl\n }\n } catch (err) {\n logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)\n } finally {\n await new Promise<void>((resolve) => writer.end(resolve))\n }\n\n const duration = elapsed()\n // Final authoritative size after flush\n try {\n fileSize = statSync(filepath).size\n } catch {\n // keep last known value\n }\n if (onProgress) {\n onProgress({\n bytes: fileSize,\n elapsed: duration,\n speed: duration > 0 ? fileSize / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(fileSize)})`,\n )\n\n return { file: filepath, duration, size: fileSize }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAoBA,eAAsB,eACpB,SACA,MACA,WACA,aACA,YACA,YACA,QACA,QACA,SACuB;CACvB,UAAU,WAAW,EAAE,WAAW,KAAK,CAAC;CACxC,MAAM,WAAW,eAAe,MAAM,IAAI;CAC1C,MAAM,WAAW,KAAK,WAAW,QAAQ;CAEzC,MAAM,SAAS,QAAQ,SAAS,OAAO,IAAI,WAAW;CACtD,QAAQ,KAAK,YAAY,OAAO,IAAI,UAAU;CAE9C,MAAM,YAAY,KAAK,IAAI;;CAG3B,IAAI,WAAW;CACf,IAAI,mBAAmB;CAEvB,MAAM,aAAa,eAAe;CAClC,IAAI,CAAC,YACH,MAAM,IAAI,YACR,oBACA,2HAEF;CAGF,SAAS,UAAkB;EACzB,QAAQ,KAAK,IAAI,IAAI,aAAa;CACpC;CAEA,MAAM,SAAS,kBAAkB,QAAQ;CAEzC,IAAI;EACF,OAAO,CAAC,OAAO,SAAS;GACtB,MAAM,YAAY,cAAc,IAAI,KAAK,IAAI,GAAG,cAAc,QAAQ,CAAC,IAAI,KAAA;GAC3E,MAAM,kBAAkB,YAAY,SAAS,QAAQ,QAAQ,WAAW,OAAO;GAG/E,IAAI;IACF,WAAW,SAAS,QAAQ,CAAC,CAAC;GAChC,QAAQ,CAER;GAGA,MAAM,MAAM,KAAK,IAAI;GACrB,MAAM,IAAI,QAAQ;GAClB,IAAI,cAAc,MAAM,oBAAoB,KAAM;IAChD,mBAAmB;IACnB,MAAM,QAAQ,IAAI,IAAI,WAAW,IAAI;IACrC,WAAW;KAAE,OAAO;KAAU,SAAS;KAAG;IAAM,CAAC;GACnD;GAEA,IAAI,OAAO,SAAS;GACpB,IAAI,cAAc,KAAK,KAAK,aAAa;IACvC,QAAQ,KAAK,2BAA2B,YAAY,GAAG;IACvD;GACF;GACA,IAAI,CAAC,YAAY;GAEjB,MAAM,UAAU,MAAM,WAAW;GACjC,IAAI,CAAC,SAAS;IACZ,QAAQ,KAAK,wCAAwC;IACrD;GACF;GACA,UAAU;EACZ;CACF,SAAS,KAAK;EACZ,QAAQ,MAAM,oBAAoB,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GAAG;CACtF,UAAU;EACR,MAAM,IAAI,SAAe,YAAY,OAAO,IAAI,OAAO,CAAC;CAC1D;CAEA,MAAM,WAAW,QAAQ;CAEzB,IAAI;EACF,WAAW,SAAS,QAAQ,CAAC,CAAC;CAChC,QAAQ,CAER;CACA,IAAI,YACF,WAAW;EACT,OAAO;EACP,SAAS;EACT,OAAO,WAAW,IAAI,WAAW,WAAW;CAC9C,CAAC;CAEH,QAAQ,KACN,uBAAuB,SAAS,IAAI,eAAe,QAAQ,EAAE,IAAI,aAAa,QAAQ,EAAE,EAC1F;CAEA,OAAO;EAAE,MAAM;EAAU;EAAU,MAAM;CAAS;AACpD"}
|
|
@@ -1,11 +1,51 @@
|
|
|
1
|
-
import { TikTokError } from "../../
|
|
1
|
+
import { TikTokError } from "../../core/errors.mjs";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
2
3
|
import { spawn } from "node:child_process";
|
|
3
|
-
|
|
4
|
+
import { execa } from "execa";
|
|
5
|
+
//#region src/io/media/ffmpeg.ts
|
|
4
6
|
/**
|
|
5
|
-
*
|
|
7
|
+
* FFmpeg — the single home for process invocation.
|
|
8
|
+
*
|
|
9
|
+
* Two modes: `pipeFfmpegSegment` streams stdout into a `WriteStream` (live
|
|
10
|
+
* capture), `runFfmpeg` runs to completion and returns stdout (remux,
|
|
11
|
+
* segment). Locating the binary lives here too, so no caller reimplements a
|
|
12
|
+
* PATH lookup.
|
|
6
13
|
*/
|
|
7
14
|
/** Kill FFmpeg if no output is received within this window after spawn. */
|
|
8
15
|
const FFMPEG_STARTUP_TIMEOUT = 3e4;
|
|
16
|
+
/** Find the FFmpeg binary via `Bun.which()` or a PATH search. */
|
|
17
|
+
function findFfmpegPath() {
|
|
18
|
+
const bunWhich = globalThis.Bun?.which;
|
|
19
|
+
if (typeof bunWhich === "function") return bunWhich("ffmpeg") ?? null;
|
|
20
|
+
const paths = process.env.PATH?.split(":") ?? [];
|
|
21
|
+
for (const dir of paths) try {
|
|
22
|
+
const full = `${dir}/ffmpeg`;
|
|
23
|
+
statSync(full);
|
|
24
|
+
return full;
|
|
25
|
+
} catch {}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Run FFmpeg to completion and return stdout — for output that is a file
|
|
30
|
+
* rather than a pipe (the segment muxer's `-segment_list pipe:1`).
|
|
31
|
+
*/
|
|
32
|
+
async function runFfmpeg(args, signal) {
|
|
33
|
+
const ffmpegPath = findFfmpegPath();
|
|
34
|
+
if (!ffmpegPath) throw new TikTokError("ffmpeg-not-found", "FFmpeg not found. Install it:\n Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\n macOS: brew install ffmpeg\n Windows: choco install ffmpeg");
|
|
35
|
+
try {
|
|
36
|
+
return (await execa(ffmpegPath, args, {
|
|
37
|
+
cancelSignal: signal,
|
|
38
|
+
stdio: [
|
|
39
|
+
"ignore",
|
|
40
|
+
"pipe",
|
|
41
|
+
"pipe"
|
|
42
|
+
]
|
|
43
|
+
})).stdout ?? "";
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (signal?.aborted) throw new TikTokError("aborted", "Aborted by user");
|
|
46
|
+
throw new TikTokError("ffmpeg-error", `FFmpeg failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
9
49
|
/**
|
|
10
50
|
* Build FFmpeg HTTP option arguments for cookies and headers.
|
|
11
51
|
* TikTok's CDN requires session cookies and a Referer to serve the stream.
|
|
@@ -111,6 +151,6 @@ async function pipeFfmpegSegment(ffmpegPath, url, writer, signal, maxDuration, c
|
|
|
111
151
|
});
|
|
112
152
|
}
|
|
113
153
|
//#endregion
|
|
114
|
-
export { pipeFfmpegSegment };
|
|
154
|
+
export { findFfmpegPath, pipeFfmpegSegment, runFfmpeg };
|
|
115
155
|
|
|
116
|
-
//# sourceMappingURL=ffmpeg
|
|
156
|
+
//# sourceMappingURL=ffmpeg.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ffmpeg.mjs","names":[],"sources":["../../../src/io/media/ffmpeg.ts"],"sourcesContent":["/**\n * FFmpeg — the single home for process invocation.\n *\n * Two modes: `pipeFfmpegSegment` streams stdout into a `WriteStream` (live\n * capture), `runFfmpeg` runs to completion and returns stdout (remux,\n * segment). Locating the binary lives here too, so no caller reimplements a\n * PATH lookup.\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\"\nimport type { WriteStream } from \"node:fs\"\nimport { statSync } from \"node:fs\"\nimport { execa } from \"execa\"\nimport { TikTokError } from \"../../core/errors\"\n\n/** Kill FFmpeg if no output is received within this window after spawn. */\nexport const FFMPEG_STARTUP_TIMEOUT = 30_000\n\n/** Find the FFmpeg binary via `Bun.which()` or a PATH search. */\nexport function findFfmpegPath(): string | null {\n const bunWhich = (globalThis as { Bun?: { which?: (cmd: string) => string | null } }).Bun?.which\n if (typeof bunWhich === \"function\") {\n return (bunWhich(\"ffmpeg\") as string) ?? null\n }\n const paths = process.env.PATH?.split(\":\") ?? []\n for (const dir of paths) {\n try {\n const full = `${dir}/ffmpeg`\n statSync(full)\n return full\n } catch {\n // not found in this directory\n }\n }\n return null\n}\n\n/**\n * Run FFmpeg to completion and return stdout — for output that is a file\n * rather than a pipe (the segment muxer's `-segment_list pipe:1`).\n */\nexport async function runFfmpeg(args: string[], signal?: AbortSignal): Promise<string> {\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found. Install it:\\n\" +\n \" Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\\n\" +\n \" macOS: brew install ffmpeg\\n\" +\n \" Windows: choco install ffmpeg\",\n )\n }\n try {\n const result = await execa(ffmpegPath, args, {\n cancelSignal: signal,\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n return result.stdout ?? \"\"\n } catch (err: unknown) {\n if (signal?.aborted) {\n throw new TikTokError(\"aborted\", \"Aborted by user\")\n }\n const message = err instanceof Error ? err.message : String(err)\n throw new TikTokError(\"ffmpeg-error\", `FFmpeg failed: ${message}`)\n }\n}\n\n/**\n * Build FFmpeg HTTP option arguments for cookies and headers.\n * TikTok's CDN requires session cookies and a Referer to serve the stream.\n */\nfunction buildHttpArgs(cookies?: Record<string, string>): string[] {\n const args: string[] = []\n\n // ponytail: user-agent header, CDNs tend to be picky\n args.push(\n \"-user_agent\",\n \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0\",\n )\n args.push(\"-headers\", \"Referer: https://www.tiktok.com/\\r\\n\")\n\n if (cookies && Object.keys(cookies).length > 0) {\n const cookieStr = Object.entries(cookies)\n .map(([k, v]) => `${k}=${v}`)\n .join(\"\\r\\n\")\n args.push(\"-cookies\", cookieStr)\n }\n\n return args\n}\n\n/** Standard FFmpeg reconnect + mpegts pipe arguments. */\nconst FFMPEG_BASE_ARGS = [\n \"-reconnect\",\n \"1\",\n \"-reconnect_at_eof\",\n \"1\",\n \"-reconnect_streamed\",\n \"1\",\n \"-reconnect_delay_max\",\n \"5\",\n \"-c\",\n \"copy\",\n \"-f\",\n \"mpegts\",\n \"pipe:1\",\n]\n\n/**\n * Spawn an FFmpeg process to download from `url` and pipe its stdout to\n * `writer` with backpressure handling.\n *\n * Uses child_process.spawn directly (not execa) because execa v9 defaults\n * to encoding stdout as UTF-8, which corrupts binary MPEG-TS data.\n */\nexport async function pipeFfmpegSegment(\n ffmpegPath: string,\n url: string,\n writer: WriteStream,\n signal: AbortSignal,\n maxDuration?: number,\n cookies?: Record<string, string>,\n): Promise<void> {\n const durationArgs = maxDuration ? [\"-t\", String(maxDuration)] : []\n const httpArgs = buildHttpArgs(cookies)\n // ponytail: input options (-headers, -cookies, -user_agent) must come before -i\n const args = [...httpArgs, \"-i\", url, ...durationArgs, ...FFMPEG_BASE_ARGS]\n const proc: ChildProcess = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n\n let stderr = \"\"\n let firstDataTimer: ReturnType<typeof setTimeout> | null = null\n\n const clearStartupTimer = () => {\n if (firstDataTimer) {\n clearTimeout(firstDataTimer)\n firstDataTimer = null\n }\n }\n\n // Forward abort signal to process\n const onAbort = () => {\n if (!proc.killed) proc.kill(\"SIGTERM\")\n }\n signal.addEventListener(\"abort\", onAbort, { once: true })\n\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n clearStartupTimer()\n stderr += chunk.toString()\n if (stderr.length > 10000) stderr = stderr.slice(-5000)\n })\n\n proc.stdout?.on(\"data\", (chunk: Buffer) => {\n clearStartupTimer()\n const canContinue = writer.write(chunk)\n if (!canContinue) {\n proc.stdout?.pause()\n writer.once(\"drain\", () => proc.stdout?.resume())\n }\n })\n\n await new Promise<void>((resolve, reject) => {\n firstDataTimer = setTimeout(() => {\n if (!proc.killed) {\n proc.kill(\"SIGTERM\")\n setTimeout(() => {\n if (!proc.killed) proc.kill(\"SIGKILL\")\n }, 2000)\n }\n reject(\n new TikTokError(\n \"ffmpeg-error\",\n `FFmpeg startup timed out after ${FFMPEG_STARTUP_TIMEOUT / 1000}s\\n${stderr.slice(-500)}`,\n ),\n )\n }, FFMPEG_STARTUP_TIMEOUT)\n\n proc.on(\"close\", (code) => {\n clearStartupTimer()\n signal.removeEventListener(\"abort\", onAbort)\n if (signal.aborted) {\n resolve()\n return\n }\n if (code === 0 || code === null) {\n resolve()\n return\n }\n reject(\n new TikTokError(\"ffmpeg-error\", `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`),\n )\n })\n\n proc.on(\"error\", (err) => {\n clearStartupTimer()\n signal.removeEventListener(\"abort\", onAbort)\n reject(err)\n })\n })\n}\n"],"mappings":";;;;;;;;;;;;;;AAgBA,MAAa,yBAAyB;;AAGtC,SAAgB,iBAAgC;CAC9C,MAAM,WAAY,WAAoE,KAAK;CAC3F,IAAI,OAAO,aAAa,YACtB,OAAQ,SAAS,QAAQ,KAAgB;CAE3C,MAAM,QAAQ,QAAQ,IAAI,MAAM,MAAM,GAAG,KAAK,CAAC;CAC/C,KAAK,MAAM,OAAO,OAChB,IAAI;EACF,MAAM,OAAO,GAAG,IAAI;EACpB,SAAS,IAAI;EACb,OAAO;CACT,QAAQ,CAER;CAEF,OAAO;AACT;;;;;AAMA,eAAsB,UAAU,MAAgB,QAAuC;CACrF,MAAM,aAAa,eAAe;CAClC,IAAI,CAAC,YACH,MAAM,IAAI,YACR,oBACA,sKAIF;CAEF,IAAI;EAKF,QAAO,MAJc,MAAM,YAAY,MAAM;GAC3C,cAAc;GACd,OAAO;IAAC;IAAU;IAAQ;GAAM;EAClC,CAAC,EAAA,CACa,UAAU;CAC1B,SAAS,KAAc;EACrB,IAAI,QAAQ,SACV,MAAM,IAAI,YAAY,WAAW,iBAAiB;EAGpD,MAAM,IAAI,YAAY,gBAAgB,kBADtB,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GACE;CACnE;AACF;;;;;AAMA,SAAS,cAAc,SAA4C;CACjE,MAAM,OAAiB,CAAC;CAGxB,KAAK,KACH,eACA,kFACF;CACA,KAAK,KAAK,YAAY,sCAAsC;CAE5D,IAAI,WAAW,OAAO,KAAK,OAAO,CAAC,CAAC,SAAS,GAAG;EAC9C,MAAM,YAAY,OAAO,QAAQ,OAAO,CAAC,CACtC,KAAK,CAAC,GAAG,OAAO,GAAG,EAAE,GAAG,GAAG,CAAC,CAC5B,KAAK,MAAM;EACd,KAAK,KAAK,YAAY,SAAS;CACjC;CAEA,OAAO;AACT;;AAGA,MAAM,mBAAmB;CACvB;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;;AASA,eAAsB,kBACpB,YACA,KACA,QACA,QACA,aACA,SACe;CACf,MAAM,eAAe,cAAc,CAAC,MAAM,OAAO,WAAW,CAAC,IAAI,CAAC;CAIlE,MAAM,OAAqB,MAAM,YAAY;EAD/B,GAFG,cAAc,OAEP;EAAG;EAAM;EAAK,GAAG;EAAc,GAAG;CACV,GAAG,EACjD,OAAO;EAAC;EAAU;EAAQ;CAAM,EAClC,CAAC;CAED,IAAI,SAAS;CACb,IAAI,iBAAuD;CAE3D,MAAM,0BAA0B;EAC9B,IAAI,gBAAgB;GAClB,aAAa,cAAc;GAC3B,iBAAiB;EACnB;CACF;CAGA,MAAM,gBAAgB;EACpB,IAAI,CAAC,KAAK,QAAQ,KAAK,KAAK,SAAS;CACvC;CACA,OAAO,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;CAExD,KAAK,QAAQ,GAAG,SAAS,UAAkB;EACzC,kBAAkB;EAClB,UAAU,MAAM,SAAS;EACzB,IAAI,OAAO,SAAS,KAAO,SAAS,OAAO,MAAM,IAAK;CACxD,CAAC;CAED,KAAK,QAAQ,GAAG,SAAS,UAAkB;EACzC,kBAAkB;EAElB,IAAI,CADgB,OAAO,MAAM,KAClB,GAAG;GAChB,KAAK,QAAQ,MAAM;GACnB,OAAO,KAAK,eAAe,KAAK,QAAQ,OAAO,CAAC;EAClD;CACF,CAAC;CAED,MAAM,IAAI,SAAe,SAAS,WAAW;EAC3C,iBAAiB,iBAAiB;GAChC,IAAI,CAAC,KAAK,QAAQ;IAChB,KAAK,KAAK,SAAS;IACnB,iBAAiB;KACf,IAAI,CAAC,KAAK,QAAQ,KAAK,KAAK,SAAS;IACvC,GAAG,GAAI;GACT;GACA,OACE,IAAI,YACF,gBACA,kCAAkC,yBAAyB,IAAK,KAAK,OAAO,MAAM,IAAI,GACxF,CACF;EACF,GAAG,sBAAsB;EAEzB,KAAK,GAAG,UAAU,SAAS;GACzB,kBAAkB;GAClB,OAAO,oBAAoB,SAAS,OAAO;GAC3C,IAAI,OAAO,SAAS;IAClB,QAAQ;IACR;GACF;GACA,IAAI,SAAS,KAAK,SAAS,MAAM;IAC/B,QAAQ;IACR;GACF;GACA,OACE,IAAI,YAAY,gBAAgB,2BAA2B,KAAK,IAAI,OAAO,MAAM,IAAI,GAAG,CAC1F;EACF,CAAC;EAED,KAAK,GAAG,UAAU,QAAQ;GACxB,kBAAkB;GAClB,OAAO,oBAAoB,SAAS,OAAO;GAC3C,OAAO,GAAG;EACZ,CAAC;CACH,CAAC;AACH"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalize.mjs","names":[],"sources":["../../../src/io/media/normalize.ts"],"sourcesContent":["/**\n * Audio normalizer — wraps peaknorm's EBU R128 loudness normalization.\n * Runs after conversion/segmenting to normalize the final MP4 audio.\n */\n\nimport type { NormalizeOptions, NormalizeResult } from \"peaknorm\"\nimport { normalizeFile } from \"peaknorm\"\nimport type { Logger } from \"../../core/log\"\n\nexport interface AudioNormalizer {\n /** Normalize a single audio/video file. Returns the normalize result. */\n normalize(file: string): Promise<NormalizeResult>\n}\n\nexport interface AudioNormalizerOptions {\n /** Target loudness in LUFS (default: -14). */\n loudness: number\n /** Audio codec for output (default: peaknorm's default, \"libopus\"). */\n audioCodec?: string\n /** Audio bitrate for output (default: peaknorm's default, \"96k\"). */\n audioBitrate?: string\n /** Called when a file starts normalization. */\n onStart?: (file: string) => void\n /** Called with progress updates (percent 0-100, phase \"analyzing\"|\"normalizing\"). */\n onProgress?: (file: string, percent: number, phase: string) => void\n /** Called when a file completes normalization successfully. */\n onComplete?: (result: NormalizeResult) => void\n /** Called when a file errors during normalization. */\n onError?: (file: string, error: Error) => void\n}\n\nexport function createAudioNormalizer(\n opts: AudioNormalizerOptions,\n logger?: Logger,\n): AudioNormalizer {\n return {\n async normalize(file: string): Promise<NormalizeResult> {\n logger?.info(`Normalizing audio: ${file}`)\n opts.onStart?.(file)\n\n try {\n const normalizeOpts: NormalizeOptions = {\n loudness: opts.loudness,\n backup: false,\n onFileProgress: (f, percent, phase) => {\n opts.onProgress?.(f, percent, phase)\n },\n }\n if (opts.audioCodec !== undefined) normalizeOpts.audioCodec = opts.audioCodec\n if (opts.audioBitrate !== undefined) normalizeOpts.audioBitrate = opts.audioBitrate\n const result = await normalizeFile(file, normalizeOpts)\n\n logger?.info(`Audio normalized: ${result.output}`)\n opts.onComplete?.(result)\n return result\n } catch (err) {\n const error = err instanceof Error ? err : new Error(String(err))\n logger?.error(`Audio normalization failed: ${error.message}`)\n opts.onError?.(file, error)\n throw error\n }\n },\n }\n}\n"],"mappings":";;AA+BA,SAAgB,sBACd,MACA,QACiB;CACjB,OAAO,EACL,MAAM,UAAU,MAAwC;EACtD,QAAQ,KAAK,sBAAsB,MAAM;EACzC,KAAK,UAAU,IAAI;EAEnB,IAAI;GACF,MAAM,gBAAkC;IACtC,UAAU,KAAK;IACf,QAAQ;IACR,iBAAiB,GAAG,SAAS,UAAU;KACrC,KAAK,aAAa,GAAG,SAAS,KAAK;IACrC;GACF;GACA,IAAI,KAAK,eAAe,KAAA,GAAW,cAAc,aAAa,KAAK;GACnE,IAAI,KAAK,iBAAiB,KAAA,GAAW,cAAc,eAAe,KAAK;GACvE,MAAM,SAAS,MAAM,cAAc,MAAM,aAAa;GAEtD,QAAQ,KAAK,qBAAqB,OAAO,QAAQ;GACjD,KAAK,aAAa,MAAM;GACxB,OAAO;EACT,SAAS,KAAK;GACZ,MAAM,QAAQ,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC;GAChE,QAAQ,MAAM,+BAA+B,MAAM,SAAS;GAC5D,KAAK,UAAU,MAAM,KAAK;GAC1B,MAAM;EACR;CACF,EACF;AACF"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { isLiveReason, verdictOf } from "../../core/verdict.mjs";
|
|
2
|
+
import { discover } from "./discovery/chain.mjs";
|
|
3
|
+
import { createApiLiveSource, createProfileSource } from "./discovery/fallback.mjs";
|
|
4
|
+
import { createPageSource } from "./discovery/page.mjs";
|
|
5
|
+
import { createPageFetcher } from "./page.mjs";
|
|
6
|
+
import { createWebcastApi } from "./webcast.mjs";
|
|
7
|
+
//#region src/io/tiktok/client.ts
|
|
8
|
+
function createTikTokClient(http, logger, showDebug, signal) {
|
|
9
|
+
const debug = (msg) => {
|
|
10
|
+
logger?.debug(msg);
|
|
11
|
+
if (showDebug) process.stderr.write(`[API_DEBUG] ${msg}\n`);
|
|
12
|
+
};
|
|
13
|
+
let cached = null;
|
|
14
|
+
let cachedUser = "";
|
|
15
|
+
const webcast = createWebcastApi(http, debug, signal);
|
|
16
|
+
const tryFetchPage = createPageFetcher(http, debug, signal);
|
|
17
|
+
const sources = [
|
|
18
|
+
createPageSource(),
|
|
19
|
+
createApiLiveSource(),
|
|
20
|
+
createProfileSource()
|
|
21
|
+
];
|
|
22
|
+
async function ensureCache(user) {
|
|
23
|
+
if (cached && cachedUser === user) return cached;
|
|
24
|
+
cached = await resolveVerdict(user);
|
|
25
|
+
cachedUser = user;
|
|
26
|
+
return cached;
|
|
27
|
+
}
|
|
28
|
+
function invalidateCache() {
|
|
29
|
+
cached = null;
|
|
30
|
+
cachedUser = "";
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
invalidateCache,
|
|
34
|
+
async getVerdict(user) {
|
|
35
|
+
return ensureCache(user);
|
|
36
|
+
},
|
|
37
|
+
async isRoomAlive(roomId) {
|
|
38
|
+
if (cached?.room && String(cached.room.id) === String(roomId)) return isLiveReason(cached.reason);
|
|
39
|
+
return webcast.fetchCheckAlive(roomId);
|
|
40
|
+
},
|
|
41
|
+
async getLiveUrl(roomId) {
|
|
42
|
+
if (cached?.room && String(cached.room.id) === String(roomId) && cached.stream) return cached.stream.url;
|
|
43
|
+
const res = await webcast.fetchStreamUrlFromApi(roomId);
|
|
44
|
+
return res.ok ? res.value : null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
async function resolveVerdict(user) {
|
|
48
|
+
const { candidate, evidence } = await discover(sources, {
|
|
49
|
+
user,
|
|
50
|
+
http,
|
|
51
|
+
debug,
|
|
52
|
+
fetchPage: tryFetchPage,
|
|
53
|
+
webcast
|
|
54
|
+
});
|
|
55
|
+
switch (candidate.kind) {
|
|
56
|
+
case "room": return verdictOf({
|
|
57
|
+
room: candidate.room,
|
|
58
|
+
stream: candidate.stream,
|
|
59
|
+
evidence
|
|
60
|
+
});
|
|
61
|
+
case "denied": return verdictOf({
|
|
62
|
+
room: candidate.room,
|
|
63
|
+
denied: true,
|
|
64
|
+
evidence
|
|
65
|
+
});
|
|
66
|
+
case "failed": return verdictOf({
|
|
67
|
+
failure: candidate.error,
|
|
68
|
+
evidence
|
|
69
|
+
});
|
|
70
|
+
case "miss": return verdictOf({ evidence });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
export { createTikTokClient };
|
|
76
|
+
|
|
77
|
+
//# sourceMappingURL=client.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.mjs","names":[],"sources":["../../../src/io/tiktok/client.ts"],"sourcesContent":["/**\n * TikTok API — resolves room IDs, live status, and stream URLs.\n *\n * `createTikTokClient` is a thin orchestrator: per-user cache, cache invalidation,\n * and `discover()` over sources returning `Candidate`s. The chain turns\n * failures into evidence instead of aborting. `getVerdict` is the only\n * public verdict surface.\n *\n * Architecture:\n * createTikTokClient(httpClient) → { isRoomAlive, getLiveUrl, getVerdict }\n *\n * All public methods share a per-tick cache so the page is fetched at most once\n * per poll cycle. The optional `signal` aborts in-flight HTTP requests.\n *\n * Note: The room/enter Webcast endpoint has been deprecated due to frequent\n * 403 errors. All lookups go through room/info (reliable for live + offline).\n */\n\nimport type { Logger } from \"../../core/log\"\nimport { isLiveReason, type Verdict, verdictOf } from \"../../core/verdict\"\nimport type { HttpClient } from \"../http\"\nimport { type DiscoverySource, discover, type ResolveContext } from \"./discovery/chain\"\nimport { createApiLiveSource, createProfileSource } from \"./discovery/fallback\"\nimport { createPageSource } from \"./discovery/page\"\nimport { createPageFetcher } from \"./page\"\nimport { createWebcastApi } from \"./webcast\"\n\nexport interface TikTokClient {\n /** Check if a room is currently live. */\n isRoomAlive(roomId: string): Promise<boolean>\n /** Get the best-quality FLV stream URL for a room. Returns null on failure. */\n getLiveUrl(roomId: string): Promise<string | null>\n /** Full per-tick verdict with reason, refs, evidence trail, and failure. */\n getVerdict(user: string): Promise<Verdict>\n /** Invalidate per-tick cache (call at start of each poll tick). */\n invalidateCache(): void\n}\n\nexport function createTikTokClient(\n http: HttpClient,\n logger?: Logger,\n showDebug?: boolean,\n signal?: AbortSignal,\n): TikTokClient {\n // Debug logging helper — writes to stderr only when --debug is passed.\n const debug = (msg: string) => {\n logger?.debug(msg)\n if (showDebug) process.stderr.write(`[API_DEBUG] ${msg}\\n`)\n }\n let cached: Verdict | null = null\n let cachedUser = \"\"\n const webcast = createWebcastApi(http, debug, signal)\n const tryFetchPage = createPageFetcher(http, debug, signal)\n const sources: DiscoverySource[] = [\n createPageSource(),\n createApiLiveSource(),\n createProfileSource(),\n ]\n\n async function ensureCache(user: string): Promise<Verdict> {\n if (cached && cachedUser === user) return cached\n cached = await resolveVerdict(user)\n cachedUser = user\n return cached\n }\n\n function invalidateCache(): void {\n cached = null\n cachedUser = \"\"\n }\n\n return {\n invalidateCache,\n\n async getVerdict(user: string): Promise<Verdict> {\n return ensureCache(user)\n },\n\n async isRoomAlive(roomId: string): Promise<boolean> {\n if (cached?.room && String(cached.room.id) === String(roomId)) {\n return isLiveReason(cached.reason)\n }\n // Use the lightweight check_alive endpoint instead of re-fetching\n // the full room/info response.\n return webcast.fetchCheckAlive(roomId)\n },\n\n async getLiveUrl(roomId: string): Promise<string | null> {\n if (cached?.room && String(cached.room.id) === String(roomId) && cached.stream) {\n return cached.stream.url\n }\n // Room ID, not a username: never route this through ensureCache.\n const res = await webcast.fetchStreamUrlFromApi(roomId)\n return res.ok ? res.value : null\n },\n }\n\n // ─── Internal: discovery chain → one verdict ───\n\n async function resolveVerdict(user: string): Promise<Verdict> {\n const ctx: ResolveContext = { user, http, debug, fetchPage: tryFetchPage, webcast }\n const { candidate, evidence } = await discover(sources, ctx)\n switch (candidate.kind) {\n case \"room\":\n return verdictOf({ room: candidate.room, stream: candidate.stream, evidence })\n case \"denied\":\n return verdictOf({ room: candidate.room, denied: true, evidence })\n case \"failed\":\n return verdictOf({ failure: candidate.error, evidence })\n case \"miss\":\n return verdictOf({ evidence })\n }\n }\n}\n"],"mappings":";;;;;;;AAsCA,SAAgB,mBACd,MACA,QACA,WACA,QACc;CAEd,MAAM,SAAS,QAAgB;EAC7B,QAAQ,MAAM,GAAG;EACjB,IAAI,WAAW,QAAQ,OAAO,MAAM,eAAe,IAAI,GAAG;CAC5D;CACA,IAAI,SAAyB;CAC7B,IAAI,aAAa;CACjB,MAAM,UAAU,iBAAiB,MAAM,OAAO,MAAM;CACpD,MAAM,eAAe,kBAAkB,MAAM,OAAO,MAAM;CAC1D,MAAM,UAA6B;EACjC,iBAAiB;EACjB,oBAAoB;EACpB,oBAAoB;CACtB;CAEA,eAAe,YAAY,MAAgC;EACzD,IAAI,UAAU,eAAe,MAAM,OAAO;EAC1C,SAAS,MAAM,eAAe,IAAI;EAClC,aAAa;EACb,OAAO;CACT;CAEA,SAAS,kBAAwB;EAC/B,SAAS;EACT,aAAa;CACf;CAEA,OAAO;EACL;EAEA,MAAM,WAAW,MAAgC;GAC/C,OAAO,YAAY,IAAI;EACzB;EAEA,MAAM,YAAY,QAAkC;GAClD,IAAI,QAAQ,QAAQ,OAAO,OAAO,KAAK,EAAE,MAAM,OAAO,MAAM,GAC1D,OAAO,aAAa,OAAO,MAAM;GAInC,OAAO,QAAQ,gBAAgB,MAAM;EACvC;EAEA,MAAM,WAAW,QAAwC;GACvD,IAAI,QAAQ,QAAQ,OAAO,OAAO,KAAK,EAAE,MAAM,OAAO,MAAM,KAAK,OAAO,QACtE,OAAO,OAAO,OAAO;GAGvB,MAAM,MAAM,MAAM,QAAQ,sBAAsB,MAAM;GACtD,OAAO,IAAI,KAAK,IAAI,QAAQ;EAC9B;CACF;CAIA,eAAe,eAAe,MAAgC;EAE5D,MAAM,EAAE,WAAW,aAAa,MAAM,SAAS,SAAS;GAD1B;GAAM;GAAM;GAAO,WAAW;GAAc;EAChB,CAAC;EAC3D,QAAQ,UAAU,MAAlB;GACE,KAAK,QACH,OAAO,UAAU;IAAE,MAAM,UAAU;IAAM,QAAQ,UAAU;IAAQ;GAAS,CAAC;GAC/E,KAAK,UACH,OAAO,UAAU;IAAE,MAAM,UAAU;IAAM,QAAQ;IAAM;GAAS,CAAC;GACnE,KAAK,UACH,OAAO,UAAU;IAAE,SAAS,UAAU;IAAO;GAAS,CAAC;GACzD,KAAK,QACH,OAAO,UAAU,EAAE,SAAS,CAAC;EACjC;CACF;AACF"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
//#region src/io/tiktok/discovery/chain.ts
|
|
2
|
+
/** Nothing found here — let the next source try. */
|
|
3
|
+
function miss(note) {
|
|
4
|
+
return {
|
|
5
|
+
kind: "miss",
|
|
6
|
+
note
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/** A transport/parse failure. The chain keeps going; it only wins over a miss. */
|
|
10
|
+
function failed(error) {
|
|
11
|
+
return {
|
|
12
|
+
kind: "failed",
|
|
13
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Room live with a stream URL. Uses `knownUrl` when the page already carried
|
|
18
|
+
* it; otherwise asks the Webcast API. A 4003110 refusal becomes `denied`.
|
|
19
|
+
*/
|
|
20
|
+
async function withStream(ctx, room, source, knownUrl) {
|
|
21
|
+
if (knownUrl) return {
|
|
22
|
+
kind: "room",
|
|
23
|
+
room,
|
|
24
|
+
stream: { url: knownUrl },
|
|
25
|
+
source
|
|
26
|
+
};
|
|
27
|
+
const res = await ctx.webcast.fetchStreamUrlFromApi(room.id);
|
|
28
|
+
if (res.ok) return {
|
|
29
|
+
kind: "room",
|
|
30
|
+
room,
|
|
31
|
+
stream: res.value ? { url: res.value } : null,
|
|
32
|
+
source
|
|
33
|
+
};
|
|
34
|
+
if (res.statusCode === 4003110) return refused(ctx, room.id, room.title, source);
|
|
35
|
+
return {
|
|
36
|
+
kind: "room",
|
|
37
|
+
room,
|
|
38
|
+
stream: null,
|
|
39
|
+
source
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Authoritative room/info lookup: the single 4003110 consumer for room IDs. */
|
|
43
|
+
async function fromRoomInfo(ctx, roomId, source) {
|
|
44
|
+
const res = await ctx.webcast.roomInfo(roomId);
|
|
45
|
+
if (res.ok) {
|
|
46
|
+
if (!res.value) return miss(`room/info returned no data for ${roomId}`);
|
|
47
|
+
return {
|
|
48
|
+
kind: "room",
|
|
49
|
+
room: res.value.room,
|
|
50
|
+
stream: res.value.stream,
|
|
51
|
+
source
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (res.statusCode === 4003110) return refused(ctx, roomId, null, source);
|
|
55
|
+
if (res.statusCode === 0) return failed(/* @__PURE__ */ new Error(`room/info transport failure for ${roomId}`));
|
|
56
|
+
return miss(`room/info HTTP ${res.statusCode} for ${roomId}`);
|
|
57
|
+
}
|
|
58
|
+
/** Gate refusal: check_alive decides whether the room is live (restricted) or offline. */
|
|
59
|
+
async function refused(ctx, roomId, title, source) {
|
|
60
|
+
return {
|
|
61
|
+
kind: "denied",
|
|
62
|
+
room: await ctx.webcast.refusedRoom(roomId, title),
|
|
63
|
+
code: 4003110,
|
|
64
|
+
source
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function outcomeOf(candidate) {
|
|
68
|
+
switch (candidate.kind) {
|
|
69
|
+
case "room": return "hit";
|
|
70
|
+
case "denied": return "denied";
|
|
71
|
+
case "failed": return "error";
|
|
72
|
+
case "miss": return "miss";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Run discovery sources in order. A throw — sync or async — becomes a
|
|
77
|
+
* `failed` candidate and the chain continues: the `probe.ts:154` dead end,
|
|
78
|
+
* inverted. A room or a gate refusal ends the chain; the first failure
|
|
79
|
+
* outranks a later miss.
|
|
80
|
+
*/
|
|
81
|
+
async function discover(sources, ctx) {
|
|
82
|
+
const evidence = [];
|
|
83
|
+
let best = {
|
|
84
|
+
kind: "miss",
|
|
85
|
+
note: "no sources configured"
|
|
86
|
+
};
|
|
87
|
+
for (const source of sources) {
|
|
88
|
+
const candidate = await (async () => {
|
|
89
|
+
try {
|
|
90
|
+
return await source.find(ctx);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
ctx.debug(`discover: ${source.id} threw — ${error instanceof Error ? error.message : error}`);
|
|
93
|
+
return {
|
|
94
|
+
kind: "failed",
|
|
95
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
evidence.push({
|
|
100
|
+
source: candidate.kind === "room" || candidate.kind === "denied" ? candidate.source : source.id,
|
|
101
|
+
outcome: outcomeOf(candidate)
|
|
102
|
+
});
|
|
103
|
+
if (candidate.kind === "room" || candidate.kind === "denied") return {
|
|
104
|
+
candidate,
|
|
105
|
+
evidence
|
|
106
|
+
};
|
|
107
|
+
if (candidate.kind === "failed" && best.kind !== "failed") best = candidate;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
candidate: best,
|
|
111
|
+
evidence
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
export { discover, failed, fromRoomInfo, miss, withStream };
|
|
116
|
+
|
|
117
|
+
//# sourceMappingURL=chain.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chain.mjs","names":[],"sources":["../../../../src/io/tiktok/discovery/chain.ts"],"sourcesContent":["/**\n * Discovery chain — one idea in one file: a source reports facts, the chain\n * turns every outcome (including a throw) into evidence, and the first room or\n * gate refusal ends it.\n *\n * Types, candidate constructors, and the runner live together because they are\n * a single contract; splitting them across `types.ts` and `candidates.ts` only\n * added import edges.\n */\n\nimport type { Evidence, EvidenceOutcome, RoomRef, SourceId, StreamRef } from \"../../../core/verdict\"\nimport type { HttpClient } from \"../../http\"\nimport type { WebcastReader } from \"../webcast\"\n\n/**\n * What one discovery source found. `room`/`denied` end the chain; `miss` and\n * `failed` let the next source try — a throw is evidence, never a dead end.\n */\nexport type Candidate =\n | { kind: \"room\"; room: RoomRef; stream: StreamRef | null; source: SourceId }\n | { kind: \"denied\"; room: RoomRef; code: number; source: SourceId }\n | { kind: \"miss\"; note: string }\n | { kind: \"failed\"; error: Error }\n\nexport interface ResolveContext {\n user: string\n http: HttpClient\n debug: (msg: string) => void\n fetchPage: (user: string, path: string) => Promise<{ html: string; baseUrl: string } | null>\n webcast: WebcastReader\n}\n\nexport interface DiscoverySource {\n readonly id: \"page\" | \"api-live\" | \"profile\"\n find(ctx: ResolveContext): Promise<Candidate>\n}\n\n// ─── candidate constructors ────────────────────────────────\n\n/** Nothing found here — let the next source try. */\nexport function miss(note: string): Candidate {\n return { kind: \"miss\", note }\n}\n\n/** A transport/parse failure. The chain keeps going; it only wins over a miss. */\nexport function failed(error: unknown): Candidate {\n return { kind: \"failed\", error: error instanceof Error ? error : new Error(String(error)) }\n}\n\n/**\n * Room live with a stream URL. Uses `knownUrl` when the page already carried\n * it; otherwise asks the Webcast API. A 4003110 refusal becomes `denied`.\n */\nexport async function withStream(\n ctx: ResolveContext,\n room: RoomRef,\n source: SourceId,\n knownUrl: string | null,\n): Promise<Candidate> {\n if (knownUrl) return { kind: \"room\", room, stream: { url: knownUrl }, source }\n const res = await ctx.webcast.fetchStreamUrlFromApi(room.id)\n if (res.ok) {\n return { kind: \"room\", room, stream: res.value ? { url: res.value } : null, source }\n }\n if (res.statusCode === 4003110) return refused(ctx, room.id, room.title, source)\n return { kind: \"room\", room, stream: null, source }\n}\n\n/** Authoritative room/info lookup: the single 4003110 consumer for room IDs. */\nexport async function fromRoomInfo(\n ctx: ResolveContext,\n roomId: string,\n source: SourceId,\n): Promise<Candidate> {\n const res = await ctx.webcast.roomInfo(roomId)\n if (res.ok) {\n if (!res.value) return miss(`room/info returned no data for ${roomId}`)\n return { kind: \"room\", room: res.value.room, stream: res.value.stream, source }\n }\n if (res.statusCode === 4003110) return refused(ctx, roomId, null, source)\n if (res.statusCode === 0) return failed(new Error(`room/info transport failure for ${roomId}`))\n return miss(`room/info HTTP ${res.statusCode} for ${roomId}`)\n}\n\n/** Gate refusal: check_alive decides whether the room is live (restricted) or offline. */\nexport async function refused(\n ctx: ResolveContext,\n roomId: string,\n title: string | null,\n source: SourceId,\n): Promise<Candidate> {\n const room = await ctx.webcast.refusedRoom(roomId, title)\n return { kind: \"denied\", room, code: 4003110, source }\n}\n\n// ─── runner ────────────────────────────────────────────────\n\nfunction outcomeOf(candidate: Candidate): EvidenceOutcome {\n switch (candidate.kind) {\n case \"room\":\n return \"hit\"\n case \"denied\":\n return \"denied\"\n case \"failed\":\n return \"error\"\n case \"miss\":\n return \"miss\"\n }\n}\n\n/**\n * Run discovery sources in order. A throw — sync or async — becomes a\n * `failed` candidate and the chain continues: the `probe.ts:154` dead end,\n * inverted. A room or a gate refusal ends the chain; the first failure\n * outranks a later miss.\n */\nexport async function discover(\n sources: readonly DiscoverySource[],\n ctx: ResolveContext,\n): Promise<{ candidate: Candidate; evidence: Evidence[] }> {\n const evidence: Evidence[] = []\n let best: Candidate = { kind: \"miss\", note: \"no sources configured\" }\n\n for (const source of sources) {\n const candidate = await (async (): Promise<Candidate> => {\n try {\n return await source.find(ctx)\n } catch (error) {\n ctx.debug(\n `discover: ${source.id} threw — ${error instanceof Error ? error.message : error}`,\n )\n return { kind: \"failed\", error: error instanceof Error ? error : new Error(String(error)) }\n }\n })()\n evidence.push({\n source:\n candidate.kind === \"room\" || candidate.kind === \"denied\" ? candidate.source : source.id,\n outcome: outcomeOf(candidate),\n })\n if (candidate.kind === \"room\" || candidate.kind === \"denied\") return { candidate, evidence }\n // First failure is the root cause; a later failure must not overwrite it.\n if (candidate.kind === \"failed\" && best.kind !== \"failed\") best = candidate\n }\n\n return { candidate: best, evidence }\n}\n"],"mappings":";;AAwCA,SAAgB,KAAK,MAAyB;CAC5C,OAAO;EAAE,MAAM;EAAQ;CAAK;AAC9B;;AAGA,SAAgB,OAAO,OAA2B;CAChD,OAAO;EAAE,MAAM;EAAU,OAAO,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC;CAAE;AAC5F;;;;;AAMA,eAAsB,WACpB,KACA,MACA,QACA,UACoB;CACpB,IAAI,UAAU,OAAO;EAAE,MAAM;EAAQ;EAAM,QAAQ,EAAE,KAAK,SAAS;EAAG;CAAO;CAC7E,MAAM,MAAM,MAAM,IAAI,QAAQ,sBAAsB,KAAK,EAAE;CAC3D,IAAI,IAAI,IACN,OAAO;EAAE,MAAM;EAAQ;EAAM,QAAQ,IAAI,QAAQ,EAAE,KAAK,IAAI,MAAM,IAAI;EAAM;CAAO;CAErF,IAAI,IAAI,eAAe,SAAS,OAAO,QAAQ,KAAK,KAAK,IAAI,KAAK,OAAO,MAAM;CAC/E,OAAO;EAAE,MAAM;EAAQ;EAAM,QAAQ;EAAM;CAAO;AACpD;;AAGA,eAAsB,aACpB,KACA,QACA,QACoB;CACpB,MAAM,MAAM,MAAM,IAAI,QAAQ,SAAS,MAAM;CAC7C,IAAI,IAAI,IAAI;EACV,IAAI,CAAC,IAAI,OAAO,OAAO,KAAK,kCAAkC,QAAQ;EACtE,OAAO;GAAE,MAAM;GAAQ,MAAM,IAAI,MAAM;GAAM,QAAQ,IAAI,MAAM;GAAQ;EAAO;CAChF;CACA,IAAI,IAAI,eAAe,SAAS,OAAO,QAAQ,KAAK,QAAQ,MAAM,MAAM;CACxE,IAAI,IAAI,eAAe,GAAG,OAAO,uBAAO,IAAI,MAAM,mCAAmC,QAAQ,CAAC;CAC9F,OAAO,KAAK,kBAAkB,IAAI,WAAW,OAAO,QAAQ;AAC9D;;AAGA,eAAsB,QACpB,KACA,QACA,OACA,QACoB;CAEpB,OAAO;EAAE,MAAM;EAAU,MAAA,MADN,IAAI,QAAQ,YAAY,QAAQ,KAAK;EACzB,MAAM;EAAS;CAAO;AACvD;AAIA,SAAS,UAAU,WAAuC;CACxD,QAAQ,UAAU,MAAlB;EACE,KAAK,QACH,OAAO;EACT,KAAK,UACH,OAAO;EACT,KAAK,UACH,OAAO;EACT,KAAK,QACH,OAAO;CACX;AACF;;;;;;;AAQA,eAAsB,SACpB,SACA,KACyD;CACzD,MAAM,WAAuB,CAAC;CAC9B,IAAI,OAAkB;EAAE,MAAM;EAAQ,MAAM;CAAwB;CAEpE,KAAK,MAAM,UAAU,SAAS;EAC5B,MAAM,YAAY,OAAO,YAAgC;GACvD,IAAI;IACF,OAAO,MAAM,OAAO,KAAK,GAAG;GAC9B,SAAS,OAAO;IACd,IAAI,MACF,aAAa,OAAO,GAAG,WAAW,iBAAiB,QAAQ,MAAM,UAAU,OAC7E;IACA,OAAO;KAAE,MAAM;KAAU,OAAO,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC;IAAE;GAC5F;EACF,EAAA,CAAG;EACH,SAAS,KAAK;GACZ,QACE,UAAU,SAAS,UAAU,UAAU,SAAS,WAAW,UAAU,SAAS,OAAO;GACvF,SAAS,UAAU,SAAS;EAC9B,CAAC;EACD,IAAI,UAAU,SAAS,UAAU,UAAU,SAAS,UAAU,OAAO;GAAE;GAAW;EAAS;EAE3F,IAAI,UAAU,SAAS,YAAY,KAAK,SAAS,UAAU,OAAO;CACpE;CAEA,OAAO;EAAE,WAAW;EAAM;CAAS;AACrC"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { failed, fromRoomInfo, miss, withStream } from "./chain.mjs";
|
|
2
|
+
import { extractUserFromUniversalData } from "../parse.mjs";
|
|
3
|
+
//#region src/io/tiktok/discovery/fallback.ts
|
|
4
|
+
function createApiLiveSource() {
|
|
5
|
+
return {
|
|
6
|
+
id: "api-live",
|
|
7
|
+
async find(ctx) {
|
|
8
|
+
try {
|
|
9
|
+
const url = `https://www.tiktok.com/api-live/user/room/?aid=1988&uniqueId=${encodeURIComponent(ctx.user)}&sourceType=54`;
|
|
10
|
+
const res = await ctx.http.get(url);
|
|
11
|
+
ctx.debug(`fetchLiveInfoFromApi: ${url} status=${res.status}`);
|
|
12
|
+
if (!res.ok) return miss(`api-live HTTP ${res.status}`);
|
|
13
|
+
const text = await res.text();
|
|
14
|
+
ctx.debug(`fetchLiveInfoFromApi: returned ${text.length} bytes`);
|
|
15
|
+
if (text.length === 0) return miss("api-live: empty body");
|
|
16
|
+
const userData = (JSON.parse(text)?.data)?.user;
|
|
17
|
+
if (!userData?.roomId) {
|
|
18
|
+
ctx.debug("fetchLiveInfoFromApi: no roomId in API response");
|
|
19
|
+
return miss("api-live: no roomId");
|
|
20
|
+
}
|
|
21
|
+
const room = {
|
|
22
|
+
id: String(userData.roomId),
|
|
23
|
+
title: null,
|
|
24
|
+
live: userData.status === 2
|
|
25
|
+
};
|
|
26
|
+
ctx.debug(`fetchLiveInfoFromApi: roomId=${room.id}, status=${userData.status}, live=${room.live}`);
|
|
27
|
+
if (!room.live) return {
|
|
28
|
+
kind: "room",
|
|
29
|
+
room,
|
|
30
|
+
stream: null,
|
|
31
|
+
source: "api-live"
|
|
32
|
+
};
|
|
33
|
+
return withStream(ctx, room, "api-live", null);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return failed(error);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function createProfileSource() {
|
|
41
|
+
return {
|
|
42
|
+
id: "profile",
|
|
43
|
+
async find(ctx) {
|
|
44
|
+
try {
|
|
45
|
+
const page = await ctx.fetchPage(ctx.user, "");
|
|
46
|
+
if (!page) {
|
|
47
|
+
ctx.debug("fetchLiveInfoFromProfile: all domains blocked for profile page");
|
|
48
|
+
return miss("profile: page blocked");
|
|
49
|
+
}
|
|
50
|
+
ctx.debug(`fetchLiveInfoFromProfile: returned ${page.html.length} bytes (${page.baseUrl}), first 300: ${page.html.slice(0, 300).replace(/\n/g, "\\n")}`);
|
|
51
|
+
const { userId, roomId } = extractUserFromUniversalData(page.html);
|
|
52
|
+
if (!userId) return miss("profile: no universal data");
|
|
53
|
+
if (!roomId) return miss("profile: user offline");
|
|
54
|
+
return fromRoomInfo(ctx, roomId, "room-info");
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return failed(error);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
export { createApiLiveSource, createProfileSource };
|
|
63
|
+
|
|
64
|
+
//# sourceMappingURL=fallback.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fallback.mjs","names":[],"sources":["../../../../src/io/tiktok/discovery/fallback.ts"],"sourcesContent":["/**\n * Fallback sources tried after the page: TikTok's own `/api-live/` endpoint,\n * then the profile page's universal data. Both are thin, both fail into\n * `miss`/`failed` so the chain keeps its evidence trail.\n */\n\nimport type { RoomRef } from \"../../../core/verdict\"\nimport { extractUserFromUniversalData } from \"../parse\"\nimport {\n type Candidate,\n type DiscoverySource,\n failed,\n fromRoomInfo,\n miss,\n type ResolveContext,\n withStream,\n} from \"./chain\"\n\nexport function createApiLiveSource(): DiscoverySource {\n return {\n id: \"api-live\",\n async find(ctx: ResolveContext): Promise<Candidate> {\n try {\n const url = `https://www.tiktok.com/api-live/user/room/?aid=1988&uniqueId=${encodeURIComponent(ctx.user)}&sourceType=54`\n const res = await ctx.http.get(url)\n ctx.debug(`fetchLiveInfoFromApi: ${url} status=${res.status}`)\n if (!res.ok) return miss(`api-live HTTP ${res.status}`)\n\n const text = await res.text()\n ctx.debug(`fetchLiveInfoFromApi: returned ${text.length} bytes`)\n if (text.length === 0) return miss(\"api-live: empty body\")\n\n const data = JSON.parse(text) as Record<string, unknown>\n const userData = (data?.data as Record<string, unknown> | undefined)?.user as\n | { roomId?: string; status?: number; uniqueId?: string }\n | undefined\n\n if (!userData?.roomId) {\n ctx.debug(\"fetchLiveInfoFromApi: no roomId in API response\")\n return miss(\"api-live: no roomId\")\n }\n\n // status: 2 = live, 4 = offline, 1 = ...\n const room: RoomRef = {\n id: String(userData.roomId),\n title: null,\n live: userData.status === 2,\n }\n ctx.debug(\n `fetchLiveInfoFromApi: roomId=${room.id}, status=${userData.status}, live=${room.live}`,\n )\n\n if (!room.live) return { kind: \"room\", room, stream: null, source: \"api-live\" }\n return withStream(ctx, room, \"api-live\", null)\n } catch (error) {\n return failed(error)\n }\n },\n }\n}\n\nexport function createProfileSource(): DiscoverySource {\n return {\n id: \"profile\",\n async find(ctx: ResolveContext): Promise<Candidate> {\n try {\n const page = await ctx.fetchPage(ctx.user, \"\")\n if (!page) {\n ctx.debug(\"fetchLiveInfoFromProfile: all domains blocked for profile page\")\n return miss(\"profile: page blocked\")\n }\n ctx.debug(\n `fetchLiveInfoFromProfile: returned ${page.html.length} bytes (${page.baseUrl}), first 300: ${page.html.slice(0, 300).replace(/\\n/g, \"\\\\n\")}`,\n )\n const { userId, roomId } = extractUserFromUniversalData(page.html)\n if (!userId) return miss(\"profile: no universal data\")\n // No roomId means the user is offline (room/enter was unreliable).\n if (!roomId) return miss(\"profile: user offline\")\n\n // If roomId is embedded in the profile page, the user is live.\n // Use room/info which works reliably for both live and offline rooms.\n return fromRoomInfo(ctx, roomId, \"room-info\")\n } catch (error) {\n return failed(error)\n }\n },\n }\n}\n"],"mappings":";;;AAkBA,SAAgB,sBAAuC;CACrD,OAAO;EACL,IAAI;EACJ,MAAM,KAAK,KAAyC;GAClD,IAAI;IACF,MAAM,MAAM,gEAAgE,mBAAmB,IAAI,IAAI,EAAE;IACzG,MAAM,MAAM,MAAM,IAAI,KAAK,IAAI,GAAG;IAClC,IAAI,MAAM,yBAAyB,IAAI,UAAU,IAAI,QAAQ;IAC7D,IAAI,CAAC,IAAI,IAAI,OAAO,KAAK,iBAAiB,IAAI,QAAQ;IAEtD,MAAM,OAAO,MAAM,IAAI,KAAK;IAC5B,IAAI,MAAM,kCAAkC,KAAK,OAAO,OAAO;IAC/D,IAAI,KAAK,WAAW,GAAG,OAAO,KAAK,sBAAsB;IAGzD,MAAM,YADO,KAAK,MAAM,IACH,CAAC,EAAE,KAAA,EAA8C;IAItE,IAAI,CAAC,UAAU,QAAQ;KACrB,IAAI,MAAM,iDAAiD;KAC3D,OAAO,KAAK,qBAAqB;IACnC;IAGA,MAAM,OAAgB;KACpB,IAAI,OAAO,SAAS,MAAM;KAC1B,OAAO;KACP,MAAM,SAAS,WAAW;IAC5B;IACA,IAAI,MACF,gCAAgC,KAAK,GAAG,WAAW,SAAS,OAAO,SAAS,KAAK,MACnF;IAEA,IAAI,CAAC,KAAK,MAAM,OAAO;KAAE,MAAM;KAAQ;KAAM,QAAQ;KAAM,QAAQ;IAAW;IAC9E,OAAO,WAAW,KAAK,MAAM,YAAY,IAAI;GAC/C,SAAS,OAAO;IACd,OAAO,OAAO,KAAK;GACrB;EACF;CACF;AACF;AAEA,SAAgB,sBAAuC;CACrD,OAAO;EACL,IAAI;EACJ,MAAM,KAAK,KAAyC;GAClD,IAAI;IACF,MAAM,OAAO,MAAM,IAAI,UAAU,IAAI,MAAM,EAAE;IAC7C,IAAI,CAAC,MAAM;KACT,IAAI,MAAM,gEAAgE;KAC1E,OAAO,KAAK,uBAAuB;IACrC;IACA,IAAI,MACF,sCAAsC,KAAK,KAAK,OAAO,UAAU,KAAK,QAAQ,gBAAgB,KAAK,KAAK,MAAM,GAAG,GAAG,CAAC,CAAC,QAAQ,OAAO,KAAK,GAC5I;IACA,MAAM,EAAE,QAAQ,WAAW,6BAA6B,KAAK,IAAI;IACjE,IAAI,CAAC,QAAQ,OAAO,KAAK,4BAA4B;IAErD,IAAI,CAAC,QAAQ,OAAO,KAAK,uBAAuB;IAIhD,OAAO,aAAa,KAAK,QAAQ,WAAW;GAC9C,SAAS,OAAO;IACd,OAAO,OAAO,KAAK;GACrB;EACF;CACF;AACF"}
|