@lzwme/m3u8-dl 0.0.2 → 0.0.4

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 CHANGED
@@ -12,16 +12,19 @@
12
12
  [![GitHub forks][forks-badge]][forks-url]
13
13
  [![GitHub stars][stars-badge]][stars-url]
14
14
 
15
- 一个 m3u8 文件批量下载工具。
15
+ 一个 m3u8 文件视频批量下载工具。
16
+
17
+ ![](./examples/img/m3u8dl-search-demo.png)
16
18
 
17
19
  ## 功能特性(Features)
18
20
 
19
21
  - 多线程下载。线程池模式的多线程下载。
20
22
  - `边下边播模式`。支持使用已下载的 ts 缓存文件在线播放。
21
23
  - 支持指定多个 m3u8 地址批量下载。
22
- - 支持缓存续传。下载失败则保留缓存,重试时只下载失败的片段。
23
- - 支持常见的 AES 解密。
24
- - 支持转换为 mp4。**需全局安装 ffmpeg**
24
+ - 支持缓存续传。下载失败会保留缓存,重试时只下载失败的片段。
25
+ - 支持常见的 AES 加密视频流解密。
26
+ - 自动转换为 mp4。**需全局安装 ffmpeg**
27
+ - `[NEW!]`支持指定采集站标准 API,以命令行交互的方式搜索和下载。
25
28
 
26
29
  ## 安装(Install)
27
30
 
@@ -36,7 +39,7 @@ m3u8dl -h
36
39
  npx @lzwme/m3u8-dl -h
37
40
  ```
38
41
 
39
- ## 使用(Useage)
42
+ ## Useage
40
43
 
41
44
  提示:如需要下载并转换为 `mp4` 视频格式,您需全局安装 [ffmpeg](https://ffmpeg.org/download.html)。
42
45
 
@@ -46,20 +49,20 @@ npx @lzwme/m3u8-dl -h
46
49
  m3u8dl --help
47
50
  ```
48
51
 
49
- **下载指定 URL 的 m3u8 文件:**
52
+ #### 下载指定 URL 的 m3u8 文件:
50
53
 
51
54
  ```bash
52
55
  m3u8dl https://lzw.me/x/m3u8-player/test.m3u8
53
56
  ```
54
57
 
55
- **批量下载示例一:**
58
+ #### 批量下载示例一:
56
59
 
57
60
  ```bash
58
61
  # 下载多个文件:
59
62
  m3u8dl "第1集|https://s.xlzys.com/play/zbqMZYRb/index.m3u8" "第2集|https://s.xlzys.com/play/PdyJXrwe/index.m3u8" --filename "三体"
60
63
  ```
61
64
 
62
- **批量下载示例二:**
65
+ #### 批量下载示例二:
63
66
 
64
67
  新建文件 `三体.txt`,内容格式:
65
68
 
@@ -77,6 +80,17 @@ m3u8dl 三体.txt
77
80
 
78
81
  提示:可创建并指定多个 txt 文件实现对多个影视剧集的一键批量下载。
79
82
 
83
+ #### 指定采集站 API 搜索并下载
84
+
85
+ ```bash
86
+ m3u8dl search -h
87
+
88
+ # 指定采集站 API url 地址(会缓存),然后按提示操作
89
+ m3u8dl s -u https://jyzyapi.com/provide/vod/
90
+ ```
91
+
92
+ **声明:** 以上仅作示例,请自行搜索查找可用的采集站 API。本工具仅用作技术研究学习,不提供任何具体资源类信息。
93
+
80
94
  ### API 调用
81
95
 
82
96
  ```ts
@@ -93,10 +107,6 @@ for (const filepath of fileList) {
93
107
  }
94
108
  ```
95
109
 
