Source: ytmp3.js

/**
 * @file Core library for **YTMP3** project.
 *
 * This module contains the core functionality for **YTMP3** project.
 * It utilizes the [@distube/ytdl-core](https://www.npmjs.com/package/@distube/ytdl-core) (to download YouTube videos) and
 * [fluent-ffmpeg](https://www.npmjs.com/package/fluent-ffmpeg) (to convert audio formats) libraries.
 *
 * This module provides APIs to easily download YouTube videos (also supports YouTube Music) and convert them to MP3 format.
 * The output MP3 files are stored in the specified output directory named `download` relatively to the project's root directory.
 *
 * You can download a single YouTube video or a bunch of YouTube videos from a file as audio files and convert them to MP3 format.
 * If you want to download a single YouTube video, please use the {@link module:ytmp3~singleDownload `singleDownload`} function.
 * Or if you want to download a bunch of YouTube videos, first you need to store all YouTube URLs in a file and then use the
 * {@link module:ytmp3~batchDownload `batchDownload`} function to download them by passing the path of the file.
 *
 * @example <caption> Download a single YouTube video </caption>
 * const ytmp3 = require('ytmp3-js');
 * ytmp3.singleDownload('https://www.youtube.com/watch?v=<VIDEO_ID>')
 *   .then(outputFile => console.log('Download complete:', outputFile))
 *   .catch(err => console.error('Download failed:', err));
 *
 * @example <caption> Download a batch of YouTube videos </caption>
 * const ytmp3 = require('ytmp3-js');
 * ytmp3.batchDownload('./urls.txt')
 *   .then(outputFiles => {
 *     for (const outFile of outputFiles) {
 *       console.log('Download complete:', outFile);
 *     }
 *   })
 *   .catch(err => console.error('Download failed:', err));
 *
 * @module    ytmp3
 * @version   1.1.0
 * @requires  audioconv
 * @requires  utils
 * @author    Ryuu Mitsuki <https://github.com/mitsuki31>
 * @license   MIT
 * @since     1.0.0
 */

'use strict';

const fs = require('fs'),           // File system module
      os = require('os'),           // OS module
      path = require('path'),       // Path module
      ytdl = require('@distube/ytdl-core');  // Youtube Downloader module

const {
  // eslint-disable-next-line no-unused-vars
  Readable  // only for type declaration
} = require('stream');

const {
  LOGDIR,
  logger: log,
  ProgressBar,
  URLUtils,
  createDirIfNotExist,
  createDirIfNotExistSync,
  createLogFile
} = require('./utils');
const {
  checkFfmpeg,
  convertAudio,
  defaultOptions: defaultAudioConvOptions
} = require('./audioconv');

/**
 * The video information object.
 *
 * @typedef  {Object}           VideoData
 * @property {ytdl.videoInfo}   info - The video information object retrieved using
 *                                     `ytdl.getInfo()` function.
 * @property {ytdl.videoFormat} format - The chosen audio format for downloading.
 * @property {string}           title - The sanitized title of the video, with illegal
 *                                      characters replaced by underscores.
 * @property {string}           author - The name of the video's author.
 * @property {string}           videoUrl - The URL of the video.
 * @property {string}           videoId - The ID of the video.
 * @property {string}           channelId - The ID of the channel that uploaded the video.
 * @property {number}           viewers - The view count of the video.
 *
 * @private
 * @since    1.0.0
 */

/**
 * The download result object returned by the {@link downloadAudio} function.
 *
 * @typedef  {Object}         DownloadResult
 * @property {ytdl.videoInfo} videoInfo - The raw video information object.
 * @property {VideoData}      videoData - The processed video data object
 *                                        containing sanitized and formatted information.
 * @property {Readable}       download - The download stream for the audio.
 *
 * @private
 * @since    1.0.0
 */

