api.js

import { FormData, Client, buildConnector, request } from 'undici';
import { Cookie, CookieJar } from 'tough-cookie';
import { filesize } from 'filesize';

import child_process from 'node:child_process';
import crypto from 'node:crypto';
import tls from 'node:tls';

/**
 * Main module for api interacting with TeraBox
 * @module api
 */

/**
 * Constructs a remote file path by combining a directory and filename, ensuring proper slash formatting
 * @param {string} sdir - The directory path (with or without trailing slash)
 * @param {string} sfile - The filename to append to the directory path
 * @returns {string} The combined full path with exactly one slash between directory and filename
 * @example
 * makeRemoteFPath('documents', 'file.txt')    // returns 'documents/file.txt'
 * makeRemoteFPath('documents/', 'file.txt')   // returns 'documents/file.txt'
 * @ignore
 */
function makeRemoteFPath(sdir, sfile){
    const tdir = sdir.match(/\/$/) ? sdir : sdir + '/';
    return tdir + sfile;
}

/**
 * A utility class for handling application/x-www-form-urlencoded data
 * Wraps URLSearchParams with additional convenience methods and encoding behavior
 * @class
 */
class FormUrlEncoded {
    /**
     * Creates a new FormUrlEncoded instance
     * @param {Object.<string, string>} [params] - Optional initial parameters as key-value pairs
     * @example
     * const form = new FormUrlEncoded({ foo: 'bar', baz: 'qux' });
     */
    constructor(params) {
        this.data = new URLSearchParams();
        if(typeof params === 'object' && params !== null){
            for (const [key, value] of Object.entries(params)) {
                this.data.append(key, value);
            }
        }
    }
    /**
     * Sets or replaces a parameter value
     * @param {string} param - The parameter name
     * @param {string} value - The parameter value
     * @returns {void}
     */
    set(param, value){
        this.data.set(param, value);
    }
    /**
     * Appends a new value to an existing parameter
     * @param {string} param - The parameter name
     * @param {string} value - The parameter value
     * @returns {void}
     */
    append(param, value){
        this.data.append(param, value);
    }
    /**
     * Removes a parameter
     * @param {string} param - The parameter name to remove
     * @returns {void}
     */
    delete(param){
        this.data.delete(param);
    }
    /**
     * Returns the encoded string representation (space encoded as %20)
     * Suitable for application/x-www-form-urlencoded content
     * @returns {string} The encoded form data
     * @example
     * form.str(); // returns "foo=bar&baz=qux"
     */
    str(){
        return this.data.toString().replace(/\+/g, '%20');
    }
    /**
     * Returns the underlying URLSearchParams object
     * @returns {URLSearchParams} The native URLSearchParams instance
     */
    url(){
        return this.data;
    }
}

/**
 * Generates a signed download token using a modified RC4-like algorithm
 *
 * This function implements a stream cipher similar to RC4 that:
 * <br>1. Initializes a permutation array using the secret key (s1)
 * <br>2. Generates a pseudorandom keystream
 * <br>3. XORs the input data (s2) with the keystream
 * <br>4. Returns the result as a Base64-encoded string
 *
 * @param {string} s1 - The secret key used for signing (should be at least 1 character)
 * @param {string} s2 - The input data to be signed
 * @returns {string} Base64-encoded signature
 * @example
 * const signature = signDownload('secret-key', 'data-to-sign');
 * // Returns something like: "X3p8YFJjUA=="
 */
function signDownload(s1, s2) {
    // Initialize permutation array (p) and key array (a)
    const p = new Uint8Array(256);
    const a = new Uint8Array(256);
    const result = [];
    
    // Key-scheduling algorithm (KSA)
    // Initialize the permutation array with the secret key
    Array.from({ length: 256 }, (_, i) => {
        a[i] = s1.charCodeAt(i % s1.length);
        p[i] = i;
    });
    
    // Scramble the permutation array using the key
    let j = 0;
    Array.from({ length: 256 }, (_, i) => {
        j = (j + p[i] + a[i]) % 256;
        [p[i], p[j]] = [p[j], p[i]]; // swap
    });
    
    // Pseudo-random generation algorithm (PRGA)
    // Generate keystream and XOR with input data
    let i = 0; j = 0;
    Array.from({ length: s2.length }, (_, q) => {
        i = (i + 1) % 256;
        j = (j + p[i]) % 256;
        [p[i], p[j]] = [p[j], p[i]]; // swap
        const k = p[(p[i] + p[j]) % 256];
        result.push(s2.charCodeAt(q) ^ k);
    });
    
    // Return the result as Base64
    return Buffer.from(result).toString('base64');
}

/**
 * Validates whether a string is a properly formatted MD5 hash
 * <br>
 * <br>Checks if the input:
 * <br>1. Is exactly 32 characters long
 * <br>2. Contains only hexadecimal characters (a-f, 0-9)
 * <br>3. Is in lowercase
 * <br>
 * <br>Note: This only validates the format, not the cryptographic correctness of the hash.
 *
 * @param {*} md5 - The value to check (typically a string)
 * @returns {boolean} True if the input is a valid MD5 format, false otherwise
 * @example
 * checkMd5val('d41d8cd98f00b204e9800998ecf8427e') // returns true
 * checkMd5val('D41D8CD98F00B204E9800998ECF8427E') // returns false (uppercase)
 * checkMd5val('z41d8cd98f00b204e9800998ecf8427e') // returns false (invalid character)
 * checkMd5val('d41d8cd98f')                       // returns false (too short)
 */
function checkMd5val(md5){
    if(typeof md5 !== 'string') return false;
    return /^[a-f0-9]{32}$/.test(md5);
}

/**
 * Validates that all elements in an array are properly formatted MD5 hashes
 * <br>
 * <br>Checks if:
 * <br>1. The input is an array
 * <br>2. Every element in the array passes checkMd5val() validation
 * <br>(32-character hexadecimal strings in lowercase)
 *
 * @param {*} arr - The array to validate
 * @returns {boolean} True if all elements are valid MD5 hashes, false otherwise
 *                   (also returns false if input is not an array)
 * @see {@link module:api~checkMd5val|Function CheckMd5Val} for individual MD5 hash validation logic
 *
 * @example
 * checkMd5arr(['d41d8cd98f00b204e9800998ecf8427e', '5d41402abc4b2a76b9719d911017c592']) // true
 * checkMd5arr(['d41d8cd98f00b204e9800998ecf8427e', 'invalid']) // false
 * checkMd5arr('not an array') // false
 * checkMd5arr([]) // false (empty array is considered invalid)
 */
function checkMd5arr(arr) {
    if (!Array.isArray(arr)) return false;
    if (arr.length === 0) return false;
    return arr.every(item => {
        return checkMd5val(item);
    });
}

/**
 * Applies a custom transformation to what appears to be an MD5 hash
 * <br>
 * <br>This function performs a series of reversible transformations on an input string
 * <br>that appears to be an MD5 hash (32 hexadecimal characters). The transformation includes:
 * <br>1. Character restoration at position 9
 * <br>2. XOR operation with position-dependent values
 * <br>3. Byte reordering of the result
 *
 * @param {string} md5 - The input string (expected to be 32 hexadecimal characters)
 * @returns {string} The transformed result (32 hexadecimal characters)
 * @throws Will return the original input unchanged if length is not 32
 *
 * @example
 * decodeMd5('a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6') // returns transformed value
 * decodeMd5('short') // returns 'short' (unchanged)
 */
function decodeMd5(md5) {
    // Return unchanged if not 32 characters
    if (md5.length !== 32) return md5;
    
    // Restore character at position 9
    const restoredHexChar = (md5.charCodeAt(9) - 'g'.charCodeAt(0)).toString(16);
    const o = md5.slice(0, 9) + restoredHexChar + md5.slice(10);
    
    // Apply XOR transformation to each character
    let n = '';
    for (let i = 0; i < o.length; i++) {
        const orig = parseInt(o[i], 16) ^ (i & 15);
        n += orig.toString(16);
    }
    
    // Reorder the bytes in the result
    const e =
        n.slice(8, 16) +  // original bytes 8-15 (now first)
        n.slice(0, 8) +   // original bytes 0-7 (now second)
        n.slice(24, 32) + // original bytes 24-31 (now third)
        n.slice(16, 24);   // original bytes 16-23 (now last)
    
    return e;
}

