@ziplayer/plugin 0.1.33 → 0.1.40
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 +180 -180
- package/YTSR_README.md +310 -310
- package/dist/TTSPlugin.js.map +1 -1
- package/dist/YTSRPlugin.d.ts.map +1 -1
- package/dist/YTSRPlugin.js +17 -1
- package/dist/YTSRPlugin.js.map +1 -1
- package/dist/YouTubePlugin.d.ts +15 -6
- package/dist/YouTubePlugin.d.ts.map +1 -1
- package/dist/YouTubePlugin.js +122 -47
- package/dist/YouTubePlugin.js.map +1 -1
- package/dist/utils/progress-bar.d.ts +8 -0
- package/dist/utils/progress-bar.d.ts.map +1 -0
- package/dist/utils/progress-bar.js +24 -0
- package/dist/utils/progress-bar.js.map +1 -0
- package/dist/utils/sabr-stream-factory.d.ts +25 -0
- package/dist/utils/sabr-stream-factory.d.ts.map +1 -0
- package/dist/utils/sabr-stream-factory.js +83 -0
- package/dist/utils/sabr-stream-factory.js.map +1 -0
- package/dist/utils/stream-converter.d.ts +10 -0
- package/dist/utils/stream-converter.d.ts.map +1 -0
- package/dist/utils/stream-converter.js +78 -0
- package/dist/utils/stream-converter.js.map +1 -0
- package/package.json +44 -42
- package/src/SoundCloudPlugin.ts +368 -368
- package/src/SpotifyPlugin.ts +312 -312
- package/src/TTSPlugin.ts +361 -361
- package/src/YTSRPlugin.ts +596 -583
- package/src/YouTubePlugin.ts +620 -528
- package/src/index.ts +103 -103
- package/src/types/googlevideo.d.ts +45 -0
- package/src/utils/sabr-stream-factory.ts +96 -0
- package/src/utils/stream-converter.ts +87 -0
- package/tsconfig.json +23 -23
package/src/YouTubePlugin.ts
CHANGED
|
@@ -1,528 +1,620 @@
|
|
|
1
|
-
import { BasePlugin, Track, SearchResult, StreamInfo } from "ziplayer";
|
|
2
|
-
|
|
3
|
-
import { Innertube, Log } from "youtubei.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
private
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
raw?.
|
|
106
|
-
raw?.
|
|
107
|
-
raw?.
|
|
108
|
-
raw?.
|
|
109
|
-
raw?.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
raw?.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* @
|
|
183
|
-
* plugin
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
if (
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
1
|
+
import { BasePlugin, Track, SearchResult, StreamInfo, Player } from "ziplayer";
|
|
2
|
+
|
|
3
|
+
import { Innertube, Log } from "youtubei.js";
|
|
4
|
+
import {
|
|
5
|
+
createSabrStream,
|
|
6
|
+
createOutputStream,
|
|
7
|
+
createStreamSink,
|
|
8
|
+
DEFAULT_SABR_OPTIONS,
|
|
9
|
+
type StreamResult,
|
|
10
|
+
} from "./utils/sabr-stream-factory";
|
|
11
|
+
import { webStreamToNodeStream } from "./utils/stream-converter";
|
|
12
|
+
|
|
13
|
+
export interface PluginOptions {
|
|
14
|
+
player: Player;
|
|
15
|
+
debug?: boolean;
|
|
16
|
+
searchClient?: Innertube;
|
|
17
|
+
client?: Innertube;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A plugin for handling YouTube audio content including videos, playlists, and search functionality.
|
|
22
|
+
*
|
|
23
|
+
* This plugin provides comprehensive support for:
|
|
24
|
+
* - YouTube video URLs (youtube.com, youtu.be, music.youtube.com)
|
|
25
|
+
* - YouTube playlist URLs and dynamic mixes
|
|
26
|
+
* - YouTube search queries
|
|
27
|
+
* - Audio stream extraction from YouTube videos
|
|
28
|
+
* - Related track recommendations
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* const youtubePlugin = new YouTubePlugin();
|
|
32
|
+
*
|
|
33
|
+
* // Add to PlayerManager
|
|
34
|
+
* const manager = new PlayerManager({
|
|
35
|
+
* plugins: [youtubePlugin]
|
|
36
|
+
* });
|
|
37
|
+
*
|
|
38
|
+
* // Search for videos
|
|
39
|
+
* const result = await youtubePlugin.search("Never Gonna Give You Up", "user123");
|
|
40
|
+
*
|
|
41
|
+
* // Get audio stream
|
|
42
|
+
* const stream = await youtubePlugin.getStream(result.tracks[0]);
|
|
43
|
+
*
|
|
44
|
+
* @since 1.0.0
|
|
45
|
+
*/
|
|
46
|
+
export class YouTubePlugin extends BasePlugin {
|
|
47
|
+
name = "youtube";
|
|
48
|
+
version = "1.0.0";
|
|
49
|
+
|
|
50
|
+
private client!: Innertube;
|
|
51
|
+
private searchClient!: Innertube;
|
|
52
|
+
private ready: Promise<void>;
|
|
53
|
+
private player: Player;
|
|
54
|
+
private options: PluginOptions;
|
|
55
|
+
/**
|
|
56
|
+
* Creates a new YouTubePlugin instance.
|
|
57
|
+
*
|
|
58
|
+
* The plugin will automatically initialize YouTube clients for both video playback
|
|
59
|
+
* and search functionality. Initialization is asynchronous and handled internally.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const plugin = new YouTubePlugin();
|
|
63
|
+
* // Plugin is ready to use after initialization completes
|
|
64
|
+
*/
|
|
65
|
+
constructor(options: PluginOptions) {
|
|
66
|
+
super();
|
|
67
|
+
this.player = options.player;
|
|
68
|
+
this.options = options;
|
|
69
|
+
this.ready = this.init();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async init(): Promise<void> {
|
|
73
|
+
this.client =
|
|
74
|
+
this.options.client ??
|
|
75
|
+
(await Innertube.create({
|
|
76
|
+
client_type: "ANDROID",
|
|
77
|
+
retrieve_player: false,
|
|
78
|
+
} as any));
|
|
79
|
+
|
|
80
|
+
// Use a separate web client for search to avoid mobile parser issues
|
|
81
|
+
this.searchClient =
|
|
82
|
+
this.options.searchClient ??
|
|
83
|
+
(await Innertube.create({
|
|
84
|
+
client_type: "WEB",
|
|
85
|
+
retrieve_player: false,
|
|
86
|
+
} as any));
|
|
87
|
+
Log.setLevel(0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private debug(message?: any, ...optionalParams: any[]): void {
|
|
91
|
+
if (this.options?.debug && this?.player && this.player?.listenerCount("debug") > 0) {
|
|
92
|
+
this.player.emit("debug", `[YouTubePlugin] ${message}`, ...optionalParams);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Build a Track from various YouTube object shapes (search item, playlist item, watch_next feed, basic_info, info)
|
|
96
|
+
private buildTrack(raw: any, requestedBy: string, extra?: { playlist?: string }): Track {
|
|
97
|
+
const pickFirst = (...vals: any[]) => vals.find((v) => v !== undefined && v !== null && v !== "");
|
|
98
|
+
|
|
99
|
+
// Try to resolve from multiple common shapes
|
|
100
|
+
const id = pickFirst(
|
|
101
|
+
raw?.id,
|
|
102
|
+
raw?.video_id,
|
|
103
|
+
raw?.videoId,
|
|
104
|
+
raw?.content_id,
|
|
105
|
+
raw?.identifier,
|
|
106
|
+
raw?.basic_info?.id,
|
|
107
|
+
raw?.basic_info?.video_id,
|
|
108
|
+
raw?.basic_info?.videoId,
|
|
109
|
+
raw?.basic_info?.content_id,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const title = pickFirst(
|
|
113
|
+
raw?.metadata?.title?.text,
|
|
114
|
+
raw?.title?.text,
|
|
115
|
+
raw?.title,
|
|
116
|
+
raw?.headline,
|
|
117
|
+
raw?.basic_info?.title,
|
|
118
|
+
"Unknown title",
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const durationValue = pickFirst(
|
|
122
|
+
raw?.length_seconds,
|
|
123
|
+
raw?.duration?.seconds,
|
|
124
|
+
raw?.duration?.text,
|
|
125
|
+
raw?.duration,
|
|
126
|
+
raw?.length_text,
|
|
127
|
+
raw?.basic_info?.duration,
|
|
128
|
+
);
|
|
129
|
+
const duration = Number(toSeconds(durationValue)) || 0;
|
|
130
|
+
|
|
131
|
+
const thumb = pickFirst(
|
|
132
|
+
raw?.thumbnails?.[0]?.url,
|
|
133
|
+
raw?.thumbnail?.[0]?.url,
|
|
134
|
+
raw?.thumbnail?.url,
|
|
135
|
+
raw?.thumbnail?.thumbnails?.[0]?.url,
|
|
136
|
+
raw?.content_image?.image?.[0]?.url,
|
|
137
|
+
raw?.basic_info?.thumbnail?.[0]?.url,
|
|
138
|
+
raw?.basic_info?.thumbnail?.[raw?.basic_info?.thumbnail?.length - 1]?.url,
|
|
139
|
+
raw?.thumbnails?.[raw?.thumbnails?.length - 1]?.url,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const author = pickFirst(raw?.author?.name, raw?.author, raw?.channel?.name, raw?.owner?.name, raw?.basic_info?.author);
|
|
143
|
+
|
|
144
|
+
const views = pickFirst(
|
|
145
|
+
raw?.view_count,
|
|
146
|
+
raw?.views,
|
|
147
|
+
raw?.short_view_count,
|
|
148
|
+
raw?.stats?.view_count,
|
|
149
|
+
raw?.basic_info?.view_count,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const url = pickFirst(raw?.url, id ? `https://www.youtube.com/watch?v=${id}` : undefined);
|
|
153
|
+
|
|
154
|
+
this.debug("Track build:", {
|
|
155
|
+
id: String(id),
|
|
156
|
+
title: String(title),
|
|
157
|
+
url: String(url),
|
|
158
|
+
duration,
|
|
159
|
+
thumbnail: thumb,
|
|
160
|
+
requestedBy,
|
|
161
|
+
source: this.name,
|
|
162
|
+
});
|
|
163
|
+
return {
|
|
164
|
+
id: String(id),
|
|
165
|
+
title: String(title),
|
|
166
|
+
url: String(url),
|
|
167
|
+
duration,
|
|
168
|
+
thumbnail: thumb,
|
|
169
|
+
requestedBy,
|
|
170
|
+
source: this.name,
|
|
171
|
+
metadata: {
|
|
172
|
+
author,
|
|
173
|
+
views,
|
|
174
|
+
...(extra?.playlist ? { playlist: extra.playlist } : {}),
|
|
175
|
+
},
|
|
176
|
+
} as Track;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Determines if this plugin can handle the given query.
|
|
181
|
+
*
|
|
182
|
+
* @param query - The search query or URL to check
|
|
183
|
+
* @returns `true` if the plugin can handle the query, `false` otherwise
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* plugin.canHandle("https://www.youtube.com/watch?v=dQw4w9WgXcQ"); // true
|
|
187
|
+
* plugin.canHandle("Never Gonna Give You Up"); // true
|
|
188
|
+
* plugin.canHandle("spotify:track:123"); // false
|
|
189
|
+
*/
|
|
190
|
+
canHandle(query: string): boolean {
|
|
191
|
+
const q = (query || "").trim().toLowerCase();
|
|
192
|
+
const isUrl = q.startsWith("http://") || q.startsWith("https://");
|
|
193
|
+
if (isUrl) {
|
|
194
|
+
try {
|
|
195
|
+
const parsed = new URL(query);
|
|
196
|
+
const allowedHosts = ["youtube.com", "www.youtube.com", "music.youtube.com", "youtu.be", "www.youtu.be"];
|
|
197
|
+
return allowedHosts.includes(parsed.hostname.toLowerCase());
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Avoid intercepting explicit patterns for other extractors
|
|
204
|
+
if (q.startsWith("tts:") || q.startsWith("say ")) return false;
|
|
205
|
+
if (q.startsWith("spotify:") || q.includes("open.spotify.com")) return false;
|
|
206
|
+
if (q.includes("soundcloud")) return false;
|
|
207
|
+
|
|
208
|
+
// Treat remaining non-URL free text as YouTube-searchable
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Validates if a URL is a valid YouTube URL.
|
|
214
|
+
*
|
|
215
|
+
* @param url - The URL to validate
|
|
216
|
+
* @returns `true` if the URL is a valid YouTube URL, `false` otherwise
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* plugin.validate("https://www.youtube.com/watch?v=dQw4w9WgXcQ"); // true
|
|
220
|
+
* plugin.validate("https://youtu.be/dQw4w9WgXcQ"); // true
|
|
221
|
+
* plugin.validate("https://spotify.com/track/123"); // false
|
|
222
|
+
*/
|
|
223
|
+
validate(url: string): boolean {
|
|
224
|
+
try {
|
|
225
|
+
const parsed = new URL(url);
|
|
226
|
+
const allowedHosts = ["youtube.com", "www.youtube.com", "music.youtube.com", "youtu.be", "www.youtu.be", "m.youtube.com"];
|
|
227
|
+
return allowedHosts.includes(parsed.hostname.toLowerCase());
|
|
228
|
+
} catch (e) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Searches for YouTube content based on the given query.
|
|
235
|
+
*
|
|
236
|
+
* This method handles both URL-based queries (direct video/playlist links) and
|
|
237
|
+
* text-based search queries. For URLs, it will extract video or playlist information.
|
|
238
|
+
* For text queries, it will perform a YouTube search and return up to 10 results.
|
|
239
|
+
*
|
|
240
|
+
* @param query - The search query (URL or text)
|
|
241
|
+
* @param requestedBy - The user ID who requested the search
|
|
242
|
+
* @returns A SearchResult containing tracks and optional playlist information
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* // Search by URL
|
|
246
|
+
* const result = await plugin.search("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "user123");
|
|
247
|
+
*
|
|
248
|
+
* // Search by text
|
|
249
|
+
* const searchResult = await plugin.search("Never Gonna Give You Up", "user123");
|
|
250
|
+
* console.log(searchResult.tracks); // Array of Track objects
|
|
251
|
+
*/
|
|
252
|
+
async search(query: string, requestedBy: string): Promise<SearchResult> {
|
|
253
|
+
await this.ready;
|
|
254
|
+
|
|
255
|
+
if (this.validate(query)) {
|
|
256
|
+
const listId = this.extractListId(query);
|
|
257
|
+
this.debug("List ID:", listId);
|
|
258
|
+
if (listId) {
|
|
259
|
+
if (this.isMixListId(listId)) {
|
|
260
|
+
const anchorVideoId = this.extractVideoId(query);
|
|
261
|
+
if (anchorVideoId) {
|
|
262
|
+
try {
|
|
263
|
+
this.debug("Getting info for anchor video ID:", anchorVideoId);
|
|
264
|
+
const info: any = await (this.searchClient as any).getInfo(anchorVideoId);
|
|
265
|
+
this.debug("Info:", info);
|
|
266
|
+
const feed: any[] = info?.watch_next_feed || [];
|
|
267
|
+
this.debug("Feed:", feed);
|
|
268
|
+
const tracks: Track[] = feed
|
|
269
|
+
.filter((tr: any) => tr?.content_type === "VIDEO")
|
|
270
|
+
.map((v: any) => this.buildTrack(v, requestedBy, { playlist: listId }));
|
|
271
|
+
this.debug("Tracks:", tracks);
|
|
272
|
+
const { basic_info } = info;
|
|
273
|
+
|
|
274
|
+
const currTrack = this.buildTrack(basic_info, requestedBy);
|
|
275
|
+
this.debug("Current track:", currTrack);
|
|
276
|
+
tracks.unshift(currTrack);
|
|
277
|
+
this.debug("Tracks:", tracks);
|
|
278
|
+
return {
|
|
279
|
+
tracks,
|
|
280
|
+
playlist: { name: "YouTube Mix", url: query, thumbnail: tracks[0]?.thumbnail },
|
|
281
|
+
};
|
|
282
|
+
} catch {
|
|
283
|
+
// ignore and fall back to normal playlist handling below
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const playlist: any = await (this.searchClient as any).getPlaylist(listId);
|
|
289
|
+
const videos: any[] = playlist?.videos || playlist?.items || [];
|
|
290
|
+
const tracks: Track[] = videos.map((v: any) => this.buildTrack(v, requestedBy, { playlist: listId }));
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
tracks,
|
|
294
|
+
playlist: {
|
|
295
|
+
name: playlist?.title || playlist?.metadata?.title || `Playlist ${listId}`,
|
|
296
|
+
url: query,
|
|
297
|
+
thumbnail: playlist?.thumbnails?.[0]?.url || playlist?.thumbnail?.url,
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
} catch {
|
|
301
|
+
const withoutList = query.replace(/[?&]list=[^&]+/, "").replace(/[?&]$/, "");
|
|
302
|
+
return await this.search(withoutList, requestedBy);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const videoId = this.extractVideoId(query);
|
|
307
|
+
if (!videoId) throw new Error("Invalid YouTube URL");
|
|
308
|
+
|
|
309
|
+
const info = await this.client.getBasicInfo(videoId);
|
|
310
|
+
const track = this.buildTrack(info, requestedBy);
|
|
311
|
+
return { tracks: [track] };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Text search → return up to 10 video tracks
|
|
315
|
+
const res: any = await this.searchClient.search(query, {
|
|
316
|
+
type: "video" as any,
|
|
317
|
+
});
|
|
318
|
+
const items: any[] = res?.items || res?.videos || res?.results || [];
|
|
319
|
+
|
|
320
|
+
const tracks: Track[] = items.slice(0, 10).map((v: any) => this.buildTrack(v, requestedBy));
|
|
321
|
+
|
|
322
|
+
return { tracks };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Extracts tracks from a YouTube playlist URL.
|
|
327
|
+
*
|
|
328
|
+
* @param url - The YouTube playlist URL
|
|
329
|
+
* @param requestedBy - The user ID who requested the extraction
|
|
330
|
+
* @returns An array of Track objects from the playlist
|
|
331
|
+
*
|
|
332
|
+
* @example
|
|
333
|
+
* const tracks = await plugin.extractPlaylist(
|
|
334
|
+
* "https://www.youtube.com/playlist?list=PLrAXtmRdnEQy6nuLMOV8uM0bMq3MUfHc1",
|
|
335
|
+
* "user123"
|
|
336
|
+
* );
|
|
337
|
+
* console.log(`Found ${tracks.length} tracks in playlist`);
|
|
338
|
+
*/
|
|
339
|
+
async extractPlaylist(url: string, requestedBy: string): Promise<Track[]> {
|
|
340
|
+
await this.ready;
|
|
341
|
+
|
|
342
|
+
const listId = this.extractListId(url);
|
|
343
|
+
if (!listId) return [];
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
// Attempt to handle dynamic Mix playlists via watch_next feed
|
|
347
|
+
if (this.isMixListId(listId)) {
|
|
348
|
+
const anchorVideoId = this.extractVideoId(url);
|
|
349
|
+
if (anchorVideoId) {
|
|
350
|
+
try {
|
|
351
|
+
const info: any = await (this.searchClient as any).getInfo(anchorVideoId);
|
|
352
|
+
const feed: any[] = info?.watch_next_feed || [];
|
|
353
|
+
return feed
|
|
354
|
+
.filter((tr: any) => tr?.content_type === "VIDEO")
|
|
355
|
+
.map((v: any) => this.buildTrack(v, requestedBy, { playlist: listId }));
|
|
356
|
+
} catch {}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const playlist: any = await (this.client as any).getPlaylist(listId);
|
|
361
|
+
const videos: any[] = playlist?.videos || playlist?.items || [];
|
|
362
|
+
return videos.map((v: any) => {
|
|
363
|
+
return this.buildTrack(v, requestedBy, { playlist: listId }); //ack;
|
|
364
|
+
});
|
|
365
|
+
} catch {
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Retrieves the audio stream for a YouTube track using sabr download.
|
|
372
|
+
*
|
|
373
|
+
* This method extracts the audio stream from a YouTube video using the sabr download
|
|
374
|
+
* method which provides better quality and more reliable streaming.
|
|
375
|
+
*
|
|
376
|
+
* @param track - The Track object to get the stream for
|
|
377
|
+
* @returns A StreamInfo object containing the audio stream and metadata
|
|
378
|
+
* @throws {Error} If the track ID is invalid or stream extraction fails
|
|
379
|
+
*
|
|
380
|
+
* @example
|
|
381
|
+
* const track = { id: "dQw4w9WgXcQ", title: "Never Gonna Give You Up", ... };
|
|
382
|
+
* const streamInfo = await plugin.getStream(track);
|
|
383
|
+
* console.log(streamInfo.type); // "arbitrary"
|
|
384
|
+
* console.log(streamInfo.stream); // Readable stream
|
|
385
|
+
*/
|
|
386
|
+
async getStream(track: Track): Promise<StreamInfo> {
|
|
387
|
+
await this.ready;
|
|
388
|
+
|
|
389
|
+
const id = this.extractVideoId(track.url) || track.id;
|
|
390
|
+
|
|
391
|
+
if (!id) throw new Error("Invalid track id");
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
this.debug("🚀 Attempting sabr download for video ID:", id);
|
|
395
|
+
// Use sabr download for better quality and reliability
|
|
396
|
+
const { streamResults } = await createSabrStream(id, DEFAULT_SABR_OPTIONS);
|
|
397
|
+
const { audioStream, selectedFormats, videoTitle } = streamResults;
|
|
398
|
+
|
|
399
|
+
this.debug("✅ Sabr download successful, converting Web Stream to Node.js Stream");
|
|
400
|
+
// Convert Web Stream to Node.js Readable Stream
|
|
401
|
+
const nodeStream = webStreamToNodeStream(audioStream);
|
|
402
|
+
|
|
403
|
+
this.debug("✅ Stream conversion complete, returning Node.js stream");
|
|
404
|
+
// Return the converted Node.js stream
|
|
405
|
+
return {
|
|
406
|
+
stream: nodeStream,
|
|
407
|
+
type: "arbitrary",
|
|
408
|
+
metadata: {
|
|
409
|
+
...track.metadata,
|
|
410
|
+
itag: selectedFormats.audioFormat.itag,
|
|
411
|
+
mime: selectedFormats.audioFormat.mimeType,
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
} catch (e: any) {
|
|
415
|
+
this.debug("⚠️ Sabr download failed, falling back to youtubei.js:", e.message);
|
|
416
|
+
// Fallback to original youtubei.js method if sabr download fails
|
|
417
|
+
try {
|
|
418
|
+
const stream: any = await (this.client as any).download(id, {
|
|
419
|
+
type: "audio",
|
|
420
|
+
quality: "best",
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Check if it's a Web Stream and convert it
|
|
424
|
+
this.debug("🔍 Checking stream type:", typeof stream, stream?.constructor?.name);
|
|
425
|
+
if (stream && typeof stream.getReader === "function") {
|
|
426
|
+
this.debug("🔄 Converting Web Stream to Node.js Stream");
|
|
427
|
+
const nodeStream = webStreamToNodeStream(stream);
|
|
428
|
+
this.debug("✅ Stream converted successfully");
|
|
429
|
+
return {
|
|
430
|
+
stream: nodeStream,
|
|
431
|
+
type: "arbitrary",
|
|
432
|
+
metadata: track.metadata,
|
|
433
|
+
};
|
|
434
|
+
} else {
|
|
435
|
+
this.debug("⚠️ Stream is not a Web Stream or is null");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
stream,
|
|
440
|
+
type: "arbitrary",
|
|
441
|
+
metadata: track.metadata,
|
|
442
|
+
};
|
|
443
|
+
} catch (fallbackError: any) {
|
|
444
|
+
try {
|
|
445
|
+
const info: any = await (this.client as any).getBasicInfo(id);
|
|
446
|
+
|
|
447
|
+
// Prefer m4a audio-only formats first
|
|
448
|
+
let format: any = info?.chooseFormat?.({
|
|
449
|
+
type: "audio",
|
|
450
|
+
quality: "best",
|
|
451
|
+
});
|
|
452
|
+
if (!format && info?.formats?.length) {
|
|
453
|
+
const audioOnly = info.formats.filter((f: any) => f.mime_type?.includes("audio"));
|
|
454
|
+
audioOnly.sort((a: any, b: any) => (b.bitrate || 0) - (a.bitrate || 0));
|
|
455
|
+
format = audioOnly[0];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (!format) throw new Error("No audio format available");
|
|
459
|
+
|
|
460
|
+
let url: string | undefined = undefined;
|
|
461
|
+
if (typeof format.decipher === "function") {
|
|
462
|
+
url = format.decipher((this.client as any).session.player);
|
|
463
|
+
}
|
|
464
|
+
if (!url) url = format.url;
|
|
465
|
+
|
|
466
|
+
if (!url) throw new Error("No valid URL to decipher");
|
|
467
|
+
const res = await fetch(url);
|
|
468
|
+
|
|
469
|
+
if (!res.ok || !res.body) {
|
|
470
|
+
throw new Error(`HTTP ${res.status}`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Convert Web Stream to Node.js Stream
|
|
474
|
+
this.debug("🔄 Converting fetch response Web Stream to Node.js Stream");
|
|
475
|
+
const nodeStream = webStreamToNodeStream(res.body);
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
stream: nodeStream,
|
|
479
|
+
type: "arbitrary",
|
|
480
|
+
metadata: {
|
|
481
|
+
...track.metadata,
|
|
482
|
+
itag: format.itag,
|
|
483
|
+
mime: format.mime_type,
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
} catch (inner: any) {
|
|
487
|
+
throw new Error(`Failed to get YouTube stream: ${inner?.message || inner}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Gets related tracks for a given YouTube video.
|
|
495
|
+
*
|
|
496
|
+
* This method fetches the "watch next" feed from YouTube to find related videos
|
|
497
|
+
* that are similar to the provided track. It can filter out tracks that are
|
|
498
|
+
* already in the history to avoid duplicates.
|
|
499
|
+
*
|
|
500
|
+
* @param trackURL - The YouTube video URL to get related tracks for
|
|
501
|
+
* @param opts - Options for filtering and limiting results
|
|
502
|
+
* @param opts.limit - Maximum number of related tracks to return (default: 5)
|
|
503
|
+
* @param opts.offset - Number of tracks to skip from the beginning (default: 0)
|
|
504
|
+
* @param opts.history - Array of tracks to exclude from results
|
|
505
|
+
* @returns An array of related Track objects
|
|
506
|
+
*
|
|
507
|
+
* @example
|
|
508
|
+
* const related = await plugin.getRelatedTracks(
|
|
509
|
+
* "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
510
|
+
* { limit: 3, history: [currentTrack] }
|
|
511
|
+
* );
|
|
512
|
+
* console.log(`Found ${related.length} related tracks`);
|
|
513
|
+
*/
|
|
514
|
+
async getRelatedTracks(trackURL: string, opts: { limit?: number; offset?: number; history?: Track[] } = {}): Promise<Track[]> {
|
|
515
|
+
await this.ready;
|
|
516
|
+
this.debug("Getting related tracks for:", trackURL);
|
|
517
|
+
const videoId = this.extractVideoId(trackURL);
|
|
518
|
+
this.debug("Video ID:", videoId);
|
|
519
|
+
if (!videoId) {
|
|
520
|
+
// If the last track URL is not a direct video URL (e.g., playlist URL),
|
|
521
|
+
// we cannot fetch related videos reliably.
|
|
522
|
+
return [];
|
|
523
|
+
}
|
|
524
|
+
this.debug("Getting info for video ID:", videoId);
|
|
525
|
+
const info: any = await await (this.searchClient as any).getInfo(videoId);
|
|
526
|
+
const related: any[] = info?.watch_next_feed || [];
|
|
527
|
+
this.debug("Related:", related);
|
|
528
|
+
const offset = opts.offset ?? 0;
|
|
529
|
+
const limit = opts.limit ?? 5;
|
|
530
|
+
|
|
531
|
+
const relatedfilter = related.filter(
|
|
532
|
+
(tr: any) => tr.content_type === "VIDEO" && !(opts?.history ?? []).some((t) => t.url === tr.url),
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
return relatedfilter.slice(offset, offset + limit).map((v: any) => this.buildTrack(v, "auto"));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Provides a fallback stream by searching for the track title.
|
|
540
|
+
*
|
|
541
|
+
* This method is used when the primary stream extraction fails. It performs
|
|
542
|
+
* a search using the track's title and attempts to get a stream from the
|
|
543
|
+
* first search result.
|
|
544
|
+
*
|
|
545
|
+
* @param track - The Track object to get a fallback stream for
|
|
546
|
+
* @returns A StreamInfo object containing the fallback audio stream
|
|
547
|
+
* @throws {Error} If no fallback track is found or stream extraction fails
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* try {
|
|
551
|
+
* const stream = await plugin.getStream(track);
|
|
552
|
+
* } catch (error) {
|
|
553
|
+
* // Try fallback
|
|
554
|
+
* const fallbackStream = await plugin.getFallback(track);
|
|
555
|
+
* }
|
|
556
|
+
*/
|
|
557
|
+
async getFallback(track: Track): Promise<StreamInfo> {
|
|
558
|
+
try {
|
|
559
|
+
const result = await this.search(track.title, track.requestedBy);
|
|
560
|
+
const first = result.tracks[0];
|
|
561
|
+
this.debug("Fallback track:", first);
|
|
562
|
+
if (!first) throw new Error("No fallback track found");
|
|
563
|
+
return await this.getStream(first);
|
|
564
|
+
} catch (e: any) {
|
|
565
|
+
throw new Error(`YouTube fallback search failed: ${e?.message || e}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private extractVideoId(input: string): string | null {
|
|
570
|
+
try {
|
|
571
|
+
const u = new URL(input);
|
|
572
|
+
const allowedShortHosts = ["youtu.be"];
|
|
573
|
+
const allowedLongHosts = ["youtube.com", "www.youtube.com", "music.youtube.com", "m.youtube.com"];
|
|
574
|
+
if (allowedShortHosts.includes(u.hostname)) {
|
|
575
|
+
return u.pathname.split("/").filter(Boolean)[0] || null;
|
|
576
|
+
}
|
|
577
|
+
if (allowedLongHosts.includes(u.hostname)) {
|
|
578
|
+
// watch?v=, shorts/, embed/
|
|
579
|
+
if (u.searchParams.get("v")) return u.searchParams.get("v");
|
|
580
|
+
const path = u.pathname;
|
|
581
|
+
if (path.startsWith("/shorts/")) return path.replace("/shorts/", "");
|
|
582
|
+
if (path.startsWith("/embed/")) return path.replace("/embed/", "");
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
} catch {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private isMixListId(listId: string): boolean {
|
|
591
|
+
// YouTube dynamic mixes typically start with 'RD'
|
|
592
|
+
return typeof listId === "string" && listId.toUpperCase().startsWith("RD");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private extractListId(input: string): string | null {
|
|
596
|
+
try {
|
|
597
|
+
const u = new URL(input);
|
|
598
|
+
return u.searchParams.get("list");
|
|
599
|
+
} catch {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
function toSeconds(d: any): number | undefined {
|
|
605
|
+
if (typeof d === "number") return d;
|
|
606
|
+
if (typeof d === "string") {
|
|
607
|
+
// mm:ss or hh:mm:ss
|
|
608
|
+
const parts = d.split(":").map(Number);
|
|
609
|
+
if (parts.some((n) => Number.isNaN(n))) return undefined;
|
|
610
|
+
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
611
|
+
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
|
612
|
+
const asNum = Number(d);
|
|
613
|
+
return Number.isFinite(asNum) ? asNum : undefined;
|
|
614
|
+
}
|
|
615
|
+
if (d && typeof d === "object") {
|
|
616
|
+
if (typeof (d as any).seconds === "number") return (d as any).seconds;
|
|
617
|
+
if (typeof (d as any).milliseconds === "number") return Math.floor((d as any).milliseconds / 1000);
|
|
618
|
+
}
|
|
619
|
+
return undefined;
|
|
620
|
+
}
|