UNPKG

@ziplayer/plugin

Version:

A modular Discord voice player with plugin system

470 lines 19.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.YouTubePlugin = void 0; const ziplayer_1 = require("ziplayer"); const youtubei_js_1 = require("youtubei.js"); /** * A plugin for handling YouTube audio content including videos, playlists, and search functionality. * * This plugin provides comprehensive support for: * - YouTube video URLs (youtube.com, youtu.be, music.youtube.com) * - YouTube playlist URLs and dynamic mixes * - YouTube search queries * - Audio stream extraction from YouTube videos * - Related track recommendations * * @example * const youtubePlugin = new YouTubePlugin(); * * // Add to PlayerManager * const manager = new PlayerManager({ * plugins: [youtubePlugin] * }); * * // Search for videos * const result = await youtubePlugin.search("Never Gonna Give You Up", "user123"); * * // Get audio stream * const stream = await youtubePlugin.getStream(result.tracks[0]); * * @since 1.0.0 */ class YouTubePlugin extends ziplayer_1.BasePlugin { /** * Creates a new YouTubePlugin instance. * * The plugin will automatically initialize YouTube clients for both video playback * and search functionality. Initialization is asynchronous and handled internally. * * @example * const plugin = new YouTubePlugin(); * // Plugin is ready to use after initialization completes */ constructor() { super(); this.name = "youtube"; this.version = "1.0.0"; this.ready = this.init(); } async init() { this.client = await youtubei_js_1.Innertube.create({ client_type: "ANDROID", retrieve_player: false, }); // Use a separate web client for search to avoid mobile parser issues this.searchClient = await youtubei_js_1.Innertube.create({ client_type: "WEB", retrieve_player: false, }); youtubei_js_1.Log.setLevel(0); } // Build a Track from various YouTube object shapes (search item, playlist item, watch_next feed, basic_info, info) buildTrack(raw, requestedBy, extra) { const pickFirst = (...vals) => vals.find((v) => v !== undefined && v !== null && v !== ""); // Try to resolve from multiple common shapes const id = pickFirst(raw?.id, raw?.video_id, raw?.videoId, raw?.content_id, raw?.identifier, raw?.basic_info?.id, raw?.basic_info?.video_id, raw?.basic_info?.videoId, raw?.basic_info?.content_id); const title = pickFirst(raw?.metadata?.title?.text, raw?.title?.text, raw?.title, raw?.headline, raw?.basic_info?.title, "Unknown title"); const durationValue = pickFirst(raw?.length_seconds, raw?.duration?.seconds, raw?.duration?.text, raw?.duration, raw?.length_text, raw?.basic_info?.duration); const duration = Number(toSeconds(durationValue)) || 0; const thumb = pickFirst(raw?.thumbnails?.[0]?.url, raw?.thumbnail?.[0]?.url, raw?.thumbnail?.url, raw?.thumbnail?.thumbnails?.[0]?.url, raw?.content_image?.image?.[0]?.url, raw?.basic_info?.thumbnail?.[0]?.url, raw?.basic_info?.thumbnail?.[raw?.basic_info?.thumbnail?.length - 1]?.url, raw?.thumbnails?.[raw?.thumbnails?.length - 1]?.url); const author = pickFirst(raw?.author?.name, raw?.author, raw?.channel?.name, raw?.owner?.name, raw?.basic_info?.author); const views = pickFirst(raw?.view_count, raw?.views, raw?.short_view_count, raw?.stats?.view_count, raw?.basic_info?.view_count); const url = pickFirst(raw?.url, id ? `https://www.youtube.com/watch?v=${id}` : undefined); return { id: String(id), title: String(title), url: String(url), duration, thumbnail: thumb, requestedBy, source: this.name, metadata: { author, views, ...(extra?.playlist ? { playlist: extra.playlist } : {}), }, }; } /** * Determines if this plugin can handle the given query. * * @param query - The search query or URL to check * @returns `true` if the plugin can handle the query, `false` otherwise * * @example * plugin.canHandle("https://www.youtube.com/watch?v=dQw4w9WgXcQ"); // true * plugin.canHandle("Never Gonna Give You Up"); // true * plugin.canHandle("spotify:track:123"); // false */ canHandle(query) { const q = (query || "").trim().toLowerCase(); const isUrl = q.startsWith("http://") || q.startsWith("https://"); if (isUrl) { try { const parsed = new URL(query); const allowedHosts = ["youtube.com", "www.youtube.com", "music.youtube.com", "youtu.be", "www.youtu.be"]; return allowedHosts.includes(parsed.hostname.toLowerCase()); } catch (e) { return false; } } // Avoid intercepting explicit patterns for other extractors if (q.startsWith("tts:") || q.startsWith("say ")) return false; if (q.startsWith("spotify:") || q.includes("open.spotify.com")) return false; if (q.includes("soundcloud")) return false; // Treat remaining non-URL free text as YouTube-searchable return true; } /** * Validates if a URL is a valid YouTube URL. * * @param url - The URL to validate * @returns `true` if the URL is a valid YouTube URL, `false` otherwise * * @example * plugin.validate("https://www.youtube.com/watch?v=dQw4w9WgXcQ"); // true * plugin.validate("https://youtu.be/dQw4w9WgXcQ"); // true * plugin.validate("https://spotify.com/track/123"); // false */ validate(url) { try { const parsed = new URL(url); const allowedHosts = ["youtube.com", "www.youtube.com", "music.youtube.com", "youtu.be", "www.youtu.be", "m.youtube.com"]; return allowedHosts.includes(parsed.hostname.toLowerCase()); } catch (e) { return false; } } /** * Searches for YouTube content based on the given query. * * This method handles both URL-based queries (direct video/playlist links) and * text-based search queries. For URLs, it will extract video or playlist information. * For text queries, it will perform a YouTube search and return up to 10 results. * * @param query - The search query (URL or text) * @param requestedBy - The user ID who requested the search * @returns A SearchResult containing tracks and optional playlist information * * @example * // Search by URL * const result = await plugin.search("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "user123"); * * // Search by text * const searchResult = await plugin.search("Never Gonna Give You Up", "user123"); * console.log(searchResult.tracks); // Array of Track objects */ async search(query, requestedBy) { await this.ready; if (this.validate(query)) { const listId = this.extractListId(query); if (listId) { if (this.isMixListId(listId)) { const anchorVideoId = this.extractVideoId(query); if (anchorVideoId) { try { const info = await this.searchClient.getInfo(anchorVideoId); const feed = info?.watch_next_feed || []; const tracks = feed .filter((tr) => tr?.content_type === "VIDEO") .map((v) => this.buildTrack(v, requestedBy, { playlist: listId })); const { basic_info } = info; const currTrack = this.buildTrack(basic_info, requestedBy); tracks.unshift(currTrack); return { tracks, playlist: { name: "YouTube Mix", url: query, thumbnail: tracks[0]?.thumbnail }, }; } catch { // ignore and fall back to normal playlist handling below } } } try { const playlist = await this.searchClient.getPlaylist(listId); const videos = playlist?.videos || playlist?.items || []; const tracks = videos.map((v) => this.buildTrack(v, requestedBy, { playlist: listId })); return { tracks, playlist: { name: playlist?.title || playlist?.metadata?.title || `Playlist ${listId}`, url: query, thumbnail: playlist?.thumbnails?.[0]?.url || playlist?.thumbnail?.url, }, }; } catch { const withoutList = query.replace(/[?&]list=[^&]+/, "").replace(/[?&]$/, ""); return await this.search(withoutList, requestedBy); } } const videoId = this.extractVideoId(query); if (!videoId) throw new Error("Invalid YouTube URL"); const info = await this.client.getBasicInfo(videoId); const track = this.buildTrack(info, requestedBy); return { tracks: [track] }; } // Text search → return up to 10 video tracks const res = await this.searchClient.search(query, { type: "video", }); const items = res?.items || res?.videos || res?.results || []; const tracks = items.slice(0, 10).map((v) => this.buildTrack(v, requestedBy)); return { tracks }; } /** * Extracts tracks from a YouTube playlist URL. * * @param url - The YouTube playlist URL * @param requestedBy - The user ID who requested the extraction * @returns An array of Track objects from the playlist * * @example * const tracks = await plugin.extractPlaylist( * "https://www.youtube.com/playlist?list=PLrAXtmRdnEQy6nuLMOV8uM0bMq3MUfHc1", * "user123" * ); * console.log(`Found ${tracks.length} tracks in playlist`); */ async extractPlaylist(url, requestedBy) { await this.ready; const listId = this.extractListId(url); if (!listId) return []; try { // Attempt to handle dynamic Mix playlists via watch_next feed if (this.isMixListId(listId)) { const anchorVideoId = this.extractVideoId(url); if (anchorVideoId) { try { const info = await this.searchClient.getInfo(anchorVideoId); const feed = info?.watch_next_feed || []; return feed .filter((tr) => tr?.content_type === "VIDEO") .map((v) => this.buildTrack(v, requestedBy, { playlist: listId })); } catch { } } } const playlist = await this.client.getPlaylist(listId); const videos = playlist?.videos || playlist?.items || []; return videos.map((v) => { return this.buildTrack(v, requestedBy, { playlist: listId }); //ack; }); } catch { return []; } } /** * Retrieves the audio stream for a YouTube track. * * This method extracts the audio stream from a YouTube video using the YouTube client. * It attempts to get the best quality audio stream available and handles various * format fallbacks if the primary method fails. * * @param track - The Track object to get the stream for * @returns A StreamInfo object containing the audio stream and metadata * @throws {Error} If the track ID is invalid or stream extraction fails * * @example * const track = { id: "dQw4w9WgXcQ", title: "Never Gonna Give You Up", ... }; * const streamInfo = await plugin.getStream(track); * console.log(streamInfo.type); // "arbitrary" * console.log(streamInfo.stream); // Readable stream */ async getStream(track) { await this.ready; const id = this.extractVideoId(track.url) || track.id; if (!id) throw new Error("Invalid track id"); try { const stream = await this.client.download(id, { type: "audio", quality: "best", }); return { stream, type: "arbitrary", metadata: track.metadata, }; } catch (e) { try { const info = await this.client.getBasicInfo(id); // Prefer m4a audio-only formats first let format = info?.chooseFormat?.({ type: "audio", quality: "best", }); if (!format && info?.formats?.length) { const audioOnly = info.formats.filter((f) => f.mime_type?.includes("audio")); audioOnly.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)); format = audioOnly[0]; } if (!format) throw new Error("No audio format available"); let url = undefined; if (typeof format.decipher === "function") { url = format.decipher(this.client.session.player); } if (!url) url = format.url; if (!url) throw new Error("No valid URL to decipher"); const res = await fetch(url); if (!res.ok || !res.body) { throw new Error(`HTTP ${res.status}`); } return { stream: res.body, type: "arbitrary", metadata: { ...track.metadata, itag: format.itag, mime: format.mime_type, }, }; } catch (inner) { throw new Error(`Failed to get YouTube stream: ${inner?.message || inner}`); } } } /** * Gets related tracks for a given YouTube video. * * This method fetches the "watch next" feed from YouTube to find related videos * that are similar to the provided track. It can filter out tracks that are * already in the history to avoid duplicates. * * @param trackURL - The YouTube video URL to get related tracks for * @param opts - Options for filtering and limiting results * @param opts.limit - Maximum number of related tracks to return (default: 5) * @param opts.offset - Number of tracks to skip from the beginning (default: 0) * @param opts.history - Array of tracks to exclude from results * @returns An array of related Track objects * * @example * const related = await plugin.getRelatedTracks( * "https://www.youtube.com/watch?v=dQw4w9WgXcQ", * { limit: 3, history: [currentTrack] } * ); * console.log(`Found ${related.length} related tracks`); */ async getRelatedTracks(trackURL, opts = {}) { await this.ready; const videoId = this.extractVideoId(trackURL); if (!videoId) { // If the last track URL is not a direct video URL (e.g., playlist URL), // we cannot fetch related videos reliably. return []; } const info = await await this.searchClient.getInfo(videoId); const related = info?.watch_next_feed || []; const offset = opts.offset ?? 0; const limit = opts.limit ?? 5; const relatedfilter = related.filter((tr) => tr.content_type === "VIDEO" && !(opts?.history ?? []).some((t) => t.url === tr.url)); return relatedfilter.slice(offset, offset + limit).map((v) => this.buildTrack(v, "auto")); } /** * Provides a fallback stream by searching for the track title. * * This method is used when the primary stream extraction fails. It performs * a search using the track's title and attempts to get a stream from the * first search result. * * @param track - The Track object to get a fallback stream for * @returns A StreamInfo object containing the fallback audio stream * @throws {Error} If no fallback track is found or stream extraction fails * * @example * try { * const stream = await plugin.getStream(track); * } catch (error) { * // Try fallback * const fallbackStream = await plugin.getFallback(track); * } */ async getFallback(track) { try { const result = await this.search(track.title, track.requestedBy); const first = result.tracks[0]; if (!first) throw new Error("No fallback track found"); return await this.getStream(first); } catch (e) { throw new Error(`YouTube fallback search failed: ${e?.message || e}`); } } extractVideoId(input) { try { const u = new URL(input); const allowedShortHosts = ["youtu.be"]; const allowedLongHosts = ["youtube.com", "www.youtube.com", "music.youtube.com", "m.youtube.com"]; if (allowedShortHosts.includes(u.hostname)) { return u.pathname.split("/").filter(Boolean)[0] || null; } if (allowedLongHosts.includes(u.hostname)) { // watch?v=, shorts/, embed/ if (u.searchParams.get("v")) return u.searchParams.get("v"); const path = u.pathname; if (path.startsWith("/shorts/")) return path.replace("/shorts/", ""); if (path.startsWith("/embed/")) return path.replace("/embed/", ""); } return null; } catch { return null; } } isMixListId(listId) { // YouTube dynamic mixes typically start with 'RD' return typeof listId === "string" && listId.toUpperCase().startsWith("RD"); } extractListId(input) { try { const u = new URL(input); return u.searchParams.get("list"); } catch { return null; } } } exports.YouTubePlugin = YouTubePlugin; function toSeconds(d) { if (typeof d === "number") return d; if (typeof d === "string") { // mm:ss or hh:mm:ss const parts = d.split(":").map(Number); if (parts.some((n) => Number.isNaN(n))) return undefined; if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; if (parts.length === 2) return parts[0] * 60 + parts[1]; const asNum = Number(d); return Number.isFinite(asNum) ? asNum : undefined; } if (d && typeof d === "object") { if (typeof d.seconds === "number") return d.seconds; if (typeof d.milliseconds === "number") return Math.floor(d.milliseconds / 1000); } return undefined; } //# sourceMappingURL=YouTubePlugin.js.map