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