WDwloadDlp.mjs

import path from 'path'
import process from 'process'
import get from 'lodash-es/get.js'
import size from 'lodash-es/size.js'
import each from 'lodash-es/each.js'
import genID from 'wsemi/src/genID.mjs'
import now2strp from 'wsemi/src/now2strp.mjs'
import sep from 'wsemi/src/sep.mjs'
import strright from 'wsemi/src/strright.mjs'
import strdelright from 'wsemi/src/strdelright.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import isnum from 'wsemi/src/isnum.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import cint from 'wsemi/src/cint.mjs'
import cdbl from 'wsemi/src/cdbl.mjs'
import execProcess from 'wsemi/src/execProcess.mjs'
import fsIsFile from 'wsemi/src/fsIsFile.mjs'
import fsCopyFile from 'wsemi/src/fsCopyFile.mjs'
import fsCleanFolder from 'wsemi/src/fsCleanFolder.mjs'
import fsDeleteFile from 'wsemi/src/fsDeleteFile.mjs'
import autoDownloadFiles from './autoDownloadFiles.mjs'


function isWindows() {
    return process.platform === 'win32'
}


/**
 * 下載video檔案,預設轉mp4,核心調用yt-dlp,只能用於Windows作業系統
 *
 * yt-dlp: https://github.com/yt-dlp/yt-dlp
 *
 * @param {String} url 輸入網址字串,支援網站種類詳見yt-dlp
 * @param {String} fp 輸入儲存video(*.mp4)檔案路徑字串
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {Boolean} [opt.clean=false] 輸入預先清除暫存檔布林值,預設false
 * @param {Function} [opt.funProg=null] 輸入回傳進度函數,傳入參數為prog代表進度百分比、nn代表當前已下載ts檔案數量、na代表全部須下載ts檔案數量,預設null
 * @returns {Promise} 回傳Promise,resolve回傳成功訊息,reject回傳錯誤訊息
 * @example
 * import fs from 'fs'
 * import WDwloadDlp from './src/WDwloadDlp.mjs'
 *
 * async function test() {
 *
 *     //url
 *     let url = `https://www.youtube.com/watch?v=jfXg5ZslKg4` //youtube mp4
 *     // let url = `https://www.youtube.com/watch?v=fTk0mc946dk` //youtube webm
 *     // let url = `https://www.youtube.com/watch?v=lKoCiBVKQaQ` //youtube webm
 *     // let url = `https://www.bilibili.com/video/BV1JZ421x7q8/?spm_id_from=333.1073.channel.secondary_floor_video.click` //bilibili
 *     // let url = `https://ooo.mp4` //直接提供mp4檔
 *     // let url = `https://cdn.jsdelivr.net/npm/w-demores/res/video/aigen_hls/playlist.m3u8`
 *
 *     //fp
 *     let fp = './test.mp4'
 *
 *     //funProg
 *     let funProg = (prog, nn, na) => {
 *         console.log('prog', `${prog.toFixed(2)}%`, nn, na)
 *     }
 *
 *     //WDwloadDlp
 *     await WDwloadDlp(url, fp, {
 *         clean: true, //單一程序執行時, 事先清除之前暫存檔, 減少浪費硬碟空間
 *         funProg,
 *     })
 *
 *     //len
 *     let len = fs.statSync(fp).size
 *     console.log('len', len)
 *
 *     console.log('done:', fp)
 * }
 * test()
 *     .catch((err) => {
 *         console.log('catch', err)
 *     })
 * // prog 0.49% 1 99
 * // prog 5.05% 4 99
 * // ...
 * // prog 99.00% 98 99
 * // prog 100.00% 99 99
 * // len 22394508
 * // done: ./test.mp4
 *
 */