96
- ## API 文档
97
-
98
- - [https://lzwme.github.io/m3u8-dl/](https://lzwme.github.io/m3u8-dl/)
99
-
100
110
  ## 开发(Development)
101
111
 
102
112
  本地二次开发:
package/cjs/cli.js CHANGED
@@ -4,9 +4,19 @@ const node_path_1 = require("node:path");
4
4
  const commander_1 = require("commander");
5
5
  const console_log_colors_1 = require("console-log-colors");
6
6
  const fe_utils_1 = require("@lzwme/fe-utils");
7
+ const enquirer_1 = require("enquirer");
7
8
  const utils_js_1 = require("./lib/utils.js");
8
9
  const m3u8_batch_download_1 = require("./m3u8-batch-download");
10
+ const video_search_js_1 = require("./lib/video-search.js");
9
11
  const pkg = (0, fe_utils_1.readJsonFileSync)((0, node_path_1.resolve)(__dirname, '../package.json'));
12
+ process.on('unhandledRejection', (r, p) => {
13
+ console.log('[退出]UnhandledPromiseRejection', r, p);
14
+ process.exit();
15
+ });
16
+ process.on('SIGINT', signal => {
17
+ utils_js_1.logger.info('强制退出', signal);
18
+ process.exit();
19
+ });
10
20
  commander_1.program
11
21
  .version(pkg.version, '-v, --version')
12
22
  .description((0, console_log_colors_1.cyanBright)(pkg.description))
@@ -21,14 +31,8 @@ commander_1.program
21
31
  .option('-C, --cache-dir <dirpath>', `临时文件保存目录。默认为 cache`)
22
32
  .option('-S, --save-dir <dirpath>', `下载文件保存的路径。默认为当前目录`)
23
33
  .option('--no-del-cache', `下载成功后是否删除临时文件。默认为 true。保存临时文件可以在重复下载时识别缓存`, true)
24
- .action(async (urls, options) => {
25
- if (options.debug) {
26
- utils_js_1.logger.updateOptions({ levelType: 'debug' });
27
- }
28
- else if (options.silent) {
29
- utils_js_1.logger.updateOptions({ levelType: 'silent' });
30
- options.progress = false;
31
- }
34
+ .action(async (urls) => {
35
+ const options = getOptions();
32
36
  utils_js_1.logger.debug(urls, options);
33
37
  if (options.progress != null)
34
38
  options.showProgress = options.progress;
@@ -38,4 +42,115 @@ commander_1.program
38
42
  else
39
43
  commander_1.program.help();
40
44
  });
45
+ commander_1.program
46
+ .command('search [keyword]')
47
+ .alias('s')
48
+ .option('-u,--url <api...>', '影视搜索的接口地址(m3u8采集站标准接口)')
49
+ .description('m3u8视频在线搜索与下载')
50
+ .action(async (keyword, options) => {
51
+ VideoSerachAndDL(keyword, options, getOptions());
52
+ });
41
53
  commander_1.program.parse(process.argv);
54
+ function getOptions() {
55
+ const options = commander_1.program.opts();
56
+ if (options.debug) {
57
+ utils_js_1.logger.updateOptions({ levelType: 'debug' });
58
+ }
59
+ else if (options.silent) {
60
+ utils_js_1.logger.updateOptions({ levelType: 'silent' });
61
+ options.progress = false;
62
+ }
63
+ return options;
64
+ }
65
+ async function VideoSerachAndDL(keyword, options, baseOpts) {
66
+ const vs = new video_search_js_1.VideoSearch();
67
+ await vs.updateOptions({ api: options.url || [] });
68
+ let apiUrl = vs.api[0];
69
+ if (!options.url && vs.api.length > 0) {
70
+ await (0, enquirer_1.prompt)({
71
+ type: 'select',
72
+ name: 'k',
73
+ message: '请选择 API 站点',
74
+ choices: vs.api.map(d => ({ name: d, message: d })),
75
+ validate: value => value.length >= 1,
76
+ }).then(v => (apiUrl = v.k));
77
+ }
78
+ await (0, enquirer_1.prompt)({
79
+ type: 'input',
80
+ name: 'k',
81
+ message: '请输入关键字',
82
+ validate: value => value.length > 1,
83
+ initial: keyword,
84
+ }).then(v => (keyword = v.k));
85
+ const sRes = await vs.search(keyword, apiUrl);
86
+ utils_js_1.logger.debug(sRes);
87
+ if (!sRes.total) {
88
+ console.log(console_log_colors_1.color.green(`[${keyword}]`), `没有搜到结果`);
89
+ return VideoSerachAndDL(keyword, options, baseOpts);
90
+ }
91
+ const choices = sRes.list.map((d, idx) => ({
92
+ name: d.vod_id,
93
+ message: `${idx + 1}. [${d.type_name}] ${d.vod_name}`,
94
+ hint: `${d.vod_remarks}(${d.vod_time})`,
95
+ }));
96
+ const answer1 = await (0, enquirer_1.prompt)({
97
+ type: 'select',
98
+ name: 'vid',
99
+ pointer: '👉',
100
+ message: `查找到了 ${console_log_colors_1.color.greenBright(sRes.list.length)} 条结果,请选择:`,
101
+ choices: choices.concat({ name: -1, message: (0, console_log_colors_1.greenBright)('重新搜索'), hint: '' }),
102
+ });
103
+ if (answer1.vid === -1)
104
+ return VideoSerachAndDL(keyword, options, baseOpts);
105
+ const vResult = await vs.getVideoList(answer1.vid, apiUrl);
106
+ if (!vResult.list?.length) {
107
+ utils_js_1.logger.error('获取视频信息失败!', vResult.msg);
108
+ return VideoSerachAndDL(keyword, options, baseOpts);
109
+ }
110
+ else {
111
+ const info = vResult.list[0];
112
+ const urls = info.vod_play_url
113
+ .split(info.vod_play_note)
114
+ .find(d => d.includes('.m3u8'))
115
+ .split('#');
116
+ utils_js_1.logger.debug(info, urls);
117
+ const r = (key, desc) => (info[key] ? ` [${desc}] ${(0, console_log_colors_1.greenBright)(info[key])}` : '');
118
+ console.log([
119
+ `\n [名称] ${(0, console_log_colors_1.cyanBright)(info.vod_name)}`,
120
+ r('vod_sub', '别名'),
121
+ ` [更新] ${(0, console_log_colors_1.greenBright)(info.vod_remarks)}(${(0, console_log_colors_1.gray)(info.vod_time)})`,
122
+ r('vod_total', '总集数'),
123
+ r('type_name', '分类'),
124
+ r('vod_class', '类别'),
125
+ r('vod_writer', '作者'),
126
+ r('vod_area', '地区'),
127
+ r('vod_lang', '语言'),
128
+ r('vod_year', '年份'),
129
+ r('vod_douban_score', '评分'),
130
+ r('vod_pubdate', '上映日期'),
131
+ `\n${(0, console_log_colors_1.green)((info.vod_content || info.vod_blurb).replace(/<\/?.+?>/g, ''))}\n`, // 描述
132
+ ]
133
+ .filter(Boolean)
134
+ .join('\n'), '\n');
135
+ const answer = await (0, enquirer_1.prompt)({
136
+ type: 'select',
137
+ name: 'url',
138
+ choices: [
139
+ { name: '1', message: (0, console_log_colors_1.green)('全部下载') },
140
+ { name: '-1', message: (0, console_log_colors_1.cyanBright)('重新搜索') },
141
+ ].concat(urls.map((d, i) => ({ name: d, message: `${i + 1}. ${d}` }))),
142
+ message: `获取到了 ${console_log_colors_1.color.magentaBright(urls.length)} 条视频下载地址,请选择:`,
143
+ });
144
+ if (answer.url !== '-1') {
145
+ const p = await (0, enquirer_1.prompt)({
146
+ type: 'confirm',
147
+ name: 'play',
148
+ initial: baseOpts.play,
149
+ message: `【${(0, console_log_colors_1.greenBright)(info.vod_name)}】是否边下边播?`,
150
+ });
151
+ baseOpts.play = p.play;
152
+ await (0, m3u8_batch_download_1.m3u8BatchDownload)(answer.url === '1' ? urls : [answer.url], { filename: info.vod_name.replaceAll(' ', '_'), ...baseOpts });
153
+ }
154
+ return VideoSerachAndDL(keyword, options, baseOpts);
155
+ }
156
+ }
@@ -1,5 +1,5 @@
1
1
  /// <reference types="node" />
2
- import { M3u8DLOptions, TsItemInfo } from '../type';
2
+ import { M3u8DLOptions, TsItemInfo } from '../types/m3u8';
3
3
  /**
4
4
  * 边下边看
5
5
  */
@@ -68,6 +68,7 @@ async function createLocalServer(baseDir) {
68
68
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
69
69
  res.end('Not found');
70
70
  }).listen(port, () => {
71
+ console.log();
71
72
  utils_1.logger.info('Created Local Server:', console_log_colors_1.color.greenBright(origin));
72
73
  });
73
74
  return { port, origin, server };
@@ -1,2 +1,2 @@
1
- import type { M3u8DLOptions, TsItemInfo } from '../type';
1
+ import type { M3u8DLOptions, TsItemInfo } from '../types/m3u8';
2
2
  export declare function m3u8Convert(options: M3u8DLOptions, data: TsItemInfo[]): Promise<string>;
@@ -11,13 +11,15 @@ async function m3u8Convert(options, data) {
11
11
  let ffmpegSupport = (0, utils_1.isSupportFfmpeg)();
12
12
  let filepath = (0, node_path_1.resolve)(options.saveDir, options.filename);
13
13
  if (!ffmpegSupport)
14
- filepath = filepath.replace('.mp4', '.ts');
14
+ filepath = filepath.replace(/\.mp4$/, '.ts');
15
15
  if (!options.force && (0, node_fs_1.existsSync)(filepath))
16
16
  return filepath;
17
17
  utils_1.logger.info(`Starting ${ffmpegSupport ? 'convert to mp4' : 'merge into ts'} file:`, (0, console_log_colors_1.greenBright)(filepath));
18
18
  if (ffmpegSupport) {
19
19
  const inputFilePath = (0, node_path_1.resolve)(options.cacheDir, 'input.txt');
20
- const filesAllArr = data.map(d => (0, node_path_1.resolve)(d.tsOut)).filter(d => (0, node_fs_1.existsSync)(d));
20
+ let filesAllArr = data.map(d => (0, node_path_1.resolve)(d.tsOut)).filter(d => (0, node_fs_1.existsSync)(d));
21
+ if (process.platform === 'win32')
22
+ filesAllArr = filesAllArr.map(d => d.replaceAll('\\', '/'));
21
23
  await node_fs_1.promises.writeFile(inputFilePath, 'ffconcat version 1.0\nfile ' + filesAllArr.join('\nfile '));
22
24
  const cmd = `ffmpeg -y -f concat -safe 0 -i ${inputFilePath} -acodec copy -vcodec copy -absf aac_adtstoasc ${filepath}`;
23
25
  utils_1.logger.debug('[convert to mp4]cmd:', (0, console_log_colors_1.cyan)(cmd));
@@ -26,12 +28,13 @@ async function m3u8Convert(options, data) {
26
28
  if (r.error)
27
29
  utils_1.logger.error('Conversion to mp4 failed. Please confirm that `ffmpeg` is installed!', r.stderr);
28
30
  }
29
- else {
31
+ if (!ffmpegSupport) {
32
+ filepath = filepath.replace(/\.mp4$/, '.ts');
30
33
  await node_fs_1.promises.writeFile(filepath, Buffer.concat(data.map(d => (0, node_fs_1.readFileSync)(d.tsOut))));
31
34
  }
32
- if ((0, node_fs_1.existsSync)(filepath)) {
33
- utils_1.logger.info(`File saved[${(0, console_log_colors_1.magentaBright)((0, helper_1.formatByteSize)((0, node_fs_1.statSync)(filepath).size))}]:`, (0, console_log_colors_1.greenBright)(filepath));
34
- }
35
+ if (!(0, node_fs_1.existsSync)(filepath))
36
+ return '';
37
+ utils_1.logger.info(`File saved[${(0, console_log_colors_1.magentaBright)((0, helper_1.formatByteSize)((0, node_fs_1.statSync)(filepath).size))}]:`, (0, console_log_colors_1.greenBright)(filepath));
35
38
  return filepath;
36
39
  }
37
40
  exports.m3u8Convert = m3u8Convert;
@@ -1,4 +1,10 @@
1
- import type { M3u8DLOptions, TsItemInfo } from '../type';
1
+ import { WorkerPool } from './worker_pool';
2
+ import type { M3u8DLOptions, TsItemInfo, WorkerTaskInfo } from '../types/m3u8';
3
+ export declare const workPoll: WorkerPool<WorkerTaskInfo, {
4
+ success: boolean;
5
+ info: TsItemInfo;
6
+ }>;
7
+ export declare function preDownLoad(url: string, options: M3u8DLOptions): Promise<void>;
2
8
  export declare function m3u8Download(url: string, options?: M3u8DLOptions): Promise<{
3
9
  options: M3u8DLOptions;
4
10
  m3u8Info: {
@@ -6,7 +12,7 @@ export declare function m3u8Download(url: string, options?: M3u8DLOptions): Prom
6
12
  tsCount: number;
7
13
  durationSecond: number;
8
14
  data: TsItemInfo[];
9
- crypto: import("../type").M3u8Crypto;
15
+ crypto: import("../types/m3u8").M3u8Crypto;
10
16
  };
11
17
  filepath: string;
12
18
  }>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.m3u8Download = void 0;
3
+ exports.m3u8Download = exports.preDownLoad = exports.workPoll = void 0;
4
4
  const node_path_1 = require("node:path");
5
5
  const node_fs_1 = require("node:fs");
6
6
  const node_os_1 = require("node:os");
@@ -12,15 +12,21 @@ const worker_pool_1 = require("./worker_pool");
12
12
  const parseM3u8_1 = require("./parseM3u8");
13
13
  const m3u8_convert_1 = require("./m3u8-convert");
14
14
  const local_play_1 = require("./local-play");
15
+ const cache = {
16
+ m3u8Info: {},
17
+ downloading: new Set(),
18
+ };
15
19
  const tsDlFile = (0, node_path_1.resolve)(__dirname, './ts-download.js');
20
+ exports.workPoll = new worker_pool_1.WorkerPool(tsDlFile);
16
21
  async function formatOptions(url, opts) {
17
22
  const options = {
18
23
  delCache: !opts.debug,
19
24
  saveDir: process.cwd(),
25
+ showProgress: true,
20
26
  ...opts,
21
27
  };
22
28
  if (!url.startsWith('http')) {
23
- url = url.replace('$', '|').replace(/\|\|+/, '|');
29
+ url = url.replace(/\$+/, '|').replace(/\|\|+/, '|');
24
30
  if (url.includes('|')) {
25
31
  const r = url.split('|');
26
32
  url = r[1];
@@ -39,35 +45,65 @@ async function formatOptions(url, opts) {
39
45
  options.filename += '.mp4';
40
46
  if (!options.cacheDir)
41
47
  options.cacheDir = `cache/${urlMd5}`;
42
- if (!(0, node_fs_1.existsSync)(options.cacheDir))
43
- await node_fs_1.promises.mkdir(options.cacheDir, { recursive: true });
44
48
  if (options.headers)
45
49
  utils_1.request.setHeaders(options.headers);
46
50
  if (options.debug) {
47
51
  utils_1.logger.updateOptions({ levelType: 'debug' });
48
- utils_1.logger.debug('[m3u8-DL]options', options);
52
+ utils_1.logger.debug('[m3u8-DL]options', options, url);
49
53
  }
50
54
  return [url, options];
51
55
  }
52
- async function m3u8Download(url, options = {}) {
53
- utils_1.logger.info('Starting download for', (0, console_log_colors_1.cyanBright)(url));
56
+ async function m3u8InfoParse(url, options = {}) {
54
57
  [url, options] = await formatOptions(url, options);
55
58
  const ext = (0, utils_1.isSupportFfmpeg)() ? '.mp4' : '.ts';
56
59
  let filepath = (0, node_path_1.resolve)(options.saveDir, options.filename);
57
60
  if (!filepath.endsWith(ext))
58
61
  filepath += ext;
59
62
  const result = { options, m3u8Info: null, filepath };
60
- if (!options.force && (0, node_fs_1.existsSync)(filepath)) {
61
- utils_1.logger.info('file already exist:', filepath);
63
+ if (cache.m3u8Info[url]) {
64
+ Object.assign(result, cache.m3u8Info[url]);
62
65
  return result;
63
66
  }
67
+ if (!options.force && (0, node_fs_1.existsSync)(filepath))
68
+ return result;
64
69
  const m3u8Info = await (0, parseM3u8_1.parseM3U8)('', url, options.cacheDir).catch(e => utils_1.logger.error(e));
65
- if (m3u8Info && m3u8Info?.tsCount > 0) {
70
+ if (m3u8Info && m3u8Info?.tsCount > 0)
66
71
  result.m3u8Info = m3u8Info;
72
+ return result;
73
+ }
74
+ async function preDownLoad(url, options) {
75
+ const result = await m3u8InfoParse(url, options);
76
+ if (!result.m3u8Info)
77
+ return;
78
+ for (const info of result.m3u8Info.data) {
79
+ if (!exports.workPoll.freeNum)
80
+ return;
81
+ if (!cache.downloading.has(info.uri)) {
82
+ cache.downloading.add(info.uri);
83
+ exports.workPoll.runTask({ info, options: JSON.parse(JSON.stringify(result.options)), crypto: result.m3u8Info.crypto }, () => {
84
+ cache.downloading.delete(info.uri);
85
+ });
86
+ }
87
+ }
88
+ }
89
+ exports.preDownLoad = preDownLoad;
90
+ async function m3u8Download(url, options = {}) {
91
+ utils_1.logger.info('Starting download for', (0, console_log_colors_1.cyanBright)(url));
92
+ const result = await m3u8InfoParse(url, options);
93
+ options = result.options;
94
+ if (!options.force && (0, node_fs_1.existsSync)(result.filepath) && !result.m3u8Info) {
95
+ utils_1.logger.info('file already exist:', result.filepath);
96
+ return result;
97
+ }
98
+ if (result.m3u8Info?.tsCount > 0) {
99
+ let n = options.threadNum - exports.workPoll.numThreads;
100
+ if (n > 0)
101
+ while (n--)
102
+ exports.workPoll.addNewWorker();
103
+ const { m3u8Info } = result;
67
104
  const startTime = Date.now();
68
- const pool = new worker_pool_1.WorkerPool(tsDlFile, options.threadNum);
69
105
  const barrier = new fe_utils_1.Barrier();
70
- const playStart = Math.min(options.threadNum + 2, m3u8Info.tsCount);
106
+ const playStart = Math.min(options.threadNum + 2, result.m3u8Info.tsCount);
71
107
  const stats = {
72
108
  /** 下载成功的 ts 数量 */
73
109
  tsSuccess: 0,
@@ -78,7 +114,7 @@ async function m3u8Download(url, options = {}) {
78
114
  };
79
115
  const runTask = (data) => {
80
116
  for (const info of data) {
81
- pool.runTask({ info, options: JSON.parse(JSON.stringify(options)), crypto: m3u8Info.crypto }, (err, res) => {
117
+ exports.workPoll.runTask({ info, options: JSON.parse(JSON.stringify(options)), crypto: m3u8Info.crypto }, (err, res) => {
82
118
  if (!res || err) {
83
119
  if (err) {
84
120
  console.log('\n');
@@ -98,26 +134,29 @@ async function m3u8Download(url, options = {}) {
98
134
  info.tsSize = res.info.tsSize;
99
135
  info.success = 1;
100
136
  stats.tsSuccess++;
101
- stats.duration = +(stats.duration + info.duration).toFixed(2);
137
+ stats.duration += info.duration;
102
138
  }
103
139
  else {
104
140
  stats.tsFailed++;
105
141
  }
106
142
  const finished = stats.tsFailed + stats.tsSuccess;
107
- if (options.showProgress !== false) {
143
+ if (options.showProgress) {
144
+ const timeCost = Date.now() - startTime;
108
145
  const downloadedSize = m3u8Info.data.reduce((a, b) => a + (b.tsSize || 0), 0);
109
- const avgSpeed = (0, helper_1.formatByteSize)((downloadedSize / (Date.now() - startTime)) * 1000);
146
+ const downloadedDuration = m3u8Info.data.reduce((a, b) => a + (b.tsSize ? b.duration : 0), 0);
147
+ const avgSpeed = (0, helper_1.formatByteSize)((downloadedSize / timeCost) * 1000);
148
+ const restTime = downloadedDuration ? (timeCost * (m3u8Info.durationSecond - stats.duration)) / downloadedDuration : 0;
110
149
  const percent = Math.floor((finished / m3u8Info.tsCount) * 100);
111
150
  const processBar = '='.repeat(Math.floor(percent * 0.2)).padEnd(20, '-');
112
151
  utils_1.logger.logInline(`${percent}% [${(0, console_log_colors_1.greenBright)(processBar)}] ${(0, console_log_colors_1.cyan)(finished)} ` +
113
- `${(0, console_log_colors_1.green)(stats.duration + 'sec')} ` +
114
- `${(0, console_log_colors_1.blueBright)((0, helper_1.formatByteSize)(downloadedSize))} ${(0, console_log_colors_1.yellowBright)((0, fe_utils_1.formatTimeCost)(startTime))} ${(0, console_log_colors_1.magentaBright)(avgSpeed + '/s')}` +
115
- (finished === m3u8Info.tsCount ? '\n' : ''));
152
+ `${(0, console_log_colors_1.green)(stats.duration.toFixed(2) + 'sec')} ` +
153
+ `${(0, console_log_colors_1.blueBright)((0, helper_1.formatByteSize)(downloadedSize))} ${(0, console_log_colors_1.yellowBright)((0, fe_utils_1.formatTimeCost)(startTime))} ${(0, console_log_colors_1.magentaBright)(avgSpeed + '/s')} ` +
154
+ (finished === m3u8Info.tsCount ? '\n' : restTime ? `${(0, console_log_colors_1.cyan)((0, fe_utils_1.formatTimeCost)(Date.now() - Math.ceil(restTime)))}` : ''));
116
155
  }
117
156
  if (options.onProgress)
118
157
  options.onProgress(finished, m3u8Info.tsCount, info);
119
158
  if (finished === m3u8Info.tsCount) {
120
- pool.close();
159
+ // pool.close();
121
160
  barrier.open();
122
161
  }
123
162
  if (options.play && finished === playStart) {
@@ -127,13 +166,13 @@ async function m3u8Download(url, options = {}) {
127
166
  }
128
167
  };
129
168
  if (options.showProgress) {
130
- console.info(`Total segments: ${(0, console_log_colors_1.cyan)(m3u8Info.tsCount)}, duration: ${(0, console_log_colors_1.green)(m3u8Info.durationSecond + 'sec')}.`, `Parallel jobs: ${(0, console_log_colors_1.magenta)(options.threadNum)}`);
169
+ console.info(`\nTotal segments: ${(0, console_log_colors_1.cyan)(m3u8Info.tsCount)}, duration: ${(0, console_log_colors_1.green)(m3u8Info.durationSecond + 'sec')}.`, `Parallel jobs: ${(0, console_log_colors_1.magenta)(options.threadNum)}`);
131
170
  }
132
171
  runTask(m3u8Info.data);
133
172
  await barrier.wait();
134
173
  if (stats.tsFailed === 0) {
135
174
  result.filepath = await (0, m3u8_convert_1.m3u8Convert)(options, m3u8Info.data);
136
- if ((0, node_fs_1.existsSync)(options.cacheDir) && options.delCache)
175
+ if (result.filepath && (0, node_fs_1.existsSync)(options.cacheDir) && options.delCache)
137
176
  (0, fe_utils_1.rmrfAsync)(options.cacheDir);
138
177
  }
139
178
  else
@@ -1,4 +1,4 @@
1
- import type { M3u8Crypto, TsItemInfo } from '../type';
1
+ import type { M3u8Crypto, TsItemInfo } from '../types/m3u8';
2
2
  export declare function parseM3U8(content: string, url?: string, cacheDir?: string): Promise<{
3
3
  manifest: any;
4
4
  /** ts 文件数量 */
@@ -19,8 +19,9 @@ async function parseM3U8(content, url = process.cwd(), cacheDir = './cache') {
19
19
  let parser = new m3u8_parser_1.Parser();
20
20
  parser.push(content);
21
21
  parser.end();
22
+ utils_1.logger.debug('parser.manifest', parser.manifest);
22
23
  if (parser.manifest.playlists?.length > 0) {
23
- url = new URL(url, parser.manifest.playlists[0].uri).toString();
24
+ url = new URL(parser.manifest.playlists[0].uri, url).toString();
24
25
  content = (await (0, utils_1.getRetry)(url)).data;
25
26
  parser = new m3u8_parser_1.Parser();
26
27
  parser.push(content);
@@ -70,6 +71,7 @@ async function parseM3U8(content, url = process.cwd(), cacheDir = './cache') {
70
71
  });
71
72
  result.durationSecond += tsList[i].duration;
72
73
  }
74
+ result.durationSecond = +Number(result.durationSecond).toFixed(2);
73
75
  return result;
74
76
  }
75
77
  exports.parseM3U8 = parseM3U8;
@@ -0,0 +1,7 @@
1
+ import { LiteStorage } from '@lzwme/fe-utils';
2
+ import { type VSOptions } from './video-search';
3
+ export interface M3u8StorConfig extends VSOptions {
4
+ /** 播放地址缓存 */
5
+ api?: string[];
6
+ }
7
+ export declare const stor: LiteStorage<M3u8StorConfig>;
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.stor = void 0;
4
+ const fe_utils_1 = require("@lzwme/fe-utils");
5
+ const node_path_1 = require("node:path");
6
+ const node_os_1 = require("node:os");
7
+ exports.stor = fe_utils_1.LiteStorage.getInstance({ uuid: 'm3u8dl', filepath: (0, node_path_1.resolve)((0, node_os_1.homedir)(), '.liteStorage/m3u8dl.json') });
@@ -1,2 +1,2 @@
1
- import type { M3u8Crypto, TsItemInfo } from '../type';
1
+ import type { M3u8Crypto, TsItemInfo } from '../types/m3u8';
2
2
  export declare function tsDownload(info: TsItemInfo, cryptoInfo: M3u8Crypto): Promise<boolean>;
@@ -3,7 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.tsDownload = void 0;
4
4
  const node_crypto_1 = require("node:crypto");
5
5
  const node_fs_1 = require("node:fs");
6
+ const node_path_1 = require("node:path");
6
7
  const node_worker_threads_1 = require("node:worker_threads");
8
+ const fe_utils_1 = require("@lzwme/fe-utils");
7
9
  const utils_1 = require("./utils");
8
10
  async function tsDownload(info, cryptoInfo) {
9
11
  try {
@@ -13,6 +15,7 @@ async function tsDownload(info, cryptoInfo) {
13
15
  if (r.response.statusCode === 200) {
14
16
  utils_1.logger.debug('\n', info);
15
17
  const data = cryptoInfo.key ? aesDecrypt(r.buffer, cryptoInfo) : r.buffer;
18
+ (0, fe_utils_1.mkdirp)((0, node_path_1.dirname)(info.tsOut));
16
19
  await node_fs_1.promises.writeFile(info.tsOut, data);
17
20
  info.tsSize = r.buffer.byteLength;
18
21
  return true;
package/cjs/lib/utils.js CHANGED
@@ -5,7 +5,8 @@ const fe_utils_1 = require("@lzwme/fe-utils");
5
5
  exports.request = new fe_utils_1.Request('', {
6
6
  'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
7
7
  });
8
- const getRetry = (url, retries = 3) => (0, fe_utils_1.retry)(() => exports.request.get(url), 1000, retries, r => r.response.statusCode === 200);
8
+ // process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
9
+ const getRetry = (url, retries = 3) => (0, fe_utils_1.retry)(() => exports.request.get(url, null, {}, { rejectUnauthorized: false }), 1000, retries, r => r.response.statusCode === 200);
9
10
  exports.getRetry = getRetry;
10
11
  exports.logger = fe_utils_1.NLogger.getLogger('[M3U8-DL]', { color: fe_utils_1.color });
11
12
  let _isSupportFfmpeg = null;
@@ -0,0 +1,14 @@
1
+ import { VideoListResult, VideoSearchResult } from '../types';
2
+ export interface VSOptions {
3
+ /** 播放地址缓存 */
4
+ api?: string[];
5
+ }
6
+ export declare class VideoSearch {
7
+ protected options: VSOptions;
8
+ get api(): string[];
9
+ constructor(options?: VSOptions);
10
+ updateOptions(options: VSOptions): Promise<this>;
11
+ search(wd: string, api?: string): Promise<VideoSearchResult>;
12
+ getVideoList(ids: number | string | (number | string)[], api?: string): Promise<VideoListResult>;
13
+ formatUrl(url: string | string[]): Promise<string[]>;
14
+ }
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VideoSearch = void 0;
4
+ const fe_utils_1 = require("@lzwme/fe-utils");
5
+ const storage_1 = require("./storage");
6
+ const req = new fe_utils_1.Request(null, {
7
+ 'content-type': 'application/json; charset=UTF-8',
8
+ });
9
+ class VideoSearch {
10
+ options;
11
+ get api() {
12
+ return this.options.api;
13
+ }
14
+ constructor(options = {}) {
15
+ this.options = options;
16
+ if (!options.api?.length)
17
+ options.api = [];
18
+ if (process.env.VAPI)
19
+ options.api.push(...process.env.VAPI.split('$$$'));
20
+ this.updateOptions(options).then(() => {
21
+ if (!this.api.length)
22
+ throw Error('没有可用站点,请添加或指定');
23
+ });
24
+ }
25
+ async updateOptions(options) {
26
+ const cache = storage_1.stor.get();
27
+ if (Array.isArray(cache.api))
28
+ this.options.api.push(...cache.api);
29
+ if (options.api?.length) {
30
+ this.options.api.unshift(...options.api);
31
+ this.options.api = await this.formatUrl(this.options.api);
32
+ storage_1.stor.set({ api: this.options.api });
33
+ }
34
+ return this;
35
+ }
36
+ async search(wd, api = this.api[0]) {
37
+ let { data } = await req.get(api, { wd }, null, { rejectUnauthorized: false });
38
+ if (typeof data == 'string')
39
+ data = JSON.parse(data);
40
+ return data;
41
+ }
42
+ async getVideoList(ids, api = this.api[0]) {
43
+ let { data } = await req.get(api, {
44
+ ac: 'videolist',
45
+ ids: Array.isArray(ids) ? ids.join(',') : ids,
46
+ }, null, { rejectUnauthorized: false });
47
+ if (typeof data == 'string')
48
+ data = JSON.parse(data);
49
+ return data;
50
+ }
51
+ async formatUrl(url) {
52
+ const urls = [];
53
+ if (!url)
54
+ return urls;
55
+ if (typeof url === 'string')
56
+ url = [url];
57
+ for (let u of url) {
58
+ u = String(u || '').trim();
59
+ if (!u)
60
+ continue;
61
+ if (u.endsWith('.json')) {
62
+ const { data } = await req.get(u, null, null, { rejectUnauthorized: false });
63
+ if (Array.isArray(data)) {
64
+ urls.push(...(await this.formatUrl(data)));
65
+ }
66
+ else {
67
+ urls.push(...Object.values(data));
68
+ }
69
+ }
70
+ else if (u.startsWith('http')) {
71
+ if (u.endsWith('provide/'))
72
+ u += 'vod/';
73
+ if (u.endsWith('provide/vod'))
74
+ u += '/';
75
+ urls.push(u.replace('/at/xml/', '/'));
76
+ }
77
+ }
78
+ return [...new Set(urls)];
79
+ }
80
+ }
81
+ exports.VideoSearch = VideoSearch;
82
+ // const v = new VideoSearch({ api: ['https://api.xinlangapi.com/xinlangapi.php/provide/vod/'] });
83
+ // v.search('三体')
84
+ // .then(d => {
85
+ // console.log(d.total, d.list);
86
+ // return v.getVideoList(d.list[0].vod_id);
87
+ // })
88
+ // .then(d => {
89
+ // console.log('detail:', d.total, d.list[0]);
90
+ // });
@@ -7,6 +7,9 @@ export declare class WorkerPool<T = unknown, R = unknown> extends EventEmitter {
7
7
  private freeWorkers;
8
8
  private workerTaskInfo;
9
9
  private tasks;
10
+ get totalTask(): number;
11
+ get totalNum(): number;
12
+ get freeNum(): number;
10
13
  constructor(processorFile: string, numThreads?: number);
11
14
  addNewWorker(processorFile?: string): void;
12
15
  runTask(task: T, callback: (err: Error | null, result: R) => void): void;
@@ -26,6 +26,15 @@ class WorkerPool extends node_events_1.EventEmitter {
26
26
  freeWorkers = [];
27
27
  workerTaskInfo = new Map();
28
28
  tasks = [];
29
+ get totalTask() {
30
+ return this.tasks.length;
31
+ }
32
+ get totalNum() {
33
+ return this.workers.length;
34
+ }
35
+ get freeNum() {
36
+ return this.freeWorkers.length;
37
+ }
29
38
  constructor(processorFile, numThreads = 0) {
30
39
  super();
31
40
  this.processorFile = processorFile;
@@ -51,10 +60,10 @@ class WorkerPool extends node_events_1.EventEmitter {
51
60
  // 如果成功:调用传递给`runTask`的回调,删除与Worker关联的`TaskInfo`,并再次将其标记为空闲。
52
61
  const r = this.workerTaskInfo.get(worker);
53
62
  if (r) {
54
- r.done(null, result);
55
63
  this.workerTaskInfo.delete(worker);
56
64
  this.freeWorkers.push(worker);
57
65
  this.emit(kWorkerFreedEvent);
66
+ r.done(null, result);
58
67
  }
59
68
  });
60
69
  worker.on('error', err => {
@@ -73,6 +82,8 @@ class WorkerPool extends node_events_1.EventEmitter {
73
82
  this.workers.push(worker);
74
83
  this.freeWorkers.push(worker);
75
84
  this.emit(kWorkerFreedEvent);
85
+ if (this.numThreads < this.workers.length)
86
+ this.numThreads = this.workers.length;
76
87
  }
77
88
  runTask(task, callback) {
78
89
  if (this.freeWorkers.length === 0) {
@@ -1,2 +1,2 @@
1
- import { M3u8DLOptions } from './type';
1
+ import { M3u8DLOptions } from './types/m3u8';
2
2
  export declare function m3u8BatchDownload(urls: string[], options: M3u8DLOptions): Promise<void>;
@@ -4,7 +4,9 @@ exports.m3u8BatchDownload = void 0;
4
4
  const node_fs_1 = require("node:fs");
5
5
  const node_path_1 = require("node:path");
6
6
  const m3u8_download_1 = require("./lib/m3u8-download");
7
- async function m3u8BatchDownload(urls, options) {
7
+ const utils_1 = require("./lib/utils");
8
+ async function formatUrls(urls, options) {
9
+ const taskset = new Map();
8
10
  for (const url of urls) {
9
11
  if ((0, node_fs_1.existsSync)(url)) {
10
12
  const content = await node_fs_1.promises.readFile(url, 'utf8');
@@ -17,13 +19,42 @@ async function m3u8BatchDownload(urls, options) {
17
19
  href = `${idx}|${href}`;
18
20
  return href;
19
21
  });
20
- if (!options.filename)
21
- options.filename = (0, node_path_1.basename)(url).split('.')[0];
22
- await m3u8BatchDownload(list, options);
22
+ const o = { ...options };
23
+ if (!o.filename)
24
+ o.filename = (0, node_path_1.basename)(url).split('.')[0];
25
+ const t = await formatUrls(list, o);
26
+ for (const d of t.entries())
27
+ taskset.set(d[0], d[1]);
23
28
  continue;
24
29
  }
25
30
  }
26
- await (0, m3u8_download_1.m3u8Download)(url, options);
31
+ taskset.set(url, options);
27
32
  }
33
+ return taskset;
34
+ }
35
+ async function m3u8BatchDownload(urls, options) {
36
+ const tasks = await formatUrls(urls, options);
37
+ return new Promise(rs => {
38
+ let preDLing = false;
39
+ const run = async () => {
40
+ const [key, keyNext] = [...tasks.keys()];
41
+ if (key) {
42
+ const o = { ...tasks.get(key) };
43
+ tasks.delete(key);
44
+ const p = o.onProgress;
45
+ o.onProgress = (finished, total, info) => {
46
+ if (p)
47
+ p(finished, total, info);
48
+ if (!preDLing && keyNext && tasks.size && m3u8_download_1.workPoll.freeNum > 1 && total - finished < options.threadNum) {
49
+ utils_1.logger.debug('\n[预下载下一集]', 'freeNum:', m3u8_download_1.workPoll.freeNum, 'totalNum:', m3u8_download_1.workPoll.totalNum, 'totalTask:', m3u8_download_1.workPoll.totalTask, tasks.size);
50
+ preDLing = true;
51
+ (0, m3u8_download_1.preDownLoad)(keyNext, options).then(() => (preDLing = false));
52
+ }
53
+ };
54
+ (0, m3u8_download_1.m3u8Download)(key, o).then(() => (tasks.size === 0 ? rs() : run()));
55
+ }
56
+ };
57
+ run();
58
+ });
28
59
  }
29
60
  exports.m3u8BatchDownload = m3u8BatchDownload;
@@ -0,0 +1,2 @@
1
+ export * from './m3u8';
2
+ export * from './video-search';
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./m3u8"), exports);
18
+ __exportStar(require("./video-search"), exports);
@@ -1,4 +1,4 @@
1
- /// <reference path="../global.d.ts" />
1
+ /// <reference path="../../global.d.ts" />
2
2
  /// <reference types="node" />
3
3
  /// <reference types="node" />
4
4
  import type { IncomingHttpHeaders } from 'node:http';
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ /* eslint-disable @typescript-eslint/triple-slash-reference */
3
+ /// <reference path="../../global.d.ts"/>
4
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,122 @@
1
+ /** 模糊搜索返回的结果(?wd=<wd>) */
2
+ export interface VideoSearchResult {
3
+ code: number;
4
+ msg: string;
5
+ page: number;
6
+ pagecount: number;
7
+ limit: string;
8
+ total: number;
9
+ list: VodList[];
10
+ class: {
11
+ type_id: number;
12
+ type_pid: number;
13
+ type_name: string;
14
+ }[];
15
+ }
16
+ interface VodList {
17
+ vod_id: number;
18
+ vod_name: string;
19
+ type_id: number;
20
+ type_name: string;
21
+ vod_en: string;
22
+ vod_time: string;
23
+ vod_remarks: string;
24
+ vod_play_from: string;
25
+ vod_play_url: string;
26
+ }
27
+ /** 按 id 搜素返回的详情列表 */
28
+ export interface VideoListResult {
29
+ code: number;
30
+ msg: string;
31
+ page: number;
32
+ pagecount: number;
33
+ limit: string;
34
+ total: number;
35
+ list: VideoDetails[];
36
+ }
37
+ export interface VideoDetails {
38
+ vod_id: number;
39
+ type_id: number;
40
+ type_id_1: number;
41
+ group_id: number;
42
+ vod_name: string;
43
+ vod_sub: string;
44
+ vod_en: string;
45
+ vod_status: number;
46
+ vod_letter: string;
47
+ vod_color: string;
48
+ vod_tag: string;
49
+ vod_class: string;
50
+ vod_pic: string;
51
+ vod_pic_thumb: string;
52
+ vod_pic_slide: string;
53
+ vod_pic_screenshot: null;
54
+ vod_actor: string;
55
+ vod_director: string;
56
+ vod_writer: string;
57
+ vod_behind: string;
58
+ vod_blurb: string;
59
+ vod_remarks: string;
60
+ vod_pubdate: string;
61
+ vod_total: number;
62
+ vod_serial: string;
63
+ vod_tv: string;
64
+ vod_weekday: string;
65
+ vod_area: string;
66
+ vod_lang: string;
67
+ vod_year: string;
68
+ vod_version: string;
69
+ vod_state: string;
70
+ vod_author: string;
71
+ vod_jumpurl: string;
72
+ vod_tpl: string;
73
+ vod_tpl_play: string;
74
+ vod_tpl_down: string;
75
+ vod_isend: number;
76
+ vod_lock: number;
77
+ vod_level: number;
78
+ vod_copyright: number;
79
+ vod_points: number;
80
+ vod_points_play: number;
81
+ vod_points_down: number;
82
+ vod_hits: number;
83
+ vod_hits_day: number;
84
+ vod_hits_week: number;
85
+ vod_hits_month: number;
86
+ vod_duration: string;
87
+ vod_up: number;
88
+ vod_down: number;
89
+ vod_score: string;
90
+ vod_score_all: number;
91
+ vod_score_num: number;
92
+ vod_time: string;
93
+ vod_time_add: number;
94
+ vod_time_hits: number;
95
+ vod_time_make: number;
96
+ vod_trysee: number;
97
+ vod_douban_id: number;
98
+ vod_douban_score: string;
99
+ vod_reurl: string;
100
+ vod_rel_vod: string;
101
+ vod_rel_art: string;
102
+ vod_pwd: string;
103
+ vod_pwd_url: string;
104
+ vod_pwd_play: string;
105
+ vod_pwd_play_url: string;
106
+ vod_pwd_down: string;
107
+ vod_pwd_down_url: string;
108
+ vod_content: string;
109
+ vod_play_from: string;
110
+ vod_play_server: string;
111
+ vod_play_note: string;
112
+ vod_play_url: string;
113
+ vod_down_from: string;
114
+ vod_down_server: string;
115
+ vod_down_note: string;
116
+ vod_down_url: string;
117
+ vod_plot: number;
118
+ vod_plot_name: string;
119
+ vod_plot_detail: string;
120
+ type_name: string;
121
+ }
122
+ export {};
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lzwme/m3u8-dl",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
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",
@@ -44,23 +44,24 @@
44
44
  "registry": "https://registry.npmjs.com"
45
45
  },
46
46
  "devDependencies": {
47
- "@lzwme/fed-lint-helper": "^2.2.0",
48
- "@types/node": "^18.11.18",
49
- "@typescript-eslint/eslint-plugin": "^5.49.0",
50
- "@typescript-eslint/parser": "^5.49.0",
51
- "eslint": "^8.32.0",
52
- "eslint-config-prettier": "^8.6.0",
47
+ "@lzwme/fed-lint-helper": "^2.3.2",
48
+ "@types/node": "^20.2.0",
49
+ "@typescript-eslint/eslint-plugin": "^5.59.6",
50
+ "@typescript-eslint/parser": "^5.59.6",
51
+ "eslint": "^8.40.0",
52
+ "eslint-config-prettier": "^8.8.0",
53
53
  "eslint-plugin-prettier": "^4.2.1",
54
54
  "husky": "^8.0.3",
55
- "prettier": "^2.8.3",
55
+ "prettier": "^2.8.8",
56
56
  "standard-version": "^9.5.0",
57
- "typescript": "^4.9.4"
57
+ "typescript": "^5.0.4"
58
58
  },
59
59
  "dependencies": {
60
- "@lzwme/fe-utils": "^1.3.3",
61
- "commander": "^10.0.0",
62
- "console-log-colors": "^0.3.3",
63
- "m3u8-parser": "^6.0.0"
60
+ "@lzwme/fe-utils": "^1.5.1",
61
+ "commander": "^10.0.1",
62
+ "console-log-colors": "^0.4.0",
63
+ "enquirer": "^2.3.6",
64
+ "m3u8-parser": "^6.1.0"
64
65
  },
65
66
  "files": [
66
67
  "cjs",