/**
 * An object to configure the download process on {@link module:ytmp3~singleDownload `singleDownload`}
 * and {@link module:ytmp3~batchDownload `batchDownload`} functions.
 *
 * @typedef {Object} DownloadOptions
 * @property {string} [cwd='.'] - The current working directory. If not specified, defaults
 *                                to the current directory.
 * @property {string} [outDir='.'] - The output directory where downloaded files will be saved.
 *                                   If not specified, defaults to the current directory.
 * @property {boolean} [convertAudio=true] - Whether to convert the downloaded audio to a specified
 *                                           format. Defaults to `true`.
 * @property {ConvertAudioOptions} [converterOptions=audiconv.defaultOptions]
 *           Options for the audio converter. If not specified, defaults to {@link module:audioconv~defaultOptions `defaultOptions`}.
 * @property {boolean} [quiet=false] - Whether to suppress console output. Defaults to `false`.
 *
 * @global
 * @since   1.0.0
 */


// region Constants

/**
 * The library name.
 * @constant
 * @default
 * @public
 */
const NAME = 'ytmp3';

/**
 * The library version.
 * @constant
 * @public
 */
const VERSION = require('../package.json').version;

/**
 * The library version information in a frozen object.
 *
 * @property {number} major - The major version number.
 * @property {number} minor - The minor version number.
 * @property {number} patch - The patch version number.
 * @property {string} build - The build information of current version (i.e., stable).
 *
 * @readonly
 * @public
 * @since    1.1.0
 */
const VERSION_INFO = (() => {
  const [ major, minor, patch, build ] = VERSION.split(/[.-]/g);
  return Object.freeze({
    major: Number.parseInt(major),
    minor: Number.parseInt(minor),
    patch: Number.parseInt(patch),
    build: !build ? 'stable' : build });
})();
delete VERSION_INFO.prototype;

// Prevent the 'ytdl-core' module to check updates
Object.assign(process.env, { YTDL_NO_UPDATE: true });

/**
 * The audio format options for the `ytdl.downloadFromInfo()` function.
 * @private
 * @default
 * @type {ytdl.chooseFormatOptions}
 */
const AUDIO_FMT_OPTIONS = {
  quality: 140,
  filter: 'audioonly'
};


// region Helpers


/**
 * Validates a YouTube URL.
 *
 * This function validates a YouTube URL and throws an error if it is not valid.
 *
 * @param  {string | URL} url - The URL to validate.
 * @param  {boolean} [verbose=false] - Whether to log verbose messages or not.
 *
 * @throws {TypeError}          If the URL is not a string nor an instance of `URL`.
 * @throws {Error}              If the input URL is not valid.
 *
 * @package
 * @since  1.0.0
 */
function validateYTURL(url, verbose=false) {
  if (!url || !(typeof url === 'string' || url instanceof URL)) {
    throw new TypeError(`Invalid type of URL: ${typeof url}`);
  }

  // Parse the given URL string
  url = (typeof url === 'string') ? new URL(url) : url;

  verbose && log.info('Validating URL, please wait...');
  if (URLUtils.validateUrl(url)) {
    verbose && log.done('\x1b[92m\u2714\x1b[0m URL is valid');
  } else {
    verbose && log.error('\x1b[91m\u2716\x1b[0m URL is invalid');
    throw new Error('Invalid URL: ' + url);
  }
}



/**
 * Resolves and validates download options.
 *
 * This function ensures the provided download options are correctly typed and
 * assigns default values where necessary.
 *
 * @param    {Object} options - The options object.
 * @param    {?(DownloadOptions | Object | undefined)} options.downloadOptions
 *           The download options to resolve and validate.
 *
 * @returns {DownloadOptions} The resolved download options. This object can be passed
 *                            safely to {@link module:ytmp3~singleDownload `singleDownload`} or
 *                            {@link module:ytmp3~singleDownload `singleDownload`} function.
 *
 * @throws {TypeError} If the given argument is non-null, but also not an object type.
 *                     And if any of the options have incorrect type.
 *
 * @public
 * @since    1.0.0
 */
