From 383fdd73f8a345f9f339a682a767959d579be7e5 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sat, 31 Aug 2024 00:21:32 +0700 Subject: [PATCH 01/21] chore(npm): Add `lsfnd` dependency --- package-lock.json | 26 +++++++++++++++----------- package.json | 5 +++-- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1198f7c..14ab2d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@distube/ytdl-core": "^4.13.5", "@mitsuki31/temppath": "^0.5.0", "argparse": "^2.0.1", - "fluent-ffmpeg": "^2.1.3" + "fluent-ffmpeg": "^2.1.3", + "lsfnd": "^1.1.0" }, "bin": { "ytmp3": "index.js" @@ -27,7 +28,7 @@ "nyc": "^17.0.0" }, "engines": { - "node": ">=16" + "node": ">=18" }, "funding": { "type": "individual", @@ -1472,15 +1473,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/@eslint/js": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", - "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", - "dev": true, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", @@ -2403,6 +2395,18 @@ "yallist": "^3.0.2" } }, + "node_modules/lsfnd": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/lsfnd/-/lsfnd-1.1.0.tgz", + "integrity": "sha512-InlYRAJNI4meEoccSGfEnOFQYCmjO04AAg2HNCCauDX4xvNBE8j9bjhEgP0I+mn29d3CaqmCQct3XjPg+4iscw==", + "engines": { + "node": ">=16.20.x" + }, + "funding": { + "type": "individual", + "url": "https://ko-fi.com/dhefam31" + } + }, "node_modules/m3u8stream": { "version": "0.8.6", "resolved": "https://registry.npmjs.org/m3u8stream/-/m3u8stream-0.8.6.tgz", diff --git a/package.json b/package.json index 675d49d..b4a72a2 100644 --- a/package.json +++ b/package.json @@ -45,13 +45,14 @@ "url": "https://ko-fi.com/dhefam31" }, "engines": { - "node": ">=16" + "node": ">=18" }, "dependencies": { "@distube/ytdl-core": "^4.13.5", "@mitsuki31/temppath": "^0.5.0", "argparse": "^2.0.1", - "fluent-ffmpeg": "^2.1.3" + "fluent-ffmpeg": "^2.1.3", + "lsfnd": "^1.1.0" }, "devDependencies": { "@eslint/js": "^9.8.0", From 2d21e53fbc901f2909041327b30ce1fe2e649327 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sat, 31 Aug 2024 00:26:09 +0700 Subject: [PATCH 02/21] refactor(utils): Enhance object type checks and new constant - Introduced `YTMP3_HOMEDIR` constant to standardize the home directory path across the utility functions. - Refactored `LOGDIR` to utilize the new `YTMP3_HOMEDIR` constant for better maintainability. - Improved the `isObject` function to strictly check for non-null objects while ensuring compatibility with objects created from null prototypes. - Added a new `isPlainObject` function to specifically identify plain objects (`{}`) with an `Object` prototype. - Implemented a `getType` utility function to return a detailed string representation of the type for given values. - Updated module exports to include the new utilities (`YTMP3_HOMEDIR`, `isPlainObject`, and `getType`). --- lib/utils.js | 81 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 17 deletions(-) diff --git a/lib/utils.js b/lib/utils.js index ab55baa..180eda4 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -2,7 +2,7 @@ * Utilities module for **YTMP3** project. * * @module utils - * @author Ryuu Mitsuki (https://github.com/mitsuki31) + * @author Ryuu Mitsuki * @license MIT * @since 1.0.0 */ @@ -79,6 +79,7 @@ const ROOTDIR = path.join(__dirname, '..'); * @since 1.0.0 */ const OUTDIR = path.join(ROOTDIR, 'download'); +const YTMP3_HOMEDIR = path.join(os.homedir(), '.ytmp3-js'); /** * The log directory for error logs. * @@ -88,8 +89,7 @@ const OUTDIR = path.join(ROOTDIR, 'download'); * * @since 1.0.0 */ -const LOGDIR = path.join(os.homedir(), '.ytmp3-js', 'logs'); - +const LOGDIR = path.join(YTMP3_HOMEDIR, 'logs'); // region Utilities Function @@ -134,6 +134,9 @@ function createLogFile(prefix) { (new Date()).toISOString().split('.')[0].replace(/:/g, '.')}.log`; } + +// region Type Checker + /** * Checks if a given value is null or undefined. * @@ -148,27 +151,69 @@ function isNullOrUndefined(x) { } /** - * Checks if a given value is an object type. + * Determines whether the provided value is a non-null object. * - * This function will strictly checks whether the given value is an object, not `null`, - * `Array`, a `RegExp` instance, neither a `URL` instance. + * This function returns `true` for any value that is of the object type and is not `null`, + * but it does not guarantee that the object is a plain object (`{}`). * - * @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. + * @param {any} x - The value to be checked. + * @returns {boolean} `true` if the value is a non-null object, otherwise `false`. * - * @public - * @since 1.0.0 + * @package + * @since 1.0.0 + * @see {@link module:utils~isPlainObject isPlainObject} */ function isObject(x) { - return (!isNullOrUndefined(x) - && !Array.isArray(x) - && !(x instanceof RegExp) - && !(x instanceof URL) - && typeof x === 'object' + return ( + !isNullOrUndefined(x) && + typeof x === 'object' && + !Array.isArray(x) && + Object.prototype.toString && + /^\[object .+\]$/.test(Object.prototype.toString.call(x)) + ); +} + +/** + * Determines whether the provided value is a plain object (`{}`). + * + * This function returns `true` only if the value is a non-null object with + * a prototype of `Object`. + * + * @param {any} x - The value to be checked. + * @returns {boolean} `true` if the value is a plain object, otherwise `false`. + * + * @package + * @since 1.1.0 + * @see {@link module:utils~isObject isObject} + */ +function isPlainObject(x) { + return ( + !isNullOrUndefined(x) && + typeof x === 'object' && + !Array.isArray(x) && + Object.prototype.toString && + /^\[object Object\]$/.test(Object.prototype.toString.call(x)) ); } +/** + * Returns the type of the provided value as a string. + * + * For `null` values, it returns `'null'`, and for objects, it returns a more detailed + * type such as `'[object Object]'`. + * + * @param {any} x - The value whose type is to be determined. + * @returns {string} A string representing the type of the value. + * + * @package + * @since 1.1.0 + */ +function getType(x) { + return x === null + ? 'null' : typeof x === 'object' + ? Object.prototype.toString.call(x) : typeof x; +} + /** * **Logger Namespace** * @namespace module:utils~Logger @@ -431,14 +476,16 @@ class ProgressBar { module.exports = Object.freeze({ - ROOTDIR, OUTDIR, LOGDIR, + ROOTDIR, OUTDIR, LOGDIR, YTMP3_HOMEDIR, logger, log: logger, // alias for `logger` isNullOrUndefined, isObject, + isPlainObject, createDirIfNotExist, createDirIfNotExistSync, createLogFile, dropNullAndUndefined, + getType, ProgressBar }); From f09865894c9cb81a073bdc03af83fa755f56a00b Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sat, 31 Aug 2024 00:40:34 +0700 Subject: [PATCH 03/21] feat(errors): New custom error classes addition - Added `InvalidTypeError` class to handle cases where values do not match expected types. Includes additional properties for `actualType`, `expectedType`, and `cause`, which provide more context for type-related errors. - Added `GlobalConfigParserError` class to handle errors related to invalid or unprocessable configuration files. It includes properties such as `errno`, `code`, `syscall`, and `path` to offer detailed information about the underlying error. - Both new error classes extend the base `Error` class and utilize the `isPlainObject` utility function to validate options. - Updated module exports to include the new error classes alongside the existing ones. --- lib/error.js | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/lib/error.js b/lib/error.js index 9ac2ad8..70ccd8e 100644 --- a/lib/error.js +++ b/lib/error.js @@ -1,13 +1,15 @@ /** * A module contains all custom error classes for **YTMP3-JS** project. * - * @author Ryuu Mitsuki (https://github.com/mitsuki31) + * @author Ryuu Mitsuki * @license MIT * @since 1.1.0 */ 'use strict'; +const { isPlainObject } = require('./utils'); + /** * @classdesc Represents an error that occurred during video ID extraction. * @@ -27,7 +29,80 @@ class IDExtractorError extends Error {} */ class UnknownOptionError extends Error {} +/** + * @classdesc This error is typically thrown when a value does not match the expected type. + * + * @extends Error + * @param {string | Error} error - The error message or an instance of an `Error`. + * @param {Object} [options] - Additional options for the error. + * @param {string} [options.actualType] - The actual type of the value that caused the error. + * @param {string} [options.expectedType] - The expected type of the value. + * @param {Error} [options.cause] - The underlying error that caused this error, if applicable. + * @global + * @since 1.1.0 + */ +class InvalidTypeError extends Error { + constructor(error, options) { + super(error, options); + if (error instanceof Error) { + this.message = error.message; + } else { + this.message = error; + } + if (isPlainObject(options)) { + this.actualType = options.actualType; + this.expectedType = options.expectedType; + this.cause = options.cause; + + if (typeof this.actualType !== 'string') delete this.actualType; + if (typeof this.expectedType !== 'string') delete this.expectedType; + if (!(this.cause instanceof Error)) delete this.cause; + } + } +} + +/** + * @classdesc This error is typically thrown when a configuration file is invalid + * or cannot be processed correctly. + * + * @extends Error + * @param {string | Error} error - The error message or an instance of an Error. + * @param {Object} [options] - Additional options for the error. + * @param {Error} [options.cause] - The underlying error that caused this error, if applicable. + * @param {number} [options.cause.errno] - The error number from the underlying error. + * @param {string} [options.cause.code] - The error code from the underlying error. + * @param {string} [options.cause.syscall] - The system call that caused the underlying error. + * @param {string} [options.cause.path] - The file path involved in the underlying error. + * @global + * @since 1.1.0 + */ +class GlobalConfigParserError extends Error { + constructor(error, options) { + super(error instanceof Error ? error.message : error, options); + if (error instanceof Error) { + this.message = error.message; + this.stack = error.stack; + } else { + this.message = error; + } + if (isPlainObject(options)) { + if (options.cause instanceof Error) { + this.errno = options.cause.errno; + this.code = options.cause.code; + this.syscall = options.cause.syscall; + this.path = options.cause.path; + if (!this.errno) delete this.errno; + if (!this.code) delete this.code; + if (!this.syscall) delete this.syscall; + if (!this.path) delete this.path; + } + } + } +} + module.exports = Object.freeze({ IDExtractorError, - UnknownOptionError + UnknownOptionError, + InvalidTypeError, + GlobalConfigParserError }); From a600b135a604f7d447a807ced8b2f0fba46ae652 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sat, 31 Aug 2024 01:21:42 +0700 Subject: [PATCH 04/21] refactor(config): Update config file extensions and handling - Updated `KNOWN_CONFIG_EXTS` to include new `.config` variants for configuration files, marking older extensions as deprecated. These deprecated extensions (`.js`, `.mjs`, `.cjs`) will be removed in the next major update. - Modified `parseConfig` function to handle the new configuration file extensions and adjusted logic for resolving and importing configuration files. Improved error message formatting to list supported extensions more clearly. - Updated JSDoc comments to reflect changes in configuration extensions. Deprecation Note: The following extensions are now deprecated and will be removed in the next major version: `.js`, `.mjs`, `.cjs`. Please transition to using `.config.js`, `.config.mjs`, or `.config.cjs` for future compatibility. --- lib/config.js | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/lib/config.js b/lib/config.js index 286bc28..4c469fc 100644 --- a/lib/config.js +++ b/lib/config.js @@ -59,7 +59,7 @@ * @requires ytmp3 * @requires audioconv * @requires utils - * @author Ryuu Mitsuki (https://github.com/mitsuki31) + * @author Ryuu Mitsuki * @license MIT * @since 1.0.0 */ @@ -97,13 +97,23 @@ const { UnknownOptionError } = require('./error'); /** * An array containing all known configuration file's extension names. * - * @type {Readonly>} + * **Deprecation Note**: + * For extensions such as, `.js`, `.cjs` and `.mjs`, needs to be concatenated with `.config` + * to prevent config file lookup confusion in the future. At this time, we won't deleted those + * extension names until next major update, but please consider to change your configuration file's + * extension for future compatibility. + * + * @type {Readonly>} * @readonly * @default * @package * @since 1.0.0 */ -const KNOWN_CONFIG_EXTS = [ '.js', '.mjs', '.cjs', '.json' ]; +const KNOWN_CONFIG_EXTS = Object.freeze([ + '.js', '.cjs', '.mjs', // ! DEPRECATED: Will be removed on next major update + '.config.js', '.config.mjs', '.config.cjs', + '.json' +]); /** * An array containing all known configuration options. * @@ -113,7 +123,7 @@ const KNOWN_CONFIG_EXTS = [ '.js', '.mjs', '.cjs', '.json' ]; * @package * @since 1.0.0 */ -const KNOWN_OPTIONS = [ 'downloadOptions', 'audioConverterOptions' ]; +const KNOWN_OPTIONS = Object.freeze([ 'downloadOptions', 'audioConverterOptions' ]); /** * A string representating the format of error message. * Can be formatted using `util.format()` function. @@ -286,19 +296,19 @@ function parseConfig(configFile, resolve=true, forceRequire=false) { } const file = path.resolve(configFile); // Copy and resolve path - const ext = path.extname(configFile); // Extract the extension name + let ext = path.extname(configFile); // Extract the extension name + ext = path.extname(configFile.replace(new RegExp(`${ext}$`), '')) + ext; if (!(KNOWN_CONFIG_EXTS.includes(ext))) { throw new Error(`Supported configuration file is: ${ - KNOWN_CONFIG_EXTS.map(x => `'${x}'`).toString().replace(/,/g, ' | ') + KNOWN_CONFIG_EXTS.map(x => `'${x}'`).join(' | ') }`); } // Import the configuration file let config = null; // Only include '.cjs' and '.json' to use require() - if (KNOWN_CONFIG_EXTS.slice(2).includes(path.extname(configFile)) - || forceRequire) { + if (['.config.cjs', '.json'].includes(ext) || forceRequire) { config = require(configFile); } else { // On Windows, replace all '\' with '/' to use import() @@ -310,8 +320,10 @@ function parseConfig(configFile, resolve=true, forceRequire=false) { 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 new Promise(function (resolve, reject) { + config + .then((result) => resolve(resolveOrCheckOnly(result))) + .catch((err) => reject(err)); }); } From ffa8e4580a9f4231c28b92e2af44d1f8eb66f922 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sat, 31 Aug 2024 01:34:50 +0700 Subject: [PATCH 05/21] docs(config): JSDoc addition and improvement - Added a JSDoc for `YTMP3_HOMEDIR` constant - Improve the JSDoc of other constants --- lib/utils.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/lib/utils.js b/lib/utils.js index 180eda4..5c6dabd 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -71,15 +71,34 @@ const FrozenProperty = { /** * The root directory of the project. + * @type {string} + * @constant + * @package * @since 1.0.0 */ const ROOTDIR = path.join(__dirname, '..'); /** * The output directory for the downloaded audio files. + * @type {string} + * @constant + * @package * @since 1.0.0 */ const OUTDIR = path.join(ROOTDIR, 'download'); +/** + * The home directory path for the YTMP3-JS configuration and data files. + * + * This path is constructed by joining the user's home directory with the `'.ytmp3-js'` folder. + * On POSIX systems, this will typically be `"/home//.ytmp3-js"`, while on Windows systems, + * it will be `"C:\Users\\.ytmp3-js"`. For Termux Android, it will be `"/data/data/com.termux/files/home/.ytmp3-js"`. + * + * @type {string} + * @constant + * @package + * @since 1.1.0 + */ const YTMP3_HOMEDIR = path.join(os.homedir(), '.ytmp3-js'); + /** * The log directory for error logs. * @@ -87,6 +106,9 @@ const YTMP3_HOMEDIR = path.join(os.homedir(), '.ytmp3-js'); * - **POSIX**: `$HOME/.ytmp3-js/logs` * - **Windows**: `%USERPROFILE%\.ytmp3-js\logs` * + * @type {string} + * @constant + * @package * @since 1.0.0 */ const LOGDIR = path.join(YTMP3_HOMEDIR, 'logs'); From 8c54c1778f6b83ec9d13e1af6d396569d39b6fbc Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sat, 31 Aug 2024 01:41:27 +0700 Subject: [PATCH 06/21] refactor(config): Enhance error handling and improve JSDoc - Replaced `TypeError` with `InvalidTypeError` in `configChecker` and `parseConfig` functions for better error specificity when invalid types are encountered. - Enhanced `configChecker` to include `actualType` and `expectedType` in error details when an invalid configuration object is detected. - Exported `KNOWN_OPTIONS`, `KNOWN_CONFIG_EXTS`, and `ERR_FORMAT` constants for improved modularity and testing purposes. - Updated JSDoc to reflect the change from `TypeError` to `InvalidTypeError`. - Improved JSDoc examples for configuration files by specifying the expected filenames (e.g., `ytmp3-js.config.cjs`). --- lib/config.js | 49 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/lib/config.js b/lib/config.js index 4c469fc..b6e5c83 100644 --- a/lib/config.js +++ b/lib/config.js @@ -9,13 +9,12 @@ * 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 + * file will be validated first and will throws a {@link InvalidTypeError} if any known + * configuration options has an invalid type, or throws a {@link 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 + * @example JSON Configuration File (ytmp3-js.json) * { * "downloadOptions": { * "outDir": "/path/to/download/folder", @@ -30,7 +29,7 @@ * } * } * - * @example CommonJS Module Configuration File + * @example CommonJS Module Configuration File (ytmp3-js.config.cjs) * module.exports = { * downloadOptions: { * outDir: '..', @@ -39,7 +38,7 @@ * } * } * - * @example ES Module Configuration File + * @example ES Module Configuration File (ytmp3-js.config.mjs) * export default { * downloadOptions: { * cwd: process.env.HOME, @@ -84,15 +83,21 @@ const path = require('node:path'); const util = require('node:util'); + const ytmp3 = require('./ytmp3'); const { isNullOrUndefined, isObject, + isPlainObject, + getType } = require('./utils'); const { resolveOptions: resolveACOptions } = require('./audioconv'); -const { UnknownOptionError } = require('./error'); +const { + UnknownOptionError, + InvalidTypeError, +} = require('./error'); /** * An array containing all known configuration file's extension names. @@ -156,7 +161,7 @@ const ERR_FORMAT = '%s\n\tat \x1b[90m%s\n\x1b[1;91m%s\x1b[0m\n'; * @returns {DownloadOptions} The resolved download options and audio converter * options if provided. * - * @throws {TypeError} If any known options is not object type. + * @throws {InvalidTypeError} If any known options is not object type. * * @package * @since 1.0.0 @@ -205,7 +210,7 @@ function resolveConfig({ config, file }) { * @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 {InvalidTypeError} If any known options is not object type. * @throws {UnknownOptionError} If there are unknown fields in the configuration. * * @package @@ -213,7 +218,10 @@ function resolveConfig({ config, file }) { */ function configChecker({ config, file }) { if (!config || typeof config !== 'object') { - throw new TypeError('Invalid type of configuration: ' + typeof config); + throw new InvalidTypeError('Invalid type of configuration object', { + actualType: getType(config), + expectedType: getType({}) + }); } file = (file && !path.isAbsolute(file)) ? path.resolve(file) : file; @@ -231,10 +239,13 @@ function configChecker({ config, file }) { // Check for known options have a valid type (object) if (isNullOrUndefined(config[field]) || !isObject(config[field])) { - throw new TypeError(util.format(ERR_FORMAT, + throw new InvalidTypeError(util.format(ERR_FORMAT, `Expected type of field '${field}' is an object`, dirFile, baseFile - )); + ), { + actualType: getType(field), + expectedType: getType({}) + }); } }); } @@ -255,8 +266,8 @@ function configChecker({ config, file }) { * 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. + * @throws {InvalidTypeError} 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'); @@ -292,7 +303,10 @@ function parseConfig(configFile, resolve=true, forceRequire=false) { } if (!configFile || typeof configFile !== 'string') { - throw new TypeError('Expected a string path refers to a configuration file'); + throw new InvalidTypeError('Expected a string path refers to a configuration file', { + actualType: getType(configFile), + expectedType: 'string' + }); } const file = path.resolve(configFile); // Copy and resolve path @@ -346,6 +360,9 @@ const importConfig = (file, forceRequire=false) => parseConfig(file, true, force module.exports = Object.freeze({ + KNOWN_OPTIONS, + KNOWN_CONFIG_EXTS, + ERR_FORMAT, configChecker, resolveConfig, parseConfig, From 2cff700a49146e17463ed78db38280ec97cddfcd Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sat, 31 Aug 2024 01:45:06 +0700 Subject: [PATCH 07/21] feat(config): Implement global config file parser with prioritized search - Introduced `findGlobalConfig()` to locate the most appropriate global configuration file in the user's home directory. - Added `parseGlobalConfig()` to validate and parse the global configuration file, with optional parser settings. - Integrated the `lsfnd` npm package to assist in locating configuration files. - Updated JSDoc comments to document the new functionality. --- lib/config.js | 155 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 154 insertions(+), 1 deletion(-) diff --git a/lib/config.js b/lib/config.js index b6e5c83..b3fc1ab 100644 --- a/lib/config.js +++ b/lib/config.js @@ -58,6 +58,7 @@ * @requires ytmp3 * @requires audioconv * @requires utils + * @requires {@link https://npmjs.com/package/lsfnd npm:lsfnd} * @author Ryuu Mitsuki * @license MIT * @since 1.0.0 @@ -81,11 +82,14 @@ 'use strict'; +const fs = require('node:fs'); const path = require('node:path'); const util = require('node:util'); +const { ls, lsTypes } = require('lsfnd'); const ytmp3 = require('./ytmp3'); const { + YTMP3_HOMEDIR, isNullOrUndefined, isObject, isPlainObject, @@ -97,6 +101,7 @@ const { const { UnknownOptionError, InvalidTypeError, + GlobalConfigParserError } = require('./error'); /** @@ -359,6 +364,152 @@ function parseConfig(configFile, resolve=true, forceRequire=false) { const importConfig = (file, forceRequire=false) => parseConfig(file, true, forceRequire); +// region Global Config Parser + +/** + * Asynchronously finds and returns the path to the most appropriate global configuration + * file for the `ytmp3-js` module. + * + * The function searches for configuration files in the user's home directory + * (specifically, the `~/.ytmp3-js` directory -- for more details, see {@link module:utils~YTMP3_HOMEDIR `YTMP3_HOMEDIR`}) + * and applies a series of prioritization and validation steps to ensure that the returned + * file is valid and non-empty. + * + * The function first retrieves a list of configuration files in the specified directory that + * match a set of known file extensions ({@link module:config~KNOWN_CONFIG_EXTS `KNOWN_CONFIG_EXTS`}). + * If exactly one file is found, its basename is returned immediately. If multiple configuration + * files are present, the function prioritizes specific configuration file names in the following order: + * 1. `ytmp3-js.config.cjs` + * 2. `ytmp3-js.config.mjs` + * 3. `ytmp3-js.config.js` + * 4. `ytmp3-js.json` + * + * If the prioritized file is empty, the function will iterate through other available files + * until it finds a non-empty file or exhausts the list. + * + * @returns {Promise} + * A promise fullfills with a string representing the absolute path of the selected + * configuration file, or `null` if no global configuration file is found. + * + * @async + * @package + * @since 1.1.0 + * @see {@linkcode https://npmjs.com/package/lsfnd npm:lsfnd} + */ +async function findGlobalConfig() { + const configDirname = YTMP3_HOMEDIR; + const knownConfigExtsRegex = new RegExp(`${KNOWN_CONFIG_EXTS.join('$|')}$`); + const configs = await ls(configDirname, { + encoding: 'utf8', + match: knownConfigExtsRegex, + recursive: false, + absolute: false, + basename: true + }, lsTypes.LS_F); + + // If cannot found any global configuration file, return null instead + // to indicate that user have not configure the global configuration + if (!configs || (Array.isArray(configs) && !configs.length)) return null; + + // Return the first index if only found one configuration file + if (configs.length === 1) return path.join(configDirname, configs[0]); + + const prioritizedConfigs = [ + 'ytmp3-js.config.cjs', // #1 + 'ytmp3-js.config.mjs', // #2 + 'ytmp3-js.config.js', // #3 + 'ytmp3-js.json' // #4 + ]; + + let retries = 0; + let configIndex = configs.indexOf(prioritizedConfigs[retries++]); + while (!(~configIndex)) { + if (retries === prioritizedConfigs.length - 1) break; + configIndex = configs.indexOf(prioritizedConfigs[retries++]); + } + + // Get the absolute path of the config file + let configRealPath = path.join(configDirname, configs[configIndex]); + let configStat = await fs.promises.stat(configRealPath); + + // If the config file is empty, try to find another config file + retries = 0; // reset + while (!configStat.size) { + if (retries === configs.length) break; + if (retries === configIndex) { + retries++; + continue; + } + configRealPath = path.join(configDirname, configs[retries++]); + configStat = await fs.promises.stat(configRealPath); + } + + return configRealPath; +} + +/** + * Parses the global configuration file at the specified path with optional parser options. + * + * This function validates the type of the configuration file path and parser options, checks + * if the file is readable, and imports the configuration file. The `forceRequire` option is + * enabled if the file extension is `.json` unless overridden by the provided options. + * + * @param {string} globConfigPath - The path to the global configuration file. Must be a valid string. + * @param {Object} [parserOptions] - Optional settings for parsing the configuration file. + * @param {boolean} [parserOptions.forceRequire] - If `true`, forces the use of `require()` for importing the file, + * even if it's an ES module. + * + * @returns {Promise} A promise fullfills with the parsed configuration data. + * + * @throws {InvalidTypeError} If `globConfigPath` is not a string or `parserOptions` is not a plain object. + * @throws {GlobalConfigParserError} If the configuration file cannot be accessed. + * + * @async + * @package + * @since 1.1.0 + */ +async function parseGlobalConfig(globConfigPath, parserOptions) { + if (isNullOrUndefined(globConfigPath) || typeof globConfigPath !== 'string') { + throw new InvalidTypeError('Unknown configuration file path', { + actualType: getType(globConfigPath), + expectedType: 'string' + }); + } + + parserOptions = isNullOrUndefined(parserOptions) ? {} : parserOptions; + if (!isPlainObject(parserOptions)) { + throw new InvalidTypeError('Invalid type of configuration parser options', { + actualType: getType(parserOptions), + expectedType: Object.prototype.toString.call({}) + }); + } + + parserOptions = { + forceRequire: typeof parserOptions.forceRequire === 'boolean' + ? parserOptions.forceRequire + : undefined // Default to `undefined`, which is automatically desired by function + }; + + // Enable `forceRequire` when importing a JSON configuration file + // to prevent undesired exception due to importing a JSON using `import()` + // which need to use `with { type: 'json' }` expression. + // * NOTE: This can be overridden by `parserOptons.forceRequire` + let forceRequire = path.extname(globConfigPath) === '.json'; + if (typeof parserOptions.forceRequire !== 'undefined') ({ forceRequire } = parserOptions); + + // Check if the configuration file is readable + try { + await fs.promises.access(globConfigPath, fs.constants.R_OK); + } catch (accessErr) { + throw new GlobalConfigParserError( + 'Unable to access the global configuration file', { cause: accessErr }); + } + + // Import the configuration file + return await parseConfig(globConfigPath, true, forceRequire); +} + + module.exports = Object.freeze({ KNOWN_OPTIONS, KNOWN_CONFIG_EXTS, @@ -366,5 +517,7 @@ module.exports = Object.freeze({ configChecker, resolveConfig, parseConfig, - importConfig + importConfig, + findGlobalConfig, + parseGlobalConfig }); From 0f70d2d3c8162cce4b24f1770b3d8471f054c4e4 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sat, 31 Aug 2024 22:27:08 +0700 Subject: [PATCH 08/21] refactor(index): Split main index for API and CLI usage - Separated the main index file to create a distinct entry point for API-related functionality. - Introduced a new module called `bin/cli.js` specifically designed to handle command-line interface (CLI) operations. - Moved command line arguments parser functionality to a module called `argparser` in "bin" directory. --- bin/argparser.js | 313 +++++++++++++++++++++++++++++++++ bin/cli.js | 177 +++++++++++++++++++ index.js | 446 +---------------------------------------------- 3 files changed, 496 insertions(+), 440 deletions(-) create mode 100644 bin/argparser.js create mode 100755 bin/cli.js diff --git a/bin/argparser.js b/bin/argparser.js new file mode 100644 index 0000000..732b3ea --- /dev/null +++ b/bin/argparser.js @@ -0,0 +1,313 @@ +/** + * @file Argument parser for YTMP3 command-line options. + * + * @module bin/argparser + * @requires audioconv + * @requires config + * @requires utils + * @requires ytmp3 + * @author Ryuu Mitsuki + * @since 1.1.0 + * @license MIT + */ + +'use strict'; + +/** + * A type definition for the filtered options object, which is returned from {@link filterOptions} function. + * + * @typedef {Object} FilteredOptions + * @property {string} urls - A list of URLs 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. + * + * @package + * @since 1.0.0 + */ + +const { ArgumentParser } = require('argparse'); + +const { resolveOptions: resolveACOptions } = require('../lib/audioconv'); +const { dropNullAndUndefined } = require('../lib/utils'); +const { importConfig } = require('../lib/config'); +const { resolveDlOptions } = require('../lib/ytmp3'); +const pkg = require('../package.json'); + + +// Windows: "C:\Users\...\AppData\Local\Temp\ytmp3-js" +// Linux: "/home/usr/tmp/ytmp3-js" +// Termux Android: "/data/data/com.termux/files/usr/tmp/ytmp3-js" +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.substring(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`; + + +/** + * Initializes the argument parser for command-line options. + * + * @returns {argparse.ArgumentParser} The `ArgumentParser` instance. + * + * @package + * @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 ==== // + // :: inputOptions + parser.add_argument( + '--inputOptions', + '--input-options', + '--addInputOptions', + '--add-input-options', + '--inOpt', + { + metavar: 'OPT', + help: 'Add custom input options for audio conversion', + type: 'str', + dest: 'inputOptions', + } + ); + // :: outputOptions + parser.add_argument( + '--outputOptions', + '--output-options', + '--addOption', + '--add-option', + '--addOutputOptions', + '--add-output-options', + '--outOpt', + { + metavar: 'OPT', + help: 'Add custom output options for audio conversion', + type: 'str', + dest: 'outputOptions', + } + ); + // :: 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. + * + * @returns {FilteredOptions} 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. + * + * @package + * @since 1.0.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 + let dlOptionsFromConfig = optionsCopy.config + ? importConfig(optionsCopy.config) + : {}; + // Await the download options if it is a promise + if (dlOptionsFromConfig instanceof Promise) { + dlOptionsFromConfig = await dlOptionsFromConfig; + } + const acOptionsFromConfig = dlOptionsFromConfig.converterOptions || {}; + delete optionsCopy.config; // No longer needed + delete dlOptionsFromConfig.converterOptions; // No longer needed + + return Object.freeze({ + urls: optionsCopy.URL, + batchFile: optionsCopy.file, + version: optionsCopy.version, + copyright: optionsCopy.copyright, + printConfig: optionsCopy.printConfig, + downloadOptions: { + ...resolveDlOptions({ downloadOptions: { + // Download options from config file can be overriden with download + // options from the command-line + ...dlOptionsFromConfig || {}, + ...(resolveDlOptions({ downloadOptions: optionsCopy })) + }}), + converterOptions: { + ...resolveACOptions({ + ...dropNullAndUndefined(acOptionsFromConfig), + ...dropNullAndUndefined(optionsCopy) + }, false), + quiet: quiet >= 2 + }, + quiet: quiet >= 1 + } + }); +} + + +module.exports = { + author, + __version__, + __copyright__, + initParser, + filterOptions +}; diff --git a/bin/cli.js b/bin/cli.js new file mode 100755 index 0000000..c1c5813 --- /dev/null +++ b/bin/cli.js @@ -0,0 +1,177 @@ +#!/usr/bin/env node + +/** + * @file Main binary module for **YTMP3** project to download YouTube audios using CLI. + * + * @requires utils + * @requires ytmp3 + * @requires bin/argparser + * @author Ryuu Mitsuki + * @license MIT + * @since 0.1.0 + */ + +'use strict'; + +const fs = require('fs'); // File system module +const path = require('path'); // Path module +const { EOL } = require('os'); +const { promisify } = require('util'); +const { + getTempPath, + createTempPath: _createTempPath +} = require('@mitsuki31/temppath'); +const createTempPath = promisify(_createTempPath); + +const { logger: log } = require('../lib/utils'); +const ytmp3 = require('../lib/ytmp3'); +const pkg = require('../package.json'); +const { + __version__, + __copyright__, + initParser, + filterOptions +} = require('./argparser'); + + +const TEMPDIR = path.join(path.dirname(getTempPath()), 'ytmp3-js'); +const DEFAULT_BATCH_FILE = path.join(__dirname, 'downloads.txt'); + +/** Store the file path of cached multiple download URLs. */ +let multipleDlCache = null; + + +/** + * Creates a cache file for URLs to be downloaded. + * + * This function creates a temporary file in the system's temporary directory + * containing a list of URLs to be downloaded using the + * {@link module:ytmp3~batchDownload `ytmp3.batchDownload`} function. + * + * @param {string[]} urls - URLs to be written to cache file + * @returns {Promise} The path to the cache file for later deletion + * + * @private + * @since 1.0.0 + */ +async function createCache(urls) { + const cache = await createTempPath(TEMPDIR, { + asFile: true, + ext: 'dl', + maxLen: 20 + }); + // Create write stream for cache file + const cacheStream = fs.createWriteStream(cache); + + // Write URLs to cache + urls.forEach(url => cacheStream.write(`${url}${EOL}`)); + + // Close the write stream + cacheStream.end(); + return cache; +} + +/** + * Deletes the cache file if it exists + * + * @returns {Promise} `true` if the cache file is deleted successfully + * @private + * @since 1.0.0 + */ +async function deleteCache() { + if (!multipleDlCache) return false; + if (fs.existsSync(multipleDlCache)) { + // Delete the parent directory of the cache file + await fs.promises.rm(path.dirname(multipleDlCache), { recursive: true, force: true }); + } + return true; +} + +/** + * Main function. + * @private + * @since 1.0.0 + */ +async function main() { + const { + urls, + 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; + } + + let downloadSucceed = false; + try { + if ((!urls || (urls && !urls.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 ((!urls || (urls && !urls.length)) && batchFile) { + log.info('\x1b[95mMode: \x1b[97mBatch Download\x1b[0m'); + downloadSucceed = !!await ytmp3.batchDownload(batchFile, downloadOptions); + } else if (urls.length && !batchFile) { + if (Array.isArray(urls) && urls.length > 1) { + log.info('\x1b[95mMode: \x1b[97mMultiple Downloads\x1b[0m'); + multipleDlCache = await createCache(urls); + downloadSucceed = !!await ytmp3.batchDownload(multipleDlCache, downloadOptions); + } else { + log.info('\x1b[95mMode: \x1b[97mSingle Download\x1b[0m'); + downloadSucceed = !!await ytmp3.singleDownload(urls[0], downloadOptions); + } + } + } catch (dlErr) { + log.error(dlErr.message); + console.error(dlErr.stack); + process.exit(1); + } finally { + await deleteCache(); + } + + if (downloadSucceed) { + log.info(`Downloaded files saved at \x1b[93m${downloadOptions.outDir}\x1b[0m`); + } +} + + +if (require.main === module) { + main(); +} diff --git a/index.js b/index.js index 96579f8..a8b5e6f 100644 --- a/index.js +++ b/index.js @@ -1,454 +1,26 @@ -#!/usr/bin/env node - /** - * @file Main module for **YTMP3** project to download YouTube videos as audio files using CLI. + * @file Main module of **YTMP3-JS** library providing useful APIs to download YouTube audios with ease. * * @requires lib/audioconv + * @requires lib/error + * @requires lib/url-utils * @requires lib/utils * @requires lib/ytmp3 - * @author Ryuu Mitsuki (https://github.com/mitsuki31) + * @author Ryuu Mitsuki * @license MIT * @since 0.1.0 */ -/** - * A type definition for the filtered options object, which is returned from {@link filterOptions} function. - * - * @typedef {Object} FilteredOptions - * @property {string} urls - A list of URLs 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 { EOL } = require('os'); -const { promisify } = require('util'); -const { ArgumentParser } = require('argparse'); -const { - getTempPath, - createTempPath: _createTempPath -} = require('@mitsuki31/temppath'); -const createTempPath = promisify(_createTempPath); - const { defaultOptions: defaultAudioConvOptions, - resolveOptions: resolveACOptions, checkFfmpeg, - convertAudio, + convertAudio } = require('./lib/audioconv'); -const { logger: log, dropNullAndUndefined } = require('./lib/utils'); -const { importConfig } = require('./lib/config'); const URLUtils = require('./lib/url-utils'); const error = require('./lib/error'); const ytmp3 = require('./lib/ytmp3'); -const pkg = require('./package.json'); - -const DEFAULT_BATCH_FILE = path.join(__dirname, 'downloads.txt'); -// Windows: "C:\Users\...\AppData\Local\Temp\ytmp3-js" -// Linux: "/home/usr/tmp/ytmp3-js" -// Termux Android: "/data/data/com.termux/files/usr/tmp/ytmp3-js" -const TEMPDIR = path.join(path.dirname(getTempPath()), 'ytmp3-js'); -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.substring(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`; - -/** Store the file path of cached multiple download URLs. */ -let multipleDlCache = null; - -/** - * 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 ==== // - // :: inputOptions - parser.add_argument( - '--inputOptions', - '--input-options', - '--addInputOptions', - '--add-input-options', - '--inOpt', - { - metavar: 'OPT', - help: 'Add custom input options for audio conversion', - type: 'str', - dest: 'inputOptions', - } - ); - // :: outputOptions - parser.add_argument( - '--outputOptions', - '--output-options', - '--addOption', - '--add-option', - '--addOutputOptions', - '--add-output-options', - '--outOpt', - { - metavar: 'OPT', - help: 'Add custom output options for audio conversion', - type: 'str', - dest: 'outputOptions', - } - ); - // :: 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. - * - * @returns {FilteredOptions} 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 - */ -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 - let dlOptionsFromConfig = optionsCopy.config - ? importConfig(optionsCopy.config) - : {}; - // Await the download options if it is a promise - if (dlOptionsFromConfig instanceof Promise) { - dlOptionsFromConfig = await dlOptionsFromConfig; - } - const acOptionsFromConfig = dlOptionsFromConfig.converterOptions || {}; - delete optionsCopy.config; // No longer needed - delete dlOptionsFromConfig.converterOptions; // No longer needed - - return Object.freeze({ - urls: 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 - } - }); -} - -/** - * Creates a cache file for URLs to be downloaded. - * - * This function creates a temporary file in the system's temporary directory - * containing a list of URLs to be downloaded using the - * {@link module:ytmp3~batchDownload `ytmp3.batchDownload`} function. - * - * @param {string[]} urls - URLs to be written to cache file - * @returns {Promise} The path to the cache file for later deletion - */ -async function createCache(urls) { - const cache = await createTempPath(TEMPDIR, { - asFile: true, - ext: 'dl', - maxLen: 20 - }); - // Create write stream for cache file - const cacheStream = fs.createWriteStream(cache); - - // Write URLs to cache - urls.forEach(url => cacheStream.write(`${url}${EOL}`)); - - // Close the write stream - cacheStream.end(); - return cache; -} - -/** - * Deletes the cache file if it exists - * - * @returns {Promise} `true` if the cache file is deleted successfully - */ -async function deleteCache() { - if (!multipleDlCache) return false; - if (fs.existsSync(multipleDlCache)) { - // Delete the parent directory of the cache file - await fs.promises.rm(path.dirname(multipleDlCache), { recursive: true, force: true }); - } - return true; -} - - -/** - * Main function. - * @private - * @since 1.0.0 - */ -async function main() { - const { - urls, - 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; - } - - let downloadSucceed = false; - try { - if ((!urls || (urls && !urls.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 ((!urls || (urls && !urls.length)) && batchFile) { - log.info('\x1b[95mMode: \x1b[97mBatch Download\x1b[0m'); - downloadSucceed = !!await ytmp3.batchDownload(batchFile, downloadOptions); - } else if (urls.length && !batchFile) { - if (Array.isArray(urls) && urls.length > 1) { - log.info('\x1b[95mMode: \x1b[97mMultiple Downloads\x1b[0m'); - multipleDlCache = await createCache(urls); - downloadSucceed = !!await ytmp3.batchDownload(multipleDlCache, downloadOptions); - await deleteCache(); - } else { - log.info('\x1b[95mMode: \x1b[97mSingle Download\x1b[0m'); - downloadSucceed = !!await ytmp3.singleDownload(urls[0], downloadOptions); - } - } - } catch (dlErr) { - // Prevent the cache file still exists when an error occurs - await deleteCache(); - - log.error(dlErr.message); - console.error(dlErr.stack); - process.exit(1); - } - - if (downloadSucceed) { - log.info(`Downloaded files saved at \x1b[93m${downloadOptions.outDir}\x1b[0m`); - } -} module.exports = Object.freeze({ @@ -470,11 +42,5 @@ module.exports = Object.freeze({ checkFfmpeg, convertAudio, // :: error - IDExtractorError: error.IDExtractorError, - UnknownOptionError: error.UnknownOptionError + ...error }); - - -if (require.main === module) { - main(); -} From 09f404e35e7f7eeb3001b12b72088c22189e8ae9 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sat, 31 Aug 2024 22:46:45 +0700 Subject: [PATCH 09/21] chore(npm): Update `bin` entry point to point to CLI module - Adjusted the `bin` field in `package.json` to use `./bin/cli.js` instead of `./index.js` for the `ytmp3` command. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b4a72a2..436f7ba 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "A Node.js library offers an easy and uncomplicated method for downloading YouTube audio", "main": "index.js", "bin": { - "ytmp3": "./index.js" + "ytmp3": "./bin/cli.js" }, "files": [ "index.js", From 5785beeff81ffa17e387226d4b7d835c6cba547a Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sat, 31 Aug 2024 23:09:25 +0700 Subject: [PATCH 10/21] chore(eslint): Update config to include bin directory - Added the "bin" directory to `include` field in `eslint.config.js` file - Added the "bin" directory to `ignores` field in `eslint.config.test.js` file --- eslint.config.js | 1 + eslint.config.test.js | 1 + 2 files changed, 2 insertions(+) diff --git a/eslint.config.js b/eslint.config.js index 1d869be..85fb6a9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -29,6 +29,7 @@ module.exports = [ files: [ 'index.js', 'lib/**/*.js', + 'bin/**/*', 'eslint.config.js' ], languageOptions: { diff --git a/eslint.config.test.js b/eslint.config.test.js index aa4830a..45f41ef 100644 --- a/eslint.config.test.js +++ b/eslint.config.test.js @@ -15,6 +15,7 @@ module.exports = [ '**/coverage/', 'config/example/**.{mjs,js,json}', 'lib/', + 'bin/', 'index.js', 'eslint.config.*' ] From a5a284f44ae6d50a5bf24d94ee1ae0b62bfaa92c Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sat, 31 Aug 2024 23:53:32 +0700 Subject: [PATCH 11/21] fix(audioconv): Fix object checker in options resolver Now it uses `utils.isPlainObject` instead `utils.isObject` to check a plain object --- lib/audioconv.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/audioconv.js b/lib/audioconv.js index 34cf036..5a1b2a6 100644 --- a/lib/audioconv.js +++ b/lib/audioconv.js @@ -40,7 +40,7 @@ const { createLogFile, dropNullAndUndefined, isNullOrUndefined, - isObject + isPlainObject } = require('./utils'); /** @@ -195,7 +195,7 @@ function splitOptions(options) { * @since 1.0.0 */ function resolveOptions(options, useDefault=false) { - if (!isObject(options)) return defaultOptions; + if (!isPlainObject(options)) return defaultOptions; return { inputOptions: ( From 73fcd470e572c9c93a5992f6e528ebbda3c4794d Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sun, 1 Sep 2024 00:12:39 +0700 Subject: [PATCH 12/21] test(config): Update tests to reflect `InvalidTypeError` usage - Replaced instances of `TypeError` with `InvalidTypeError` in the `config` module test suite. - Adjusted test descriptions to align with the updated error handling in the `config` module. - Ensured that `InvalidTypeError` is correctly imported and used in assertions for better type validation. --- test/unittest/config.spec.mjs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/test/unittest/config.spec.mjs b/test/unittest/config.spec.mjs index 8edc9df..749d2c1 100644 --- a/test/unittest/config.spec.mjs +++ b/test/unittest/config.spec.mjs @@ -7,7 +7,10 @@ import { tmpdir } from 'node:os'; import config from '../../lib/config.js'; import utils from'../../lib/utils.js'; import error from '../../lib/error.js'; -const { UnknownOptionError } = error; +const { + UnknownOptionError, + InvalidTypeError +} = error; describe('module:config', function () { const testMessages = { @@ -15,7 +18,7 @@ describe('module:config', function () { 'should parse and resolve the JSON configuration file', 'should parse and resolve the ES Module configuration file', 'should parse and resolve the CommonJS configuration file', - 'should throw `TypeError` when configuration file path is not a string', + 'should throw `InvalidTypeError` when configuration file path is not a string', 'should throw `Error` if configuration file extension are unknown' ], parseConfig: [ @@ -27,9 +30,9 @@ describe('module:config', function () { 'should return an empty object when specified configuration with nullable value' ], configChecker: [ - 'should throw `TypeError` if the given config is not an object', + 'should throw `InvalidTypeError` if the given config is not an object', 'should throw `UnknownOptionError` when found unknown download options', - 'should throw `TypeError` if any known options is not an object type' + 'should throw `InvalidTypeError` if any known options is not an object type' ] }; let configObj; @@ -132,7 +135,7 @@ describe('module:config', function () { it(testMessages.importConfig[3], async function () { await assert.rejects(async () => { await config.importConfig(123n); - }, TypeError); + }, InvalidTypeError); }); it(testMessages.importConfig[4], async function () { @@ -206,7 +209,8 @@ describe('module:config', function () { it(testMessages.configChecker[0], function () { [ null, 5, '_' ].forEach((cfg) => - assert.throws(() => config.configChecker({ config: cfg, file }))); + assert.throws(() => + config.configChecker({ config: cfg, file })), InvalidTypeError); }); it(testMessages.configChecker[1], function () { @@ -222,7 +226,7 @@ describe('module:config', function () { ]; configs.forEach((cfg) => { assert.throws(() => - config.configChecker({ config: cfg, file }), TypeError); + config.configChecker({ config: cfg, file }), InvalidTypeError); }); }); }); From 3b3fde57a0332f5463cd79a865ef66c79fed25fc Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sun, 1 Sep 2024 00:14:57 +0700 Subject: [PATCH 13/21] test(utils): Enhance tests for object type checks - Added new test cases for `isObject` to ensure it returns `true` for non-array objects and objects with null prototypes. - Introduced a separate test suite for `isPlainObject` to validate that it correctly identifies plain objects, including those with null prototypes. - Updated test assertions for better clarity and accuracy. --- test/unittest/utils.spec.mjs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/test/unittest/utils.spec.mjs b/test/unittest/utils.spec.mjs index 969e71a..c7b973b 100644 --- a/test/unittest/utils.spec.mjs +++ b/test/unittest/utils.spec.mjs @@ -21,7 +21,10 @@ describe('module:utils', function () { 'should return true if given argument is a nullable value' ], isObject: [ - 'should return true if supplied with a literal object' + 'should return true if supplied with any object except array' + ], + isPlainObject: [ + 'should return true if supplied with a plain object only including null prototype object' ], ProgressBar: [ 'should initialize a new instance class', @@ -108,10 +111,22 @@ describe('module:utils', function () { describe('#isObject', function () { it(testMessages.isObject[0], function () { - assert.ok(utils.isObject({}) === true); - assert.ok(utils.isObject([]) === false); - assert.ok(utils.isObject(new RegExp('foo')) === false); - assert.ok(utils.isObject(100n) === false); + assert.equal(utils.isObject({}), true); + assert.equal(utils.isObject([]), false); + assert.equal(utils.isObject(new RegExp('foo')), true); + assert.equal(utils.isObject(100n), false); + assert.equal(utils.isPlainObject(Object.create(null)), true); + assert.equal(utils.isObject(new Set()), true); + }); + }); + + describe('#isPlainObject', function () { + it(testMessages.isPlainObject[0], function () { + assert.equal(utils.isPlainObject({}), true); + assert.equal(utils.isPlainObject([]), false); + assert.equal(utils.isPlainObject(/g/i), false); + assert.equal(utils.isPlainObject(Object.create(null)), true); + assert.equal(utils.isPlainObject(new Set()), false); }); }); From 952fd7187f4525dec1d3e981ed91bebad83e0604 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sun, 1 Sep 2024 02:56:05 +0700 Subject: [PATCH 14/21] refactor(cli): Improve argparse behavior for intermixed options - Updated `argparser.js` to suppress default values for options when not provided, ensuring cleaner output and reducing unnecessary clutter in the parsed arguments. - Switched to `parse_intermixed_args()` to support intermixed positional and optional arguments, improving usability and flexibility in the command-line interface. This change enhances the overall argument parsing behavior, making the CLI tool more intuitive and user-friendly. --- bin/argparser.js | 35 +++++++++++++++++++++++------------ bin/cli.js | 2 +- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/bin/argparser.js b/bin/argparser.js index 732b3ea..7b13806 100644 --- a/bin/argparser.js +++ b/bin/argparser.js @@ -28,7 +28,7 @@ * @since 1.0.0 */ -const { ArgumentParser } = require('argparse'); +const { ArgumentParser, SUPPRESS } = require('argparse'); const { resolveOptions: resolveACOptions } = require('../lib/audioconv'); const { dropNullAndUndefined } = require('../lib/utils'); @@ -83,41 +83,45 @@ function initParser() { parser.add_argument('URL', { help: 'The YouTube URL(s) to download. Supports multiple URLs', type: 'str', - nargs: '*' // Support multiple URLs + nargs: '*', // Support multiple URLs + default: SUPPRESS }); // :: cwd parser.add_argument('--cwd', { metavar: 'DIR', help: 'Set the current working directory (default: current directory)', type: 'str', - default: '.' + default: SUPPRESS }); // :: 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' + dest: 'file', + default: SUPPRESS }); // :: 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' + dest: 'outDir', + default: SUPPRESS }); // :: config parser.add_argument('-c', '--config', { metavar: 'FILE', help: 'Path to configuration file containing `downloadOptions` object', type: 'str', - dest: 'config' + dest: 'config', + default: SUPPRESS }); // :: convertAudio parser.add_argument('-C', '--convertAudio', '--convert-audio', { help: 'Enable audio conversion to a specific format (requires FFmpeg)', action: 'store_true', - dest: 'convertAudio' + dest: 'convertAudio', + default: SUPPRESS }); // ==== Audio Converter Options ==== // @@ -133,6 +137,7 @@ function initParser() { help: 'Add custom input options for audio conversion', type: 'str', dest: 'inputOptions', + default: SUPPRESS } ); // :: outputOptions @@ -149,6 +154,7 @@ function initParser() { help: 'Add custom output options for audio conversion', type: 'str', dest: 'outputOptions', + default: SUPPRESS } ); // :: format @@ -156,31 +162,36 @@ function initParser() { metavar: 'FMT', help: 'Convert the audio to the specified format. Requires `--convertAudio`', type: 'str', + default: SUPPRESS }); // :: codec parser.add_argument('--codec', '--encoding', { metavar: 'CODEC', help: 'Specify the codec for the converted audio. Requires `--convertAudio`', - dest: 'codec' + dest: 'codec', + default: SUPPRESS }); // :: bitrate parser.add_argument('--bitrate', { metavar: 'N', help: 'Set the bitrate for the converted audio in kbps. Requires `--convertAudio`', - type: 'str' + type: 'str', + default: SUPPRESS }); // :: 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' + dest: 'frequency', + default: SUPPRESS }); // :: channels parser.add_argument('--channels', { metavar: 'N', help: 'Specify the audio channels for the converted audio. Requires `--convertAudio`', - type: 'int' + type: 'int', + default: SUPPRESS }); // :: deleteOld parser.add_argument('--deleteOld', '--delete-old', '--overwrite', { diff --git a/bin/cli.js b/bin/cli.js index c1c5813..317a7ea 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -101,7 +101,7 @@ async function main() { downloadOptions, printConfig } = await filterOptions({ - options: initParser().parse_args() + options: initParser().parse_intermixed_args() }); // Version From 80317950617f3f14e76069dc1c43606d92117703 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sun, 1 Sep 2024 03:12:04 +0700 Subject: [PATCH 15/21] feat(cli): Add `--no-config` option to disable configuration files load - Introduced a new `--noConfig` (`--no-config`) CLI option, allowing users to disable the loading of global or specified configuration files. - Updated the `filterOptions` function to respect the `--no-config` flag, preventing the merging of global configuration with command-line options when this flag is set. - Enhanced the global configuration parsing logic to conditionally apply settings only when `--no-config` is not specified. - Improved overall flexibility by allowing users to opt out of configuration file overrides, ensuring more precise control over the CLI's behavior. --- bin/argparser.js | 69 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/bin/argparser.js b/bin/argparser.js index 7b13806..1347b71 100644 --- a/bin/argparser.js +++ b/bin/argparser.js @@ -31,8 +31,16 @@ const { ArgumentParser, SUPPRESS } = require('argparse'); const { resolveOptions: resolveACOptions } = require('../lib/audioconv'); -const { dropNullAndUndefined } = require('../lib/utils'); -const { importConfig } = require('../lib/config'); +const { + dropNullAndUndefined, + isPlainObject, + isNullOrUndefined +} = require('../lib/utils'); +const { + importConfig, + findGlobalConfig, + parseGlobalConfig +} = require('../lib/config'); const { resolveDlOptions } = require('../lib/ytmp3'); const pkg = require('../package.json'); @@ -116,6 +124,11 @@ function initParser() { dest: 'config', default: SUPPRESS }); + // :: noConfig + parser.add_argument('--noConfig', '--no-config', { + help: 'Disable the automatic loading and merging of global or specified configuration files', + action: 'store_true' + }); // :: convertAudio parser.add_argument('-C', '--convertAudio', '--convert-audio', { help: 'Enable audio conversion to a specific format (requires FFmpeg)', @@ -267,18 +280,58 @@ function initParser() { * @since 1.0.0 */ async function filterOptions({ options }) { - if (!options || (Array.isArray(options) || typeof options !== 'object')) return {}; + if (!options || !isPlainObject(options)) return {}; - // Deep copy the options - const optionsCopy = JSON.parse(JSON.stringify(options)); + // Deep copy the options and remove unspecified options, + // especially for 'cwd' and 'outDir' + let optionsCopy = dropNullAndUndefined( + JSON.parse(JSON.stringify(options))); + const { noConfig, quiet } = optionsCopy; // 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; + delete optionsCopy.noConfig; // No longer used + + // Look up for global configuration file and parse if available + const globalConfigFile = await findGlobalConfig(); // ! Can be null + // Only parse the global configuration file if `--no-config` option is disabled + const globalConfig = (globalConfigFile && !noConfig) + ? await parseGlobalConfig(globalConfigFile) + : null; + const dlOptionsFromGlobalConfig = { ...(globalConfig || {}) }; + const acOptionsFromGlobalConfig = globalConfig?.converterOptions || {}; + if (dlOptionsFromGlobalConfig.converterOptions) { + delete dlOptionsFromGlobalConfig.converterOptions; + } + + // Override options and resolve unspecified options + // if global config is available + if (!isNullOrUndefined(globalConfig) && !noConfig) { + optionsCopy = { + ...optionsCopy, + cwd: isNullOrUndefined(optionsCopy.cwd) + ? globalConfig.cwd : optionsCopy.cwd, + outDir: isNullOrUndefined(optionsCopy.outDir) + ? globalConfig.outDir : optionsCopy.outDir, + convertAudio: isNullOrUndefined(optionsCopy.convertAudio) + ? globalConfig.convertAudio : optionsCopy.convertAudio, + quiet: optionsCopy.quiet === 0 + ? globalConfig.quiet : optionsCopy.quiet, + }; + } + + optionsCopy.convertAudio = isNullOrUndefined(optionsCopy.convertAudio) + ? false : optionsCopy.convertAudio; + // Set to an empty array for clarity that the options is empty and unspecified + optionsCopy.inputOptions = isNullOrUndefined(optionsCopy.inputOptions) + ? [] : optionsCopy.inputOptions; + optionsCopy.outputOptions = isNullOrUndefined(optionsCopy.outputOptions) + ? [] : optionsCopy.outputOptions; // Extract and resolve the download options from configuration file if given - let dlOptionsFromConfig = optionsCopy.config + // and only if the `--no-config` option is disabled + let dlOptionsFromConfig = optionsCopy.config && !noConfig ? importConfig(optionsCopy.config) : {}; // Await the download options if it is a promise @@ -297,6 +350,7 @@ async function filterOptions({ options }) { printConfig: optionsCopy.printConfig, downloadOptions: { ...resolveDlOptions({ downloadOptions: { + ...dlOptionsFromGlobalConfig, // Download options from global config // Download options from config file can be overriden with download // options from the command-line ...dlOptionsFromConfig || {}, @@ -304,6 +358,7 @@ async function filterOptions({ options }) { }}), converterOptions: { ...resolveACOptions({ + ...acOptionsFromGlobalConfig, // Audio conversion options from global config ...dropNullAndUndefined(acOptionsFromConfig), ...dropNullAndUndefined(optionsCopy) }, false), From 973fd51a48b0d27f579b23ed924c8570c9eba4b5 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Sun, 1 Sep 2024 03:37:26 +0700 Subject: [PATCH 16/21] feat(cli): Add support for inverting enabled options - Added the ability to inverse the behavior of enabled options by using `BooleanOptionalAction` in `argparse`. - Updated `--convertAudio` and `--deleteOld` options to use `BooleanOptionalAction`, allowing users to disable these options by using their inverse form (e.g., `--no-convertAudio` or `--no-deleteOld`). - Introduced a new `--noQuiet` option to reset the `quiet` level, enabling suppressed output messages even when `quiet` is enabled. --- bin/argparser.js | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/bin/argparser.js b/bin/argparser.js index 1347b71..80199e6 100644 --- a/bin/argparser.js +++ b/bin/argparser.js @@ -28,7 +28,12 @@ * @since 1.0.0 */ -const { ArgumentParser, SUPPRESS } = require('argparse'); +const { + SUPPRESS, + ZERO_OR_MORE, + ArgumentParser, + BooleanOptionalAction +} = require('argparse'); const { resolveOptions: resolveACOptions } = require('../lib/audioconv'); const { @@ -91,7 +96,7 @@ function initParser() { parser.add_argument('URL', { help: 'The YouTube URL(s) to download. Supports multiple URLs', type: 'str', - nargs: '*', // Support multiple URLs + nargs: ZERO_OR_MORE, // Support multiple URLs default: SUPPRESS }); // :: cwd @@ -132,9 +137,8 @@ function initParser() { // :: convertAudio parser.add_argument('-C', '--convertAudio', '--convert-audio', { help: 'Enable audio conversion to a specific format (requires FFmpeg)', - action: 'store_true', - dest: 'convertAudio', - default: SUPPRESS + action: BooleanOptionalAction, + dest: 'convertAudio' }); // ==== Audio Converter Options ==== // @@ -209,7 +213,7 @@ function initParser() { // :: deleteOld parser.add_argument('--deleteOld', '--delete-old', '--overwrite', { help: 'Delete the old file after audio conversion is done. Requires `--convertAudio`', - action: 'store_true', + action: BooleanOptionalAction, dest: 'deleteOld' }); // :: quiet @@ -218,6 +222,12 @@ function initParser() { action: 'count', default: 0 // Set the default to ensure it is always number }); + // :: noQuiet + parser.add_argument('--noQuiet', '--no-quiet', { + help: 'Enable suppressed output messages (only affect if `quiet` is enabled)', + action: 'store_true', + dest: 'noQuiet' + }); // ==== Other Options ==== // // :: help @@ -287,11 +297,16 @@ async function filterOptions({ options }) { let optionsCopy = dropNullAndUndefined( JSON.parse(JSON.stringify(options))); - const { noConfig, quiet } = optionsCopy; + const { noConfig, noQuiet } = optionsCopy; + let { quiet } = optionsCopy; // We need to extract the quiet option first and delete it // if not, `audioconv.resolveOptions()` function will throw an error delete optionsCopy.quiet; delete optionsCopy.noConfig; // No longer used + delete optionsCopy.noQuiet; // No longer used + + // Reset the quiet level to zero if the `--no-quiet` is specified + if (noQuiet) quiet = 0; // Look up for global configuration file and parse if available const globalConfigFile = await findGlobalConfig(); // ! Can be null From 6b28cfda26f59d5e3ee359f658a11d9e6060c8b1 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Wed, 4 Sep 2024 00:00:18 +0700 Subject: [PATCH 17/21] feat(config): Add `searchDir` param to control search directory - Added `searchDir` parameter to `findGlobalConfig` function, allowing the search directory for the global configuration file to be specified. Defaults to `utils.YTMP3_HOMEDIR`. - Implemented directory existence and type checks to ensure the specified `searchDir` is a valid directory. - Updated the function to return `null` if the `searchDir` does not exist or is not a directory, enhancing error handling and flexibility in configuration management. --- lib/config.js | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/lib/config.js b/lib/config.js index b3fc1ab..74044b7 100644 --- a/lib/config.js +++ b/lib/config.js @@ -387,19 +387,35 @@ const importConfig = (file, forceRequire=false) => parseConfig(file, true, force * If the prioritized file is empty, the function will iterate through other available files * until it finds a non-empty file or exhausts the list. * + * @param {string} [YTMP3_HOMEDIR] - The directory from where to search the global configuration file. + * Defaults to {@link module:utils~YTMP3_HOMEDIR `YTMP3_HOMEDIR`}. * @returns {Promise} * A promise fullfills with a string representing the absolute path of the selected - * configuration file, or `null` if no global configuration file is found. + * configuration file, or `null` if no global configuration file is found or if the + * `searchDir` is not exist and not a directory. * * @async * @package * @since 1.1.0 * @see {@linkcode https://npmjs.com/package/lsfnd npm:lsfnd} */ -async function findGlobalConfig() { - const configDirname = YTMP3_HOMEDIR; +async function findGlobalConfig(searchDir = YTMP3_HOMEDIR) { + searchDir = (isNullOrUndefined(searchDir) || typeof searchDir !== 'string') + ? YTMP3_HOMEDIR : searchDir; const knownConfigExtsRegex = new RegExp(`${KNOWN_CONFIG_EXTS.join('$|')}$`); - const configs = await ls(configDirname, { + + // Before start searching the config file, we need to check whether the `searchDir` + // is exist and does not refer to non-directory type + try { + const stat = await fs.promises.stat(searchDir); + if (!stat.isDirectory()) return null; // Not a directory + } catch (err) { + // ENOENT means no such a directory or file + if (err.code && err.code === 'ENOENT') return null; + throw err; // Otherwise throw back the error + } + + const configs = await ls(searchDir || YTMP3_HOMEDIR, { encoding: 'utf8', match: knownConfigExtsRegex, recursive: false, @@ -412,7 +428,7 @@ async function findGlobalConfig() { if (!configs || (Array.isArray(configs) && !configs.length)) return null; // Return the first index if only found one configuration file - if (configs.length === 1) return path.join(configDirname, configs[0]); + if (configs.length === 1) return path.join(searchDir, configs[0]); const prioritizedConfigs = [ 'ytmp3-js.config.cjs', // #1 @@ -429,7 +445,7 @@ async function findGlobalConfig() { } // Get the absolute path of the config file - let configRealPath = path.join(configDirname, configs[configIndex]); + let configRealPath = path.join(searchDir, configs[configIndex]); let configStat = await fs.promises.stat(configRealPath); // If the config file is empty, try to find another config file @@ -440,7 +456,7 @@ async function findGlobalConfig() { retries++; continue; } - configRealPath = path.join(configDirname, configs[retries++]); + configRealPath = path.join(searchDir, configs[retries++]); configStat = await fs.promises.stat(configRealPath); } From ee2d13e2ca5b3dda3a97c29d83aa8b8416910101 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Wed, 4 Sep 2024 00:46:43 +0700 Subject: [PATCH 18/21] test(config): Add new test suites and improvements * Added new test suites for new functions (`findGlobalConfig` and `parseGlobalConfig`) that search and parse the known global configuration file of YTMP3-JS. * Added new 3 test cases for `parseConfig` function. * Several improvements to other test suites and code coverage. --- test/unittest/config.spec.mjs | 192 +++++++++++++++++++++++++++++++++- test/unittest/utils.spec.mjs | 4 +- 2 files changed, 192 insertions(+), 4 deletions(-) diff --git a/test/unittest/config.spec.mjs b/test/unittest/config.spec.mjs index 749d2c1..413e1e0 100644 --- a/test/unittest/config.spec.mjs +++ b/test/unittest/config.spec.mjs @@ -1,15 +1,20 @@ import assert from 'node:assert'; import fs from 'node:fs'; import path from 'node:path'; +import os from 'node:os'; import { format } from 'node:util'; import { tmpdir } from 'node:os'; +import { lsFiles } from 'lsfnd'; +import { getTempPath } from '@mitsuki31/temppath'; import config from '../../lib/config.js'; import utils from'../../lib/utils.js'; +import audioconv from '../../lib/audioconv.js'; import error from '../../lib/error.js'; const { UnknownOptionError, - InvalidTypeError + InvalidTypeError, + GlobalConfigParserError } = error; describe('module:config', function () { @@ -23,7 +28,9 @@ describe('module:config', function () { ], parseConfig: [ 'should validate only the configuration file', - 'should validate and resolve the configuration file' + 'should validate and resolve the configuration file', + 'should handle Windows path while importing configuration file', + 'should reject if an error occurred while importing configuration file' ], resolveConfig: [ 'should resolve audio conversion options from `converterOptions` field', @@ -33,6 +40,18 @@ describe('module:config', function () { 'should throw `InvalidTypeError` if the given config is not an object', 'should throw `UnknownOptionError` when found unknown download options', 'should throw `InvalidTypeError` if any known options is not an object type' + ], + findGlobalConfig: [ + 'should return the path to the global config file', + 'should return null if `searchDir` is not a directory', + 'should throw an error if `fs.stat` fails with an unexpected error' + ], + parseGlobalConfig: [ + 'should return an object with parsed configuration settings', + 'should throw an error if any option in configuration file is invalid', + 'should throw an error if the configuration file is unreadable', + 'should throw an error if the parser options is not a plain object', + 'should throw an error if the given configuration path is not a string' ] }; let configObj; @@ -175,6 +194,27 @@ describe('module:config', function () { }); }); + it(testMessages.parseConfig[2], async function () { + const configFile = (process.platform !== 'win32') + ? tempFile.replace('/', '\\') : tempFile; + const processStub = global.process; + global.process = { platform: 'win32' }; + + try { + await config.parseConfig(configFile); + } catch (err) { + assert.fail(err); + } finally { + global.process = processStub; + } + }); + + it(testMessages.parseConfig[3], async function () { + await assert.rejects(async () => + await config.parseConfig('unknown and inexistence conf file.config.mjs') + ); + }); + after(function () { if (fs.existsSync(tempFile)) fs.rmSync(tempFile); }); @@ -231,6 +271,154 @@ describe('module:config', function () { }); }); + describe('#findGlobalConfig', function () { + let searchDir; + let globalConfigs = ['ytmp3-js.config.mjs', 'ytmp3.config.js', 'ytmp3-js.json']; + let statStub; + + before(async function () { + statStub = fs.promises.stat; + searchDir = path.join(utils.ROOTDIR, 'tmp', 'configs'); + globalConfigs = globalConfigs.map((cfg) => path.join(searchDir, cfg)); + await utils.createDirIfNotExist(searchDir); + + // Create empty config files except for JSON config + // "ytmp3-js.json" file is have a low priority + for (const cfg of globalConfigs) { + const content = !/ytmp3-js.json$/.test(cfg) ? '' : JSON.stringify({ + downloadOptions: { convertAudio: true } + }); + await fs.promises.writeFile(cfg, content, { encoding: 'utf8' }); + } + }); + + it(testMessages.findGlobalConfig[0], async function () { + const searchedGlobConfig = await config.findGlobalConfig(searchDir); + + assert.notStrictEqual(searchedGlobConfig, null) + assert.ok(fs.existsSync(searchedGlobConfig)); + assert.notStrictEqual(fs.statSync(searchedGlobConfig).size, 0); + }); + + it(testMessages.findGlobalConfig[1], async function () { + const result = await config.findGlobalConfig( + path.join(utils.ROOTDIR, 'README.md') // Must be an existence file + ); + assert.strictEqual(result, null); + }); + + it(testMessages.findGlobalConfig[2], async function () { + const err = new Error('Permission denied'); + err.errno = -13; + err.code = 'EACCES'; + err.syscall = 'stat'; + err.path = searchDir; + + fs.promises.stat = async (f) => { + throw err; + } + + await assert.rejects(() => config.findGlobalConfig(searchDir), err); + }); + + after(function () { + fs.promises.stat = statStub; + if (fs.existsSync(searchDir)) fs.rmSync(searchDir, { recursive: true }); + }); + }); + + describe('#parseGlobalConfig', function () { + let exampleGlobalConfigDir; + let exampleGlobalConfig; + let exampleInvalidGlobalConfig; + let exampleGlobalConfigWO; + let configs; + + before(async function () { + exampleGlobalConfigDir = getTempPath(path.join(utils.ROOTDIR, 'tmp'), 25); + exampleGlobalConfig = path.join(exampleGlobalConfigDir, 'ytmp3-js-test.json'); + exampleGlobalConfigWO = path.join(exampleGlobalConfigDir, 'ytmp3-js-test-write-only.js'); + exampleInvalidGlobalConfig = path.join(exampleGlobalConfigDir, 'ytmp3-test-invalid.cjs'); + + configs = { + _: { + downloadOptions: { + cwd: os.homedir(), + outDir: 'downloads', + convertAudio: false, + converterOptions: false, + quiet: true + } + }, + expected: { + cwd: os.homedir(), + outDir: path.join(os.homedir(), 'downloads'), // Relative to 'cwd' + convertAudio: false, + converterOptions: audioconv.defaultOptions, + quiet: true + } + } + + await utils.createDirIfNotExist(exampleGlobalConfigDir); + + // Create a fake global config file in temporary directory + await fs.promises.writeFile(exampleGlobalConfig, JSON.stringify(configs._), { + encoding: 'utf8' + }); + + // Create a write-only configuration file + await fs.promises.writeFile(exampleGlobalConfigWO, '', { encoding: 'utf8' }); + await fs.promises.chmod(exampleGlobalConfigWO, 200); // write-only + + // Create an invalid global config file + await fs.promises.writeFile(exampleInvalidGlobalConfig, + `'use strict'; module.exports = ${JSON.stringify({ + // This configuration is invalid because the download options was declared + // outside the `downloadOptions` field + outDir: 'downloads/music', // Error + convertAudio: true, // Error + audioConverterOptions: { // No error + format: 'mp4', + bitrate: 320 + } + })};`.trim(), { encoding: 'utf8' }); + }); + + it(testMessages.parseGlobalConfig[0], async function () { + let actualConfig; + await assert.doesNotReject(async () => { + actualConfig = await config.parseGlobalConfig(exampleGlobalConfig); + }); + assert.deepStrictEqual(actualConfig, configs.expected); + }); + + it(testMessages.parseGlobalConfig[1], async function () { + await assert.rejects(() => + config.parseGlobalConfig(exampleInvalidGlobalConfig), UnknownOptionError); + }); + + it(testMessages.parseGlobalConfig[2], async function () { + await assert.rejects(() => + config.parseGlobalConfig(exampleGlobalConfigWO), GlobalConfigParserError) + }); + + it(testMessages.parseGlobalConfig[3], async function () { + await assert.rejects(() => + config.parseGlobalConfig(exampleGlobalConfig, [ 'hello world' ]), InvalidTypeError); + }); + + it(testMessages.parseGlobalConfig[4], async function () { + await assert.rejects(() => + config.parseGlobalConfig([ exampleInvalidGlobalConfig ]), InvalidTypeError); + }); + + after(function () { + if (fs.existsSync(exampleGlobalConfigDir)) { + fs.rmSync(exampleGlobalConfigDir, { recursive: true }); + } + }); + }); + after(function () { if (fs.existsSync(path.dirname(tempFileNoExt))) { fs.rmSync(path.dirname(tempFileNoExt), { recursive: true }); diff --git a/test/unittest/utils.spec.mjs b/test/unittest/utils.spec.mjs index c7b973b..ac47e0f 100644 --- a/test/unittest/utils.spec.mjs +++ b/test/unittest/utils.spec.mjs @@ -140,7 +140,7 @@ describe('module:utils', function () { it(testMessages.ProgressBar[0], function () { assert.notStrictEqual(pb, null); assert.notStrictEqual(typeof pb, 'undefined'); - // Dump test + // Dummy test assert.doesNotThrow(() => new utils.ProgressBar({ barWidth: 20, barCharTotal: '_', @@ -164,7 +164,7 @@ describe('module:utils', function () { assert.notStrictEqual(typeof bar, 'undefined'); assert.notStrictEqual(bar.length, 0); - // Dump test + // Dummy test const columns = process.stdout.columns; if ('columns' in process.stdout) delete process.stdout.columns; new utils.ProgressBar({ barWidth: 'auto' }).create(1, 10); From ea94d386e0c7ed64749430c9dfcefac4b00f958e Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Wed, 4 Sep 2024 00:51:30 +0700 Subject: [PATCH 19/21] test(error): Add test module for `error` module --- test/unittest/error.spec.mjs | 130 +++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 test/unittest/error.spec.mjs diff --git a/test/unittest/error.spec.mjs b/test/unittest/error.spec.mjs new file mode 100644 index 0000000..e47d0c1 --- /dev/null +++ b/test/unittest/error.spec.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert'; + +import error from '../../lib/error.js'; + +describe('module:error', function () { + describe('.IDExtractorError', function () { + it('should create an instance with a message string', function () { + const err = new error.IDExtractorError(null); + assert.ok(err instanceof Error); + }); + }); + + describe('.UnknownOptionError', function () { + it('should create an instance with a message string', function () { + const err = new error.UnknownOptionError(null); + assert.ok(err instanceof Error); + }); + }); + + describe('.InvalidTypeError', function () { + it('should create an instance with a message string', function () { + const err = new error.InvalidTypeError('Invalid type provided'); + + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Invalid type provided'); + assert.strictEqual(err.actualType, undefined); + assert.strictEqual(err.expectedType, undefined); + }); + + it('should create an instance with a message from another `Error` instance', function () { + const msg = 'Original error message'; + const originalError = new Error(msg); + const err = new error.InvalidTypeError(originalError); + + assert.strictEqual(err.message, msg); + assert.strictEqual(err.actualType, undefined); + assert.strictEqual(err.expectedType, undefined); + }); + + it('should create an instance with `actualType` and `expectedType` if provided', function () { + const err = new error.InvalidTypeError('Type mismatch', { + actualType: 'number', + expectedType: 'string', + }); + assert.strictEqual(err.actualType, 'number'); + assert.strictEqual(err.expectedType, 'string'); + }); + + it('should not set `actualType` or `expectedType` if they are not strings', function () { + const err = new error.InvalidTypeError('Type mismatch', { + actualType: 42, + expectedType: null, + }); + assert.strictEqual(err.actualType, undefined); + assert.strictEqual(err.expectedType, undefined); + }); + + it('should set the cause if it is an instance of `Error`', function () { + const cause = new Error('Underlying cause'); + const err = new error.InvalidTypeError('Type mismatch', { cause }); + assert.strictEqual(err.cause, cause); + }); + + it('should not set the cause if it is not an instance of `Error`', function () { + const err = new error.InvalidTypeError('Type mismatch', { cause: 'Not an error' }); + assert.strictEqual(err.cause, undefined); + }); + + it('should ignore options that are not plain objects', function () { + const err = new error.InvalidTypeError('Type mismatch', null); + assert.strictEqual(err.actualType, undefined); + assert.strictEqual(err.expectedType, undefined); + assert.strictEqual(err.cause, undefined); + }); + }); + + describe('.GlobalConfigParserError', function () { + it('should create an instance with a message string', function () { + const err = new error.GlobalConfigParserError('Invalid configuration'); + + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Invalid configuration'); + assert.strictEqual(error.errno, undefined); + assert.strictEqual(error.code, undefined); + assert.strictEqual(error.syscall, undefined); + assert.strictEqual(error.path, undefined); + }); + + it('should create an instance with a message and stack from another `Error` instance', function () { + const msg = 'Original error message'; + const originalError = new Error(msg); + const err = new error.GlobalConfigParserError(originalError); + assert.strictEqual(err.message, msg); + assert.strictEqual(err.stack, originalError.stack); + }); + + it('should set properties (errno, code, syscall, path) if provided by the cause error', function () { + const cause = new Error('Permission denied'); + cause.errno = -13; + cause.code = 'EACCES'; + cause.syscall = 'open'; + cause.path = '/home/foo/.ytmp3-js/ytmp3-js.config.cjs'; + + const err = new error.GlobalConfigParserError('Parsing error', { cause }); + assert.strictEqual(err.errno, -13); + assert.strictEqual(err.code, 'EACCES'); + assert.strictEqual(err.syscall, 'open'); + assert.strictEqual(err.path, '/home/foo/.ytmp3-js/ytmp3-js.config.cjs'); + }); + + it('should not set properties (errno, code, syscall, path) if they are not present on the cause error', function () { + const cause = new Error('Cause without specific properties'); + const err = new error.GlobalConfigParserError('Parsing error', { cause }); + + assert.strictEqual(err.errno, undefined); + assert.strictEqual(err.code, undefined); + assert.strictEqual(err.syscall, undefined); + assert.strictEqual(err.path, undefined); + }); + + it('should ignore options that are not plain objects', function () { + const err = new error.GlobalConfigParserError('Parsing error', null); + + assert.strictEqual(err.errno, undefined); + assert.strictEqual(err.code, undefined); + assert.strictEqual(err.syscall, undefined); + assert.strictEqual(err.path, undefined); + }); + }); +}); From f0dc427923a66841aee6f5bdb802732f771ad78a Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Wed, 4 Sep 2024 01:34:20 +0700 Subject: [PATCH 20/21] refactor(config): Fix path resolution for Windows and POSIX platforms Refactored the `parseConfig` function to properly handle path resolution across different platforms. This change ensures that paths are correctly resolved on both Windows and POSIX systems by replacing incorrect path separators and using platform-specific logic. - On Windows, '/' is replaced with the appropriate Windows separator. - On POSIX systems, '\' is replaced with the appropriate POSIX separator. - Applied `path.resolve` to ensure the paths are absolute. This change enhances cross-platform compatibility and resolves issues related to incorrect path handling in configuration file imports. --- lib/config.js | 10 ++++++++++ test/unittest/config.spec.mjs | 10 ++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/config.js b/lib/config.js index 74044b7..38e87a0 100644 --- a/lib/config.js +++ b/lib/config.js @@ -324,6 +324,16 @@ function parseConfig(configFile, resolve=true, forceRequire=false) { }`); } + if (process.platform === 'win32') { + configFile = (path.win32.isAbsolute(configFile) + ? configFile + : path.posix.resolve(configFile.replace(/\//g, path.win32.sep))).trim(); + } else { + configFile = (path.posix.isAbsolute(configFile) + ? configFile + : path.posix.resolve(configFile.replace(/\\/g, path.posix.sep))).trim(); + } + // Import the configuration file let config = null; // Only include '.cjs' and '.json' to use require() diff --git a/test/unittest/config.spec.mjs b/test/unittest/config.spec.mjs index 413e1e0..f13e2bf 100644 --- a/test/unittest/config.spec.mjs +++ b/test/unittest/config.spec.mjs @@ -398,8 +398,14 @@ describe('module:config', function () { }); it(testMessages.parseGlobalConfig[2], async function () { - await assert.rejects(() => - config.parseGlobalConfig(exampleGlobalConfigWO), GlobalConfigParserError) + await assert.rejects(async () => { + // On Windows system, it would not rejects because the file is still readable + await config.parseGlobalConfig(exampleGlobalConfigWO); + // Throw an error for Windows only + if (process.platform === 'win32') { + throw new GlobalConfigParserError('Known rejection error: ' + process.platform); + } + }, GlobalConfigParserError); }); it(testMessages.parseGlobalConfig[3], async function () { From 56bf31b08b19c2bd9649729b83e1ae77f56ad1cf Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Wed, 4 Sep 2024 21:41:55 +0700 Subject: [PATCH 21/21] docs(cli): Update the CLI's epilog docs * Added homepage and GitHub repo link to the epilog of CLI documentation. --- bin/argparser.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bin/argparser.js b/bin/argparser.js index 80199e6..9999f23 100644 --- a/bin/argparser.js +++ b/bin/argparser.js @@ -32,7 +32,8 @@ const { SUPPRESS, ZERO_OR_MORE, ArgumentParser, - BooleanOptionalAction + BooleanOptionalAction, + RawDescriptionHelpFormatter } = require('argparse'); const { resolveOptions: resolveACOptions } = require('../lib/audioconv'); @@ -86,7 +87,14 @@ function initParser() { ? 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 + formatter_class: RawDescriptionHelpFormatter, + epilog: ` + Developed by \x1b[93m${author.name}\x1b[0m (${author.website}). + + \x1b[1;91m::\x1b[0m \x1b[1;96m[Homepage]\x1b[0m\thttps://mitsuki31.github.io/ytmp3-js + \x1b[1;91m::\x1b[0m \x1b[1;95m[GitHub]\x1b[0m\thttps://github.com/mitsuki31/ytmp3-js + `.trim().replace(/[ ]{2,}/g, ''), // eslint-disable-next-line camelcase add_help: false // Use custom help argument });