@lzwme/m3u8-dl 1.1.3 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.MD +3 -2
- package/cjs/cli.js +3 -1
- package/cjs/index.d.ts +2 -0
- package/cjs/index.js +2 -0
- package/cjs/lib/file-download.d.ts +2 -0
- package/cjs/lib/file-download.js +81 -0
- package/cjs/lib/format-options.d.ts +2 -0
- package/cjs/lib/format-options.js +80 -0
- package/cjs/lib/m3u8-download.d.ts +2 -10
- package/cjs/lib/m3u8-download.js +14 -7
- package/cjs/lib/parseM3u8.js +8 -1
- package/cjs/lib/utils.d.ts +0 -2
- package/cjs/lib/utils.js +0 -37
- package/cjs/m3u8-batch-download.js +29 -24
- package/cjs/server/download-server.d.ts +4 -1
- package/cjs/server/download-server.js +41 -58
- package/cjs/types/m3u8.d.ts +18 -6
- package/cjs/types/video-parser.d.ts +7 -7
- package/cjs/video-parser/index.d.ts +2 -5
- package/cjs/video-parser/index.js +14 -74
- package/cjs/video-parser/parsers/base-parser.d.ts +1 -1
- package/cjs/video-parser/parsers/base-parser.js +4 -12
- package/client/index.html +25 -14
- package/package.json +10 -9
package/README.MD
CHANGED
|
@@ -128,13 +128,13 @@ for (const filepath of fileList) {
|
|
|
128
128
|
|
|
129
129
|
```bash
|
|
130
130
|
# docker pull ghcr.io/lzwme/m3u8-dl:latest
|
|
131
|
-
docker pull
|
|
131
|
+
docker pull renxia/m3u8dl-dl:latest
|
|
132
132
|
|
|
133
133
|
docker run --rm -it \
|
|
134
134
|
-v ./cache:/app/cache \
|
|
135
135
|
-v ./downloads:/app/downloads \
|
|
136
136
|
-p 6600:6600 \
|
|
137
|
-
|
|
137
|
+
renxia/m3u8dl-dl:latest
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
也可以基于 [docker-compose.yml](./docker/docker-compose.yml) 部署:
|
|
@@ -152,6 +152,7 @@ services:
|
|
|
152
152
|
DS_PORT: '6600'
|
|
153
153
|
DS_SAVE_DIR: '/app/downloads'
|
|
154
154
|
DS_CACHE_DIR: '/app/cache'
|
|
155
|
+
DS_SECRET: '' # 设置访问密码
|
|
155
156
|
DS_DEBUG: ''
|
|
156
157
|
# command: >
|
|
157
158
|
# sh -c "node cjs/server/index.js"
|
package/cjs/cli.js
CHANGED
|
@@ -66,6 +66,7 @@ commander_1.program
|
|
|
66
66
|
.option('--no-del-cache', `下载成功后是否删除临时文件。默认为 true。保存临时文件可以在重复下载时识别缓存`)
|
|
67
67
|
.option('--no-convert', '下载成功后,是否不合并转换为 mp4 文件。默认为 true。')
|
|
68
68
|
.option('-H, --headers <headers>', `自定义请求头。格式为 key1=value1\nkey2=value2`)
|
|
69
|
+
.option('-T, --type <type>', `指定下载类型。默认根据URL自动识别,如果是批量下载多个不同 URL 类型,请不要设置。可选值:m3u8, file, parser`)
|
|
69
70
|
.action(async (urls) => {
|
|
70
71
|
const options = getOptions();
|
|
71
72
|
utils_js_1.logger.debug(urls, options);
|
|
@@ -82,11 +83,12 @@ commander_1.program
|
|
|
82
83
|
.command('server')
|
|
83
84
|
.description('启动下载中心web服务')
|
|
84
85
|
.option('-P, --port <port>', '指定web服务端口。默认为6600')
|
|
85
|
-
.option('--token <token>', '指定web服务密码(请求头authorization)。默认为空')
|
|
86
|
+
.option('-t, --token <token>', '指定web服务密码(请求头authorization)。默认为空')
|
|
86
87
|
.action((options) => {
|
|
87
88
|
const opts = getOptions();
|
|
88
89
|
if (opts.debug)
|
|
89
90
|
options.debug = true;
|
|
91
|
+
console.log(opts, options);
|
|
90
92
|
Promise.resolve().then(() => __importStar(require('./server/download-server.js'))).then(m => {
|
|
91
93
|
new m.DLServer(options);
|
|
92
94
|
});
|
package/cjs/index.d.ts
CHANGED
package/cjs/index.js
CHANGED
|
@@ -15,4 +15,6 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
__exportStar(require("./lib/m3u8-download"), exports);
|
|
18
|
+
__exportStar(require("./lib/file-download"), exports);
|
|
18
19
|
__exportStar(require("./lib/parseM3u8"), exports);
|
|
20
|
+
__exportStar(require("./video-parser"), exports);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fileDownload = fileDownload;
|
|
4
|
+
const node_path_1 = require("node:path");
|
|
5
|
+
const fe_utils_1 = require("@lzwme/fe-utils");
|
|
6
|
+
const console_log_colors_1 = require("console-log-colors");
|
|
7
|
+
const utils_js_1 = require("./utils.js");
|
|
8
|
+
const format_options_js_1 = require("./format-options.js");
|
|
9
|
+
async function fileDownload(url, options) {
|
|
10
|
+
utils_js_1.logger.debug('fileDownload', url, options);
|
|
11
|
+
[url, options] = (0, format_options_js_1.formatOptions)(url, options);
|
|
12
|
+
const startTime = Date.now();
|
|
13
|
+
const stats = {
|
|
14
|
+
url,
|
|
15
|
+
startTime,
|
|
16
|
+
progress: 0,
|
|
17
|
+
tsSuccess: 0,
|
|
18
|
+
tsFailed: 0,
|
|
19
|
+
tsCount: 0,
|
|
20
|
+
duration: 0,
|
|
21
|
+
durationDownloaded: 0,
|
|
22
|
+
downloadedSize: 0,
|
|
23
|
+
avgSpeed: 0,
|
|
24
|
+
avgSpeedDesc: '0B/s',
|
|
25
|
+
speed: 0,
|
|
26
|
+
speedDesc: '0B/s',
|
|
27
|
+
remainingTime: 0,
|
|
28
|
+
filename: options.filename,
|
|
29
|
+
localVideo: (0, node_path_1.resolve)(options.saveDir, options.filename),
|
|
30
|
+
};
|
|
31
|
+
utils_js_1.logger.debug('开始下载', (0, console_log_colors_1.gray)(url), (0, console_log_colors_1.cyan)(stats.localVideo));
|
|
32
|
+
if (options.onInited)
|
|
33
|
+
options.onInited(stats, null, null);
|
|
34
|
+
try {
|
|
35
|
+
const r = await (0, fe_utils_1.download)({
|
|
36
|
+
url,
|
|
37
|
+
filepath: stats.localVideo,
|
|
38
|
+
paralelism: options.threadNum,
|
|
39
|
+
force: options.force,
|
|
40
|
+
requestOptions: {
|
|
41
|
+
headers: {
|
|
42
|
+
referer: url,
|
|
43
|
+
...(0, utils_js_1.formatHeaders)(options.headers),
|
|
44
|
+
},
|
|
45
|
+
rejectUnauthorized: false,
|
|
46
|
+
},
|
|
47
|
+
onProgress: info => {
|
|
48
|
+
stats.progress = +info.percent.toFixed(2);
|
|
49
|
+
stats.size = info.size;
|
|
50
|
+
stats.downloadedSize = info.downloaded;
|
|
51
|
+
stats.speed = info.speed;
|
|
52
|
+
stats.speedDesc = (0, fe_utils_1.formatByteSize)(stats.speed) + '/s';
|
|
53
|
+
stats.remainingTime = Math.round((info.size - info.downloaded) / stats.speed);
|
|
54
|
+
if (options.showProgress) {
|
|
55
|
+
const processBar = info.percent === -1 ? '' : '='.repeat(Math.floor(stats.progress * 0.2)).padEnd(20, '-');
|
|
56
|
+
utils_js_1.logger.logInline(`${stats.progress}% [${(0, console_log_colors_1.greenBright)(processBar)}] ` +
|
|
57
|
+
`${(0, console_log_colors_1.blueBright)((0, fe_utils_1.formatByteSize)(stats.downloadedSize))} ${(0, console_log_colors_1.yellowBright)((0, fe_utils_1.formatTimeCost)(startTime))} ${(0, console_log_colors_1.magentaBright)(stats.speedDesc)} ` +
|
|
58
|
+
(stats.progress === 100 ? '\n' : stats.remainingTime ? `${(0, console_log_colors_1.cyan)((0, fe_utils_1.formatTimeCost)(Date.now() - stats.remainingTime))}` : ''));
|
|
59
|
+
}
|
|
60
|
+
if (options.onProgress) {
|
|
61
|
+
return options.onProgress(info.downloaded, info.size, null, stats);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
stats.endTime = Date.now();
|
|
66
|
+
return {
|
|
67
|
+
errmsg: r.filepath ? '下载完成' : '下载失败',
|
|
68
|
+
...r,
|
|
69
|
+
stats,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
utils_js_1.logger.error('下载失败', error.message, (0, console_log_colors_1.gray)(url));
|
|
74
|
+
stats.errmsg = error.message;
|
|
75
|
+
return {
|
|
76
|
+
isExist: false,
|
|
77
|
+
errmsg: '下载失败: ' + error.message,
|
|
78
|
+
stats,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatOptions = formatOptions;
|
|
4
|
+
const node_os_1 = require("node:os");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const fe_utils_1 = require("@lzwme/fe-utils");
|
|
7
|
+
const utils_1 = require("./utils");
|
|
8
|
+
const video_parser_1 = require("../video-parser");
|
|
9
|
+
const fileSupportExtList = [
|
|
10
|
+
'.mp4',
|
|
11
|
+
'.mkv',
|
|
12
|
+
'.avi',
|
|
13
|
+
'.mov',
|
|
14
|
+
'.wmv',
|
|
15
|
+
'.ts',
|
|
16
|
+
'.exe',
|
|
17
|
+
'.zip',
|
|
18
|
+
'.rar',
|
|
19
|
+
'.pdf',
|
|
20
|
+
'.doc',
|
|
21
|
+
'.docx',
|
|
22
|
+
'.xls',
|
|
23
|
+
'.xlsx',
|
|
24
|
+
'.ppt',
|
|
25
|
+
'.pptx',
|
|
26
|
+
];
|
|
27
|
+
function formatOptions(url, opts) {
|
|
28
|
+
const options = {
|
|
29
|
+
delCache: !opts.debug,
|
|
30
|
+
saveDir: process.cwd(),
|
|
31
|
+
showProgress: true,
|
|
32
|
+
...opts,
|
|
33
|
+
};
|
|
34
|
+
let ext = options.filename ? (0, node_path_1.extname)(options.filename) : '';
|
|
35
|
+
if (!options.type) {
|
|
36
|
+
if (video_parser_1.VideoParser.getPlatform(url).platform !== 'unknown') {
|
|
37
|
+
options.type = 'parser';
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
options.type = 'm3u8';
|
|
41
|
+
if (!url.includes('.m3u8')) {
|
|
42
|
+
const e = fileSupportExtList.find(d => url.includes(d));
|
|
43
|
+
if (e) {
|
|
44
|
+
options.type = 'file';
|
|
45
|
+
ext = e;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
let [u, n] = url.split(/[|$]+/);
|
|
51
|
+
if (n && n.startsWith('http'))
|
|
52
|
+
[u, n] = [n, u];
|
|
53
|
+
url = u;
|
|
54
|
+
if (n) {
|
|
55
|
+
if (!options.filename)
|
|
56
|
+
options.filename = n;
|
|
57
|
+
else
|
|
58
|
+
options.filename = `${options.filename.replace(/\.(ts|mp4)$/, '')}-${n}${ext}`;
|
|
59
|
+
}
|
|
60
|
+
const urlMd5 = (0, fe_utils_1.md5)(url, false);
|
|
61
|
+
if (!options.filename) {
|
|
62
|
+
if (ext && url.includes(ext))
|
|
63
|
+
options.filename = (0, node_path_1.basename)(url.split(ext)[0]) + ext;
|
|
64
|
+
else
|
|
65
|
+
options.filename = urlMd5 + ext;
|
|
66
|
+
}
|
|
67
|
+
if (!(0, node_path_1.extname)(options.filename) && options.type !== 'file')
|
|
68
|
+
options.filename += ext || '.mp4';
|
|
69
|
+
if (!options.cacheDir)
|
|
70
|
+
options.cacheDir = `cache`;
|
|
71
|
+
if (options.headers)
|
|
72
|
+
options.headers = (0, utils_1.formatHeaders)(options.headers);
|
|
73
|
+
if (!options.threadNum || +options.threadNum <= 0)
|
|
74
|
+
options.threadNum = Math.min((0, node_os_1.cpus)().length * 2, 8);
|
|
75
|
+
if (options.debug) {
|
|
76
|
+
utils_1.logger.updateOptions({ levelType: 'debug' });
|
|
77
|
+
utils_1.logger.debug('[m3u8-DL]options', options, url);
|
|
78
|
+
}
|
|
79
|
+
return [url, options, urlMd5];
|
|
80
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { M3u8DLOptions, M3u8WorkerPool } from '../types/m3u8.js';
|
|
1
|
+
import type { M3u8DLOptions, M3u8DLResult, M3u8WorkerPool } from '../types/m3u8.js';
|
|
3
2
|
/** 下载队列管理 */
|
|
4
3
|
export declare class DownloadQueue {
|
|
5
4
|
private queue;
|
|
@@ -23,12 +22,5 @@ export declare class DownloadQueue {
|
|
|
23
22
|
export declare const downloadQueue: DownloadQueue;
|
|
24
23
|
export declare const workPollPublic: M3u8WorkerPool;
|
|
25
24
|
export declare function preDownLoad(url: string, options: M3u8DLOptions, wp?: M3u8WorkerPool): Promise<void>;
|
|
26
|
-
export declare function m3u8Download(url: string, options?: M3u8DLOptions): Promise<
|
|
27
|
-
filepath?: string;
|
|
28
|
-
error?: Error;
|
|
29
|
-
} | {
|
|
30
|
-
options: M3u8DLOptions;
|
|
31
|
-
m3u8Info: Awaited<ReturnType<typeof parseM3U8>> | null;
|
|
32
|
-
filepath: string;
|
|
33
|
-
}>;
|
|
25
|
+
export declare function m3u8Download(url: string, options?: M3u8DLOptions): Promise<M3u8DLResult>;
|
|
34
26
|
export declare function m3u8DLStop(url: string, wp?: M3u8WorkerPool): number;
|
package/cjs/lib/m3u8-download.js
CHANGED
|
@@ -10,6 +10,7 @@ const fe_utils_1 = require("@lzwme/fe-utils");
|
|
|
10
10
|
const helper_1 = require("@lzwme/fe-utils/cjs/common/helper");
|
|
11
11
|
const console_log_colors_1 = require("console-log-colors");
|
|
12
12
|
const utils_js_1 = require("./utils.js");
|
|
13
|
+
const format_options_js_1 = require("./format-options.js");
|
|
13
14
|
const worker_pool_js_1 = require("./worker_pool.js");
|
|
14
15
|
const parseM3u8_js_1 = require("./parseM3u8.js");
|
|
15
16
|
const m3u8_convert_js_1 = require("./m3u8-convert.js");
|
|
@@ -43,11 +44,12 @@ class DownloadQueue {
|
|
|
43
44
|
try {
|
|
44
45
|
const { maxDownloads, ...options } = next.options;
|
|
45
46
|
const result = await m3u8Download(next.url, options);
|
|
46
|
-
next.options.onComplete?.(
|
|
47
|
+
next.options.onComplete?.(result);
|
|
47
48
|
}
|
|
48
49
|
catch (error) {
|
|
49
50
|
next.options.onComplete?.({
|
|
50
|
-
|
|
51
|
+
errmsg: error instanceof Error ? error.message : error ? JSON.stringify(error) : 'Unknown error',
|
|
52
|
+
options: next.options,
|
|
51
53
|
});
|
|
52
54
|
}
|
|
53
55
|
finally {
|
|
@@ -85,7 +87,8 @@ const cache = {
|
|
|
85
87
|
const tsDlFile = (0, node_path_1.resolve)(__dirname, './ts-download.js');
|
|
86
88
|
exports.workPollPublic = new worker_pool_js_1.WorkerPool(tsDlFile);
|
|
87
89
|
async function m3u8InfoParse(url, options = {}) {
|
|
88
|
-
|
|
90
|
+
let urlMd5 = '';
|
|
91
|
+
[url, options, urlMd5] = (0, format_options_js_1.formatOptions)(url, options);
|
|
89
92
|
const ext = (0, utils_js_1.isSupportFfmpeg)() ? '.mp4' : '.ts';
|
|
90
93
|
/** 最终合并转换后的文件路径 */
|
|
91
94
|
let filepath = (0, node_path_1.resolve)(options.saveDir, options.filename);
|
|
@@ -98,7 +101,7 @@ async function m3u8InfoParse(url, options = {}) {
|
|
|
98
101
|
}
|
|
99
102
|
if (!options.force && (0, node_fs_1.existsSync)(filepath))
|
|
100
103
|
return result;
|
|
101
|
-
const m3u8Info = await (0, parseM3u8_js_1.parseM3U8)(url, (0, node_path_1.resolve)(options.cacheDir,
|
|
104
|
+
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));
|
|
102
105
|
if (m3u8Info && m3u8Info?.tsCount > 0)
|
|
103
106
|
result.m3u8Info = m3u8Info;
|
|
104
107
|
return result;
|
|
@@ -142,8 +145,10 @@ async function m3u8Download(url, options = {}) {
|
|
|
142
145
|
return new Promise(resolve => {
|
|
143
146
|
const newOptions = {
|
|
144
147
|
...options,
|
|
145
|
-
onComplete:
|
|
146
|
-
|
|
148
|
+
onComplete: r => {
|
|
149
|
+
if (options.onComplete)
|
|
150
|
+
options.onComplete(r);
|
|
151
|
+
resolve(r);
|
|
147
152
|
},
|
|
148
153
|
};
|
|
149
154
|
exports.downloadQueue.add(url, newOptions, options.priority || 0);
|
|
@@ -155,6 +160,7 @@ async function m3u8Download(url, options = {}) {
|
|
|
155
160
|
options = result.options;
|
|
156
161
|
if (!options.force && (0, node_fs_1.existsSync)(result.filepath) && !result.m3u8Info) {
|
|
157
162
|
utils_js_1.logger.info('file already exist:', result.filepath);
|
|
163
|
+
result.isExist = true;
|
|
158
164
|
return result;
|
|
159
165
|
}
|
|
160
166
|
if (result.m3u8Info?.tsCount > 0) {
|
|
@@ -261,7 +267,8 @@ async function m3u8Download(url, options = {}) {
|
|
|
261
267
|
if (options.showProgress) {
|
|
262
268
|
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)}`);
|
|
263
269
|
}
|
|
264
|
-
|
|
270
|
+
result.stats = stats;
|
|
271
|
+
(0, local_play_js_1.toLocalM3u8)(m3u8Info.data);
|
|
265
272
|
if (options.onInited)
|
|
266
273
|
options.onInited(stats, m3u8Info, workPoll);
|
|
267
274
|
runTask(m3u8Info.data);
|
package/cjs/lib/parseM3u8.js
CHANGED
|
@@ -30,7 +30,14 @@ async function parseM3U8(content, cacheDir = './cache', headers) {
|
|
|
30
30
|
parser.end();
|
|
31
31
|
utils_1.logger.debug('parser.manifest', parser.manifest);
|
|
32
32
|
if (parser.manifest.playlists?.length > 0) {
|
|
33
|
-
|
|
33
|
+
let maxBandwidthItem = parser.manifest.playlists[0];
|
|
34
|
+
for (const item of parser.manifest.playlists) {
|
|
35
|
+
if (!maxBandwidthItem || (item.attributes?.BANDWIDTH || 0) > (maxBandwidthItem.attributes?.BANDWIDTH || 0)) {
|
|
36
|
+
maxBandwidthItem = item;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
url = new URL(maxBandwidthItem.uri, url).toString();
|
|
40
|
+
utils_1.logger.debug('maxBandwidthItem', maxBandwidthItem, url);
|
|
34
41
|
content = (await (0, utils_1.getRetry)(url, headers)).data;
|
|
35
42
|
parser = new m3u8_parser_1.Parser();
|
|
36
43
|
parser.push(content);
|
package/cjs/lib/utils.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { IncomingHttpHeaders } from 'node:http';
|
|
2
2
|
import { Stats } from 'node:fs';
|
|
3
3
|
import { NLogger, Request } from '@lzwme/fe-utils';
|
|
4
|
-
import { M3u8DLOptions } from '../types';
|
|
5
4
|
export declare const request: Request;
|
|
6
5
|
export declare const getRetry: <T = string>(url: string, headers?: IncomingHttpHeaders, retries?: number) => Promise<{
|
|
7
6
|
data: T;
|
|
@@ -19,4 +18,3 @@ export declare function getLocation(url: string, method?: string): Promise<strin
|
|
|
19
18
|
* 如果 headers 是字符串,会先将其解析为对象;如果 headers 为空,则返回空对象。
|
|
20
19
|
*/
|
|
21
20
|
export declare function formatHeaders(headers: string | IncomingHttpHeaders): Record<string, string>;
|
|
22
|
-
export declare function formatOptions(url: string, opts: M3u8DLOptions): readonly [string, M3u8DLOptions];
|
package/cjs/lib/utils.js
CHANGED
|
@@ -5,8 +5,6 @@ exports.isSupportFfmpeg = isSupportFfmpeg;
|
|
|
5
5
|
exports.findFiles = findFiles;
|
|
6
6
|
exports.getLocation = getLocation;
|
|
7
7
|
exports.formatHeaders = formatHeaders;
|
|
8
|
-
exports.formatOptions = formatOptions;
|
|
9
|
-
const node_os_1 = require("node:os");
|
|
10
8
|
const node_fs_1 = require("node:fs");
|
|
11
9
|
const node_path_1 = require("node:path");
|
|
12
10
|
const fe_utils_1 = require("@lzwme/fe-utils");
|
|
@@ -68,38 +66,3 @@ function formatHeaders(headers) {
|
|
|
68
66
|
return {};
|
|
69
67
|
return (0, fe_utils_1.toLowcaseKeyObject)(headers);
|
|
70
68
|
}
|
|
71
|
-
function formatOptions(url, opts) {
|
|
72
|
-
const options = {
|
|
73
|
-
delCache: !opts.debug,
|
|
74
|
-
saveDir: process.cwd(),
|
|
75
|
-
showProgress: true,
|
|
76
|
-
...opts,
|
|
77
|
-
};
|
|
78
|
-
if (!url.startsWith('http')) {
|
|
79
|
-
url = url.replace(/\$+/, '|').replace(/\|\|+/, '|');
|
|
80
|
-
if (url.includes('|')) {
|
|
81
|
-
const r = url.split('|');
|
|
82
|
-
url = r[1];
|
|
83
|
-
if (!options.filename)
|
|
84
|
-
options.filename = r[0];
|
|
85
|
-
else
|
|
86
|
-
options.filename = `${options.filename.replace(/\.(ts|mp4)$/, '')}-${r[0]}`;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
const urlMd5 = (0, fe_utils_1.md5)(url, false);
|
|
90
|
-
if (!options.threadNum || +options.threadNum <= 0)
|
|
91
|
-
options.threadNum = Math.min((0, node_os_1.cpus)().length * 2, 8);
|
|
92
|
-
if (!options.filename)
|
|
93
|
-
options.filename = urlMd5;
|
|
94
|
-
if (!options.filename.endsWith('.mp4'))
|
|
95
|
-
options.filename += '.mp4';
|
|
96
|
-
if (!options.cacheDir)
|
|
97
|
-
options.cacheDir = `cache`;
|
|
98
|
-
if (options.headers)
|
|
99
|
-
options.headers = formatHeaders(options.headers);
|
|
100
|
-
if (options.debug) {
|
|
101
|
-
exports.logger.updateOptions({ levelType: 'debug' });
|
|
102
|
-
exports.logger.debug('[m3u8-DL]options', options, url);
|
|
103
|
-
}
|
|
104
|
-
return [url, options];
|
|
105
|
-
}
|
|
@@ -5,7 +5,7 @@ exports.m3u8BatchDownload = m3u8BatchDownload;
|
|
|
5
5
|
* @Author: renxia lzwy0820@qq.com
|
|
6
6
|
* @Date: 2024-07-30 08:57:58
|
|
7
7
|
* @LastEditors: renxia
|
|
8
|
-
* @LastEditTime: 2025-05-
|
|
8
|
+
* @LastEditTime: 2025-05-19 17:03:50
|
|
9
9
|
* @FilePath: \m3u8-dl\src\m3u8-batch-download.ts
|
|
10
10
|
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
|
11
11
|
*/
|
|
@@ -15,9 +15,11 @@ const m3u8_download_1 = require("./lib/m3u8-download");
|
|
|
15
15
|
const utils_1 = require("./lib/utils");
|
|
16
16
|
const video_parser_1 = require("./video-parser");
|
|
17
17
|
const console_log_colors_1 = require("console-log-colors");
|
|
18
|
+
const file_download_1 = require("./lib/file-download");
|
|
19
|
+
const format_options_1 = require("./lib/format-options");
|
|
18
20
|
async function formatUrls(urls, options) {
|
|
19
21
|
const taskset = new Map();
|
|
20
|
-
for (
|
|
22
|
+
for (let url of urls) {
|
|
21
23
|
if (!url)
|
|
22
24
|
continue;
|
|
23
25
|
if ((0, node_fs_1.existsSync)(url)) {
|
|
@@ -40,6 +42,7 @@ async function formatUrls(urls, options) {
|
|
|
40
42
|
continue;
|
|
41
43
|
}
|
|
42
44
|
}
|
|
45
|
+
[url, options] = (0, format_options_1.formatOptions)(url, options);
|
|
43
46
|
taskset.set(url, options);
|
|
44
47
|
}
|
|
45
48
|
return taskset;
|
|
@@ -49,50 +52,52 @@ async function m3u8BatchDownload(urls, options) {
|
|
|
49
52
|
let workPoll;
|
|
50
53
|
return new Promise(rs => {
|
|
51
54
|
let preDLing = false;
|
|
55
|
+
const afterDownload = (r, url) => {
|
|
56
|
+
const success = r.filepath && (0, node_fs_1.existsSync)(r.filepath);
|
|
57
|
+
if (success) {
|
|
58
|
+
if (r.isExist)
|
|
59
|
+
utils_1.logger.info('文件已存在:', (0, console_log_colors_1.gray)(r.filepath));
|
|
60
|
+
utils_1.logger.debug('下载完成:', (0, console_log_colors_1.gray)(url), (0, console_log_colors_1.gray)(r.filepath));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
utils_1.logger.error('下载失败:', (0, console_log_colors_1.red)(r.errmsg || '未知错误'), (0, console_log_colors_1.gray)(url));
|
|
64
|
+
}
|
|
65
|
+
if (tasks.size === 0)
|
|
66
|
+
rs(r.filepath && (0, node_fs_1.existsSync)(r.filepath));
|
|
67
|
+
else
|
|
68
|
+
run();
|
|
69
|
+
};
|
|
52
70
|
const run = () => {
|
|
53
71
|
const [url, urlNext] = [...tasks.keys()];
|
|
54
72
|
if (url) {
|
|
55
|
-
const o =
|
|
73
|
+
const o = tasks.get(url);
|
|
56
74
|
const onProgress = o.onProgress;
|
|
57
|
-
const useVideoParser = video_parser_1.VideoParser.getPlatform(url) !== null;
|
|
58
75
|
tasks.delete(url);
|
|
59
76
|
o.onInited = (s, _i, wp) => {
|
|
60
77
|
if (workPoll)
|
|
61
78
|
workPoll = wp;
|
|
62
|
-
if (
|
|
79
|
+
if (o.type === 'parser') {
|
|
63
80
|
utils_1.logger.info('视频解析完成:', s.filename, (0, console_log_colors_1.gray)(s.url));
|
|
64
81
|
}
|
|
65
82
|
};
|
|
66
83
|
o.onProgress = (finished, total, info, stats) => {
|
|
67
84
|
if (onProgress)
|
|
68
85
|
onProgress(finished, total, info, stats);
|
|
69
|
-
if (
|
|
86
|
+
if (o.type === 'm3u8' && !preDLing && urlNext && tasks.size && workPoll.freeNum > 1 && total - finished < options.threadNum) {
|
|
70
87
|
utils_1.logger.debug('\n[预下载下一集]', 'freeNum:', workPoll.freeNum, 'totalNum:', workPoll.totalNum, 'totalTask:', workPoll.totalTask, tasks.size);
|
|
71
88
|
preDLing = true;
|
|
72
89
|
(0, m3u8_download_1.preDownLoad)(urlNext, options, workPoll).then(() => (preDLing = false));
|
|
73
90
|
}
|
|
74
91
|
};
|
|
75
|
-
if (
|
|
92
|
+
if (o.type === 'parser') {
|
|
76
93
|
const vp = new video_parser_1.VideoParser();
|
|
77
|
-
vp.download(url, o).then(r =>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
else {
|
|
82
|
-
if (r.data?.isExist)
|
|
83
|
-
utils_1.logger.info('文件已存在:', (0, console_log_colors_1.gray)(r.data.filepath));
|
|
84
|
-
utils_1.logger.debug('下载完成:', (0, console_log_colors_1.gray)(url), (0, console_log_colors_1.gray)(r.data.filepath));
|
|
85
|
-
}
|
|
86
|
-
if (tasks.size === 0) {
|
|
87
|
-
rs(r.data.filepath && (0, node_fs_1.existsSync)(r.data.filepath));
|
|
88
|
-
}
|
|
89
|
-
else {
|
|
90
|
-
run();
|
|
91
|
-
}
|
|
92
|
-
});
|
|
94
|
+
vp.download(url, o).then(r => afterDownload(r, url));
|
|
95
|
+
}
|
|
96
|
+
else if (o.type === 'file') {
|
|
97
|
+
(0, file_download_1.fileDownload)(url, o).then(r => afterDownload(r, url));
|
|
93
98
|
}
|
|
94
99
|
else {
|
|
95
|
-
(0, m3u8_download_1.m3u8Download)(url, o).then(r => (
|
|
100
|
+
(0, m3u8_download_1.m3u8Download)(url, o).then(r => afterDownload(r, url));
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
103
|
};
|
|
@@ -6,12 +6,15 @@ interface DLServerOptions {
|
|
|
6
6
|
cacheDir?: string;
|
|
7
7
|
configPath?: string;
|
|
8
8
|
debug?: boolean;
|
|
9
|
-
/** 登录 token,默认取环境变量
|
|
9
|
+
/** 登录 token,默认取环境变量 DS_SECRET */
|
|
10
10
|
token?: string;
|
|
11
11
|
}
|
|
12
12
|
interface CacheItem extends Partial<M3u8DLProgressStats> {
|
|
13
13
|
url: string;
|
|
14
|
+
/** 用户设置的参数 */
|
|
14
15
|
options: M3u8DLOptions;
|
|
16
|
+
/** 格式化后实际下载使用的参数 */
|
|
17
|
+
dlOptions?: M3u8DLOptions;
|
|
15
18
|
status: 'pause' | 'resume' | 'done' | 'pending' | 'error';
|
|
16
19
|
current?: TsItemInfo;
|
|
17
20
|
workPoll?: M3u8WorkerPool;
|
|
@@ -34,11 +34,6 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.DLServer = void 0;
|
|
37
|
-
/*
|
|
38
|
-
* @Author: renxia lzwy0820@qq.com
|
|
39
|
-
* @Date: 2025-05-07 21:03:09
|
|
40
|
-
* @LastEditors: renxia
|
|
41
|
-
*/
|
|
42
37
|
const node_fs_1 = require("node:fs");
|
|
43
38
|
const node_path_1 = require("node:path");
|
|
44
39
|
const fe_utils_1 = require("@lzwme/fe-utils");
|
|
@@ -46,6 +41,8 @@ const m3u8_download_js_1 = require("../lib/m3u8-download.js");
|
|
|
46
41
|
const utils_js_1 = require("../lib/utils.js");
|
|
47
42
|
const index_js_1 = require("../video-parser/index.js");
|
|
48
43
|
const console_log_colors_1 = require("console-log-colors");
|
|
44
|
+
const file_download_js_1 = require("../lib/file-download.js");
|
|
45
|
+
const format_options_js_1 = require("../lib/format-options.js");
|
|
49
46
|
class DLServer {
|
|
50
47
|
app = null;
|
|
51
48
|
wss = null;
|
|
@@ -53,7 +50,7 @@ class DLServer {
|
|
|
53
50
|
options = {
|
|
54
51
|
port: Number(process.env.DS_PORT) || 6600,
|
|
55
52
|
cacheDir: (0, node_path_1.resolve)(process.cwd(), './cache'),
|
|
56
|
-
token: process.env.DS_TOKEN || '',
|
|
53
|
+
token: process.env.DS_SECRET || process.env.DS_TOKEN || '',
|
|
57
54
|
debug: process.env.DS_DEBUG == '1',
|
|
58
55
|
};
|
|
59
56
|
serverInfo = {
|
|
@@ -74,6 +71,7 @@ class DLServer {
|
|
|
74
71
|
dlOptions: {
|
|
75
72
|
debug: process.env.DS_DEBUG == '1',
|
|
76
73
|
saveDir: process.env.DS_SAVE_DIR || './downloads',
|
|
74
|
+
threadNum: 4,
|
|
77
75
|
},
|
|
78
76
|
};
|
|
79
77
|
/** 下载任务缓存 */
|
|
@@ -91,6 +89,8 @@ class DLServer {
|
|
|
91
89
|
const pkg = JSON.parse((0, node_fs_1.readFileSync)(pkgFile, 'utf8'));
|
|
92
90
|
this.serverInfo.version = pkg.version;
|
|
93
91
|
}
|
|
92
|
+
if (opts.token)
|
|
93
|
+
opts.token = (0, fe_utils_1.md5)(opts.token.trim()).slice(0, 8);
|
|
94
94
|
this.init();
|
|
95
95
|
}
|
|
96
96
|
async init() {
|
|
@@ -175,11 +175,10 @@ class DLServer {
|
|
|
175
175
|
const { default: express } = await Promise.resolve().then(() => __importStar(require('express')));
|
|
176
176
|
const { WebSocketServer } = await Promise.resolve().then(() => __importStar(require('ws')));
|
|
177
177
|
const app = (this.app = express());
|
|
178
|
-
const server = app.listen(this.options.port, () => utils_js_1.logger.info(`Server running on port ${this.options.port}`));
|
|
178
|
+
const server = app.listen(this.options.port, () => utils_js_1.logger.info(`Server running on port ${(0, console_log_colors_1.green)(this.options.port)}`));
|
|
179
179
|
const wss = (this.wss = new WebSocketServer({ server }));
|
|
180
180
|
app.use(express.json());
|
|
181
181
|
app.use(express.static((0, node_path_1.resolve)(__dirname, '../../client')));
|
|
182
|
-
// headers
|
|
183
182
|
app.use((req, res, next) => {
|
|
184
183
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
185
184
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
|
|
@@ -213,28 +212,15 @@ class DLServer {
|
|
|
213
212
|
}
|
|
214
213
|
ws.send(JSON.stringify({ type: 'serverInfo', data: this.serverInfo }));
|
|
215
214
|
ws.send(JSON.stringify({ type: 'tasks', data: Object.fromEntries(this.dlCacheClone()) }));
|
|
216
|
-
// ws.on('message', (message, _isBinary) => {
|
|
217
|
-
// logger.info('Received message from client:', (message as Buffer).toString('utf8'));
|
|
218
|
-
// });
|
|
219
|
-
});
|
|
220
|
-
wss.on('close', () => {
|
|
221
|
-
utils_js_1.logger.info('WebSocket server closed');
|
|
222
|
-
});
|
|
223
|
-
wss.on('error', err => {
|
|
224
|
-
utils_js_1.logger.error('WebSocket server error:', err);
|
|
225
|
-
});
|
|
226
|
-
wss.on('listening', () => {
|
|
227
|
-
utils_js_1.logger.info(`WebSocket server listening on port ${this.options.port}`);
|
|
228
215
|
});
|
|
216
|
+
wss.on('close', () => utils_js_1.logger.info('WebSocket server closed'));
|
|
217
|
+
wss.on('error', err => utils_js_1.logger.error('WebSocket server error:', err));
|
|
218
|
+
wss.on('listening', () => utils_js_1.logger.info(`WebSocket server listening on port ${(0, console_log_colors_1.green)(this.options.port)}`));
|
|
229
219
|
return { app, wss };
|
|
230
220
|
}
|
|
231
221
|
startDownload(url, options) {
|
|
232
222
|
const cacheItem = this.dlCache.get(url);
|
|
233
|
-
const dlOptions = {
|
|
234
|
-
...this.cfg.dlOptions,
|
|
235
|
-
...options,
|
|
236
|
-
cacheDir: this.options.cacheDir,
|
|
237
|
-
};
|
|
223
|
+
const dlOptions = (0, format_options_js_1.formatOptions)(url, { ...this.cfg.dlOptions, ...options, cacheDir: this.options.cacheDir })[1];
|
|
238
224
|
utils_js_1.logger.debug('startDownload', url, dlOptions, cacheItem?.status);
|
|
239
225
|
if (cacheItem && cacheItem?.status === 'resume')
|
|
240
226
|
return cacheItem.options;
|
|
@@ -242,12 +228,11 @@ class DLServer {
|
|
|
242
228
|
if (cacheItem)
|
|
243
229
|
cacheItem.status = 'pending';
|
|
244
230
|
else
|
|
245
|
-
this.dlCache.set(url, { options
|
|
231
|
+
this.dlCache.set(url, { options, dlOptions, status: 'pending', url });
|
|
246
232
|
return cacheItem?.options || dlOptions;
|
|
247
233
|
}
|
|
248
234
|
let workPoll = cacheItem?.workPoll;
|
|
249
|
-
const
|
|
250
|
-
const defaultItem = { options: dlOptions, status: 'resume', url };
|
|
235
|
+
const defaultItem = { options, dlOptions, status: 'resume', url };
|
|
251
236
|
const opts = {
|
|
252
237
|
...dlOptions,
|
|
253
238
|
showProgress: dlOptions.debug || this.options.debug,
|
|
@@ -259,20 +244,21 @@ class DLServer {
|
|
|
259
244
|
this.dlCache.set(url, item);
|
|
260
245
|
this.saveCache();
|
|
261
246
|
this.wsSend('progress', url);
|
|
247
|
+
return status !== 'pause';
|
|
262
248
|
},
|
|
263
249
|
};
|
|
264
|
-
const afterDownload = (
|
|
250
|
+
const afterDownload = (r, url) => {
|
|
265
251
|
const item = this.dlCache.get(url) || defaultItem;
|
|
266
|
-
if (filepath && (0, node_fs_1.existsSync)(filepath)) {
|
|
267
|
-
item.localVideo = filepath;
|
|
268
|
-
item.downloadedSize = (0, node_fs_1.statSync)(filepath).size;
|
|
252
|
+
if (r.filepath && (0, node_fs_1.existsSync)(r.filepath)) {
|
|
253
|
+
item.localVideo = r.filepath;
|
|
254
|
+
item.downloadedSize = (0, node_fs_1.statSync)(r.filepath).size;
|
|
269
255
|
}
|
|
270
|
-
else if (!errmsg && opts.convert !== false)
|
|
271
|
-
errmsg = '下载失败';
|
|
256
|
+
else if (!r.errmsg && opts.convert !== false)
|
|
257
|
+
r.errmsg = '下载失败';
|
|
272
258
|
item.endTime = Date.now();
|
|
273
|
-
item.
|
|
274
|
-
item.
|
|
275
|
-
utils_js_1.logger.info('Download complete:', item.status, (0, console_log_colors_1.red)(
|
|
259
|
+
item.errmsg = r.errmsg;
|
|
260
|
+
item.status = r.errmsg ? 'error' : 'done';
|
|
261
|
+
utils_js_1.logger.info('Download complete:', item.status, (0, console_log_colors_1.red)(r.errmsg), (0, console_log_colors_1.gray)(url), (0, console_log_colors_1.cyan)(r.filepath));
|
|
276
262
|
this.dlCache.set(url, item);
|
|
277
263
|
this.wsSend('progress', url);
|
|
278
264
|
this.saveCache();
|
|
@@ -288,21 +274,19 @@ class DLServer {
|
|
|
288
274
|
if (cacheItem)
|
|
289
275
|
cacheItem.status = 'resume';
|
|
290
276
|
try {
|
|
291
|
-
if (
|
|
277
|
+
if (dlOptions.type === 'parser') {
|
|
292
278
|
const vp = new index_js_1.VideoParser();
|
|
293
|
-
vp.download(url, opts).then(r =>
|
|
294
|
-
|
|
295
|
-
|
|
279
|
+
vp.download(url, opts).then(r => afterDownload(r, url));
|
|
280
|
+
}
|
|
281
|
+
else if (dlOptions.type === 'file') {
|
|
282
|
+
(0, file_download_js_1.fileDownload)(url, opts).then(r => afterDownload(r, url));
|
|
296
283
|
}
|
|
297
284
|
else {
|
|
298
|
-
(0, m3u8_download_js_1.m3u8Download)(url, opts).then(r =>
|
|
299
|
-
const errmsg = 'error' in r ? r.error.cause || r.error.message : '';
|
|
300
|
-
afterDownload({ filepath: r.filepath, errmsg });
|
|
301
|
-
});
|
|
285
|
+
(0, m3u8_download_js_1.m3u8Download)(url, opts).then(r => afterDownload(r, url));
|
|
302
286
|
}
|
|
303
287
|
}
|
|
304
288
|
catch (error) {
|
|
305
|
-
afterDownload({ filepath: '', errmsg: error.message });
|
|
289
|
+
afterDownload({ filepath: '', errmsg: error.message }, url);
|
|
306
290
|
utils_js_1.logger.error('下载失败:', error);
|
|
307
291
|
}
|
|
308
292
|
return dlOptions;
|
|
@@ -315,8 +299,10 @@ class DLServer {
|
|
|
315
299
|
const item = this.dlCache.get(data);
|
|
316
300
|
if (item) {
|
|
317
301
|
const { workPoll, ...stats } = item;
|
|
318
|
-
data = { ...stats, url: data };
|
|
302
|
+
data = [{ ...stats, url: data }];
|
|
319
303
|
}
|
|
304
|
+
else
|
|
305
|
+
return;
|
|
320
306
|
}
|
|
321
307
|
// 广播进度信息给所有客户端
|
|
322
308
|
this.wss.clients.forEach(client => {
|
|
@@ -330,13 +316,11 @@ class DLServer {
|
|
|
330
316
|
app.get('/healthcheck', (_req, res) => {
|
|
331
317
|
res.json({ message: 'ok', code: 0 });
|
|
332
318
|
});
|
|
333
|
-
// API to set default config
|
|
334
319
|
app.post('/config', (req, res) => {
|
|
335
320
|
const config = req.body;
|
|
336
321
|
this.saveConfig(config);
|
|
337
322
|
res.json({ message: 'Config updated successfully', code: 0 });
|
|
338
323
|
});
|
|
339
|
-
// API to get default config
|
|
340
324
|
app.get('/config', (_req, res) => {
|
|
341
325
|
res.json({ ...this.cfg.dlOptions, ...this.cfg.webOptions });
|
|
342
326
|
});
|
|
@@ -406,7 +390,7 @@ class DLServer {
|
|
|
406
390
|
const list = [];
|
|
407
391
|
for (const url of urlsToPause) {
|
|
408
392
|
const { workPoll, ...item } = this.dlCache.get(url);
|
|
409
|
-
if (item?.status
|
|
393
|
+
if (['resume', 'pending'].includes(item?.status)) {
|
|
410
394
|
(0, m3u8_download_js_1.m3u8DLStop)(url, workPoll);
|
|
411
395
|
item.status = item.tsSuccess === item.tsCount ? 'done' : 'pause';
|
|
412
396
|
list.push(item);
|
|
@@ -422,18 +406,18 @@ class DLServer {
|
|
|
422
406
|
const urlsToResume = all ? [...this.dlCache.keys()] : urls;
|
|
423
407
|
const list = [];
|
|
424
408
|
for (const url of urlsToResume) {
|
|
425
|
-
const
|
|
409
|
+
const item = this.dlCache.get(url);
|
|
426
410
|
if (['pause', 'error'].includes(item?.status)) {
|
|
427
411
|
this.startDownload(url, item.options);
|
|
428
|
-
|
|
412
|
+
const { workPoll, ...t } = item;
|
|
413
|
+
list.push(t);
|
|
429
414
|
}
|
|
430
415
|
else
|
|
431
416
|
console.log(item?.status, url);
|
|
432
417
|
}
|
|
433
|
-
|
|
434
|
-
if (count)
|
|
418
|
+
if (list.length)
|
|
435
419
|
this.wsSend('progress', list);
|
|
436
|
-
res.json({ message:
|
|
420
|
+
res.json({ message: list.length ? `已恢复 ${list.length} 个下载任务` : '没有找到可恢复的下载任务', code: 0, count: list.length });
|
|
437
421
|
});
|
|
438
422
|
// API to delete download
|
|
439
423
|
app.post('/delete', (req, res) => {
|
|
@@ -460,10 +444,9 @@ class DLServer {
|
|
|
460
444
|
}
|
|
461
445
|
}
|
|
462
446
|
}
|
|
463
|
-
|
|
464
|
-
if (count)
|
|
447
|
+
if (list.length)
|
|
465
448
|
this.wsSend('delete', list);
|
|
466
|
-
res.json({ message: `已删除 ${
|
|
449
|
+
res.json({ message: `已删除 ${list.length} 个下载任务`, code: 0, count: list.length });
|
|
467
450
|
});
|
|
468
451
|
app.get(/^\/localplay\/(.*)$/, (req, res) => {
|
|
469
452
|
let filepath = decodeURIComponent(req.params[0]);
|
package/cjs/types/m3u8.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AnyObject } from '@lzwme/fe-utils';
|
|
1
|
+
import type { AnyObject, DownloadResult } from '@lzwme/fe-utils';
|
|
2
2
|
import type { IncomingHttpHeaders } from 'node:http';
|
|
3
3
|
import type { WorkerPool } from '../lib/worker_pool';
|
|
4
4
|
export interface TsItemInfo {
|
|
@@ -100,12 +100,9 @@ export interface M3u8DLOptions {
|
|
|
100
100
|
/** 当初始化完成、下载开始时回调 */
|
|
101
101
|
onInited?: (stats: M3u8DLProgressStats, m3u8Info: M3u8Info, workPoll: M3u8WorkerPool) => void;
|
|
102
102
|
/** 每当 ts 文件下载完成时回调,可用于自定义进度控制 */
|
|
103
|
-
onProgress?: (finished: number, total: number, currentInfo: TsItemInfo, stats: M3u8DLProgressStats) => void;
|
|
103
|
+
onProgress?: (finished: number, total: number, currentInfo: TsItemInfo, stats: M3u8DLProgressStats) => void | boolean;
|
|
104
104
|
/** 下载完成时回调,主要用于内部多任务管理 */
|
|
105
|
-
onComplete?: (result:
|
|
106
|
-
error?: Error;
|
|
107
|
-
filepath?: string;
|
|
108
|
-
}) => void;
|
|
105
|
+
onComplete?: (result: M3u8DLResult) => void;
|
|
109
106
|
/** 并发下载线程数。取决于服务器限制,过多可能会容易下载失败。一般建议不超过 8 个。默认为 cpu数 * 2,但不超过 8 */
|
|
110
107
|
threadNum?: number;
|
|
111
108
|
/** 最大并发下载任务数 */
|
|
@@ -128,6 +125,21 @@ export interface M3u8DLOptions {
|
|
|
128
125
|
play?: boolean;
|
|
129
126
|
/** 下载完毕后,是否合并转换为 mp4 或 ts 文件。默认为 true */
|
|
130
127
|
convert?: boolean;
|
|
128
|
+
/**
|
|
129
|
+
* 下载类型。默认自动识别
|
|
130
|
+
* - 'm3u8':下载 m3u8 文件
|
|
131
|
+
* - 'file':下载普通文件
|
|
132
|
+
* - 'parser':下载 VideoParser 支持解析的平台视频文件
|
|
133
|
+
*/
|
|
134
|
+
type?: 'm3u8' | 'file' | 'parser';
|
|
135
|
+
}
|
|
136
|
+
export interface M3u8DLResult extends Partial<DownloadResult> {
|
|
137
|
+
/** 下载进度统计 */
|
|
138
|
+
stats?: M3u8DLProgressStats;
|
|
139
|
+
/** 下载选项 */
|
|
140
|
+
options?: M3u8DLOptions;
|
|
141
|
+
/** m3u8 文件信息 */
|
|
142
|
+
m3u8Info?: M3u8Info;
|
|
131
143
|
}
|
|
132
144
|
export interface WorkerTaskInfo {
|
|
133
145
|
/** m3u8 文件地址 */
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export interface VideoInfo {
|
|
2
|
-
author: string;
|
|
3
|
-
uid: string;
|
|
4
|
-
avatar: string;
|
|
5
|
-
like: number;
|
|
6
|
-
time: number;
|
|
7
|
-
title: string;
|
|
8
|
-
cover: string;
|
|
9
2
|
url: string;
|
|
3
|
+
title: string;
|
|
4
|
+
author?: string;
|
|
5
|
+
avatar?: string;
|
|
6
|
+
time?: number;
|
|
7
|
+
cover?: string;
|
|
8
|
+
uid?: string;
|
|
9
|
+
like?: number;
|
|
10
10
|
/** 来源页面 */
|
|
11
11
|
referer?: string;
|
|
12
12
|
music?: {
|
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { ApiResponse, M3u8DLOptions, M3u8DLProgressStats, VideoInfo } from '../types';
|
|
1
|
+
import type { ApiResponse, M3u8DLOptions, M3u8DLResult, VideoInfo } from '../types';
|
|
3
2
|
export declare class VideoParser {
|
|
4
3
|
private static readonly platforms;
|
|
5
4
|
/**
|
|
6
5
|
* 解析视频 URL
|
|
7
6
|
*/
|
|
8
7
|
parse(url: string, headers?: Record<string, string>): Promise<ApiResponse<VideoInfo>>;
|
|
9
|
-
download(url: string, options: M3u8DLOptions): Promise<
|
|
10
|
-
stats?: M3u8DLProgressStats;
|
|
11
|
-
}>;
|
|
8
|
+
download(url: string, options: M3u8DLOptions): Promise<M3u8DLResult>;
|
|
12
9
|
/**
|
|
13
10
|
* 根据 URL 获取平台标识
|
|
14
11
|
*/
|
|
@@ -3,12 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.VideoParser = void 0;
|
|
4
4
|
const url_1 = require("url");
|
|
5
5
|
const node_path_1 = require("node:path");
|
|
6
|
-
const fe_utils_1 = require("@lzwme/fe-utils");
|
|
7
|
-
const console_log_colors_1 = require("console-log-colors");
|
|
8
6
|
const douyin_parser_1 = require("./parsers/douyin-parser");
|
|
9
7
|
const pipixia_parser_1 = require("./parsers/pipixia-parser");
|
|
10
8
|
const weibo_parser_1 = require("./parsers/weibo-parser");
|
|
9
|
+
const base_parser_1 = require("./parsers/base-parser");
|
|
11
10
|
const utils_1 = require("../lib/utils");
|
|
11
|
+
const file_download_1 = require("../lib/file-download");
|
|
12
12
|
class VideoParser {
|
|
13
13
|
static platforms = {
|
|
14
14
|
pipixia: {
|
|
@@ -23,6 +23,10 @@ class VideoParser {
|
|
|
23
23
|
class: weibo_parser_1.WeiboParser,
|
|
24
24
|
domains: ['weibo.com'],
|
|
25
25
|
},
|
|
26
|
+
unknown: {
|
|
27
|
+
class: base_parser_1.BaseParser,
|
|
28
|
+
domains: ['**'],
|
|
29
|
+
},
|
|
26
30
|
};
|
|
27
31
|
/**
|
|
28
32
|
* 解析视频 URL
|
|
@@ -38,83 +42,19 @@ class VideoParser {
|
|
|
38
42
|
const info = await this.parse(url);
|
|
39
43
|
utils_1.logger.debug('解析视频信息', info);
|
|
40
44
|
if (info.code || !info.data?.url)
|
|
41
|
-
return {
|
|
45
|
+
return { errmsg: info.message || '解析视频信息失败', options };
|
|
42
46
|
if (!options.filename && info.data.title) {
|
|
43
47
|
options.filename = info.data.title.replaceAll(/[\s\\/:*?"<>|]/g, '_');
|
|
44
48
|
}
|
|
45
|
-
|
|
49
|
+
if (!options.type)
|
|
50
|
+
options.type = 'parser';
|
|
46
51
|
if (!(0, node_path_1.extname)(options.filename))
|
|
47
52
|
options.filename += '.mp4';
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
startTime,
|
|
52
|
-
progress: 0,
|
|
53
|
-
tsSuccess: 0,
|
|
54
|
-
tsFailed: 0,
|
|
55
|
-
tsCount: 0,
|
|
56
|
-
duration: 0,
|
|
57
|
-
durationDownloaded: 0,
|
|
58
|
-
downloadedSize: 0,
|
|
59
|
-
avgSpeed: 0,
|
|
60
|
-
avgSpeedDesc: '0B/s',
|
|
61
|
-
speed: 0,
|
|
62
|
-
speedDesc: '0B/s',
|
|
63
|
-
remainingTime: 0,
|
|
64
|
-
filename: options.filename,
|
|
65
|
-
localVideo: (0, node_path_1.resolve)(options.saveDir, options.filename),
|
|
53
|
+
options.headers = {
|
|
54
|
+
referer: info.data.referer || info.data.url,
|
|
55
|
+
...(0, utils_1.formatHeaders)(options.headers),
|
|
66
56
|
};
|
|
67
|
-
|
|
68
|
-
if (options.onInited)
|
|
69
|
-
options.onInited(stats, null, null);
|
|
70
|
-
try {
|
|
71
|
-
const r = await (0, fe_utils_1.download)({
|
|
72
|
-
url,
|
|
73
|
-
filepath: stats.localVideo,
|
|
74
|
-
paralelism: options.threadNum,
|
|
75
|
-
force: options.force,
|
|
76
|
-
requestOptions: {
|
|
77
|
-
headers: {
|
|
78
|
-
referer: info.data.referer || info.data.url,
|
|
79
|
-
...(0, utils_1.formatHeaders)(options.headers),
|
|
80
|
-
},
|
|
81
|
-
rejectUnauthorized: false,
|
|
82
|
-
},
|
|
83
|
-
onProgress: info => {
|
|
84
|
-
stats.progress = +info.percent.toFixed(2);
|
|
85
|
-
stats.size = info.size;
|
|
86
|
-
stats.downloadedSize = info.downloaded;
|
|
87
|
-
stats.speed = info.speed;
|
|
88
|
-
stats.speedDesc = (0, fe_utils_1.formatByteSize)(stats.speed) + '/s';
|
|
89
|
-
stats.remainingTime = Math.round((info.size - info.downloaded) / stats.speed);
|
|
90
|
-
if (options.showProgress) {
|
|
91
|
-
const processBar = info.percent === -1 ? '' : '='.repeat(Math.floor(stats.progress * 0.2)).padEnd(20, '-');
|
|
92
|
-
utils_1.logger.logInline(`${stats.progress}% [${(0, console_log_colors_1.greenBright)(processBar)}] ` +
|
|
93
|
-
`${(0, console_log_colors_1.blueBright)((0, fe_utils_1.formatByteSize)(stats.downloadedSize))} ${(0, console_log_colors_1.yellowBright)((0, fe_utils_1.formatTimeCost)(startTime))} ${(0, console_log_colors_1.magentaBright)(stats.speedDesc)} ` +
|
|
94
|
-
(stats.progress === 100 ? '\n' : stats.remainingTime ? `${(0, console_log_colors_1.cyan)((0, fe_utils_1.formatTimeCost)(Date.now() - stats.remainingTime))}` : ''));
|
|
95
|
-
}
|
|
96
|
-
if (options.onProgress) {
|
|
97
|
-
options.onProgress(info.downloaded, info.size, null, stats);
|
|
98
|
-
}
|
|
99
|
-
},
|
|
100
|
-
});
|
|
101
|
-
stats.endTime = Date.now();
|
|
102
|
-
return {
|
|
103
|
-
code: r.filepath ? 0 : 201,
|
|
104
|
-
message: r.filepath ? '下载完成' : '下载失败',
|
|
105
|
-
data: r,
|
|
106
|
-
stats,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
catch (error) {
|
|
110
|
-
utils_1.logger.error('下载失败', error.message, (0, console_log_colors_1.gray)(url));
|
|
111
|
-
stats.errmsg = error.message;
|
|
112
|
-
return {
|
|
113
|
-
code: 201,
|
|
114
|
-
message: '下载失败: ' + error.message,
|
|
115
|
-
stats,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
57
|
+
return (0, file_download_1.fileDownload)(url, options);
|
|
118
58
|
}
|
|
119
59
|
/**
|
|
120
60
|
* 根据 URL 获取平台标识
|
|
@@ -129,12 +69,12 @@ class VideoParser {
|
|
|
129
69
|
return { url, platform };
|
|
130
70
|
}
|
|
131
71
|
}
|
|
72
|
+
return { url, platform: 'unknown' };
|
|
132
73
|
}
|
|
133
74
|
catch (error) {
|
|
134
75
|
console.error('解析 URL 失败', url, error);
|
|
135
76
|
return null;
|
|
136
77
|
}
|
|
137
|
-
return null;
|
|
138
78
|
}
|
|
139
79
|
/**
|
|
140
80
|
* 获取所有支持的平台列表
|
|
@@ -2,5 +2,5 @@ import type { ApiResponse, VideoInfo } from '../../types';
|
|
|
2
2
|
export declare abstract class BaseParser {
|
|
3
3
|
protected static success<T>(data: T): ApiResponse<T>;
|
|
4
4
|
protected static error(code: number, message: string): ApiResponse<null>;
|
|
5
|
-
static parse(
|
|
5
|
+
static parse(url: string, headers?: Record<string, string>): Promise<ApiResponse<VideoInfo>>;
|
|
6
6
|
}
|
|
@@ -3,21 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.BaseParser = void 0;
|
|
4
4
|
class BaseParser {
|
|
5
5
|
static success(data) {
|
|
6
|
-
return {
|
|
7
|
-
code: 0,
|
|
8
|
-
message: 'success',
|
|
9
|
-
data,
|
|
10
|
-
};
|
|
6
|
+
return { code: 0, message: 'success', data };
|
|
11
7
|
}
|
|
12
8
|
static error(code, message) {
|
|
13
|
-
return {
|
|
14
|
-
code,
|
|
15
|
-
message,
|
|
16
|
-
data: null,
|
|
17
|
-
};
|
|
9
|
+
return { code, message, data: null };
|
|
18
10
|
}
|
|
19
|
-
static parse(
|
|
20
|
-
return Promise.resolve(this.success(
|
|
11
|
+
static parse(url, headers = {}) {
|
|
12
|
+
return Promise.resolve(this.success({ url, title: '', referer: headers.referer }));
|
|
21
13
|
}
|
|
22
14
|
}
|
|
23
15
|
exports.BaseParser = BaseParser;
|
package/client/index.html
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
<title>M3U8 下载管理</title>
|
|
10
10
|
<link rel="icon" type="image/svg+xml" href="logo.svg">
|
|
11
11
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
12
|
-
<script src="https://
|
|
13
|
-
<script src="https://
|
|
14
|
-
<
|
|
12
|
+
<script src="https://s4.zstatic.net/ajax/libs/vue/2.7.16/vue.min.js" integrity="sha512-Wx8niGbPNCD87mSuF0sBRytwW2+2ZFr7HwVDF8krCb3egstCc4oQfig+/cfg2OHd82KcUlOYxlSDAqdHqK5TCw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
|
13
|
+
<script src="https://s4.zstatic.net/ajax/libs/sweetalert2/11.16.1/sweetalert2.min.js" integrity="sha512-LGHBR+kJ5jZSIzhhdfytPoEHzgaYuTRifq9g5l6ja6/k9NAOsAi5dQh4zQF6JIRB8cAYxTRedERUF+97/KuivQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
|
14
|
+
<script src="https://s4.zstatic.net/ajax/libs/blueimp-md5/2.19.0/js/md5.min.js" integrity="sha512-8pbzenDolL1l5OPSsoURCx9TEdMFTaeFipASVrMYKhuYtly+k3tcsQYliOEKTmuB1t7yuzAiVo+yd7SJz+ijFQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
|
15
|
+
<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/sweetalert2/11.16.1/sweetalert2.min.css" integrity="sha512-WnmDqbbAeHb7Put2nIAp7KNlnMup0FXVviOctducz1omuXB/hHK3s2vd3QLffK/CvvFUKrpioxdo+/Jo3k/xIw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
|
16
|
+
<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
|
15
17
|
|
|
16
18
|
<style>
|
|
17
19
|
#app {
|
|
@@ -139,7 +141,7 @@
|
|
|
139
141
|
</nav>
|
|
140
142
|
</div>
|
|
141
143
|
|
|
142
|
-
<div class="main-content p-6"
|
|
144
|
+
<div class="main-content p-1 md:p-6"
|
|
143
145
|
:style="{ marginLeft: sidebarCollapsed ? '0' : '16rem', width: sidebarCollapsed ? '100%' : 'calc(100% - 16rem)' }">
|
|
144
146
|
<div v-if="activeSection === 'about'" class="bg-white rounded-lg shadow p-6">
|
|
145
147
|
<h2 class="text-xl font-semibold mb-6">关于项目</h2>
|
|
@@ -426,9 +428,9 @@ services:
|
|
|
426
428
|
<div class="flex items-center">
|
|
427
429
|
<input type="checkbox" :checked="selectedTasks.includes(task.url)" @change="toggleTaskSelection(task.url)"
|
|
428
430
|
class="form-checkbox h-5 w-5 text-blue-500 rounded focus:ring-blue-500 mr-2"
|
|
429
|
-
|
|
430
|
-
<h3 class="font-bold text-green-600 truncate" :title="task.url">
|
|
431
|
-
{{ task.
|
|
431
|
+
title="'选择任务:' + (task.localVideo || task.filename || task.url)">
|
|
432
|
+
<h3 class="font-bold text-green-600 truncate max-w-[calc(100vw-100px)]" :title="task.url">
|
|
433
|
+
{{ task.filename || task.localVideo || task.url }}
|
|
432
434
|
</h3>
|
|
433
435
|
<span v-if="task.status === 'pending'" class="ml-2 px-2 py-0.5 text-xs bg-yellow-100 text-yellow-800 rounded">等待中</span>
|
|
434
436
|
<span v-else-if="task.status === 'resume'"
|
|
@@ -449,7 +451,7 @@ services:
|
|
|
449
451
|
<i class="fas fa-clock mr-1"></i>
|
|
450
452
|
<span>时长: {{ T.formatTime(task.duration * 1000) }}</span>
|
|
451
453
|
</span><span class="flex items-center">
|
|
452
|
-
<i class="fas fa-
|
|
454
|
+
<i class="fas fa-file-video mr-1"></i>
|
|
453
455
|
<span>大小: {{ T.formatSize(task.size || task.downloadedSize) }}</span>
|
|
454
456
|
</span>
|
|
455
457
|
<span v-if="task.tsCount" class="flex items-center">
|
|
@@ -547,7 +549,8 @@ services:
|
|
|
547
549
|
},
|
|
548
550
|
alert(msg, p) {
|
|
549
551
|
p = typeof msg === 'object' ? msg : Object.assign({ text: msg }, p);
|
|
550
|
-
|
|
552
|
+
if (!p.toast) p.allowOutsideClick = false;
|
|
553
|
+
Swal.fire(Object.assign({ icon: 'info', showConfirmButton: false, showCloseButton: true }, p));
|
|
551
554
|
},
|
|
552
555
|
toast(msg, p) {
|
|
553
556
|
p = typeof msg === 'object' ? msg : Object.assign({ text: msg }, p);
|
|
@@ -601,7 +604,7 @@ services:
|
|
|
601
604
|
};
|
|
602
605
|
|
|
603
606
|
Vue.prototype.T = T;
|
|
604
|
-
new Vue({
|
|
607
|
+
window.APP = new Vue({
|
|
605
608
|
el: '#app',
|
|
606
609
|
data: {
|
|
607
610
|
ws: null,
|
|
@@ -691,9 +694,12 @@ services:
|
|
|
691
694
|
forceUpdate: function () {
|
|
692
695
|
const now = Date.now();
|
|
693
696
|
if (now - this.forceUpdateTime > 500) {
|
|
694
|
-
this.$forceUpdate();
|
|
695
697
|
this.forceUpdateTime = now;
|
|
696
|
-
|
|
698
|
+
this.$forceUpdate();
|
|
699
|
+
} else {
|
|
700
|
+
if (this.forceUpdateTimeout) clearTimeout(this.forceUpdateTimeout);
|
|
701
|
+
this.forceUpdateTimeout = setTimeout(() => this.forceUpdate(), 500);
|
|
702
|
+
}
|
|
697
703
|
},
|
|
698
704
|
wsConnect: function (reconnectDelay = 3000) {
|
|
699
705
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
@@ -716,11 +722,14 @@ services:
|
|
|
716
722
|
if (!Array.isArray(data)) data = [data];
|
|
717
723
|
this.$nextTick(() => {
|
|
718
724
|
data.forEach(item => item.url && (this.tasks[item.url] = item));
|
|
719
|
-
|
|
725
|
+
this.forceUpdate();
|
|
720
726
|
});
|
|
721
727
|
break;
|
|
722
728
|
case 'delete':
|
|
723
|
-
if (Array.isArray(data))
|
|
729
|
+
if (Array.isArray(data)) {
|
|
730
|
+
data.forEach(url => delete this.tasks[url]);
|
|
731
|
+
this.forceUpdate();
|
|
732
|
+
}
|
|
724
733
|
break;
|
|
725
734
|
case 'queueStatus':
|
|
726
735
|
this.queueStatus = data;
|
|
@@ -767,6 +776,8 @@ services:
|
|
|
767
776
|
const isUpdated = this.token !== T.reqHeaders.authorization;
|
|
768
777
|
if (!isUpdated) return;
|
|
769
778
|
|
|
779
|
+
if (this.token) this.token = md5(this.token).slice(0, 8);
|
|
780
|
+
|
|
770
781
|
T.reqHeaders.authorization = this.token || '';
|
|
771
782
|
if (this.token) {
|
|
772
783
|
localStorage.setItem('token', this.token);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lzwme/m3u8-dl",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Batch download of m3u8 files and convert to mp4",
|
|
5
5
|
"main": "cjs/index.js",
|
|
6
6
|
"types": "cjs/index.d.ts",
|
|
@@ -43,27 +43,28 @@
|
|
|
43
43
|
"registry": "https://registry.npmjs.com"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@eslint/js": "^9.
|
|
46
|
+
"@eslint/js": "^9.28.0",
|
|
47
47
|
"@lzwme/fed-lint-helper": "^2.6.6",
|
|
48
48
|
"@types/express": "^5.0.2",
|
|
49
|
-
"@types/
|
|
49
|
+
"@types/m3u8-parser": "^7.2.2",
|
|
50
|
+
"@types/node": "^22.15.29",
|
|
50
51
|
"@types/ws": "^8.18.1",
|
|
51
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
52
|
-
"@typescript-eslint/parser": "^8.
|
|
53
|
-
"eslint": "^9.
|
|
52
|
+
"@typescript-eslint/eslint-plugin": "^8.33.0",
|
|
53
|
+
"@typescript-eslint/parser": "^8.33.0",
|
|
54
|
+
"eslint": "^9.28.0",
|
|
54
55
|
"eslint-config-prettier": "^10.1.5",
|
|
55
|
-
"eslint-plugin-prettier": "^5.4.
|
|
56
|
+
"eslint-plugin-prettier": "^5.4.1",
|
|
56
57
|
"express": "^5.1.0",
|
|
57
58
|
"husky": "^9.1.7",
|
|
58
59
|
"prettier": "^3.5.3",
|
|
59
60
|
"standard-version": "^9.5.0",
|
|
60
61
|
"typescript": "^5.8.3",
|
|
61
|
-
"typescript-eslint": "^8.
|
|
62
|
+
"typescript-eslint": "^8.33.0",
|
|
62
63
|
"ws": "^8.18.2"
|
|
63
64
|
},
|
|
64
65
|
"dependencies": {
|
|
65
66
|
"@lzwme/fe-utils": "^1.9.0",
|
|
66
|
-
"commander": "^
|
|
67
|
+
"commander": "^14.0.0",
|
|
67
68
|
"console-log-colors": "^0.5.0",
|
|
68
69
|
"enquirer": "^2.4.1",
|
|
69
70
|
"m3u8-parser": "^7.2.0"
|