function resolveDlOptions({ downloadOptions }) {
  /**
   * Throws a `TypeError` with a message indicating the expected type for a property.
   *
   * @param   {string} prop - The name of the property.
   * @param   {string} type - The actual type of the property.
   * @param   {string} expectedType - The expected type of the property.
   * @throws  {TypeError} Throws a `TypeError` with a descriptive message.
   * @private
   * @since   1.0.0
   */
  function throwErr(prop, type, expectedType) {
    const m = `Unknown type of \`downloadOptions.${prop}\` property. `
      + `Got '${type}', expected is '${expectedType}' type`;
    throw new TypeError(m);
  }
  /**
   * Determines the type of a given value, differentiating between arrays, null, and objects.
   *
   * @param   {any} x - The value to determine the type of.
   * @returns {string} The determined type of the value.
   * @private
   * @since   1.0.0
   */
  function getTypeOf(x) {
    // `typeof []` and `typeof null` are always returns 'object', which is ambiguous
    return Array.isArray(x) ? 'array' : ((x === null) ? 'null' : typeof x);
  }

  // Throw an error if the given options is not an object
  if (downloadOptions && (
    Array.isArray(downloadOptions) || typeof downloadOptions !== 'object')
  ) {
    throw new TypeError('Unknown type of `downloadOptions`: ' + typeof downloadOptions);
  }

  const cwd = (downloadOptions?.cwd && typeof downloadOptions.cwd !== 'string')
    && throwErr('cwd', getTypeOf(downloadOptions.cwd), 'string')
    || (downloadOptions?.cwd
      ? (path.isAbsolute(downloadOptions.cwd)
        ? downloadOptions.cwd
        : path.resolve(downloadOptions.cwd)
      )
      : path.resolve('.')  // Fallback value
    );

  // Ensure it is an object
  downloadOptions = (!downloadOptions) ? {} : downloadOptions;
  return {
    // ==> downloadOptions.cwd
    cwd,
    // ==> downloadOptions.outDir
    outDir: (
      (downloadOptions.outDir && typeof downloadOptions.outDir !== 'string')
        && throwErr('outDir', getTypeOf(downloadOptions.outDir), 'string')
        || (downloadOptions.outDir
          ? (path.isAbsolute(downloadOptions.outDir)
            // If the specified `outDir` is an absolute path,
            // do not resolve the path to relative to the `cwd`
            ? downloadOptions.outDir
            : path.join(cwd, downloadOptions.outDir)
          )
          : path.resolve('.')  // Fallback value
        )
    ),
    // ==> downloadOptions.convertAudio
    convertAudio: (
      (downloadOptions.convertAudio === null
        || typeof downloadOptions.convertAudio === 'undefined')
        ? true  // Fallback value
        : (downloadOptions.convertAudio && typeof downloadOptions.convertAudio !== 'boolean')
          && throwErr('convertAudio', getTypeOf(downloadOptions.convertAudio), 'boolean')
          || downloadOptions.convertAudio
    ),
    // ==> downloadOptions.converterOptions
    converterOptions: (
      ((downloadOptions.converterOptions
        && (
          Array.isArray(downloadOptions.converterOptions)
          || typeof downloadOptions.converterOptions !== 'object'
        ))
        && throwErr('converterOptions', getTypeOf(downloadOptions.converterOptions), 'object')
        || downloadOptions.converterOptions
      ) || defaultAudioConvOptions  // Fallback value
    ),
    // ==> downloadOptions.quiet
    quiet: (
      (downloadOptions.quiet === null || typeof downloadOptions.quiet === 'undefined')
        ? false  // Fallback value
        : (downloadOptions.quiet && typeof downloadOptions.quiet !== 'boolean')
          && throwErr('quiet', getTypeOf(downloadOptions.quiet), 'boolean')
          || downloadOptions.quiet
    )
  };
}


// region Core Functions


