@ziplayer/plugin
Version:
A modular Discord voice player with plugin system
529 lines (479 loc) • 17.7 kB
text/typescript
import { BasePlugin, Track, SearchResult, StreamInfo } from "ziplayer";
import { Innertube, Log } from "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
*/
export class YouTubePlugin extends BasePlugin {
name = "youtube";
version = "1.0.0";
private client!: Innertube;
private searchClient!: Innertube;
private ready: Promise<void>;
/**
* 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.ready = this.init();
}
private async init(): Promise<void> {
this.client = await Innertube.create({
client_type: "ANDROID",
retrieve_player: false,
} as any);
// Use a separate web client for search to avoid mobile parser issues
this.searchClient = await Innertube.create({
client_type: "WEB",
retrieve_player: false,
} as any);
Log.setLevel(0);
}
// Build a Track from various YouTube object shapes (search item, playlist item, watch_next feed, basic_info, info)
private buildTrack(raw: any, requestedBy: string, extra?: { playlist?: string }): Track {
const pickFirst = (...vals: any[]) => 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 } : {}),
},
} as Track;
}
/**
* 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: string): boolean {
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: string): boolean {
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: string, requestedBy: string): Promise<SearchResult> {
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: any = await (this.searchClient as any).getInfo(anchorVideoId);
const feed: any[] = info?.watch_next_feed || [];
const tracks: Track[] = feed
.filter((tr: any) => tr?.content_type === "VIDEO")
.map((v: any) => 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: any = await (this.searchClient as any).getPlaylist(listId);
const videos: any[] = playlist?.videos || playlist?.items || [];
const tracks: Track[] = videos.map((v: any) => 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: any = await this.searchClient.search(query, {
type: "video" as any,
});
const items: any[] = res?.items || res?.videos || res?.results || [];
const tracks: Track[] = items.slice(0, 10).map((v: any) => 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: string, requestedBy: string): Promise<Track[]> {
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: any = await (this.searchClient as any).getInfo(anchorVideoId);
const feed: any[] = info?.watch_next_feed || [];
return feed
.filter((tr: any) => tr?.content_type === "VIDEO")
.map((v: any) => this.buildTrack(v, requestedBy, { playlist: listId }));
} catch {}
}
}
const playlist: any = await (this.client as any).getPlaylist(listId);
const videos: any[] = playlist?.videos || playlist?.items || [];
return videos.map((v: any) => {
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: Track): Promise<StreamInfo> {
await this.ready;
const id = this.extractVideoId(track.url) || track.id;
if (!id) throw new Error("Invalid track id");
try {
const stream: any = await (this.client as any).download(id, {
type: "audio",
quality: "best",
});
return {
stream,
type: "arbitrary",
metadata: track.metadata,
};
} catch (e: any) {
try {
const info: any = await (this.client as any).getBasicInfo(id);
// Prefer m4a audio-only formats first
let format: any = info?.chooseFormat?.({
type: "audio",
quality: "best",
});
if (!format && info?.formats?.length) {
const audioOnly = info.formats.filter((f: any) => f.mime_type?.includes("audio"));
audioOnly.sort((a: any, b: any) => (b.bitrate || 0) - (a.bitrate || 0));
format = audioOnly[0];
}
if (!format) throw new Error("No audio format available");
let url: string | undefined = undefined;
if (typeof format.decipher === "function") {
url = format.decipher((this.client as any).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 as any,
type: "arbitrary",
metadata: {
...track.metadata,
itag: format.itag,
mime: format.mime_type,
},
};
} catch (inner: any) {
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: string, opts: { limit?: number; offset?: number; history?: Track[] } = {}): Promise<Track[]> {
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: any = await await (this.searchClient as any).getInfo(videoId);
const related: any[] = info?.watch_next_feed || [];
const offset = opts.offset ?? 0;
const limit = opts.limit ?? 5;
const relatedfilter = related.filter(
(tr: any) => tr.content_type === "VIDEO" && !(opts?.history ?? []).some((t) => t.url === tr.url),
);
return relatedfilter.slice(offset, offset + limit).map((v: any) => 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: Track): Promise<StreamInfo> {
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: any) {
throw new Error(`YouTube fallback search failed: ${e?.message || e}`);
}
}
private extractVideoId(input: string): string | null {
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;
}
}
private isMixListId(listId: string): boolean {
// YouTube dynamic mixes typically start with 'RD'
return typeof listId === "string" && listId.toUpperCase().startsWith("RD");
}
private extractListId(input: string): string | null {
try {
const u = new URL(input);
return u.searchParams.get("list");
} catch {
return null;
}
}
}
function toSeconds(d: any): number | undefined {
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 as any).seconds === "number") return (d as any).seconds;
if (typeof (d as any).milliseconds === "number") return Math.floor((d as any).milliseconds / 1000);
}
return undefined;
}