@mks2508/bundlp 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +614 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3131 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +19 -8
- package/dist/core/extractor.d.ts +0 -30
- package/dist/core/extractor.d.ts.map +0 -1
- package/dist/http/client.d.ts +0 -50
- package/dist/http/client.d.ts.map +0 -1
- package/dist/http/retry.d.ts +0 -22
- package/dist/http/retry.d.ts.map +0 -1
- package/dist/index.d.ts +0 -20
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -19021
- package/dist/innertube/client.d.ts +0 -62
- package/dist/innertube/client.d.ts.map +0 -1
- package/dist/player/ast/analyzer.d.ts +0 -16
- package/dist/player/ast/analyzer.d.ts.map +0 -1
- package/dist/player/ast/extractor.d.ts +0 -35
- package/dist/player/ast/extractor.d.ts.map +0 -1
- package/dist/player/ast/matchers.d.ts +0 -40
- package/dist/player/ast/matchers.d.ts.map +0 -1
- package/dist/player/cache.d.ts +0 -60
- package/dist/player/cache.d.ts.map +0 -1
- package/dist/player/player.d.ts +0 -49
- package/dist/player/player.d.ts.map +0 -1
- package/dist/po-token/botguard/challenge.d.ts +0 -22
- package/dist/po-token/botguard/challenge.d.ts.map +0 -1
- package/dist/po-token/botguard/client.d.ts +0 -25
- package/dist/po-token/botguard/client.d.ts.map +0 -1
- package/dist/po-token/cache/token-cache.d.ts +0 -24
- package/dist/po-token/cache/token-cache.d.ts.map +0 -1
- package/dist/po-token/index.d.ts +0 -14
- package/dist/po-token/index.d.ts.map +0 -1
- package/dist/po-token/manager.d.ts +0 -34
- package/dist/po-token/manager.d.ts.map +0 -1
- package/dist/po-token/minter/web-minter.d.ts +0 -20
- package/dist/po-token/minter/web-minter.d.ts.map +0 -1
- package/dist/po-token/policies.d.ts +0 -18
- package/dist/po-token/policies.d.ts.map +0 -1
- package/dist/po-token/providers/local.provider.d.ts +0 -26
- package/dist/po-token/providers/local.provider.d.ts.map +0 -1
- package/dist/po-token/providers/provider.interface.d.ts +0 -15
- package/dist/po-token/providers/provider.interface.d.ts.map +0 -1
- package/dist/po-token/types.d.ts +0 -160
- package/dist/po-token/types.d.ts.map +0 -1
- package/dist/result/index.d.ts +0 -6
- package/dist/result/index.d.ts.map +0 -1
- package/dist/result/result.types.d.ts +0 -14
- package/dist/result/result.types.d.ts.map +0 -1
- package/dist/result/result.utils.d.ts +0 -32
- package/dist/result/result.utils.d.ts.map +0 -1
- package/dist/streaming/dash/parser.d.ts +0 -37
- package/dist/streaming/dash/parser.d.ts.map +0 -1
- package/dist/streaming/dash/segments.d.ts +0 -58
- package/dist/streaming/dash/segments.d.ts.map +0 -1
- package/dist/streaming/decipher.d.ts +0 -24
- package/dist/streaming/decipher.d.ts.map +0 -1
- package/dist/streaming/drm.d.ts +0 -26
- package/dist/streaming/drm.d.ts.map +0 -1
- package/dist/streaming/formats.d.ts +0 -20
- package/dist/streaming/formats.d.ts.map +0 -1
- package/dist/streaming/hls/parser.d.ts +0 -10
- package/dist/streaming/hls/parser.d.ts.map +0 -1
- package/dist/streaming/hls/segments.d.ts +0 -37
- package/dist/streaming/hls/segments.d.ts.map +0 -1
- package/dist/streaming/processor.d.ts +0 -20
- package/dist/streaming/processor.d.ts.map +0 -1
- package/dist/types/error.types.d.ts +0 -12
- package/dist/types/error.types.d.ts.map +0 -1
- package/dist/types/index.d.ts +0 -8
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/innertube.types.d.ts +0 -155
- package/dist/types/innertube.types.d.ts.map +0 -1
- package/dist/types/player.types.d.ts +0 -30
- package/dist/types/player.types.d.ts.map +0 -1
- package/dist/types/video.types.d.ts +0 -112
- package/dist/types/video.types.d.ts.map +0 -1
- package/dist/utils/constants.d.ts +0 -129
- package/dist/utils/constants.d.ts.map +0 -1
- package/dist/utils/m3u8.d.ts +0 -31
- package/dist/utils/m3u8.d.ts.map +0 -1
- package/dist/utils/xml.d.ts +0 -41
- package/dist/utils/xml.d.ts.map +0 -1
- package/dist/validation/schemas.d.ts +0 -290
- package/dist/validation/schemas.d.ts.map +0 -1
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3131 @@
|
|
|
1
|
+
import { type } from "arktype";
|
|
2
|
+
import logger from "@mks2508/better-logger";
|
|
3
|
+
import { parse } from "meriyah";
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
import { mkdirSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { JSDOM } from "jsdom";
|
|
8
|
+
|
|
9
|
+
//#region src/result/result.utils.ts
|
|
10
|
+
/** Create a successful result */
|
|
11
|
+
const ok = (value) => ({
|
|
12
|
+
ok: true,
|
|
13
|
+
value
|
|
14
|
+
});
|
|
15
|
+
/** Create an error result */
|
|
16
|
+
const err = (error) => ({
|
|
17
|
+
ok: false,
|
|
18
|
+
error
|
|
19
|
+
});
|
|
20
|
+
/** Type guard for successful result */
|
|
21
|
+
const isOk = (result) => result.ok;
|
|
22
|
+
/** Type guard for error result */
|
|
23
|
+
const isErr = (result) => !result.ok;
|
|
24
|
+
/** Pattern match on a Result */
|
|
25
|
+
function match(result, handlers) {
|
|
26
|
+
return result.ok ? handlers.ok(result.value) : handlers.err(result.error);
|
|
27
|
+
}
|
|
28
|
+
/** Map over a successful result */
|
|
29
|
+
function map(result, fn) {
|
|
30
|
+
return result.ok ? ok(fn(result.value)) : result;
|
|
31
|
+
}
|
|
32
|
+
/** Map over an error result */
|
|
33
|
+
function mapErr(result, fn) {
|
|
34
|
+
return result.ok ? result : err(fn(result.error));
|
|
35
|
+
}
|
|
36
|
+
/** Chain Results (flatMap) */
|
|
37
|
+
function andThen(result, fn) {
|
|
38
|
+
return result.ok ? fn(result.value) : result;
|
|
39
|
+
}
|
|
40
|
+
/** Unwrap with default value */
|
|
41
|
+
function unwrapOr(result, defaultValue) {
|
|
42
|
+
return result.ok ? result.value : defaultValue;
|
|
43
|
+
}
|
|
44
|
+
/** Unwrap or throw */
|
|
45
|
+
function unwrap(result) {
|
|
46
|
+
if (result.ok) return result.value;
|
|
47
|
+
throw new Error(`Unwrap called on Err: ${JSON.stringify(result.error)}`);
|
|
48
|
+
}
|
|
49
|
+
/** Try to execute a function and wrap in Result */
|
|
50
|
+
function tryCatch(fn) {
|
|
51
|
+
try {
|
|
52
|
+
return ok(fn());
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return err(e);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Try to execute an async function and wrap in Result */
|
|
58
|
+
async function tryCatchAsync(fn) {
|
|
59
|
+
try {
|
|
60
|
+
return ok(await fn());
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return err(e);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/utils/constants.ts
|
|
68
|
+
const YOUTUBE_BASE_URL = "https://www.youtube.com";
|
|
69
|
+
const INNER_TUBE_API_URL = `${YOUTUBE_BASE_URL}/youtubei/v1`;
|
|
70
|
+
const VIDEO_ID_LENGTH = 11;
|
|
71
|
+
const RETRY_DEFAULTS = {
|
|
72
|
+
RETRIES: 3,
|
|
73
|
+
DELAY: 1e3,
|
|
74
|
+
BACKOFF: 2,
|
|
75
|
+
MAX_DELAY: 3e4
|
|
76
|
+
};
|
|
77
|
+
const INNERTUBE_CLIENTS = {
|
|
78
|
+
WEB: {
|
|
79
|
+
NAME: "WEB",
|
|
80
|
+
VERSION: "2.20250222.10.00",
|
|
81
|
+
CLIENT_ID: 1,
|
|
82
|
+
API_KEY: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
|
83
|
+
INNERTUBE_CONTEXT: { client: {
|
|
84
|
+
clientName: "WEB",
|
|
85
|
+
clientVersion: "2.20250222.10.00",
|
|
86
|
+
platform: "DESKTOP"
|
|
87
|
+
} },
|
|
88
|
+
HEADERS: {
|
|
89
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
90
|
+
"X-YouTube-Client-Name": "1",
|
|
91
|
+
"X-YouTube-Client-Version": "2.20250222.10.00"
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
ANDROID: {
|
|
95
|
+
NAME: "ANDROID",
|
|
96
|
+
VERSION: "19.35.36",
|
|
97
|
+
CLIENT_ID: 3,
|
|
98
|
+
API_KEY: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
|
99
|
+
SDK_VERSION: 33,
|
|
100
|
+
INNERTUBE_CONTEXT: { client: {
|
|
101
|
+
clientName: "ANDROID",
|
|
102
|
+
clientVersion: "19.35.36",
|
|
103
|
+
androidSdkVersion: 33
|
|
104
|
+
} },
|
|
105
|
+
HEADERS: {
|
|
106
|
+
"User-Agent": "com.google.android.youtube/19.35.36(Linux; U; Android 13; en_US; SM-S908E Build/TP1A.220624.014) gzip",
|
|
107
|
+
"X-YouTube-Client-Name": "3",
|
|
108
|
+
"X-YouTube-Client-Version": "19.35.36"
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
TV: {
|
|
112
|
+
NAME: "TVHTML5",
|
|
113
|
+
VERSION: "7.20250923.13.00",
|
|
114
|
+
CLIENT_ID: 7,
|
|
115
|
+
API_KEY: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
|
116
|
+
INNERTUBE_CONTEXT: { client: {
|
|
117
|
+
clientName: "TVHTML5",
|
|
118
|
+
clientVersion: "7.20250923.13.00",
|
|
119
|
+
userAgent: "Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version"
|
|
120
|
+
} },
|
|
121
|
+
HEADERS: {
|
|
122
|
+
"User-Agent": "Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version",
|
|
123
|
+
"X-YouTube-Client-Name": "7",
|
|
124
|
+
"X-YouTube-Client-Version": "7.20250923.13.00"
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
IOS: {
|
|
128
|
+
NAME: "IOS",
|
|
129
|
+
VERSION: "20.10.4",
|
|
130
|
+
CLIENT_ID: 5,
|
|
131
|
+
API_KEY: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
|
132
|
+
INNERTUBE_CONTEXT: { client: {
|
|
133
|
+
clientName: "IOS",
|
|
134
|
+
clientVersion: "20.10.4",
|
|
135
|
+
deviceMake: "Apple",
|
|
136
|
+
deviceModel: "iPhone16,2",
|
|
137
|
+
osName: "iPhone",
|
|
138
|
+
osVersion: "18.3.2.22D82"
|
|
139
|
+
} },
|
|
140
|
+
HEADERS: {
|
|
141
|
+
"User-Agent": "com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)",
|
|
142
|
+
"X-YouTube-Client-Name": "5",
|
|
143
|
+
"X-YouTube-Client-Version": "20.10.4"
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
ANDROID_SDKLESS: {
|
|
147
|
+
NAME: "ANDROID",
|
|
148
|
+
VERSION: "20.10.38",
|
|
149
|
+
CLIENT_ID: 3,
|
|
150
|
+
API_KEY: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
|
151
|
+
INNERTUBE_CONTEXT: { client: {
|
|
152
|
+
clientName: "ANDROID",
|
|
153
|
+
clientVersion: "20.10.38",
|
|
154
|
+
osName: "Android",
|
|
155
|
+
osVersion: "11"
|
|
156
|
+
} },
|
|
157
|
+
HEADERS: {
|
|
158
|
+
"User-Agent": "com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip",
|
|
159
|
+
"X-YouTube-Client-Name": "3",
|
|
160
|
+
"X-YouTube-Client-Version": "20.10.38"
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
TV_EMBEDDED: {
|
|
164
|
+
NAME: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
|
|
165
|
+
VERSION: "2.0",
|
|
166
|
+
CLIENT_ID: 85,
|
|
167
|
+
API_KEY: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
|
168
|
+
INNERTUBE_CONTEXT: {
|
|
169
|
+
client: {
|
|
170
|
+
clientName: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
|
|
171
|
+
clientVersion: "2.0",
|
|
172
|
+
clientScreen: "EMBED",
|
|
173
|
+
platform: "TV"
|
|
174
|
+
},
|
|
175
|
+
thirdParty: { embedUrl: "https://www.youtube.com/" }
|
|
176
|
+
},
|
|
177
|
+
HEADERS: {
|
|
178
|
+
"User-Agent": "Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version",
|
|
179
|
+
"X-YouTube-Client-Name": "85",
|
|
180
|
+
"X-YouTube-Client-Version": "2.0"
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region src/types/error.types.ts
|
|
187
|
+
function createError(code, message, cause) {
|
|
188
|
+
return {
|
|
189
|
+
code,
|
|
190
|
+
message,
|
|
191
|
+
cause,
|
|
192
|
+
recoverable: isRecoverable(code)
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function isRecoverable(code) {
|
|
196
|
+
return [
|
|
197
|
+
"RATE_LIMITED",
|
|
198
|
+
"NETWORK_ERROR",
|
|
199
|
+
"PO_TOKEN_REQUIRED",
|
|
200
|
+
"PO_TOKEN_EXPIRED",
|
|
201
|
+
"BOTGUARD_INIT_FAILED",
|
|
202
|
+
"ALL_PROVIDERS_FAILED"
|
|
203
|
+
].includes(code);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region src/http/client.ts
|
|
208
|
+
function parseCookies(setCookieHeaders) {
|
|
209
|
+
return setCookieHeaders.map((header) => {
|
|
210
|
+
const [nameValue, ...attributes] = header.split(";").map((p) => p.trim());
|
|
211
|
+
const [name, value] = nameValue.split("=");
|
|
212
|
+
const cookie = {
|
|
213
|
+
name,
|
|
214
|
+
value
|
|
215
|
+
};
|
|
216
|
+
for (const attr of attributes) {
|
|
217
|
+
const [key, val] = attr.split("=");
|
|
218
|
+
const keyLower = key.toLowerCase();
|
|
219
|
+
if (keyLower === "domain") cookie.domain = val;
|
|
220
|
+
else if (keyLower === "path") cookie.path = val;
|
|
221
|
+
else if (keyLower === "secure") cookie.secure = true;
|
|
222
|
+
else if (keyLower === "httponly") cookie.httpOnly = true;
|
|
223
|
+
else if (keyLower === "expires") cookie.expires = new Date(val);
|
|
224
|
+
}
|
|
225
|
+
return cookie;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function cookiesToHeader(cookies) {
|
|
229
|
+
return Array.from(cookies.values()).map((c) => `${c.name}=${c.value}`).join("; ");
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* HTTP client with cookie jar and controlled redirect handling.
|
|
233
|
+
* Superior to YouTube.js: tracks cookies across redirects, prevents loops.
|
|
234
|
+
*/
|
|
235
|
+
var HttpClient = class {
|
|
236
|
+
cookies = /* @__PURE__ */ new Map();
|
|
237
|
+
/**
|
|
238
|
+
* Performs a fetch request with automatic cookie and redirect handling.
|
|
239
|
+
* @param url - The URL to fetch
|
|
240
|
+
* @param options - Fetch options. Use manualRedirects=true for controlled redirects
|
|
241
|
+
* @returns Result containing the Response or a BundlpError
|
|
242
|
+
*/
|
|
243
|
+
async request(url, options = {}) {
|
|
244
|
+
const { maxRedirects = 10, manualRedirects = false, timeout, ...fetchOptions } = options;
|
|
245
|
+
if (!manualRedirects) try {
|
|
246
|
+
return ok(await fetch(url, {
|
|
247
|
+
...fetchOptions,
|
|
248
|
+
signal: timeout ? AbortSignal.timeout(timeout) : void 0
|
|
249
|
+
}));
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return err(createError("NETWORK_ERROR", `Request to ${url} failed: ${error.message}`, error));
|
|
252
|
+
}
|
|
253
|
+
let currentUrl = url;
|
|
254
|
+
let redirectCount = 0;
|
|
255
|
+
const visitedUrls = /* @__PURE__ */ new Set();
|
|
256
|
+
while (redirectCount <= maxRedirects) {
|
|
257
|
+
if (visitedUrls.has(currentUrl)) return err(createError("NETWORK_ERROR", `Redirect loop detected at ${currentUrl}`));
|
|
258
|
+
visitedUrls.add(currentUrl);
|
|
259
|
+
try {
|
|
260
|
+
const headers = new Headers(fetchOptions.headers);
|
|
261
|
+
const cookieHeader = cookiesToHeader(this.cookies);
|
|
262
|
+
if (cookieHeader) headers.set("Cookie", cookieHeader);
|
|
263
|
+
const response = await fetch(currentUrl, {
|
|
264
|
+
...fetchOptions,
|
|
265
|
+
headers,
|
|
266
|
+
redirect: "manual",
|
|
267
|
+
signal: timeout ? AbortSignal.timeout(timeout) : void 0
|
|
268
|
+
});
|
|
269
|
+
const setCookies = response.headers.getSetCookie?.() || [];
|
|
270
|
+
for (const cookie of parseCookies(setCookies)) this.cookies.set(cookie.name, cookie);
|
|
271
|
+
if (response.status >= 300 && response.status < 400) {
|
|
272
|
+
const location = response.headers.get("location");
|
|
273
|
+
if (!location) return ok(response);
|
|
274
|
+
currentUrl = new URL(location, currentUrl).href;
|
|
275
|
+
redirectCount++;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
return ok(response);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
return err(createError("NETWORK_ERROR", `Request to ${currentUrl} failed: ${error.message}`, error));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return err(createError("NETWORK_ERROR", `Too many redirects (${redirectCount}) for ${url}`));
|
|
284
|
+
}
|
|
285
|
+
clearCookies() {
|
|
286
|
+
this.cookies.clear();
|
|
287
|
+
}
|
|
288
|
+
getCookies() {
|
|
289
|
+
return new Map(this.cookies);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Performs a GET request.
|
|
293
|
+
* @param url - The URL to fetch
|
|
294
|
+
* @param options - Optional fetch options
|
|
295
|
+
* @returns Result containing the Response or a BundlpError
|
|
296
|
+
*/
|
|
297
|
+
async get(url, options = {}) {
|
|
298
|
+
return this.request(url, {
|
|
299
|
+
...options,
|
|
300
|
+
method: "GET"
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Performs a POST request with JSON body.
|
|
305
|
+
* @param url - The URL to post to
|
|
306
|
+
* @param body - The request body (will be JSON stringified)
|
|
307
|
+
* @param options - Optional fetch options
|
|
308
|
+
* @returns Result containing the Response or a BundlpError
|
|
309
|
+
*/
|
|
310
|
+
async post(url, body, options = {}) {
|
|
311
|
+
return this.request(url, {
|
|
312
|
+
...options,
|
|
313
|
+
method: "POST",
|
|
314
|
+
body: JSON.stringify(body),
|
|
315
|
+
headers: {
|
|
316
|
+
"Content-Type": "application/json",
|
|
317
|
+
...options.headers
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
const httpClient = new HttpClient();
|
|
323
|
+
|
|
324
|
+
//#endregion
|
|
325
|
+
//#region src/validation/schemas.ts
|
|
326
|
+
/**
|
|
327
|
+
* Schema for playability status returned by InnerTube API.
|
|
328
|
+
*/
|
|
329
|
+
const PlayabilityStatusSchema = type({
|
|
330
|
+
status: "'OK' | 'UNPLAYABLE' | 'LOGIN_REQUIRED' | 'AGE_CHECK_REQUIRED' | 'ERROR'",
|
|
331
|
+
"reason?": "string",
|
|
332
|
+
"playableInEmbed?": "boolean",
|
|
333
|
+
"liveStreamability?": "unknown"
|
|
334
|
+
});
|
|
335
|
+
/**
|
|
336
|
+
* Schema for thumbnail objects.
|
|
337
|
+
*/
|
|
338
|
+
const ThumbnailSchema = type({
|
|
339
|
+
url: "string",
|
|
340
|
+
width: "number",
|
|
341
|
+
height: "number"
|
|
342
|
+
});
|
|
343
|
+
/**
|
|
344
|
+
* Schema for video details from InnerTube response.
|
|
345
|
+
* Some fields are optional as TV and other clients may omit them.
|
|
346
|
+
*/
|
|
347
|
+
const VideoDetailsSchema = type({
|
|
348
|
+
videoId: "string",
|
|
349
|
+
"title?": "string",
|
|
350
|
+
lengthSeconds: "string",
|
|
351
|
+
channelId: "string",
|
|
352
|
+
"shortDescription?": "string",
|
|
353
|
+
"thumbnail?": { thumbnails: ThumbnailSchema.array() },
|
|
354
|
+
"viewCount?": "string",
|
|
355
|
+
"author?": "string",
|
|
356
|
+
isLiveContent: "boolean",
|
|
357
|
+
"isPrivate?": "boolean",
|
|
358
|
+
"keywords?": "string[]"
|
|
359
|
+
});
|
|
360
|
+
/**
|
|
361
|
+
* Schema for raw format objects from streaming data.
|
|
362
|
+
*/
|
|
363
|
+
const RawFormatSchema = type({
|
|
364
|
+
itag: "number",
|
|
365
|
+
"url?": "string",
|
|
366
|
+
"signatureCipher?": "string",
|
|
367
|
+
"cipher?": "string",
|
|
368
|
+
mimeType: "string",
|
|
369
|
+
bitrate: "number",
|
|
370
|
+
"width?": "number",
|
|
371
|
+
"height?": "number",
|
|
372
|
+
"fps?": "number",
|
|
373
|
+
"qualityLabel?": "string",
|
|
374
|
+
quality: "string",
|
|
375
|
+
"audioQuality?": "string",
|
|
376
|
+
"audioSampleRate?": "string",
|
|
377
|
+
"audioChannels?": "number",
|
|
378
|
+
"contentLength?": "string",
|
|
379
|
+
"approxDurationMs?": "string",
|
|
380
|
+
"initRange?": {
|
|
381
|
+
start: "string",
|
|
382
|
+
end: "string"
|
|
383
|
+
},
|
|
384
|
+
"indexRange?": {
|
|
385
|
+
start: "string",
|
|
386
|
+
end: "string"
|
|
387
|
+
},
|
|
388
|
+
"lastModified?": "string",
|
|
389
|
+
"drmFamilies?": "string[]"
|
|
390
|
+
});
|
|
391
|
+
/**
|
|
392
|
+
* Schema for streaming data section of player response.
|
|
393
|
+
*/
|
|
394
|
+
const StreamingDataSchema = type({
|
|
395
|
+
expiresInSeconds: "string",
|
|
396
|
+
"formats?": RawFormatSchema.array(),
|
|
397
|
+
"adaptiveFormats?": RawFormatSchema.array(),
|
|
398
|
+
"hlsManifestUrl?": "string",
|
|
399
|
+
"dashManifestUrl?": "string"
|
|
400
|
+
});
|
|
401
|
+
/**
|
|
402
|
+
* Schema for caption track information.
|
|
403
|
+
* Note: name can be either { simpleText } or { runs } format.
|
|
404
|
+
*/
|
|
405
|
+
const CaptionTrackSchema = type({
|
|
406
|
+
baseUrl: "string",
|
|
407
|
+
languageCode: "string",
|
|
408
|
+
"name?": "unknown",
|
|
409
|
+
"kind?": "string",
|
|
410
|
+
"isTranslatable?": "boolean"
|
|
411
|
+
});
|
|
412
|
+
/**
|
|
413
|
+
* Schema for captions data section.
|
|
414
|
+
*/
|
|
415
|
+
const CaptionsDataSchema = type({ "playerCaptionsTracklistRenderer?": { "captionTracks?": CaptionTrackSchema.array() } });
|
|
416
|
+
/**
|
|
417
|
+
* Schema for microformat data section.
|
|
418
|
+
*/
|
|
419
|
+
const MicroformatDataSchema = type({ "playerMicroformatRenderer?": {
|
|
420
|
+
"uploadDate?": "string",
|
|
421
|
+
"publishDate?": "string",
|
|
422
|
+
"category?": "string",
|
|
423
|
+
"isFamilySafe?": "boolean",
|
|
424
|
+
"lengthSeconds?": "string"
|
|
425
|
+
} });
|
|
426
|
+
/**
|
|
427
|
+
* Schema for the complete InnerTube player response.
|
|
428
|
+
*/
|
|
429
|
+
const PlayerResponseSchema = type({
|
|
430
|
+
playabilityStatus: PlayabilityStatusSchema,
|
|
431
|
+
"videoDetails?": VideoDetailsSchema,
|
|
432
|
+
"streamingData?": StreamingDataSchema,
|
|
433
|
+
"captions?": CaptionsDataSchema,
|
|
434
|
+
"microformat?": MicroformatDataSchema,
|
|
435
|
+
"storyboards?": "unknown"
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
//#endregion
|
|
439
|
+
//#region src/streaming/drm.ts
|
|
440
|
+
/**
|
|
441
|
+
* Checks if a format is DRM-protected.
|
|
442
|
+
* @param format - Format to check
|
|
443
|
+
* @returns Whether format has DRM
|
|
444
|
+
*/
|
|
445
|
+
function hasDrm(format) {
|
|
446
|
+
return !!(format.drmFamilies && format.drmFamilies.length > 0);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Checks if all formats are DRM-only.
|
|
450
|
+
* @param formats - Array of formats
|
|
451
|
+
* @returns Whether all formats have DRM
|
|
452
|
+
*/
|
|
453
|
+
function isDrmOnly(formats) {
|
|
454
|
+
if (formats.length === 0) return false;
|
|
455
|
+
return formats.every(hasDrm);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region src/innertube/client.ts
|
|
460
|
+
const log = logger.withPrefix("[InnerTube]");
|
|
461
|
+
/**
|
|
462
|
+
* Client for interacting with YouTube's InnerTube API.
|
|
463
|
+
*/
|
|
464
|
+
var InnerTubeClient = class {
|
|
465
|
+
currentClient;
|
|
466
|
+
FALLBACK_ORDER = [
|
|
467
|
+
"ANDROID_SDKLESS",
|
|
468
|
+
"TV",
|
|
469
|
+
"WEB"
|
|
470
|
+
];
|
|
471
|
+
constructor(config = {}) {
|
|
472
|
+
this.currentClient = config.initialClient || "WEB";
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Sets the client type to use for API requests.
|
|
476
|
+
* @param client - The client name to use
|
|
477
|
+
* @returns Result indicating success or error if client is unknown
|
|
478
|
+
*/
|
|
479
|
+
setClient(client) {
|
|
480
|
+
if (!INNERTUBE_CLIENTS[client]) return err(createError("INVALID_CLIENT", `Unknown client: ${client}`));
|
|
481
|
+
this.currentClient = client;
|
|
482
|
+
return ok(void 0);
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Gets the current client name being used.
|
|
486
|
+
* @returns The current client name
|
|
487
|
+
*/
|
|
488
|
+
getClientName() {
|
|
489
|
+
return this.currentClient;
|
|
490
|
+
}
|
|
491
|
+
getHeaders() {
|
|
492
|
+
const config = INNERTUBE_CLIENTS[this.currentClient];
|
|
493
|
+
return {
|
|
494
|
+
"Content-Type": "application/json",
|
|
495
|
+
Accept: "*/*",
|
|
496
|
+
Origin: YOUTUBE_BASE_URL,
|
|
497
|
+
Referer: `${YOUTUBE_BASE_URL}/`,
|
|
498
|
+
...config.HEADERS
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
buildContext() {
|
|
502
|
+
const config = INNERTUBE_CLIENTS[this.currentClient];
|
|
503
|
+
const context = {
|
|
504
|
+
client: {
|
|
505
|
+
hl: "en",
|
|
506
|
+
gl: "US",
|
|
507
|
+
...config.INNERTUBE_CONTEXT?.client
|
|
508
|
+
},
|
|
509
|
+
user: { lockedSafetyMode: false },
|
|
510
|
+
request: { useSsl: true }
|
|
511
|
+
};
|
|
512
|
+
const innertubeContext = config.INNERTUBE_CONTEXT;
|
|
513
|
+
if (innertubeContext?.thirdParty) context.thirdParty = innertubeContext.thirdParty;
|
|
514
|
+
return context;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Fetches video player data from the InnerTube API.
|
|
518
|
+
* @param videoId - The YouTube video ID
|
|
519
|
+
* @param options - Optional parameters for the request
|
|
520
|
+
* @returns Result containing PlayerResponse or BundlpError
|
|
521
|
+
*/
|
|
522
|
+
async fetchPlayer(videoId, options = {}) {
|
|
523
|
+
const config = INNERTUBE_CLIENTS[this.currentClient];
|
|
524
|
+
log.debug(`Fetching player for ${videoId} with client ${this.currentClient}`);
|
|
525
|
+
const payload = {
|
|
526
|
+
context: this.buildContext(),
|
|
527
|
+
videoId,
|
|
528
|
+
contentCheckOk: true,
|
|
529
|
+
racyCheckOk: true
|
|
530
|
+
};
|
|
531
|
+
if (options.signatureTimestamp) payload.playbackContext = { contentPlaybackContext: { signatureTimestamp: options.signatureTimestamp } };
|
|
532
|
+
if (options.poToken) {
|
|
533
|
+
if (!payload.serviceIntegrityDimensions) payload.serviceIntegrityDimensions = {};
|
|
534
|
+
payload.serviceIntegrityDimensions.poToken = options.poToken;
|
|
535
|
+
log.debug(`Using po_token (length: ${options.poToken.length})`);
|
|
536
|
+
}
|
|
537
|
+
const url = `${INNER_TUBE_API_URL}/player?key=${config.API_KEY || ""}`;
|
|
538
|
+
const responseResult = await httpClient.post(url, payload, { headers: this.getHeaders() });
|
|
539
|
+
if (isErr(responseResult)) {
|
|
540
|
+
log.error(`HTTP request failed for client ${this.currentClient}:`, responseResult.error.message);
|
|
541
|
+
return responseResult;
|
|
542
|
+
}
|
|
543
|
+
const response = responseResult.value;
|
|
544
|
+
if (!response.ok) {
|
|
545
|
+
log.error(`Player request failed for ${this.currentClient}: HTTP ${response.status}`);
|
|
546
|
+
return err(createError("INNERTUBE_ERROR", `Player request failed with status ${response.status}`));
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
const validated = PlayerResponseSchema(await response.json());
|
|
550
|
+
if (validated instanceof type.errors) {
|
|
551
|
+
log.error(`Parse error for ${this.currentClient}:`, validated.summary);
|
|
552
|
+
return err(createError("PARSE_ERROR", `Invalid player response: ${validated.summary}`));
|
|
553
|
+
}
|
|
554
|
+
const status = validated.playabilityStatus.status;
|
|
555
|
+
const reason = validated.playabilityStatus.reason || "No reason";
|
|
556
|
+
log.debug(`Client ${this.currentClient} response: status=${status}, reason=${reason}`);
|
|
557
|
+
if (status === "ERROR") {
|
|
558
|
+
log.warn(`Client ${this.currentClient} returned ERROR: ${reason}`);
|
|
559
|
+
return err(createError("VIDEO_UNAVAILABLE", reason));
|
|
560
|
+
}
|
|
561
|
+
if (status === "LOGIN_REQUIRED") {
|
|
562
|
+
log.warn(`Client ${this.currentClient} requires login: ${reason}`);
|
|
563
|
+
return err(createError("VIDEO_UNAVAILABLE", `Login required: ${reason}`));
|
|
564
|
+
}
|
|
565
|
+
if (status === "UNPLAYABLE") {
|
|
566
|
+
log.warn(`Client ${this.currentClient} unplayable: ${reason}`);
|
|
567
|
+
return err(createError("VIDEO_UNAVAILABLE", `Unplayable: ${reason}`));
|
|
568
|
+
}
|
|
569
|
+
const formatsCount = (validated.streamingData?.formats?.length || 0) + (validated.streamingData?.adaptiveFormats?.length || 0);
|
|
570
|
+
log.info(`Client ${this.currentClient} success: ${formatsCount} formats`);
|
|
571
|
+
return ok(validated);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
log.error(`Parse exception for ${this.currentClient}:`, error);
|
|
574
|
+
return err(createError("PARSE_ERROR", "Failed to parse InnerTube response", error));
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Fetches player data with automatic client fallback.
|
|
579
|
+
* Tries clients in order: ANDROID_SDKLESS → TV → IOS → WEB
|
|
580
|
+
* @param videoId - Video ID to fetch
|
|
581
|
+
* @param options - Fetch options
|
|
582
|
+
* @returns Result containing player response or error
|
|
583
|
+
*/
|
|
584
|
+
async fetchPlayerWithFallback(videoId, options = {}) {
|
|
585
|
+
const errors = [];
|
|
586
|
+
log.info(`Starting fallback extraction for ${videoId}`);
|
|
587
|
+
log.info(`Fallback order: ${this.FALLBACK_ORDER.join(" → ")}`);
|
|
588
|
+
for (const clientName of this.FALLBACK_ORDER) {
|
|
589
|
+
log.info(`Trying client: ${clientName}`);
|
|
590
|
+
if (isErr(this.setClient(clientName))) {
|
|
591
|
+
log.warn(`Failed to set client ${clientName}`);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const result = await this.fetchPlayer(videoId, options);
|
|
595
|
+
if (isOk(result)) {
|
|
596
|
+
const validationResult = this.isValidResponse(result.value);
|
|
597
|
+
if (validationResult.valid) {
|
|
598
|
+
log.success(`Client ${clientName} returned valid response`);
|
|
599
|
+
return result;
|
|
600
|
+
}
|
|
601
|
+
log.warn(`Client ${clientName} response invalid: ${validationResult.reason}`);
|
|
602
|
+
errors.push({
|
|
603
|
+
client: clientName,
|
|
604
|
+
error: createError("VIDEO_UNAVAILABLE", validationResult.reason)
|
|
605
|
+
});
|
|
606
|
+
} else {
|
|
607
|
+
log.warn(`Client ${clientName} failed: ${result.error.message}`);
|
|
608
|
+
errors.push({
|
|
609
|
+
client: clientName,
|
|
610
|
+
error: result.error
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
log.error(`All clients failed for ${videoId}. Errors:`, errors.map((e) => ({
|
|
615
|
+
client: e.client,
|
|
616
|
+
error: e.error.message
|
|
617
|
+
})));
|
|
618
|
+
return err(createError("VIDEO_UNAVAILABLE", "All clients failed", errors));
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Validates if a player response is usable.
|
|
622
|
+
* @param response - Player response to validate
|
|
623
|
+
* @returns Object with valid flag and reason if invalid
|
|
624
|
+
*/
|
|
625
|
+
isValidResponse(response) {
|
|
626
|
+
if (response.playabilityStatus.status !== "OK") return {
|
|
627
|
+
valid: false,
|
|
628
|
+
reason: `Status is ${response.playabilityStatus.status}: ${response.playabilityStatus.reason || "No reason"}`
|
|
629
|
+
};
|
|
630
|
+
if (!response.streamingData) return {
|
|
631
|
+
valid: false,
|
|
632
|
+
reason: "No streamingData in response"
|
|
633
|
+
};
|
|
634
|
+
if (this.hasDrmOnly(response.streamingData)) return {
|
|
635
|
+
valid: false,
|
|
636
|
+
reason: "All formats are DRM-protected"
|
|
637
|
+
};
|
|
638
|
+
return {
|
|
639
|
+
valid: true,
|
|
640
|
+
reason: "OK"
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Checks if streaming data contains only DRM-protected formats.
|
|
645
|
+
* @param streamingData - Streaming data to check
|
|
646
|
+
* @returns Whether all formats are DRM-protected
|
|
647
|
+
*/
|
|
648
|
+
hasDrmOnly(streamingData) {
|
|
649
|
+
const allFormats = [...streamingData.formats || [], ...streamingData.adaptiveFormats || []];
|
|
650
|
+
if (allFormats.length === 0) return true;
|
|
651
|
+
return isDrmOnly(allFormats);
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
//#endregion
|
|
656
|
+
//#region src/player/ast/matchers.ts
|
|
657
|
+
const WALK_STOP = Symbol("stop");
|
|
658
|
+
/**
|
|
659
|
+
* Recursively walks an AST tree and applies a callback to each node.
|
|
660
|
+
* Uses iterative approach with explicit stack for better performance on large ASTs.
|
|
661
|
+
*/
|
|
662
|
+
function walkAst(node, callback) {
|
|
663
|
+
const stack = [node];
|
|
664
|
+
while (stack.length > 0) {
|
|
665
|
+
const current = stack.pop();
|
|
666
|
+
const result = callback(current);
|
|
667
|
+
if (result === WALK_STOP) return void 0;
|
|
668
|
+
if (result) return result;
|
|
669
|
+
for (const key of Object.keys(current)) {
|
|
670
|
+
const child = current[key];
|
|
671
|
+
if (child && typeof child === "object") {
|
|
672
|
+
if (Array.isArray(child)) for (let i = child.length - 1; i >= 0; i--) {
|
|
673
|
+
const item = child[i];
|
|
674
|
+
if (item && typeof item === "object" && "type" in item) stack.push(item);
|
|
675
|
+
}
|
|
676
|
+
else if ("type" in child) stack.push(child);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Extracts FunctionExpression from either VariableDeclarator or AssignmentExpression
|
|
683
|
+
*/
|
|
684
|
+
function getFunctionExpression(node) {
|
|
685
|
+
if (node.type === "VariableDeclarator") {
|
|
686
|
+
const init = node.init;
|
|
687
|
+
if (init?.type === "FunctionExpression") return init;
|
|
688
|
+
}
|
|
689
|
+
if (node.type === "AssignmentExpression") {
|
|
690
|
+
const right = node.right;
|
|
691
|
+
if (right?.type === "FunctionExpression") return right;
|
|
692
|
+
}
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Finds the signature decipher function call in YouTube's player.js.
|
|
697
|
+
* Supports multiple patterns:
|
|
698
|
+
* - Pattern 1: LogicalExpression with SequenceExpression (YouTube.js style)
|
|
699
|
+
* - Pattern 2: Direct CallExpression with decodeURIComponent argument
|
|
700
|
+
* - Pattern 3: AssignmentExpression with cipher function call
|
|
701
|
+
*/
|
|
702
|
+
function sigMatcher(node) {
|
|
703
|
+
const funcExpr = getFunctionExpression(node);
|
|
704
|
+
if (!funcExpr) return false;
|
|
705
|
+
const params = funcExpr.params;
|
|
706
|
+
if (!params || params.length < 1 || params.length > 5) return false;
|
|
707
|
+
const body = funcExpr.body;
|
|
708
|
+
if (body?.type !== "BlockStatement") return false;
|
|
709
|
+
let foundCallExpr = null;
|
|
710
|
+
walkAst(body, (innerNode) => {
|
|
711
|
+
if (innerNode.type === "LogicalExpression" && innerNode.operator === "&&") {
|
|
712
|
+
const right = innerNode.right;
|
|
713
|
+
if (right?.type === "SequenceExpression") {
|
|
714
|
+
const expressions = right.expressions;
|
|
715
|
+
if (expressions?.[0]?.type === "AssignmentExpression") {
|
|
716
|
+
const assignRight = expressions[0].right;
|
|
717
|
+
if (assignRight?.type === "CallExpression") {
|
|
718
|
+
if (assignRight.arguments?.some((arg) => {
|
|
719
|
+
if (arg.type !== "CallExpression") return false;
|
|
720
|
+
const callee = arg.callee;
|
|
721
|
+
return callee?.type === "Identifier" && callee.name === "decodeURIComponent";
|
|
722
|
+
})) {
|
|
723
|
+
foundCallExpr = assignRight;
|
|
724
|
+
return WALK_STOP;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (innerNode.type === "CallExpression") {
|
|
731
|
+
const args = innerNode.arguments;
|
|
732
|
+
if (!args || args.length !== 2) return void 0;
|
|
733
|
+
const firstArg = args[0];
|
|
734
|
+
if (firstArg?.type !== "Literal" || typeof firstArg.value !== "number") return;
|
|
735
|
+
const secondArg = args[1];
|
|
736
|
+
if (secondArg?.type !== "CallExpression") return void 0;
|
|
737
|
+
const secondCallee = secondArg.callee;
|
|
738
|
+
if (secondCallee?.type !== "Identifier" || secondCallee.name !== "decodeURIComponent") return;
|
|
739
|
+
if (innerNode.callee?.type === "Identifier") {
|
|
740
|
+
foundCallExpr = innerNode;
|
|
741
|
+
return WALK_STOP;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (innerNode.type === "AssignmentExpression") {
|
|
745
|
+
const right = innerNode.right;
|
|
746
|
+
if (right?.type === "CallExpression") {
|
|
747
|
+
const args = right.arguments;
|
|
748
|
+
if (args?.length === 2) {
|
|
749
|
+
const firstArg = args[0];
|
|
750
|
+
const secondArg = args[1];
|
|
751
|
+
if (firstArg?.type === "Literal" && typeof firstArg.value === "number" && secondArg?.type === "CallExpression") {
|
|
752
|
+
const secondCallee = secondArg.callee;
|
|
753
|
+
if (secondCallee?.type === "Identifier" && secondCallee.name === "decodeURIComponent") {
|
|
754
|
+
foundCallExpr = right;
|
|
755
|
+
return WALK_STOP;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
return foundCallExpr || false;
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Finds all potential 'n' parameter transformation function references.
|
|
766
|
+
* Searches for patterns like: var XX = [functionName] or var XX = [functionName, "string"]
|
|
767
|
+
* YouTube uses both single-element and multi-element arrays.
|
|
768
|
+
* @param ast - Full AST to search
|
|
769
|
+
* @returns Array of candidate function names
|
|
770
|
+
*/
|
|
771
|
+
function findNFunctionCandidates(ast) {
|
|
772
|
+
const candidates = [];
|
|
773
|
+
walkAst(ast, (node) => {
|
|
774
|
+
if (node.type !== "VariableDeclarator") return void 0;
|
|
775
|
+
const id = node.id;
|
|
776
|
+
const init = node.init;
|
|
777
|
+
if (id?.type !== "Identifier") return void 0;
|
|
778
|
+
if (init?.type !== "ArrayExpression") return void 0;
|
|
779
|
+
const elements = init.elements;
|
|
780
|
+
if (!elements || elements.length === 0) return void 0;
|
|
781
|
+
const firstElement = elements[0];
|
|
782
|
+
if (firstElement?.type !== "Identifier") return void 0;
|
|
783
|
+
candidates.push(firstElement.name);
|
|
784
|
+
});
|
|
785
|
+
return candidates;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Finds the signatureTimestamp value in player.js.
|
|
789
|
+
* Pattern 1: Direct ObjectExpression property (fastest)
|
|
790
|
+
* Pattern 2: Inside FunctionExpression body (fallback)
|
|
791
|
+
*/
|
|
792
|
+
function timestampMatcher(node) {
|
|
793
|
+
if (node.type === "ObjectExpression") {
|
|
794
|
+
const properties = node.properties;
|
|
795
|
+
if (!properties) return false;
|
|
796
|
+
for (const prop of properties) {
|
|
797
|
+
if (prop.type !== "Property") continue;
|
|
798
|
+
const key = prop.key;
|
|
799
|
+
if ((key?.type === "Identifier" ? key.name : key?.type === "Literal" ? String(key.value) : null) === "signatureTimestamp") return prop;
|
|
800
|
+
}
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
const funcExpr = getFunctionExpression(node);
|
|
804
|
+
if (!funcExpr) return false;
|
|
805
|
+
const funcBody = funcExpr.body;
|
|
806
|
+
if (!funcBody) return false;
|
|
807
|
+
let foundProperty = null;
|
|
808
|
+
walkAst(funcBody, (innerNode) => {
|
|
809
|
+
if (innerNode.type !== "ObjectExpression") return void 0;
|
|
810
|
+
const properties = innerNode.properties;
|
|
811
|
+
if (!properties) return void 0;
|
|
812
|
+
for (const prop of properties) {
|
|
813
|
+
if (prop.type !== "Property") continue;
|
|
814
|
+
const key = prop.key;
|
|
815
|
+
if ((key?.type === "Identifier" ? key.name : key?.type === "Literal" ? String(key.value) : null) === "signatureTimestamp") {
|
|
816
|
+
foundProperty = prop;
|
|
817
|
+
return WALK_STOP;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
return foundProperty || false;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
//#endregion
|
|
825
|
+
//#region src/player/ast/analyzer.ts
|
|
826
|
+
const JS_BUILTINS = new Set([
|
|
827
|
+
"Array",
|
|
828
|
+
"Object",
|
|
829
|
+
"String",
|
|
830
|
+
"Number",
|
|
831
|
+
"Boolean",
|
|
832
|
+
"Math",
|
|
833
|
+
"JSON",
|
|
834
|
+
"Date",
|
|
835
|
+
"RegExp",
|
|
836
|
+
"Error",
|
|
837
|
+
"TypeError",
|
|
838
|
+
"RangeError",
|
|
839
|
+
"SyntaxError",
|
|
840
|
+
"parseInt",
|
|
841
|
+
"parseFloat",
|
|
842
|
+
"isNaN",
|
|
843
|
+
"isFinite",
|
|
844
|
+
"eval",
|
|
845
|
+
"encodeURIComponent",
|
|
846
|
+
"decodeURIComponent",
|
|
847
|
+
"encodeURI",
|
|
848
|
+
"decodeURI",
|
|
849
|
+
"undefined",
|
|
850
|
+
"null",
|
|
851
|
+
"NaN",
|
|
852
|
+
"Infinity",
|
|
853
|
+
"console",
|
|
854
|
+
"window",
|
|
855
|
+
"document",
|
|
856
|
+
"navigator",
|
|
857
|
+
"location",
|
|
858
|
+
"setTimeout",
|
|
859
|
+
"setInterval",
|
|
860
|
+
"clearTimeout",
|
|
861
|
+
"clearInterval",
|
|
862
|
+
"Promise",
|
|
863
|
+
"Map",
|
|
864
|
+
"Set",
|
|
865
|
+
"WeakMap",
|
|
866
|
+
"WeakSet",
|
|
867
|
+
"Symbol",
|
|
868
|
+
"Uint8Array",
|
|
869
|
+
"Int8Array",
|
|
870
|
+
"Uint16Array",
|
|
871
|
+
"Int16Array",
|
|
872
|
+
"Uint32Array",
|
|
873
|
+
"Int32Array",
|
|
874
|
+
"Float32Array",
|
|
875
|
+
"Float64Array",
|
|
876
|
+
"ArrayBuffer",
|
|
877
|
+
"DataView",
|
|
878
|
+
"Blob",
|
|
879
|
+
"URL",
|
|
880
|
+
"URLSearchParams",
|
|
881
|
+
"fetch",
|
|
882
|
+
"Request",
|
|
883
|
+
"Response",
|
|
884
|
+
"Headers",
|
|
885
|
+
"atob",
|
|
886
|
+
"btoa",
|
|
887
|
+
"escape",
|
|
888
|
+
"unescape",
|
|
889
|
+
"Proxy",
|
|
890
|
+
"Reflect",
|
|
891
|
+
"Function",
|
|
892
|
+
"this",
|
|
893
|
+
"arguments",
|
|
894
|
+
"self"
|
|
895
|
+
]);
|
|
896
|
+
function isBuiltin(name) {
|
|
897
|
+
return JS_BUILTINS.has(name);
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Analyzes a function node to find all external dependencies it references.
|
|
901
|
+
* Recursively discovers nested dependencies.
|
|
902
|
+
* Filters out JavaScript built-ins.
|
|
903
|
+
*/
|
|
904
|
+
const MAX_DEPTH = 4;
|
|
905
|
+
function isLocalParameterVar(name) {
|
|
906
|
+
if (name.length !== 1) return false;
|
|
907
|
+
if (name === "z" || name === "g") return false;
|
|
908
|
+
return true;
|
|
909
|
+
}
|
|
910
|
+
function analyzeDependencies(funcNode, ast, visited = /* @__PURE__ */ new Set(), depth = 0) {
|
|
911
|
+
const dependencies = /* @__PURE__ */ new Set();
|
|
912
|
+
if (depth > MAX_DEPTH) return dependencies;
|
|
913
|
+
const identifiers = extractReferencedIdentifiers(funcNode);
|
|
914
|
+
for (const identifier of identifiers) {
|
|
915
|
+
if (isBuiltin(identifier)) continue;
|
|
916
|
+
if (visited.has(identifier)) continue;
|
|
917
|
+
if (isLocalParameterVar(identifier)) continue;
|
|
918
|
+
visited.add(identifier);
|
|
919
|
+
const definition = findDefinition(identifier, ast);
|
|
920
|
+
if (definition) {
|
|
921
|
+
dependencies.add(identifier);
|
|
922
|
+
const nestedDeps = analyzeDependencies(definition, ast, visited, depth + 1);
|
|
923
|
+
for (const dep of nestedDeps) dependencies.add(dep);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return dependencies;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Extracts all identifiers referenced within a node that are not locally declared.
|
|
930
|
+
* @param node - The AST node to scan
|
|
931
|
+
* @returns Set of external identifier names
|
|
932
|
+
*/
|
|
933
|
+
function extractReferencedIdentifiers(node) {
|
|
934
|
+
const identifiers = /* @__PURE__ */ new Set();
|
|
935
|
+
const declared = /* @__PURE__ */ new Set();
|
|
936
|
+
walkAst(node, (innerNode) => {
|
|
937
|
+
if (innerNode.type === "VariableDeclarator") {
|
|
938
|
+
const id = innerNode.id;
|
|
939
|
+
if (id?.type === "Identifier") declared.add(id.name);
|
|
940
|
+
}
|
|
941
|
+
if (innerNode.type === "FunctionDeclaration" || innerNode.type === "FunctionExpression") {
|
|
942
|
+
const params = innerNode.params;
|
|
943
|
+
if (params) {
|
|
944
|
+
for (const param of params) if (param.type === "Identifier") declared.add(param.name);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
if (innerNode.type === "MemberExpression") {
|
|
948
|
+
const object = innerNode.object;
|
|
949
|
+
if (object?.type === "Identifier") {
|
|
950
|
+
const name = object.name;
|
|
951
|
+
if (!declared.has(name)) identifiers.add(name);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (innerNode.type === "Identifier") {
|
|
955
|
+
const parent = getParent(node, innerNode);
|
|
956
|
+
if (parent?.type === "CallExpression" && parent.callee === innerNode) {
|
|
957
|
+
const name = innerNode.name;
|
|
958
|
+
if (!declared.has(name)) identifiers.add(name);
|
|
959
|
+
}
|
|
960
|
+
if (parent?.type === "ArrayExpression") {
|
|
961
|
+
if (parent.elements?.includes(innerNode)) {
|
|
962
|
+
const name = innerNode.name;
|
|
963
|
+
if (!declared.has(name)) identifiers.add(name);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
if (parent?.type === "UnaryExpression" && parent.operator === "typeof") {
|
|
967
|
+
const name = innerNode.name;
|
|
968
|
+
if (!declared.has(name)) identifiers.add(name);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
return identifiers;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Finds a variable or function definition by name in the AST.
|
|
976
|
+
* @param name - The identifier name to find
|
|
977
|
+
* @param ast - The AST to search
|
|
978
|
+
* @returns The definition node if found, null otherwise
|
|
979
|
+
*/
|
|
980
|
+
function findDefinition(name, ast) {
|
|
981
|
+
let found = null;
|
|
982
|
+
walkAst(ast, (node) => {
|
|
983
|
+
if (node.type === "VariableDeclarator") {
|
|
984
|
+
const id = node.id;
|
|
985
|
+
if (id?.type === "Identifier" && id.name === name) {
|
|
986
|
+
const init = node.init;
|
|
987
|
+
if (init) {
|
|
988
|
+
found = init;
|
|
989
|
+
return node;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
if (node.type === "FunctionDeclaration") {
|
|
994
|
+
const id = node.id;
|
|
995
|
+
if (id?.type === "Identifier" && id.name === name) {
|
|
996
|
+
found = node;
|
|
997
|
+
return node;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (node.type === "AssignmentExpression") {
|
|
1001
|
+
const left = node.left;
|
|
1002
|
+
if (left?.type === "Identifier" && left.name === name) {
|
|
1003
|
+
const right = node.right;
|
|
1004
|
+
if (right) {
|
|
1005
|
+
found = right;
|
|
1006
|
+
return node;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
return found;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Finds the parent node of a target node within an AST.
|
|
1015
|
+
* @param root - The root node to search from
|
|
1016
|
+
* @param target - The target node to find the parent of
|
|
1017
|
+
* @returns The parent node if found, null otherwise
|
|
1018
|
+
*/
|
|
1019
|
+
function getParent(root, target) {
|
|
1020
|
+
let parent = null;
|
|
1021
|
+
walkAst(root, (node) => {
|
|
1022
|
+
for (const key of Object.keys(node)) {
|
|
1023
|
+
const child = node[key];
|
|
1024
|
+
if (child === target) {
|
|
1025
|
+
parent = node;
|
|
1026
|
+
return node;
|
|
1027
|
+
}
|
|
1028
|
+
if (Array.isArray(child) && child.includes(target)) {
|
|
1029
|
+
parent = node;
|
|
1030
|
+
return node;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
return parent;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
//#endregion
|
|
1038
|
+
//#region src/player/ast/extractor.ts
|
|
1039
|
+
/**
|
|
1040
|
+
* Finds a function definition by name in the AST.
|
|
1041
|
+
* Supports function declarations, variable declarations with function expressions,
|
|
1042
|
+
* and assignment expressions.
|
|
1043
|
+
* @param ast - The AST root node to search
|
|
1044
|
+
* @param functionName - The name of the function to find
|
|
1045
|
+
* @returns The function node if found, null otherwise
|
|
1046
|
+
*/
|
|
1047
|
+
function findFunctionDefinition(ast, functionName) {
|
|
1048
|
+
let definition = null;
|
|
1049
|
+
walkAst(ast, (node) => {
|
|
1050
|
+
const id = node.id;
|
|
1051
|
+
if (node.type === "FunctionDeclaration" && id?.name === functionName) {
|
|
1052
|
+
definition = node;
|
|
1053
|
+
return WALK_STOP;
|
|
1054
|
+
}
|
|
1055
|
+
if (node.type === "VariableDeclarator" && id?.type === "Identifier" && id.name === functionName) {
|
|
1056
|
+
const init = node.init;
|
|
1057
|
+
if (init?.type === "FunctionExpression" || init?.type === "ArrowFunctionExpression") {
|
|
1058
|
+
definition = init;
|
|
1059
|
+
return WALK_STOP;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const left = node.left;
|
|
1063
|
+
if (node.type === "AssignmentExpression" && left?.type === "Identifier" && left.name === functionName) {
|
|
1064
|
+
const right = node.right;
|
|
1065
|
+
if (right?.type === "FunctionExpression" || right?.type === "ArrowFunctionExpression") {
|
|
1066
|
+
definition = right;
|
|
1067
|
+
return WALK_STOP;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
return definition;
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Extracts the source code for a given AST node.
|
|
1075
|
+
* @param code - The full source code string
|
|
1076
|
+
* @param node - The AST node with start and end positions
|
|
1077
|
+
* @returns The extracted code slice
|
|
1078
|
+
*/
|
|
1079
|
+
function extractCode(code, node) {
|
|
1080
|
+
const start = node.start;
|
|
1081
|
+
const end = node.end;
|
|
1082
|
+
if (typeof start !== "number" || typeof end !== "number") return "";
|
|
1083
|
+
return code.slice(start, end);
|
|
1084
|
+
}
|
|
1085
|
+
function isSafeInitializer(node) {
|
|
1086
|
+
if (!node) return true;
|
|
1087
|
+
return true;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Extracts a full declaration (var X = ...) for a given name from the AST.
|
|
1091
|
+
* Handles both VariableDeclaration and AssignmentExpression patterns.
|
|
1092
|
+
* Only extracts if initializer is "safe" (no side-effects).
|
|
1093
|
+
* @param code - The full source code string
|
|
1094
|
+
* @param name - The identifier name to find
|
|
1095
|
+
* @param ast - The AST root node
|
|
1096
|
+
* @returns The complete declaration code, or null if not found
|
|
1097
|
+
*/
|
|
1098
|
+
function extractFullDeclaration(code, name, ast) {
|
|
1099
|
+
let result = null;
|
|
1100
|
+
walkAst(ast, (node) => {
|
|
1101
|
+
if (node.type === "FunctionDeclaration") {
|
|
1102
|
+
if (node.id?.name === name) {
|
|
1103
|
+
result = extractCode(code, node);
|
|
1104
|
+
return WALK_STOP;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
if (node.type === "VariableDeclaration") {
|
|
1108
|
+
const decls = node.declarations;
|
|
1109
|
+
if (decls) for (const decl of decls) {
|
|
1110
|
+
const id = decl.id;
|
|
1111
|
+
const init = decl.init;
|
|
1112
|
+
if (id?.name === name && init && isSafeInitializer(init)) {
|
|
1113
|
+
result = extractCode(code, node);
|
|
1114
|
+
return WALK_STOP;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (node.type === "ExpressionStatement") {
|
|
1119
|
+
const expr = node.expression;
|
|
1120
|
+
if (expr?.type === "AssignmentExpression") {
|
|
1121
|
+
const left = expr.left;
|
|
1122
|
+
const right = expr.right;
|
|
1123
|
+
if (left?.type === "Identifier" && left.name === name && isSafeInitializer(right)) {
|
|
1124
|
+
const rightCode = extractCode(code, right);
|
|
1125
|
+
if (rightCode) {
|
|
1126
|
+
result = `var ${name} = ${rightCode}`;
|
|
1127
|
+
return WALK_STOP;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
if (node.type === "AssignmentExpression") {
|
|
1133
|
+
const left = node.left;
|
|
1134
|
+
if (left?.type === "Identifier" && left.name === name) {
|
|
1135
|
+
const right = node.right;
|
|
1136
|
+
if (isSafeInitializer(right)) {
|
|
1137
|
+
const rightCode = extractCode(code, right);
|
|
1138
|
+
if (rightCode) {
|
|
1139
|
+
result = `var ${name} = ${rightCode}`;
|
|
1140
|
+
return WALK_STOP;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
return result;
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Extracts code for a function with a simplified approach.
|
|
1150
|
+
* Creates a self-contained IIFE with stubs for globals.
|
|
1151
|
+
* @param code - The full source code string
|
|
1152
|
+
* @param funcNode - The function AST node
|
|
1153
|
+
* @param ast - The full AST for dependency lookup
|
|
1154
|
+
* @param funcName - The name of the main function
|
|
1155
|
+
* @returns Combined code with dependencies wrapped in IIFE
|
|
1156
|
+
*/
|
|
1157
|
+
function extractCodeWithDependencies(code, funcNode, ast, funcName) {
|
|
1158
|
+
const dependencies = analyzeDependencies(funcNode, ast);
|
|
1159
|
+
const parts = [];
|
|
1160
|
+
const extracted = /* @__PURE__ */ new Set();
|
|
1161
|
+
const predeclared = /* @__PURE__ */ new Set();
|
|
1162
|
+
for (const depName of dependencies) {
|
|
1163
|
+
if (extracted.has(depName)) continue;
|
|
1164
|
+
const depCode = extractFullDeclaration(code, depName, ast);
|
|
1165
|
+
if (depCode) {
|
|
1166
|
+
parts.push(depCode);
|
|
1167
|
+
extracted.add(depName);
|
|
1168
|
+
} else predeclared.add(depName);
|
|
1169
|
+
}
|
|
1170
|
+
if (!extracted.has(funcName)) {
|
|
1171
|
+
const mainCode = extractFullDeclaration(code, funcName, ast);
|
|
1172
|
+
if (mainCode) parts.push(mainCode);
|
|
1173
|
+
}
|
|
1174
|
+
const stubs = ["var g = { cF: function(a, b) { return b.indexOf(a) >= 0; }, C$: function(a) { return /\\s/.test(a); } };"];
|
|
1175
|
+
const predeclareStmt = predeclared.size > 0 ? `var ${Array.from(predeclared).join(", ")};` : "";
|
|
1176
|
+
const postProcess = "if (typeof z === 'string' && z.indexOf(';') !== -1) z = z.split(';');";
|
|
1177
|
+
return [
|
|
1178
|
+
...stubs,
|
|
1179
|
+
predeclareStmt,
|
|
1180
|
+
parts.join(";\n"),
|
|
1181
|
+
postProcess
|
|
1182
|
+
].filter(Boolean).join("\n");
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
//#endregion
|
|
1186
|
+
//#region src/player/cache.ts
|
|
1187
|
+
/**
|
|
1188
|
+
* SQLite-based cache for YouTube player.js cipher functions.
|
|
1189
|
+
* Stores extracted signature and n-transform functions to avoid re-parsing.
|
|
1190
|
+
*/
|
|
1191
|
+
var PlayerCache = class {
|
|
1192
|
+
db;
|
|
1193
|
+
ttlMs;
|
|
1194
|
+
/**
|
|
1195
|
+
* Creates a new PlayerCache instance.
|
|
1196
|
+
* @param cachePath - Path to the SQLite database file
|
|
1197
|
+
* @param ttlDays - Time-to-live for cached entries in days
|
|
1198
|
+
*/
|
|
1199
|
+
constructor(cachePath, ttlDays = 7) {
|
|
1200
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
1201
|
+
this.db = new Database(cachePath);
|
|
1202
|
+
this.ttlMs = ttlDays * 24 * 60 * 60 * 1e3;
|
|
1203
|
+
this.initializeSchema();
|
|
1204
|
+
}
|
|
1205
|
+
initializeSchema() {
|
|
1206
|
+
this.db.run(`
|
|
1207
|
+
CREATE TABLE IF NOT EXISTS players (
|
|
1208
|
+
player_id TEXT PRIMARY KEY,
|
|
1209
|
+
code TEXT NOT NULL,
|
|
1210
|
+
sig_function TEXT,
|
|
1211
|
+
n_function TEXT,
|
|
1212
|
+
signature_timestamp INTEGER,
|
|
1213
|
+
cached_at INTEGER NOT NULL
|
|
1214
|
+
)
|
|
1215
|
+
`);
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Retrieves a cached player entry.
|
|
1219
|
+
* @param playerId - The player ID to look up
|
|
1220
|
+
* @returns Result containing the cached player or null if not found/expired
|
|
1221
|
+
*/
|
|
1222
|
+
get(playerId) {
|
|
1223
|
+
try {
|
|
1224
|
+
const row = this.db.prepare(`
|
|
1225
|
+
SELECT player_id, code, sig_function, n_function, signature_timestamp, cached_at
|
|
1226
|
+
FROM players
|
|
1227
|
+
WHERE player_id = ?
|
|
1228
|
+
`).get(playerId);
|
|
1229
|
+
if (!row) return ok(null);
|
|
1230
|
+
if (this.isExpired(row.cached_at)) {
|
|
1231
|
+
this.delete(playerId);
|
|
1232
|
+
return ok(null);
|
|
1233
|
+
}
|
|
1234
|
+
return ok({
|
|
1235
|
+
playerId: row.player_id,
|
|
1236
|
+
code: row.code,
|
|
1237
|
+
sigFunction: row.sig_function || "",
|
|
1238
|
+
nFunction: row.n_function || "",
|
|
1239
|
+
signatureTimestamp: row.signature_timestamp || 0,
|
|
1240
|
+
cachedAt: row.cached_at
|
|
1241
|
+
});
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
return err(createError("CACHE_ERROR", "Failed to read from cache", error));
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Stores a player entry in the cache.
|
|
1248
|
+
* @param data - The player data to cache
|
|
1249
|
+
* @returns Result indicating success or failure
|
|
1250
|
+
*/
|
|
1251
|
+
set(data) {
|
|
1252
|
+
try {
|
|
1253
|
+
this.db.prepare(`
|
|
1254
|
+
INSERT OR REPLACE INTO players (
|
|
1255
|
+
player_id, code, sig_function, n_function, signature_timestamp, cached_at
|
|
1256
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
1257
|
+
`).run(data.playerId, data.code, data.sigFunction, data.nFunction, data.signatureTimestamp, data.cachedAt);
|
|
1258
|
+
return ok(void 0);
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
return err(createError("CACHE_ERROR", "Failed to write to cache", error));
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Deletes a specific player entry from the cache.
|
|
1265
|
+
* @param playerId - The player ID to delete
|
|
1266
|
+
* @returns Result indicating success or failure
|
|
1267
|
+
*/
|
|
1268
|
+
delete(playerId) {
|
|
1269
|
+
try {
|
|
1270
|
+
this.db.prepare("DELETE FROM players WHERE player_id = ?").run(playerId);
|
|
1271
|
+
return ok(void 0);
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
return err(createError("CACHE_ERROR", "Failed to delete from cache", error));
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Clears all entries from the cache.
|
|
1278
|
+
* @returns Result indicating success or failure
|
|
1279
|
+
*/
|
|
1280
|
+
clear() {
|
|
1281
|
+
try {
|
|
1282
|
+
this.db.run("DELETE FROM players");
|
|
1283
|
+
return ok(void 0);
|
|
1284
|
+
} catch (error) {
|
|
1285
|
+
return err(createError("CACHE_ERROR", "Failed to clear cache", error));
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Retrieves the most recent valid cached player entry.
|
|
1290
|
+
* Used to avoid re-downloading player.js when we have valid cache.
|
|
1291
|
+
* @returns Result containing the cached player or null if none valid
|
|
1292
|
+
*/
|
|
1293
|
+
getLatest() {
|
|
1294
|
+
try {
|
|
1295
|
+
const row = this.db.prepare(`
|
|
1296
|
+
SELECT player_id, code, sig_function, n_function, signature_timestamp, cached_at
|
|
1297
|
+
FROM players
|
|
1298
|
+
ORDER BY cached_at DESC
|
|
1299
|
+
LIMIT 1
|
|
1300
|
+
`).get();
|
|
1301
|
+
if (!row) return ok(null);
|
|
1302
|
+
if (this.isExpired(row.cached_at)) {
|
|
1303
|
+
this.delete(row.player_id);
|
|
1304
|
+
return ok(null);
|
|
1305
|
+
}
|
|
1306
|
+
return ok({
|
|
1307
|
+
playerId: row.player_id,
|
|
1308
|
+
code: row.code,
|
|
1309
|
+
sigFunction: row.sig_function || "",
|
|
1310
|
+
nFunction: row.n_function || "",
|
|
1311
|
+
signatureTimestamp: row.signature_timestamp || 0,
|
|
1312
|
+
cachedAt: row.cached_at
|
|
1313
|
+
});
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
return err(createError("CACHE_ERROR", "Failed to read latest from cache", error));
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Closes the database connection.
|
|
1320
|
+
*/
|
|
1321
|
+
close() {
|
|
1322
|
+
this.db.close();
|
|
1323
|
+
}
|
|
1324
|
+
isExpired(cachedAt) {
|
|
1325
|
+
return Date.now() - cachedAt > this.ttlMs;
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
|
|
1329
|
+
//#endregion
|
|
1330
|
+
//#region src/player/player.ts
|
|
1331
|
+
/**
|
|
1332
|
+
* Handles YouTube player.js downloading, parsing, and cipher function extraction.
|
|
1333
|
+
* Uses AST-based extraction for robustness against YouTube's obfuscation changes.
|
|
1334
|
+
*/
|
|
1335
|
+
var Player = class {
|
|
1336
|
+
cache = null;
|
|
1337
|
+
playerCode = "";
|
|
1338
|
+
sigFunctionCode = "";
|
|
1339
|
+
nFunctionCode = "";
|
|
1340
|
+
decipherFunc = null;
|
|
1341
|
+
nFunc = null;
|
|
1342
|
+
signatureTimestamp = 0;
|
|
1343
|
+
initialized = false;
|
|
1344
|
+
constructor(cacheDir) {
|
|
1345
|
+
if (cacheDir) this.cache = new PlayerCache(join(cacheDir, "player.db"));
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Downloads and parses the YouTube player.js to extract cipher functions.
|
|
1349
|
+
* Uses cache-first strategy to minimize network requests.
|
|
1350
|
+
* @param playerUrl - Optional direct URL to player.js, auto-detected if not provided
|
|
1351
|
+
* @returns Result indicating success or failure
|
|
1352
|
+
*/
|
|
1353
|
+
async initialize(playerUrl) {
|
|
1354
|
+
if (this.initialized) return ok(void 0);
|
|
1355
|
+
if (this.cache && !playerUrl) {
|
|
1356
|
+
const latestResult = this.cache.getLatest();
|
|
1357
|
+
if (isOk(latestResult) && latestResult.value) return this.restoreFromCache(latestResult.value);
|
|
1358
|
+
}
|
|
1359
|
+
if (!playerUrl) {
|
|
1360
|
+
const htmlResult = await httpClient.get(YOUTUBE_BASE_URL);
|
|
1361
|
+
if (!isOk(htmlResult)) return err(htmlResult.error);
|
|
1362
|
+
const match$1 = (await htmlResult.value.text()).match(/\/s\/player\/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+\/base\.js/);
|
|
1363
|
+
if (!match$1) return err(createError("PLAYER_FETCH_FAILED", "Could not determine player URL"));
|
|
1364
|
+
playerUrl = YOUTUBE_BASE_URL + match$1[0];
|
|
1365
|
+
}
|
|
1366
|
+
const playerId = this.extractPlayerId(playerUrl);
|
|
1367
|
+
if (this.cache && playerId) {
|
|
1368
|
+
const cacheResult = this.cache.get(playerId);
|
|
1369
|
+
if (isOk(cacheResult) && cacheResult.value) return this.restoreFromCache(cacheResult.value);
|
|
1370
|
+
}
|
|
1371
|
+
const codeResult = await httpClient.get(playerUrl);
|
|
1372
|
+
if (!isOk(codeResult)) return err(codeResult.error);
|
|
1373
|
+
this.playerCode = await codeResult.value.text();
|
|
1374
|
+
const processResult = this.processCode(this.playerCode);
|
|
1375
|
+
if (isOk(processResult)) {
|
|
1376
|
+
this.initialized = true;
|
|
1377
|
+
if (this.cache && playerId) this.cache.set({
|
|
1378
|
+
playerId,
|
|
1379
|
+
code: this.playerCode,
|
|
1380
|
+
sigFunction: this.sigFunctionCode,
|
|
1381
|
+
nFunction: this.nFunctionCode,
|
|
1382
|
+
signatureTimestamp: this.signatureTimestamp,
|
|
1383
|
+
cachedAt: Date.now()
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
return processResult;
|
|
1387
|
+
}
|
|
1388
|
+
extractPlayerId(playerUrl) {
|
|
1389
|
+
const match$1 = playerUrl.match(/\/s\/player\/([a-zA-Z0-9_-]+)\//);
|
|
1390
|
+
return match$1 ? match$1[1] : null;
|
|
1391
|
+
}
|
|
1392
|
+
restoreFromCache(cached) {
|
|
1393
|
+
try {
|
|
1394
|
+
this.playerCode = cached.code;
|
|
1395
|
+
this.sigFunctionCode = cached.sigFunction;
|
|
1396
|
+
this.nFunctionCode = cached.nFunction;
|
|
1397
|
+
this.signatureTimestamp = cached.signatureTimestamp;
|
|
1398
|
+
if (this.sigFunctionCode) this.decipherFunc = new Function("sig", this.sigFunctionCode);
|
|
1399
|
+
if (this.nFunctionCode) this.nFunc = new Function("n", this.nFunctionCode);
|
|
1400
|
+
this.initialized = true;
|
|
1401
|
+
return ok(void 0);
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
return err(createError("CACHE_ERROR", "Failed to restore from cache", error));
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
processCode(code) {
|
|
1407
|
+
try {
|
|
1408
|
+
const ast = parse(code, {
|
|
1409
|
+
ranges: true,
|
|
1410
|
+
next: true
|
|
1411
|
+
});
|
|
1412
|
+
this.extractSignatureTimestamp(ast);
|
|
1413
|
+
this.extractDecipherFunction(ast, code);
|
|
1414
|
+
this.extractNFunction(ast, code);
|
|
1415
|
+
return ok(void 0);
|
|
1416
|
+
} catch (error) {
|
|
1417
|
+
return err(createError("PARSE_ERROR", "Failed to parse player.js", error));
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
extractSignatureTimestamp(ast) {
|
|
1421
|
+
const stsNode = walkAst(ast, timestampMatcher);
|
|
1422
|
+
if (stsNode) {
|
|
1423
|
+
const value = stsNode.value;
|
|
1424
|
+
if (value?.type === "Literal" && typeof value.value === "number") this.signatureTimestamp = value.value;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
extractDecipherFunction(ast, code) {
|
|
1428
|
+
const sigCall = walkAst(ast, sigMatcher);
|
|
1429
|
+
if (!sigCall) return;
|
|
1430
|
+
const callee = sigCall.callee;
|
|
1431
|
+
if (callee?.type !== "Identifier") return;
|
|
1432
|
+
const funcName = callee.name;
|
|
1433
|
+
const funcDef = findFunctionDefinition(ast, funcName);
|
|
1434
|
+
if (!funcDef) return;
|
|
1435
|
+
const modeArg = sigCall.arguments?.[0];
|
|
1436
|
+
const modeValue = modeArg?.type === "Literal" && typeof modeArg.value === "number" ? modeArg.value : 83;
|
|
1437
|
+
const funcCode = extractCodeWithDependencies(code, funcDef, ast, funcName);
|
|
1438
|
+
if (!funcCode) return;
|
|
1439
|
+
this.sigFunctionCode = `
|
|
1440
|
+
${funcCode}
|
|
1441
|
+
function __decipher_wrapper__(input) {
|
|
1442
|
+
var result = ${funcName}(${modeValue}, input);
|
|
1443
|
+
return Array.isArray(result) ? result.join('') : result;
|
|
1444
|
+
}
|
|
1445
|
+
return __decipher_wrapper__(sig);
|
|
1446
|
+
`;
|
|
1447
|
+
try {
|
|
1448
|
+
this.decipherFunc = new Function("sig", this.sigFunctionCode);
|
|
1449
|
+
} catch {
|
|
1450
|
+
this.decipherFunc = null;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
extractNFunction(ast, code) {
|
|
1454
|
+
const candidates = findNFunctionCandidates(ast);
|
|
1455
|
+
let bestCandidate = null;
|
|
1456
|
+
for (const nFuncName of candidates) {
|
|
1457
|
+
const nFuncDef = findFunctionDefinition(ast, nFuncName);
|
|
1458
|
+
if (!nFuncDef) continue;
|
|
1459
|
+
if (nFuncDef.type !== "FunctionExpression" && nFuncDef.type !== "ArrowFunctionExpression" && nFuncDef.type !== "FunctionDeclaration") continue;
|
|
1460
|
+
const nFuncCode = extractCodeWithDependencies(code, nFuncDef, ast, nFuncName);
|
|
1461
|
+
if (!bestCandidate || nFuncCode.length > bestCandidate.size) bestCandidate = {
|
|
1462
|
+
name: nFuncName,
|
|
1463
|
+
code: nFuncCode,
|
|
1464
|
+
size: nFuncCode.length
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
if (!bestCandidate) return;
|
|
1468
|
+
this.nFunctionCode = `
|
|
1469
|
+
${bestCandidate.code}
|
|
1470
|
+
function __n_wrapper__(input) {
|
|
1471
|
+
return ${bestCandidate.name}(input);
|
|
1472
|
+
}
|
|
1473
|
+
return __n_wrapper__(n);
|
|
1474
|
+
`;
|
|
1475
|
+
try {
|
|
1476
|
+
this.nFunc = new Function("n", this.nFunctionCode);
|
|
1477
|
+
} catch {
|
|
1478
|
+
this.nFunc = null;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Returns the signature timestamp extracted from player.js.
|
|
1483
|
+
* Required for some InnerTube API calls.
|
|
1484
|
+
*/
|
|
1485
|
+
getSignatureTimestamp() {
|
|
1486
|
+
return this.signatureTimestamp;
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Deciphers a signature cipher string and constructs the final playback URL.
|
|
1490
|
+
* @param baseUrl - The base URL from the format
|
|
1491
|
+
* @param signatureCipher - The encoded signature cipher string
|
|
1492
|
+
* @returns The deciphered playback URL
|
|
1493
|
+
*/
|
|
1494
|
+
decipher(baseUrl, signatureCipher) {
|
|
1495
|
+
if (!this.decipherFunc) return baseUrl;
|
|
1496
|
+
const params = new URLSearchParams(signatureCipher);
|
|
1497
|
+
const urlParam = params.get("url");
|
|
1498
|
+
const sig = params.get("s");
|
|
1499
|
+
const sp = params.get("sp") || "sig";
|
|
1500
|
+
if (!sig || !urlParam) return baseUrl;
|
|
1501
|
+
try {
|
|
1502
|
+
const decryptedSig = this.decipherFunc(decodeURIComponent(sig));
|
|
1503
|
+
return `${decodeURIComponent(urlParam)}&${sp}=${encodeURIComponent(decryptedSig)}`;
|
|
1504
|
+
} catch {
|
|
1505
|
+
return baseUrl;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Deobfuscates the 'n' parameter to prevent throttling.
|
|
1510
|
+
* @param nParam - The obfuscated n parameter value
|
|
1511
|
+
* @returns The deobfuscated n parameter value
|
|
1512
|
+
*/
|
|
1513
|
+
deobfuscateN(nParam) {
|
|
1514
|
+
if (!this.nFunc) return nParam;
|
|
1515
|
+
try {
|
|
1516
|
+
return this.nFunc(nParam);
|
|
1517
|
+
} catch {
|
|
1518
|
+
return nParam;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
//#endregion
|
|
1524
|
+
//#region src/http/retry.ts
|
|
1525
|
+
/**
|
|
1526
|
+
* Retry utilities with exponential backoff.
|
|
1527
|
+
* Provides automatic retry logic for transient failures.
|
|
1528
|
+
* @module http/retry
|
|
1529
|
+
*/
|
|
1530
|
+
const NON_RETRYABLE_PATTERNS = [
|
|
1531
|
+
"invalid",
|
|
1532
|
+
"not found",
|
|
1533
|
+
"drm",
|
|
1534
|
+
"private"
|
|
1535
|
+
];
|
|
1536
|
+
/**
|
|
1537
|
+
* Executes a function with automatic retry on failure.
|
|
1538
|
+
* @param fn - The async function to execute
|
|
1539
|
+
* @param options - Retry configuration options
|
|
1540
|
+
* @returns The result of the function if successful
|
|
1541
|
+
* @throws The last error if all retries are exhausted
|
|
1542
|
+
*/
|
|
1543
|
+
async function withRetry(fn, options = {}) {
|
|
1544
|
+
const { retries = RETRY_DEFAULTS.RETRIES, delay = RETRY_DEFAULTS.DELAY, backoff = RETRY_DEFAULTS.BACKOFF, maxDelay = RETRY_DEFAULTS.MAX_DELAY, shouldRetry = defaultShouldRetry, onRetry } = options;
|
|
1545
|
+
let lastError;
|
|
1546
|
+
for (let attempt = 0; attempt <= retries; attempt++) try {
|
|
1547
|
+
return await fn();
|
|
1548
|
+
} catch (error) {
|
|
1549
|
+
lastError = error;
|
|
1550
|
+
if (attempt < retries && shouldRetry(lastError)) {
|
|
1551
|
+
const waitTime = Math.min(delay * Math.pow(backoff, attempt), maxDelay);
|
|
1552
|
+
onRetry?.(lastError, attempt + 1);
|
|
1553
|
+
await sleep(waitTime);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
throw lastError;
|
|
1557
|
+
}
|
|
1558
|
+
function defaultShouldRetry(error) {
|
|
1559
|
+
const message = error.message.toLowerCase();
|
|
1560
|
+
return !NON_RETRYABLE_PATTERNS.some((pattern) => message.includes(pattern));
|
|
1561
|
+
}
|
|
1562
|
+
function sleep(ms) {
|
|
1563
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
//#endregion
|
|
1567
|
+
//#region src/streaming/formats.ts
|
|
1568
|
+
/**
|
|
1569
|
+
* Parses a raw format object from InnerTube response into a typed Format.
|
|
1570
|
+
* @param raw - The raw format object from streaming data
|
|
1571
|
+
* @param isAdaptive - Whether this is an adaptive format (video-only or audio-only)
|
|
1572
|
+
* @returns Parsed Format object
|
|
1573
|
+
*/
|
|
1574
|
+
function parseFormat(raw, isAdaptive) {
|
|
1575
|
+
return {
|
|
1576
|
+
itag: raw.itag,
|
|
1577
|
+
url: raw.url || "",
|
|
1578
|
+
mimeType: raw.mimeType,
|
|
1579
|
+
codecs: extractCodecs(raw.mimeType),
|
|
1580
|
+
bitrate: raw.bitrate,
|
|
1581
|
+
width: raw.width,
|
|
1582
|
+
height: raw.height,
|
|
1583
|
+
fps: raw.fps,
|
|
1584
|
+
qualityLabel: raw.qualityLabel,
|
|
1585
|
+
audioSampleRate: raw.audioSampleRate ? parseInt(raw.audioSampleRate, 10) : void 0,
|
|
1586
|
+
audioChannels: raw.audioChannels,
|
|
1587
|
+
contentLength: raw.contentLength ? parseInt(raw.contentLength, 10) : void 0,
|
|
1588
|
+
hasDrm: !!raw.drmFamilies,
|
|
1589
|
+
isAdaptive
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Extracts codec strings from a mimeType.
|
|
1594
|
+
* @param mimeType - The mimeType string containing codec info
|
|
1595
|
+
* @returns Array of codec strings
|
|
1596
|
+
*/
|
|
1597
|
+
function extractCodecs(mimeType) {
|
|
1598
|
+
const match$1 = mimeType.match(/codecs="([^"]+)"/);
|
|
1599
|
+
if (!match$1) return [];
|
|
1600
|
+
return match$1[1].split(", ");
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Categorizes formats into combined (progressive), video-only, and audio-only.
|
|
1604
|
+
* @param formats - Array of all formats
|
|
1605
|
+
* @returns Object with categorized formats
|
|
1606
|
+
*/
|
|
1607
|
+
function categorizeFormats(formats) {
|
|
1608
|
+
const combined = [];
|
|
1609
|
+
const video = [];
|
|
1610
|
+
const audio = [];
|
|
1611
|
+
for (const format of formats) if (!format.isAdaptive) combined.push(format);
|
|
1612
|
+
else if (format.mimeType.includes("video")) video.push(format);
|
|
1613
|
+
else if (format.mimeType.includes("audio")) audio.push(format);
|
|
1614
|
+
return {
|
|
1615
|
+
combined,
|
|
1616
|
+
video,
|
|
1617
|
+
audio
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
//#endregion
|
|
1622
|
+
//#region src/streaming/decipher.ts
|
|
1623
|
+
/**
|
|
1624
|
+
* Handles deciphering of YouTube format URLs.
|
|
1625
|
+
* Processes signature ciphers and n-parameter obfuscation.
|
|
1626
|
+
*/
|
|
1627
|
+
var FormatDecipher = class {
|
|
1628
|
+
constructor(player) {
|
|
1629
|
+
this.player = player;
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Deciphers a single format's URL.
|
|
1633
|
+
* @param format - The raw format with potentially ciphered URL
|
|
1634
|
+
* @returns Format with deciphered URL
|
|
1635
|
+
*/
|
|
1636
|
+
decipher(format) {
|
|
1637
|
+
if (format.url && !format.signatureCipher && !format.cipher) return format;
|
|
1638
|
+
let url = format.url || "";
|
|
1639
|
+
if (format.signatureCipher || format.cipher) url = this.player.decipher(url, format.signatureCipher || format.cipher || "");
|
|
1640
|
+
if (url && url.includes("n=")) url = this.deobfuscateNParam(url);
|
|
1641
|
+
return {
|
|
1642
|
+
...format,
|
|
1643
|
+
url
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
/**
|
|
1647
|
+
* Deciphers a batch of formats.
|
|
1648
|
+
* @param formats - Array of raw formats
|
|
1649
|
+
* @returns Array of formats with deciphered URLs
|
|
1650
|
+
*/
|
|
1651
|
+
decipherBatch(formats) {
|
|
1652
|
+
return formats.map((format) => this.decipher(format));
|
|
1653
|
+
}
|
|
1654
|
+
deobfuscateNParam(url) {
|
|
1655
|
+
try {
|
|
1656
|
+
const urlObj = new URL(url);
|
|
1657
|
+
const nParam = urlObj.searchParams.get("n");
|
|
1658
|
+
if (nParam) {
|
|
1659
|
+
const deobfuscatedN = this.player.deobfuscateN(nParam);
|
|
1660
|
+
urlObj.searchParams.set("n", deobfuscatedN);
|
|
1661
|
+
return urlObj.toString();
|
|
1662
|
+
}
|
|
1663
|
+
} catch {
|
|
1664
|
+
return url;
|
|
1665
|
+
}
|
|
1666
|
+
return url;
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
//#endregion
|
|
1671
|
+
//#region src/utils/m3u8.ts
|
|
1672
|
+
/**
|
|
1673
|
+
* Parses an M3U8 playlist.
|
|
1674
|
+
* @param content - M3U8 file content
|
|
1675
|
+
* @returns Result containing parsed playlist or error
|
|
1676
|
+
*/
|
|
1677
|
+
function parseM3u8(content) {
|
|
1678
|
+
try {
|
|
1679
|
+
const lines = content.split("\n").map((line) => line.trim()).filter((line) => line);
|
|
1680
|
+
if (!lines[0]?.startsWith("#EXTM3U")) return err(createError("PARSE_ERROR", "Invalid M3U8 file: missing #EXTM3U header"));
|
|
1681
|
+
const version = extractVersion(lines);
|
|
1682
|
+
if (lines.some((line) => line.includes("#EXT-X-STREAM-INF"))) return ok({
|
|
1683
|
+
type: "master",
|
|
1684
|
+
version,
|
|
1685
|
+
variants: parseMasterPlaylist(lines)
|
|
1686
|
+
});
|
|
1687
|
+
return ok({
|
|
1688
|
+
type: "media",
|
|
1689
|
+
version,
|
|
1690
|
+
segments: parseMediaPlaylist(lines),
|
|
1691
|
+
targetDuration: extractTargetDuration(lines)
|
|
1692
|
+
});
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
return err(createError("PARSE_ERROR", "Failed to parse M3U8", error));
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Extracts version from M3U8 playlist.
|
|
1699
|
+
* @param lines - Playlist lines
|
|
1700
|
+
* @returns Version number
|
|
1701
|
+
*/
|
|
1702
|
+
function extractVersion(lines) {
|
|
1703
|
+
const versionLine = lines.find((line) => line.startsWith("#EXT-X-VERSION:"));
|
|
1704
|
+
if (!versionLine) return 3;
|
|
1705
|
+
const version = parseInt(versionLine.split(":")[1], 10);
|
|
1706
|
+
return isNaN(version) ? 3 : version;
|
|
1707
|
+
}
|
|
1708
|
+
/**
|
|
1709
|
+
* Extracts target duration from media playlist.
|
|
1710
|
+
* @param lines - Playlist lines
|
|
1711
|
+
* @returns Target duration in seconds
|
|
1712
|
+
*/
|
|
1713
|
+
function extractTargetDuration(lines) {
|
|
1714
|
+
const targetLine = lines.find((line) => line.startsWith("#EXT-X-TARGETDURATION:"));
|
|
1715
|
+
if (!targetLine) return void 0;
|
|
1716
|
+
const duration = parseInt(targetLine.split(":")[1], 10);
|
|
1717
|
+
return isNaN(duration) ? void 0 : duration;
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Parses master playlist variants.
|
|
1721
|
+
* @param lines - Playlist lines
|
|
1722
|
+
* @returns Array of HLS variants
|
|
1723
|
+
*/
|
|
1724
|
+
function parseMasterPlaylist(lines) {
|
|
1725
|
+
const variants = [];
|
|
1726
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1727
|
+
const line = lines[i];
|
|
1728
|
+
if (line.startsWith("#EXT-X-STREAM-INF:")) {
|
|
1729
|
+
const attributes = parseStreamInf(line);
|
|
1730
|
+
const url = lines[i + 1];
|
|
1731
|
+
if (url && !url.startsWith("#")) variants.push({
|
|
1732
|
+
url,
|
|
1733
|
+
bandwidth: attributes.bandwidth,
|
|
1734
|
+
resolution: attributes.resolution,
|
|
1735
|
+
codecs: attributes.codecs,
|
|
1736
|
+
frameRate: attributes.frameRate
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
return variants;
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* Parses #EXT-X-STREAM-INF attributes.
|
|
1744
|
+
* @param line - Stream info line
|
|
1745
|
+
* @returns Parsed attributes
|
|
1746
|
+
*/
|
|
1747
|
+
function parseStreamInf(line) {
|
|
1748
|
+
const attrs = line.split(":")[1];
|
|
1749
|
+
const bandwidth = extractAttribute(attrs, "BANDWIDTH");
|
|
1750
|
+
const resolution = extractAttribute(attrs, "RESOLUTION");
|
|
1751
|
+
const codecs = extractAttribute(attrs, "CODECS");
|
|
1752
|
+
const frameRateStr = extractAttribute(attrs, "FRAME-RATE");
|
|
1753
|
+
return {
|
|
1754
|
+
bandwidth: parseInt(bandwidth || "0", 10),
|
|
1755
|
+
resolution: resolution || void 0,
|
|
1756
|
+
codecs: codecs || void 0,
|
|
1757
|
+
frameRate: frameRateStr ? parseFloat(frameRateStr) : void 0
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Parses media playlist segments.
|
|
1762
|
+
* @param lines - Playlist lines
|
|
1763
|
+
* @returns Array of HLS segments
|
|
1764
|
+
*/
|
|
1765
|
+
function parseMediaPlaylist(lines) {
|
|
1766
|
+
const segments = [];
|
|
1767
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1768
|
+
const line = lines[i];
|
|
1769
|
+
if (line.startsWith("#EXTINF:")) {
|
|
1770
|
+
const duration = parseFloat(line.split(":")[1].split(",")[0]);
|
|
1771
|
+
const url = lines[i + 1];
|
|
1772
|
+
if (url && !url.startsWith("#")) {
|
|
1773
|
+
const byteRange = parseByteRange(lines[i - 1]);
|
|
1774
|
+
segments.push({
|
|
1775
|
+
url,
|
|
1776
|
+
duration,
|
|
1777
|
+
byteRange
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
return segments;
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1785
|
+
* Parses #EXT-X-BYTERANGE directive.
|
|
1786
|
+
* @param line - Byte range line
|
|
1787
|
+
* @returns Byte range object or undefined
|
|
1788
|
+
*/
|
|
1789
|
+
function parseByteRange(line) {
|
|
1790
|
+
if (!line?.startsWith("#EXT-X-BYTERANGE:")) return void 0;
|
|
1791
|
+
const parts = line.split(":")[1].split("@");
|
|
1792
|
+
if (parts.length !== 2) return void 0;
|
|
1793
|
+
const length = parseInt(parts[0], 10);
|
|
1794
|
+
const start = parseInt(parts[1], 10);
|
|
1795
|
+
if (isNaN(length) || isNaN(start)) return void 0;
|
|
1796
|
+
return {
|
|
1797
|
+
start,
|
|
1798
|
+
length
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Extracts attribute value from M3U8 attribute string.
|
|
1803
|
+
* @param attrs - Attribute string
|
|
1804
|
+
* @param name - Attribute name
|
|
1805
|
+
* @returns Attribute value or undefined
|
|
1806
|
+
*/
|
|
1807
|
+
function extractAttribute(attrs, name) {
|
|
1808
|
+
const regex = /* @__PURE__ */ new RegExp(`${name}=([^,]+)`);
|
|
1809
|
+
const match$1 = attrs.match(regex);
|
|
1810
|
+
if (!match$1) return void 0;
|
|
1811
|
+
let value = match$1[1].trim();
|
|
1812
|
+
if (value.startsWith("\"") && value.endsWith("\"")) value = value.slice(1, -1);
|
|
1813
|
+
return value;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
//#endregion
|
|
1817
|
+
//#region src/streaming/hls/parser.ts
|
|
1818
|
+
/**
|
|
1819
|
+
* Fetches and parses an HLS manifest.
|
|
1820
|
+
* @param manifestUrl - URL to HLS manifest
|
|
1821
|
+
* @returns Result containing parsed playlist or error
|
|
1822
|
+
*/
|
|
1823
|
+
async function fetchHlsManifest(manifestUrl) {
|
|
1824
|
+
const response = await httpClient.get(manifestUrl);
|
|
1825
|
+
if (!isOk(response)) return err(response.error);
|
|
1826
|
+
try {
|
|
1827
|
+
return parseM3u8(await response.value.text());
|
|
1828
|
+
} catch (error) {
|
|
1829
|
+
return err(createError("PARSE_ERROR", "Failed to fetch HLS manifest", error));
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
//#endregion
|
|
1834
|
+
//#region src/utils/xml.ts
|
|
1835
|
+
/**
|
|
1836
|
+
* Parses XML string into a simple node tree.
|
|
1837
|
+
* @param xml - XML string to parse
|
|
1838
|
+
* @returns Root XML node
|
|
1839
|
+
*/
|
|
1840
|
+
function parseXml(xml) {
|
|
1841
|
+
const root = {
|
|
1842
|
+
type: "element",
|
|
1843
|
+
name: "root",
|
|
1844
|
+
children: []
|
|
1845
|
+
};
|
|
1846
|
+
const stack = [root];
|
|
1847
|
+
const tagRegex = /<\/?([a-zA-Z0-9_-]+)([^>]*)>/g;
|
|
1848
|
+
let lastIndex = 0;
|
|
1849
|
+
let match$1;
|
|
1850
|
+
while ((match$1 = tagRegex.exec(xml)) !== null) {
|
|
1851
|
+
const textBefore = xml.slice(lastIndex, match$1.index).trim();
|
|
1852
|
+
if (textBefore && stack.length > 0) {
|
|
1853
|
+
const parent = stack[stack.length - 1];
|
|
1854
|
+
if (parent.children) parent.children.push({
|
|
1855
|
+
type: "text",
|
|
1856
|
+
text: textBefore
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
const fullTag = match$1[0];
|
|
1860
|
+
const tagName = match$1[1];
|
|
1861
|
+
const attributes = match$1[2];
|
|
1862
|
+
if (fullTag.startsWith("</")) stack.pop();
|
|
1863
|
+
else {
|
|
1864
|
+
const node = {
|
|
1865
|
+
type: "element",
|
|
1866
|
+
name: tagName,
|
|
1867
|
+
attributes: parseAttributes(attributes),
|
|
1868
|
+
children: []
|
|
1869
|
+
};
|
|
1870
|
+
const parent = stack[stack.length - 1];
|
|
1871
|
+
if (parent.children) parent.children.push(node);
|
|
1872
|
+
if (!fullTag.endsWith("/>")) stack.push(node);
|
|
1873
|
+
}
|
|
1874
|
+
lastIndex = tagRegex.lastIndex;
|
|
1875
|
+
}
|
|
1876
|
+
return root;
|
|
1877
|
+
}
|
|
1878
|
+
/**
|
|
1879
|
+
* Parses XML attributes from a string.
|
|
1880
|
+
* @param attrString - Attribute string from XML tag
|
|
1881
|
+
* @returns Record of attribute name-value pairs
|
|
1882
|
+
*/
|
|
1883
|
+
function parseAttributes(attrString) {
|
|
1884
|
+
const attrs = {};
|
|
1885
|
+
const attrRegex = /([a-zA-Z0-9_-]+)="([^"]*)"/g;
|
|
1886
|
+
let match$1;
|
|
1887
|
+
while ((match$1 = attrRegex.exec(attrString)) !== null) attrs[match$1[1]] = match$1[2];
|
|
1888
|
+
return attrs;
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* Finds all nodes matching a tag name.
|
|
1892
|
+
* @param node - Root node to search
|
|
1893
|
+
* @param tagName - Tag name to match
|
|
1894
|
+
* @returns Array of matching nodes
|
|
1895
|
+
*/
|
|
1896
|
+
function findAll(node, tagName) {
|
|
1897
|
+
const results = [];
|
|
1898
|
+
if (node.type === "element" && node.name === tagName) results.push(node);
|
|
1899
|
+
if (node.children) for (const child of node.children) results.push(...findAll(child, tagName));
|
|
1900
|
+
return results;
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Finds the first node matching a tag name.
|
|
1904
|
+
* @param node - Root node to search
|
|
1905
|
+
* @param tagName - Tag name to match
|
|
1906
|
+
* @returns First matching node or undefined
|
|
1907
|
+
*/
|
|
1908
|
+
function findFirst(node, tagName) {
|
|
1909
|
+
if (node.type === "element" && node.name === tagName) return node;
|
|
1910
|
+
if (node.children) for (const child of node.children) {
|
|
1911
|
+
const found = findFirst(child, tagName);
|
|
1912
|
+
if (found) return found;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* Gets attribute value from a node.
|
|
1917
|
+
* @param node - XML node
|
|
1918
|
+
* @param attrName - Attribute name
|
|
1919
|
+
* @returns Attribute value or undefined
|
|
1920
|
+
*/
|
|
1921
|
+
function getAttribute(node, attrName) {
|
|
1922
|
+
return node.attributes?.[attrName];
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Gets text content from a node.
|
|
1926
|
+
* @param node - XML node
|
|
1927
|
+
* @returns Text content or empty string
|
|
1928
|
+
*/
|
|
1929
|
+
function getTextContent(node) {
|
|
1930
|
+
if (node.type === "text") return node.text || "";
|
|
1931
|
+
if (node.children) return node.children.map((child) => getTextContent(child)).join("");
|
|
1932
|
+
return "";
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
//#endregion
|
|
1936
|
+
//#region src/streaming/dash/parser.ts
|
|
1937
|
+
/**
|
|
1938
|
+
* Parses a DASH manifest XML string.
|
|
1939
|
+
* @param xml - DASH manifest XML
|
|
1940
|
+
* @returns Result containing parsed manifest or error
|
|
1941
|
+
*/
|
|
1942
|
+
function parseDashManifest(xml) {
|
|
1943
|
+
try {
|
|
1944
|
+
const mpd = findFirst(parseXml(xml), "MPD");
|
|
1945
|
+
if (!mpd) return err(createError("PARSE_ERROR", "Invalid DASH manifest: missing MPD element"));
|
|
1946
|
+
const duration = parseDuration(getAttribute(mpd, "mediaPresentationDuration"));
|
|
1947
|
+
const periods = findAll(mpd, "Period");
|
|
1948
|
+
const adaptationSets = [];
|
|
1949
|
+
for (const period of periods) {
|
|
1950
|
+
const sets = findAll(period, "AdaptationSet");
|
|
1951
|
+
for (const set of sets) {
|
|
1952
|
+
const parsed = parseAdaptationSet(set);
|
|
1953
|
+
if (parsed) adaptationSets.push(parsed);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
return ok({
|
|
1957
|
+
duration,
|
|
1958
|
+
adaptationSets
|
|
1959
|
+
});
|
|
1960
|
+
} catch (error) {
|
|
1961
|
+
return err(createError("PARSE_ERROR", "Failed to parse DASH manifest", error));
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
/**
|
|
1965
|
+
* Parses an AdaptationSet element.
|
|
1966
|
+
* @param node - AdaptationSet XML node
|
|
1967
|
+
* @returns Parsed adaptation set or undefined
|
|
1968
|
+
*/
|
|
1969
|
+
function parseAdaptationSet(node) {
|
|
1970
|
+
const id = getAttribute(node, "id") || "";
|
|
1971
|
+
const mimeType = getAttribute(node, "mimeType") || "";
|
|
1972
|
+
const contentType = mimeType.startsWith("video") ? "video" : "audio";
|
|
1973
|
+
const representations = findAll(node, "Representation");
|
|
1974
|
+
const parsedReps = [];
|
|
1975
|
+
for (const rep of representations) {
|
|
1976
|
+
const parsed = parseRepresentation(rep);
|
|
1977
|
+
if (parsed) parsedReps.push(parsed);
|
|
1978
|
+
}
|
|
1979
|
+
if (parsedReps.length === 0) return void 0;
|
|
1980
|
+
return {
|
|
1981
|
+
id,
|
|
1982
|
+
mimeType,
|
|
1983
|
+
contentType,
|
|
1984
|
+
representations: parsedReps
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
/**
|
|
1988
|
+
* Parses a Representation element.
|
|
1989
|
+
* @param node - Representation XML node
|
|
1990
|
+
* @returns Parsed representation or undefined
|
|
1991
|
+
*/
|
|
1992
|
+
function parseRepresentation(node) {
|
|
1993
|
+
const id = getAttribute(node, "id") || "";
|
|
1994
|
+
const bandwidthStr = getAttribute(node, "bandwidth");
|
|
1995
|
+
const codecs = getAttribute(node, "codecs") || "";
|
|
1996
|
+
if (!bandwidthStr) return void 0;
|
|
1997
|
+
const bandwidth = parseInt(bandwidthStr, 10);
|
|
1998
|
+
if (isNaN(bandwidth)) return void 0;
|
|
1999
|
+
const widthStr = getAttribute(node, "width");
|
|
2000
|
+
const heightStr = getAttribute(node, "height");
|
|
2001
|
+
const frameRateStr = getAttribute(node, "frameRate");
|
|
2002
|
+
const audioSampleRateStr = getAttribute(node, "audioSampleRate");
|
|
2003
|
+
const baseUrlNode = findFirst(node, "BaseURL");
|
|
2004
|
+
const baseUrl = baseUrlNode ? getTextContent(baseUrlNode) : "";
|
|
2005
|
+
const initRange = parseRange(findFirst(node, "Initialization"));
|
|
2006
|
+
const indexRange = parseRange(findFirst(node, "SegmentBase"));
|
|
2007
|
+
return {
|
|
2008
|
+
id,
|
|
2009
|
+
bandwidth,
|
|
2010
|
+
codecs,
|
|
2011
|
+
width: widthStr ? parseInt(widthStr, 10) : void 0,
|
|
2012
|
+
height: heightStr ? parseInt(heightStr, 10) : void 0,
|
|
2013
|
+
frameRate: frameRateStr ? parseFloat(frameRateStr) : void 0,
|
|
2014
|
+
audioSampleRate: audioSampleRateStr ? parseInt(audioSampleRateStr, 10) : void 0,
|
|
2015
|
+
baseUrl,
|
|
2016
|
+
initRange,
|
|
2017
|
+
indexRange
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Parses range attribute from a node.
|
|
2022
|
+
* @param node - XML node with range attribute
|
|
2023
|
+
* @returns Range object or undefined
|
|
2024
|
+
*/
|
|
2025
|
+
function parseRange(node) {
|
|
2026
|
+
if (!node) return void 0;
|
|
2027
|
+
const range = getAttribute(node, "range");
|
|
2028
|
+
if (!range) return void 0;
|
|
2029
|
+
const parts = range.split("-");
|
|
2030
|
+
if (parts.length !== 2) return void 0;
|
|
2031
|
+
const start = parseInt(parts[0], 10);
|
|
2032
|
+
const end = parseInt(parts[1], 10);
|
|
2033
|
+
if (isNaN(start) || isNaN(end)) return void 0;
|
|
2034
|
+
return {
|
|
2035
|
+
start,
|
|
2036
|
+
end
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
2039
|
+
/**
|
|
2040
|
+
* Parses ISO 8601 duration string.
|
|
2041
|
+
* @param duration - ISO 8601 duration string (e.g., "PT1H2M3S")
|
|
2042
|
+
* @returns Duration in seconds
|
|
2043
|
+
*/
|
|
2044
|
+
function parseDuration(duration) {
|
|
2045
|
+
if (!duration) return 0;
|
|
2046
|
+
const match$1 = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?/);
|
|
2047
|
+
if (!match$1) return 0;
|
|
2048
|
+
const hours = parseInt(match$1[1] || "0", 10);
|
|
2049
|
+
const minutes = parseInt(match$1[2] || "0", 10);
|
|
2050
|
+
const seconds = parseFloat(match$1[3] || "0");
|
|
2051
|
+
return hours * 3600 + minutes * 60 + seconds;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
//#endregion
|
|
2055
|
+
//#region src/streaming/hls/segments.ts
|
|
2056
|
+
/**
|
|
2057
|
+
* Resolves a potentially relative URL against a base URL.
|
|
2058
|
+
* @param baseUrl - Base URL from manifest location
|
|
2059
|
+
* @param path - Relative or absolute path
|
|
2060
|
+
* @returns Resolved absolute URL
|
|
2061
|
+
*/
|
|
2062
|
+
function resolveUrl(baseUrl, path) {
|
|
2063
|
+
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
|
2064
|
+
const url = new URL(baseUrl);
|
|
2065
|
+
if (path.startsWith("/")) return `${url.protocol}//${url.host}${path}`;
|
|
2066
|
+
const basePath = url.pathname.substring(0, url.pathname.lastIndexOf("/") + 1);
|
|
2067
|
+
return `${url.protocol}//${url.host}${basePath}${path}`;
|
|
2068
|
+
}
|
|
2069
|
+
/**
|
|
2070
|
+
* Resolves all variant URLs in a master playlist.
|
|
2071
|
+
* @param playlist - Parsed HLS master playlist
|
|
2072
|
+
* @param manifestUrl - Original manifest URL
|
|
2073
|
+
* @returns Playlist with resolved variant URLs
|
|
2074
|
+
*/
|
|
2075
|
+
function resolveVariantUrls(playlist, manifestUrl) {
|
|
2076
|
+
if (playlist.type !== "master" || !playlist.variants) return playlist;
|
|
2077
|
+
return {
|
|
2078
|
+
...playlist,
|
|
2079
|
+
variants: playlist.variants.map((variant) => ({
|
|
2080
|
+
...variant,
|
|
2081
|
+
url: resolveUrl(manifestUrl, variant.url)
|
|
2082
|
+
}))
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
//#endregion
|
|
2087
|
+
//#region src/streaming/dash/segments.ts
|
|
2088
|
+
/**
|
|
2089
|
+
* Extracts stream information from a DASH manifest.
|
|
2090
|
+
* @param manifest - Parsed DASH manifest
|
|
2091
|
+
* @returns Extracted stream information
|
|
2092
|
+
*/
|
|
2093
|
+
function extractDashStreams(manifest) {
|
|
2094
|
+
const videoStreams = [];
|
|
2095
|
+
const audioStreams = [];
|
|
2096
|
+
for (const adaptationSet of manifest.adaptationSets) {
|
|
2097
|
+
const streams = adaptationSet.contentType === "video" ? videoStreams : audioStreams;
|
|
2098
|
+
for (const rep of adaptationSet.representations) streams.push(representationToStreamInfo(rep, adaptationSet));
|
|
2099
|
+
}
|
|
2100
|
+
videoStreams.sort((a, b) => b.bandwidth - a.bandwidth);
|
|
2101
|
+
audioStreams.sort((a, b) => b.bandwidth - a.bandwidth);
|
|
2102
|
+
return {
|
|
2103
|
+
duration: manifest.duration,
|
|
2104
|
+
videoStreams,
|
|
2105
|
+
audioStreams
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Converts a DASH representation to stream info.
|
|
2110
|
+
* @param rep - DASH representation
|
|
2111
|
+
* @param adaptationSet - Parent adaptation set
|
|
2112
|
+
* @returns Stream info
|
|
2113
|
+
*/
|
|
2114
|
+
function representationToStreamInfo(rep, adaptationSet) {
|
|
2115
|
+
return {
|
|
2116
|
+
representationId: rep.id,
|
|
2117
|
+
bandwidth: rep.bandwidth,
|
|
2118
|
+
mimeType: adaptationSet.mimeType,
|
|
2119
|
+
codecs: rep.codecs,
|
|
2120
|
+
width: rep.width,
|
|
2121
|
+
height: rep.height,
|
|
2122
|
+
baseUrl: rep.baseUrl,
|
|
2123
|
+
initRange: rep.initRange,
|
|
2124
|
+
indexRange: rep.indexRange
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
//#endregion
|
|
2129
|
+
//#region src/streaming/processor.ts
|
|
2130
|
+
/**
|
|
2131
|
+
* Processes streaming data from InnerTube API responses.
|
|
2132
|
+
* Handles format parsing, deciphering, and categorization.
|
|
2133
|
+
*/
|
|
2134
|
+
var StreamingDataProcessor = class {
|
|
2135
|
+
decipher;
|
|
2136
|
+
constructor(player) {
|
|
2137
|
+
this.decipher = new FormatDecipher(player);
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Processes streaming data into a categorized FormatCollection.
|
|
2141
|
+
* @param streamingData - Raw streaming data from player response
|
|
2142
|
+
* @returns Categorized format collection with deciphered URLs
|
|
2143
|
+
*/
|
|
2144
|
+
async process(streamingData) {
|
|
2145
|
+
const rawFormats = streamingData.formats || [];
|
|
2146
|
+
const rawAdaptive = streamingData.adaptiveFormats || [];
|
|
2147
|
+
const decipheredFormats = this.decipher.decipherBatch(rawFormats);
|
|
2148
|
+
const decipheredAdaptive = this.decipher.decipherBatch(rawAdaptive);
|
|
2149
|
+
const formats = decipheredFormats.map((f) => parseFormat(f, false));
|
|
2150
|
+
const adaptiveFormats = decipheredAdaptive.map((f) => parseFormat(f, true));
|
|
2151
|
+
const categorized = categorizeFormats([...formats, ...adaptiveFormats]);
|
|
2152
|
+
const [hls, dash] = await Promise.all([this.extractHlsInfo(streamingData), this.extractDashInfo(streamingData)]);
|
|
2153
|
+
return {
|
|
2154
|
+
combined: categorized.combined,
|
|
2155
|
+
video: categorized.video,
|
|
2156
|
+
audio: categorized.audio,
|
|
2157
|
+
hls,
|
|
2158
|
+
dash
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
async extractHlsInfo(streamingData) {
|
|
2162
|
+
if (!streamingData.hlsManifestUrl) return void 0;
|
|
2163
|
+
const manifestUrl = streamingData.hlsManifestUrl;
|
|
2164
|
+
const result = await fetchHlsManifest(manifestUrl);
|
|
2165
|
+
if (!isOk(result)) return {
|
|
2166
|
+
manifestUrl,
|
|
2167
|
+
variants: []
|
|
2168
|
+
};
|
|
2169
|
+
const playlist = result.value;
|
|
2170
|
+
if (playlist.type !== "master" || !playlist.variants) return {
|
|
2171
|
+
manifestUrl,
|
|
2172
|
+
variants: []
|
|
2173
|
+
};
|
|
2174
|
+
return {
|
|
2175
|
+
manifestUrl,
|
|
2176
|
+
variants: (resolveVariantUrls(playlist, manifestUrl).variants || []).map((v) => ({
|
|
2177
|
+
url: v.url,
|
|
2178
|
+
bandwidth: v.bandwidth,
|
|
2179
|
+
resolution: v.resolution,
|
|
2180
|
+
codecs: v.codecs
|
|
2181
|
+
}))
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
async extractDashInfo(streamingData) {
|
|
2185
|
+
if (!streamingData.dashManifestUrl) return void 0;
|
|
2186
|
+
const manifestUrl = streamingData.dashManifestUrl;
|
|
2187
|
+
const response = await httpClient.get(manifestUrl);
|
|
2188
|
+
if (!isOk(response)) return {
|
|
2189
|
+
manifestUrl,
|
|
2190
|
+
duration: 0
|
|
2191
|
+
};
|
|
2192
|
+
try {
|
|
2193
|
+
const parseResult = parseDashManifest(await response.value.text());
|
|
2194
|
+
if (!isOk(parseResult)) return {
|
|
2195
|
+
manifestUrl,
|
|
2196
|
+
duration: 0
|
|
2197
|
+
};
|
|
2198
|
+
return {
|
|
2199
|
+
manifestUrl,
|
|
2200
|
+
duration: extractDashStreams(parseResult.value).duration
|
|
2201
|
+
};
|
|
2202
|
+
} catch {
|
|
2203
|
+
return {
|
|
2204
|
+
manifestUrl,
|
|
2205
|
+
duration: 0
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
};
|
|
2210
|
+
|
|
2211
|
+
//#endregion
|
|
2212
|
+
//#region src/core/extractor.ts
|
|
2213
|
+
const VIDEO_ID_PATTERNS = [
|
|
2214
|
+
/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/,
|
|
2215
|
+
/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/,
|
|
2216
|
+
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/,
|
|
2217
|
+
/youtube\.com\/live\/([a-zA-Z0-9_-]{11})/,
|
|
2218
|
+
/youtube\.com\/v\/([a-zA-Z0-9_-]{11})/,
|
|
2219
|
+
/youtube-nocookie\.com\/embed\/([a-zA-Z0-9_-]{11})/,
|
|
2220
|
+
/m\.youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/,
|
|
2221
|
+
/music\.youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/
|
|
2222
|
+
];
|
|
2223
|
+
/**
|
|
2224
|
+
* Main YouTube video extractor class.
|
|
2225
|
+
* Handles video information extraction using InnerTube API.
|
|
2226
|
+
*/
|
|
2227
|
+
var YouTubeExtractor = class {
|
|
2228
|
+
client;
|
|
2229
|
+
player;
|
|
2230
|
+
config;
|
|
2231
|
+
constructor(config = {}) {
|
|
2232
|
+
this.config = config;
|
|
2233
|
+
this.client = new InnerTubeClient({ initialClient: config.preferredClient });
|
|
2234
|
+
this.player = new Player(config.cacheDir);
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* Extracts video information from a YouTube URL or video ID.
|
|
2238
|
+
* @param url - YouTube URL or video ID
|
|
2239
|
+
* @returns Result containing VideoInfo or BundlpError
|
|
2240
|
+
*/
|
|
2241
|
+
async extract(url) {
|
|
2242
|
+
const videoIdResult = this.parseVideoId(url);
|
|
2243
|
+
if (isErr(videoIdResult)) return videoIdResult;
|
|
2244
|
+
return withRetry(() => this.realExtract(videoIdResult.value), {
|
|
2245
|
+
retries: RETRY_DEFAULTS.RETRIES,
|
|
2246
|
+
delay: RETRY_DEFAULTS.DELAY,
|
|
2247
|
+
backoff: RETRY_DEFAULTS.BACKOFF
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
parseVideoId(url) {
|
|
2251
|
+
for (const pattern of VIDEO_ID_PATTERNS) {
|
|
2252
|
+
const match$1 = url.match(pattern);
|
|
2253
|
+
if (match$1) return ok(match$1[1]);
|
|
2254
|
+
}
|
|
2255
|
+
if ((/* @__PURE__ */ new RegExp(`^[a-zA-Z0-9_-]{${VIDEO_ID_LENGTH}}$`)).test(url)) return ok(url);
|
|
2256
|
+
return err(createError("INVALID_URL", `Could not extract video ID from: ${url}`));
|
|
2257
|
+
}
|
|
2258
|
+
async realExtract(videoId) {
|
|
2259
|
+
const playerResult = await this.player.initialize();
|
|
2260
|
+
if (isErr(playerResult)) return err(playerResult.error);
|
|
2261
|
+
const responseResult = await this.client.fetchPlayerWithFallback(videoId, {
|
|
2262
|
+
signatureTimestamp: this.player.getSignatureTimestamp(),
|
|
2263
|
+
poToken: this.config.poToken
|
|
2264
|
+
});
|
|
2265
|
+
if (isErr(responseResult)) return err(responseResult.error);
|
|
2266
|
+
const playerResponse = responseResult.value;
|
|
2267
|
+
if (playerResponse.playabilityStatus.status === "ERROR") return err(createError("VIDEO_UNAVAILABLE", playerResponse.playabilityStatus.reason || "Video unavailable"));
|
|
2268
|
+
if (!playerResponse.streamingData) return err(createError("VIDEO_UNAVAILABLE", "No streaming data found"));
|
|
2269
|
+
const formats = await this.processFormats(playerResponse.streamingData);
|
|
2270
|
+
return ok(this.assembleVideoInfo(videoId, playerResponse, formats));
|
|
2271
|
+
}
|
|
2272
|
+
async processFormats(streamingData) {
|
|
2273
|
+
return new StreamingDataProcessor(this.player).process(streamingData);
|
|
2274
|
+
}
|
|
2275
|
+
assembleVideoInfo(videoId, playerResponse, formats) {
|
|
2276
|
+
const details = playerResponse.videoDetails;
|
|
2277
|
+
const microformat = playerResponse.microformat?.playerMicroformatRenderer;
|
|
2278
|
+
return {
|
|
2279
|
+
id: videoId,
|
|
2280
|
+
title: details.title,
|
|
2281
|
+
description: details.shortDescription,
|
|
2282
|
+
duration: parseInt(details.lengthSeconds, 10),
|
|
2283
|
+
uploadDate: microformat?.uploadDate,
|
|
2284
|
+
channel: {
|
|
2285
|
+
id: details.channelId,
|
|
2286
|
+
name: details.author,
|
|
2287
|
+
url: `${YOUTUBE_BASE_URL}/channel/${details.channelId}`
|
|
2288
|
+
},
|
|
2289
|
+
viewCount: parseInt(details.viewCount, 10),
|
|
2290
|
+
thumbnails: details.thumbnail.thumbnails,
|
|
2291
|
+
formats,
|
|
2292
|
+
subtitles: /* @__PURE__ */ new Map(),
|
|
2293
|
+
isLive: details.isLiveContent,
|
|
2294
|
+
isPrivate: details.isPrivate
|
|
2295
|
+
};
|
|
2296
|
+
}
|
|
2297
|
+
};
|
|
2298
|
+
|
|
2299
|
+
//#endregion
|
|
2300
|
+
//#region src/utils/headers.ts
|
|
2301
|
+
/**
|
|
2302
|
+
* HTTP Headers Utilities for YouTube Downloads
|
|
2303
|
+
*
|
|
2304
|
+
* Provides headers needed for downloading from deciphered YouTube URLs.
|
|
2305
|
+
* These headers are extracted from bundlp's InnerTube client configurations.
|
|
2306
|
+
*/
|
|
2307
|
+
/**
|
|
2308
|
+
* Get HTTP headers for downloading from deciphered YouTube URLs
|
|
2309
|
+
* Based on the client type used for extraction
|
|
2310
|
+
*
|
|
2311
|
+
* @param clientType - The client type to use (WEB, ANDROID, TV, IOS)
|
|
2312
|
+
* @returns Object with required HTTP headers for YouTube CDN requests
|
|
2313
|
+
*/
|
|
2314
|
+
function getDownloadHeaders(clientType = "ANDROID") {
|
|
2315
|
+
const client = INNERTUBE_CLIENTS[clientType];
|
|
2316
|
+
return {
|
|
2317
|
+
"User-Agent": client.HEADERS["User-Agent"],
|
|
2318
|
+
"Accept": "*/*",
|
|
2319
|
+
"Accept-Encoding": "identity",
|
|
2320
|
+
"Referer": "https://www.youtube.com/",
|
|
2321
|
+
"Origin": "https://www.youtube.com",
|
|
2322
|
+
"Sec-Fetch-Mode": "no-cors"
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
/**
|
|
2326
|
+
* Get minimal headers that work for most YouTube CDN requests
|
|
2327
|
+
* Use this as a fallback if full headers cause issues
|
|
2328
|
+
*
|
|
2329
|
+
* @returns Minimal set of headers needed for YouTube downloads
|
|
2330
|
+
*/
|
|
2331
|
+
function getMinimalDownloadHeaders() {
|
|
2332
|
+
return {
|
|
2333
|
+
"User-Agent": "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
|
2334
|
+
"Referer": "https://www.youtube.com/"
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
//#endregion
|
|
2339
|
+
//#region src/po-token/policies.ts
|
|
2340
|
+
/**
|
|
2341
|
+
* PO Token policies for each InnerTube client.
|
|
2342
|
+
* Defines when PO tokens are required for different endpoints.
|
|
2343
|
+
*/
|
|
2344
|
+
const PO_TOKEN_POLICIES = {
|
|
2345
|
+
WEB: {
|
|
2346
|
+
GVS: {
|
|
2347
|
+
HTTPS: "required",
|
|
2348
|
+
DASH: "required",
|
|
2349
|
+
HLS: "recommended"
|
|
2350
|
+
},
|
|
2351
|
+
PLAYER: "not_required",
|
|
2352
|
+
SUBS: "not_required",
|
|
2353
|
+
notRequiredForPremium: true
|
|
2354
|
+
},
|
|
2355
|
+
ANDROID_SDKLESS: {
|
|
2356
|
+
GVS: "not_required",
|
|
2357
|
+
PLAYER: "not_required",
|
|
2358
|
+
SUBS: "not_required"
|
|
2359
|
+
},
|
|
2360
|
+
ANDROID: {
|
|
2361
|
+
GVS: "not_required",
|
|
2362
|
+
PLAYER: "not_required",
|
|
2363
|
+
SUBS: "not_required"
|
|
2364
|
+
},
|
|
2365
|
+
TV: {
|
|
2366
|
+
GVS: "not_required",
|
|
2367
|
+
PLAYER: "not_required",
|
|
2368
|
+
SUBS: "not_required"
|
|
2369
|
+
},
|
|
2370
|
+
TV_EMBEDDED: {
|
|
2371
|
+
GVS: "not_required",
|
|
2372
|
+
PLAYER: "not_required",
|
|
2373
|
+
SUBS: "not_required"
|
|
2374
|
+
},
|
|
2375
|
+
IOS: {
|
|
2376
|
+
GVS: {
|
|
2377
|
+
HTTPS: "required",
|
|
2378
|
+
DASH: "required",
|
|
2379
|
+
HLS: "recommended"
|
|
2380
|
+
},
|
|
2381
|
+
PLAYER: "recommended",
|
|
2382
|
+
SUBS: "not_required"
|
|
2383
|
+
}
|
|
2384
|
+
};
|
|
2385
|
+
|
|
2386
|
+
//#endregion
|
|
2387
|
+
//#region src/po-token/botguard/challenge.ts
|
|
2388
|
+
const WAA_CREATE_ENDPOINT = "https://jnn-pa.googleapis.com/$rpc/google.internal.waa.v1.Waa/Create";
|
|
2389
|
+
const INNERTUBE_ATT_ENDPOINT = "https://www.youtube.com/youtubei/v1/att/get";
|
|
2390
|
+
const GOOG_API_KEY$1 = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw";
|
|
2391
|
+
const REQUEST_KEY$1 = "O43z0dpjhgX20SCx4KAo";
|
|
2392
|
+
var ChallengeFetcher = class {
|
|
2393
|
+
timeout;
|
|
2394
|
+
constructor(options = {}) {
|
|
2395
|
+
this.timeout = options.timeout ?? 15e3;
|
|
2396
|
+
}
|
|
2397
|
+
async fetchAttestation() {
|
|
2398
|
+
const payload = { context: { client: {
|
|
2399
|
+
clientName: "WEB",
|
|
2400
|
+
clientVersion: "2.20250222.10.00",
|
|
2401
|
+
hl: "en",
|
|
2402
|
+
gl: "US"
|
|
2403
|
+
} } };
|
|
2404
|
+
try {
|
|
2405
|
+
const response = await fetch(INNERTUBE_ATT_ENDPOINT, {
|
|
2406
|
+
method: "POST",
|
|
2407
|
+
headers: {
|
|
2408
|
+
"Content-Type": "application/json",
|
|
2409
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
2410
|
+
"Origin": "https://www.youtube.com",
|
|
2411
|
+
"Referer": "https://www.youtube.com/"
|
|
2412
|
+
},
|
|
2413
|
+
body: JSON.stringify(payload),
|
|
2414
|
+
signal: AbortSignal.timeout(this.timeout)
|
|
2415
|
+
});
|
|
2416
|
+
if (!response.ok) return err(createError("BOTGUARD_INIT_FAILED", `Attestation request failed with status ${response.status}`));
|
|
2417
|
+
const data = await response.json();
|
|
2418
|
+
return this.parseAttestationResponse(data);
|
|
2419
|
+
} catch (error) {
|
|
2420
|
+
if (error instanceof Error && error.name === "TimeoutError") return err(createError("BOTGUARD_INIT_FAILED", "Attestation fetch timeout"));
|
|
2421
|
+
return err(createError("BOTGUARD_INIT_FAILED", "Failed to fetch attestation", error));
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
parseAttestationResponse(data) {
|
|
2425
|
+
if (!data || typeof data !== "object") return err(createError("PARSE_ERROR", "Invalid attestation response"));
|
|
2426
|
+
const response = data;
|
|
2427
|
+
const responseContext = response.responseContext;
|
|
2428
|
+
const botguardData = response.botguardData;
|
|
2429
|
+
const challenge = response.challenge;
|
|
2430
|
+
if (!responseContext?.visitorData) return err(createError("PARSE_ERROR", "Missing visitorData in response"));
|
|
2431
|
+
if (!challenge || typeof challenge !== "string") return err(createError("PARSE_ERROR", "Missing challenge in response"));
|
|
2432
|
+
if (!botguardData?.program) return err(createError("PARSE_ERROR", "Missing botguardData.program in response"));
|
|
2433
|
+
return ok({
|
|
2434
|
+
visitorData: responseContext.visitorData,
|
|
2435
|
+
challenge,
|
|
2436
|
+
program: botguardData.program
|
|
2437
|
+
});
|
|
2438
|
+
}
|
|
2439
|
+
async fetchWaaChallenge() {
|
|
2440
|
+
try {
|
|
2441
|
+
const response = await fetch(WAA_CREATE_ENDPOINT, {
|
|
2442
|
+
method: "POST",
|
|
2443
|
+
headers: {
|
|
2444
|
+
"Content-Type": "application/json+protobuf",
|
|
2445
|
+
"x-goog-api-key": GOOG_API_KEY$1
|
|
2446
|
+
},
|
|
2447
|
+
body: JSON.stringify([REQUEST_KEY$1]),
|
|
2448
|
+
signal: AbortSignal.timeout(this.timeout)
|
|
2449
|
+
});
|
|
2450
|
+
if (!response.ok) return err(createError("BOTGUARD_INIT_FAILED", `WAA Create request failed with status ${response.status}`));
|
|
2451
|
+
const encoded = (await response.json())[1];
|
|
2452
|
+
if (!encoded || typeof encoded !== "string") return err(createError("PARSE_ERROR", "Invalid WAA response format"));
|
|
2453
|
+
return this.descrambleWaaResponse(encoded);
|
|
2454
|
+
} catch (error) {
|
|
2455
|
+
if (error instanceof Error && error.name === "TimeoutError") return err(createError("BOTGUARD_INIT_FAILED", "WAA Create fetch timeout"));
|
|
2456
|
+
return err(createError("BOTGUARD_INIT_FAILED", "Failed to fetch WAA challenge", error));
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
descrambleWaaResponse(encoded) {
|
|
2460
|
+
try {
|
|
2461
|
+
const decoded = atob(encoded);
|
|
2462
|
+
const bytes = new Uint8Array(decoded.length);
|
|
2463
|
+
for (let i = 0; i < decoded.length; i++) bytes[i] = decoded.charCodeAt(i) + 97 & 255;
|
|
2464
|
+
const descrambled = new TextDecoder("utf-8").decode(bytes);
|
|
2465
|
+
const parsed = JSON.parse(descrambled);
|
|
2466
|
+
const messageId = parsed[0];
|
|
2467
|
+
const interpreterArray = parsed[1];
|
|
2468
|
+
const interpreterHash = parsed[3];
|
|
2469
|
+
const program = parsed[4];
|
|
2470
|
+
const globalName = parsed[5];
|
|
2471
|
+
if (!program || !globalName) return err(createError("PARSE_ERROR", "Missing required fields in WAA response"));
|
|
2472
|
+
let interpreterScript = "";
|
|
2473
|
+
if (Array.isArray(interpreterArray) && interpreterArray.length >= 6) interpreterScript = interpreterArray[5];
|
|
2474
|
+
if (!interpreterScript || typeof interpreterScript !== "string") return err(createError("PARSE_ERROR", "Failed to extract interpreter script"));
|
|
2475
|
+
return ok({
|
|
2476
|
+
messageId: messageId || "",
|
|
2477
|
+
interpreterScript,
|
|
2478
|
+
interpreterHash: interpreterHash || "",
|
|
2479
|
+
program,
|
|
2480
|
+
globalName
|
|
2481
|
+
});
|
|
2482
|
+
} catch (error) {
|
|
2483
|
+
return err(createError("PARSE_ERROR", "Failed to descramble WAA response", error));
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
async fetchFullChallenge() {
|
|
2487
|
+
return this.fetchWaaChallenge();
|
|
2488
|
+
}
|
|
2489
|
+
async fetch() {
|
|
2490
|
+
return this.fetchAttestation();
|
|
2491
|
+
}
|
|
2492
|
+
};
|
|
2493
|
+
|
|
2494
|
+
//#endregion
|
|
2495
|
+
//#region src/po-token/botguard/client.ts
|
|
2496
|
+
var DeferredPromise = class {
|
|
2497
|
+
promise;
|
|
2498
|
+
resolve;
|
|
2499
|
+
reject;
|
|
2500
|
+
constructor() {
|
|
2501
|
+
this.promise = new Promise((resolve, reject) => {
|
|
2502
|
+
this.resolve = resolve;
|
|
2503
|
+
this.reject = reject;
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
};
|
|
2507
|
+
var BotGuardClient = class BotGuardClient {
|
|
2508
|
+
dom = null;
|
|
2509
|
+
vm = null;
|
|
2510
|
+
program;
|
|
2511
|
+
globalName;
|
|
2512
|
+
deferredVmFunctions = new DeferredPromise();
|
|
2513
|
+
syncSnapshotFunction;
|
|
2514
|
+
initTimeout;
|
|
2515
|
+
snapshotTimeout;
|
|
2516
|
+
constructor(challenge, options = {}) {
|
|
2517
|
+
this.program = challenge.program;
|
|
2518
|
+
this.globalName = challenge.globalName;
|
|
2519
|
+
this.initTimeout = options.timeout ?? 1e4;
|
|
2520
|
+
this.snapshotTimeout = 5e3;
|
|
2521
|
+
}
|
|
2522
|
+
static async create(challenge, options = {}) {
|
|
2523
|
+
const client = new BotGuardClient(challenge, options);
|
|
2524
|
+
const loadResult = await client.load(challenge);
|
|
2525
|
+
if (!isOk(loadResult)) return loadResult;
|
|
2526
|
+
return ok(client);
|
|
2527
|
+
}
|
|
2528
|
+
async load(challenge) {
|
|
2529
|
+
try {
|
|
2530
|
+
this.dom = new JSDOM("<!DOCTYPE html><html><head></head><body></body></html>", {
|
|
2531
|
+
url: "https://www.youtube.com/",
|
|
2532
|
+
runScripts: "dangerously",
|
|
2533
|
+
pretendToBeVisual: true
|
|
2534
|
+
});
|
|
2535
|
+
const window = this.dom.window;
|
|
2536
|
+
window.trustedTypes = { createPolicy: (name, rules) => ({
|
|
2537
|
+
name,
|
|
2538
|
+
createHTML: rules?.createHTML || ((s) => s),
|
|
2539
|
+
createScript: rules?.createScript || ((s) => s),
|
|
2540
|
+
createScriptURL: rules?.createScriptURL || ((s) => s)
|
|
2541
|
+
}) };
|
|
2542
|
+
window.fetch = globalThis.fetch;
|
|
2543
|
+
window.Request = globalThis.Request;
|
|
2544
|
+
window.Response = globalThis.Response;
|
|
2545
|
+
window.Headers = globalThis.Headers;
|
|
2546
|
+
const evalFn = window.eval;
|
|
2547
|
+
evalFn(challenge.interpreterScript);
|
|
2548
|
+
this.vm = window[this.globalName];
|
|
2549
|
+
if (!this.vm) return err(createError("BOTGUARD_INIT_FAILED", `VM not found at globalName: ${this.globalName}`));
|
|
2550
|
+
if (typeof this.vm.a !== "function") return err(createError("BOTGUARD_INIT_FAILED", "VM init function (a) not found"));
|
|
2551
|
+
const loadResult = await this.loadProgram();
|
|
2552
|
+
if (!isOk(loadResult)) return loadResult;
|
|
2553
|
+
return ok(void 0);
|
|
2554
|
+
} catch (error) {
|
|
2555
|
+
return err(createError("BOTGUARD_INIT_FAILED", `Failed to load BotGuard: ${error.message}`, error));
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
async loadProgram() {
|
|
2559
|
+
return new Promise((resolve) => {
|
|
2560
|
+
const timeoutId = setTimeout(() => {
|
|
2561
|
+
resolve(err(createError("BOTGUARD_INIT_FAILED", "Program load timeout")));
|
|
2562
|
+
}, this.initTimeout);
|
|
2563
|
+
try {
|
|
2564
|
+
const vmFunctionsCallback = (asyncSnapshotFunction, shutdownFunction, passEventFunction, checkCameraFunction) => {
|
|
2565
|
+
this.deferredVmFunctions.resolve({
|
|
2566
|
+
asyncSnapshotFunction,
|
|
2567
|
+
shutdownFunction,
|
|
2568
|
+
passEventFunction,
|
|
2569
|
+
checkCameraFunction
|
|
2570
|
+
});
|
|
2571
|
+
};
|
|
2572
|
+
const initFn = this.vm.a;
|
|
2573
|
+
const result = initFn(this.program, vmFunctionsCallback, true, void 0, () => {}, [[], []]);
|
|
2574
|
+
clearTimeout(timeoutId);
|
|
2575
|
+
if (Array.isArray(result) && result[0]) this.syncSnapshotFunction = result[0];
|
|
2576
|
+
resolve(ok(void 0));
|
|
2577
|
+
} catch (error) {
|
|
2578
|
+
clearTimeout(timeoutId);
|
|
2579
|
+
resolve(err(createError("BOTGUARD_INIT_FAILED", `Program load error: ${error.message}`, error)));
|
|
2580
|
+
}
|
|
2581
|
+
});
|
|
2582
|
+
}
|
|
2583
|
+
async snapshot(binding) {
|
|
2584
|
+
const snapshotArgs = [
|
|
2585
|
+
binding,
|
|
2586
|
+
Date.now(),
|
|
2587
|
+
[],
|
|
2588
|
+
false
|
|
2589
|
+
];
|
|
2590
|
+
try {
|
|
2591
|
+
let result;
|
|
2592
|
+
if (this.syncSnapshotFunction) result = await this.syncSnapshotFunction(snapshotArgs);
|
|
2593
|
+
else {
|
|
2594
|
+
const vmFunctions = await Promise.race([this.deferredVmFunctions.promise, new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("Timeout waiting for VM functions")), this.snapshotTimeout))]);
|
|
2595
|
+
if (!vmFunctions.asyncSnapshotFunction) return err(createError("BOTGUARD_SNAPSHOT_FAILED", "No snapshot function available"));
|
|
2596
|
+
result = await new Promise((resolve, reject) => {
|
|
2597
|
+
const timeout = setTimeout(() => reject(/* @__PURE__ */ new Error("Snapshot timeout")), this.snapshotTimeout);
|
|
2598
|
+
vmFunctions.asyncSnapshotFunction((response) => {
|
|
2599
|
+
clearTimeout(timeout);
|
|
2600
|
+
resolve(response);
|
|
2601
|
+
}, snapshotArgs);
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
return ok(result);
|
|
2605
|
+
} catch (error) {
|
|
2606
|
+
return err(createError("BOTGUARD_SNAPSHOT_FAILED", `Snapshot failed: ${error.message}`, error));
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
async snapshotWithSignalOutput(binding) {
|
|
2610
|
+
const webPoSignalOutput = [];
|
|
2611
|
+
const snapshotArgs = [
|
|
2612
|
+
binding,
|
|
2613
|
+
Date.now(),
|
|
2614
|
+
webPoSignalOutput,
|
|
2615
|
+
false
|
|
2616
|
+
];
|
|
2617
|
+
try {
|
|
2618
|
+
let snapshot;
|
|
2619
|
+
if (this.syncSnapshotFunction) snapshot = await this.syncSnapshotFunction(snapshotArgs);
|
|
2620
|
+
else {
|
|
2621
|
+
const vmFunctions = await Promise.race([this.deferredVmFunctions.promise, new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("Timeout waiting for VM functions")), this.snapshotTimeout))]);
|
|
2622
|
+
if (!vmFunctions.asyncSnapshotFunction) return err(createError("BOTGUARD_SNAPSHOT_FAILED", "No snapshot function available"));
|
|
2623
|
+
snapshot = await new Promise((resolve, reject) => {
|
|
2624
|
+
const timeout = setTimeout(() => reject(/* @__PURE__ */ new Error("Snapshot timeout")), this.snapshotTimeout);
|
|
2625
|
+
vmFunctions.asyncSnapshotFunction((response) => {
|
|
2626
|
+
clearTimeout(timeout);
|
|
2627
|
+
resolve(response);
|
|
2628
|
+
}, snapshotArgs);
|
|
2629
|
+
});
|
|
2630
|
+
}
|
|
2631
|
+
return ok({
|
|
2632
|
+
snapshot,
|
|
2633
|
+
webPoSignalOutput
|
|
2634
|
+
});
|
|
2635
|
+
} catch (error) {
|
|
2636
|
+
return err(createError("BOTGUARD_SNAPSHOT_FAILED", `Snapshot failed: ${error.message}`, error));
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
shutdown() {
|
|
2640
|
+
this.deferredVmFunctions.promise.then((vmFunctions) => {
|
|
2641
|
+
if (vmFunctions.shutdownFunction) try {
|
|
2642
|
+
vmFunctions.shutdownFunction();
|
|
2643
|
+
} catch {}
|
|
2644
|
+
}).catch(() => {});
|
|
2645
|
+
if (this.dom) {
|
|
2646
|
+
this.dom.window.close();
|
|
2647
|
+
this.dom = null;
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
};
|
|
2651
|
+
function generateColdStartToken(identifier, clientState = 1) {
|
|
2652
|
+
const encodedIdentifier = new TextEncoder().encode(identifier);
|
|
2653
|
+
if (encodedIdentifier.length > 118) throw new Error("Content binding is too long");
|
|
2654
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
2655
|
+
const randomKeys = [Math.floor(Math.random() * 256), Math.floor(Math.random() * 256)];
|
|
2656
|
+
const header = new Uint8Array([
|
|
2657
|
+
randomKeys[0],
|
|
2658
|
+
randomKeys[1],
|
|
2659
|
+
0,
|
|
2660
|
+
clientState,
|
|
2661
|
+
timestamp >> 24 & 255,
|
|
2662
|
+
timestamp >> 16 & 255,
|
|
2663
|
+
timestamp >> 8 & 255,
|
|
2664
|
+
timestamp & 255
|
|
2665
|
+
]);
|
|
2666
|
+
const packet = new Uint8Array(2 + header.length + encodedIdentifier.length);
|
|
2667
|
+
packet[0] = 34;
|
|
2668
|
+
packet[1] = header.length + encodedIdentifier.length;
|
|
2669
|
+
packet.set(header, 2);
|
|
2670
|
+
packet.set(encodedIdentifier, 2 + header.length);
|
|
2671
|
+
const payload = packet.subarray(2);
|
|
2672
|
+
const keyLength = randomKeys.length;
|
|
2673
|
+
for (let i = keyLength; i < payload.length; i++) payload[i] ^= payload[i % keyLength];
|
|
2674
|
+
return u8ToBase64Url$1(packet);
|
|
2675
|
+
}
|
|
2676
|
+
function u8ToBase64Url$1(u8) {
|
|
2677
|
+
return btoa(String.fromCharCode(...u8)).replace(/\+/g, "-").replace(/\//g, "_");
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
//#endregion
|
|
2681
|
+
//#region src/po-token/minter/web-minter.ts
|
|
2682
|
+
const WAA_GENERATE_IT_ENDPOINT = "https://jnn-pa.googleapis.com/$rpc/google.internal.waa.v1.Waa/GenerateIT";
|
|
2683
|
+
const GOOG_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw";
|
|
2684
|
+
const REQUEST_KEY = "O43z0dpjhgX20SCx4KAo";
|
|
2685
|
+
var WebPoMinter = class WebPoMinter {
|
|
2686
|
+
mintCallback;
|
|
2687
|
+
constructor(mintCallback) {
|
|
2688
|
+
this.mintCallback = mintCallback;
|
|
2689
|
+
}
|
|
2690
|
+
static async create(integrityTokenData, webPoSignalOutput) {
|
|
2691
|
+
const getMinter = webPoSignalOutput[0];
|
|
2692
|
+
if (!getMinter || typeof getMinter !== "function") return err(createError("INTEGRITY_TOKEN_FAILED", "Minter function not found in webPoSignalOutput"));
|
|
2693
|
+
if (!integrityTokenData.integrityToken) return err(createError("INTEGRITY_TOKEN_FAILED", "No integrity token provided"));
|
|
2694
|
+
try {
|
|
2695
|
+
const mintCallback = await getMinter(base64ToU8(integrityTokenData.integrityToken));
|
|
2696
|
+
if (typeof mintCallback !== "function") return err(createError("INTEGRITY_TOKEN_FAILED", "Invalid mint callback returned"));
|
|
2697
|
+
return ok(new WebPoMinter(mintCallback));
|
|
2698
|
+
} catch (error) {
|
|
2699
|
+
return err(createError("INTEGRITY_TOKEN_FAILED", `Failed to create minter: ${error.message}`, error));
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
async mint(identifier) {
|
|
2703
|
+
try {
|
|
2704
|
+
const identifierBytes = new TextEncoder().encode(identifier);
|
|
2705
|
+
const result = await this.mintCallback(identifierBytes);
|
|
2706
|
+
if (!result) return err(createError("INTEGRITY_TOKEN_FAILED", "Mint returned undefined"));
|
|
2707
|
+
if (result instanceof Uint8Array) return ok(result);
|
|
2708
|
+
if (ArrayBuffer.isView(result)) return ok(new Uint8Array(result.buffer));
|
|
2709
|
+
if (Array.isArray(result)) return ok(new Uint8Array(result));
|
|
2710
|
+
return err(createError("INTEGRITY_TOKEN_FAILED", `Mint returned invalid type: ${typeof result}`));
|
|
2711
|
+
} catch (error) {
|
|
2712
|
+
return err(createError("INTEGRITY_TOKEN_FAILED", `Mint failed: ${error.message}`, error));
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
async mintAsWebsafeString(identifier) {
|
|
2716
|
+
const mintResult = await this.mint(identifier);
|
|
2717
|
+
if (!isOk(mintResult)) return mintResult;
|
|
2718
|
+
return ok(u8ToBase64Url(mintResult.value));
|
|
2719
|
+
}
|
|
2720
|
+
};
|
|
2721
|
+
async function fetchIntegrityToken(botguardResponse, options = {}) {
|
|
2722
|
+
const timeout = options.timeout ?? 1e4;
|
|
2723
|
+
try {
|
|
2724
|
+
const response = await fetch(WAA_GENERATE_IT_ENDPOINT, {
|
|
2725
|
+
method: "POST",
|
|
2726
|
+
headers: {
|
|
2727
|
+
"Content-Type": "application/json+protobuf",
|
|
2728
|
+
"x-goog-api-key": GOOG_API_KEY
|
|
2729
|
+
},
|
|
2730
|
+
body: JSON.stringify([REQUEST_KEY, botguardResponse]),
|
|
2731
|
+
signal: AbortSignal.timeout(timeout)
|
|
2732
|
+
});
|
|
2733
|
+
if (!response.ok) return err(createError("INTEGRITY_TOKEN_FAILED", `GenerateIT request failed with status ${response.status}`));
|
|
2734
|
+
const json = await response.json();
|
|
2735
|
+
const integrityToken = json[0];
|
|
2736
|
+
const estimatedTtlSecs = json[1];
|
|
2737
|
+
const mintRefreshThreshold = json[2];
|
|
2738
|
+
const websafeFallbackToken = json[3];
|
|
2739
|
+
if (!integrityToken) return err(createError("INTEGRITY_TOKEN_FAILED", "No integrity token in response"));
|
|
2740
|
+
return ok({
|
|
2741
|
+
integrityToken,
|
|
2742
|
+
estimatedTtlSecs: estimatedTtlSecs ?? 3600,
|
|
2743
|
+
mintRefreshThreshold: mintRefreshThreshold ?? 300,
|
|
2744
|
+
websafeFallbackToken
|
|
2745
|
+
});
|
|
2746
|
+
} catch (error) {
|
|
2747
|
+
if (error instanceof Error && error.name === "TimeoutError") return err(createError("INTEGRITY_TOKEN_FAILED", "GenerateIT request timeout"));
|
|
2748
|
+
return err(createError("INTEGRITY_TOKEN_FAILED", "Failed to fetch integrity token", error));
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
function base64ToU8(base64) {
|
|
2752
|
+
const base64Normalized = base64.replace(/-/g, "+").replace(/_/g, "/").replace(/\./g, "=");
|
|
2753
|
+
const binary = atob(base64Normalized);
|
|
2754
|
+
const bytes = new Uint8Array(binary.length);
|
|
2755
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
2756
|
+
return bytes;
|
|
2757
|
+
}
|
|
2758
|
+
function u8ToBase64Url(u8) {
|
|
2759
|
+
return btoa(String.fromCharCode(...u8)).replace(/\+/g, "-").replace(/\//g, "_");
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
//#endregion
|
|
2763
|
+
//#region src/po-token/providers/local.provider.ts
|
|
2764
|
+
const REFRESH_MARGIN_MS = 600 * 1e3;
|
|
2765
|
+
var LocalBotGuardProvider = class {
|
|
2766
|
+
name = "local-botguard";
|
|
2767
|
+
priority = 100;
|
|
2768
|
+
options;
|
|
2769
|
+
challengeFetcher;
|
|
2770
|
+
client = null;
|
|
2771
|
+
challenge = null;
|
|
2772
|
+
attestation = null;
|
|
2773
|
+
integrityData = null;
|
|
2774
|
+
minter = null;
|
|
2775
|
+
initPromise = null;
|
|
2776
|
+
createdAt = 0;
|
|
2777
|
+
constructor(options = {}) {
|
|
2778
|
+
this.options = options;
|
|
2779
|
+
this.challengeFetcher = new ChallengeFetcher({ timeout: options.timeout });
|
|
2780
|
+
}
|
|
2781
|
+
async isAvailable() {
|
|
2782
|
+
try {
|
|
2783
|
+
await import("jsdom");
|
|
2784
|
+
return true;
|
|
2785
|
+
} catch {
|
|
2786
|
+
return false;
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
async generate(identifier, _binding) {
|
|
2790
|
+
const initResult = await this.ensureInitialized();
|
|
2791
|
+
if (!isOk(initResult)) return initResult;
|
|
2792
|
+
if (!this.minter || !this.integrityData) return err(createError("BOTGUARD_INIT_FAILED", "Minter not initialized"));
|
|
2793
|
+
const tokenResult = await this.minter.mintAsWebsafeString(identifier);
|
|
2794
|
+
if (!isOk(tokenResult)) return tokenResult;
|
|
2795
|
+
const now = Date.now();
|
|
2796
|
+
const ttlMs = (this.integrityData.estimatedTtlSecs ?? 43200) * 1e3;
|
|
2797
|
+
return ok({
|
|
2798
|
+
token: tokenResult.value,
|
|
2799
|
+
createdAt: now,
|
|
2800
|
+
expiresAt: now + ttlMs,
|
|
2801
|
+
refreshAt: now + ttlMs - REFRESH_MARGIN_MS,
|
|
2802
|
+
bindingType: "session",
|
|
2803
|
+
bindingValue: identifier,
|
|
2804
|
+
client: "WEB",
|
|
2805
|
+
context: "GVS"
|
|
2806
|
+
});
|
|
2807
|
+
}
|
|
2808
|
+
async ensureInitialized() {
|
|
2809
|
+
if (this.minter && this.integrityData) {
|
|
2810
|
+
const now = Date.now();
|
|
2811
|
+
const refreshThresholdMs = (this.integrityData.mintRefreshThreshold ?? 100) * 1e3;
|
|
2812
|
+
if (now - this.createdAt < refreshThresholdMs * 1e3) return ok(void 0);
|
|
2813
|
+
this.reset();
|
|
2814
|
+
}
|
|
2815
|
+
if (this.initPromise) return this.initPromise;
|
|
2816
|
+
this.initPromise = this.initialize();
|
|
2817
|
+
const result = await this.initPromise;
|
|
2818
|
+
this.initPromise = null;
|
|
2819
|
+
return result;
|
|
2820
|
+
}
|
|
2821
|
+
async initialize() {
|
|
2822
|
+
const challengeResult = await this.challengeFetcher.fetchFullChallenge();
|
|
2823
|
+
if (!isOk(challengeResult)) return challengeResult;
|
|
2824
|
+
this.challenge = challengeResult.value;
|
|
2825
|
+
const attResult = await this.challengeFetcher.fetchAttestation();
|
|
2826
|
+
if (!isOk(attResult)) return attResult;
|
|
2827
|
+
this.attestation = attResult.value;
|
|
2828
|
+
const clientResult = await BotGuardClient.create(this.challenge, { timeout: this.options.timeout });
|
|
2829
|
+
if (!isOk(clientResult)) return clientResult;
|
|
2830
|
+
this.client = clientResult.value;
|
|
2831
|
+
const snapshotResult = await this.client.snapshotWithSignalOutput();
|
|
2832
|
+
if (!isOk(snapshotResult)) return snapshotResult;
|
|
2833
|
+
const { snapshot, webPoSignalOutput } = snapshotResult.value;
|
|
2834
|
+
const integrityResult = await fetchIntegrityToken(snapshot, { timeout: this.options.timeout });
|
|
2835
|
+
if (!isOk(integrityResult)) return integrityResult;
|
|
2836
|
+
this.integrityData = integrityResult.value;
|
|
2837
|
+
this.createdAt = Date.now();
|
|
2838
|
+
const minterResult = await WebPoMinter.create(this.integrityData, webPoSignalOutput);
|
|
2839
|
+
if (!isOk(minterResult)) return minterResult;
|
|
2840
|
+
this.minter = minterResult.value;
|
|
2841
|
+
return ok(void 0);
|
|
2842
|
+
}
|
|
2843
|
+
reset() {
|
|
2844
|
+
if (this.client) {
|
|
2845
|
+
this.client.shutdown();
|
|
2846
|
+
this.client = null;
|
|
2847
|
+
}
|
|
2848
|
+
this.challenge = null;
|
|
2849
|
+
this.attestation = null;
|
|
2850
|
+
this.integrityData = null;
|
|
2851
|
+
this.minter = null;
|
|
2852
|
+
}
|
|
2853
|
+
shutdown() {
|
|
2854
|
+
this.reset();
|
|
2855
|
+
}
|
|
2856
|
+
getVisitorData() {
|
|
2857
|
+
return this.attestation?.visitorData;
|
|
2858
|
+
}
|
|
2859
|
+
};
|
|
2860
|
+
|
|
2861
|
+
//#endregion
|
|
2862
|
+
//#region src/po-token/cache/token-cache.ts
|
|
2863
|
+
var TokenCache = class {
|
|
2864
|
+
db;
|
|
2865
|
+
constructor(cachePath) {
|
|
2866
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
2867
|
+
this.db = new Database(cachePath);
|
|
2868
|
+
this.initializeSchema();
|
|
2869
|
+
}
|
|
2870
|
+
initializeSchema() {
|
|
2871
|
+
this.db.run(`
|
|
2872
|
+
CREATE TABLE IF NOT EXISTS tokens (
|
|
2873
|
+
id TEXT PRIMARY KEY,
|
|
2874
|
+
token TEXT NOT NULL,
|
|
2875
|
+
integrity_token TEXT,
|
|
2876
|
+
binding_type TEXT NOT NULL,
|
|
2877
|
+
binding_value TEXT NOT NULL,
|
|
2878
|
+
client TEXT NOT NULL,
|
|
2879
|
+
context TEXT NOT NULL,
|
|
2880
|
+
created_at INTEGER NOT NULL,
|
|
2881
|
+
expires_at INTEGER NOT NULL,
|
|
2882
|
+
refresh_at INTEGER NOT NULL
|
|
2883
|
+
)
|
|
2884
|
+
`);
|
|
2885
|
+
this.db.run(`
|
|
2886
|
+
CREATE INDEX IF NOT EXISTS idx_tokens_lookup
|
|
2887
|
+
ON tokens(binding_value, client, context)
|
|
2888
|
+
`);
|
|
2889
|
+
this.db.run(`
|
|
2890
|
+
CREATE INDEX IF NOT EXISTS idx_tokens_expires
|
|
2891
|
+
ON tokens(expires_at)
|
|
2892
|
+
`);
|
|
2893
|
+
this.db.run(`
|
|
2894
|
+
CREATE INDEX IF NOT EXISTS idx_tokens_refresh
|
|
2895
|
+
ON tokens(refresh_at)
|
|
2896
|
+
`);
|
|
2897
|
+
}
|
|
2898
|
+
get(bindingValue, client, context) {
|
|
2899
|
+
try {
|
|
2900
|
+
const row = this.db.prepare(`
|
|
2901
|
+
SELECT * FROM tokens
|
|
2902
|
+
WHERE binding_value = ? AND client = ? AND context = ?
|
|
2903
|
+
AND expires_at > ?
|
|
2904
|
+
ORDER BY created_at DESC
|
|
2905
|
+
LIMIT 1
|
|
2906
|
+
`).get(bindingValue, client, context, Date.now());
|
|
2907
|
+
if (!row) return ok(null);
|
|
2908
|
+
return ok(this.rowToTokenData(row));
|
|
2909
|
+
} catch (error) {
|
|
2910
|
+
return err(createError("CACHE_ERROR", "Failed to read token from cache", error));
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
getByClient(client) {
|
|
2914
|
+
try {
|
|
2915
|
+
return ok(this.db.prepare(`
|
|
2916
|
+
SELECT * FROM tokens
|
|
2917
|
+
WHERE client = ? AND expires_at > ?
|
|
2918
|
+
ORDER BY created_at DESC
|
|
2919
|
+
`).all(client, Date.now()).map(this.rowToTokenData));
|
|
2920
|
+
} catch (error) {
|
|
2921
|
+
return err(createError("CACHE_ERROR", "Failed to get tokens by client", error));
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
getExpiringSoon(marginMs) {
|
|
2925
|
+
try {
|
|
2926
|
+
const refreshThreshold = Date.now() + marginMs;
|
|
2927
|
+
return ok(this.db.prepare(`
|
|
2928
|
+
SELECT * FROM tokens
|
|
2929
|
+
WHERE refresh_at <= ? AND expires_at > ?
|
|
2930
|
+
ORDER BY refresh_at ASC
|
|
2931
|
+
`).all(refreshThreshold, Date.now()).map(this.rowToTokenData));
|
|
2932
|
+
} catch (error) {
|
|
2933
|
+
return err(createError("CACHE_ERROR", "Failed to get expiring tokens", error));
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
set(data) {
|
|
2937
|
+
try {
|
|
2938
|
+
const id = `${data.bindingValue}:${data.client}:${data.context}`;
|
|
2939
|
+
this.db.prepare(`
|
|
2940
|
+
INSERT OR REPLACE INTO tokens (
|
|
2941
|
+
id, token, integrity_token, binding_type, binding_value,
|
|
2942
|
+
client, context, created_at, expires_at, refresh_at
|
|
2943
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2944
|
+
`).run(id, data.token, data.integrityToken ?? null, data.bindingType, data.bindingValue, data.client, data.context, data.createdAt, data.expiresAt, data.refreshAt);
|
|
2945
|
+
return ok(void 0);
|
|
2946
|
+
} catch (error) {
|
|
2947
|
+
return err(createError("CACHE_ERROR", "Failed to write token to cache", error));
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
delete(bindingValue, client, context) {
|
|
2951
|
+
try {
|
|
2952
|
+
const id = `${bindingValue}:${client}:${context}`;
|
|
2953
|
+
this.db.prepare("DELETE FROM tokens WHERE id = ?").run(id);
|
|
2954
|
+
return ok(void 0);
|
|
2955
|
+
} catch (error) {
|
|
2956
|
+
return err(createError("CACHE_ERROR", "Failed to delete token", error));
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
deleteExpired() {
|
|
2960
|
+
try {
|
|
2961
|
+
return ok(this.db.prepare("DELETE FROM tokens WHERE expires_at <= ?").run(Date.now()).changes);
|
|
2962
|
+
} catch (error) {
|
|
2963
|
+
return err(createError("CACHE_ERROR", "Failed to delete expired tokens", error));
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
clear() {
|
|
2967
|
+
try {
|
|
2968
|
+
this.db.run("DELETE FROM tokens");
|
|
2969
|
+
return ok(void 0);
|
|
2970
|
+
} catch (error) {
|
|
2971
|
+
return err(createError("CACHE_ERROR", "Failed to clear token cache", error));
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
getStats() {
|
|
2975
|
+
try {
|
|
2976
|
+
const now = Date.now();
|
|
2977
|
+
const total = this.db.prepare("SELECT COUNT(*) as count FROM tokens").get().count;
|
|
2978
|
+
const valid = this.db.prepare("SELECT COUNT(*) as count FROM tokens WHERE expires_at > ?").get(now).count;
|
|
2979
|
+
return ok({
|
|
2980
|
+
total,
|
|
2981
|
+
valid,
|
|
2982
|
+
expired: total - valid
|
|
2983
|
+
});
|
|
2984
|
+
} catch (error) {
|
|
2985
|
+
return err(createError("CACHE_ERROR", "Failed to get cache stats", error));
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
close() {
|
|
2989
|
+
this.db.close();
|
|
2990
|
+
}
|
|
2991
|
+
rowToTokenData(row) {
|
|
2992
|
+
return {
|
|
2993
|
+
token: row.token,
|
|
2994
|
+
integrityToken: row.integrity_token ?? void 0,
|
|
2995
|
+
bindingType: row.binding_type,
|
|
2996
|
+
bindingValue: row.binding_value,
|
|
2997
|
+
client: row.client,
|
|
2998
|
+
context: row.context,
|
|
2999
|
+
createdAt: row.created_at,
|
|
3000
|
+
expiresAt: row.expires_at,
|
|
3001
|
+
refreshAt: row.refresh_at
|
|
3002
|
+
};
|
|
3003
|
+
}
|
|
3004
|
+
};
|
|
3005
|
+
|
|
3006
|
+
//#endregion
|
|
3007
|
+
//#region src/po-token/manager.ts
|
|
3008
|
+
var PoTokenManager = class {
|
|
3009
|
+
staticToken;
|
|
3010
|
+
policies;
|
|
3011
|
+
providers;
|
|
3012
|
+
cache = null;
|
|
3013
|
+
constructor(config = {}) {
|
|
3014
|
+
this.staticToken = config.token;
|
|
3015
|
+
this.policies = PO_TOKEN_POLICIES;
|
|
3016
|
+
if (config.providers) this.providers = [...config.providers].sort((a, b) => b.priority - a.priority);
|
|
3017
|
+
else this.providers = [new LocalBotGuardProvider({ timeout: config.timeout })];
|
|
3018
|
+
if (config.cacheEnabled !== false && config.cachePath) this.cache = new TokenCache(config.cachePath);
|
|
3019
|
+
}
|
|
3020
|
+
async getToken(identifier, client = "WEB", binding) {
|
|
3021
|
+
if (this.staticToken) return ok(this.staticToken);
|
|
3022
|
+
if (!this.isRequired(client)) return ok("");
|
|
3023
|
+
const context = binding ? "GVS" : "GVS";
|
|
3024
|
+
if (this.cache) {
|
|
3025
|
+
const cachedResult = this.cache.get(identifier, client, context);
|
|
3026
|
+
if (isOk(cachedResult) && cachedResult.value && cachedResult.value.expiresAt > Date.now()) return ok(cachedResult.value.token);
|
|
3027
|
+
}
|
|
3028
|
+
for (const provider of this.providers) {
|
|
3029
|
+
if (!await provider.isAvailable()) continue;
|
|
3030
|
+
const result = await provider.generate(identifier, binding);
|
|
3031
|
+
if (isOk(result)) {
|
|
3032
|
+
if (this.cache) this.cache.set(result.value);
|
|
3033
|
+
return ok(result.value.token);
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
return ok(generateColdStartToken(identifier));
|
|
3037
|
+
}
|
|
3038
|
+
async getTokenData(identifier, client = "WEB", binding) {
|
|
3039
|
+
if (this.staticToken) return ok({
|
|
3040
|
+
token: this.staticToken,
|
|
3041
|
+
createdAt: Date.now(),
|
|
3042
|
+
expiresAt: Date.now() + 36e5,
|
|
3043
|
+
refreshAt: Date.now() + 3e6,
|
|
3044
|
+
bindingType: "session",
|
|
3045
|
+
bindingValue: identifier,
|
|
3046
|
+
client,
|
|
3047
|
+
context: "GVS"
|
|
3048
|
+
});
|
|
3049
|
+
if (!this.isRequired(client)) return err(createError("PO_TOKEN_EXPIRED", `PO Token not required for client ${client}`));
|
|
3050
|
+
const context = binding ? "GVS" : "GVS";
|
|
3051
|
+
if (this.cache) {
|
|
3052
|
+
const cachedResult = this.cache.get(identifier, client, context);
|
|
3053
|
+
if (isOk(cachedResult) && cachedResult.value && cachedResult.value.expiresAt > Date.now()) return ok(cachedResult.value);
|
|
3054
|
+
}
|
|
3055
|
+
for (const provider of this.providers) {
|
|
3056
|
+
if (!await provider.isAvailable()) continue;
|
|
3057
|
+
const result = await provider.generate(identifier, binding);
|
|
3058
|
+
if (isOk(result)) {
|
|
3059
|
+
if (this.cache) this.cache.set(result.value);
|
|
3060
|
+
return result;
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
return err(createError("ALL_PROVIDERS_FAILED", "All PO Token providers failed"));
|
|
3064
|
+
}
|
|
3065
|
+
getColdStartToken(identifier) {
|
|
3066
|
+
return generateColdStartToken(identifier);
|
|
3067
|
+
}
|
|
3068
|
+
isRequired(client) {
|
|
3069
|
+
const policy = this.policies[client];
|
|
3070
|
+
if (!policy) return false;
|
|
3071
|
+
if (typeof policy.GVS === "string") return policy.GVS === "required";
|
|
3072
|
+
return policy.GVS.HTTPS === "required" || policy.GVS.DASH === "required";
|
|
3073
|
+
}
|
|
3074
|
+
needsTokenForGvs(client) {
|
|
3075
|
+
const policy = this.policies[client];
|
|
3076
|
+
if (!policy) return false;
|
|
3077
|
+
if (typeof policy.GVS === "string") return policy.GVS !== "not_required";
|
|
3078
|
+
return policy.GVS.HTTPS !== "not_required" || policy.GVS.DASH !== "not_required" || policy.GVS.HLS !== "not_required";
|
|
3079
|
+
}
|
|
3080
|
+
getPolicy(client) {
|
|
3081
|
+
return this.policies[client];
|
|
3082
|
+
}
|
|
3083
|
+
getVisitorData() {
|
|
3084
|
+
for (const provider of this.providers) if (provider instanceof LocalBotGuardProvider) return provider.getVisitorData();
|
|
3085
|
+
}
|
|
3086
|
+
shutdown() {
|
|
3087
|
+
for (const provider of this.providers) provider.shutdown();
|
|
3088
|
+
if (this.cache) this.cache.deleteExpired();
|
|
3089
|
+
}
|
|
3090
|
+
getCacheStats() {
|
|
3091
|
+
if (!this.cache) return null;
|
|
3092
|
+
const result = this.cache.getStats();
|
|
3093
|
+
if (isOk(result)) return result.value;
|
|
3094
|
+
return null;
|
|
3095
|
+
}
|
|
3096
|
+
};
|
|
3097
|
+
|
|
3098
|
+
//#endregion
|
|
3099
|
+
//#region src/utils/logger.ts
|
|
3100
|
+
logger.preset("cyberpunk");
|
|
3101
|
+
logger.showTimestamp();
|
|
3102
|
+
logger.showLocation();
|
|
3103
|
+
logger.addSerializer(Error, (err$1) => ({
|
|
3104
|
+
name: err$1.name,
|
|
3105
|
+
message: err$1.message,
|
|
3106
|
+
stack: err$1.stack?.split("\n").slice(0, 5).join("\n")
|
|
3107
|
+
}));
|
|
3108
|
+
const bundlpLogger = logger.scope("Bundlp");
|
|
3109
|
+
const extractorLogger = logger.component("Extractor");
|
|
3110
|
+
const cipherLogger = logger.component("Cipher");
|
|
3111
|
+
const decoderLogger = logger.component("Decoder");
|
|
3112
|
+
const innertubeLogger = logger.api("InnerTube");
|
|
3113
|
+
const httpLogger = logger.api("HTTP");
|
|
3114
|
+
const playerLogger = logger.scope("Bundlp:Player");
|
|
3115
|
+
const streamingLogger = logger.scope("Bundlp:Streaming");
|
|
3116
|
+
const poTokenLogger = logger.scope("Bundlp:PoToken");
|
|
3117
|
+
const cacheLogger = logger.scope("Bundlp:Cache");
|
|
3118
|
+
|
|
3119
|
+
//#endregion
|
|
3120
|
+
//#region src/index.ts
|
|
3121
|
+
/**
|
|
3122
|
+
* Appends po_token to a download URL
|
|
3123
|
+
*/
|
|
3124
|
+
function appendPoToken(url, poToken) {
|
|
3125
|
+
if (!url || !poToken) return url;
|
|
3126
|
+
return `${url}${url.includes("?") ? "&" : "?"}pot=${encodeURIComponent(poToken)}`;
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
//#endregion
|
|
3130
|
+
export { LocalBotGuardProvider, PoTokenManager, YouTubeExtractor, andThen, appendPoToken, bundlpLogger, err, extractorLogger, getDownloadHeaders, getMinimalDownloadHeaders, isErr, isOk, map, mapErr, match, ok, tryCatch, tryCatchAsync, unwrap, unwrapOr };
|
|
3131
|
+
//# sourceMappingURL=index.mjs.map
|