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 ADDED
@@ -0,0 +1,4 @@
1
+ # youtubei-es6
2
+ YouTube extractor from WD-40 bot converted to ES6
3
+
4
+ https://github.com/iTsMaaT/WD-40/tree/develop/utils/helpers/youtubei
@@ -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 };