@ziplayer/plugin
Version:
A modular Discord voice player with plugin system
470 lines • 19.7 kB
JavaScript
"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