From 8b708988604134001382fc2c476a97598059057d Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sat, 6 Jul 2024 16:17:47 +0700 Subject: [PATCH 01/18] feat(audioconv): Add `useDefault` param to `resolveOptions` Set this parameter to false will make the function to set the unspecified or null options to undefined instead the default value. Additionally, the `resolveOptions` are now exported to ease the internal development. --- lib/audioconv.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/audioconv.js b/lib/audioconv.js index fe0abe7..de443c4 100644 --- a/lib/audioconv.js +++ b/lib/audioconv.js @@ -106,29 +106,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 +296,7 @@ async function convertAudio(inFile, options = defaultOptions) { module.exports = Object.freeze({ defaultOptions, + resolveOptions, checkFfmpeg, convertAudio }); From 5179fe0ad03ab57895e6295ac7440c56ab73a5fe Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sat, 6 Jul 2024 16:21:51 +0700 Subject: [PATCH 02/18] chore(ytmp3): Export the `resolveDlOptions` function For easier code maintenance within internal project. --- lib/ytmp3.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ytmp3.js b/lib/ytmp3.js index 4b1f2b9..c88d1d5 100644 --- a/lib/ytmp3.js +++ b/lib/ytmp3.js @@ -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 }, From 11a7d589b7d25d3be5b8ef5b3c1f3c85c7e3d19a Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sun, 7 Jul 2024 15:46:52 +0700 Subject: [PATCH 03/18] chore(deps): Add the `argparse` dependency This module is useful to parse command-line options. --- package-lock.json | 4 ++-- package.json | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) 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" }, From 6a7ca9a798cf437b75790af295c31ba21ce91bdb Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sun, 7 Jul 2024 19:40:36 +0700 Subject: [PATCH 04/18] feat: Add the `initParser` function --- index.js | 146 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 142 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index e976a8a..9ddd60b 100644 --- a/index.js +++ b/index.js @@ -14,20 +14,37 @@ 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 ytmp3 = require('./lib/ytmp3'); -const { - downloadOptions, - audioConverterOptions -} = require('./config/ytmp3-js.config'); +const config = 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__ = (() => { + 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. @@ -69,6 +86,127 @@ function getInput() { return DEFAULT_BATCH_FILE; } + +/** + * 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' + }); + // :: 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' + }); + + return parser; +} + module.exports = Object.freeze({ // :: ytmp3 (Core) name: ytmp3.name, From 9cc39913ace92e95de86cdec7faa93aae27638ea Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sun, 7 Jul 2024 19:42:32 +0700 Subject: [PATCH 05/18] feat: Add `filterOptions` function for resolving options This function will perform filtering and resolving given options from command-line argument parser and returns a read-only object with the processed and filtered options. --- index.js | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/index.js b/index.js index 9ddd60b..b2ee6b7 100644 --- a/index.js +++ b/index.js @@ -207,6 +207,67 @@ function initParser() { 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. + * + * @returns {Object} A frozen object with the filtered and processed options. + * + * @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`. + * + * The returned object is frozen to prevent further modifications. + * + * @private + * @since 1.0.0 + */ +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; + + return Object.freeze({ + url: optionsCopy.URL, + batchFile: optionsCopy.file, + version: optionsCopy.version, + copyright: optionsCopy.copyright, + downloadOptions: { + ...(ytmp3.resolveDlOptions({ downloadOptions: optionsCopy })), + converterOptions: { + ...(resolveACOptions(optionsCopy, false)), + quiet: (quiet >= 2) ? true : false + }, + quiet: (quiet >= 1) ? true : false + } + }); +} + module.exports = Object.freeze({ // :: ytmp3 (Core) name: ytmp3.name, From 3815d614af457807d0ae95f4cd2d92bb2d6ff26c Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sun, 7 Jul 2024 20:15:52 +0700 Subject: [PATCH 06/18] fix(lint): Remove redundant double negation Fixed ESLint error for rule: no-extra-boolean-cast --- lib/audioconv.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/audioconv.js b/lib/audioconv.js index de443c4..d011ed1 100644 --- a/lib/audioconv.js +++ b/lib/audioconv.js @@ -110,25 +110,25 @@ function resolveOptions(options, useDefault=true) { return { format: (typeof options?.format === 'string') ? options.format - : (!!useDefault ? defaultOptions.format : undefined), + : (useDefault ? defaultOptions.format : undefined), bitrate: ['string', 'number'].includes(typeof options?.bitrate) ? options.bitrate - : (!!useDefault ? defaultOptions.bitrate : undefined), + : (useDefault ? defaultOptions.bitrate : undefined), frequency: (typeof options?.frequency === 'number') ? options.frequency - : (!!useDefault ? defaultOptions.frequency : undefined), + : (useDefault ? defaultOptions.frequency : undefined), codec: (typeof options?.codec === 'string') ? options.codec - : (!!useDefault ? defaultOptions.codec : undefined), + : (useDefault ? defaultOptions.codec : undefined), channels: (typeof options?.channels === 'number') ? options.channels - : (!!useDefault ? defaultOptions.channels : undefined), + : (useDefault ? defaultOptions.channels : undefined), deleteOld: (typeof options?.deleteOld === 'boolean') ? options.deleteOld - : (!!useDefault ? defaultOptions.deleteOld : undefined), + : (useDefault ? defaultOptions.deleteOld : undefined), quiet: (typeof options?.quiet === 'boolean') ? options.quiet - : (!!useDefault ? defaultOptions.quiet : undefined) + : (useDefault ? defaultOptions.quiet : undefined) }; } From 0e6be9f17a97b40d4bc84bde57471f8450e95219 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sun, 7 Jul 2024 20:37:19 +0700 Subject: [PATCH 07/18] fix(lint): Fix several lint errors * Removed unnecessary escape on regular expression * Disabled error for camelcase and prefer-const rules for specific code * Changed the double quotes to single quote strings --- index.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index b2ee6b7..4a968df 100644 --- a/index.js +++ b/index.js @@ -30,11 +30,12 @@ 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], + 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 @@ -102,7 +103,7 @@ function initParser() { : (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 + // eslint-disable-next-line camelcase add_help: false // Use custom help argument }); @@ -116,7 +117,7 @@ function initParser() { // :: cwd parser.add_argument('--cwd', { metavar: 'DIR', - help: "Set the current working directory (default: current directory)", + help: 'Set the current working directory (default: current directory)', type: 'str', default: '.' }); @@ -129,7 +130,7 @@ function initParser() { // :: outDir parser.add_argument('-o', '--outDir', '--out-dir', { metavar: 'DIR', - help: "Specify the output directory for downloaded files (default: current directory)", + help: 'Specify the output directory for downloaded files (default: current directory)', type: 'str', default: '.', dest: 'outDir' From 714235ca1a6067521c7db0c884d886fceb11cfb4 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sun, 7 Jul 2024 20:42:39 +0700 Subject: [PATCH 08/18] feat: Add new argument to configure via config file * Added new option to argument parser with options as following: - '-c' - '--config' * Implemented a new function called `resolveConfig` to resolve and parse the configuration file along with the documentation * Added a typedef called `YTMP3_Config` used for references of the configuration file * Removed the `getInput()` function from main module --- index.js | 96 +++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 68 insertions(+), 28 deletions(-) diff --git a/index.js b/index.js index 4a968df..5b116ba 100644 --- a/index.js +++ b/index.js @@ -10,6 +10,18 @@ * @since 0.1.0 */ +/** + * @typedef {Object} YTMP3_Config + * @property {module:ytmp3~DownloadOptions} downloadOptions + * Options related to the download process. + * @property {module:audioconv~AudioConverterOptions} [audioConverterOptions] + * Options related to the audio conversion process, if not defined in + * `downloadOptions`. + * + * @global + * @since 1.0.0 + */ + 'use strict'; const fs = require('fs'); // File system module @@ -48,45 +60,66 @@ const __copyright__ = `${pkg.name} - Copyright (c) 2023-${ /** - * Gets the input argument from the command line arguments. + * Resolves the configuration for YTMP3-JS from a given config object and file. * - * 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. + * 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. * - * @returns {URL | string} - The input argument from the command line; - * either a `URL` object or a batch file path. + * @param {Object} params - The parameters for the function. + * @param {Object} 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. * - * @throws {Error} If no batch file is specified. + * @returns {Object} The resolved download options. + * + * @throws {TypeError} If `downloadOptions` or `downloadOptions.converterOptions` + * (also can be `audioConverterOptions`) are not objects. * * @private - * @since 0.2.0 + * @since 1.0.0 */ -function getInput() { - const args = process.argv.slice(2); // Get all arguments except the first two +function resolveConfig({ config, file }) { + if (!config || (Array.isArray(config) || typeof config !== 'object')) return {}; - 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]; - } + let { downloadOptions } = config; + let audioConverterOptions, acOptionsFrom; + if (downloadOptions.converterOptions) { + acOptionsFrom = 'downloadOptions.converterOptions'; + audioConverterOptions = downloadOptions.converterOptions; + } else { + acOptionsFrom = 'audioConverterOptions'; + audioConverterOptions = config.audioConverterOptions; // May be undefined } - // 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'); - process.exit(1); + // Checker + if (downloadOptions + && (Array.isArray(downloadOptions) || typeof downloadOptions !== 'object')) { + throw new TypeError( + `Expected an object for \`downloadOptions\` at file: ${file}`); + } else if (audioConverterOptions + && (Array.isArray(audioConverterOptions) + || typeof audioConverterOptions !== 'object')) { + throw new TypeError( + `Expected an object for \`${acOptionsFrom}\` at file: ${file}`); } - return DEFAULT_BATCH_FILE; -} + // 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 = !audioConverterOptions + ? undefined : resolveACOptions(audioConverterOptions, false); + + // Assign the `audioConverterOptions` to `downloadOptions` + if (!audioConverterOptions) { + Object.assign(downloadOptions, { + converterOptions: audioConverterOptions + }); + } + return downloadOptions; +} /** * Initializes the argument parser for command-line options. @@ -135,6 +168,13 @@ function initParser() { 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)', From 468be059a27f42dace7b2f88868329ae5d6e3aea Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sun, 7 Jul 2024 20:48:42 +0700 Subject: [PATCH 09/18] feat: Add the main function as driver function All code within block `if (require,main === module)` has been reallocated to main function and refactored. --- index.js | 95 ++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 78 insertions(+), 17 deletions(-) diff --git a/index.js b/index.js index 5b116ba..f29996b 100644 --- a/index.js +++ b/index.js @@ -309,6 +309,83 @@ function filterOptions({ options }) { }); } + +/** + * Main function. + * @private + * @since 1.0.0 + */ +async function main() { + const { + url, + batchFile, + version, + copyright, + downloadOptions + } = 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; + } + + let downloadSucceed = false; + try { + if (!url && !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 && batchFile) { + log.info('\x1b[95mMode: \x1b[97mBatch Download\x1b[0m'); + downloadSucceed = !!await ytmp3.batchDownload(batchFile, downloadOptions); + } else if (url && !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 + } else { + log.info('\x1b[95mMode: \x1b[97mSingle Download\x1b[0m'); + downloadSucceed = !!await ytmp3.singleDownload(url[0], downloadOptions); + } + } + } catch (dlErr) { + log.error(dlErr.message); + process.exit(1); + } + + if (downloadSucceed) { + log.info(`Downloaded files saved at \x1b[93m${downloadOptions.outDir}\x1b[0m`); + } +} + + module.exports = Object.freeze({ // :: ytmp3 (Core) name: ytmp3.name, @@ -323,22 +400,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(); } From 84be5b0cdfe01143a1c2a78021ce8a8511c17c23 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Wed, 10 Jul 2024 13:43:45 +0700 Subject: [PATCH 10/18] feat(utils): Add two helper functions to check type * Added `isNullOrUndefined` function to check whether the given value is null or undefined * Added `isObject` function to check whether the given value is an object and not null, an array, a `RegExp` instance, neither a URL instance --- lib/utils.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/lib/utils.js b/lib/utils.js index 8fd1002..03be13e 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. * From 22ce766e1e2b8195c1fb4f943b1de42bb6b1c982 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Wed, 10 Jul 2024 19:42:42 +0700 Subject: [PATCH 11/18] feat(utils): Export the two helper functions I'd forgot to export them on 84be5b0 --- lib/utils.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/utils.js b/lib/utils.js index 03be13e..9f0b5c2 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -371,6 +371,8 @@ class ProgressBar { module.exports = Object.freeze({ ROOTDIR, OUTDIR, LOGDIR, logger, + isNullOrUndefined, + isObject, ProgressBar, createDirIfNotExist }); From d71c00d00120ca92c90d8351f63da3536b467267 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sun, 14 Jul 2024 21:00:29 +0700 Subject: [PATCH 12/18] feat(config): Add `config` module * Introduced a new module named `config.js` offering the configuration resolver and parser * Added `parseConfig` function to import and parse the configuration file with support both JSON and JS file * Added `configChecker` function to check and validate the configuration object and throw an error if specific condition is met * Added an alias named `importConfig` that aliased from `parseConfig` function with `resolve` argument set to true * Moved the `resolveConfig` from main module to `config` module and refactored it to supports current module environments --- index.js | 63 ---------- lib/config.js | 343 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 343 insertions(+), 63 deletions(-) create mode 100644 lib/config.js diff --git a/index.js b/index.js index f29996b..e33d49e 100644 --- a/index.js +++ b/index.js @@ -58,69 +58,6 @@ const __version__ = (() => { const __copyright__ = `${pkg.name} - Copyright (c) 2023-${ new Date().getFullYear()} ${author.name} (${author.website})\n`; - -/** - * Resolves the configuration for YTMP3-JS from a given config object and file. - * - * 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 {Object} 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 {Object} The resolved download options. - * - * @throws {TypeError} If `downloadOptions` or `downloadOptions.converterOptions` - * (also can be `audioConverterOptions`) are not objects. - * - * @private - * @since 1.0.0 - */ -function resolveConfig({ config, file }) { - if (!config || (Array.isArray(config) || typeof config !== 'object')) return {}; - - let { downloadOptions } = config; - let audioConverterOptions, acOptionsFrom; - if (downloadOptions.converterOptions) { - acOptionsFrom = 'downloadOptions.converterOptions'; - audioConverterOptions = downloadOptions.converterOptions; - } else { - acOptionsFrom = 'audioConverterOptions'; - audioConverterOptions = config.audioConverterOptions; // May be undefined - } - - // Checker - if (downloadOptions - && (Array.isArray(downloadOptions) || typeof downloadOptions !== 'object')) { - throw new TypeError( - `Expected an object for \`downloadOptions\` at file: ${file}`); - } else if (audioConverterOptions - && (Array.isArray(audioConverterOptions) - || typeof audioConverterOptions !== 'object')) { - throw new TypeError( - `Expected an object for \`${acOptionsFrom}\` at file: ${file}`); - } - - // 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 = !audioConverterOptions - ? undefined : resolveACOptions(audioConverterOptions, false); - - // Assign the `audioConverterOptions` to `downloadOptions` - if (!audioConverterOptions) { - Object.assign(downloadOptions, { - converterOptions: audioConverterOptions - }); - } - return downloadOptions; -} - /** * Initializes the argument parser for command-line options. * diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..7209ee8 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,343 @@ +/** + * @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 fs = require('node:fs'); +const path = require('node:path'); +const util = require('node:util'); +const ytmp3 = require('./ytmp3'); +const { + isNullOrUndefined, + isObject, + logger: log +} = 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, acOptionsFrom; + if ('converterOptions' in downloadOptions) { + acOptionsFrom = 'downloadOptions.converterOptions'; + audioConverterOptions = downloadOptions.converterOptions || {}; + } else if ('audioConverterOptions' in config) { + acOptionsFrom = 'audioConverterOptions'; + 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 +}); From befd8fb1fb91bffb98e97992ec539589cd34dbab Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sun, 14 Jul 2024 21:38:58 +0700 Subject: [PATCH 13/18] docs: Change typedefs visibility to global Changed the visibility of two typedefs (`DownloadOptions` and `ConvertAudioOptions`) to global. --- lib/audioconv.js | 1 + lib/ytmp3.js | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/audioconv.js b/lib/audioconv.js index d011ed1..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} */ diff --git a/lib/ytmp3.js b/lib/ytmp3.js index c88d1d5..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 */ From 221864c83538b9a19c2efa2835c1a8a0bfd06713 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Wed, 17 Jul 2024 17:24:09 +0700 Subject: [PATCH 14/18] feat(utils): Add `dropNullAndUndefined` function This helper function easily removes `null` and `undefined` properties from an object, useful for removing unnecessary properties from an object. --- lib/utils.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/utils.js b/lib/utils.js index 9f0b5c2..e476d01 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -368,11 +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 }); From d87d320c9f15ad46153483626d95be583abb5309 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Wed, 17 Jul 2024 17:44:35 +0700 Subject: [PATCH 15/18] feat(cli): Add config resolver for `--config` option The option was implemented on change `714235c` but it was not handled properly. So, this change we added to resolve and parse the configuration file along with the documentation, including defined a typedef `FilteredOptions` and removed the `YTMP3_Config` typedef (which is now are defined in `lib/config` module). --- index.js | 55 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/index.js b/index.js index e33d49e..a8ffdbb 100644 --- a/index.js +++ b/index.js @@ -11,15 +11,18 @@ */ /** - * @typedef {Object} YTMP3_Config - * @property {module:ytmp3~DownloadOptions} downloadOptions - * Options related to the download process. - * @property {module:audioconv~AudioConverterOptions} [audioConverterOptions] - * Options related to the audio conversion process, if not defined in - * `downloadOptions`. + * A type definition for the filtered options object, which is returned from {@link filterOptions} function. * - * @global - * @since 1.0.0 + * @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'; @@ -34,9 +37,9 @@ const { 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 config = require('./config/ytmp3-js.config'); const pkg = require('./package.json'); const DEFAULT_BATCH_FILE = path.join(__dirname, 'downloads.txt'); @@ -192,7 +195,7 @@ function initParser() { * @param {Object} params.options - The options object containing various configuration * settings from command-line argument parser. * - * @returns {Object} A frozen object with the filtered and processed options. + * @returns {FilteredOptions} A frozen object with the filtered and processed options. * * @description * This function performs the following steps: @@ -219,7 +222,7 @@ function initParser() { * @private * @since 1.0.0 */ -function filterOptions({ options }) { +async function filterOptions({ options }) { if (!options || (Array.isArray(options) || typeof options !== 'object')) return {}; // Deep copy the options @@ -230,18 +233,34 @@ function filterOptions({ options }) { 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, downloadOptions: { - ...(ytmp3.resolveDlOptions({ downloadOptions: optionsCopy })), + ...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(optionsCopy, false)), - quiet: (quiet >= 2) ? true : false + ...resolveACOptions({ + ...dropNullAndUndefined(acOptionsFromConfig), + ...dropNullAndUndefined(optionsCopy) + }, false), + quiet: quiet >= 2 }, - quiet: (quiet >= 1) ? true : false + quiet: quiet >= 1 } }); } @@ -258,8 +277,8 @@ async function main() { batchFile, version, copyright, - downloadOptions - } = filterOptions({ + downloadOptions, + } = await filterOptions({ options: initParser().parse_args() }); From f72d616ea4306ce1f813a41522f2e46efcdb36f1 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Wed, 17 Jul 2024 17:52:55 +0700 Subject: [PATCH 16/18] feat(cli): Add `--print-config` option This option to show the configuration that being used during download and audio conversion process. Useful for debugging. --- index.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/index.js b/index.js index a8ffdbb..b8735b6 100644 --- a/index.js +++ b/index.js @@ -184,6 +184,11 @@ function initParser() { 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; } @@ -246,6 +251,7 @@ async function filterOptions({ options }) { 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 @@ -278,6 +284,7 @@ async function main() { version, copyright, downloadOptions, + printConfig } = await filterOptions({ options: initParser().parse_args() }); @@ -303,6 +310,11 @@ async function main() { process.stdout.write(__copyright__); return; } + // Print configuration + if (printConfig) { + console.log(downloadOptions); + return; + } let downloadSucceed = false; try { From 048fe04e0080b4ab58948f1f73cbe9423f71adc8 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Wed, 17 Jul 2024 17:55:12 +0700 Subject: [PATCH 17/18] fix(main): Fix logic on if statements * Fixed the logic on if statements due to unresolved null `url` * Added a warning message when user provide multiple URLs in a single command (`ytmp3 `). Because we still developing this feature of multiple downloads using URLs * Display the stack error if an error occurred on main function, this improving readability and make it easier to troubleshoot the problem * Minor refinement to the top-level module documentation --- index.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index b8735b6..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) @@ -318,7 +319,7 @@ async function main() { let downloadSucceed = false; try { - if (!url && !batchFile) { + 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`); @@ -330,14 +331,16 @@ async function main() { } log.info('\x1b[95mMode: \x1b[97mBatch Download\x1b[0m'); downloadSucceed = !!await ytmp3.batchDownload(DEFAULT_BATCH_FILE, downloadOptions); - } else if (!url && batchFile) { + } 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 && !batchFile) { + } 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); @@ -345,6 +348,7 @@ async function main() { } } catch (dlErr) { log.error(dlErr.message); + console.error(dlErr.stack); process.exit(1); } From 3421a33da542a4d0513fd860032e41c8bf5bff66 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Wed, 17 Jul 2024 22:47:45 +0700 Subject: [PATCH 18/18] fix(lint): Address several linting errors My bad, forgot to run lint first before pushing branch --- lib/config.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/config.js b/lib/config.js index 7209ee8..b005f00 100644 --- a/lib/config.js +++ b/lib/config.js @@ -82,14 +82,12 @@ 'use strict'; -const fs = require('node:fs'); const path = require('node:path'); const util = require('node:util'); const ytmp3 = require('./ytmp3'); const { isNullOrUndefined, isObject, - logger: log } = require('./utils'); const { resolveOptions: resolveACOptions @@ -161,12 +159,10 @@ function resolveConfig({ 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, acOptionsFrom; + let audioConverterOptions; if ('converterOptions' in downloadOptions) { - acOptionsFrom = 'downloadOptions.converterOptions'; audioConverterOptions = downloadOptions.converterOptions || {}; } else if ('audioConverterOptions' in config) { - acOptionsFrom = 'audioConverterOptions'; audioConverterOptions = config.audioConverterOptions || {}; }