UNPKG

discord-player-ytdlp

Version:

Discord Player extractor that utilizing yt-dlp

1 lines 69.4 kB
{"version":3,"sources":["../utils.js","../index.js"],"sourcesContent":["/**\n * YtDlp Extractor Utilities\n * Helper functions for the custom YtDlp-Youtubei hybrid extractor\n */\n\nconst { exec } = require('child_process');\nconst { promisify } = require('util');\nconst fs = require('fs');\nconst path = require('path');\nconst { Innertube } = require('youtubei.js');\n\nconst execAsync = promisify(exec);\n\n/**\n * Initialize YouTube service\n */\nlet innertube = null;\nlet lastCookies = null;\n\nconst initializeYouTube = async (options = {}) => {\n // Check if we need to reinitialize due to different cookies\n const currentCookies = options.cookies;\n const needsReinit = !innertube || (currentCookies !== lastCookies);\n\n if (needsReinit) {\n try {\n const initOptions = {};\n\n // Add cookies if provided\n if (options.cookies) {\n // Handle different cookie formats\n if (typeof options.cookies === 'string') {\n // If it's a string, assume it's in Netscape format or raw cookie string\n initOptions.cookie = options.cookies;\n } else {\n // If it's already an object or array, pass it directly\n initOptions.cookie = options.cookies;\n }\n }\n\n // Add client configuration if provided\n if (options.client) {\n initOptions.client_name = options.client;\n }\n\n // Add additional options for better private content access\n initOptions.enable_session_cache = true;\n\n // Close existing instance if reinitializing\n if (innertube) {\n try {\n await innertube.session.signOut();\n } catch (e) {\n // Ignore sign out errors\n }\n }\n\n innertube = await Innertube.create(initOptions);\n lastCookies = currentCookies;\n } catch (error) {\n console.error('❌ Failed to initialize YouTube service for extractor:', error);\n // Don't throw here, return null to allow fallback\n innertube = null;\n }\n }\n return innertube;\n};\n\n/**\n * Check if a string is a valid URL\n */\nconst isValidUrl = (string) => {\n try {\n new URL(string);\n return true;\n } catch (_) {\n return false;\n }\n};\n\n/**\n * Check if URL is a YouTube URL\n */\nconst isYouTubeUrl = (url) => {\n const youtubeRegex = /^(https?:\\/\\/)?(www\\.)?(youtube\\.com|youtu\\.be|m\\.youtube\\.com)/i;\n return youtubeRegex.test(url);\n};\n\n/**\n * Check if URL is a YouTube playlist URL\n */\nconst isYouTubePlaylistUrl = (url) => {\n const playlistRegex = /[?&]list=([a-zA-Z0-9_-]+)/;\n return isYouTubeUrl(url) && playlistRegex.test(url);\n};\n\n/**\n * Extract playlist ID from YouTube URL\n */\nconst extractYouTubePlaylistId = (url) => {\n const regex = /[?&]list=([a-zA-Z0-9_-]+)/;\n const match = url.match(regex);\n return match ? match[1] : null;\n};\n\n/**\n * Extract video ID from YouTube URL\n */\nconst extractYouTubeId = (url) => {\n const regex = /(?:youtube\\.com\\/(?:[^\\/]+\\/.+\\/|(?:v|e(?:mbed)?)\\/|.*[?&]v=)|youtu\\.be\\/)([^\"&?\\/\\s]{11})/;\n const match = url.match(regex);\n return match ? match[1] : null;\n};\n\n/**\n * Search YouTube using youtubei.js\n */\nconst searchYouTube = async (query, limit = 1, options = {}) => {\n try {\n const yt = await initializeYouTube(options);\n if (!yt) {\n throw new Error('YouTube service not available');\n }\n\n // Add timeout to prevent hanging\n const searchPromise = yt.search(query, { type: 'video' });\n const timeoutPromise = new Promise((_, reject) =>\n setTimeout(() => reject(new Error('YouTube search timeout')), 10000)\n );\n\n const searchResults = await Promise.race([searchPromise, timeoutPromise]);\n\n if (!searchResults.videos || searchResults.videos.length === 0) {\n return [];\n }\n\n const results = searchResults.videos.slice(0, limit).map(video => ({\n id: video.id,\n title: video.title?.text || 'Unknown Title',\n duration: video.duration?.text || 'Unknown',\n thumbnail: video.thumbnails?.[0]?.url || null,\n url: `https://www.youtube.com/watch?v=${video.id}`,\n author: video.author?.name || 'Unknown Artist',\n views: video.view_count?.text || '0'\n }));\n\n return results;\n } catch (error) {\n console.error('YouTube search error:', error);\n return [];\n }\n};\n\n/**\n * Get YouTube playlist information and tracks\n */\nconst getYouTubePlaylist = async (playlistId, options = {}) => {\n try {\n // Check if this is a YouTube Mix playlist (starts with RD)\n if (playlistId.startsWith('RD')) {\n // For Mix playlists, we need to use a different approach\n // Extract the seed video ID from the Mix playlist ID\n let seedVideoId = null;\n if (playlistId.length > 2) {\n seedVideoId = playlistId.substring(2); // Remove 'RD' prefix\n }\n\n if (!seedVideoId || seedVideoId.length !== 11) {\n throw new Error('Invalid YouTube Mix playlist ID format');\n }\n\n // For Mix playlists, we'll get the seed video and generate related tracks\n // This is a workaround since Mix playlists are dynamically generated\n const yt = await initializeYouTube(options);\n if (!yt) {\n throw new Error('YouTube service not available');\n }\n\n try {\n // Get the seed video info\n const seedVideo = await yt.getInfo(seedVideoId);\n if (!seedVideo) {\n throw new Error('Seed video not found');\n }\n\n // Create a basic playlist structure with the seed video\n const tracks = [{\n id: seedVideoId,\n title: seedVideo.basic_info?.title || 'Unknown Title',\n duration: seedVideo.basic_info?.duration?.text || 'Unknown',\n thumbnail: seedVideo.basic_info?.thumbnail?.[0]?.url || null,\n url: `https://www.youtube.com/watch?v=${seedVideoId}`,\n author: seedVideo.basic_info?.author || 'Unknown Artist',\n views: seedVideo.basic_info?.view_count || '0'\n }];\n\n // Try to get related videos to simulate the Mix\n if (seedVideo.watch_next_feed) {\n const relatedVideos = seedVideo.watch_next_feed\n .filter(item => item.type === 'CompactVideo' && item.id && item.title)\n .slice(0, 19) // Get up to 19 more videos (20 total)\n .map(video => ({\n id: video.id,\n title: video.title?.text || 'Unknown Title',\n duration: video.duration?.text || 'Unknown',\n thumbnail: video.thumbnails?.[0]?.url || null,\n url: `https://www.youtube.com/watch?v=${video.id}`,\n author: video.author?.name || 'Unknown Artist',\n views: video.view_count?.text || '0'\n }));\n\n tracks.push(...relatedVideos);\n }\n\n return {\n id: playlistId,\n title: `Mix - ${seedVideo.basic_info?.title || 'Unknown'}`,\n description: 'YouTube Mix playlist (auto-generated)',\n thumbnail: seedVideo.basic_info?.thumbnail?.[0]?.url || null,\n author: 'YouTube',\n url: `https://www.youtube.com/playlist?list=${playlistId}`,\n tracks: tracks\n };\n } catch (mixError) {\n console.error('Mix playlist generation error:', mixError);\n throw new Error('Unable to access Mix playlist. This may be a private or unavailable Mix.');\n }\n }\n\n const yt = await initializeYouTube(options);\n if (!yt) {\n throw new Error('YouTube service not available');\n }\n\n // Add timeout for playlist requests\n const playlistPromise = yt.getPlaylist(playlistId);\n const timeoutPromise = new Promise((_, reject) =>\n setTimeout(() => reject(new Error('Playlist request timeout')), 15000)\n );\n\n const playlist = await Promise.race([playlistPromise, timeoutPromise]);\n\n if (!playlist) {\n throw new Error('Playlist not found or inaccessible');\n }\n\n // Check if playlist is accessible\n if (!playlist.videos && !playlist.items) {\n throw new Error('Playlist is private or unavailable. Please check your authentication.');\n }\n\n const tracks = [];\n\n // Handle different playlist response formats\n const videos = playlist.videos || playlist.items || [];\n\n if (videos.length === 0) {\n return {\n id: playlistId,\n title: playlist.info?.title || playlist.title?.text || 'Unknown Playlist',\n description: playlist.info?.description || playlist.description?.text || '',\n thumbnail: playlist.info?.thumbnails?.[0]?.url || playlist.thumbnails?.[0]?.url || null,\n author: playlist.info?.author?.name || playlist.author?.name || 'Unknown',\n url: `https://www.youtube.com/playlist?list=${playlistId}`,\n tracks: []\n };\n }\n\n // Process videos in batches to avoid overwhelming the API\n const batchSize = 10;\n\n for (let i = 0; i < videos.length; i += batchSize) {\n const batch = videos.slice(i, i + batchSize);\n const batchPromises = batch.map(async (video) => {\n try {\n // Handle different video object formats\n const videoId = video.id || video.video_id;\n const title = video.title?.text || video.title || 'Unknown Title';\n const author = video.author?.name || video.channel?.name || 'Unknown Artist';\n const duration = video.duration?.text || video.duration || 'Unknown';\n const thumbnail = video.thumbnails?.[0]?.url || video.thumbnail?.url || null;\n const views = video.view_count?.text || video.views || '0';\n\n if (!videoId) {\n return null;\n }\n\n return {\n id: videoId,\n title: title,\n duration: duration,\n thumbnail: thumbnail,\n url: `https://www.youtube.com/watch?v=${videoId}`,\n author: author,\n views: views\n };\n } catch (error) {\n return null;\n }\n });\n\n const batchResults = await Promise.allSettled(batchPromises);\n const validResults = batchResults\n .filter(result => result.status === 'fulfilled' && result.value !== null)\n .map(result => result.value);\n\n tracks.push(...validResults);\n }\n\n return {\n id: playlistId,\n title: playlist.info?.title || playlist.title?.text || 'Unknown Playlist',\n description: playlist.info?.description || playlist.description?.text || '',\n thumbnail: playlist.info?.thumbnails?.[0]?.url || playlist.thumbnails?.[0]?.url || null,\n author: playlist.info?.author?.name || playlist.author?.name || 'Unknown',\n url: `https://www.youtube.com/playlist?list=${playlistId}`,\n tracks: tracks\n };\n } catch (error) {\n console.error('YouTube playlist error:', error);\n\n // Provide more specific error messages\n if (error.message.includes('unviewable') || error.message.includes('private')) {\n throw new Error('This playlist is private or requires authentication. Please check your YouTube cookies.');\n } else if (error.message.includes('timeout')) {\n throw new Error('Playlist request timed out. Please try again.');\n } else if (error.message.includes('not found')) {\n throw new Error('Playlist not found. Please check the playlist ID.');\n }\n\n throw error;\n }\n};\n\n/**\n * Get YouTube video metadata using youtubei.js\n */\nconst getYouTubeMetadata = async (videoId, options = {}) => {\n try {\n const yt = await initializeYouTube(options);\n if (!yt) {\n throw new Error('YouTube service not available');\n }\n\n const info = await yt.getInfo(videoId);\n if (!info) {\n throw new Error('Video not found');\n }\n\n // Better duration handling - try multiple sources\n let duration = 'Unknown';\n if (info.basic_info?.duration) {\n if (typeof info.basic_info.duration === 'object') {\n // If duration is an object with seconds property\n if (info.basic_info.duration.seconds) {\n duration = formatDuration(info.basic_info.duration.seconds);\n } else if (info.basic_info.duration.text) {\n duration = info.basic_info.duration.text;\n }\n } else if (typeof info.basic_info.duration === 'number') {\n // If duration is a number (seconds)\n duration = formatDuration(info.basic_info.duration);\n } else if (typeof info.basic_info.duration === 'string') {\n // If duration is already a string\n duration = info.basic_info.duration;\n }\n }\n\n // Better thumbnail handling\n let thumbnail = null;\n if (info.basic_info?.thumbnail) {\n if (Array.isArray(info.basic_info.thumbnail) && info.basic_info.thumbnail.length > 0) {\n // Get the highest quality thumbnail\n const thumbnails = info.basic_info.thumbnail;\n thumbnail = thumbnails[thumbnails.length - 1]?.url || thumbnails[0]?.url;\n } else if (typeof info.basic_info.thumbnail === 'string') {\n thumbnail = info.basic_info.thumbnail;\n }\n }\n\n return {\n id: videoId,\n title: info.basic_info?.title || 'Unknown Title',\n duration: duration,\n thumbnail: thumbnail,\n url: `https://www.youtube.com/watch?v=${videoId}`,\n author: info.basic_info?.author || 'Unknown Artist',\n views: info.basic_info?.view_count || 0,\n description: info.basic_info?.short_description || ''\n };\n } catch (error) {\n console.error('YouTube metadata error:', error);\n return null;\n }\n};\n\n/**\n * Get related tracks for autoplay functionality\n */\nconst getRelatedTracks = async (videoId, options = {}, limit = 10) => {\n try {\n const yt = await initializeYouTube(options);\n if (!yt) {\n throw new Error('YouTube service not available');\n }\n\n const info = await yt.getInfo(videoId);\n if (!info || !info.watch_next_feed) {\n return [];\n }\n\n // Get related videos from watch next feed\n const relatedVideos = info.watch_next_feed.filter(item =>\n item.type === 'CompactVideo' && item.id && item.title\n ).slice(0, limit);\n\n return relatedVideos.map(video => ({\n id: video.id,\n title: video.title?.text || 'Unknown Title',\n duration: video.duration?.text || 'Unknown',\n thumbnail: video.thumbnails?.[0]?.url || null,\n url: `https://www.youtube.com/watch?v=${video.id}`,\n author: video.author?.name || 'Unknown Artist',\n views: video.view_count?.text || '0'\n }));\n } catch (error) {\n console.error('YouTube related tracks error:', error);\n return [];\n }\n};\n\n/**\n * Get streaming URL using yt-dlp with optimizations\n */\nconst getStreamingUrl = async (url, ytdlpPath, quality = 'bestaudio[ext=m4a]/bestaudio[ext=webm]/bestaudio', cookies = null) => {\n try {\n if (!fs.existsSync(ytdlpPath)) {\n throw new Error(`yt-dlp binary not found at: ${ytdlpPath}`);\n }\n\n // Optimized command for faster extraction and better compatibility\n const optimizedArgs = [\n '-f', quality,\n '--get-url',\n '--no-playlist',\n '--no-warnings',\n '--no-check-certificates',\n '--prefer-insecure',\n '--skip-download',\n '--no-call-home',\n '--no-cache-dir',\n '--socket-timeout', '10',\n '--retries', '3',\n '--fragment-retries', '3'\n ];\n\n // Add cookies if provided for YouTube authentication\n if (cookies && typeof cookies === 'string' && cookies.trim()) {\n // Create a temporary cookies file for yt-dlp\n const tempCookiesFile = path.join(__dirname, 'temp_cookies.txt');\n try {\n // Convert browser cookies to Netscape format\n const netscapeCookies = convertToNetscapeFormat(cookies);\n fs.writeFileSync(tempCookiesFile, netscapeCookies);\n optimizedArgs.push('--cookies', tempCookiesFile);\n } catch (cookieError) {\n // Proceed without cookies if conversion fails\n }\n }\n\n const command = `\"${ytdlpPath}\" ${optimizedArgs.join(' ')} \"${url}\"`;\n\n const { stdout, stderr } = await execAsync(command, {\n timeout: 15000, // Reduced timeout for faster response\n maxBuffer: 1024 * 1024, // 1MB buffer should be enough for URL\n encoding: 'utf8',\n windowsHide: true // Hide console window on Windows\n });\n\n // Clean up temporary cookies file\n if (cookies) {\n const tempCookiesFile = path.join(__dirname, 'temp_cookies.txt');\n try {\n if (fs.existsSync(tempCookiesFile)) {\n fs.unlinkSync(tempCookiesFile);\n }\n } catch (cleanupError) {\n // Ignore cleanup errors\n }\n }\n\n if (stderr && !stdout) {\n throw new Error(`yt-dlp error: ${stderr}`);\n }\n\n const streamUrl = stdout.trim();\n if (!streamUrl || !streamUrl.startsWith('http')) {\n throw new Error('Invalid streaming URL returned');\n }\n\n return streamUrl;\n } catch (error) {\n console.error('yt-dlp streaming error:', error.message);\n throw error;\n }\n};\n\n/**\n * Get YouTube metadata using yt-dlp for consistency\n */\nconst getYouTubeMetadataWithYtDlp = async (videoId, ytdlpPath, cookies = null) => {\n let tempCookiesFile = null;\n\n try {\n if (!fs.existsSync(ytdlpPath)) {\n throw new Error(`yt-dlp binary not found at: ${ytdlpPath}`);\n }\n\n const url = `https://www.youtube.com/watch?v=${videoId}`;\n\n // Optimized command arguments for faster metadata extraction\n const args = [\n '-J',\n '--no-playlist',\n '--no-warnings',\n '--no-check-certificates',\n '--skip-download',\n '--no-call-home',\n '--no-cache-dir',\n '--socket-timeout', '15',\n '--retries', '1',\n '--fragment-retries', '1',\n '--extractor-retries', '1'\n ];\n\n // Add cookies if provided\n if (cookies) {\n tempCookiesFile = path.join(__dirname, `temp_cookies_metadata_${Date.now()}_${Math.random().toString(36).substr(2, 9)}.txt`);\n try {\n // Convert cookies to Netscape format if needed\n const netscapeCookies = convertToNetscapeFormat(cookies);\n fs.writeFileSync(tempCookiesFile, netscapeCookies);\n args.push('--cookies', tempCookiesFile);\n } catch (cookieError) {\n console.warn('Failed to write cookies for metadata, continuing without:', cookieError.message);\n tempCookiesFile = null;\n }\n }\n\n const command = `\"${ytdlpPath}\" ${args.join(' ')} \"${url}\"`;\n\n const { stdout, stderr } = await execAsync(command, {\n timeout: 30000, // Increased timeout to 30 seconds\n maxBuffer: 2 * 1024 * 1024, // Increased buffer to 2MB\n windowsHide: true, // Hide console window on Windows\n killSignal: 'SIGKILL' // Use SIGKILL instead of SIGTERM for more reliable termination\n });\n\n if (stderr && !stdout) {\n throw new Error(`yt-dlp metadata error: ${stderr}`);\n }\n\n if (!stdout || stdout.trim() === '') {\n throw new Error('yt-dlp returned empty response');\n }\n\n let info;\n try {\n info = JSON.parse(stdout);\n } catch (parseError) {\n throw new Error(`Failed to parse yt-dlp JSON response: ${parseError.message}`);\n }\n\n // Validate essential fields\n if (!info.id && !info.display_id) {\n throw new Error('Invalid video data: missing video ID');\n }\n\n return {\n id: videoId,\n title: info.title || info.fulltitle || 'Unknown Title',\n duration: info.duration ? formatDuration(info.duration) : 'Unknown',\n thumbnail: info.thumbnail || (info.thumbnails && info.thumbnails[0] ? info.thumbnails[0].url : null),\n url: `https://www.youtube.com/watch?v=${videoId}`,\n author: info.uploader || info.channel || info.uploader_id || 'Unknown Artist',\n views: info.view_count || 0,\n description: info.description || ''\n };\n } catch (error) {\n console.error('yt-dlp YouTube metadata error:', error);\n throw error;\n } finally {\n // Clean up temporary cookies file\n if (tempCookiesFile) {\n try {\n if (fs.existsSync(tempCookiesFile)) {\n fs.unlinkSync(tempCookiesFile);\n }\n } catch (cleanupError) {\n // Ignore cleanup errors\n }\n }\n }\n};\n\n/**\n * Get basic info using yt-dlp (fallback for non-YouTube sites)\n */\nconst getBasicInfo = async (url, ytdlpPath) => {\n try {\n if (!fs.existsSync(ytdlpPath)) {\n throw new Error(`yt-dlp binary not found at: ${ytdlpPath}`);\n }\n\n const command = `\"${ytdlpPath}\" -J --flat-playlist --no-warnings \"${url}\"`;\n const { stdout, stderr } = await execAsync(command, {\n timeout: 15000, // Reduced timeout\n maxBuffer: 1024 * 1024, // 1MB buffer\n windowsHide: true // Hide console window on Windows\n });\n\n if (stderr && !stdout) {\n throw new Error(`yt-dlp info error: ${stderr}`);\n }\n\n const info = JSON.parse(stdout);\n\n return {\n id: info.id || 'unknown',\n title: info.title || info.fulltitle || 'Unknown Title',\n duration: info.duration ? formatDuration(info.duration) : 'Unknown',\n thumbnail: info.thumbnail || null,\n url: info.webpage_url || url,\n author: info.uploader || info.channel || 'Unknown Artist',\n description: info.description || ''\n };\n } catch (error) {\n console.error('yt-dlp info error:', error);\n throw error;\n }\n};\n\n/**\n * Format duration from seconds to readable format\n */\nconst formatDuration = (seconds) => {\n if (!seconds || isNaN(seconds)) return 'Unknown';\n\n const hours = Math.floor(seconds / 3600);\n const minutes = Math.floor((seconds % 3600) / 60);\n const secs = Math.floor(seconds % 60);\n\n if (hours > 0) {\n return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;\n } else {\n return `${minutes}:${secs.toString().padStart(2, '0')}`;\n }\n};\n\n/**\n * Check if yt-dlp can handle the URL\n */\nconst canExtract = async (url, ytdlpPath) => {\n try {\n if (!fs.existsSync(ytdlpPath)) {\n return false;\n }\n\n const command = `\"${ytdlpPath}\" --simulate --quiet \"${url}\"`;\n await execAsync(command, {\n timeout: 10000,\n windowsHide: true // Hide console window on Windows\n });\n return true;\n } catch (error) {\n return false;\n }\n};\n\n/**\n * Validate URL format and accessibility\n */\nconst validateUrl = (url) => {\n if (!url || typeof url !== 'string') {\n return false;\n }\n\n // Check if it's a valid URL\n if (!isValidUrl(url)) {\n return false;\n }\n\n // Check for common unsupported protocols\n const unsupportedProtocols = ['file:', 'ftp:', 'mailto:'];\n const protocol = new URL(url).protocol;\n if (unsupportedProtocols.includes(protocol)) {\n return false;\n }\n\n return true;\n};\n\n/**\n * Create a standardized track object\n */\nconst createTrackObject = (info, source = 'yt-dlp') => {\n return {\n title: info.title || 'Unknown Title',\n author: info.author || 'Unknown Artist',\n duration: info.duration || 'Unknown',\n url: info.url,\n thumbnail: info.thumbnail || null,\n source: source,\n raw: info\n };\n};\n\n/**\n * Convert browser cookies to Netscape format for yt-dlp\n */\nconst convertToNetscapeFormat = (browserCookies) => {\n try {\n // Netscape format header\n let netscapeCookies = '# Netscape HTTP Cookie File\\n';\n netscapeCookies += '# This is a generated file! Do not edit.\\n\\n';\n\n // Split cookies by semicolon and process each one\n const cookies = browserCookies.split(';').map(cookie => cookie.trim());\n\n for (const cookie of cookies) {\n if (!cookie || !cookie.includes('=')) continue;\n\n const [name, ...valueParts] = cookie.split('=');\n const value = valueParts.join('='); // Handle values that contain '='\n\n if (!name || !value) continue;\n\n // Netscape format: domain, domain_specified, path, secure, expires, name, value\n // For YouTube cookies, we'll use these defaults:\n const domain = '.youtube.com';\n const domainSpecified = 'TRUE';\n const path = '/';\n const secure = name.includes('Secure') ? 'TRUE' : 'FALSE';\n const expires = '0'; // Session cookie\n\n netscapeCookies += `${domain}\\t${domainSpecified}\\t${path}\\t${secure}\\t${expires}\\t${name.trim()}\\t${value.trim()}\\n`;\n }\n\n return netscapeCookies;\n } catch (error) {\n // Fallback: return original cookies\n return browserCookies;\n }\n};\n\nmodule.exports = {\n initializeYouTube,\n isValidUrl,\n isYouTubeUrl,\n isYouTubePlaylistUrl,\n extractYouTubeId,\n extractYouTubePlaylistId,\n searchYouTube,\n getYouTubePlaylist,\n getYouTubeMetadata,\n getYouTubeMetadataWithYtDlp,\n getRelatedTracks,\n getStreamingUrl,\n getBasicInfo,\n formatDuration,\n canExtract,\n validateUrl,\n createTrackObject,\n convertToNetscapeFormat\n};\n","/**\n * YtDlp-Youtubei Hybrid Extractor\n * Uses yt-dlp for consistent metadata and streaming, with youtubei.js for search/playlists\n * Supports both YouTube search queries and direct URLs from various sites\n *\n * Options:\n * - preferYtdlpMetadata: boolean (default: true) - Whether to prefer yt-dlp for YouTube metadata\n * - ytdlpPath: string - Path to yt-dlp binary\n * - streamQuality: string - Quality selector for streaming\n * - enableYouTubeSearch: boolean (default: true) - Enable YouTube search functionality\n * - enableDirectUrls: boolean (default: true) - Enable direct URL handling\n * - youtubeiOptions: object - Options for youtubei.js (cookies, client)\n */\n\nconst { BaseExtractor, Track, Playlist } = require('discord-player');\nconst {\n isValidUrl,\n isYouTubeUrl,\n isYouTubePlaylistUrl,\n extractYouTubeId,\n extractYouTubePlaylistId,\n searchYouTube,\n getYouTubePlaylist,\n getYouTubeMetadata,\n getYouTubeMetadataWithYtDlp,\n getRelatedTracks,\n getStreamingUrl,\n getBasicInfo,\n canExtract,\n validateUrl\n} = require('./utils');\n\nclass YtDlpExtractor extends BaseExtractor {\n static identifier = 'ytdlp-extractor';\n\n constructor(context, options) {\n super(context, options);\n\n // Configuration\n this.ytdlpPath = options.ytdlpPath;\n this.priority = options.priority || 100;\n this.enableYouTubeSearch = options.enableYouTubeSearch !== false;\n this.enableDirectUrls = options.enableDirectUrls !== false;\n this.streamQuality = options.streamQuality || 'bestaudio[ext=m4a]/bestaudio[ext=webm]/bestaudio';\n this.preferYtdlpMetadata = options.preferYtdlpMetadata !== false; // Default to true for consistency\n\n // YouTubei options\n this.youtubeiOptions = {\n cookies: options.youtubeiOptions?.cookies || null,\n client: options.youtubeiOptions?.client || null\n };\n\n // Supported protocols for direct URLs\n this.protocols = ['http:', 'https:'];\n\n this.debug('YtDlp Extractor initialized');\n }\n\n /**\n * Activate the extractor\n */\n async activate() {\n this.debug('Activating YtDlp Extractor');\n\n // Verify yt-dlp binary exists\n const fs = require('fs');\n if (!fs.existsSync(this.ytdlpPath)) {\n throw new Error(`yt-dlp binary not found at: ${this.ytdlpPath}`);\n }\n\n this.debug('YtDlp Extractor activated successfully');\n }\n\n /**\n * Deactivate the extractor\n */\n async deactivate() {\n this.debug('YtDlp Extractor deactivated');\n }\n\n /**\n * Validate if this extractor can handle the query\n */\n async validate(query, type) {\n try {\n this.debug(`Validating query: ${query}, type: ${type}`);\n\n // Handle direct URLs\n if (isValidUrl(query)) {\n if (!this.enableDirectUrls) {\n this.debug('Direct URLs disabled');\n return false;\n }\n\n // Always handle YouTube URLs (including playlists) if YouTube search is enabled\n if (isYouTubeUrl(query) && this.enableYouTubeSearch) {\n this.debug('YouTube URL detected and enabled');\n return true;\n }\n\n // Check if yt-dlp can handle other URLs (with timeout)\n this.debug('Checking if yt-dlp can extract URL');\n const canHandle = await canExtract(query, this.ytdlpPath);\n return canHandle;\n }\n\n // Handle search queries - accept all if YouTube search is enabled\n if (this.enableYouTubeSearch) {\n this.debug('Search query accepted');\n return true;\n }\n\n this.debug('Query not supported');\n return false;\n } catch (error) {\n this.debug(`Validation error: ${error.message}`);\n return false;\n }\n }\n\n /**\n * Handle the query and return track information\n */\n async handle(query, context) {\n try {\n this.debug(`Handling query: ${query}`);\n \n if (isValidUrl(query)) {\n return await this.handleDirectUrl(query, context);\n } else {\n return await this.handleSearchQuery(query, context);\n }\n } catch (error) {\n this.debug(`Handle error: ${error.message}`);\n return this.createResponse(null, []);\n }\n }\n\n /**\n * Handle direct URL queries\n */\n async handleDirectUrl(url, context) {\n try {\n if (!validateUrl(url)) {\n throw new Error('Invalid URL format');\n }\n\n if (isYouTubeUrl(url)) {\n // Check if it's a playlist URL\n if (isYouTubePlaylistUrl(url)) {\n return await this.handleYouTubePlaylist(url, context);\n }\n\n // Handle single video\n const videoId = extractYouTubeId(url);\n if (!videoId) {\n throw new Error('Could not extract YouTube video ID');\n }\n\n // Get YouTube metadata with configurable preference and fallback\n let trackInfo;\n let metadataSource;\n\n if (this.preferYtdlpMetadata) {\n // Try yt-dlp first, fallback to youtubei.js\n metadataSource = 'yt-dlp';\n try {\n const cookies = this.youtubeiOptions?.cookies || null;\n this.debug(`Attempting to get metadata using yt-dlp for video: ${videoId}`);\n trackInfo = await getYouTubeMetadataWithYtDlp(videoId, this.ytdlpPath, cookies);\n this.debug(`Successfully got metadata using yt-dlp`);\n } catch (ytdlpError) {\n this.debug(`yt-dlp metadata failed: ${ytdlpError.message}`);\n this.debug(`Falling back to youtubei.js for metadata`);\n metadataSource = 'youtubei.js';\n\n try {\n trackInfo = await getYouTubeMetadata(videoId, this.youtubeiOptions);\n if (trackInfo) {\n this.debug(`Successfully got metadata using youtubei.js fallback`);\n }\n } catch (youtubeiError) {\n this.debug(`youtubei.js metadata also failed: ${youtubeiError.message}`);\n trackInfo = null;\n }\n }\n } else {\n // Try youtubei.js first, fallback to yt-dlp\n metadataSource = 'youtubei.js';\n try {\n this.debug(`Attempting to get metadata using youtubei.js for video: ${videoId}`);\n trackInfo = await getYouTubeMetadata(videoId, this.youtubeiOptions);\n if (trackInfo) {\n this.debug(`Successfully got metadata using youtubei.js`);\n } else {\n throw new Error('youtubei.js returned null');\n }\n } catch (youtubeiError) {\n this.debug(`youtubei.js metadata failed: ${youtubeiError.message}`);\n this.debug(`Falling back to yt-dlp for metadata`);\n metadataSource = 'yt-dlp';\n\n try {\n const cookies = this.youtubeiOptions?.cookies || null;\n trackInfo = await getYouTubeMetadataWithYtDlp(videoId, this.ytdlpPath, cookies);\n if (trackInfo) {\n this.debug(`Successfully got metadata using yt-dlp fallback`);\n }\n } catch (ytdlpError) {\n this.debug(`yt-dlp metadata also failed: ${ytdlpError.message}`);\n trackInfo = null;\n }\n }\n }\n\n if (!trackInfo) {\n throw new Error('Could not get YouTube metadata from either yt-dlp or youtubei.js');\n }\n\n // Add metadata source info to raw data\n trackInfo.metadataSource = metadataSource;\n\n const track = new Track(this, {\n title: trackInfo.title,\n author: trackInfo.author,\n duration: trackInfo.duration,\n url: trackInfo.url,\n thumbnail: trackInfo.thumbnail,\n source: 'ytdlp-extractor',\n raw: trackInfo,\n requestedBy: context.requestedBy,\n queryType: 'arbitrary'\n });\n\n return this.createResponse(null, [track]);\n } else {\n // Use yt-dlp for other sites\n const trackInfo = await getBasicInfo(url, this.ytdlpPath);\n\n const track = new Track(this, {\n title: trackInfo.title,\n author: trackInfo.author,\n duration: trackInfo.duration,\n url: trackInfo.url,\n thumbnail: trackInfo.thumbnail,\n source: 'ytdlp-extractor',\n raw: trackInfo,\n requestedBy: context.requestedBy,\n queryType: 'arbitrary'\n });\n\n return this.createResponse(null, [track]);\n }\n } catch (error) {\n this.debug(`Direct URL error: ${error.message}`);\n throw error;\n }\n }\n\n /**\n * Handle YouTube playlist URLs\n */\n async handleYouTubePlaylist(url, context) {\n try {\n const playlistId = extractYouTubePlaylistId(url);\n if (!playlistId) {\n throw new Error('Could not extract playlist ID');\n }\n\n const playlistInfo = await getYouTubePlaylist(playlistId, this.youtubeiOptions);\n if (!playlistInfo || !playlistInfo.tracks || playlistInfo.tracks.length === 0) {\n throw new Error('Could not get playlist information or playlist is empty');\n }\n\n // Create playlist object\n const playlist = new Playlist(this, {\n title: playlistInfo.title,\n description: playlistInfo.description,\n thumbnail: playlistInfo.thumbnail,\n type: 'playlist',\n source: 'ytdlp-extractor',\n author: {\n name: playlistInfo.author,\n url: null\n },\n tracks: [],\n id: playlistId,\n url: playlistInfo.url,\n rawPlaylist: playlistInfo\n });\n\n // Create tracks\n const tracks = playlistInfo.tracks.map(trackData => {\n const track = new Track(this, {\n title: trackData.title,\n author: trackData.author,\n duration: trackData.duration,\n url: trackData.url,\n thumbnail: trackData.thumbnail,\n source: 'ytdlp-extractor',\n raw: trackData,\n requestedBy: context.requestedBy,\n queryType: 'arbitrary',\n playlist: playlist\n });\n return track;\n });\n\n playlist.tracks = tracks;\n return this.createResponse(playlist, tracks);\n } catch (error) {\n this.debug(`YouTube playlist error: ${error.message}`);\n throw error;\n }\n }\n\n /**\n * Handle search queries (YouTube only)\n */\n async handleSearchQuery(query, context) {\n try {\n if (!this.enableYouTubeSearch) {\n throw new Error('YouTube search is disabled');\n }\n\n const searchResults = await searchYouTube(query, 1, this.youtubeiOptions);\n if (!searchResults || searchResults.length === 0) {\n return this.createResponse(null, []);\n }\n\n const result = searchResults[0];\n\n // Create track with YouTube URL that will be passed to yt-dlp for streaming\n const track = new Track(this, {\n title: result.title,\n author: result.author,\n duration: result.duration,\n url: result.url, // This YouTube URL will be used by yt-dlp for streaming\n thumbnail: result.thumbnail,\n source: 'ytdlp-extractor',\n raw: {\n ...result,\n originalQuery: query,\n searchMethod: 'youtubei'\n },\n requestedBy: context.requestedBy,\n queryType: 'youtubeSearch'\n });\n\n return this.createResponse(null, [track]);\n } catch (error) {\n this.debug(`Search query error: ${error.message}`);\n // Return empty response instead of throwing to prevent crashes\n return this.createResponse(null, []);\n }\n }\n\n /**\n * Get streaming URL for a track\n */\n async stream(info) {\n try {\n this.debug(`Getting stream for: ${info.title || info.raw?.title || 'Unknown'}`);\n\n // Use the URL from the track info\n const url = info.url || info.raw?.url;\n if (!url) {\n throw new Error('No URL found in track info');\n }\n\n // Get fresh streaming URL each time to avoid expiration\n // Pass cookies to yt-dlp for authentication if available\n const cookies = this.youtubeiOptions?.cookies || null;\n const streamUrl = await getStreamingUrl(url, this.ytdlpPath, this.streamQuality, cookies);\n\n if (!streamUrl || !streamUrl.startsWith('http')) {\n throw new Error('Invalid streaming URL returned');\n }\n\n this.debug(`Stream URL obtained successfully`);\n\n // Return the stream URL directly - discord-player will handle it\n return streamUrl;\n } catch (error) {\n this.debug(`Stream error: ${error.message}`);\n throw error;\n }\n }\n\n /**\n * Get related tracks for autoplay functionality\n */\n async getRelatedTracks(track, history) {\n try {\n this.debug(`Getting related tracks for: ${track.title}`);\n\n // Only work with YouTube tracks for now\n if (!isYouTubeUrl(track.url)) {\n this.debug('Non-YouTube track, no related tracks available');\n return this.createResponse(null, []);\n }\n\n const videoId = extractYouTubeId(track.url);\n if (!videoId) {\n this.debug('Could not extract video ID');\n return this.createResponse(null, []);\n }\n\n // Get related tracks using YouTube's recommendation system\n let relatedTracks = await getRelatedTracks(videoId, this.youtubeiOptions, 10);\n\n // If no related tracks found, try search-based approach\n if (!relatedTracks || relatedTracks.length === 0) {\n this.debug('No related tracks from YouTube API, trying search-based approach');\n\n // Use track author for search\n if (track.author && track.author !== 'Unknown Artist') {\n const searchQuery = `${track.author} music`;\n relatedTracks = await searchYouTube(searchQuery, 5, this.youtubeiOptions);\n }\n }\n\n // Filter out tracks that are already in history\n const historyUrls = new Set(\n history.tracks.toArray().map(t => t.url)\n );\n\n const filteredTracks = relatedTracks.filter(trackData =>\n !historyUrls.has(trackData.url) && trackData.url !== track.url\n );\n\n if (filteredTracks.length === 0) {\n this.debug('No new related tracks found after filtering');\n return this.createResponse(null, []);\n }\n\n // Create Track objects\n const tracks = filteredTracks.slice(0, 5).map(trackData => {\n const relatedTrack = new Track(this, {\n title: trackData.title,\n author: trackData.author,\n duration: trackData.duration,\n url: trackData.url,\n thumbnail: trackData.thumbnail,\n source: 'ytdlp-extractor',\n raw: {\n ...trackData,\n relatedTo: track.url,\n autoplay: true\n },\n requestedBy: track.requestedBy,\n queryType: 'autoplay'\n });\n return relatedTrack;\n });\n\n this.debug(`Found ${tracks.length} related tracks`);\n return this.createResponse(null, tracks);\n } catch (error) {\n this.debug(`Related tracks error: ${error.message}`);\n return this.createResponse(null, []);\n }\n }\n\n /**\n * Bridge functionality for other extractors\n */\n async bridge(track, sourceExtractor) {\n try {\n // If the source extractor is not this one, try to get stream\n if (sourceExtractor?.identifier !== this.identifier) {\n const streamUrl = await this.stream(track);\n return { stream: streamUrl, type: 'arbitrary' };\n }\n\n return null;\n } catch (error) {\n this.debug(`Bridge error: ${error.message}`);\n return null;\n }\n }\n\n /**\n * Create bridge query for track search\n */\n createBridgeQuery(track) {\n return `${track.author} - ${track.title}`;\n }\n}\n\nmodule.exports = { YtDlpExtractor };\n"],"mappings":"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