youtubei-es6 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/getInnertube.js +32 -0
- package/package.json +16 -0
- package/poTokenGenerator.js +315 -0
- package/youtubeSabrCore.js +178 -0
- package/youtubeiExtractor.js +298 -0
package/README.md
ADDED
package/getInnertube.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Innertube, UniversalCache, Platform } from "youtubei.js";
|
|
2
|
+
|
|
3
|
+
let ineerTubeInstance = null;
|
|
4
|
+
|
|
5
|
+
Platform.shim.eval = async (data, env) => {
|
|
6
|
+
const properties = [];
|
|
7
|
+
|
|
8
|
+
if (env.n) properties.push(`n: exportedVars.nFunction("${env.n}")`);
|
|
9
|
+
|
|
10
|
+
if (env.sig) properties.push(`sig: exportedVars.sigFunction("${env.sig}")`);
|
|
11
|
+
|
|
12
|
+
const code = `${data.output}\nreturn { ${properties.join(", ")} }`;
|
|
13
|
+
|
|
14
|
+
return new Function(code)();
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the Innertube instance
|
|
19
|
+
* @returns {Promise<Innertube>} The Innertube instance
|
|
20
|
+
*/
|
|
21
|
+
async function getInnertube(cookies) {
|
|
22
|
+
if (!ineerTubeInstance) {
|
|
23
|
+
ineerTubeInstance = await Innertube.create({
|
|
24
|
+
cache: new UniversalCache(false),
|
|
25
|
+
// player_id: "0004de42",
|
|
26
|
+
cookie: cookies,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return ineerTubeInstance;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { getInnertube };
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "youtubei-es6",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "YouTube extractor from WD-40 bot converted to ES6",
|
|
5
|
+
"main": "youtubeiExtractor.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
|
+
},
|
|
9
|
+
"author": "",
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"discord-player": "^7.1.0",
|
|
13
|
+
"stream": "^0.0.3",
|
|
14
|
+
"youtubei.js": "^16.0.1"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { BG, GOOG_API_KEY, USER_AGENT, buildURL } from "bgutils-js";
|
|
2
|
+
import { JSDOM } from "jsdom";
|
|
3
|
+
import { createCanvas, ImageData as CanvasImageData } from "@napi-rs/canvas";
|
|
4
|
+
|
|
5
|
+
const REQUEST_KEY = "O43z0dpjhgX20SCx4KAo";
|
|
6
|
+
|
|
7
|
+
let domWindow;
|
|
8
|
+
let initializationPromise = null;
|
|
9
|
+
let botguardClient;
|
|
10
|
+
let webPoMinter;
|
|
11
|
+
let activeScriptId = null;
|
|
12
|
+
let canvasPatched = false;
|
|
13
|
+
|
|
14
|
+
function patchCanvasSupport(window) {
|
|
15
|
+
if (canvasPatched) return;
|
|
16
|
+
|
|
17
|
+
const HTMLCanvasElement = window?.HTMLCanvasElement;
|
|
18
|
+
if (!HTMLCanvasElement) return;
|
|
19
|
+
|
|
20
|
+
Object.defineProperty(HTMLCanvasElement.prototype, "_napiCanvasState", {
|
|
21
|
+
configurable: true,
|
|
22
|
+
enumerable: false,
|
|
23
|
+
writable: true,
|
|
24
|
+
value: null,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
HTMLCanvasElement.prototype.getContext = function getContext(
|
|
28
|
+
type,
|
|
29
|
+
options,
|
|
30
|
+
) {
|
|
31
|
+
if (type !== "2d") return null;
|
|
32
|
+
|
|
33
|
+
const width =
|
|
34
|
+
Number.isFinite(this.width) && this.width > 0 ? this.width : 300;
|
|
35
|
+
const height =
|
|
36
|
+
Number.isFinite(this.height) && this.height > 0 ? this.height : 150;
|
|
37
|
+
|
|
38
|
+
const state = this._napiCanvasState || {};
|
|
39
|
+
|
|
40
|
+
if (!state.canvas) {
|
|
41
|
+
state.canvas = createCanvas(width, height);
|
|
42
|
+
} else if (
|
|
43
|
+
state.canvas.width !== width ||
|
|
44
|
+
state.canvas.height !== height
|
|
45
|
+
) {
|
|
46
|
+
state.canvas.width = width;
|
|
47
|
+
state.canvas.height = height;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
state.context = state.canvas.getContext("2d", options);
|
|
51
|
+
this._napiCanvasState = state;
|
|
52
|
+
return state.context;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
HTMLCanvasElement.prototype.toDataURL = function toDataURL(...args) {
|
|
56
|
+
if (!this._napiCanvasState?.canvas) {
|
|
57
|
+
const width =
|
|
58
|
+
Number.isFinite(this.width) && this.width > 0
|
|
59
|
+
? this.width
|
|
60
|
+
: 300;
|
|
61
|
+
const height =
|
|
62
|
+
Number.isFinite(this.height) && this.height > 0
|
|
63
|
+
? this.height
|
|
64
|
+
: 150;
|
|
65
|
+
this._napiCanvasState = {
|
|
66
|
+
canvas: createCanvas(width, height),
|
|
67
|
+
context: null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return this._napiCanvasState.canvas.toDataURL(...args);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (!window.ImageData) window.ImageData = CanvasImageData;
|
|
75
|
+
|
|
76
|
+
if (!Reflect.has(globalThis, "ImageData")) {
|
|
77
|
+
Object.defineProperty(globalThis, "ImageData", {
|
|
78
|
+
configurable: true,
|
|
79
|
+
enumerable: false,
|
|
80
|
+
writable: true,
|
|
81
|
+
value: CanvasImageData,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
canvasPatched = true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ensureDomEnvironment(userAgent) {
|
|
89
|
+
if (domWindow) return domWindow;
|
|
90
|
+
|
|
91
|
+
const dom = new JSDOM(
|
|
92
|
+
"<!DOCTYPE html><html><head></head><body></body></html>",
|
|
93
|
+
{
|
|
94
|
+
url: "https://www.youtube.com/",
|
|
95
|
+
referrer: "https://www.youtube.com/",
|
|
96
|
+
userAgent,
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
domWindow = dom.window;
|
|
101
|
+
|
|
102
|
+
const globalAssignments = {
|
|
103
|
+
window: domWindow,
|
|
104
|
+
document: domWindow.document,
|
|
105
|
+
location: domWindow.location,
|
|
106
|
+
origin: domWindow.origin,
|
|
107
|
+
navigator: domWindow.navigator,
|
|
108
|
+
HTMLElement: domWindow.HTMLElement,
|
|
109
|
+
atob: domWindow.atob,
|
|
110
|
+
btoa: domWindow.btoa,
|
|
111
|
+
crypto: domWindow.crypto,
|
|
112
|
+
performance: domWindow.performance,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
for (const [key, value] of Object.entries(globalAssignments)) {
|
|
116
|
+
if (!Reflect.has(globalThis, key)) {
|
|
117
|
+
Object.defineProperty(globalThis, key, {
|
|
118
|
+
configurable: true,
|
|
119
|
+
enumerable: false,
|
|
120
|
+
writable: true,
|
|
121
|
+
value,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!Reflect.has(globalThis, "self")) {
|
|
127
|
+
Object.defineProperty(globalThis, "self", {
|
|
128
|
+
configurable: true,
|
|
129
|
+
enumerable: false,
|
|
130
|
+
writable: true,
|
|
131
|
+
value: globalThis,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
patchCanvasSupport(domWindow);
|
|
136
|
+
|
|
137
|
+
return domWindow;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function resetBotguardState() {
|
|
141
|
+
if (botguardClient?.shutdown) {
|
|
142
|
+
try {
|
|
143
|
+
botguardClient.shutdown();
|
|
144
|
+
} catch {
|
|
145
|
+
/* no-op */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (activeScriptId && domWindow?.document)
|
|
150
|
+
domWindow.document.getElementById(activeScriptId)?.remove();
|
|
151
|
+
|
|
152
|
+
botguardClient = undefined;
|
|
153
|
+
webPoMinter = undefined;
|
|
154
|
+
activeScriptId = null;
|
|
155
|
+
initializationPromise = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function initializeBotguard(innertube, { forceRefresh } = {}) {
|
|
159
|
+
if (forceRefresh) resetBotguardState();
|
|
160
|
+
|
|
161
|
+
if (webPoMinter) return webPoMinter;
|
|
162
|
+
|
|
163
|
+
if (initializationPromise) return await initializationPromise;
|
|
164
|
+
|
|
165
|
+
const userAgent = innertube.session.context.client.userAgent || USER_AGENT;
|
|
166
|
+
ensureDomEnvironment(userAgent);
|
|
167
|
+
|
|
168
|
+
initializationPromise = (async () => {
|
|
169
|
+
const challengeResponse = await innertube.getAttestationChallenge(
|
|
170
|
+
"ENGAGEMENT_TYPE_UNBOUND",
|
|
171
|
+
);
|
|
172
|
+
const challenge = challengeResponse?.bg_challenge;
|
|
173
|
+
|
|
174
|
+
if (!challenge)
|
|
175
|
+
throw new Error("Failed to retrieve Botguard challenge.");
|
|
176
|
+
|
|
177
|
+
const interpreterUrl =
|
|
178
|
+
challenge.interpreter_url
|
|
179
|
+
?.private_do_not_access_or_else_trusted_resource_url_wrapped_value;
|
|
180
|
+
|
|
181
|
+
if (!interpreterUrl)
|
|
182
|
+
throw new Error(
|
|
183
|
+
"Botguard challenge did not provide an interpreter URL.",
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (!domWindow.document.getElementById(interpreterUrl)) {
|
|
187
|
+
const interpreterResponse = await fetch(`https:${interpreterUrl}`, {
|
|
188
|
+
headers: {
|
|
189
|
+
"user-agent": userAgent,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const interpreterJavascript = await interpreterResponse.text();
|
|
194
|
+
|
|
195
|
+
if (!interpreterJavascript)
|
|
196
|
+
throw new Error(
|
|
197
|
+
"Failed to download Botguard interpreter script.",
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const script = domWindow.document.createElement("script");
|
|
201
|
+
script.type = "text/javascript";
|
|
202
|
+
script.id = interpreterUrl;
|
|
203
|
+
script.textContent = interpreterJavascript;
|
|
204
|
+
domWindow.document.head.appendChild(script);
|
|
205
|
+
activeScriptId = script.id;
|
|
206
|
+
|
|
207
|
+
const executeInterpreter = new domWindow.Function(
|
|
208
|
+
interpreterJavascript,
|
|
209
|
+
);
|
|
210
|
+
executeInterpreter.call(domWindow);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
botguardClient = await BG.BotGuardClient.create({
|
|
214
|
+
program: challenge.program,
|
|
215
|
+
globalName: challenge.global_name,
|
|
216
|
+
globalObj: globalThis,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const webPoSignalOutput = [];
|
|
220
|
+
const botguardSnapshot = await botguardClient.snapshot({
|
|
221
|
+
webPoSignalOutput,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const integrityResponse = await fetch(buildURL("GenerateIT", true), {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: {
|
|
227
|
+
"content-type": "application/json+protobuf",
|
|
228
|
+
"x-goog-api-key": GOOG_API_KEY,
|
|
229
|
+
"x-user-agent": "grpc-web-javascript/0.1",
|
|
230
|
+
"user-agent": userAgent,
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify([REQUEST_KEY, botguardSnapshot]),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const integrityPayload = await integrityResponse.json();
|
|
236
|
+
const integrityToken = integrityPayload?.[0];
|
|
237
|
+
|
|
238
|
+
if (typeof integrityToken !== "string")
|
|
239
|
+
throw new Error("Botguard integrity token generation failed.");
|
|
240
|
+
|
|
241
|
+
webPoMinter = await BG.WebPoMinter.create(
|
|
242
|
+
{ integrityToken },
|
|
243
|
+
webPoSignalOutput,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
return webPoMinter;
|
|
247
|
+
})()
|
|
248
|
+
.catch((error) => {
|
|
249
|
+
resetBotguardState();
|
|
250
|
+
throw error;
|
|
251
|
+
})
|
|
252
|
+
.finally(() => {
|
|
253
|
+
initializationPromise = null;
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return await initializationPromise;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function requireBinding(binding) {
|
|
260
|
+
if (!binding)
|
|
261
|
+
throw new Error("Content binding is required to mint a WebPO token.");
|
|
262
|
+
return binding;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function getWebPoMinter(innertube, options = {}) {
|
|
266
|
+
const minter = await initializeBotguard(innertube, options);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
generatePlaceholder(binding) {
|
|
270
|
+
return BG.PoToken.generateColdStartToken(requireBinding(binding));
|
|
271
|
+
},
|
|
272
|
+
async mint(binding) {
|
|
273
|
+
return await minter.mintAsWebsafeString(requireBinding(binding));
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function invalidateWebPoMinter() {
|
|
279
|
+
resetBotguardState();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Generates Data Sync tokens required for content PO token minting.
|
|
284
|
+
*
|
|
285
|
+
* @param {Innertube} innertube - The Innertube instance.
|
|
286
|
+
* @returns {Promise<{dataSyncId: string, fullToken: string}>} The Data Sync ID and full token.
|
|
287
|
+
*/
|
|
288
|
+
async function generateDataSyncTokens(innertube) {
|
|
289
|
+
try {
|
|
290
|
+
const accountInfo = await innertube.account.getInfo();
|
|
291
|
+
console.log(accountInfo);
|
|
292
|
+
const dataSyncId =
|
|
293
|
+
accountInfo.contents.contents[0].endpoint.payload.supportedTokens[2]
|
|
294
|
+
.datasyncIdToken.datasyncIdToken;
|
|
295
|
+
|
|
296
|
+
if (!dataSyncId)
|
|
297
|
+
throw new Error("Data Sync ID not found in account info");
|
|
298
|
+
|
|
299
|
+
console.log("Data Sync ID:", dataSyncId);
|
|
300
|
+
const minter = await getWebPoMinter(innertube);
|
|
301
|
+
|
|
302
|
+
const fullToken = await minter.mint(dataSyncId);
|
|
303
|
+
console.log("Full Token:", fullToken);
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
dataSyncId,
|
|
307
|
+
fullToken,
|
|
308
|
+
};
|
|
309
|
+
} catch (error) {
|
|
310
|
+
console.error("Error generating Data Sync tokens:", error);
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export { getWebPoMinter, invalidateWebPoMinter, generateDataSyncTokens };
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Constants, YTNodes } from "youtubei.js";
|
|
2
|
+
import { EnabledTrackTypes, buildSabrFormat } from "googlevideo/utils";
|
|
3
|
+
import { SabrStream } from "googlevideo/sabr-stream";
|
|
4
|
+
import { Readable, PassThrough } from "stream";
|
|
5
|
+
import { getWebPoMinter, invalidateWebPoMinter } from "./poTokenGenerator.js";
|
|
6
|
+
import { getInnertube } from "./getInnertube.js";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_OPTIONS = {
|
|
9
|
+
audioQuality: "AUDIO_QUALITY_MEDIUM",
|
|
10
|
+
enabledTrackTypes: EnabledTrackTypes.AUDIO_ONLY,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Converts a stream to a Node.js Readable stream
|
|
15
|
+
*
|
|
16
|
+
* @param {ReadableStream} stream - The stream to convert
|
|
17
|
+
* @returns {Readable} The Node.js Readable stream
|
|
18
|
+
*/
|
|
19
|
+
function toNodeReadable(stream) {
|
|
20
|
+
const nodeStream = new PassThrough();
|
|
21
|
+
const reader = stream.getReader();
|
|
22
|
+
|
|
23
|
+
(async () => {
|
|
24
|
+
try {
|
|
25
|
+
while (true) {
|
|
26
|
+
const { done, value } = await reader.read();
|
|
27
|
+
if (done) break;
|
|
28
|
+
if (value) {
|
|
29
|
+
if (!nodeStream.write(Buffer.from(value)))
|
|
30
|
+
await new Promise((resolve) =>
|
|
31
|
+
nodeStream.once("drain", resolve),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} finally {
|
|
36
|
+
nodeStream.end();
|
|
37
|
+
}
|
|
38
|
+
})();
|
|
39
|
+
|
|
40
|
+
return nodeStream;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Creates a SABR stream for a given video ID
|
|
45
|
+
*
|
|
46
|
+
* @param {string} videoId - The video ID
|
|
47
|
+
* @returns {Promise<Readable>} The SABR stream
|
|
48
|
+
*/
|
|
49
|
+
async function createSabrStream(videoId, cookies, logSabrEvents = false) {
|
|
50
|
+
const innertube = await getInnertube(cookies);
|
|
51
|
+
let accountInfo = null;
|
|
52
|
+
|
|
53
|
+
// === Mint initial PO token ===
|
|
54
|
+
try {
|
|
55
|
+
accountInfo = await innertube.account.getInfo();
|
|
56
|
+
} catch (e) {
|
|
57
|
+
accountInfo = null;
|
|
58
|
+
}
|
|
59
|
+
const dataSyncId =
|
|
60
|
+
accountInfo?.contents?.contents[0]?.endpoint?.payload
|
|
61
|
+
?.supportedTokens?.[2]?.datasyncIdToken?.datasyncIdToken ??
|
|
62
|
+
innertube.session.context.client.visitorData;
|
|
63
|
+
const minter = await getWebPoMinter(innertube);
|
|
64
|
+
const contentPoToken = await minter.mint(videoId);
|
|
65
|
+
const poToken = await minter.mint(dataSyncId);
|
|
66
|
+
|
|
67
|
+
// === Player request ===
|
|
68
|
+
const watchEndpoint = new YTNodes.NavigationEndpoint({
|
|
69
|
+
watchEndpoint: { videoId },
|
|
70
|
+
});
|
|
71
|
+
const playerResponse = await watchEndpoint.call(innertube.actions, {
|
|
72
|
+
playbackContext: {
|
|
73
|
+
adPlaybackContext: { pyv: true },
|
|
74
|
+
contentPlaybackContext: {
|
|
75
|
+
vis: 0,
|
|
76
|
+
splay: false,
|
|
77
|
+
lactMilliseconds: "-1",
|
|
78
|
+
signatureTimestamp:
|
|
79
|
+
innertube.session.player?.signature_timestamp,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
contentCheckOk: true,
|
|
83
|
+
racyCheckOk: true,
|
|
84
|
+
serviceIntegrityDimensions: { poToken: poToken },
|
|
85
|
+
parse: true,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const serverAbrStreamingUrl = await innertube.session.player?.decipher(
|
|
89
|
+
playerResponse.streaming_data?.server_abr_streaming_url,
|
|
90
|
+
);
|
|
91
|
+
const videoPlaybackUstreamerConfig =
|
|
92
|
+
playerResponse.player_config?.media_common_config
|
|
93
|
+
.media_ustreamer_request_config?.video_playback_ustreamer_config;
|
|
94
|
+
|
|
95
|
+
if (!videoPlaybackUstreamerConfig)
|
|
96
|
+
throw new Error("ustreamerConfig not found");
|
|
97
|
+
if (!serverAbrStreamingUrl)
|
|
98
|
+
throw new Error("serverAbrStreamingUrl not found");
|
|
99
|
+
|
|
100
|
+
const sabrFormats =
|
|
101
|
+
playerResponse.streaming_data?.adaptive_formats.map(buildSabrFormat) ||
|
|
102
|
+
[];
|
|
103
|
+
|
|
104
|
+
const serverAbrStream = new SabrStream({
|
|
105
|
+
formats: sabrFormats,
|
|
106
|
+
serverAbrStreamingUrl,
|
|
107
|
+
videoPlaybackUstreamerConfig,
|
|
108
|
+
poToken: contentPoToken,
|
|
109
|
+
clientInfo: {
|
|
110
|
+
clientName: parseInt(
|
|
111
|
+
Constants.CLIENT_NAME_IDS[
|
|
112
|
+
innertube.session.context.client.clientName
|
|
113
|
+
],
|
|
114
|
+
),
|
|
115
|
+
clientVersion: innertube.session.context.client.clientVersion,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// === Stream protection handling ===
|
|
120
|
+
let protectionFailureCount = 0;
|
|
121
|
+
let lastStatus = null;
|
|
122
|
+
serverAbrStream.on("streamProtectionStatusUpdate", async (statusUpdate) => {
|
|
123
|
+
if (statusUpdate.status !== lastStatus) {
|
|
124
|
+
if (logSabrEvents)
|
|
125
|
+
console.log("Stream Protection Status Update:", statusUpdate);
|
|
126
|
+
lastStatus = statusUpdate.status;
|
|
127
|
+
}
|
|
128
|
+
if (statusUpdate.status === 2) {
|
|
129
|
+
protectionFailureCount = Math.min(protectionFailureCount + 1, 10);
|
|
130
|
+
if (
|
|
131
|
+
protectionFailureCount === 1 ||
|
|
132
|
+
protectionFailureCount % 5 === 0
|
|
133
|
+
)
|
|
134
|
+
if (logSabrEvents)
|
|
135
|
+
console.log(
|
|
136
|
+
`Rotating PO token... (attempt ${protectionFailureCount})`,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const rotationMinter = await getWebPoMinter(innertube, {
|
|
141
|
+
forceRefresh: protectionFailureCount >= 3,
|
|
142
|
+
});
|
|
143
|
+
const placeholderToken =
|
|
144
|
+
rotationMinter.generatePlaceholder(videoId);
|
|
145
|
+
serverAbrStream.setPoToken(placeholderToken);
|
|
146
|
+
const mintedPoToken = await rotationMinter.mint(videoId);
|
|
147
|
+
serverAbrStream.setPoToken(mintedPoToken);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (
|
|
150
|
+
protectionFailureCount === 1 ||
|
|
151
|
+
protectionFailureCount % 5 === 0
|
|
152
|
+
)
|
|
153
|
+
if (logSabrEvents)
|
|
154
|
+
console.error("Failed to rotate PO token:", err);
|
|
155
|
+
}
|
|
156
|
+
} else if (statusUpdate.status === 3) {
|
|
157
|
+
if (logSabrEvents)
|
|
158
|
+
console.error(
|
|
159
|
+
"Stream protection rejected token (SPS 3). Resetting Botguard.",
|
|
160
|
+
);
|
|
161
|
+
invalidateWebPoMinter();
|
|
162
|
+
} else {
|
|
163
|
+
protectionFailureCount = 0;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
serverAbrStream.on("error", (err) => {
|
|
168
|
+
if (logSabrEvents) console.error("SABR stream error:", err);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// === Start SABR stream ===
|
|
172
|
+
const { audioStream } = await serverAbrStream.start(DEFAULT_OPTIONS);
|
|
173
|
+
const nodeStream = toNodeReadable(audioStream);
|
|
174
|
+
|
|
175
|
+
return nodeStream;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export { createSabrStream };
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// YoutubeSabrExtractor.js
|
|
2
|
+
import { BaseExtractor, Track, Playlist, Util } from "discord-player";
|
|
3
|
+
import { createSabrStream } from "./youtubeSabrCore.js";
|
|
4
|
+
import { getInnertube } from "./getInnertube.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Discord-player extractor using only helpers from youtubeSabrCore.js.
|
|
8
|
+
*/
|
|
9
|
+
class YoutubeSabrExtractor extends BaseExtractor {
|
|
10
|
+
static identifier = "com.itsmaat.discord-player.youtube-sabr";
|
|
11
|
+
|
|
12
|
+
async activate() {
|
|
13
|
+
this.protocols = ["youtube", "yt"];
|
|
14
|
+
this.cookies = this.options.cookies;
|
|
15
|
+
this.logSabrEvents = this.options.logSabrEvents;
|
|
16
|
+
this.innertube = await getInnertube(this.cookies);
|
|
17
|
+
|
|
18
|
+
const fn = this.options.createStream;
|
|
19
|
+
if (typeof fn === "function") {
|
|
20
|
+
this._stream = (q) => {
|
|
21
|
+
return fn(this, q);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async deactivate() {
|
|
27
|
+
this._stream = null;
|
|
28
|
+
this.innertube = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async validate(query, queryType) {
|
|
32
|
+
if (typeof query !== "string") return false;
|
|
33
|
+
return (
|
|
34
|
+
!isUrl(query) ||
|
|
35
|
+
/^https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//i.test(query)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async handle(query, context) {
|
|
40
|
+
try {
|
|
41
|
+
if (!isUrl(query)) {
|
|
42
|
+
const search = await this.innertube.search(query);
|
|
43
|
+
const videos = search.videos.filter((v) => v.type === "Video");
|
|
44
|
+
|
|
45
|
+
const tracks = [];
|
|
46
|
+
for (const video of videos.slice(0, 10)) {
|
|
47
|
+
const info = await this.innertube.getBasicInfo(video.id);
|
|
48
|
+
const durationMs = (info.basic_info?.duration ?? 0) * 1000;
|
|
49
|
+
|
|
50
|
+
tracks.push(
|
|
51
|
+
new Track(context.player, {
|
|
52
|
+
title:
|
|
53
|
+
info.basic_info?.title ?? `YouTube:${video.id}`,
|
|
54
|
+
author: info.basic_info?.author ?? null,
|
|
55
|
+
url: `https://www.youtube.com/watch?v=${video.id}`,
|
|
56
|
+
thumbnail: video.thumbnails[0]?.url,
|
|
57
|
+
duration: Util.buildTimeCode(
|
|
58
|
+
Util.parseMS(durationMs),
|
|
59
|
+
),
|
|
60
|
+
source: "youtube-sabr",
|
|
61
|
+
requestedBy: context.requestedBy ?? null,
|
|
62
|
+
raw: {
|
|
63
|
+
basicInfo: info,
|
|
64
|
+
live: info.basic_info?.is_live || false,
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return this.createResponse(null, tracks);
|
|
70
|
+
}
|
|
71
|
+
let isPlaylist = false;
|
|
72
|
+
let playlistId = null;
|
|
73
|
+
const urlObj = new URL(query);
|
|
74
|
+
const hasList = urlObj.searchParams.has("list");
|
|
75
|
+
const isShortLink = /(^|\.)youtu\.be$/i.test(urlObj.hostname);
|
|
76
|
+
isPlaylist = hasList && !isShortLink;
|
|
77
|
+
playlistId = isPlaylist ? urlObj.searchParams.get("list") : null;
|
|
78
|
+
|
|
79
|
+
// If playlist detected
|
|
80
|
+
if (isPlaylist && playlistId) {
|
|
81
|
+
let playlist = await this.innertube.getPlaylist(playlistId);
|
|
82
|
+
if (!playlist?.videos?.length)
|
|
83
|
+
return this.createResponse(null, []);
|
|
84
|
+
|
|
85
|
+
const dpPlaylist = new Playlist(context.player, {
|
|
86
|
+
id: playlistId,
|
|
87
|
+
title: playlist.info.title ?? "Unknown",
|
|
88
|
+
url: query,
|
|
89
|
+
thumbnail: playlist.info.thumbnails[0].url,
|
|
90
|
+
description:
|
|
91
|
+
playlist.info.description ??
|
|
92
|
+
playlist.info.title ??
|
|
93
|
+
"UNKNOWN DESCRIPTION",
|
|
94
|
+
source: "youtube",
|
|
95
|
+
type: "playlist",
|
|
96
|
+
author: {
|
|
97
|
+
name:
|
|
98
|
+
playlist?.channels[0]?.author?.name ??
|
|
99
|
+
playlist.info.author.name ??
|
|
100
|
+
"UNKNOWN AUTHOR",
|
|
101
|
+
url:
|
|
102
|
+
playlist?.channels[0]?.author?.url ??
|
|
103
|
+
playlist.info.author.url ??
|
|
104
|
+
"UNKNOWN AUTHOR",
|
|
105
|
+
},
|
|
106
|
+
tracks: [],
|
|
107
|
+
requestedBy: context.requestedBy ?? null,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
dpPlaylist.tracks = [];
|
|
111
|
+
|
|
112
|
+
const plTracks = playlist.videos
|
|
113
|
+
.filter((v) => v.type === "PlaylistVideo")
|
|
114
|
+
.map((v) => {
|
|
115
|
+
const duration = Util.buildTimeCode(
|
|
116
|
+
Util.parseMS(v.duration.seconds * 1000),
|
|
117
|
+
);
|
|
118
|
+
const raw = {
|
|
119
|
+
duration_ms: v.duration.seconds * 1000,
|
|
120
|
+
live: v.is_live,
|
|
121
|
+
duration,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return new Track(this.context.player, {
|
|
125
|
+
title: v.title.text ?? "UNKNOWN TITLE",
|
|
126
|
+
duration: duration,
|
|
127
|
+
thumbnail: v.thumbnails[0]?.url,
|
|
128
|
+
author: v.author.name,
|
|
129
|
+
requestedBy: context.requestedBy,
|
|
130
|
+
url: `https://youtube.com/watch?v=${v.id}`,
|
|
131
|
+
raw,
|
|
132
|
+
playlist: dpPlaylist,
|
|
133
|
+
source: "youtube",
|
|
134
|
+
queryType: "youtubeVideo",
|
|
135
|
+
async requestMetadata() {
|
|
136
|
+
return this.raw;
|
|
137
|
+
},
|
|
138
|
+
metadata: raw,
|
|
139
|
+
live: v.is_live,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
while (playlist.has_continuation) {
|
|
144
|
+
playlist = await playlist.getContinuation();
|
|
145
|
+
|
|
146
|
+
plTracks.push(
|
|
147
|
+
...playlist.videos
|
|
148
|
+
.filter((v) => v.type === "PlaylistVideo")
|
|
149
|
+
.map((v) => {
|
|
150
|
+
const duration = Util.buildTimeCode(
|
|
151
|
+
Util.parseMS(v.duration.seconds * 1000),
|
|
152
|
+
);
|
|
153
|
+
const raw = {
|
|
154
|
+
duration_ms: v.duration.seconds * 1000,
|
|
155
|
+
live: v.is_live,
|
|
156
|
+
duration,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return new Track(this.context.player, {
|
|
160
|
+
title: v.title.text ?? "UNKNOWN TITLE",
|
|
161
|
+
duration,
|
|
162
|
+
thumbnail: v.thumbnails[0]?.url,
|
|
163
|
+
author: v.author.name,
|
|
164
|
+
requestedBy: context.requestedBy,
|
|
165
|
+
url: `https://youtube.com/watch?v=${v.id}`,
|
|
166
|
+
raw,
|
|
167
|
+
playlist: dpPlaylist,
|
|
168
|
+
source: "youtube",
|
|
169
|
+
queryType: "youtubeVideo",
|
|
170
|
+
async requestMetadata() {
|
|
171
|
+
return this.raw;
|
|
172
|
+
},
|
|
173
|
+
metadata: raw,
|
|
174
|
+
live: v.is_live,
|
|
175
|
+
});
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
dpPlaylist.tracks = plTracks;
|
|
181
|
+
|
|
182
|
+
return this.createResponse(dpPlaylist, plTracks);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Otherwise treat as single video
|
|
186
|
+
const videoId = extractVideoId(query);
|
|
187
|
+
if (!videoId) return this.createResponse(null, []);
|
|
188
|
+
|
|
189
|
+
const info = await this.innertube.getBasicInfo(videoId);
|
|
190
|
+
const durationMs = (info.basic_info?.duration ?? 0) * 1000;
|
|
191
|
+
|
|
192
|
+
const trackObj = new Track(context.player, {
|
|
193
|
+
title: info.basic_info?.title ?? `YouTube:${videoId}`,
|
|
194
|
+
author: info.basic_info?.author ?? null,
|
|
195
|
+
url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
196
|
+
thumbnail: info.basic_info?.thumbnail[0].url,
|
|
197
|
+
duration: Util.buildTimeCode(Util.parseMS(durationMs)),
|
|
198
|
+
source: "youtube-sabr",
|
|
199
|
+
requestedBy: context.requestedBy ?? null,
|
|
200
|
+
raw: {
|
|
201
|
+
basicInfo: info,
|
|
202
|
+
live: info.basic_info?.is_live || false,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return this.createResponse(null, [trackObj]);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
console.error("[YoutubeSabrExtractor handle error]", err);
|
|
209
|
+
return this.createResponse(null, []);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async stream(track) {
|
|
214
|
+
try {
|
|
215
|
+
if (!this.innertube)
|
|
216
|
+
throw new Error(
|
|
217
|
+
"Innertube not initialized; call activate() first",
|
|
218
|
+
);
|
|
219
|
+
const videoId = extractVideoId(track.url || track.raw?.id || "");
|
|
220
|
+
if (!videoId)
|
|
221
|
+
throw new Error("Unable to extract video id from track.url");
|
|
222
|
+
// Use the helper to create the SABR stream (returns Node.js readable)
|
|
223
|
+
const nodeStream = await createSabrStream(
|
|
224
|
+
videoId,
|
|
225
|
+
this.cookies,
|
|
226
|
+
this.logSabrEvents,
|
|
227
|
+
);
|
|
228
|
+
return nodeStream;
|
|
229
|
+
} catch (e) {
|
|
230
|
+
console.error(e);
|
|
231
|
+
throw e;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isUrl(input) {
|
|
237
|
+
try {
|
|
238
|
+
const url = new URL(input);
|
|
239
|
+
return ["https:", "http:"].includes(url.protocol);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function extractVideoId(vid) {
|
|
246
|
+
const YOUTUBE_REGEX =
|
|
247
|
+
/^https:\/\/(www\.)?youtu(\.be\/.{11}(.+)?|be\.com\/watch\?v=.{11}(&.+)?)/;
|
|
248
|
+
if (!YOUTUBE_REGEX.test(vid)) throw new Error("Invalid youtube url");
|
|
249
|
+
|
|
250
|
+
let id = new URL(vid).searchParams.get("v");
|
|
251
|
+
// VIDEO DETECTED AS YT SHORTS OR youtu.be link
|
|
252
|
+
if (!id) id = vid.split("/").at(-1)?.split("?").at(0);
|
|
253
|
+
|
|
254
|
+
return id;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function extractPlaylistId(url) {
|
|
258
|
+
try {
|
|
259
|
+
const u = new URL(url);
|
|
260
|
+
return u.searchParams.get("list");
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Try to resolve a youtu.be short link to the final canonical URL.
|
|
268
|
+
* Uses HEAD first, falls back to GET if needed. Returns the final URL string
|
|
269
|
+
* or the original input on failure.
|
|
270
|
+
*
|
|
271
|
+
* Note: relies on global fetch being available (Node 18+). If fetch isn't
|
|
272
|
+
* available in your environment, you can polyfill with node-fetch or use Innertube's fetch.
|
|
273
|
+
*/
|
|
274
|
+
async function resolveFinalUrl(input) {
|
|
275
|
+
try {
|
|
276
|
+
// prefer HEAD to minimize data, but some endpoints block HEAD so we fallback to GET
|
|
277
|
+
if (typeof fetch !== "function") return input;
|
|
278
|
+
|
|
279
|
+
// HEAD attempt
|
|
280
|
+
try {
|
|
281
|
+
const head = await fetch(input, {
|
|
282
|
+
method: "HEAD",
|
|
283
|
+
redirect: "follow",
|
|
284
|
+
});
|
|
285
|
+
if (head?.url) return head.url;
|
|
286
|
+
} catch (headErr) {
|
|
287
|
+
// ignore and try GET
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const get = await fetch(input, { method: "GET", redirect: "follow" });
|
|
291
|
+
return get?.url || input;
|
|
292
|
+
} catch (err) {
|
|
293
|
+
// anything fails -> return original
|
|
294
|
+
return input;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export { YoutubeSabrExtractor };
|