diff --git a/index.js b/index.js index e976a8a..f0ee05c 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,9 @@ #!/usr/bin/env node /** - * Main module for **YTMP3** project to download YouTube videos as audio files using CLI. + * @file Main module for **YTMP3** project to download YouTube videos as audio files using CLI. * + * @requires lib/audioconv * @requires lib/utils * @requires lib/ytmp3 * @author Ryuu Mitsuki (https://github.com/mitsuki31) @@ -10,65 +11,353 @@ * @since 0.1.0 */ +/** + * A type definition for the filtered options object, which is returned from {@link filterOptions} function. + * + * @typedef {Object} FilteredOptions + * @property {string} url - The URL(s) to be processed. + * @property {string} batchFile - The path to the batch file containing YouTube URLs. + * @property {number} version - A number counter to show the version. 1 shows this module version only, 2 shows all dependencies' version. + * @property {boolean} copyright - A boolean flag to show the copyright information. + * @property {boolean} printConfig - A boolean flag to show the currently used configuration and exit. Useful for debugging. + * @property {DownloadOptions} downloadOptions - The options related to the download process. + * + * @private + * @since 1.0.0 + */ + 'use strict'; const fs = require('fs'); // File system module const path = require('path'); // Path module +const { ArgumentParser } = require('argparse'); const { defaultOptions: defaultAudioConvOptions, + resolveOptions: resolveACOptions, checkFfmpeg, convertAudio, } = require('./lib/audioconv'); -const { logger: log } = require('./lib/utils'); +const { logger: log, dropNullAndUndefined } = require('./lib/utils'); +const { importConfig } = require('./lib/config'); const ytmp3 = require('./lib/ytmp3'); -const { - downloadOptions, - audioConverterOptions -} = require('./config/ytmp3-js.config'); +const pkg = require('./package.json'); const DEFAULT_BATCH_FILE = path.join(__dirname, 'downloads.txt'); +const author = { + name: pkg.author.split(' <')[0], + email: /<(\w+@[a-z0-9.]+)>/m.exec(pkg.author)[1], + website: /\((https?:\/\/.+)\)/m.exec(pkg.author)[1] +}; + +const __version__ = (() => { + // eslint-disable-next-line prefer-const + let [ ver, rel ] = (pkg.version || '0.0.0-dev').split('-'); + rel = (rel && rel.length !== 0) + ? rel.charAt(0).toUpperCase() + rel.substr(1) // Capitalize first letter + : 'Stable'; + return `\x1b[1m[${pkg.name.toUpperCase()}] v${ver} \x1b[2m${rel}\x1b[0m\n`; +})(); + +const __copyright__ = `${pkg.name} - Copyright (c) 2023-${ + new Date().getFullYear()} ${author.name} (${author.website})\n`; /** - * Gets the input argument from the command line arguments. + * Initializes the argument parser for command-line options. + * + * @returns {argparse.ArgumentParser} The `ArgumentParser` instance. + * + * @private + * @since 1.0.0 + */ +function initParser() { + const parser = new ArgumentParser({ + prog: pkg.title + ? pkg.title.toLowerCase() + : (pkg.name ? pkg.name.replace('-js', '') : 'ytmp3'), + description: pkg.description, + epilog: `Developed by \x1b[93m${author.name}\x1b[0m (${author.website}).`, + // eslint-disable-next-line camelcase + add_help: false // Use custom help argument + }); + + // ==== Download Options ==== // + // :: URL + parser.add_argument('URL', { + help: 'The YouTube URL(s) to download. Supports multiple URLs', + type: 'str', + nargs: '*' // Support multiple URLs + }); + // :: cwd + parser.add_argument('--cwd', { + metavar: 'DIR', + help: 'Set the current working directory (default: current directory)', + type: 'str', + default: '.' + }); + // :: FILE + parser.add_argument('-f', '--file', '--batch', { + help: 'Path to a file containing a list of YouTube URLs for batch downloading', + type: 'str', + dest: 'file' + }); + // :: outDir + parser.add_argument('-o', '--outDir', '--out-dir', { + metavar: 'DIR', + help: 'Specify the output directory for downloaded files (default: current directory)', + type: 'str', + default: '.', + dest: 'outDir' + }); + // :: config + parser.add_argument('-c', '--config', { + metavar: 'FILE', + help: 'Path to configuration file containing `downloadOptions` object', + type: 'str', + dest: 'config' + }); + // :: convertAudio + parser.add_argument('-C', '--convertAudio', '--convert-audio', { + help: 'Enable audio conversion to a specific format (requires FFmpeg)', + action: 'store_true', + dest: 'convertAudio' + }); + + // ==== Audio Converter Options ==== // + // :: format + parser.add_argument('--format', { + metavar: 'FMT', + help: 'Convert the audio to the specified format. Requires `--convertAudio`', + type: 'str', + }); + // :: codec + parser.add_argument('--codec', '--encoding', { + metavar: 'CODEC', + help: 'Specify the codec for the converted audio. Requires `--convertAudio`', + dest: 'codec' + }); + // :: bitrate + parser.add_argument('--bitrate', { + metavar: 'N', + help: 'Set the bitrate for the converted audio in kbps. Requires `--convertAudio`', + type: 'str' + }); + // :: frequency + parser.add_argument('--freq', '--frequency', { + metavar: 'N', + help: 'Set the audio sampling frequency for the converted audio in Hertz. Requires `--convertAudio`', + type: 'int', + dest: 'frequency' + }); + // :: channels + parser.add_argument('--channels', { + metavar: 'N', + help: 'Specify the audio channels for the converted audio. Requires `--convertAudio`', + type: 'int' + }); + // :: deleteOld + parser.add_argument('--deleteOld', '--delete-old', '--overwrite', { + help: 'Delete the old file after audio conversion is done. Requires `--convertAudio`', + action: 'store_true', + dest: 'deleteOld' + }); + // :: quiet + parser.add_argument('-q', '--quiet', { + help: 'Suppress output messages. Use `-qq` to also suppress audio conversion progress', + action: 'count', + default: 0 // Set the default to ensure it is always number + }); + + // ==== Other Options ==== // + // :: help + parser.add_argument('-h', '-?', '--help', { + help: 'Show this help message and exit', + action: 'help' + }); + // :: version + parser.add_argument('-V', '--version', { + help: 'Show the version. Use `-VV` to show all dependencies version', + action: 'count', + default: 0 + }); + // :: copyright + parser.add_argument('--copyright', { + help: 'Show the copyright information', + action: 'store_true' + }); + parser.add_argument('--print-config', { + help: 'Show currently used configuration and exit. Useful for debugging', + action: 'store_true', + dest: 'printConfig' + }); + + return parser; +} + +/** + * Filters and processes the provided options object for use in the download and conversion process. + * + * @param {Object} params - The parameters object. + * @param {Object} params.options - The options object containing various configuration + * settings from command-line argument parser. * - * If the first argument is a valid URL, the function returns the URL. - * Otherwise, if the first argument is a batch file path, the function - * returns the file path. + * @returns {FilteredOptions} A frozen object with the filtered and processed options. * - * @returns {URL | string} - The input argument from the command line; - * either a `URL` object or a batch file path. + * @description + * This function performs the following steps: + * 1. Validates the `options` object to ensure it is not null, an array, or a non-object. + * 2. Creates a deep copy of the `options` object to avoid mutating the original input. + * 3. Extracts the `quiet` property from the copied options and deletes it from the object to + * prevent conflicts with other functions. + * 4. Constructs a new object containing the processed options for download and conversion, + * including: + * - `url`: The URL(s) to be processed. + * - `batchFile`: The path to the batch file containing YouTube URLs. + * - `version`: The version information. + * - `copyright`: The copyright information. + * - `downloadOptions`: The options related to the download process, resolved using + * {@link module:ytmp3~resolveDlOptions `ytmp3.resolveDlOptions`} and + * {@link module:audioconv~resolveOptions `audioconv.resolveOptions`} (for the + * audio conversion options). + * - `converterOptions`: The options related to audio conversion. + * - `quiet`: A boolean flag to suppress log messages and progress information + * based on the value of `quiet`. * - * @throws {Error} If no batch file is specified. + * The returned object is frozen to prevent further modifications. * * @private - * @since 0.2.0 + * @since 1.0.0 */ -function getInput() { - const args = process.argv.slice(2); // Get all arguments except the first two - - if (args.length) { - try { - // Attempt to parse the first argument as a URL - // if failed, then the input it may be a batch file - return new URL(args[0]); - } catch (error) { - return args[0]; +async function filterOptions({ options }) { + if (!options || (Array.isArray(options) || typeof options !== 'object')) return {}; + + // Deep copy the options + const optionsCopy = JSON.parse(JSON.stringify(options)); + + // We need to extract the quiet option first and delete it + // if not, `audioconv.resolveOptions()` function will throw an error + const { quiet } = optionsCopy; + delete optionsCopy.quiet; + + // Extract and resolve the download options from configuration file if given + const dlOptionsFromConfig = optionsCopy.config + ? await /* may an ES module */ importConfig(optionsCopy.config) + : {}; + const acOptionsFromConfig = dlOptionsFromConfig.converterOptions || {}; + delete optionsCopy.config; // No longer needed + delete dlOptionsFromConfig.converterOptions; // No longer needed + + return Object.freeze({ + url: optionsCopy.URL, + batchFile: optionsCopy.file, + version: optionsCopy.version, + copyright: optionsCopy.copyright, + printConfig: optionsCopy.printConfig, + downloadOptions: { + ...ytmp3.resolveDlOptions({ downloadOptions: { + // Download options from config file can be overriden with download + // options from the command-line + ...dlOptionsFromConfig || {}, + ...(ytmp3.resolveDlOptions({ downloadOptions: optionsCopy })) + }}), + converterOptions: { + ...resolveACOptions({ + ...dropNullAndUndefined(acOptionsFromConfig), + ...dropNullAndUndefined(optionsCopy) + }, false), + quiet: quiet >= 2 + }, + quiet: quiet >= 1 } + }); +} + + +/** + * Main function. + * @private + * @since 1.0.0 + */ +async function main() { + const { + url, + batchFile, + version, + copyright, + downloadOptions, + printConfig + } = await filterOptions({ + options: initParser().parse_args() + }); + + // Version + if (version === 1) { + process.stdout.write(__version__); + return; + } else if (version >= 2) { + // If got '-VV' or '--version --version', then verbosely print this module + // version and all dependencies' version + const deps = Object.keys(pkg.dependencies); + process.stdout.write(__version__); + for (const dep of deps) { + process.stdout.write(`\x1b[1m ${ + ((deps.indexOf(dep) !== deps.length - 1) ? '├── ' : '└── ') + }${dep} :: v${require(dep + '/package.json').version}\x1b[0m\n`); + } + return; + } + // Copyright + if (copyright) { + process.stdout.write(__copyright__); + return; + } + // Print configuration + if (printConfig) { + console.log(downloadOptions); + return; } - // If no argument is specified, then return the default batch file path - log.info('\x1b[2mNo URL and batch file specified, using default batch file\x1b[0m'); - if (!fs.existsSync(DEFAULT_BATCH_FILE)) { - log.error( - `Default batch file named \x1b[93m${ - path.basename(DEFAULT_BATCH_FILE)}\x1b[0m does not exist`); - log.error('Aborted'); + let downloadSucceed = false; + try { + if ((!url || (url && !url.length)) && !batchFile) { + const defaultBatchFileBase = path.basename(DEFAULT_BATCH_FILE); + log.info(`\x1b[2mNo URL and batch file specified, searching \x1b[93m${ + defaultBatchFileBase}\x1b[0m\x1b[2m ...\x1b[0m`); + if (!fs.existsSync(DEFAULT_BATCH_FILE)) { + log.error(`Cannot find \x1b[93m${ + defaultBatchFileBase}\x1b[0m at current directory`); + log.error('Aborted'); + process.exit(1); + } + log.info('\x1b[95mMode: \x1b[97mBatch Download\x1b[0m'); + downloadSucceed = !!await ytmp3.batchDownload(DEFAULT_BATCH_FILE, downloadOptions); + } else if ((!url || (url && !url.length)) && batchFile) { + log.info('\x1b[95mMode: \x1b[97mBatch Download\x1b[0m'); + downloadSucceed = !!await ytmp3.batchDownload(batchFile, downloadOptions); + } else if (url.length && !batchFile) { + if (Array.isArray(url) && url.length > 1) { + log.info('\x1b[95mMode: \x1b[97mMultiple Downloads\x1b[0m'); + console.log(url); // FIXME + // TODO: Add support for multiple downloads + log.warn('Currently multiple downloads from URLs are not suppported'); + process.exit(0); + } else { + log.info('\x1b[95mMode: \x1b[97mSingle Download\x1b[0m'); + downloadSucceed = !!await ytmp3.singleDownload(url[0], downloadOptions); + } + } + } catch (dlErr) { + log.error(dlErr.message); + console.error(dlErr.stack); process.exit(1); } - return DEFAULT_BATCH_FILE; + + if (downloadSucceed) { + log.info(`Downloaded files saved at \x1b[93m${downloadOptions.outDir}\x1b[0m`); + } } + module.exports = Object.freeze({ // :: ytmp3 (Core) name: ytmp3.name, @@ -83,22 +372,6 @@ module.exports = Object.freeze({ }); - if (require.main === module) { - const input = getInput(); - - // Assign the `audioConverterOptions` to `downloadOptions` - Object.assign(downloadOptions, { converterOptions: audioConverterOptions }); - - if (input instanceof URL) { - log.info('URL input detected!'); - ytmp3.singleDownload(input, downloadOptions); - } else { - if (input !== DEFAULT_BATCH_FILE && !fs.existsSync(input)) { - log.error(`Batch file named \x1b[93m${input}\x1b[0m does not exist`); - log.error('Aborted'); - process.exit(1); - } - ytmp3.batchDownload(input, downloadOptions); - } + main(); } diff --git a/lib/audioconv.js b/lib/audioconv.js index fe0abe7..3bd7776 100644 --- a/lib/audioconv.js +++ b/lib/audioconv.js @@ -51,6 +51,7 @@ const { logger: log } = require('./utils'); * @property {boolean} [deleteOld=false] - Whether to delete the original file after conversion. * @property {boolean} [quiet=false] - Whether to suppress the conversion progress and error message or not. * + * @global * @since 1.0.0 * @see {@link module:audioconv~defaultOptions defaultOptions} */ @@ -106,29 +107,29 @@ const defaultOptions = Object.freeze({ * @return {ResolvedConvertAudioOptions} The resolved options. * @since 1.0.0 */ -function resolveOptions(options) { +function resolveOptions(options, useDefault=true) { return { format: (typeof options?.format === 'string') ? options.format - : defaultOptions.format, + : (useDefault ? defaultOptions.format : undefined), bitrate: ['string', 'number'].includes(typeof options?.bitrate) ? options.bitrate - : defaultOptions.bitrate, + : (useDefault ? defaultOptions.bitrate : undefined), frequency: (typeof options?.frequency === 'number') ? options.frequency - : defaultOptions.frequency, + : (useDefault ? defaultOptions.frequency : undefined), codec: (typeof options?.codec === 'string') ? options.codec - : defaultOptions.codec, + : (useDefault ? defaultOptions.codec : undefined), channels: (typeof options?.channels === 'number') ? options.channels - : defaultOptions.channels, + : (useDefault ? defaultOptions.channels : undefined), deleteOld: (typeof options?.deleteOld === 'boolean') ? options.deleteOld - : defaultOptions.deleteOld, + : (useDefault ? defaultOptions.deleteOld : undefined), quiet: (typeof options?.quiet === 'boolean') ? options.quiet - : defaultOptions.quiet + : (useDefault ? defaultOptions.quiet : undefined) }; } @@ -296,6 +297,7 @@ async function convertAudio(inFile, options = defaultOptions) { module.exports = Object.freeze({ defaultOptions, + resolveOptions, checkFfmpeg, convertAudio }); diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..b005f00 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,339 @@ +/** + * @file This module handles configuration resolution for the **YTMP3-JS** project. + * + * This module offers a parser and resolver for the configuration file of YTMP3-JS, + * which can parse both JSON and JS configuration file (support both CommonJS and + * ES module). You can see the {@link module:config~KNOWN_CONFIG_EXTS `KNOWN_CONFIG_EXTS`} + * constant to check the supported configuration file's extension names. + * + * The {@link module:config~parseConfig `parseConfig`} function will parse + * and optionally resolve the configuration file containing the download options + * and audio converter options (if defined). Before being resolved, the configuration + * file will be validated first and will throws a `TypeError` if any known + * configuration options has an invalid type, or throws a + * {@link module:config~UnknownOptionError `UnknownOptionError`} if there + * is an unknown option defined within the configuration object (see the + * {@link module:config~KNOWN_OPTIONS `KNOWN_OPTIONS`}). + * + * @example JSON Configuration File + * { + * "downloadOptions": { + * "outDir": "/path/to/download/folder", + * "quiet": false, + * "convertAudio": true, + * "converterOptions": { + * "format": "opus", + * "codec": "libopus", + * "channels": 1, + * "deleteOld": true + * } + * } + * } + * + * @example CommonJS Module Configuration File + * module.exports = { + * downloadOptions: { + * outDir: '..', + * convertAudio: false, + * quiet: true + * } + * } + * + * @example ES Module Configuration File + * export default { + * downloadOptions: { + * cwd: process.env.HOME, + * outDir: 'downloads', // $HOME/downloads + * convertAudio: true + * }, + * audioConverterOptions: { + * format: 'mp3', + * codec: 'libmp3lame', + * frequency: 48000, + * bitrate: '128k' + * deleteOld: true + * } + * } + * + * @module config + * @requires ytmp3 + * @requires audioconv + * @requires utils + * @author Ryuu Mitsuki (https://github.com/mitsuki31) + * @license MIT + * @since 1.0.0 + */ + +/** + * A typedef representating the configuration object containing options to configure + * the both download and audio conversion process. + * + * @typedef {Object} YTMP3Config + * @property {DownloadOptions} downloadOptions + * Options related to the download process. + * @property {ConvertAudioOptions} [audioConverterOptions] + * Options related to the audio conversion process, if not defined in + * `downloadOptions`. This field will be ignored if the `downloadOptions.converterOptions` + * property are defined and not contains a nullable value. + * + * @global + * @since 1.0.0 + */ + +'use strict'; + +const path = require('node:path'); +const util = require('node:util'); +const ytmp3 = require('./ytmp3'); +const { + isNullOrUndefined, + isObject, +} = require('./utils'); +const { + resolveOptions: resolveACOptions +} = require('./audioconv'); + +/** + * An array containing all known configuration file's extension names. + * @default + * @since 1.0.0 + */ +const KNOWN_CONFIG_EXTS = [ '.js', '.mjs', '.cjs', '.json' ]; +/** + * An array containing all known configuration options. + * @default + * @since 1.0.0 + */ +const KNOWN_OPTIONS = [ 'downloadOptions', 'audioConverterOptions' ]; +/** + * A string representating the format of error message. + * Can be formatted using `util.format()` function. + * + * First occurrence of `'%s'` will be intepreted as error message, the second + * as the directory name of configuration file, and the third one as the base name + * of the configuration file. + * + * @default '%s\n\tat \x1b[90m%s\n\x1b[1;91m%s\x1b[0m\n' + * @since 1.0.0 + */ +const ERR_FORMAT = '%s\n\tat \x1b[90m%s\n\x1b[1;91m%s\x1b[0m\n'; + +/** + * A class represents the error that occurred due to defining an unknown option + * in the configuration object and may throw during configuration validation. + * + * @public + * @extends Error + * @since 1.0.0 + */ +class UnknownOptionError extends Error {} + +/** + * Resolves the configuration for YTMP3-JS from a given configuration object. + * + * This function takes a configuration object typically sourced from a config file + * (e.g., `ytmp3-js.config.js`) and ensures that it adheres to the expected structure + * and types. It specifically resolves the download options and the audio converter + * options, providing fallbacks and handling type checks. + * + * @param {Object} params - The parameters for the function. + * @param {YTMP3Config} params.config - The configuration object to be resolved. + * @param {string} params.file - The file path from which the config object was sourced, + * used for error reporting. + * + * @returns {DownloadOptions} The resolved download options and audio converter + * options if provided. + * + * @throws {TypeError} If any known options is not object type. + * + * @public + * @since 1.0.0 + */ +function resolveConfig({ config, file }) { + if (isNullOrUndefined(config) || !isObject(config)) return {}; + if (!file || typeof file === 'string') file = 'unknown'; + + // Check and validate the configuration + configChecker({ config, file }); + + // By using this below logic, if user specified with any falsy value + // or unspecified it will uses the fallback value instead + let downloadOptions = config.downloadOptions || {}; + let audioConverterOptions; + if ('converterOptions' in downloadOptions) { + audioConverterOptions = downloadOptions.converterOptions || {}; + } else if ('audioConverterOptions' in config) { + audioConverterOptions = config.audioConverterOptions || {}; + } + + // Resolve the download options + downloadOptions = ytmp3.resolveDlOptions({ downloadOptions }); + // Resolve the audio converter options, but all unspecified options will + // fallback to undefined value instead their default value + audioConverterOptions = resolveACOptions(audioConverterOptions || {}, false); + + // Assign the `audioConverterOptions` to `downloadOptions` + Object.assign(downloadOptions, { + converterOptions: audioConverterOptions + }); + return downloadOptions; +} + +/** + * Checks the given configuration for validity. + * + * This function ensures that the configuration object adheres to the expected structure + * and types. It checks for unknown fields and validates the types of known options. + * Throws an error if there any known options is not object type or if there are + * unknown fields defined in the configuration. + * + * @param {Object} params - The parameters for the function. + * @param {YTMP3Config} params.config - The configuration object to be checked. + * @param {string} params.file - The file path from which the config object was sourced, + * used for error reporting. + * + * @throws {TypeError} If any known options is not object type. + * @throws {UnknownOptionError} If there are unknown fields in the configuration. + * + * @private + * @since 1.0.0 + */ +function configChecker({ config, file }) { + if (!config || typeof config !== 'object') { + throw new TypeError('Invalid type of configuration: ' + typeof config); + } + + file = (file && !path.isAbsolute(file)) ? path.resolve(file) : file; + const dirFile = path.dirname(file); + const baseFile = path.basename(file); + + Array.from(Object.keys(config)).forEach(function (field) { + // Check for unknown field as option within the configuration options + if (!(Array.from(KNOWN_OPTIONS).includes(field))) { + throw new UnknownOptionError(util.format(ERR_FORMAT, + `Unknown configuration field: '${field}' (${typeof config[field]})`, + dirFile, baseFile + )); + } + + // Check for known options have a valid type (object) + if (isNullOrUndefined(config[field]) || !isObject(config[field])) { + throw new TypeError(util.format(ERR_FORMAT, + `Expected type of field '${field}' is an object`, + dirFile, baseFile + )); + } + }); +} + +/** + * Parses a configuration file and either resolves or validates its contents. + * + * This function can handle both CommonJS and ES module formats for configuration files. + * When importing an ES module, it returns a `Promise` that resolves to the configuration + * object. It also supports optional resolution of the configuration. + * + * @param {!string} configFile - The path or URL to the configuration file. + * @param {boolean} [resolve=true] - Determines whether to resolve the configuration object. + * If set to `false`, will validate the configuration only. + * + * @returns {YTMP3Config | DownloadOptions | Promise} + * The configuration object or a `Promise` that fullfilled with the + * configuration object if an ES module is imported. The returned configuration + * object will be automatically resolved if `resolve` is set to `true`. + * + * @throws {TypeError} - If the given `configFile` is not a string. + * @throws {Error} - If the file extension is not supported or if an error occurs during import. + * + * @example Synchronously parse a CommonJS configuration file + * const config = parseConfig('./config.js'); + * console.log(config); + * + * @example Asynchronously parse an ES module configuration file + * parseConfig('./config.mjs').then((config) => { + * console.log(config); + * }).catch((error) => { + * console.error('Failed to load config:', error); + * }); + * + * @public + * @since 1.0.0 + * @see {@link module:config~resolveConfig `resolveConfig({ config, file })`} + */ +function parseConfig(configFile, resolve=true) { + function resolveOrCheckOnly(config) { + // Attempt to extract the default export (only on ES module) if the configuration + // object only contains that 'default' property, for clarity: + // export default { ... } + // + if (isObject(config) && Object.keys(config).length === 1 && 'default' in config) { + config = config.default; // Extract the default export + } + + // Resolve the configuration object if `resolve` is set to true + if (resolve) config = resolveConfig({ config, file }); // Return {} if null + // Otherwise, only validate the configuration + else configChecker({ config, file }); + return config; + } + + if (!configFile || typeof configFile !== 'string') { + throw new TypeError('Expected a string path refers to a configuration file'); + } + + const file = path.resolve(configFile); // Copy and resolve path + const ext = path.extname(configFile); // Extract the extension name + + if (!(KNOWN_CONFIG_EXTS.includes(ext))) { + throw new Error(`Supported configuration file is: ${ + KNOWN_CONFIG_EXTS.map(x => `'${x}'`).toString().replace(/,/g, ' | ') + }`); + } + + // Resolve the configuration file path + configFile = path.resolve(((configFile instanceof URL) ? configFile.href : configFile)); + + // Import the configuration file + let config = null; + try { + config = require(configFile); + } catch (importErr) { + if ('code' in importErr && importErr.code === 'ERR_REQUIRE_ESM') { + // This error occurred due to attempting to import ESM module with `require()` + config = import(configFile); + } else { + // Otherwise, throw back any error + throw importErr; + } + } + + if (config instanceof Promise) { + // Return a Promise if the imported config module is a ES Module + return new Promise(function (res, rej) { + config.then((result) => res(resolveOrCheckOnly(result))).catch((err) => rej(err)); + }); + } + + return resolveOrCheckOnly(config); +} + +/** + * An alias for {@link module:config~parseConfig `parseConfig`} function, + * with `resolve` argument is set to true. + * + * @param {string} file - A string path refers to configuration file to import and resolve. + * @returns {YTMP3Config | DownloadOptions | Promise<(YTMP3Config | DownloadOptions)>} + * + * @public + * @function + * @since 1.0.0 + * @see {@link module:config~parseConfig `parseConfig`} + */ +const importConfig = (file) => parseConfig(file, true); + + +module.exports = Object.freeze({ + resolveConfig, + parseConfig, + importConfig +}); diff --git a/lib/utils.js b/lib/utils.js index 8fd1002..e476d01 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -97,6 +97,41 @@ async function createDirIfNotExist(dirpath) { * @since 1.0.0 */ +/** + * Checks if a given value is null or undefined. + * + * @param {any} x - The value to check. + * @returns {boolean} - `true` if the value is null or undefined, otherwise `false`. + * + * @public + * @since 1.0.0 + */ +function isNullOrUndefined(x) { + return (x === null || typeof x === 'undefined'); +} + +/** + * Checks if a given value is an object type. + * + * This function will strictly checks whether the given value is an object, not `null`, + * `Array`, a `RegExp` instance, neither a `URL` instance. + * + * @param {any} x - The value to check. + * @returns {boolean} - `true` if the value is an object and not null, an array, + * a `RegExp`, nor a URL object, otherwise false. + * + * @public + * @since 1.0.0 + */ +function isObject(x) { + return (!isNullOrUndefined(x) + && !Array.isArray(x) + && !(x instanceof RegExp) + && !(x instanceof URL) + && typeof x === 'object' + ); +} + /** * A custom logger object for the **YTMP3** project with ANSI color codes. * @@ -333,9 +368,29 @@ class ProgressBar { } +/** + * Drops null and undefined values from the input object. + * + * @param {Object} obj - The input object to filter null and undefined values from. + * @return {Object} The filtered object without null and undefined values. + * + * @public + * @since 1.0.0 + */ +function dropNullAndUndefined(obj) { + return Object.keys(obj).reduce((acc, key) => { + if (!isNullOrUndefined(obj[key])) acc[key] = obj[key]; + return acc; + }, {}); +} + + module.exports = Object.freeze({ ROOTDIR, OUTDIR, LOGDIR, logger, + isNullOrUndefined, + isObject, ProgressBar, - createDirIfNotExist + createDirIfNotExist, + dropNullAndUndefined }); diff --git a/lib/ytmp3.js b/lib/ytmp3.js index 4b1f2b9..e59f7ee 100644 --- a/lib/ytmp3.js +++ b/lib/ytmp3.js @@ -107,11 +107,11 @@ const { VIDEO: VIDEO_URL } = require('./yt-urlfmt'); * 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 {module:audioconv~ConvertAudioOptions} [converterOptions=require('./audiconv').defaultOptions] + * @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`. * - * @public + * @global * @since 1.0.0 */ @@ -714,6 +714,7 @@ async function batchDownload(inputFile, downloadOptions) { const ytmp3 = Object.create(null, { ProgressBar: { value: ProgressBar, ...FrozenProperty }, + resolveDlOptions: { value: resolveDlOptions, ...FrozenProperty }, getVideosInfo: { value: getVideosInfo, ...FrozenProperty }, singleDownload: { value: singleDownload, ...FrozenProperty }, batchDownload: { value: batchDownload, ...FrozenProperty }, diff --git a/package-lock.json b/package-lock.json index 0f161b4..fb04af7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0-beta", "license": "MIT", "dependencies": { + "argparse": "^2.0.1", "fluent-ffmpeg": "^2.1.3", "ytdl-core": "^4.11.5" }, @@ -270,8 +271,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/async": { "version": "0.2.10", diff --git a/package.json b/package.json index d9a352d..f5f21a0 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "url": "https://ko-fi.com/dhefam31" }, "dependencies": { + "argparse": "^2.0.1", "fluent-ffmpeg": "^2.1.3", "ytdl-core": "^4.11.5" },