/**
 * Retrieves information for multiple YouTube videos sequentially.
 *
 * This function accepts multiple YouTube URLs and retrieves information for each
 * video sequentially. It processes each URL one by one, ensuring that the next
 * URL is processed only after the previous one is complete.
 *
 * @param {...(string | URL)} urls - The YouTube video URLs to fetch information
 *                                   for. Each URL can be either a string or a URL object.
 * @returns {Promise<ytdl.videoInfo[]>} A promise that resolves to an array of video
 *                                      information objects.
 * @throws {Error} If any of the provided URLs are invalid, validated using
 *                 `validateURL` function from `ytdl-core` module.
 *
 * @example
 * const videoUrls = [
 *   'https://www.youtube.com/watch?v=abcd1234',
 *   'https://www.youtube.com/watch?v=wxyz5678'
 * ];
 * 
 * getVideosInfo(...videoUrls).then(videoInfos => {
 *   console.log(videoInfos);
 * }).catch(error => {
 *   console.error('Error fetching video info:', error);
 * });
 *
 * @async
 * @public
 * @since  0.2.0
 */
async function getVideosInfo(...urls) {
  if (!urls) return [];
  urls = urls.filter(url => !!url);
  const results = [];

  for (let url of urls) {
    url = (url instanceof URL) ? url.href : url?.trim();
    if (!URLUtils.validateUrl(url)) throw new URIError(`Invalid URL: ${url}`);
    results.push((await ytdl.getInfo(url)));
  }
  return results;
}

/**
 * Writes error details and associated video information to a log file.
 * 
 * This function creates or appends to a log file in the {@link module:utils~LOGDIR `LOGDIR`}
 * directory, logging the error message along with relevant video data. If the log file is empty
 * after writing, it will be deleted, and the function returns `false`. Otherwise, it 
 * returns `true` to indicate successful logging.
 *
 * The error message is written to the log file in the following format:
 *
 * ```txt
 * [ERROR] <error message>
 *   Title: <video title>
 *   Author: <video author>
 *   Channel ID: <video channel ID>
 *   Viewers: <video viewers>
 *   URL: <video URL>
 * ---------------------------------------------
 * ```
 *
 * Immediately return `false` if the given log file name is invalid. The error log
 * will be in the {@link module:utils~LOGDIR `LOGDIR`} directory.
 *
 * @param {string} logfile - The name of the log file where the error details should be written.
 * @param {VideoData | Object} videoData - An object containing information about the video associated
 *                                         with the error.
 * @param {Error} [error] - The error object, optional. If not provided,
 *                          an error message will be `'Unknown error'`.
 *
 * @returns {Promise<boolean>} A promise that resolves to `true` if the log was written
 *                             successfully, otherwise `false`.
 *
 * @async
 * @package
 * @since  1.0.0
 */
async function writeErrorLog(logfile, videoData, error) {
  if (!logfile || typeof logfile !== 'string') return false;

  logfile = path.join(LOGDIR, path.basename(logfile));
  createDirIfNotExistSync(LOGDIR);

  return new Promise((resolve, reject) => {
    const logStream = fs.createWriteStream(logfile, { flags: 'a+', flush: true });
  
    // Write the necessary video information to the log file
    logStream.write(`[ERROR] ${error?.message || 'Unknown error'}${os.EOL}`);
    logStream.write(`   Title: ${videoData.title}${os.EOL}`);
    logStream.write(`   Author: ${videoData.author}${os.EOL}`);
    logStream.write(`   Channel ID: ${videoData.channelId}${os.EOL}`);
    logStream.write(`   Viewers: ${videoData.viewers}${os.EOL}`);
    logStream.write(`   URL: ${videoData.videoUrl}${os.EOL}`);
    logStream.write(`---------------------------------------------${os.EOL}`);
    logStream.end(os.EOL);

    logStream.on('finish', () => resolve(true));
    logStream.on('error', (err) => {
      if (!logStream.destroyed) logStream.destroy();
      reject(err);
    });
  });
}


