mono/packages/media/ref/yt-dlp/dist/ytdlp.js

577 lines
27 KiB
JavaScript

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