/**
* @file This module handles configuration resolution for the **YTMP3-JS** project.
*
* This module offers a parser and resolver for the configuration file of YTMP3-JS,
* which can parse both JSON and JS configuration file (support both CommonJS and
* ES module). You can see the {@link module:config~KNOWN_CONFIG_EXTS `KNOWN_CONFIG_EXTS`}
* constant to check the supported configuration file's extension names.
*
* The {@link module:config~parseConfig `parseConfig`} function will parse
* and optionally resolve the configuration file containing the download options
* and audio converter options (if defined). Before being resolved, the configuration
* file will be validated first and will throws a {@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 <caption> JSON Configuration File (<code>ytmp3-js.json</code>) </caption>
* {
* "downloadOptions": {
* "outDir": "/path/to/download/folder",
* "quiet": false,
* "convertAudio": true,
* "converterOptions": {
* "format": "opus",
* "codec": "libopus",
* "channels": 1,
* "deleteOld": true
* }
* }
* }
*
* @example <caption> CommonJS Module Configuration File (<code>ytmp3-js.config.cjs</code>) </caption>
* module.exports = {
* downloadOptions: {
* outDir: '..',
* convertAudio: false,
* quiet: true
* }
* }
*
* @example <caption> ES Module Configuration File (<code>ytmp3-js.config.mjs</code>) </caption>
* export default {
* downloadOptions: {
* cwd: process.env.HOME,
* outDir: 'downloads', // $HOME/downloads
* convertAudio: true
* },
* audioConverterOptions: {
* format: 'mp3',
* codec: 'libmp3lame',
* frequency: 48000,
* bitrate: '128k'
* deleteOld: true
* }
* }
*
* @module config
* @requires ytmp3
* @requires audioconv
* @requires utils
* @requires {@link https://npmjs.com/package/lsfnd npm:lsfnd}
* @author Ryuu Mitsuki <https://github.com/mitsuki31>
* @license MIT
* @since 1.0.0
*/
/**
* A typedef representating the configuration object containing options to configure
* the both download and audio conversion process.
*
* @typedef {Object} YTMP3Config
* @property {DownloadOptions} downloadOptions
* Options related to the download process.
* @property {ConvertAudioOptions} [audioConverterOptions]
* Options related to the audio conversion process, if not defined in
* `downloadOptions`. This field will be ignored if the `downloadOptions.converterOptions`
* property are defined and not contains a nullable value.
*
* @global
* @since 1.0.0
*/
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const util = require('node:util');
const { ls, lsTypes } = require('lsfnd');
const ytmp3 = require('./ytmp3');
const {
YTMP3_HOMEDIR,
isNullOrUndefined,
isObject,
isPlainObject,
getType
} = require('./utils');
const {
resolveOptions: resolveACOptions
} = require('./audioconv');
const {
UnknownOptionError,
InvalidTypeError,
GlobalConfigParserError
} = require('./error');
/**
* An array containing all known configuration file's extension names.
*
* **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<Array<('.js' | '.cjs' | '.mjs' | '.config.js' | '.config.mjs' | '.config.cjs' | '.json')>>}
* @readonly
* @default
* @package
* @since 1.0.0
*/
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.
*
* @type {Readonly<Array<('downloadOptions' | 'audioConverterOptions')>>}
* @readonly
* @default
* @package
* @since 1.0.0
*/
const KNOWN_OPTIONS = Object.freeze([ 'downloadOptions', 'audioConverterOptions' ]);
/**
* A string representating the format of error message.
* Can be formatted using `util.format()` function.
*
* First occurrence of `'%s'` will be intepreted as error message, the second
* as the directory name of configuration file, and the third one as the base name
* of the configuration file.
*
* @type {string}
* @constant
* @default '%s\n\tat \x1b[90m%s\n\x1b[1;91m%s\x1b[0m\n'
* @package
* @since 1.0.0
*/
const ERR_FORMAT = '%s\n\tat \x1b[90m%s\n\x1b[1;91m%s\x1b[0m\n';
/**
* Resolves the configuration for YTMP3-JS from a given configuration object.
*
* This function takes a configuration object typically sourced from a config file
* (e.g., `ytmp3-js.config.js`) and ensures that it adheres to the expected structure
* and types. It specifically resolves the download options and the audio converter
* options, providing fallbacks and handling type checks.
*
* @param {Object} params - The parameters for the function.
* @param {YTMP3Config} params.config - The configuration object to be resolved.
* @param {string} params.file - The file path from which the config object was sourced,
* used for error reporting.
*
* @returns {DownloadOptions} The resolved download options and audio converter
* options if provided.
*
* @throws {InvalidTypeError} If any known options is not object type.
*
* @package
* @since 1.0.0
*/
function resolveConfig({ config, file }) {
if (isNullOrUndefined(config) || !isObject(config)) return {};
// Set file to 'unknown' if given file is null or not a string type
if (isNullOrUndefined(file) || typeof file !== 'string') file = 'unknown';
// Check and validate the configuration
configChecker({ config, file });
// By using this below logic, if user specified with any falsy value
// or unspecified it will uses the fallback value instead
let downloadOptions = config.downloadOptions || {};
let audioConverterOptions;
if ('converterOptions' in downloadOptions) {
audioConverterOptions = downloadOptions.converterOptions;
} else if ('audioConverterOptions' in config) {
audioConverterOptions = config.audioConverterOptions;
}
// Resolve the download options
downloadOptions = ytmp3.resolveDlOptions({ downloadOptions });
// Resolve the audio converter options, but all unspecified options will
// fallback to undefined value instead their default value
audioConverterOptions = resolveACOptions(audioConverterOptions, false);
// Assign the `audioConverterOptions` to `downloadOptions`
Object.assign(downloadOptions, {
converterOptions: audioConverterOptions
});
return downloadOptions;
}
/**
* Checks the given configuration for validity.
*
* This function ensures that the configuration object adheres to the expected structure
* and types. It checks for unknown fields and validates the types of known options.
* Throws an error if there any known options is not object type or if there are
* unknown fields defined in the configuration.
*
* @param {Object} params - The parameters for the function.
* @param {YTMP3Config} params.config - The configuration object to be checked.
* @param {string} params.file - The file path from which the config object was sourced,
* used for error reporting.
*
* @throws {InvalidTypeError} If any known options is not object type.
* @throws {UnknownOptionError} If there are unknown fields in the configuration.
*
* @package
* @since 1.0.0
*/
function configChecker({ config, file }) {
if (!config || typeof config !== 'object') {
throw new InvalidTypeError('Invalid type of configuration object', {
actualType: getType(config),
expectedType: getType({})
});
}
file = (file && !path.isAbsolute(file)) ? path.resolve(file) : file;
const dirFile = path.dirname(file);
const baseFile = path.basename(file);
Array.from(Object.keys(config)).forEach(function (field) {
// Check for unknown field as option within the configuration options
if (!(Array.from(KNOWN_OPTIONS).includes(field))) {
throw new UnknownOptionError(util.format(ERR_FORMAT,
`Unknown configuration field: '${field}' (${typeof config[field]})`,
dirFile, baseFile
));
}
// Check for known options have a valid type (object)
if (isNullOrUndefined(config[field]) || !isObject(config[field])) {
throw new InvalidTypeError(util.format(ERR_FORMAT,
`Expected type of field '${field}' is an object`,
dirFile, baseFile
), {
actualType: getType(field),
expectedType: getType({})
});
}
});
}
/**
* Parses a configuration file and either resolves or validates its contents.
*
* This function can handle both CommonJS and ES module formats for configuration files.
* When importing an ES module, it returns a `Promise` that resolves to the configuration
* object. It also supports optional resolution of the configuration.
*
* @param {!string} configFile - A string path refers to the configuration file.
* @param {boolean} [resolve=true] - Determines whether to resolve the configuration object.
* If set to `false`, will validate the configuration only.
*
* @returns {YTMP3Config | DownloadOptions | Promise<(YTMP3Config | DownloadOptions)>}
* The configuration object or a `Promise` that fullfilled with the
* configuration object if an ES module is imported. The returned configuration
* object will be automatically resolved if `resolve` is set to `true`.
*
* @throws {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 <caption> Synchronously parse a CommonJS configuration file </caption>
* const config = parseConfig('./config.js');
* console.log(config);
*
* @example <caption> Asynchronously parse an ES module configuration file </caption>
* parseConfig('./config.mjs').then((config) => {
* console.log(config);
* }).catch((error) => {
* console.error('Failed to load config:', error);
* });
*
* @package
* @since 1.0.0
* @see {@link module:config~resolveConfig resolveConfig}
* @see {@link module:config~importConfig importConfig}
* (alias for <code>parseConfig(config, true)</code>)
*/
function parseConfig(configFile, resolve=true, forceRequire=false) {
function resolveOrCheckOnly(config) {
// Attempt to extract the default export (only on ES module) if the configuration
// object only contains that 'default' property, for clarity:
// export default { ... }
//
if (isObject(config) && Object.keys(config).length === 1 && 'default' in config) {
config = config.default; // Extract the default export
}
// Resolve the configuration object if `resolve` is set to true
if (resolve) config = resolveConfig({ config, file }); // Return {} if null
// Otherwise, only validate the configuration
else configChecker({ config, file });
return config;
}
if (!configFile || typeof configFile !== 'string') {
throw new InvalidTypeError('Expected a string path refers to a configuration file', {
actualType: getType(configFile),
expectedType: 'string'
});
}
const file = path.resolve(configFile); // Copy and resolve path
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}'`).join(' | ')
}`);
}
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()
if (['.config.cjs', '.json'].includes(ext) || forceRequire) {
config = require(configFile);
} else {
// On Windows, replace all '\' with '/' to use import()
if (process.platform === 'win32') {
configFile = 'file:///' + configFile.replace(/\\/g, '/');
}
config = import(configFile);
}
if (config instanceof Promise) {
// Return a Promise if the imported config module is a ES Module
return new Promise(function (resolve, reject) {
config
.then((result) => resolve(resolveOrCheckOnly(result)))
.catch((err) => reject(err));
});
}
return resolveOrCheckOnly(config);
}
/**
* An alias for {@link module:config~parseConfig `parseConfig`} function,
* with `resolve` parameter is set to `true`.
*
* @function
* @param {!string} file - A string path refers to configuration file to import and resolve.
* @returns {YTMP3Config | DownloadOptions | Promise<(YTMP3Config | DownloadOptions)>}
*
* @package
* @since 1.0.0
* @see {@link module:config~parseConfig parseConfig}
*/
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.
*
* @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<string | null>}
* A promise fullfills with a string representing the absolute path of the selected
* 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(searchDir = YTMP3_HOMEDIR) {
searchDir = (isNullOrUndefined(searchDir) || typeof searchDir !== 'string')
? YTMP3_HOMEDIR : searchDir;
const knownConfigExtsRegex = new RegExp(`${KNOWN_CONFIG_EXTS.join('$|')}$`);
// 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,
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(searchDir, 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(searchDir, 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(searchDir, 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<any>} 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,
ERR_FORMAT,
configChecker,
resolveConfig,
parseConfig,
importConfig,
findGlobalConfig,
parseGlobalConfig
});