/**
 * Converts between standard and URL-safe Base64 encoding formats
 * <br>
 * <br>Base64 strings may contain '+', '/' and '=' characters that need to be replaced
 * <br>for safe use in URLs. This function provides bidirectional conversion:
 * <br>- Mode 1: Converts to URL-safe Base64 (RFC 4648 §5)
 * <br>- Mode 2: Converts back to standard Base64
 *
 * @param {string} str - The Base64 string to convert
 * @param {number} [mode=1] - Conversion direction:
 *                            1 = to URL-safe (default),
 *                            2 = to standard
 * @returns {string} The converted Base64 string
 *
 * @example
 * // To URL-safe Base64
 * changeBase64Type('a+b/c=') // returns 'a-b_c='
 *
 * // To standard Base64
 * changeBase64Type('a-b_c=', 2) // returns 'a+b/c='
 *
 * @see {@link https://tools.ietf.org/html/rfc4648#section-5|RFC 4648 §5} for URL-safe Base64
 */
function changeBase64Type(str, mode = 1) {
    return mode === 1
        ? str.replace(/\+/g, '-').replace(/\//g, '_')  // to url-safe
        : str.replace(/-/g,  '+').replace(/_/g,  '/'); // to standard
}

/**
 * Decrypts AES-128-CBC encrypted data using provided parameters
 * <br>
 * <br>This function:
 * <br>1. Converts both parameters from URL-safe Base64 to standard Base64
 * <br>2. Extracts the IV (first 16 bytes) and ciphertext from pp1
 * <br>3. Uses pp2 as the decryption key
 * <br>4. Performs AES-128-CBC decryption
 *
 * @param {string} pp1 - Combined IV and ciphertext in URL-safe Base64 format:
 *                      First 16 bytes are IV, remainder is ciphertext
 * @param {string} pp2 - Encryption key in URL-safe Base64 format
 * @returns {string} The decrypted UTF-8 string
 * @throws {Error} May throw errors for invalid inputs or decryption failures
 *
 * @example
 * // Example usage (with actual encrypted data)
 * const decrypted = decryptAES(
 *     'MTIzNDU2Nzg5MDEyMzQ1Ng==...',  // IV + ciphertext
 *     'c2VjcmV0LWtleS1kYXRhCg=='      // Key
 * );
 *
 * @requires crypto Node.js crypto module
 * @see {@link module:api~changeBase64Type|Function ChangeBase64Type} for Base64 format conversion
 */
function decryptAES(pp1, pp2) {
    // Convert from URL-safe Base64 to standard Base64
    pp1 = changeBase64Type(pp1, 2);
    pp2 = changeBase64Type(pp2, 2);
    
    // Extract ciphertext (after first 16 bytes) and IV (first 16 bytes)
    const cipherText = pp1.substring(16);
    const key = Buffer.from(pp2, 'utf8');
    const iv = Buffer.from(pp1.substring(0, 16), 'utf8');
    
    // Create decipher with AES-128-CBC algorithm
    const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
    
    // Perform decryption
    let decrypted = decipher.update(cipherText, 'base64', 'utf8');
    decrypted += decipher.final('utf8');
    
    return decrypted;
}

/**
 * Encrypts data using RSA with a public key, with optional MD5 preprocessing
 * <br>
 * <br>Supports two encryption modes:
 * <br>1. Direct encryption of the message (default)
 * <br>2. MD5 hash preprocessing (applies MD5 + length padding before encryption)
 *
 * @param {string} message - The plaintext message to encrypt
 * @param {string|Buffer} publicKeyPEM - RSA public key in PEM format
 * @param {number} [mode=1] - Encryption mode:
 *                            1 = direct encryption,
 *                            2 = MD5 hash preprocessing
 * @returns {string} Base64-encoded encrypted data
 * @throws {Error} May throw errors for invalid keys or encryption failures
 *
 * @example
 * // Direct encryption
 * encryptRSA('secret message', publicKey);
 *
 * // With MD5 preprocessing
 * encryptRSA('secret message', publicKey, 2);
 *
 * @requires crypto Node.js crypto module
 */
function encryptRSA(message, publicKeyPEM, mode = 1) {
    // Mode 2: Apply MD5 hash and length padding
    if (mode === 2) {
        const md5 = crypto.createHash('md5').update(message).digest('hex');
        message = md5 + (md5.length<10?'0':'') + md5.length;
    }
    
    // Convert message to Buffer
    const buffer = Buffer.from(message, 'utf8');
    
    // Perform RSA encryption
    const encrypted = crypto.publicEncrypt({
            key: publicKeyPEM,
            padding: crypto.constants.RSA_PKCS1_PADDING,
        },
        buffer,
    );
    
    // Return as Base64 string
    return encrypted.toString('base64');
}

/**
 * Generates a pseudo-random SHA-1 hash from combined client parameters
 * <br>
 * <br>Creates a deterministic hash value by combining multiple client-specific parameters.
 * <br>This is typically used for generating session tokens or unique identifiers.
 *
 * @param {string} [client='web'] - Client identifier (e.g., 'web', 'mobile')
 * @param {string} seval - Session evaluation parameter
 * @param {string} encpwd - Encrypted password or password hash
 * @param {string} email - User's email address
 * @param {string} [browserid=''] - Browser fingerprint or identifier
 * @param {string} random - Random value
 * @returns {string} SHA-1 hash of the combined parameters (40-character hex string)
 *
 * @example
 * // Basic usage
 * const token = prandGen('web', 'session123', 'encryptedPwd', 'user@example.com', 'browser123', 'randomValue');
 *
 * // With default client and empty browserid
 * const token = prandGen(undefined, 'session123', 'encryptedPwd', 'user@example.com', '', 'randomValue');
 *
 * @requires crypto Node.js crypto module
 */
function prandGen(client = 'web', seval, encpwd, email, browserid = '', random) {
    // Combine all parameters with hyphens
    const combined = `${client}-${seval}-${encpwd}-${email}-${browserid}-${random}`;
    
    // Generate SHA-1 hash and return as hex string
    return crypto.createHash('sha1').update(combined).digest('hex');
}

/**
 * TeraBoxApp API client class
 *
 * Provides a comprehensive interface for interacting with TeraBox services,
 * including encryption utilities, API request handling, and session management.
 *
 * @class
 * @property {module:api~FormUrlEncoded   } FormUrlEncoded - Form URL encoding utility
 * @property {module:api~signDownload     } SignDownload - Download signature generator
 * @property {module:api~checkMd5val      } CheckMd5Val - MD5 hash validator (single)
 * @property {module:api~checkMd5arr      } CheckMd5Arr - MD5 hash validator (array)
 * @property {module:api~decodeMd5        } DecodeMd5 - Custom MD5 transformation
 * @property {module:api~changeBase64Type } ChangeBase64Type - Base64 format converter
 * @property {module:api~decryptAES       } DecryptAES - AES decryption utility
 * @property {module:api~encryptRSA       } EncryptRSA - RSA encryption utility
 * @property {module:api~prandGen         } PRandGen - Pseudo-random hash generator
 *
 * @property {string} TERABOX_DOMAIN - Default TeraBox domain
 * @property {number} TERABOX_TIMEOUT - Default API timeout (10 seconds)
 *
 * @property {Object} data - Application data including tokens and keys
 * @property {string} data.csrf - CSRF token
 * @property {string} data.logid - Log ID
 * @property {string} data.pcftoken - PCF token
 * @property {string} data.bdstoken - BDS token
 * @property {string} data.jsToken - JavaScript token
 * @property {string} data.pubkey - Public key
 *
 * @property {TeraBoxAppParams} params - Application parameters and configuration
 */
class TeraBoxApp {
    // Encryption/Utility Methods 1
    FormUrlEncoded = FormUrlEncoded;
    SignDownload = signDownload;
    CheckMd5Val = checkMd5val;
    CheckMd5Arr = checkMd5arr;
    DecodeMd5 = decodeMd5;
    
    // Encryption/Utility Methods 2
    ChangeBase64Type = changeBase64Type;
    DecryptAES = decryptAES;
    EncryptRSA = encryptRSA;
    PRandGen = prandGen;
    
    // Constants
    TERABOX_DOMAIN = 'terabox.com';
    TERABOX_TIMEOUT = 10000;
    
    // app data
    data = {
        csrf: '',
        logid: '0',
        pcftoken: '',
        bdstoken: '',
        jsToken: '',
        pubkey: '',
    };
    
    // Application parameters and configuration
    params = {
        whost: 'https://jp.' + this.TERABOX_DOMAIN,
        uhost: 'https://c-jp.' + this.TERABOX_DOMAIN,
        lang: 'en',
        app: {
            app_id: 250528,
            web: 1,
            channel: 'dubox',
            clienttype: 0, // 5 is wap?
        },
        ver_android: '3.44.2',
        ua: 'terabox;1.40.0.132;PC;PC-Windows;10.0.26100;WindowsTeraBox',
        cookie: '',
        auth: {},
        account_id: 0,
        account_name: '',
        is_vip: false,
        vip_type: 0,
        space_used: 0,
        space_total: Math.pow(1024, 3),
        space_available: Math.pow(1024, 3),
        cursor: 'null',
    };
    
    /**
     * Creates a new TeraBoxApp instance
     * @param {string} authData - Authentication data (NDUS token)
     * @param {string} [authType='ndus'] - Authentication type (currently only 'ndus' supported)
     * @throws {Error} Throws error if authType is not supported
     */
    constructor(authData, authType = 'ndus') {
        this.params.cookie = `lang=${this.params.lang}`;
        if(authType === 'ndus'){
            this.params.cookie += authData ? '; ndus=' + authData : '';
        }
        else{
            throw new Error('initTBApp', { cause: 'AuthType Not Supported!' });
        }
    }
    
    /**
     * Updates application data including tokens and user information
     * @param {string} [customPath] - Custom path to use for the update request
     * @param {number} [retries=4] - Number of retry attempts
     * @returns {Promise<Object>} The updated template data
     * @async
     * @throws {Error} Throws error if request fails or parsing fails
     */
    async updateAppData(customPath, retries = 4){
        const url = new URL(this.params.whost + (customPath ? `/${customPath}` : '/main'));
        
        try{
            const req = await request(url, {
                headers:{
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT + 10000),
            });
            
            if(req.statusCode === 302){
                const newUrl = new URL(req.headers.location);
                if(this.params.whost !== newUrl.origin){
                    this.params.whost = newUrl.origin;
                    console.warn(`[WARN] Default hostname changed to ${newUrl.origin}`);
                }
                const toPathname = newUrl.pathname.replace(/^\//, '');
                const finalUrl = toPathname + newUrl.search;
                return await this.updateAppData(finalUrl, retries);
            }
            
            if(req.headers['set-cookie']){
                const cJar = new CookieJar();
                this.params.cookie.split(';').map(cookie => cJar.setCookieSync(cookie, this.params.whost));
                if(typeof req.headers['set-cookie'] === 'string'){
                    req.headers['set-cookie'] = [req.headers['set-cookie']];
                }
                for(const cookie of req.headers['set-cookie']){
                    cJar.setCookieSync(cookie.split('; ')[0], this.params.whost);
                }
                this.params.cookie = cJar.getCookiesSync(this.params.whost).map(cookie => cookie.cookieString()).join('; ');
            }
            
            const rdata = await req.body.text();
            const tdataRegex = /<script>var templateData = (.*);<\/script>/;
            const jsTokenRegex = /window.jsToken%20%3D%20a%7D%3Bfn%28%22(.*)%22%29/;
            const tdata = rdata.match(tdataRegex) ? JSON.parse(rdata.match(tdataRegex)[1].split(';</script>')[0]) : {};
            const isLoginReq = req.headers.location === '/login' ? true : false;
            
            if(tdata.jsToken){
                tdata.jsToken = tdata.jsToken.match(/%28%22(.*)%22%29/)[1];
            }
            else if(rdata.match(jsTokenRegex)){
                tdata.jsToken = rdata.match(jsTokenRegex)[1];
            }
            else if(isLoginReq){
                console.error('[ERROR] Failed to update jsToken [Login Required]');
            }
            
            if(req.headers.logid){
                this.data.logid = req.headers.logid;
            }
            
            this.data.csrf = tdata.csrf || '';
            this.data.pcftoken = tdata.pcftoken || '';
            this.data.bdstoken = tdata.bdstoken || '';
            this.data.jsToken = tdata.jsToken || '';
            
            this.params.account_id = parseInt(tdata.uk) || 0;
            if(typeof tdata.userVipIdentity === 'number' && tdata.userVipIdentity > 0){
                this.params.is_vip = true;
                this.params.vip_type = 1;
            }
            
            return tdata;
        }
        catch(error){
            if(error.name === 'TimeoutError' && retries > 0){
                await new Promise(resolve => setTimeout(resolve, 500));
                return await this.updateAppData(customPath, retries - 1);
            }
            const errorPrefix = '[ERROR] Failed to update jsToken:';
            if(error.name === 'TimeoutError'){
                console.error(errorPrefix, error.message);
                return;
            }
            error = new Error('updateAppData', { cause: error });
            console.error(errorPrefix, error);
        }
    }
    
    /**
     * Sets default VIP parameters
     * @returns {void}
     */
    setVipDefaults(){
        this.params.is_vip = true;
        this.params.vip_type = 1; // 1: VIP, 2: SVIP
        this.params.space_total = Math.pow(1024, 3) * 2;
        this.params.space_available = Math.pow(1024, 3) * 2;
    }
    
    /**
     * Makes an API request with retry logic
     * @param {string} req_url - The request URL (relative to whost)
     * @param {Object} [req_options={}] - Request options (headers, body, etc.)
     * @param {number} [retries=4] - Number of retry attempts
     * @returns {Promise<Object>} The JSON-parsed response data
     * @async
     * @throws {Error} Throws error if all retries fail
     */
    async doReq(req_url, req_options = {}, retries = 4){
        const url = new URL(this.params.whost + req_url);
        let reqm_options = structuredClone(req_options);
        let req_headers = {};
        
        if(reqm_options.headers){
            req_headers = reqm_options.headers;
            delete reqm_options.headers;
        }
        
        const save_cookies = reqm_options.save_cookies;
        delete reqm_options.save_cookies;
        const silent_retry = reqm_options.silent_retry;
        delete reqm_options.silent_retry;
        const req_timeout = reqm_options.timeout ? reqm_options.timeout : this.TERABOX_TIMEOUT;
        delete reqm_options.timeout;
        
        try {
            const options = {
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                    ...req_headers,
                },
                ...reqm_options,
                signal: AbortSignal.timeout(req_timeout),
            };
            
            const req = await request(url, options);
            
            if(save_cookies && req.headers['set-cookie']){
                const cJar = new CookieJar();
                this.params.cookie.split(';').map(cookie => cJar.setCookieSync(cookie, this.params.whost));
                if(typeof req.headers['set-cookie'] === 'string'){
                    req.headers['set-cookie'] = [req.headers['set-cookie']];
                }
                for(const cookie of req.headers['set-cookie']){
                   cJar.setCookieSync(cookie.split('; ')[0], this.params.whost);
                }
                this.params.cookie = cJar.getCookiesSync(this.params.whost).map(cookie => cookie.cookieString()).join('; ');
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch(error){
            if (retries > 0) {
                await new Promise(resolve => setTimeout(resolve, 500));
                if(!silent_retry){
                    console.error('[ERROR] DoReq:', req_url, '|', error.code, ':', error.message, '(retrying...)');
                }
                return await this.doReq(req_url, req_options, retries - 1);
            }
            throw new Error('doReq', { cause: error });
        }
    }
    
    /**
     * Retrieves system configuration from the TeraBox API
     * @returns {Promise<Object>} The system configuration JSON data
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async getSysCfg(){
        const url = new URL(this.params.whost + '/api/getsyscfg');
        url.search = new URLSearchParams({
            clienttype: this.params.app.clienttype,
            language_type: this.params.lang,
            cfg_category_keys: '[]',
            version: 0,
        });
        
        try{
            const req = await request(url, {
                headers: {
                    'User-Agent': this.params.ua,
                    // 'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch(error){
            throw new Error('getSysCfg', { cause: error });
        }
    }
    
    /**
     * Checks login status of the current session.
     * @returns {Promise<CheckLoginResponse>} The login status JSON data.
     * @throws {Error} Throws error if HTTP status is not 200 or request fails.
     * @async
     */
    async checkLogin(){
        const url = new URL(this.params.whost + '/api/check/login');
        
        try{
            const req = await request(url, {
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const regionPrefix = req.headers['region-domain-prefix'];
            if(regionPrefix){
                const newHostname = `https://${regionPrefix}.${this.TERABOX_DOMAIN}`;
                console.warn(`[WARN] Default hostname changed to ${newHostname}`);
                this.params.whost = new URL(newHostname).origin;
                return await this.checkLogin();
            }
            
            const rdata = await req.body.json();
            if(rdata.errno === 0){
                this.params.account_id = rdata.uk;
            }
            return rdata;
        }
        catch(error){
            throw new Error('checkLogin', { cause: error });
        }
    }
    
    /**
     * Initiates the pre-login step for passport authentication
     * @param {string} email - The user's email address
     * @returns {Promise<Object>} The pre-login data JSON (includes seval, random, timestamp)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async passportPreLogin(email){
        const url = new URL(this.params.whost + '/passport/prelogin');
        const authUrl = 'wap/outlogin/login';
        
        try{
            if(this.data.pcftoken === ''){
                await this.updateAppData(authUrl);
            }
            
            const formData = new this.FormUrlEncoded();
            formData.append('client', 'web');
            formData.append('pass_version', '2.8');
            formData.append('clientfrom', 'h5');
            formData.append('pcftoken', this.data.pcftoken);
            formData.append('email', email);
            
            const req = await request(url, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                    Referer: this.params.whost,
                },
                body: formData.str(),
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('passportPreLogin', { cause: error });
        }
    }
    
    /**
     * Completes the passport login process using preLoginData and password
     * @param {Object} preLoginData - Data returned from passportPreLogin
     * @param {string} preLoginData.seval - The seval value from pre-login.
     * @param {string} preLoginData.random - The random value from pre-login.
     * @param {number} preLoginData.timestamp - The timestamp from pre-login.
     * @param {string} email - The user's email address
     * @param {string} pass - The user's plaintext password
     * @returns {Promise<Object>} The login response JSON (includes ndus token on success)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async passportLogin(preLoginData, email, pass){
        const url = new URL(this.params.whost + '/passport/login');
        
        try{
            if(this.data.pubkey === ''){
                await this.getPublicKey();
            }
            
            const cJar = new CookieJar();
            this.params.cookie.split(';').map(cookie => cJar.setCookieSync(cookie, this.params.whost));
            const browserid = cJar.toJSON().cookies.find(c => c.key === 'browserid').value || '';
            const encpwd = this.ChangeBase64Type(this.EncryptRSA(pass, this.data.pubkey, 2));
            
            const prand = this.PRandGen('web', preLoginData.seval, encpwd, email, browserid, preLoginData.random);
            
            const formData = new this.FormUrlEncoded();
            formData.append('client', 'web');
            formData.append('pass_version', '2.8');
            formData.append('clientfrom', 'h5');
            formData.append('pcftoken', this.data.pcftoken);
            formData.append('prand', prand);
            formData.append('email', email);
            formData.append('pwd', encpwd);
            formData.append('seval', preLoginData.seval);
            formData.append('random', preLoginData.random);
            formData.append('timestamp', preLoginData.timestamp);
            
            const req = await request(url, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                    Referer: this.params.whost,
                },
                body: formData.str(),
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            if(rdata.code === 0){
                if(typeof req.headers['set-cookie'] === 'string'){
                    req.headers['set-cookie'] = [req.headers['set-cookie']];
                }
                for(const cookie of req.headers['set-cookie']){
                   cJar.setCookieSync(cookie.split('; ')[0], this.params.whost);
                }
                const ndus = cJar.toJSON().cookies.find(c => c.key === 'ndus').value;
                rdata.data.ndus = ndus;
            }
            return rdata;
        }
        catch (error) {
            throw new Error('passportLogin', { cause: error });
        }
    }
    
    /**
     * Sends a registration code to the specified email
     * @param {string} email - The email address to send the code to
     * @returns {Promise<Object>} The send code response JSON (includes code and message)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async regSendCode(email){
        const url = new URL(this.params.whost + '/passport/register_v4/sendcode');
        const emailRegUrl = 'wap/outlogin/emailRegister';
        
        try{
            if(this.data.pcftoken === ''){
                await this.updateAppData(emailRegUrl);
            }
            
            const formData = new this.FormUrlEncoded();
            formData.append('client', 'web');
            formData.append('pass_version', '2.8');
            formData.append('clientfrom', 'h5');
            formData.append('pcftoken', this.data.pcftoken);
            formData.append('email', email);
            
            const req = await request(url, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                    Referer: this.params.whost,
                },
                body: formData.str(),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            // rdata.code: 0 - OK
            // rdata.code: 10 - Email format invalid
            // rdata.code: 11 - Email has been register before
            // rdata.code: 60 - Send code too fast, wait ~60sec
            return rdata;
        }
        catch (error) {
            throw new Error('regSendCode', { cause: error });
        }
    }
    
    /**
     * Verifies the registration code received via email
     * @param {string} regToken - Registration token from send code response
     * @param {string|number} code - The verification code sent to email
     * @returns {Promise<Object>} The verification response JSON
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async regVerify(regToken, code){
        const url = new URL(this.params.whost + '/passport/register_v4/verify');
        
        try{
            const formData = new this.FormUrlEncoded();
            formData.append('client', 'web');
            formData.append('pass_version', '2.8');
            formData.append('clientfrom', 'h5');
            formData.append('pcftoken', this.data.pcftoken);
            formData.append('token', regToken);
            formData.append('code', code);
            
            const req = await request(url, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                    Referer: this.params.whost,
                },
                body: formData.str(),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            // rdata.code: 0 - OK
            // rdata.code: 59 - Email code is wrong
            return rdata;
        }
        catch (error) {
            throw new Error('regVerify', { cause: error });
        }
    }
    
    /**
     * Completes the registration process by setting a password
     * @param {string} regToken - Registration token from verification step
     * @param {string} pass - The new password to set, length is 6-15 and contains at least 1 Latin letter
     * @returns {Promise<Object>} The finish registration response JSON (includes ndus token on success)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async regFinish(regToken, pass){
        const url = new URL(this.params.whost + '/passport/register_v4/finish');
        
        try{
            if(this.data.pubkey === ''){
                await this.getPublicKey();
            }
            
            if(typeof pass !== 'string' || pass.length < 6 || pass.length > 15 || !pass.match(/[a-z]/i)){
                return { code: -2, logid: 0, msg: 'invalid password', };
            }
            
            const encpwd = this.ChangeBase64Type(this.EncryptRSA(pass, this.data.pubkey, 2));
            
            const formData = new this.FormUrlEncoded();
            formData.append('client', 'web');
            formData.append('pass_version', '2.8');
            formData.append('clientfrom', 'h5');
            formData.append('pcftoken', this.data.pcftoken);
            formData.append('token', regToken);
            formData.append('pwd', encpwd);
            
            const req = await request(url, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                    Referer: this.params.whost,
                },
                body: formData.str(),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            if(rdata.code === 0 && req.headers['set-cookie']){
                const cJar = new CookieJar();
                
                if(typeof req.headers['set-cookie'] === 'string'){
                    req.headers['set-cookie'] = [req.headers['set-cookie']];
                }
                for(const cookie of req.headers['set-cookie']){
                    cJar.setCookieSync(cookie.split('; ')[0], this.params.whost);
                }
                
                const ndus = cJar.toJSON().cookies.find(c => c.key === 'ndus').value;
                rdata.data.ndus = ndus;
            }
            return rdata;
        }
        catch (error) {
            throw new Error('regFinish', { cause: error });
        }
    }
    
    /**
     * Retrieves passport user information for the current session
     * @returns {Promise<Object>} The passport user info JSON (includes display_name)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async passportGetInfo(){
        const url = new URL(this.params.whost + '/passport/get_info');
        
        try{
            const req = await request(url, {
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            if(rdata.errno === 0){
                this.params.account_name = rdata.data.display_name;
            }
            return rdata;
        }
        catch (error) {
            throw new Error('getPassport', { cause: error });
        }
    }
    
    /**
     * Fetches membership information for the current user
     * @returns {Promise<Object>} The membership JSON (includes VIP status)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async userMembership(){
        const url = new URL(this.params.whost + '/rest/2.0/membership/proxy/user');
        url.search = new URLSearchParams({
            method: 'query',
        });
        
        try{
            const req = await request(url, {
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            if(rdata.error_code === 0){
                this.params.is_vip = rdata.data.member_info.is_vip > 0 ? true : false;
                // this.params.vip_type = this.params.is_vip ? 2 : 0;
                if(this.params.is_vip === 0){
                    this.params.vip_type = 0;
                }
            }
            return rdata;
        }
        catch(error){
            throw new Error('userMembership', { cause: error });
        }
    }
    
    /**
     * Retrieves current user information (username, VIP status)
     * @returns {Promise<Object>} The user info JSON (includes records array)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async getCurrentUserInfo(){
        try{
            if(this.params.account_id === 0){
                await this.checkLogin();
            }
            
            const curUser = await this.getUserInfo(this.params.account_id);
            if(curUser.records.length > 0){
                const thisUser = curUser.records[0];
                this.params.account_name = thisUser.uname;
                this.params.is_vip = thisUser.vip_type > 0 ? true : false;
                this.params.vip_type = thisUser.vip_type;
            }
            return curUser;
        }
        catch (error) {
            throw new Error('getCurrentUserInfo', { cause: error });
        }
    }
    
    /**
     * Retrieves information for a specific user ID
     * @param {number|string} user_id - The user ID to look up
     * @returns {Promise<Object>} The user info JSON (includes data)
     * @async
     * @throws {Error} Throws error if user_id is invalid, HTTP status is not 200, or request fails
     */
    async getUserInfo(user_id){
        user_id = parseInt(user_id);
        const url = new URL(this.params.whost + '/api/user/getinfo');
        url.search = new URLSearchParams({
            user_list: JSON.stringify([user_id]),
            need_relation: 0,
            need_secret_info: 1,
        });
        
        try{
            if(isNaN(user_id) || !Number.isSafeInteger(user_id)){
                throw new Error(`${user_id} is not user id`);
            }
            
            const req = await request(url, {
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('getUserInfo', { cause: error });
        }
    }
    
    /**
     * Retrieves storage quota information for the current account
     * @returns {Promise<Object>} The quota JSON (includes total, used, available)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async getQuota(){
        const url = new URL(this.params.whost + '/api/quota');
        url.search = new URLSearchParams({
            checkexpire: 1,
            checkfree: 1,
        });
        
        try{
            const req = await request(url, {
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            if(rdata.errno === 0){
                rdata.available = rdata.total - rdata.used;
                this.params.space_available = rdata.available;
                this.params.space_total = rdata.total;
                this.params.space_used = rdata.used;
            }
            return rdata;
        }
        catch (error) {
            throw new Error('getQuota', { cause: error });
        }
    }
    
    /**
     * Retrieves the user's coins count (points)
     * @returns {Promise<Object>} The coins count JSON (includes records of coin usage)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async getCoinsCount(){
        const url = new URL(this.params.whost + '/rest/1.0/inte/system/getrecord');
        
        try{
            const req = await request(url, {
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('getCoinsCount', { cause: error });
        }
    }
    
    /**
     * Retrieves the contents of a remote directory
     * @param {string} remoteDir - Remote directory path to list
     * @param {number} [page=1] - Page number for pagination
     * @returns {Promise<Object>} The directory listing JSON (includes entries array)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async getRemoteDir(remoteDir, page = 1){
        const url = new URL(this.params.whost + '/api/list');
        
        try{
            const formData = new this.FormUrlEncoded();
            formData.append('order', 'name');
            formData.append('desc', 0);
            formData.append('dir', remoteDir);
            formData.append('num', 20000);
            formData.append('page', page);
            formData.append('showempty', 0);
            
            const req = await request(url, {
                method: 'POST',
                body: formData.str(),
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('getRemoteDir', { cause: error });
        }
    }
    
    /**
     * Retrieves the contents of a remote directory with specific file category
     * @param {number} [categoryId=1] - selected category:
     *     <br>1: video
     *     <br>2: audio
     *     <br>3: pictures
     *     <br>4: documents
     *     <br>5: apps
     *     <br>6: other
     *     <br>7: torrent
     * @param {string} remoteDir - Remote directory path to list
     * @param {number} [page=1] - Page number for pagination
     * @returns {Promise<Object>} The directory listing JSON (includes entries array)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async getCategoryList(categoryId = 1, remoteDir = '/', page = 1, order = 'name', desc = 0, num = 20000){
        const url = new URL(this.params.whost + '/api/categorylist');
        
        try{
            const formData = new this.FormUrlEncoded();
            formData.append('order', order);
            formData.append('desc', desc);
            formData.append('dir', remoteDir);
            formData.append('num', num);
            formData.append('page', page);
            formData.append('category', categoryId);
            
            const req = await request(url, {
                method: 'POST',
                body: formData.str(),
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('getCategoryList', { cause: error });
        }
    }
    
    /**
     * Retrieves the contents of the recycle bin
     * @returns {Promise<Object>} The recycle bin listing JSON (includes entries array)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async getRecycleBin(page = 1){
        const url = new URL(this.params.whost + '/api/recycle/list');
        
        try{
            url.search = new URLSearchParams({
                // order: 'name',
                desc: 0,
                num: 20000,
                page: page,
            });
            
            
            const req = await request(url, {
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('getRecycleBin', { cause: error });
        }
    }
    
    /**
     * Clears all items in the recycle bin
     * @returns {Promise<Object>} The clear recycle bin response JSON
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async clearRecycleBin(){
        const url = new URL(this.params.whost + '/api/recycle/clear');
        
        try{
            url.search = new URLSearchParams({
                'async': 1,
            });
            
            const req = await request(url, {
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('clearRecycleBin', { cause: error });
        }
    }
    
    /**
     * Initiates a precreate request for a file (reserve upload ID and pre-upload checks)
     * @param {Object} data - File data including remote_dir, file, size, upload_id (optional), and hash info
     * @param {string} data.remote_dir - Remote directory path
     * @param {string} data.file - Filename
     * @param {number} data.size - File size in bytes
     * @param {string} [data.upload_id] - Existing upload ID for resuming
     * @param {Object} data.hash - Hash information
     * @param {string} data.hash.file - MD5 hash of full file
     * @param {string} data.hash.slice - MD5 hash of first slice
     * @param {number} data.hash.crc32 - CRC32 value
     * @param {Array<string>} data.hash.chunks - Array of MD5 chunk hashes
     * @returns {Promise<Object>} The precreate response JSON (includes upload_id, etc.)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async precreateFile(data){
        const formData = new this.FormUrlEncoded();
        formData.append('path', makeRemoteFPath(data.remote_dir, data.file));
        // formData.append('target_path', data.remote_dir);
        formData.append('autoinit', 1);
        formData.append('size', data.size);
        formData.append('file_limit_switch_v34', 'true');
        formData.append('block_list', '[]');
        formData.append('rtype', 2);
        
        if(data.upload_id && typeof data.upload_id === 'string' && data.upload_id !== ''){
            formData.append('uploadid', data.upload_id);
        }
        
        // check if has correct md5 values
        if(this.CheckMd5Val(data.hash.slice) && this.CheckMd5Val(data.hash.file)){
            formData.append('content-md5', data.hash.file);
            formData.append('slice-md5', data.hash.slice);
        }
        
        // check crc32int and ignore field for crc32 out of range
        if(Number.isSafeInteger(data.hash.crc32) && data.hash.crc32 >= 0 && data.hash.crc32 <= 0xFFFFFFFF){
            formData.append('content-crc32', data.hash.crc32);
        }
        
        // check chunks hash
        if(!this.CheckMd5Arr(data.hash.chunks)){
            const predefinedHash = ['5910a591dd8fc18c32a8f3df4fdc1761']
            
            if(data.size > 4 * 1024 * 1024){
                predefinedHash.push('a5fc157d78e6ad1c7e114b056c92821e');
            }
            
            formData.set('block_list', JSON.stringify(predefinedHash));
        }
        else{
            formData.set('block_list', JSON.stringify(data.hash.chunks));
        }
        
        // formData.append('local_ctime', '');
        // formData.append('local_mtime', '');
        
        const url = new URL(this.params.whost + `/api/precreate`);
        
        try{
            if(this.data.jsToken === ''){
                await this.updateAppData();
            }
            
            url.search = new URLSearchParams({
                ...this.params.app,
                jsToken: this.data.jsToken,
            });
            
            const req = await request(url, {
                method: 'POST',
                body: formData.str(),
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            // uploadid	= 'P1-' + BASE64(ServerLocalIP + ':' + ServerTime + ':' + RequestID)
            const rdata = await req.body.json();
            // rdata.errno: 4000023 - need verify
            if(rdata.errno === 4000023){
                await this.updateAppData();
                return await this.precreateFile(data);
            }
            return rdata;
        }
        catch (error) {
            throw new Error('precreateFile', { cause: error });
        }
    }
    
    /**
     * Attempts a rapid upload using existing file hashes (skip actual upload if file already on server)
     * @param {Object} data - File data including remote_dir, file, size, and hash info
     * @param {string} data.remote_dir - Remote directory path
     * @param {string} data.file - Filename
     * @param {number} data.size - File size in bytes
     * @param {Object} data.hash - Hash information
     * @param {string} data.hash.file - MD5 hash of full file
     * @param {string} data.hash.slice - MD5 hash of first slice
     * @param {number} data.hash.crc32 - CRC32 value
     * @param {Array<string>} [data.hash.chunks] - Array of MD5 chunk hashes
     * @returns {Promise<Object>} The rapid upload response JSON (indicates success or fallback)
     * @async
     * @throws {Error} Throws error if file size < 256KB, invalid hashes, HTTP status is not 200, or request fails
     */
    async rapidUpload(data){
        const formData = new this.FormUrlEncoded({
            path:  makeRemoteFPath(data.remote_dir, data.file),
            //target_path: data.remote_dir
            'content-length': data.size,
            'content-md5': data.hash.file,
            'slice-md5': data.hash.slice,
            'content-crc32': data.hash.crc32,
            //local_ctime: '',
            //local_mtime: '',
            block_list: JSON.stringify(data.hash.chunks || []),
            rtype: 2,
            mode: 1,
        });
        
        if(!this.CheckMd5Val(data.hash.slice) || !this.CheckMd5Val(data.hash.file)){
            const badMD5 = new Error('Bad MD5 Slice Hash or MD5 File Hash');
            throw new Error('rapidUpload', { cause: badMD5 });
        }
        
        if(!Number.isSafeInteger(data.hash.crc32) || data.hash.crc32 < 0 || data.hash.crc32 > 0xFFFFFFFF){
            formData.delete('content-crc32');
        }
        
        if(!this.CheckMd5Arr(data.hash.chunks)){
            // use unsafe rapid upload if we don't have chunks hash
            formData.delete('block_list');
            formData.set('rtype', 3);
        }
        
        const url = new URL(this.params.whost + '/api/rapidupload');
        
        try{
            if(data.size < 256 * 1024){
                throw new Error(`File size too small!`);
            }
            
            const req = await request(url, {
                method: 'POST',
                body: formData.str(),
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            // rdata.errno: 2 - already exist?
            return rdata;
        }
        catch (error) {
            throw new Error('rapidUpload', { cause: error });
        }
    }
    
    /**
     * Attempts a upload file from remote server
     * @param {string} urls - Source urls (coma-separated)
     * @param {string} remote_dir - Remote directory path
     * @returns {Promise<Object>} The remote upload response JSON (indicates success or fallback)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200, or request fails
     */
    async remoteUpload(urls, remote_dir = '/Remote Upload'){
        const formData = new this.FormUrlEncoded({
            urls: urls,
            upload_to: remote_dir,
        });
        
        const url = new URL(this.params.whost + '/webmaster/remoteupload/submit');
        
        try{
            const req = await request(url, {
                method: 'POST',
                body: formData.str(),
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('remoteUpload', { cause: error });
        }
    }
    
    /**
     * Retrieves an upload host endpoint to use for file uploads
     * @returns {Promise<Object>} The upload host response JSON (includes host field)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async getUploadHost(){
        const url = new URL(this.params.whost + '/rest/2.0/pcs/file?method=locateupload');
        try{
            const req = await request(url, {
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            this.params.uhost = 'https://' + rdata.host;
            return rdata;
        }
        catch (error) {
            throw new Error('getUploadHost', { cause: error });
        }
    }
    
    /**
     * Uploads a single chunk (part) of a file
     * @param {Object} data - File data including remote_dir, file, upload_id
     * @param {number} partseq - The sequence number of this chunk (0-based)
     * @param {Blob|Buffer} blob - The binary data of the chunk
     * @param {function} [reqHandler] - Optional request progress handler
     * @param {AbortSignal} [externalAbort] - Optional external abort signal
     * @returns {Promise<Object>} The upload chunk response JSON (includes MD5 for chunk)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200, chunk upload fails, or request times out
     */
    async uploadChunk(data, partseq, blob, reqHandler, externalAbort) {
        const timeoutAborter = new AbortController;
        const timeoutId = setTimeout(() => { timeoutAborter.abort(); }, this.TERABOX_TIMEOUT);
        externalAbort = externalAbort ? externalAbort : new AbortController().signal;
        
        const url = new URL(`${this.params.uhost}/rest/2.0/pcs/superfile2`);
        url.search = new URLSearchParams({
            method: 'upload',
            ...this.params.app,
            // type: 'tmpfile',
            path: makeRemoteFPath(data.remote_dir, data.file),
            uploadid: data.upload_id,
            // uploadsign: 0,
            partseq: partseq,
        });
        
        const formData = new FormData();
        formData.append('file', blob, 'blob');
        
        const req = await request(url, {
            method: 'POST',
            body: formData,
            headers: {
                'User-Agent': this.params.ua,
                'Cookie': this.params.cookie,
            },
            signal: AbortSignal.any([
                externalAbort,
                timeoutAborter.signal,
            ]),
        });
        
        clearTimeout(timeoutId);
        
        if (req.statusCode !== 200) {
            throw new Error(`HTTP error! Status: ${req.statusCode}`);
        }
        
        const res = await req.body.json();
        if (res.error_code) {
            const uploadError = new Error(`Upload failed! Error Code #${res.error_code}`);
            uploadError.data = res;
            throw uploadError;
        }
        return res;
    }
    
    /**
     * Creates a new directory in the remote file system
     * @param {string} remoteDir - The path of the directory to create
     * @returns {Promise<Object>} The create directory response JSON
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async createDir(remoteDir){
        const formData = new this.FormUrlEncoded();
        formData.append('path', remoteDir);
        formData.append('isdir', 1);
        formData.append('block_list', '[]');
        
        const url = new URL(this.params.whost + '/api/create?a=commit');
        
        try{
            const req = await request(url, {
                method: 'POST',
                body: formData.str(),
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            // rdata.errno: -7 - param  path file name is invalid
            return rdata;
        }
        catch (error) {
            throw new Error('createFolder', { cause: error });
        }
    }
    
    /**
     * Creates a new file entry on the server after uploading chunks
     * @param {Object} data - File data including remote_dir, file, size, hash, upload_id, and chunks
     * @param {string} data.remote_dir - Remote directory path
     * @param {string} data.file - Filename
     * @param {number} data.size - File size in bytes
     * @param {Object} data.hash - Hash information
     * @param {string} data.hash.file - MD5 hash of full file
     * @param {string} data.hash.slice - MD5 hash of first slice
     * @param {number} data.hash.crc32 - CRC32 value
     * @param {Array<string>} data.hash.chunks - Array of MD5 chunk hashes
     * @param {string} data.upload_id - Upload ID obtained from precreate
     * @returns {Promise<Object>} The create file response JSON (includes MD5 and ETag)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async createFile(data){
        const formData = new this.FormUrlEncoded();
        formData.append('path', makeRemoteFPath(data.remote_dir, data.file));
        // formData.append('isdir', 0);
        formData.append('size', data.size);
        formData.append('isdir', 0);
        
        // check if has correct md5 values
        if(this.CheckMd5Val(data.hash.slice) && this.CheckMd5Val(data.hash.file)){
            formData.append('content-md5', data.hash.file);
            formData.append('slice-md5', data.hash.slice);
        }
        
        // check crc32int and ignore field for crc32 out of range
        if(Number.isSafeInteger(data.hash.crc32) && data.hash.crc32 >= 0 && data.hash.crc32 <= 0xFFFFFFFF){
            formData.append('content-crc32', data.hash.crc32);
        }
        
        formData.append('block_list', JSON.stringify(data.hash.chunks));;
        formData.append('uploadid', data.upload_id);
        formData.append('rtype', 2);
        
        // formData.append('local_ctime', '');
        // formData.append('local_mtime', '');
        // formData.append('zip_quality', '');
        // formData.append('zip_sign', '');
        // formData.append('is_revision', 0);
        // formData.append('mode', 2); // 2 is Batch Upload
        // formData.append('exif_info', exifJsonStr);
        
        const url = new URL(this.params.whost + `/api/create`);
        
        try{
            const req = await request(url, {
                method: 'POST',
                body: formData.str(),
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            // rdata.errno: 31355 - pcs service failed
            if(rdata.md5){
                // encrypted etag
                rdata.emd5 = rdata.md5;
                // decrypted etag (without chunk count)
                rdata.md5 = this.DecodeMd5(rdata.emd5);
                // set custom etag
                rdata.etag = rdata.md5;
                if(data.hash.chunks.length > 1){
                    rdata.etag += '-' + data.hash.chunks.length;
                }
            }
            return rdata;
        }
        catch (error) {
            console.log(error);
            throw new Error('createFile', { cause: error });
        }
    }
    
    /**
     * Performs file management operations (delete, copy, move, rename)
     * @param {string} operation - Operation type: 'delete', 'copy', 'move', 'rename'
     * @param {Array} fmparams - Parameters for the operation (array of paths or objects)
     * @returns {Promise<Object>} The file manager response JSON
     * @async
     * @throws {Error} Throws error if fmparams is not an array, HTTP status is not 200, or request fails
     */
    async filemanager(operation, fmparams){
        // For Delete: ["/path1","path2.rar"]
        // For Move: [{"path":"/myfolder/source.bin","dest":"/target/","newname":"newfilename.bin"}]
        // For Copy same as move
        // + "ondup": newcopy, overwrite (optional, skip by default)
        // For rename [{"id":1111,"path":"/dir1/src.bin","newname":"myfile2.bin"}]
        
        // operation - copy (file copy), move (file movement), rename (file renaming), and delete (file deletion)
        // opera=copy: filelist: [{"path":"/hello/test.mp4","dest":"","newname":"test.mp4"}]
        // opera=move: filelist: [{"path":"/test.mp4","dest":"/test_dir","newname":"test.mp4"}]
        // opera=rename: filelist:[{"path":"/hello/test.mp4","newname":"test_one.mp4"}]
        // opera=delete: filelist: ["/test.mp4"]
        
        if(!Array.isArray(fmparams)){
            throw new Error('filemanager', { cause: new Error('FS paths should be in array!') });
        }
        
        const url = new URL(this.params.whost + '/api/filemanager');
        
        const formData = new this.FormUrlEncoded();
        formData.append('filelist', JSON.stringify(fmparams));
        
        try{
            if(this.data.jsToken === ''){
                await this.updateAppData();
            }
            
            url.search = new URLSearchParams({
                ...this.params.app,
                jsToken: this.data.jsToken,
                // 'async': 1,
                onnest: 'fail',
                opera: operation, // delete, copy, move, rename
            });
            
            const req = await request(url, {
                method: 'POST',
                body: formData.str(),
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            if(rdata.errno === 450016){
                await this.updateAppData();
                return await this.filemanager(operation, fmparams);
            }
            return rdata;
        }
        catch (error) {
            throw new Error('filemanager', { cause: error });
        }
    }
    
    /**
     * Retrieves a list of shares created by the user
     * @returns {Promise<Object>} The share list JSON (includes share entries)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async shareList(page = 1){
        const url = new URL(this.params.whost + '/share/teratransfer/sharelist');
        
        try{
            url.search = new URLSearchParams({
                // ...this.params.app,
                page_size: 100,
                page: page,
            });
            
            const req = await request(url, {
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('shareList', { cause: error });
        }
    }
    
    /**
     * Sets sharing parameters (e.g., password, expiration) for specified files
     * @param {Array<string>} filelist - Array of file paths to share
     * @param {string} [pass=''] - Optional 4-character alphanumeric password
     * @param {number} [period=0] - Sharing period in days (0 for no expiration)
     * @returns {Promise<Object>} The share set response JSON (includes share IDs)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async shareSet(filelist, pass = '', period = 0){
        const url = new URL(this.params.whost + '/share/pset');
        
        try{
            url.search = new URLSearchParams({
                // ...this.params.app,
            });
            
            filelist = Array.isArray(filelist) ? filelist : [];
            filelist = JSON.stringify(filelist);
            
            pass = typeof pass === 'string' && pass.match(/^[0-9a-z]{4}$/i) ? pass : '';
            const schannel = pass !== '' ? 4 : 0;
            
            // 0 - infinity, otherwise valid X days
            period = parseInt(period);
            period = !isNaN(period) && Number.isSafeInteger(period) ? period : 0;
            
            const formData = new this.FormUrlEncoded();
            formData.append('schannel', schannel);
            formData.append('channel_list', '[]');
            formData.append('period', period);
            formData.append('path_list', filelist);
            formData.append('pwd', pass);
            
            const req = await request(url, {
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                    Referer: this.params.whost,
                },
                body: formData.str(),
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('shareSet', { cause: error });
        }
    }
    
    /**
     * Cancels existing shares by share ID
     * @param {Array<number>} [shareid_list=[]] - Array of share IDs to cancel
     * @returns {Promise<Object>} The share cancel response JSON
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async shareCancel(shareid_list = []){
        const url = new URL(this.params.whost + '/share/cancel');
        
        try{
            url.search = new URLSearchParams({
                // ...this.params.app,
            });
            
            shareid_list = Array.isArray(shareid_list) ? shareid_list : [];
            shareid_list = JSON.stringify(shareid_list);
            
            const formData = new this.FormUrlEncoded();
            formData.append('shareid_list', shareid_list);
            
            const req = await request(url, {
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                    Referer: this.params.whost,
                },
                body: formData.str(),
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('shareCancel', { cause: error });
        }
    }
    
    /**
     * Retrieves information for a shortened URL share
     * @param {string} shortUrl - The short url: after "surl="
     * @returns {Promise<Object>} The short URL info JSON (includes file list, permissions)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async shortUrlInfo(shortUrl){
        const url = new URL(this.params.whost + '/api/shorturlinfo');
        
        try{
            url.search = new URLSearchParams({
                //...this.params.app,
                shorturl: '1' + shortUrl,
                root: 1,
            });
            
            const connector = buildConnector({ ciphers: tls.DEFAULT_CIPHERS + ':!ECDHE-RSA-AES128-SHA' });
            const client = new Client(this.params.whost, { connect: connector });
            const req = await request(url, {
                method: 'GET',
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                dispatcher: client,
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('shortUrlInfo', { cause: error });
        }
    }
    
    /**
     * Lists files under a shortened URL share
     * @param {string} shortUrl - The short url: after "surl="
     * @param {string} [remoteDir=''] - Remote directory under share (empty for root)
     * @param {number} [page=1] - Page number for pagination
     * @returns {Promise<Object>} The short URL file list JSON (includes entries array)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async shortUrlList(shortUrl, remoteDir = '', page = 1){
        const url = new URL(this.params.whost + '/share/list');
        remoteDir = remoteDir || ''
        
        try{
            if(this.data.jsToken === ''){
                await this.updateAppData();
            }
            
            url.search = new URLSearchParams({
                ...this.params.app,
                jsToken: this.data.jsToken,
                shorturl: shortUrl,
                by: 'name',
                order: 'asc',
                num: 20000,
                dir: remoteDir,
                page: page,
            });
        
            if(remoteDir === ''){
                url.searchParams.append('root', '1');
            }
            
            const connector = buildConnector({ ciphers: tls.DEFAULT_CIPHERS + ':!ECDHE-RSA-AES128-SHA' });
            const client = new Client(this.params.whost, { connect: connector });
            const req = await request(url, {
                method: 'GET',
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                dispatcher: client,
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            // rdata.errno: 4000020 - need verify
            if(rdata.errno === 4000020){
                await this.updateAppData();
                return await this.shortUrlList(shortUrl, remoteDir, page);
            }
            return rdata;
        }
        catch (error) {
            throw new Error('shortUrlList', { cause: error });
        }
    }
    
    /**
     * Retrieves file difference (delta) information for synchronization
     * @returns {Promise<Object>} The file diff JSON (includes entries, request_id, has_more flag)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200, request fails, or on recursive errors
     */
    async fileDiff(){
        const formData = new this.FormUrlEncoded();
        formData.append('cursor', this.params.cursor);
        if(this.params.cursor === 'null'){
            formData.append('c', 'full');
        }
        formData.append('action', 'manual');
        
        const url = new URL(this.params.whost + '/api/filediff');
        url.search = new URLSearchParams({
            ...this.params.app,
            block_list: 1,
            // rand: '',
            // time: '',
            // vip: this.params.vip_type,
            // wp_retry_num: 2,
            // lang: this.params.lang,
            // logid: '',
        });
        
        try{
            const req = await request(url, {
                method: 'POST',
                body: formData.str(),
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            if(rdata.errno === 0){
                this.params.cursor = rdata.cursor;
                if(!Array.isArray(rdata.request_id)){
                    rdata.request_id = [ rdata.request_id ];
                }
                if(rdata.has_more){
                    // Extra FileDiff request...
                    const rFileDiff = await this.fileDiff();
                    if(rFileDiff.errno === 0){
                        rdata.reset = rFileDiff.reset;
                        rdata.request_id = rdata.request_id.concat(rFileDiff.request_id);
                        rdata.entries = Object.assign({}, rdata.entries, rFileDiff.entries);
                        rdata.has_more = rFileDiff.has_more;
                    }
                }
            }
            return rdata;
        }
        catch (error) {
            this.params.cursor = 'null';
            throw new Error('fileDiff', { cause: error });
        }
    }
    
    /**
     * Generates a PAN token for subsequent API requests
     * @returns {Promise<Object>} The PAN token response JSON (includes pan token and expire)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async genPanToken(){
        const url = new URL(this.params.whost + '/api/pantoken');
        
        try{
            url.search = new URLSearchParams({
                ...this.params.app,
                lang: this.params.lang,
                u: 'https://www.terabox.com',
            });
            
            const req = await request(url, {
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('genPanToken', { cause: error });
        }
    }
    
    /**
     * Retrieves home page information (user info, sign data)
     * @returns {Promise<Object>} The home info JSON (includes sign1, sign3, data.signb)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async getHomeInfo(){
        const url = new URL(this.params.whost + '/api/home/info');
        
        try{
            const req = await request(url, {
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            if(rdata.errno === 0){
                rdata.data.signb = this.SignDownload(rdata.data.sign3, rdata.data.sign1);
            }
            return rdata;
        }
        catch (error) {
            throw new Error('getHomeInfo', { cause: error });
        }
    }
    
    /**
     * Initiates a download request for specified file IDs
     * @param {Array<number>} fs_ids - Array of file system IDs to download
     * @param {string} signb - Base64-encoded signature from getHomeInfo
     * @returns {Promise<Object>} The download response JSON (includes dlink URLs)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async download(fs_ids){
        const url = new URL(this.params.whost + '/api/download');
        
        try{
            const homeInfo = await this.getHomeInfo();
            if(homeInfo.errno !== 0){
                throw new Error(`API error! Bad HomeInfo response`);
            }
            
            const formData = new this.FormUrlEncoded({
                fidlist: JSON.stringify(fs_ids),
                type: 'dlink',
                vip: 2, // this.params.vip_type
                sign: homeInfo.data.signb,
                timestamp: homeInfo.data.timestamp,
                need_speed: 1, // Premium speed?..
            });
            
            const req = await request(url, {
                method: 'POST',
                body: formData.str(),
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('download', { cause: error });
        }
    }
    
    /**
     * Retrieves the streaming contents of a remote file
     * @param {string} remotePath - Remote video file
     * @param {string} type - Streaming type:
     *    <br>M3U8_FLV_264_480
     *    <br>M3U8_AUTO_240
     *    <br>M3U8_AUTO_360
     *    <br>M3U8_AUTO_480
     *    <br>M3U8_AUTO_720
     *    <br>M3U8_AUTO_1080
     *    <br>M3U8_SUBTITLE_SRT
     * @returns {Promise<Object>} m3u8 playlist, or JSON with error
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async getStream(remotePath = '/video.mp4', type = 'M3U8_AUTO_480'){
        const url = new URL(this.params.whost + '/api/streaming');
        
        try{
            const formData = new this.FormUrlEncoded();
            formData.append('path', remotePath);
            formData.append('type', type);
            formData.append('vip', 2);
            
            const req = await request(url, {
                method: 'POST',
                body: formData.str(),
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('getStream', { cause: error });
        }
    }
    
    /**
     * Retrieves metadata for specified remote files
     * @param {Array<Object>} remote_file_list - Array of file descriptor objects { fs_id, path, etc. }
     * @returns {Promise<Object>} The file metadata JSON (includes size, md5, etc.)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async getFileMeta(remote_file_list){
        const url = new URL(this.params.whost + '/api/filemetas');
        
        try{
            const formData = new this.FormUrlEncoded({
                dlink: 1,
                origin: 'dlna',
                target: JSON.stringify(remote_file_list),
            });
            
            const req = await request(url, {
                method: 'POST',
                body: formData.str(),
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('getFileMeta', { cause: error });
        }
    }
    
    /**
     * Retrieves a list of recent uploads for the account
     * @param {number} [page=1] - Page number for pagination
     * @returns {Promise<Object>} The recent uploads JSON (includes records array)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async getRecentUploads(page = 1){
        const url = new URL(this.params.whost + '/rest/recent/listall');
        
        try{
            url.search = new URLSearchParams({
                ...this.params.app,
                version:  this.params.ver_android,
                // num: 20000, ???
                // page: page, ???
            });
            
            const req = await request(url, {
                method: 'GET',
                body: formData.str(),
                headers: {
                    'User-Agent': this.params.ua,
                    'Cookie': this.params.cookie,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            return rdata;
        }
        catch (error) {
            throw new Error('getRecentUploads', { cause: error });
        }
    }
    
    /**
     * Retrieves the RSA public key from the server for encryption
     * @returns {Promise<Object>} The public key response JSON (includes pp1 and pp2)
     * @async
     * @throws {Error} Throws error if HTTP status is not 200 or request fails
     */
    async getPublicKey(){
        const url = new URL(this.params.whost + '/passport/getpubkey');
        
        try{
            const req = await request(url, {
                method: 'GET',
                headers: {
                    'User-Agent': this.params.ua,
                },
                signal: AbortSignal.timeout(this.TERABOX_TIMEOUT),
            });
            
            if (req.statusCode !== 200) {
                throw new Error(`HTTP error! Status: ${req.statusCode}`);
            }
            
            const rdata = await req.body.json();
            
            if(rdata.code === 0){
                this.data.pubkey = this.DecryptAES(rdata.data.pp1, rdata.data.pp2);
            }
            
            return rdata;
        }
        catch (error) {
            throw new Error('getPublicKey', { cause: error });
        }
    }
}

// exports
export default TeraBoxApp;
export { TeraBoxApp };