@ziplayer/plugin 0.1.52 → 0.2.1-dev-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,620 +1,591 @@
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 | undefined;
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 ?? undefined;
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
- }
1
+ import { BasePlugin, Track, SearchResult, StreamInfo, Player } from "ziplayer";
2
+
3
+ import { Innertube, Log, UniversalCache } from "youtubei.js";
4
+ import { createSabrStream, DEFAULT_SABR_OPTIONS } from "./utils/sabr-stream-factory";
5
+ import { webStreamToNodeStream } from "./utils/stream-converter";
6
+
7
+ export interface PluginOptions {
8
+ player: Player;
9
+ debug?: (message?: any, ...optionalParams: any[]) => any;
10
+ searchClient?: Innertube;
11
+ client?: Innertube;
12
+ searchLimit?: number;
13
+ clientType?: "WEB" | "ANDROID" | "IOS";
14
+ searchClientType?: "WEB" | "ANDROID" | "IOS";
15
+ fallbackStream?: (track: Track) => Promise<StreamInfo>;
16
+ }
17
+
18
+ /**
19
+ * A plugin for handling YouTube audio content including videos, playlists, and search functionality.
20
+ *
21
+ * This plugin provides comprehensive support for:
22
+ * - YouTube video URLs (youtube.com, youtu.be, music.youtube.com)
23
+ * - YouTube playlist URLs and dynamic mixes
24
+ * - YouTube search queries
25
+ * - Audio stream extraction from YouTube videos
26
+ * - Related track recommendations
27
+ *
28
+ * @example
29
+ * const youtubePlugin = new YouTubePlugin();
30
+ *
31
+ * // Add to PlayerManager
32
+ * const manager = new PlayerManager({
33
+ * plugins: [youtubePlugin]
34
+ * });
35
+ *
36
+ * // Search for videos
37
+ * const result = await youtubePlugin.search("Never Gonna Give You Up", "user123");
38
+ *
39
+ * // Get audio stream
40
+ * const stream = await youtubePlugin.getStream(result.tracks[0]);
41
+ *
42
+ * @since 1.0.0
43
+ */
44
+ export class YouTubePlugin extends BasePlugin {
45
+ name = "youtube";
46
+ version = "1.0.0";
47
+
48
+ private client!: Innertube;
49
+ private searchClient!: Innertube;
50
+ private ready: Promise<void>;
51
+ private player: Player | undefined;
52
+ private options: PluginOptions;
53
+ /**
54
+ * Creates a new YouTubePlugin instance.
55
+ *
56
+ * The plugin will automatically initialize YouTube clients for both video playback
57
+ * and search functionality. Initialization is asynchronous and handled internally.
58
+ *
59
+ * @example
60
+ * const plugin = new YouTubePlugin();
61
+ * // Plugin is ready to use after initialization completes
62
+ */
63
+ constructor(options: PluginOptions) {
64
+ super();
65
+ this.player = options?.player ?? undefined;
66
+ this.options = options ?? {};
67
+ this.ready = this.init();
68
+ }
69
+
70
+ private async init(): Promise<void> {
71
+ this.client =
72
+ this.options.client ??
73
+ (await Innertube.create({
74
+ cache: new UniversalCache(true),
75
+
76
+ client_type: this.options.clientType || "WEB_REMIX",
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: this.options.searchClientType || "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?.player && this.player?.listenerCount("debug") > 0) {
92
+ this.player.emit("debug", `[YouTubePlugin] ${message}`, ...optionalParams);
93
+ }
94
+ if (this.options.debug) this.options.debug(`[YouTubePlugin] ${message}`, ...optionalParams);
95
+ }
96
+ // Build a Track from various YouTube object shapes (search item, playlist item, watch_next feed, basic_info, info)
97
+ private buildTrack(raw: any, requestedBy: string, extra?: { playlist?: string }): Track {
98
+ const pickFirst = (...vals: any[]) => vals.find((v) => v !== undefined && v !== null && v !== "");
99
+
100
+ // Try to resolve from multiple common shapes
101
+ const id = pickFirst(
102
+ raw?.id,
103
+ raw?.video_id,
104
+ raw?.videoId,
105
+ raw?.content_id,
106
+ raw?.identifier,
107
+ raw?.basic_info?.id,
108
+ raw?.basic_info?.video_id,
109
+ raw?.basic_info?.videoId,
110
+ raw?.basic_info?.content_id,
111
+ );
112
+
113
+ const title = pickFirst(
114
+ raw?.metadata?.title?.text,
115
+ raw?.title?.text,
116
+ raw?.title,
117
+ raw?.headline,
118
+ raw?.basic_info?.title,
119
+ "Unknown title",
120
+ );
121
+
122
+ const duration = pickFirst(
123
+ raw?.length_seconds,
124
+ raw?.duration?.seconds,
125
+ raw?.duration?.text,
126
+ raw?.duration,
127
+ raw?.length_text,
128
+ raw?.basic_info?.duration,
129
+ );
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
+ const res: any = await this.searchClient.search(videoId, {
309
+ type: "video" as any,
310
+ });
311
+ const items: any[] = res?.items || res?.videos || res?.results || [];
312
+
313
+ const tracks: Track[] = items.slice(0, this.options?.searchLimit ?? 10).map((v: any) => this.buildTrack(v, requestedBy));
314
+ return { tracks };
315
+ }
316
+
317
+ if (this.canHandle(query) === false) return { tracks: [] };
318
+
319
+ // Text search → return up to 10 video tracks
320
+ const res: any = await this.searchClient.search(query, {
321
+ type: "video" as any,
322
+ });
323
+ const items: any[] = res?.items || res?.videos || res?.results || [];
324
+
325
+ const tracks: Track[] = items.slice(0, this.options?.searchLimit ?? 10).map((v: any) => this.buildTrack(v, requestedBy));
326
+
327
+ return { tracks };
328
+ }
329
+
330
+ /**
331
+ * Extracts tracks from a YouTube playlist URL.
332
+ *
333
+ * @param url - The YouTube playlist URL
334
+ * @param requestedBy - The user ID who requested the extraction
335
+ * @returns An array of Track objects from the playlist
336
+ *
337
+ * @example
338
+ * const tracks = await plugin.extractPlaylist(
339
+ * "https://www.youtube.com/playlist?list=PLrAXtmRdnEQy6nuLMOV8uM0bMq3MUfHc1",
340
+ * "user123"
341
+ * );
342
+ * console.log(`Found ${tracks.length} tracks in playlist`);
343
+ */
344
+ async extractPlaylist(url: string, requestedBy: string): Promise<Track[]> {
345
+ await this.ready;
346
+
347
+ const listId = this.extractListId(url);
348
+ if (!listId) return [];
349
+
350
+ try {
351
+ // Attempt to handle dynamic Mix playlists via watch_next feed
352
+ if (this.isMixListId(listId)) {
353
+ const anchorVideoId = this.extractVideoId(url);
354
+ if (anchorVideoId) {
355
+ try {
356
+ const info: any = await (this.searchClient as any).getInfo(anchorVideoId);
357
+ const feed: any[] = info?.watch_next_feed || [];
358
+ return feed
359
+ .filter((tr: any) => tr?.content_type === "VIDEO")
360
+ .map((v: any) => this.buildTrack(v, requestedBy, { playlist: listId }));
361
+ } catch {}
362
+ }
363
+ }
364
+
365
+ const playlist: any = await (this.client as any).getPlaylist(listId);
366
+ const videos: any[] = playlist?.videos || playlist?.items || [];
367
+ return videos.map((v: any) => {
368
+ return this.buildTrack(v, requestedBy, { playlist: listId }); //ack;
369
+ });
370
+ } catch {
371
+ return [];
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Retrieves the audio stream for a YouTube track using sabr download.
377
+ *
378
+ * This method extracts the audio stream from a YouTube video using the sabr download
379
+ * method which provides better quality and more reliable streaming.
380
+ *
381
+ * @param track - The Track object to get the stream for
382
+ * @returns A StreamInfo object containing the audio stream and metadata
383
+ * @throws {Error} If the track ID is invalid or stream extraction fails
384
+ *
385
+ * @example
386
+ * const track = { id: "dQw4w9WgXcQ", title: "Never Gonna Give You Up", ... };
387
+ * const streamInfo = await plugin.getStream(track);
388
+ * console.log(streamInfo.type); // "arbitrary"
389
+ * console.log(streamInfo.stream); // Readable stream
390
+ */
391
+ async getStream(track: Track): Promise<StreamInfo> {
392
+ await this.ready;
393
+
394
+ const id = this.extractVideoId(track.url) || track.id;
395
+
396
+ if (!id) throw new Error("Invalid track id");
397
+
398
+ try {
399
+ this.debug("🚀 Attempting sabr download for video ID:", id);
400
+ // Use sabr download for better quality and reliability
401
+ // Pass optimized options for memory efficiency
402
+ const sabrOptions = { ...DEFAULT_SABR_OPTIONS };
403
+ const { stream, title, format } = await createSabrStream(id, this.client, sabrOptions);
404
+
405
+ this.debug("✅ Sabr download successful, stream ready");
406
+
407
+ if (!stream) {
408
+ throw new Error("Sabr download did not return a stream");
409
+ }
410
+
411
+ // Add error handler to prevent unhandled rejections from SABR
412
+ stream.on("error", (error: Error) => {
413
+ const errorMsg = error.message || String(error);
414
+ // Log but suppress "Controller is already closed" errors as they're expected during cleanup
415
+ if (!errorMsg.includes("Controller is already closed")) {
416
+ this.debug("⚠️ SABR stream error:", errorMsg);
417
+ }
418
+ });
419
+
420
+ return {
421
+ stream: stream,
422
+ type: "arbitrary",
423
+ metadata: {
424
+ ...track.metadata,
425
+ itag: format.itag,
426
+ mime: format.mimeType,
427
+ },
428
+ };
429
+ } catch (e: any) {
430
+ this.debug("⚠️ Sabr download failed, falling back to youtubei.js:", e.message);
431
+ if (this.options?.fallbackStream && typeof this.options.fallbackStream === "function") {
432
+ this.debug("🔁 Attempting user-provided fallback stream method");
433
+ const fbStream = await this.options.fallbackStream(track);
434
+ if (fbStream && fbStream.stream) {
435
+ this.debug(" User-provided fallback stream successful");
436
+ return fbStream;
437
+ } else {
438
+ this.debug("⚠️ User-provided fallback stream failed or returned invalid stream");
439
+ }
440
+ }
441
+
442
+ // Fallback: Use memory-optimized quality (high instead of best to reduce bandwidth by ~40%)
443
+ const stream = await this.client.download(id, {
444
+ type: "audio",
445
+ quality: "high", // Changed from "best" to reduce memory usage
446
+ });
447
+
448
+ // Check if it's a Web Stream and convert it
449
+ this.debug("🔍 Checking stream type:", typeof stream, stream?.constructor?.name);
450
+ if (stream && typeof stream.getReader === "function") {
451
+ this.debug("🔄 Converting Web Stream to Node.js Stream with backpressure handling");
452
+ const nodeStream = webStreamToNodeStream(stream, 32 * 1024); // Optimized buffer size
453
+
454
+ // Add error handler to prevent unhandled rejections
455
+ nodeStream.on("error", (error: Error) => {
456
+ const errorMsg = error.message || String(error);
457
+ if (!errorMsg.includes("Controller is already closed")) {
458
+ this.debug("⚠️ Fallback stream error:", errorMsg);
459
+ }
460
+ });
461
+
462
+ this.debug("✅ Stream converted successfully");
463
+ return {
464
+ stream: nodeStream,
465
+ type: "arbitrary",
466
+ metadata: track.metadata,
467
+ };
468
+ } else {
469
+ this.debug("⚠️ Stream is not a Web Stream or is null");
470
+ }
471
+
472
+ // Final fallback - just return the stream with optimized buffer
473
+ return {
474
+ stream: webStreamToNodeStream(stream, 32 * 1024),
475
+ type: "arbitrary",
476
+ metadata: track.metadata,
477
+ };
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Gets related tracks for a given YouTube video.
483
+ *
484
+ * This method fetches the "watch next" feed from YouTube to find related videos
485
+ * that are similar to the provided track. It can filter out tracks that are
486
+ * already in the history to avoid duplicates.
487
+ *
488
+ * @param trackURL - The YouTube video URL to get related tracks for
489
+ * @param opts - Options for filtering and limiting results
490
+ * @param opts.limit - Maximum number of related tracks to return (default: 5)
491
+ * @param opts.offset - Number of tracks to skip from the beginning (default: 0)
492
+ * @param opts.history - Array of tracks to exclude from results
493
+ * @returns An array of related Track objects
494
+ *
495
+ * @example
496
+ * const related = await plugin.getRelatedTracks(
497
+ * "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
498
+ * { limit: 3, history: [currentTrack] }
499
+ * );
500
+ * console.log(`Found ${related.length} related tracks`);
501
+ */
502
+ async getRelatedTracks(trackURL: string, opts: { limit?: number; offset?: number; history?: Track[] } = {}): Promise<Track[]> {
503
+ await this.ready;
504
+ this.debug("Getting related tracks for:", trackURL);
505
+ const videoId = this.extractVideoId(trackURL);
506
+ this.debug("Video ID:", videoId);
507
+ if (!videoId) {
508
+ // If the last track URL is not a direct video URL (e.g., playlist URL),
509
+ // we cannot fetch related videos reliably.
510
+ return [];
511
+ }
512
+ this.debug("Getting info for video ID:", videoId);
513
+ const info: any = await await (this.searchClient as any).getInfo(videoId);
514
+ const related: any[] = info?.watch_next_feed || [];
515
+ this.debug("Related:", related);
516
+ const offset = opts?.offset ?? 0;
517
+ const limit = opts?.limit ?? this.options?.searchLimit ?? 10;
518
+
519
+ const relatedfilter = related.filter(
520
+ (tr: any) => tr.content_type === "VIDEO" && !(opts?.history ?? []).some((t) => t.url === tr.url),
521
+ );
522
+
523
+ return relatedfilter.slice(offset, offset + limit).map((v: any) => this.buildTrack(v, "auto"));
524
+ }
525
+
526
+ /**
527
+ * Provides a fallback stream by searching for the track title.
528
+ *
529
+ * This method is used when the primary stream extraction fails. It performs
530
+ * a search using the track's title and attempts to get a stream from the
531
+ * first search result.
532
+ *
533
+ * @param track - The Track object to get a fallback stream for
534
+ * @returns A StreamInfo object containing the fallback audio stream
535
+ * @throws {Error} If no fallback track is found or stream extraction fails
536
+ *
537
+ * @example
538
+ * try {
539
+ * const stream = await plugin.getStream(track);
540
+ * } catch (error) {
541
+ * // Try fallback
542
+ * const fallbackStream = await plugin.getFallback(track);
543
+ * }
544
+ */
545
+ async getFallback(track: Track): Promise<StreamInfo> {
546
+ try {
547
+ const result = await this.search(track.title, track.requestedBy);
548
+ const first = result.tracks[0];
549
+ this.debug("Fallback track:", first);
550
+ if (!first) throw new Error("No fallback track found");
551
+ return await this.getStream(first);
552
+ } catch (e: any) {
553
+ throw new Error(`YouTube fallback search failed: ${e?.message || e}`);
554
+ }
555
+ }
556
+
557
+ private extractVideoId(input: string): string | null {
558
+ try {
559
+ const u = new URL(input);
560
+ const allowedShortHosts = ["youtu.be"];
561
+ const allowedLongHosts = ["youtube.com", "www.youtube.com", "music.youtube.com", "m.youtube.com"];
562
+ if (allowedShortHosts.includes(u.hostname)) {
563
+ return u.pathname.split("/").filter(Boolean)[0] || null;
564
+ }
565
+ if (allowedLongHosts.includes(u.hostname)) {
566
+ // watch?v=, shorts/, embed/
567
+ if (u.searchParams.get("v")) return u.searchParams.get("v");
568
+ const path = u.pathname;
569
+ if (path.startsWith("/shorts/")) return path.replace("/shorts/", "");
570
+ if (path.startsWith("/embed/")) return path.replace("/embed/", "");
571
+ }
572
+ return null;
573
+ } catch {
574
+ return null;
575
+ }
576
+ }
577
+
578
+ private isMixListId(listId: string): boolean {
579
+ // YouTube dynamic mixes typically start with 'RD'
580
+ return typeof listId === "string" && listId.toUpperCase().startsWith("RD");
581
+ }
582
+
583
+ private extractListId(input: string): string | null {
584
+ try {
585
+ const u = new URL(input);
586
+ return u.searchParams.get("list");
587
+ } catch {
588
+ return null;
589
+ }
590
+ }
591
+ }