577 lines
27 KiB
JavaScript
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
|