/**
* Utilities module for **YTMP3** project.
*
* @module utils
* @author Ryuu Mitsuki <https://github.com/mitsuki31>
* @license MIT
* @since 1.0.0
*/
'use strict';
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
/**
* @typedef {Function} LoggerFunc
* @param {string} msg - The message to be displayed.
* @returns {void}
* @since 1.0.0
*/
/**
* The interface of {@link module:utils~logger `logger`} object.
*
* @typedef {Object} LoggerInterface
* @property {string} INFO_PREFIX - The prefix for the info level message.
* @property {string} DONE_PREFIX - The prefix for the done level message.
* @property {string} DEBUG_PREFIX - The prefix for the debug level message.
* @property {string} WARNING_PREFIX - The prefix for the warning level message.
* @property {string} ERROR_PREFIX - The prefix for the error level message.
* @property {LoggerFunc} info - The function to log the info level message to the console.
* @property {LoggerFunc} done - The function to log the done level message to the console.
* @property {LoggerFunc} debug - The function to log the debug level message to the console.
* @property {LoggerFunc} warn - The function to log the warning level message to the console.
* @property {LoggerFunc} error - The function to log the error level message to the console.
* @since 1.0.0
*/
/**
* Options object for configuring the progress bar.
*
* @typedef {Object} ProgressBarOptions
* @property {'auto' | number} [barWidth='auto'] - The width of the progress bar. Can be `'auto'`
* or a number representing the number of characters.
* @property {string} [barCharElapsed='#'] - The character used to represent the progress of the bar.
* @property {string} [barCharTotal='-'] - The character used to represent the total length of the bar.
* @property {boolean} [bytesInfo=true] - Whether to display the bytes downloaded information in MB.
* @since 1.0.0
*/
/**
* The resolved {@link module:utils~ProgressBarOptions ProgressBarOptions} options.
*
* @typedef {ProgressBarOptions} ResolvedProgressBarOptions
* @property {'auto' | number} barWidth - The width of the progress bar. Can be `'auto'`
* or a number representing the number of characters.
* @property {string} barCharElapsed - The character used to represent the progress of the bar.
* @property {string} barCharTotal - The character used to represent the total length of the bar.
* @property {boolean} bytesInfo - Whether to display the bytes downloaded information in MB.
* @since 1.0.0
*/
// region Constants
const FrozenProperty = {
writable: false,
configurable: false,
enumerable: true
};
/**
* 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/<USERNAME>/.ytmp3-js"`, while on Windows systems,
* it will be `"C:\Users\<USERNAME>\.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.
*
* Basically, the log directory is set to:
* - **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');
// region Utilities Function
/**
* Synchronously checks whether the specified directory path is exist,
* creates new if not exist with asynchronous operation.
*
* @param {string} dirpath - The directory path to be created if not exist.
* @returns {Promise<void>}
*
* @async
* @public
* @since 1.0.0
*/
async function createDirIfNotExist(dirpath) {
if (!fs.existsSync(dirpath)) await fs.promises.mkdir(dirpath, { recursive: true });
}
/**
* Similar with {@link module:utils~createDirIfNotExist `createDirIfNotExist`}
* function, but it uses synchronous directory creation.
*
* @public
* @since 1.0.1
*/
function createDirIfNotExistSync(dirpath) {
if (!fs.existsSync(dirpath)) fs.mkdirSync(dirpath, { recursive: true });
}
/**
* Creates the error log file with the specified prefix.
* If the prefix is not specified, it will use `'ytmp3Error'` as the prefix.
*
* @param {string} [prefix='ytmp3Error'] - The prefix of the error log file.
*
* @return {string} The created error log file.
* @private
* @since 1.0.0
*/
function createLogFile(prefix) {
return `${prefix || 'ytmp3Error'}-${
(new Date()).toISOString().split('.')[0].replace(/:/g, '.')}.log`;
}
// region Type Checker
/**
* Checks if a given value is null or undefined.
*
* @param {any} x - The value to check.
* @returns {boolean} - `true` if the value is null or undefined, otherwise `false`.
*
* @public
* @since 1.0.0
*/
function isNullOrUndefined(x) {
return (x === null || typeof x === 'undefined');
}
/**
* Determines whether the provided value is a non-null object.
*
* 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 be checked.
* @returns {boolean} `true` if the value is a non-null object, otherwise `false`.
*
* @package
* @since 1.0.0
* @see {@link module:utils~isPlainObject isPlainObject}
*/
function isObject(x) {
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
* @public
* @since 1.0.0
*/
/**
* A custom logger object for the **YTMP3** project with ANSI color codes.
*
* The logger is using the ANSI color codes to add color to the log messages,
* it might not support on every terminals.
*
* @constant
* @type {module:utils~Logger}
* @public
* @since 1.0.0
* @see {@link module:utils~Logger Logger (namespace)}
* @see {@link module:utils~LoggerInterface LoggerInterface}
*/
const logger = Object.create(null);
Object.defineProperties(logger, {
/**
* The prefix for the info level message.
* @memberof module:utils~Logger
* @var {string}
* @default <pre class=str>'\x1b[96m[INFO]\x1b[0m'</pre>
*/
INFO_PREFIX: { value: '\x1b[96m[INFO]\x1b[0m', ...FrozenProperty },
/**
* The prefix for the done level message.
* @memberof module:utils~Logger
* @var {string}
* @default <pre class=str>'\x1b[92m[DONE]\x1b[0m'</pre>
*/
DONE_PREFIX: { value: '\x1b[92m[DONE]\x1b[0m', ...FrozenProperty },
/**
* The prefix for the debug level message.
* @memberof module:utils~Logger
* @var {string}
* @default <pre class=str>'\x1b[2;37m[DEBUG]\x1b[0m'</pre>
*/
DEBUG_PREFIX: { value: '\x1b[2;37m[DEBUG]\x1b[0m', ...FrozenProperty },
/**
* The prefix for the warning level message.
* @memberof module:utils~Logger
* @var {string}
* @default <pre class=str>'\x1b[93m[WARNING]\x1b[0m'</pre>
*/
WARNING_PREFIX: { value: '\x1b[93m[WARNING]\x1b[0m', ...FrozenProperty },
/**
* The prefix for the error level message.
* @memberof module:utils~Logger
* @var {string}
* @default <pre class=str>'\x1b[91m[ERROR]\x1b[0m'</pre>
*/
ERROR_PREFIX: { value: '\x1b[91m[ERROR]\x1b[0m', ...FrozenProperty },
/**
* The function to log the info level message to the console.
* @memberof module:utils~Logger
* @var {LoggerFunc}
* @function
*/
info: {
value: function (msg) {
console.log(`${this.INFO_PREFIX} ${msg}`);
},
...FrozenProperty
},
/**
* The function to log the done level message to the console.
* @memberof module:utils~Logger
* @function
* @param {string} msg - The message string to be displayed.
* @returns {void}
*/
done: {
value: function (msg) {
console.log(`${this.DONE_PREFIX} ${msg}`);
},
...FrozenProperty
},
/**
* The function to log the debug level message to the console.
* @memberof module:utils~Logger
* @function
* @param {string} msg - The message string to be displayed.
* @returns {void}
*/
debug: {
value: function (msg) {
console.log(`${this.DEBUG_PREFIX} ${msg}`);
},
...FrozenProperty
},
/**
* The function to log the warning level message to the console.
* @memberof module:utils~Logger
* @function
* @param {string} msg - The message string to be displayed.
* @returns {void}
*/
warn: {
value: function (msg) {
console.error(`${this.WARNING_PREFIX} ${msg}`);
},
...FrozenProperty
},
/**
* The function to log the error level message to the console.
* @memberof module:utils~Logger
* @function
* @param {string} msg - The message string to be displayed.
* @returns {void}
*/
error: {
value: function (msg) {
console.error(`${this.ERROR_PREFIX} ${msg}`);
},
...FrozenProperty
}
});
/**
* Drops null and undefined values from the input object.
*
* @param {Object} obj - The input object to filter null and undefined values from.
* @return {Object} The filtered object without null and undefined values.
*
* @public
* @since 1.0.0
*/
function dropNullAndUndefined(obj) {
return Object.keys(obj).reduce((acc, key) => {
if (!isNullOrUndefined(obj[key])) acc[key] = obj[key];
return acc;
}, {});
}
// region Utilities Class
class ProgressBar {
/**
* Initialize the `ProgressBar` class with specific options to configure the
* progress bar.
*
* @classdesc Class representing a progress bar for downloads.
*
* @class
* @param {ProgressBarOptions} options
* Options object for configuring the progress bar.
* @since 1.0.0
*/
constructor (options) {
/**
* Options object for configuring the progress bar.
* @var {ResolvedProgressBarOptions}
*/
this.options = {
barWidth: (typeof options?.barWidth === 'string'
|| typeof options?.barWidth === 'number')
? options.barWidth : 'auto',
barCharElapsed: (typeof options?.barCharElapsed === 'string')
? options.barCharElapsed
: '#',
barCharTotal: (typeof options?.barCharTotal === 'string')
? options.barCharTotal
: '-',
bytesInfo: (typeof options?.bytesInfo === 'boolean')
? options.bytesInfo === true
: true
};
/**
* Index for the loading animation.
* @var {number}
*/
this.idxLoading = 0;
/**
* Characters for the loading animation.
* @var {'\\' | '|' | '/' | '-'}
* @default
*/
this.loadings = [ '\\', '|', '/', '-' ];
}
/**
* Creates a string formatted download progress bar with centered percentage.
*
* @method
* @param {number} bytesDownloaded - The number of bytes downloaded so far.
* @param {number} totalBytes - The total number of bytes to download.
*
* @returns {string} The formatted progress bar string with percentage and byte information.
*
* @since 1.0.0
*/
create(bytesDownloaded, totalBytes) {
const {
barCharElapsed,
barCharTotal
} = this.options;
let barWidth = parseBarWidth(this.options.barWidth);
barWidth = Math.round(barWidth / ((barWidth > 60) ? 1.85 : 2.25));
this.idxLoading = (this.idxLoading >= this.loadings.length)
? 0
: this.idxLoading;
const loading = this.loadings[this.idxLoading++];
/**
* Parses the bar width option.
*
* @private
* @param {string | number} val - The bar width value to parse.
* @returns {number} The parsed bar width as a number.
*/
function parseBarWidth(val) {
return (!val || (typeof val === 'string' && val === 'auto'))
? ('columns' in process.stdout)
? Math.min(100, process.stdout.columns)
: 40
: val;
}
// Calculate the progress percentage
const progress = Math.max(0, Math.min(100, Math.floor(
bytesDownloaded / totalBytes * 100
)));
const progressStr = ` [${progress}%] `;
// Calculate the number of characters to fill in the progress bar
const progressBarWidth = Math.floor(barWidth * (progress / 100));
// Calculate where to start the progress bar and the percentage text
const progressBarStart = Math.max(0, progressBarWidth);
const percentagePos = Math.max(0, Math.min(barWidth - 4,
Math.floor((barWidth - 4) / 2)));
// Create the progress bar string with centered percentage
let progressBar = `[${barCharElapsed.repeat(progressBarStart)}${
barCharTotal.repeat(barWidth - progressBarWidth)
}]`;
progressBar = progressBar.substring(0, percentagePos) +
((progress < 100) ? '\x1b[0;96m' : '\x1b[0;92m') +
`${progressStr}\x1b[0m\x1b[1m` +
progressBar.substring(percentagePos + progressStr.length);
// Calculate the number of bytes downloaded in MB
const byteInfo = `[${
(bytesDownloaded / (1024 * 1024)).toFixed(2)
}/${(totalBytes / (1024 * 1024)).toFixed(2)} MB]`;
// Return the formatted progress bar with percentage
return (progress < 100)
// eslint-disable-next-line max-len
? `\x1b[K\x1b[1;93m[...]\x1b[0m \x1b[1m${progressBar} \x1b[0;93m[${loading}]\x1b[0m \x1b[1;95m${byteInfo}\x1b[0m\r`
// eslint-disable-next-line max-len
: `\x1b[K\x1b[1;92m[DONE]\x1b[0m \x1b[1m${progressBar} \x1b[0;92m[\u2714]\x1b[0m \x1b[1;95m${byteInfo}\x1b[0m\n`;
}
}
module.exports = Object.freeze({
ROOTDIR, OUTDIR, LOGDIR, YTMP3_HOMEDIR,
logger,
log: logger, // alias for `logger`
isNullOrUndefined,
isObject,
isPlainObject,
createDirIfNotExist,
createDirIfNotExistSync,
createLogFile,
dropNullAndUndefined,
getType,
ProgressBar
});