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