import { exec, spawn } from 'node:child_process'; import { promisify } from 'node:util'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { logger } from './logger.js'; const execAsync = promisify(exec); /** * A wrapper class for the yt-dlp command line tool */ export class YtDlp { /** * Create a new YtDlp instance * @param options Configuration options for yt-dlp */ constructor(options = {}) { this.options = options; this.executable = 'yt-dlp'; if (options.executablePath) { this.executable = options.executablePath; } logger.debug('YtDlp initialized with options:', options); } /** * Check if yt-dlp is installed and accessible * @returns Promise resolving to true if yt-dlp is installed, false otherwise */ async isInstalled() { try { const { stdout } = await execAsync(`${this.executable} --version`); logger.debug(`yt-dlp version: ${stdout.trim()}`); return true; } catch (error) { logger.warn('yt-dlp is not installed or not found in PATH'); return false; } } /** * Download a video from a given URL * @param url The URL of the video to download * @param options Download options * @returns Promise resolving to the path of the downloaded file */ async downloadVideo(url, options = {}) { if (!url) { logger.error('Download failed: No URL provided'); throw new Error('URL is required'); } logger.info(`Downloading video from: ${url}`); logger.debug(`Download options: ${JSON.stringify({ ...options, // Redact any sensitive information userAgent: this.options.userAgent ? '[REDACTED]' : undefined }, null, 2)}`); // Prepare output directory const outputDir = options.outputDir || '.'; if (!fs.existsSync(outputDir)) { logger.debug(`Creating output directory: ${outputDir}`); try { fs.mkdirSync(outputDir, { recursive: true }); logger.debug(`Output directory created successfully`); } catch (error) { logger.error(`Failed to create output directory: ${error.message}`); throw new Error(`Failed to create output directory ${outputDir}: ${error.message}`); } } else { logger.debug(`Output directory already exists: ${outputDir}`); } // Build command arguments const args = []; // Add user agent if specified in global options if (this.options.userAgent) { args.push('--user-agent', this.options.userAgent); } // Format selection if (options.format) { args.push('-f', options.format); } // Output template const outputTemplate = options.outputTemplate || '%(title)s.%(ext)s'; args.push('-o', path.join(outputDir, outputTemplate)); // Add other options if (options.audioOnly) { logger.debug(`Audio-only mode enabled, extracting audio with format: ${options.audioFormat || 'default'}`); logger.info(`Extracting audio in ${options.audioFormat || 'best available'} format`); // Add extract audio flag args.push('-x'); // Handle audio format if (options.audioFormat) { logger.debug(`Setting audio format to: ${options.audioFormat}`); args.push('--audio-format', options.audioFormat); // For MP3 format, ensure we're using the right audio quality if (options.audioFormat.toLowerCase() === 'mp3') { logger.debug('MP3 format requested, setting audio quality'); args.push('--audio-quality', '0'); // 0 is best quality // Ensure ffmpeg installed message for MP3 conversion logger.debug('MP3 conversion requires ffmpeg to be installed'); logger.info('Note: MP3 conversion requires ffmpeg to be installed on your system'); } } logger.debug(`Audio extraction command arguments: ${args.join(' ')}`); } if (options.subtitles) { args.push('--write-subs'); if (Array.isArray(options.subtitles)) { args.push('--sub-lang', options.subtitles.join(',')); } } if (options.maxFileSize) { args.push('--max-filesize', options.maxFileSize.toString()); } if (options.rateLimit) { args.push('--limit-rate', options.rateLimit); } // Add custom arguments if provided if (options.additionalArgs) { args.push(...options.additionalArgs); } // Add the URL args.push(url); // Create a copy of args with sensitive information redacted for logging const logSafeArgs = [...args].map(arg => { if (arg === this.options.userAgent && this.options.userAgent) { return '[REDACTED]'; } return arg; }); // Log the command for debugging logger.debug(`Executing download command: ${this.executable} ${logSafeArgs.join(' ')}`); logger.debug(`Download configuration: audioOnly=${!!options.audioOnly}, format=${options.format || 'default'}, audioFormat=${options.audioFormat || 'n/a'}`); // Add additional debugging for audio extraction if (options.audioOnly) { logger.debug(`Audio extraction details: format=${options.audioFormat || 'auto'}, mp3=${options.audioFormat?.toLowerCase() === 'mp3'}`); logger.info(`Audio extraction mode: ${options.audioFormat || 'best quality'}`); } // Print to console for user feedback if (options.audioOnly) { logger.info(`Downloading audio in ${options.audioFormat || 'best'} format from: ${url}`); } else { logger.info(`Downloading video from: ${url}`); } logger.debug('Executing download command'); return new Promise((resolve, reject) => { logger.debug('Spawning yt-dlp process'); try { const ytdlpProcess = spawn(this.executable, args, { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; let downloadedFile = null; let progressData = { percent: 0, totalSize: '0', downloadedSize: '0', speed: '0', eta: '0' }; ytdlpProcess.stdout.on('data', (data) => { const output = data.toString(); stdout += output; logger.debug(`yt-dlp output: ${output.trim()}`); // Try to extract the output filename const destinationMatch = output.match(/Destination: (.+)/); if (destinationMatch && destinationMatch[1]) { downloadedFile = destinationMatch[1].trim(); logger.debug(`Detected output filename: ${downloadedFile}`); } // Alternative method to extract the output filename const alreadyDownloadedMatch = output.match(/\[download\] (.+) has already been downloaded/); if (alreadyDownloadedMatch && alreadyDownloadedMatch[1]) { downloadedFile = alreadyDownloadedMatch[1].trim(); logger.debug(`Video was already downloaded: ${downloadedFile}`); } // Track download progress const progressMatch = output.match(/\[download\]\s+(\d+\.?\d*)%\s+of\s+~?(\S+)\s+at\s+(\S+)\s+ETA\s+(\S+)/); if (progressMatch) { progressData = { percent: parseFloat(progressMatch[1]), totalSize: progressMatch[2], downloadedSize: (parseFloat(progressMatch[1]) * parseFloat(progressMatch[2]) / 100).toFixed(2) + 'MB', speed: progressMatch[3], eta: progressMatch[4] }; // Log progress more frequently for better user feedback if (Math.floor(progressData.percent) % 5 === 0) { logger.info(`Download progress: ${progressData.percent.toFixed(1)}% (${progressData.downloadedSize}/${progressData.totalSize}) at ${progressData.speed} (ETA: ${progressData.eta})`); } } // Capture the file after extraction (important for audio conversions) const extractingMatch = output.match(/\[ExtractAudio\] Destination: (.+)/); if (extractingMatch && extractingMatch[1]) { downloadedFile = extractingMatch[1].trim(); logger.debug(`Audio extraction destination: ${downloadedFile}`); // Log audio conversion information if (options.audioOnly) { logger.info(`Converting to ${options.audioFormat || 'best format'}: ${downloadedFile}`); } } // Also capture ffmpeg conversion progress for audio const ffmpegMatch = output.match(/\[ffmpeg\] Destination: (.+)/); if (ffmpegMatch && ffmpegMatch[1]) { downloadedFile = ffmpegMatch[1].trim(); logger.debug(`ffmpeg conversion destination: ${downloadedFile}`); if (options.audioOnly && options.audioFormat) { logger.info(`Converting to ${options.audioFormat}: ${downloadedFile}`); } } }); ytdlpProcess.stderr.on('data', (data) => { const output = data.toString(); stderr += output; logger.error(`yt-dlp error: ${output.trim()}`); // Try to detect common error types for better error reporting if (output.includes('No such file or directory')) { logger.error('yt-dlp executable not found. Please make sure it is installed and in your PATH.'); } else if (output.includes('HTTP Error 403: Forbidden')) { logger.error('Access forbidden - the server denied access. This might be due to rate limiting or geo-restrictions.'); } else if (output.includes('HTTP Error 404: Not Found')) { logger.error('The requested video was not found. It might have been deleted or made private.'); } else if (output.includes('Unsupported URL')) { logger.error('The URL is not supported by yt-dlp. Check if the website is supported or if you need a newer version of yt-dlp.'); } }); ytdlpProcess.on('close', (code) => { logger.debug(`yt-dlp process exited with code ${code}`); if (code === 0) { if (downloadedFile) { // Verify the file actually exists try { const stats = fs.statSync(downloadedFile); logger.info(`Successfully downloaded: ${downloadedFile} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`); resolve(downloadedFile); } catch (error) { logger.warn(`File reported as downloaded but not found on disk: ${downloadedFile}`); logger.debug(`File access error: ${error.message}`); // Try to find an alternative filename const possibleFiles = this.searchPossibleFilenames(stdout, outputDir); if (possibleFiles.length > 0) { logger.info(`Found alternative downloaded file: ${possibleFiles[0]}`); resolve(possibleFiles[0]); } else { logger.info('Download reported as successful, but could not locate the output file'); resolve('Download completed'); } } } else { // Try to find the downloaded file from stdout if it wasn't captured logger.debug('No downloadedFile captured, searching stdout for filename'); const possibleFiles = this.searchPossibleFilenames(stdout, outputDir); if (possibleFiles.length > 0) { logger.info(`Successfully downloaded: ${possibleFiles[0]}`); resolve(possibleFiles[0]); } else { logger.info('Download successful, but could not determine the output file name'); logger.debug('Full stdout for debugging:'); logger.debug(stdout); resolve('Download completed'); } } } else { logger.error(`Download failed with exit code ${code}`); // Provide more context based on the error code let errorContext = ''; if (code === 1) { errorContext = 'General error - check URL and network connection'; } else if (code === 2) { errorContext = 'Network error - check your internet connection'; } else if (code === 3) { errorContext = 'File system error - check permissions and disk space'; } reject(new Error(`yt-dlp exited with code ${code} (${errorContext}): ${stderr}`)); } }); ytdlpProcess.on('error', (err) => { logger.error(`Failed to start yt-dlp process: ${err.message}`); logger.debug(`Error details: ${JSON.stringify(err)}`); // Check for common spawn errors and provide helpful messages if (err.message.includes('ENOENT')) { reject(new Error(`yt-dlp executable not found. Make sure it's installed and available in your PATH. Error: ${err.message}`)); } else if (err.message.includes('EACCES')) { reject(new Error(`Permission denied when executing yt-dlp. Check file permissions. Error: ${err.message}`)); } else { reject(new Error(`Failed to start yt-dlp: ${err.message}`)); } }); // Handle unexpected termination process.on('SIGINT', () => { logger.warn('Process interrupted, terminating download'); ytdlpProcess.kill('SIGINT'); }); } catch (error) { logger.error(`Exception during process spawn: ${error.message}`); reject(new Error(`Failed to start download process: ${error.message}`)); } }); } /** * Search for possible filenames in yt-dlp output * @param stdout The stdout output from yt-dlp * @param outputDir The output directory * @returns Array of possible filenames found */ searchPossibleFilenames(stdout, outputDir) { const possibleFiles = []; // Various regex patterns to find filenames in the yt-dlp output const patterns = [ /\[download\] (.+?) has already been downloaded/, /\[download\] Destination: (.+)/, /\[ffmpeg\] Destination: (.+)/, /\[ExtractAudio\] Destination: (.+)/, /\[Merger\] Merging formats into "(.+)"/, /\[Merger\] Merged into (.+)/ ]; // Try each pattern for (const pattern of patterns) { const matches = stdout.matchAll(new RegExp(pattern, 'g')); for (const match of matches) { if (match[1]) { const filePath = match[1].trim(); // Check if it's a relative or absolute path const fullPath = path.isAbsolute(filePath) ? filePath : path.join(outputDir, filePath); if (fs.existsSync(fullPath)) { logger.debug(`Found output file: ${fullPath}`); possibleFiles.push(fullPath); } } } } return possibleFiles; } /** * Get information about a video without downloading it * @param url The URL of the video to get information for * @returns Promise resolving to video information */ /** * Escapes a string for shell use based on the current platform * @param str The string to escape * @returns The escaped string */ escapeShellArg(str) { if (os.platform() === 'win32') { // Windows: Double quotes need to be escaped with backslash // and the whole string wrapped in double quotes return `"${str.replace(/"/g, '\\"')}"`; } else { // Unix-like: Single quotes provide the strongest escaping // Double any existing single quotes and wrap in single quotes return `'${str.replace(/'/g, "'\\''")}'`; } } async getVideoInfo(url, options = { dumpJson: false, flatPlaylist: false }) { if (!url) { throw new Error('URL is required'); } logger.info(`Getting video info for: ${url}`); try { // Build command with options const args = ['--dump-json']; // Add user agent if specified in global options if (this.options.userAgent) { args.push('--user-agent', this.options.userAgent); } // Add VideoInfoOptions flags if (options.flatPlaylist) { args.push('--flat-playlist'); } args.push(url); // Properly escape arguments for the exec call const escapedArgs = args.map(arg => { // Only escape arguments that need escaping (contains spaces or special characters) return /[\s"'$&()<>`|;]/.test(arg) ? this.escapeShellArg(arg) : arg; }); const { stdout } = await execAsync(`${this.executable} ${escapedArgs.join(' ')}`); const videoInfo = JSON.parse(stdout); logger.debug('Video info retrieved successfully'); return videoInfo; } catch (error) { logger.error('Failed to get video info:', error); throw new Error(`Failed to get video info: ${error.message}`); } } /** * List available formats for a video * @param url The URL of the video to get formats for * @returns Promise resolving to an array of VideoFormat objects */ async listFormats(url, options = { all: false }) { if (!url) { throw new Error('URL is required'); } logger.info(`Listing formats for: ${url}`); try { // Build command with options const formatFlag = options.all ? '--list-formats-all' : '-F'; // Properly escape URL if needed const escapedUrl = /[\s"'$&()<>`|;]/.test(url) ? this.escapeShellArg(url) : url; const { stdout } = await execAsync(`${this.executable} ${formatFlag} ${escapedUrl}`); logger.debug('Format list retrieved successfully'); // Parse the output to extract format information return this.parseFormatOutput(stdout); } catch (error) { logger.error('Failed to list formats:', error); throw new Error(`Failed to list formats: ${error.message}`); } } /** * Parse the format list output from yt-dlp into an array of VideoFormat objects * @param output The raw output from yt-dlp format listing * @returns Array of VideoFormat objects */ parseFormatOutput(output) { const formats = []; const lines = output.split('\n'); // Find the line with table headers to determine where the format list starts let formatListStartIndex = 0; for (let i = 0; i < lines.length; i++) { if (lines[i].includes('format code') || lines[i].includes('ID')) { formatListStartIndex = i + 1; break; } } // Regular expressions to match various format components const formatIdRegex = /^(\S+)/; const extensionRegex = /(\w+)\s+/; const resolutionRegex = /(\d+x\d+|\d+p)/; const fpsRegex = /(\d+)fps/; const filesizeRegex = /(\d+(\.\d+)?)(K|M|G|T)iB/; const bitrateRegex = /(\d+(\.\d+)?)(k|m)bps/; const codecRegex = /(mp4|webm|m4a|mp3|opus|vorbis)\s+([\w.]+)/i; const formatNoteRegex = /(audio only|video only|tiny|small|medium|large|best)/i; // Process each line that contains format information for (let i = formatListStartIndex; i < lines.length; i++) { const line = lines[i].trim(); if (!line || line.includes('----')) continue; // Skip empty lines or separators // Extract format ID - typically the first part of the line const formatIdMatch = line.match(formatIdRegex); if (!formatIdMatch) continue; const formatId = formatIdMatch[1]; // Create a base format object const format = { format_id: formatId, format: line, // Use the full line as the format description ext: 'unknown', protocol: 'https', vcodec: 'unknown', acodec: 'unknown' }; // Try to extract format components // Extract extension const extMatch = line.substring(formatId.length).match(extensionRegex); if (extMatch) { format.ext = extMatch[1]; } // Extract resolution const resMatch = line.match(resolutionRegex); if (resMatch) { format.resolution = resMatch[1]; // If resolution is in the form of "1280x720", extract width and height const dimensions = format.resolution.split('x'); if (dimensions.length === 2) { format.width = parseInt(dimensions[0], 10); format.height = parseInt(dimensions[1], 10); } else if (format.resolution.endsWith('p')) { // If resolution is like "720p", extract height format.height = parseInt(format.resolution.replace('p', ''), 10); } } // Extract FPS const fpsMatch = line.match(fpsRegex); if (fpsMatch) { format.fps = parseInt(fpsMatch[1], 10); } // Extract filesize const sizeMatch = line.match(filesizeRegex); if (sizeMatch) { let size = parseFloat(sizeMatch[1]); const unit = sizeMatch[3]; // Convert to bytes if (unit === 'K') size *= 1024; else if (unit === 'M') size *= 1024 * 1024; else if (unit === 'G') size *= 1024 * 1024 * 1024; else if (unit === 'T') size *= 1024 * 1024 * 1024 * 1024; format.filesize = Math.round(size); } // Extract bitrate const bitrateMatch = line.match(bitrateRegex); if (bitrateMatch) { let bitrate = parseFloat(bitrateMatch[1]); const unit = bitrateMatch[3]; // Convert to Kbps if (unit === 'm') bitrate *= 1000; format.tbr = bitrate; } // Extract format note const noteMatch = line.match(formatNoteRegex); if (noteMatch) { format.format_note = noteMatch[1]; } // Determine audio/video codec if (line.includes('audio only')) { format.vcodec = 'none'; // Try to get audio codec const codecMatch = line.match(codecRegex); if (codecMatch) { format.acodec = codecMatch[2] || format.acodec; } } else if (line.includes('video only')) { format.acodec = 'none'; // Try to get video codec const codecMatch = line.match(codecRegex); if (codecMatch) { format.vcodec = codecMatch[2] || format.vcodec; } } else { // Both audio and video const codecMatch = line.match(codecRegex); if (codecMatch) { format.container = codecMatch[1]; if (codecMatch[2]) { if (line.includes('video')) { format.vcodec = codecMatch[2]; } else if (line.includes('audio')) { format.acodec = codecMatch[2]; } } } } // Add the format to our result array formats.push(format); } return formats; } /** * Set the path to the yt-dlp executable * @param path Path to the yt-dlp executable */ setExecutablePath(path) { if (!path) { throw new Error('Executable path cannot be empty'); } this.executable = path; logger.debug(`yt-dlp executable path set to: ${path}`); } } export default YtDlp; //# sourceMappingURL=ytdlp.js.map