async function WDwloadDlp(url, fp, opt = {}) {
    let errTemp = null
    let rc

    //clean
    let clean = get(opt, 'clean')
    if (!isbol(clean)) {
        clean = false
    }

    //funProg
    let funProg = get(opt, 'funProg')

    //isWindows
    if (!isWindows()) {
        return Promise.reject('operating system is not windows')
    }

    //check
    if (!isestr(url)) {
        return Promise.reject('invalid url')
    }

    //fpExeDlp, fpExeFfmpeg, 自動定位yt-dlp.exe與ffmpeg.exe, 無檔案(安裝時npm封鎖scripts致postinstall未執行)則自動下載
    // fpExeDlp = `"${fpExeDlp}"` //用雙引號包住避免路徑有空格 //execProcess預設使用spawn不須雙引號括住
    let { fpExeDlp, fpExeFfmpeg } = await autoDownloadFiles()
    // console.log('fpExeDlp', fpExeDlp)
    // console.log('fpExeFfmpeg', fpExeFfmpeg)

    //fdExe
    let fdExe = path.dirname(fpExeDlp)
    // console.log('fdExe', fdExe)

    //cwdOri, cwdTar
    let cwdOri = process.cwd()
    let cwdTar = fdExe
    // console.log('process.cwd1', process.cwd())

    //chdir, 若不切換mseed2ascii預設輸出檔案是在工作路徑, 輸出檔變成會出現在專案下
    process.chdir(cwdTar)
    // console.log('process.cwd2', process.cwd())

    //id
    let id = `${now2strp()}-${genID(6)}`

    //fnAny
    let fnAny = `${id}` //無副檔名

    //fdDownloads
    let fdDownloads = path.resolve(fdExe, 'Downloads')
    // console.log('fdDownloads', fdDownloads)

    //clean
    if (clean) {
        fsCleanFolder(fdDownloads)
    }

    //fpInAny
    let fpInAny = path.resolve(fdDownloads, fnAny)
    // console.log('fpInAny', fpInAny)

    //fpInMp4
    let fpInMp4 = path.resolve(fdDownloads, `${id}.mp4`)
    // console.log('fpInMp4', fpInMp4)

    let fmts = []
    // let mode = 'mp4' //default
    let dn = 0 //default
    let da = 1 //default
    // let bdl = false
    let nnPre = 0
    let nn = 0
    let nnTotal = 100 //default
    let naf = 0 //全部須下載fragment(ts)數量, 0代表尚未取得
    let nnf = 0 //當前已下載fragment(ts)數量
    let prog = 0
    let bFunProg = isfun(funProg)
    let parseLine = (msg) => {
        // console.log('parseLine', msg)

        //s
        let s = sep(msg, ' ')
        // console.log('s', s)

        //stdout分不同frag之進度:
        // stdout [download]   4.0% of ~   6.17MiB at   99.52KiB/s ETA Unknown (frag 1/99)
        // stdout [download]   2.4% of ~  12.36MiB at  262.95KiB/s ETA Unknown (frag 1/99)
        // stdout [download]   1.0% of ~  29.18MiB at  262.95KiB/s ETA Unknown (frag 2/99)
        // stdout [download]   3.0% of ~   9.74MiB at  329.58KiB/s ETA Unknown (frag 2/99)
        // stdout [download]   3.1% of ~   9.77MiB at  329.58KiB/s ETA Unknown (frag 2/99)

        //stdout無frag之進度:
        // stdout [download]   0.0% of  153.09MiB at    1.43MiB/s ETA 01:49
        // stdout [download]   0.1% of  153.09MiB at    1.41MiB/s ETA 01:48
        // stdout [download]   0.2% of  153.09MiB at    1.75MiB/s ETA 01:27
        // stdout [download]   0.3% of  153.09MiB at    2.43MiB/s ETA 01:02
        // stdout [download]   0.7% of  153.09MiB at    2.75MiB/s ETA 00:55

        //fmts
        if (msg.indexOf('format(s):') >= 0) {
            let ss = sep(msg, 'format(s):')
            let ss1 = get(ss, 1, '')
            fmts = sep(ss1, '+')
            // console.log('fmts', fmts)
            da = size(fmts)
            // console.log('da', da, fmts, msg)
        }

        //自動更新fmts, 並將size(fmts)視為階段數
        if (size(fmts) > 0) {
            let _kfmt = -1
            // let _fmt = ''
            each(fmts, (fmt, kfmt) => {
                if (msg.indexOf(`.f${fmt}.`) >= 0) {
                    _kfmt = kfmt
                    // _fmt = fmt
                }
            })
            let _dn = _kfmt + 1
            if (dn < _dn) {
                dn = _dn
                // console.log('_kfmt', _kfmt, '_fmt', _fmt)
                // console.log('dn', dn, 'da', da)
            }
        }

        //自動更新naf, 優先由hlsnative(m3u8)或dashsegments所報之總片段數取得, 例如 [hlsnative] Total fragments: 37
        if (naf === 0) {
            if (msg.indexOf('Total fragments:') >= 0) {
                let ss = sep(msg, 'Total fragments:')
                let _naf = get(ss, 1, '')
                if (isnum(_naf)) {
                    _naf = cint(_naf)
                    if (_naf > 0) {
                        naf = _naf
                        // console.log('naf', naf)
                    }
                }
            }
        }

        //nf, nfa, 由進度列取得當前fragment序號與該列所報之總fragment數, 例如 (frag 82/99)
        let nf = -1
        let nfa = 0
        if (msg.indexOf('(frag') >= 0) {
            let ss = sep(msg, '(frag')
            let ss1 = get(ss, 1, '')
            ss1 = ss1.replaceAll(')', '')
            let nnna = sep(ss1, '/')
            let _nf = get(nnna, 0, '')
            let _nfa = get(nnna, 1, '')
            if (isnum(_nf)) {
                nf = cint(_nf)
            }
            if (isnum(_nfa)) {
                _nfa = cint(_nfa)
                if (_nfa > 0) {
                    nfa = _nfa
                }
            }
        }

        //自動更新naf, 次要由進度列之frag資訊取得
        if (naf === 0 && nfa > 0) {
            naf = nfa
        }

        //更新nnf, 取最大值使其單調遞增
        if (nf > nnf) {
            nnf = nf
        }

        //更新dn, 補救單一format時輸出檔名不帶.f<fmt>.標記致dn恆為0之情形,
        //單一format涵蓋m3u8走hlsnative(檔名如xxx.mp4)與直接下載mp4(檔名如xxx.mp4),
        //只要出現下載進度列(有ETA或有frag)即視為已進入第1階段
        if (dn === 0) {
            if (msg.indexOf(' ETA ') >= 0 || nf >= 0) {
                dn = 1
            }
        }

        //prog
        let _prog = 0
        if (nfa > 0) {

            //有frag資訊時直接以fragment比例為準, 不採該列所報之百分比,
            //因該百分比是當前單一fragment之估計值, 會出現59.1%後又降為54.4%之跳動,
            //以其為準會令進度瞬間衝頂而後續全被單調遞增檢核擋掉
            _prog = nf / nfa * 100

        }
        else {

            //無frag時取列首百分比, 例如 [download]  24.8% of  29.28MiB at 2.08MiB/s ETA 00:10
            //或階段完成列, 例如 [download] 100% of 45.39MiB in 00:00:06 at 6.54MiB/s
            let s1 = get(s, 1, '')
            if (strright(s1, 1) === '%') {
                _prog = cdbl(strdelright(s1, 1))
                // console.log('_prog(ori)', _prog)
            }

        }

        //依照階段重算_prog
        if (dn > 0) {

            //rDif, rPre
            let rDif = 1 / da
            let pPre = ((dn - 1) / da) * 100
            // console.log('dn', dn)
            // console.log('da', da)
            // console.log('pPre', pPre)
            // console.log('rDif', rDif)

            //計算分階段值
            _prog = rDif * _prog + pPre
            // console.log('_prog(stage)', _prog)

        }
        else {
            //尚未進入任何下載階段
            _prog = 0
        }

        //最高99%, 因可能還有轉檔(例如webm轉mp4或FixupM3u8), 故最後100%改由最後完成階段觸發
        _prog *= 0.99
        // console.log('_prog(99%)', _prog)

        //update nnTotal, 須於下方單調遞增檢核之前更新, 否則進度未推進時將停在預設值,
        //致結尾之funProg(100, nnTotal, nnTotal)回報錯誤的na
        if (naf > 0) {
            nnTotal = naf
        }

        //update
        if (prog < _prog) {
            prog = _prog
        }
        else {
            return
        }
        // console.log('prog(now)', prog)

        //update nn
        if (da === 1 && naf > 0) {
            //單一階段且有fragment資訊時直接以fragment序號為已下載數量, 語義同JSDoc所述之已下載ts檔案數量
            nn = nnf
        }
        else {
            //多階段或無fragment資訊時仍以百分比換算
            nn = cint(prog / 100 * nnTotal)
        }
        // console.log('nn', nn, 'nnTotal', nnTotal)

        //check, 若nn沒有>nnPre則視為不需要觸發funProg, 減少切太細導致高頻觸發
        if (nn <= nnPre) {
            return
        }

        //funProg
        if (bFunProg) {
            // console.log('prog', nn, nnTotal, prog)
            funProg(prog, nn, nnTotal)
        }

        //update
        nnPre = nn

    }
    let cbStdout = (msg) => {
        // console.log('cbStdout', msg)

        //可能多訊息合併觸發, 故須分列各自解析, 否則高速下載時各列之百分比與frag序號僅會取到第1列
        let ls = sep(msg, '\n')
        each(ls, (l) => {
            l = l.trim()
            if (l === '') {
                return
            }
            parseLine(l)
        })

    }

    //cmdDl
    // let cmdDl = `"${url}" -o "${fpInAny}" --newline --merge-output-format "mp4"`
    let cmdDl = [url, '-o', fpInAny, '--newline', '--no-playlist', '--merge-output-format', 'mp4', '--ffmpeg-location', fpExeFfmpeg] //execProcess預設使用spawn不須雙引號括住
    // console.log('cmdDl', cmdDl)

    //execProcess
    await execProcess(fpExeDlp, cmdDl, { cbStdout })
        // .then(function(res) {
        //     console.log('execProcess then', res)
        // })
        .catch((err) => {
            console.log('execProcess catch', err)
            errTemp = err.toString()
        })

    //chdir, 不論正常或錯誤皆需還原工作路徑
    process.chdir(cwdOri)

    //check
    if (errTemp) {
        return Promise.reject(errTemp)
    }

    //check
    if (!fsIsFile(fpInMp4)) {

        //_fpInMp4, dlp針對某些mp4下載後會無法自動提供副檔名, 得再偵測無副檔名之檔案是否存在
        let _fpInMp4 = path.resolve(fdDownloads, id)

        //check
        if (!fsIsFile(_fpInMp4)) {
            console.log(`can not find the merged file[${fpInMp4}]`)
            return Promise.reject(`invalid url[${url}] or can not download`)
        }

        //modify
        fpInMp4 = _fpInMp4

    }

    //fpOut
    let fpOut = fp
    // console.log('fpOut', fpOut)

    //fsCopyFile
    rc = fsCopyFile(fpInMp4, fpOut)
    errTemp = get(rc, 'error')
    if (errTemp) {
        return Promise.reject(errTemp.toString())
    }

    //fsDeleteFile
    rc = fsDeleteFile(fpInMp4)
    //可能無檔案無法刪, 故不檢查錯誤

    //funProg
    if (bFunProg) {
        funProg(100, nnTotal, nnTotal)
    }

    return 'ok'
}


export default WDwloadDlp