/**
 * Downloads audio from multiple YouTube videos sequentially.
 *
 * @param  {...(string | URL)} urls - The URLs to download audio from.
 *                                    Each URL can be a string or a URL object.
 * @yields {Promise<DownloadResult>} A promise that resolves to an object containing
 *                                   video information, video data, and a download stream.
 *
 * @async
 * @generator
 * @package
 * @since   1.0.0
 */
async function* downloadAudio(...urls) {
  // Map the URLs to strings, converting URL objects
  // to strings and trimming whitespace
  urls = urls.map((url) => ((url instanceof URL) ? url.href : url).trim());

  // Regex to replace illegal characters in file names with underscores
  const illegalCharRegex = /[<>:"/\\|?*]/g;

  // Fetch video information for each URL
  for (const info of (await getVideosInfo(...urls))) {
    /**
     * @ignore
     * @type {VideoData}
     */
    const data = {
      info,
      format: ytdl.chooseFormat(info.formats, AUDIO_FMT_OPTIONS),
      title: info.videoDetails.title.replace(illegalCharRegex, '_'),
      author: info.videoDetails.author.name,
      videoUrl: info.videoDetails.video_url,
      videoId: info.videoDetails.videoId,
      channelId: info.videoDetails.channelId,
      viewers: info.videoDetails.viewCount
    };

    // Yield the video information, processed data, and download stream
    yield Object.freeze({
      videoInfo: info,
      videoData: data,
      download: ytdl.downloadFromInfo(data.info, {
        format: data.format
      })
    });
  }
}


/**
 * 
 * @param {!Readable} readable - The readable stream to process.
 * @param {Object} data - The data object containing video information, video data, and an output stream.
 * @param {VideoData} data.videoData - The processed video data.
 * @param {fs.WriteStream} data.outStream - The output stream to write to.
 * @param {string} data.errLogFile - The error log file for logging errors during download.
 * @param {ProgressBar} data.progressBar - The progress bar object.
 * @param {boolean} verbose - Whether to display progress bar and error message to the terminal or not.
 *
 * @async
 * @package
 * @since  1.0.0
 */
async function downloadHandler(readable, data, verbose=false) {
  data = (!data) ? {} : data;  // Ensure data is an object
  await new Promise((resolve, reject) => {
    // Set up event listeners for the download stream
    readable
      .on('progress', (_chunk, bytesDownloaded, totalBytes) => {
        verbose && process.stdout.write(
          data.progressBar.create(bytesDownloaded, totalBytes));
      })
      .on('end', () => {
        verbose && log.done(`Download finished: \x1b[93m${data.videoData.title}\x1b[0m`);
        resolve();
      })
      .on('error', (err) => {
        verbose && log.error(`Download failed: ${data.videoData.title}`);

        // Close the output stream if it's still open
        data.outStream.closed || data.outStream.close();
        writeErrorLog(data.errLogFile, data.videoData, err).then(() => reject(err));
      })
      .pipe(data.outStream);
  });

  // Handle the output file stream
  data.outStream
    .on('error', (err) => {
      if (verbose) {
        log.error(`Unable to write to file: ${data.outStream.path}`);
        console.error(`Caused by: ${err.message}\n`);
      }
      if (fs.existsSync(data.outStream.path)) {
        // Delete the incomplete download file if exists
        fs.rmSync(data.outStream.path);
      }
      throw err;
    });
}

/**
 * Downloads audio from a single YouTube URL and saves it to the output directory.
 *
 * @param   {!(string | URL)}  inputUrl - The URL of the YouTube video to download audio from.
 * @param   {?(DownloadOptions | Object | undefined)} [downloadOptions]
 *          Options to configure the download process. If not specified, it will automatically uses default options.
 *
 * @returns {Promise<string>}          A promise that resolves a string representating
 *                                     the output file when the download completes
 *                                     or rejects if an error occurs.
 *
 * @throws {Error} If there is an error occurs during download process.
 * @throws {TypeError} If the input URL is not a string nor an instance of URL.
 *
 * @example
 * singleDownload('https://www.youtube.com/watch?v=<VIDEO_ID>')
 *   .then(outFile => console.log(outFile))
 *   .catch(err => console.error('Download failed:', err));
 *
 * @async
 * @public
 * @since   1.0.0
 */
async function singleDownload(inputUrl, downloadOptions) {
  inputUrl = (inputUrl instanceof URL) ? inputUrl.href : inputUrl?.trim();  // Trim any whitespace
  downloadOptions = resolveDlOptions({ downloadOptions });
  const { quiet } = downloadOptions;
  const progressBar = new ProgressBar();  // Initialize progress bar with default options

  // Check if the output directory exists
  await createDirIfNotExist(downloadOptions.outDir);

  // Validate the given URL
  validateYTURL(inputUrl, !quiet);

  quiet || log.info('Processing the video data...');
  const gen = downloadAudio(inputUrl);
  // Get the video information and download stream
  const { videoData, download } = (await gen.next()).value;

  // Create a write stream for the output file
  const outStream = fs.createWriteStream(
    path.join(downloadOptions.outDir, `${videoData.title}.m4a`));
  const errLogFile = createLogFile();  // Create a log file

  quiet || log.info(`Starting download \x1b[93m${videoData.title}\x1b[0m ...`);

  try {
    await downloadHandler(download, {
      videoData, outStream,
      errLogFile, progressBar
    }, !quiet);
  } catch (err) {
    const errLog = path.join(LOGDIR, path.basename(errLogFile));
    if (!quiet && fs.existsSync(errLog)) {
      log.error(`Error log written to: \x1b[93m${errLog}\x1b[0m`);
    }
    throw err;
  }

  if (downloadOptions.convertAudio) {
    if (await checkFfmpeg(false)) {
      try {
        // For the last touch, convert the downloaded audio to MP3 format
        await convertAudio(outStream.path, downloadOptions.converterOptions);
      } catch (err) {
        if (!quiet) {
          log.error(err.message);
          console.error(err.stack);
          log.warn('Skipping audio conversion for this file');
        }
      }
    } else {
      quiet || log.warn('ffmpeg not found, unable to convert audio to specific format');
    }
  }
  return outStream.path;  // Return the output file path, only if succeed
}


/**
 * Downloads audio from a file containing YouTube URLs and saves them to the output directory.
 *
 * This function is similar to {@link module:ytmp3~singleDownload `singleDownload`} but accepts
 * a file containing a list of YouTube URLs as input. If the given file is empty or does not exist,
 * an error will be thrown.
 *
 * As of version 1.0.0, this function has been enhanced and made more robust, aiming at the download process.
 * Previously, it only downloaded the first 15 URLs from the given file. Now, it downloads all of them sequentially.
 * The function can now handle an unlimited number of URLs, downloading them one by one (also known as,
 * sequential download) instead of all at once. Additionally, the download progress bar has been reworked
 * to display more precise and colorful information in the terminal, providing users with better insights
 * into the download process, and also more better and improved errors handling.
 *
 * @param   {!string} inputFile - The path to the file containing YouTube URLs.
 * @param   {?(DownloadOptions | Object | undefined)} [downloadOptions]
 *          Options to configure the download process. If not specified, it will automatically uses default options.
 *
 * @returns {Promise<string[]>} A promise that resolves to an array of strings representing the
 *                              successfully downloaded files or rejects if an error occurs.
 * 
 * @throws {Error} If the file does not exist or is empty, and if there is an error
 *                 occurs during download process.
 * 
 * @example
 * batchDownload('/path/to/urls.txt')
 *   .then(outFiles => console.log(outFiles))
 *   .catch(err => console.error('Download failed:', err));
 * 
 * @async
 * @public
 * @since  1.0.0
 */
async function batchDownload(inputFile, downloadOptions) {
  // Resolve the given file path
  inputFile = path.resolve(inputFile);
  downloadOptions = resolveDlOptions({ downloadOptions });
  const { quiet } = downloadOptions;

  // Check whether the file is exist
  if (!fs.existsSync(inputFile)) {
    throw new Error(`File not exists: ${inputFile}`);
  }

  // Read the contents of the file
  const contents = (await fs.promises.readFile(inputFile, 'utf8')).toString();
  if (contents.trim() === '') throw new Error('File is empty, no URLs found');

  // Split the contents into an array of URLs
  const urls = contents.trim().replace(/(\r\n|\r|\n)/g, '{X}').split('{X}');
  quiet || log.info('Validating URLs, please wait...');
  urls.forEach((url) => validateYTURL(url, false));  // ! Keep the verbose parameter set to false
  quiet || process.stdout.write(
    `${log.DONE_PREFIX} \x1b[92m\u2714\x1b[0m All URLs is valid\n`);

  const progressBar = new ProgressBar();  // Initialize progress bar with default options

  // Ensure that the output directory exists
  await createDirIfNotExist(downloadOptions.outDir);

  const gen = downloadAudio(...urls);

  // Initialize arrays to store the success and failed downloads
  const allDownloads = [],
        successDownloads = [],
        failedDownloads = [],
        failedConverts = [];

  // Create a log file for later logging the error during download
  const errLogFile = createLogFile();

  quiet || log.info(`Starting batch download from file \x1b[93m${
    path.basename(inputFile)}\x1b[0m ...`);

  for (let next = await gen.next(); !next.done; next = await gen.next()) {
    // Get the video information and download stream
    const { videoData, download } = next.value;
    const outFile = path.join(downloadOptions.outDir, `${videoData.title}.m4a`);
    // Create a write stream for the output file
    const outStream = fs.createWriteStream(outFile);
    allDownloads.push(outFile);

    try {
      await downloadHandler(download, {
        videoData, outStream,
        errLogFile, progressBar
      }, !quiet);
      successDownloads.push(outFile);
    /* eslint-disable-next-line no-unused-vars
       ---
       In this case, we don't need to do anything with the error,
       just continue the downloads and brief the download errors
       to user on the download summary afterwards */
    } catch (_err) {
      failedDownloads.push(outFile);
      // * Do not throw, continue download remaining audios if any
    }
  }

  if (downloadOptions.convertAudio) {
    if (await checkFfmpeg(false)) {
      for (const file of successDownloads) {
        try {
          await convertAudio(file, downloadOptions.converterOptions);
        } catch (err) {
          if (!quiet) {
            log.error(err.message);
            log.warn('Skipping audio conversion for one file');
          }
          failedConverts.push(file);
          // * Keep continue
        }
      }
    } else {
      quiet || log.warn('ffmpeg not found, unable to convert audio to specific format');
    }
  }

  // :: Downloads Summary
  if (!quiet) {
    console.log('\n\x1b[1m[DOWNLOADS SUMMARY]\x1b[0m');
    allDownloads.forEach((title) => {
      const downloaded = successDownloads.includes(title);
      title = path.basename(title);  // Trim the path
      console.log((downloaded ? '\x1b[2m' : '\x1b[1m') +
        `  [${downloaded ? '\u2714' : ' '}] ${(downloaded ? '' : '\x1b[91m') + title}\x1b[0m`);
    });

    process.stdout.write('\n');
    log.done('All done, with '
      + `\x1b[96m${failedDownloads.length}\x1b[0m download errors and `
      + `\x1b[96m${failedConverts.length}\x1b[0m convert errors`);

    const errLog = path.join(LOGDIR, path.basename(errLogFile));
    if ((failedDownloads.length > 0) && fs.existsSync(errLog)) {
      log.error(`Error logs written to: \x1b[93m${errLog}\x1b[0m`);
    }
  }

  return successDownloads;
}

module.exports = Object.freeze({
  NAME,
  VERSION,
  VERSION_INFO,
  validateYTURL,
  resolveDlOptions,
  downloadAudio,
  downloadHandler,
  getVideosInfo,
  writeErrorLog,
  singleDownload,
  batchDownload
});