@lzwme/m3u8-dl
Version:
Batch download of m3u8 files and convert to mp4
324 lines (323 loc) • 14.7 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.workPollPublic = exports.downloadQueue = exports.DownloadQueue = void 0;
exports.preDownLoad = preDownLoad;
exports.m3u8Download = m3u8Download;
exports.m3u8DLStop = m3u8DLStop;
const node_fs_1 = require("node:fs");
const node_path_1 = require("node:path");
const fe_utils_1 = require("@lzwme/fe-utils");
const helper_1 = require("@lzwme/fe-utils/cjs/common/helper");
const console_log_colors_1 = require("console-log-colors");
const format_options_js_1 = require("./format-options.js");
const local_play_js_1 = require("./local-play.js");
const m3u8_convert_js_1 = require("./m3u8-convert.js");
const parseM3u8_js_1 = require("./parseM3u8.js");
const utils_js_1 = require("./utils.js");
const worker_pool_js_1 = require("./worker_pool.js");
/** 下载队列管理 */
class DownloadQueue {
queue = [];
activeDownloads = new Set();
_maxConcurrent;
constructor(maxConcurrent = 3) {
this._maxConcurrent = maxConcurrent;
}
get maxConcurrent() {
return this._maxConcurrent;
}
set maxConcurrent(value) {
this._maxConcurrent = value;
}
add(url, options, priority = 0) {
this.queue.push({ url, options, priority });
this.queue.sort((a, b) => b.priority - a.priority);
this.processQueue();
}
async processQueue() {
if (this.activeDownloads.size >= this._maxConcurrent)
return;
const next = this.queue.shift();
if (!next)
return;
this.activeDownloads.add(next.url);
try {
const { maxDownloads, ...options } = next.options;
const result = await m3u8Download(next.url, options);
next.options.onComplete?.(result);
}
catch (error) {
next.options.onComplete?.({
errmsg: error instanceof Error ? error.message : error ? JSON.stringify(error) : 'Unknown error',
options: next.options,
});
}
finally {
this.activeDownloads.delete(next.url);
this.processQueue();
}
}
pause(url) {
const index = this.queue.findIndex(item => item.url === url);
if (index > -1) {
this.queue.splice(index, 1);
}
}
resume(url, options, priority = 0) {
this.add(url, options, priority);
}
clear() {
this.queue = [];
}
getStatus() {
return {
queueLength: this.queue.length,
activeDownloads: Array.from(this.activeDownloads),
maxConcurrent: this._maxConcurrent,
};
}
}
exports.DownloadQueue = DownloadQueue;
/** 创建全局下载队列实例 */
exports.downloadQueue = new DownloadQueue();
const cache = {
m3u8Info: {},
downloading: new Set(),
};
const tsDlFile = (0, node_path_1.resolve)(__dirname, './ts-download.js');
exports.workPollPublic = new worker_pool_js_1.WorkerPool(tsDlFile);
async function m3u8InfoParse(u, o = {}) {
const { url, options, urlMd5 } = (0, format_options_js_1.formatOptions)(u, o);
const ext = (0, utils_js_1.isSupportFfmpeg)() ? '.mp4' : '.ts';
/** 最终合并转换后的文件路径 */
let filepath = (0, node_path_1.resolve)(options.saveDir, options.filename);
if (!filepath.endsWith(ext))
filepath += ext;
const result = { options, m3u8Info: null, filepath };
if (cache.m3u8Info[url]) {
Object.assign(result, cache.m3u8Info[url]);
return result;
}
if (!options.force && (0, node_fs_1.existsSync)(filepath))
return result;
const m3u8Info = await (0, parseM3u8_js_1.parseM3U8)(url, (0, node_path_1.resolve)(options.cacheDir, urlMd5), options.headers).catch(e => {
utils_js_1.logger.error('[parseM3U8][failed]', e.message);
console.log(e);
});
if (m3u8Info && m3u8Info?.tsCount > 0) {
result.m3u8Info = m3u8Info;
if (options.ignoreSegments) {
const timeSegments = options.ignoreSegments
.split(',')
.map(d => d.split(/[- ]+/).map(d => +d.trim()))
.filter(d => d[0] && d[1] && d[0] !== d[1]);
if (timeSegments.length) {
const total = m3u8Info.data.length;
m3u8Info.data = m3u8Info.data.filter(item => {
for (let [start, end] of timeSegments) {
if (start > end)
[start, end] = [end, start];
if (item.timeline + item.duration / 2 >= start && item.timeline + item.duration / 2 <= end) {
m3u8Info.duration -= item.duration;
return false;
}
}
return true;
});
const ignoredCount = total - m3u8Info.data.length;
if (ignoredCount) {
m3u8Info.tsCount = m3u8Info.data.length;
utils_js_1.logger.info(`[parseM3U8][ignoreSegments] ignored ${(0, console_log_colors_1.cyanBright)(ignoredCount)} segments`);
m3u8Info.duration = +Number(m3u8Info.duration).toFixed(2);
}
}
}
}
return result;
}
/**
* 计算实时下载速度:取从 idx 开始的最近 maxTime 时间内的下载总大小
*/
function calcSpeed(idx, data, maxTime = 60 * 1000) {
const now = Date.now();
let startTime = now;
let downloadedSize = 0;
for (let i = idx; i >= 0; i--) {
const info = data[i];
if (info.tsSize && info.startTime && now - info.startTime < maxTime) {
if (startTime > info.startTime)
startTime = info.startTime;
downloadedSize += info.tsSize;
}
}
return now > startTime ? 1000 * (downloadedSize / (now - startTime)) : 0;
}
async function preDownLoad(url, options, wp = exports.workPollPublic) {
const result = await m3u8InfoParse(url, options);
if (!result.m3u8Info)
return;
for (const info of result.m3u8Info.data) {
if (!wp.freeNum)
return;
if (!cache.downloading.has(info.uri)) {
cache.downloading.add(info.uri);
wp.runTask({ url, info, options: JSON.parse(JSON.stringify(result.options)), crypto: result.m3u8Info.crypto[info.keyUri] }, () => {
cache.downloading.delete(info.uri);
});
}
}
}
async function m3u8Download(url, options = {}) {
// 如果设置了最大并发数,则使用队列管理
if (options.maxDownloads) {
exports.downloadQueue.maxConcurrent = options.maxDownloads;
return new Promise(resolve => {
const newOptions = {
...options,
onComplete: r => {
if (options.onComplete)
options.onComplete(r);
resolve(r);
},
};
exports.downloadQueue.add(url, newOptions, options.priority || 0);
});
}
// 原有的下载逻辑
utils_js_1.logger.info('Starting download for', (0, console_log_colors_1.cyanBright)(url));
const result = await m3u8InfoParse(url, options);
options = result.options;
if (!options.force && (0, node_fs_1.existsSync)(result.filepath) && !result.m3u8Info) {
utils_js_1.logger.info('file already exist:', result.filepath);
result.isExist = true;
return result;
}
if (result.m3u8Info?.tsCount > 0) {
const workPoll = new worker_pool_js_1.WorkerPool(tsDlFile, options.threadNum);
const { m3u8Info } = result;
const startTime = Date.now();
const barrier = new fe_utils_1.Barrier();
/** 本地开始播放最少需要下载的 ts 文件数量 */
const playStart = Math.min(options.threadNum + 2, m3u8Info.tsCount);
const stats = {
url,
startTime,
progress: 0,
tsCount: m3u8Info.tsCount,
tsSuccess: 0,
tsFailed: 0,
duration: m3u8Info.duration,
durationDownloaded: 0,
downloadedSize: 0,
avgSpeed: 0,
avgSpeedDesc: '',
speed: 0,
speedDesc: '',
remainingTime: 0,
localM3u8: (0, local_play_js_1.toLocalM3u8)(m3u8Info.data).replace(options.cacheDir, '').replaceAll(node_path_1.sep, '/').slice(1),
filename: options.filename,
threadNum: options.threadNum,
};
const runTask = (data) => {
for (const info of data) {
const o = JSON.parse(JSON.stringify(options));
workPoll.runTask({ url, info, options: o, crypto: m3u8Info.crypto[info.keyUri] }, (err, res, taskStartTime) => {
stats.errmsg = err ? err.cause || err.message || err.toString() : '';
if (!res || err) {
if (err) {
console.log('\n');
utils_js_1.logger.error('[TS-DL][error]', info.index, err, res || '');
}
if (typeof info.success !== 'number')
info.success = 0;
else
info.success--;
if (info.success >= -3) {
utils_js_1.logger.warn(`[retry][times: ${info.success}]`, info.index, info.uri);
setTimeout(() => runTask([info]), 1000);
return;
}
}
if (res?.success) {
info.tsSize = res.info.tsSize;
info.startTime = taskStartTime; // res.timeCost;
info.timeCost = Date.now() - taskStartTime;
info.success = 1;
stats.tsSuccess++;
stats.durationDownloaded += info.duration;
stats.speed = calcSpeed(info.index, data);
}
else {
stats.tsFailed++;
}
const finished = stats.tsFailed + stats.tsSuccess;
const timeCost = Date.now() - startTime;
const downloadedDuration = m3u8Info.data.reduce((a, b) => a + (b.tsSize ? b.duration : 0), 0);
stats.downloadedSize = m3u8Info.data.reduce((a, b) => a + (b.tsSize || 0), 0);
stats.avgSpeed = (stats.downloadedSize / timeCost) * 1000;
stats.avgSpeedDesc = `${(0, helper_1.formatByteSize)(stats.avgSpeed)}/s`;
// 如果当前速度小于平均速度,则更新为平均速度
if (stats.speed < stats.avgSpeed)
stats.speed = stats.avgSpeed;
stats.speedDesc = `${(0, helper_1.formatByteSize)(stats.speed)}/s`;
stats.progress = Math.floor((finished / m3u8Info.tsCount) * 100);
if (downloadedDuration) {
stats.remainingTime = (timeCost / downloadedDuration) * (m3u8Info.duration - stats.durationDownloaded);
if (stats.speed > stats.avgSpeed)
stats.remainingTime = stats.remainingTime * (stats.avgSpeed / stats.speed);
stats.remainingTime = Math.ceil(stats.remainingTime);
stats.size = Math.round(stats.downloadedSize * (stats.duration / stats.durationDownloaded));
}
if (options.showProgress) {
const processBar = '='.repeat(Math.floor(stats.progress * 0.2)).padEnd(20, '-');
utils_js_1.logger.logInline(`${stats.progress}% [${(0, console_log_colors_1.greenBright)(processBar)}] ${(0, console_log_colors_1.cyan)(finished)} ${(0, console_log_colors_1.green)(`${stats.durationDownloaded.toFixed(2)}sec`)} ${(0, console_log_colors_1.blueBright)((0, helper_1.formatByteSize)(stats.downloadedSize))} ${(0, console_log_colors_1.yellowBright)((0, fe_utils_1.formatTimeCost)(startTime))} ${(0, console_log_colors_1.magentaBright)(stats.speedDesc)} ${finished === m3u8Info.tsCount
? '\n'
: stats.remainingTime
? `${(0, console_log_colors_1.cyan)((0, fe_utils_1.formatTimeCost)(Date.now() - stats.remainingTime))}`
: ''}`);
}
if (options.onProgress)
options.onProgress(finished, m3u8Info.tsCount, info, stats);
if (finished === m3u8Info.tsCount) {
// pool.close();
barrier.open();
}
if (options.play && finished === playStart)
(0, local_play_js_1.localPlay)(m3u8Info.data);
});
}
};
if (options.showProgress) {
console.info(`\nTotal segments: ${(0, console_log_colors_1.cyan)(m3u8Info.tsCount)}, duration: ${(0, console_log_colors_1.green)(`${m3u8Info.duration}sec`)}.`, `Parallel jobs: ${(0, console_log_colors_1.magenta)(options.threadNum)}`);
}
result.stats = stats;
(0, local_play_js_1.toLocalM3u8)(m3u8Info.data);
if (options.onInited)
options.onInited(stats, m3u8Info, workPoll);
runTask(m3u8Info.data);
await barrier.wait();
workPoll.close();
if (stats.tsFailed > 0) {
utils_js_1.logger.warn('Download Failed! Please retry!', stats.tsFailed);
}
else if (options.convert !== false) {
result.filepath = await (0, m3u8_convert_js_1.m3u8Convert)(options, m3u8Info.data);
if (result.filepath && (0, node_fs_1.existsSync)(result.filepath)) {
stats.size = (0, node_fs_1.statSync)(result.filepath).size;
if (options.delCache)
(0, fe_utils_1.rmrfAsync)((0, node_path_1.dirname)(m3u8Info.data[0].tsOut));
}
}
}
utils_js_1.logger.debug('Done!', url, result.m3u8Info);
return result;
}
function m3u8DLStop(url, wp = exports.workPollPublic) {
if (!wp?.removeTask)
return 0;
const count = wp.removeTask(task => task.url === url);
// 进行中的任务,最多允许继续下载 10s
if (count === 0 && wp !== exports.workPollPublic)
setTimeout(() => wp.close(), 10_000);
return count;
}