@lzwme/m3u8-dl 1.6.0 → 1.7.0

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.
@@ -60,7 +60,7 @@ class DLServer {
60
60
  };
61
61
  serverInfo = {
62
62
  version: '',
63
- ariang: (0, node_fs_1.existsSync)((0, node_path_1.resolve)(rootDir, 'client/ariang/index.html')),
63
+ ariang: false,
64
64
  };
65
65
  cfg = {
66
66
  /** 支持 web 设置修改的参数 */
@@ -91,28 +91,29 @@ class DLServer {
91
91
  opts.cacheDir = (0, node_path_1.resolve)(opts.cacheDir);
92
92
  if (!opts.configPath)
93
93
  opts.configPath = (0, node_path_1.resolve)(opts.cacheDir, 'config.json');
94
- const pkgFile = (0, node_path_1.resolve)(rootDir, 'package.json');
95
- if ((0, node_fs_1.existsSync)(pkgFile)) {
96
- const pkg = JSON.parse((0, node_fs_1.readFileSync)(pkgFile, 'utf8'));
97
- this.serverInfo.version = pkg.version;
98
- }
99
94
  if (opts.token)
100
95
  opts.token = (0, fe_utils_1.md5)(opts.token.trim()).slice(0, 8);
101
96
  this.init();
102
97
  }
103
98
  async init() {
104
- this.readConfig();
99
+ const pkgFile = (0, node_path_1.resolve)(rootDir, 'package.json');
100
+ if (await (0, utils_js_1.checkFileExists)(pkgFile)) {
101
+ const pkg = JSON.parse(await node_fs_1.promises.readFile(pkgFile, 'utf8'));
102
+ this.serverInfo.version = pkg.version;
103
+ }
104
+ this.serverInfo.ariang = await (0, utils_js_1.checkFileExists)((0, node_path_1.resolve)(rootDir, 'client/ariang/index.html'));
105
+ await this.loadConfig();
105
106
  if (this.cfg.dlOptions.debug)
106
107
  utils_js_1.logger.updateOptions({ levelType: 'debug' });
107
- this.loadCache();
108
+ await this.loadCache();
108
109
  await this.createApp();
109
110
  this.initRouters();
110
111
  utils_js_1.logger.debug('Server initialized', 'cacheSize:', this.dlCache.size, this.options, this.cfg.dlOptions);
111
112
  }
112
- loadCache() {
113
+ async loadCache() {
113
114
  const cacheFile = (0, node_path_1.resolve)(this.options.cacheDir, 'cache.json');
114
- if ((0, node_fs_1.existsSync)(cacheFile)) {
115
- JSON.parse((0, node_fs_1.readFileSync)(cacheFile, 'utf8')).forEach(([url, item]) => {
115
+ if (await (0, utils_js_1.checkFileExists)(cacheFile)) {
116
+ JSON.parse(await node_fs_1.promises.readFile(cacheFile, 'utf8')).forEach(([url, item]) => {
116
117
  if (item.status === 'resume')
117
118
  item.status = 'pause';
118
119
  this.dlCache.set(url, item);
@@ -122,20 +123,39 @@ class DLServer {
122
123
  }
123
124
  checkDLFileLaest = 0;
124
125
  checkDLFileTimer = null;
125
- checkDLFileIsExists() {
126
+ async checkDLFileIsExists() {
126
127
  const now = Date.now();
127
128
  const interval = 1000 * 60;
128
129
  clearTimeout(this.checkDLFileTimer);
129
- if (now - this.checkDLFileLaest < interval) {
130
- this.checkDLFileTimer = setTimeout(() => this.checkDLFileIsExists(), interval - (now - this.checkDLFileLaest));
130
+ const delay = this.checkDLFileLaest + interval - now;
131
+ if (delay > 0) {
132
+ this.checkDLFileTimer = setTimeout(() => {
133
+ this.checkDLFileIsExists();
134
+ }, delay + 100);
131
135
  return;
132
136
  }
133
- this.dlCache.forEach(item => {
134
- if (item.status === 'done' && (!item.localVideo || !(0, node_fs_1.existsSync)(item.localVideo))) {
137
+ const tasks = [...this.dlCache.values()].map(item => () => this.checkItemStatus(item));
138
+ await (0, fe_utils_1.concurrency)(tasks, 3);
139
+ this.checkDLFileLaest = now;
140
+ }
141
+ async checkItemStatus(item) {
142
+ if (item.status === 'done') {
143
+ if (item.current) {
144
+ if (!item.cacheDir && item.current.tsOut)
145
+ item.cacheDir = (0, node_path_1.dirname)(item.current.tsOut);
146
+ delete item.current;
147
+ }
148
+ if (!(await (0, utils_js_1.checkFileExists)(item.localVideo)) && !(0, node_fs_1.existsSync)(item.localVideo)) {
135
149
  item.status = 'error';
136
150
  item.errmsg = '已删除';
137
151
  }
138
- });
152
+ }
153
+ else if (item.status === 'error' && item.progress === 100) {
154
+ if (await (0, utils_js_1.checkFileExists)(item.localVideo)) {
155
+ item.status = 'done';
156
+ delete item.errmsg;
157
+ }
158
+ }
139
159
  }
140
160
  dlCacheClone() {
141
161
  const info = [];
@@ -154,34 +174,32 @@ class DLServer {
154
174
  clearTimeout(this.cacheSaveTimer);
155
175
  this.cacheSaveTimer = setTimeout(() => {
156
176
  const cacheFile = (0, node_path_1.resolve)(this.options.cacheDir, 'cache.json');
157
- const info = this.dlCacheClone();
158
177
  (0, fe_utils_1.mkdirp)((0, node_path_1.dirname)(cacheFile));
159
- (0, node_fs_1.writeFileSync)(cacheFile, JSON.stringify(info));
178
+ node_fs_1.promises.writeFile(cacheFile, JSON.stringify(this.dlCacheClone()));
160
179
  }, 1000);
161
180
  }
162
- readConfig(configPath) {
181
+ async loadConfig(configPath) {
163
182
  try {
164
183
  if (!configPath)
165
184
  configPath = this.options.configPath;
166
- if ((0, node_fs_1.existsSync)(configPath))
167
- (0, fe_utils_1.assign)(this.cfg, JSON.parse((0, node_fs_1.readFileSync)(configPath, 'utf8')));
185
+ if (await (0, utils_js_1.checkFileExists)(configPath))
186
+ (0, fe_utils_1.assign)(this.cfg, JSON.parse(await node_fs_1.promises.readFile(configPath, 'utf8')));
168
187
  }
169
188
  catch (error) {
170
- utils_js_1.logger.error('读取配置失败:', error);
189
+ utils_js_1.logger.error('Load config failed:', error);
171
190
  }
172
- return this.cfg.dlOptions;
173
191
  }
174
- saveConfig(config, configPath) {
192
+ async saveConfig(config, configPath) {
175
193
  if (!configPath)
176
194
  configPath = this.options.configPath;
177
195
  // 验证 ffmpegPath 是否存在
178
196
  if (config.ffmpegPath?.trim()) {
179
197
  const ffmpegPath = config.ffmpegPath.trim();
180
- if (!(0, node_fs_1.existsSync)(ffmpegPath)) {
198
+ if (!(await (0, utils_js_1.checkFileExists)(ffmpegPath))) {
181
199
  throw new Error(`ffmpeg 路径不存在: ${ffmpegPath}`);
182
200
  }
183
201
  // 检查是否为文件(不是目录)
184
- const stats = (0, node_fs_1.statSync)(ffmpegPath);
202
+ const stats = await node_fs_1.promises.stat(ffmpegPath);
185
203
  if (!stats.isFile()) {
186
204
  throw new Error(`ffmpeg 路径不是文件: ${ffmpegPath}`);
187
205
  }
@@ -195,7 +213,7 @@ class DLServer {
195
213
  this.cfg.dlOptions[key] = value;
196
214
  }
197
215
  (0, fe_utils_1.mkdirp)((0, node_path_1.dirname)(configPath));
198
- (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(this.cfg, null, 2));
216
+ return node_fs_1.promises.writeFile(configPath, JSON.stringify(this.cfg, null, 2));
199
217
  }
200
218
  async createApp() {
201
219
  const { default: express } = await Promise.resolve().then(() => __importStar(require('express')));
@@ -203,17 +221,17 @@ class DLServer {
203
221
  const app = express();
204
222
  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)}`));
205
223
  const wss = new WebSocketServer({ server });
224
+ const hasLocalCdnDir = await (0, utils_js_1.checkFileExists)((0, node_path_1.resolve)(rootDir, 'client/local/cdn'));
206
225
  this.app = app;
207
226
  this.wss = wss;
208
- app.use((req, res, next) => {
227
+ app.use(async (req, res, next) => {
209
228
  // 处理 SPA 路由:根路径和 /page/* 路径都返回 index.html
210
229
  const isIndexPage = ['/', '/index.html'].includes(req.path) || req.path.startsWith('/page/');
211
230
  const isPlayPage = req.path.startsWith('/play.html');
212
231
  const isApi = req.path.startsWith('/api/');
213
232
  if (!isApi && (isIndexPage || isPlayPage)) {
214
- const version = this.serverInfo.version;
215
- let htmlContent = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(rootDir, `client/${isPlayPage ? 'play' : 'index'}.html`), 'utf-8').replaceAll('{{version}}', version);
216
- if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(rootDir, 'client/local/cdn'))) {
233
+ let htmlContent = await node_fs_1.promises.readFile((0, node_path_1.resolve)(rootDir, `client/${isPlayPage ? 'play' : 'index'}.html`), 'utf-8');
234
+ if (hasLocalCdnDir) {
217
235
  // 提取所有 zstatic.net 的 js 和 css 资源地址,若子路径存在于 local/cdn 目录下则替换为本地路径
218
236
  const zstaticRegex = /https:\/\/s4\.zstatic\.net\/ajax\/libs\/[^\s"'`<>]+\.(js|css)/g;
219
237
  const zstaticMatches = htmlContent.match(zstaticRegex);
@@ -221,7 +239,7 @@ class DLServer {
221
239
  for (const match of zstaticMatches) {
222
240
  const relativePath = match.split('libs/')[1];
223
241
  const localPath = (0, node_path_1.resolve)(rootDir, `client/local/cdn/${relativePath}`);
224
- if ((0, node_fs_1.existsSync)(localPath)) {
242
+ if (await (0, utils_js_1.checkFileExists)(localPath)) {
225
243
  htmlContent = htmlContent.replaceAll(match, `/local/cdn/${relativePath}`);
226
244
  }
227
245
  }
@@ -289,14 +307,23 @@ class DLServer {
289
307
  options.filename = item[1];
290
308
  }
291
309
  const { options: dlOptions } = (0, format_options_js_1.formatOptions)(url, { ...this.cfg.dlOptions, ...options, cacheDir: this.options.cacheDir });
310
+ if (!dlOptions.saveDir)
311
+ dlOptions.saveDir = this.cfg.dlOptions.saveDir;
292
312
  const cacheItem = this.dlCache.get(url) || { options, dlOptions, status: 'pending', url };
293
313
  utils_js_1.logger.debug('startDownload', url, dlOptions, cacheItem.status);
294
314
  if (cacheItem.status === 'resume')
295
315
  return;
296
- if (cacheItem.localVideo && !(0, node_fs_1.existsSync)(cacheItem.localVideo))
297
- delete cacheItem.localVideo;
298
- if (cacheItem.endTime)
299
- delete cacheItem.endTime;
316
+ if (cacheItem.localVideo) {
317
+ if (await (0, utils_js_1.checkFileExists)(cacheItem.localVideo)) {
318
+ if (cacheItem.status === 'done')
319
+ return;
320
+ }
321
+ else {
322
+ delete cacheItem.localVideo;
323
+ if (cacheItem.endTime)
324
+ delete cacheItem.endTime;
325
+ }
326
+ }
300
327
  cacheItem.status = this.downloading >= this.cfg.webOptions.maxDownloads ? 'pending' : 'resume';
301
328
  // pending 优先级靠后
302
329
  if (cacheItem.status === 'pending' && this.dlCache.has(url))
@@ -317,18 +344,20 @@ class DLServer {
317
344
  if (!item)
318
345
  return false; // 已删除
319
346
  const status = item.status || 'resume';
320
- Object.assign(item, { ...stats, current, options: dlOptions, status, workPoll, url });
347
+ if (!item.cacheDir && current?.tsOut)
348
+ item.cacheDir = (0, node_path_1.dirname)(current.tsOut);
349
+ Object.assign(item, { ...stats, dlOptions, status, workPoll, url });
321
350
  this.dlCache.set(url, item);
322
351
  this.saveCache();
323
352
  this.wsSend('progress', url);
324
353
  return status !== 'pause';
325
354
  },
326
355
  };
327
- const afterDownload = (r, url) => {
356
+ const afterDownload = async (r, url) => {
328
357
  const item = this.dlCache.get(url) || cacheItem;
329
- if (r.filepath && (0, node_fs_1.existsSync)(r.filepath)) {
358
+ if (r.filepath && (await (0, utils_js_1.checkFileExists)(r.filepath))) {
330
359
  item.localVideo = r.filepath;
331
- item.downloadedSize = (0, node_fs_1.statSync)(r.filepath).size;
360
+ item.downloadedSize = (await node_fs_1.promises.stat(r.filepath)).size;
332
361
  }
333
362
  else if (!r.errmsg && opts.convert !== false)
334
363
  r.errmsg = '下载失败';
@@ -372,6 +401,11 @@ class DLServer {
372
401
  if (queryLang && i18n_js_1.LANG_CODES.has(queryLang)) {
373
402
  return queryLang;
374
403
  }
404
+ // Try to get lang from body
405
+ const bodyLang = req.body?.lang;
406
+ if (bodyLang && i18n_js_1.LANG_CODES.has(bodyLang)) {
407
+ return bodyLang;
408
+ }
375
409
  // Try to get lang from Accept-Language header
376
410
  const acceptLanguage = req.headers['accept-language'];
377
411
  if (acceptLanguage) {
@@ -380,11 +414,6 @@ class DLServer {
380
414
  return langCode;
381
415
  }
382
416
  }
383
- // Try to get lang from body
384
- const bodyLang = req.body?.lang;
385
- if (bodyLang && i18n_js_1.LANG_CODES.has(bodyLang)) {
386
- return bodyLang;
387
- }
388
417
  // Fallback to default
389
418
  return (0, i18n_js_1.getLang)();
390
419
  }
@@ -471,19 +500,32 @@ class DLServer {
471
500
  // });
472
501
  // API to start m3u8 download
473
502
  app.post('/api/download', (req, res) => {
474
- const { url, options = {}, list = [] } = req.body;
503
+ const { list = [] } = req.body;
475
504
  const lang = this.getLangFromRequest(req);
476
505
  try {
477
- if (list.length) {
478
- for (const item of list) {
479
- const { url, ...options } = item;
480
- if (url)
481
- this.startDownload(url, options);
506
+ let duplicateCount = 0;
507
+ let startedCount = 0;
508
+ // 检查并统计重复的 URL,但仍允许下载
509
+ for (const item of list) {
510
+ const { url, ...options } = item;
511
+ if (url) {
512
+ if (this.dlCache.has(url)) {
513
+ duplicateCount++;
514
+ }
515
+ else {
516
+ startedCount++;
517
+ }
518
+ this.startDownload(url, options);
482
519
  }
483
520
  }
484
- else if (url)
485
- this.startDownload(url, options);
486
- res.json({ message: (0, i18n_js_1.t)('api.success.downloadStarted', lang, { count: list.length || 1 }), code: 0 });
521
+ let message = '';
522
+ if (duplicateCount > 0) {
523
+ message = `${(0, i18n_js_1.t)('api.success.downloadStarted', lang, { count: list.length })},${(0, i18n_js_1.t)('api.error.duplicateDownload', lang, { count: duplicateCount })}`;
524
+ }
525
+ else {
526
+ message = (0, i18n_js_1.t)('api.success.downloadStarted', lang, { count: list.length });
527
+ }
528
+ res.json({ message, code: 0, duplicateCount, startedCount });
487
529
  this.wsSend('tasks');
488
530
  }
489
531
  catch (error) {
@@ -536,7 +578,7 @@ class DLServer {
536
578
  });
537
579
  });
538
580
  // API to delete download
539
- app.post('/api/delete', (req, res) => {
581
+ app.post('/api/delete', async (req, res) => {
540
582
  const { urls, deleteCache = false, deleteVideo = false } = req.body;
541
583
  const urlsToDelete = urls;
542
584
  const list = [];
@@ -546,21 +588,21 @@ class DLServer {
546
588
  (0, m3u8_download_js_1.m3u8DLStop)(url, item.workPoll);
547
589
  this.dlCache.delete(url);
548
590
  list.push(item.url);
549
- if (deleteCache && item.current?.tsOut) {
550
- const cacheDir = (0, node_path_1.dirname)(item.current.tsOut);
551
- if ((0, node_fs_1.existsSync)(cacheDir)) {
552
- (0, node_fs_1.rmSync)(cacheDir, { recursive: true });
591
+ if (deleteCache) {
592
+ const cacheDir = item.cacheDir;
593
+ if (await (0, utils_js_1.checkFileExists)(cacheDir)) {
594
+ await node_fs_1.promises.rm(cacheDir, { recursive: true });
553
595
  utils_js_1.logger.debug('删除缓存目录:', cacheDir);
554
596
  }
555
597
  }
556
598
  if (deleteVideo) {
557
- ['.ts', '.mp4'].forEach(ext => {
599
+ for (const ext of ['.ts', '.mp4']) {
558
600
  const filepath = (0, node_path_1.resolve)(item.options.saveDir, item.options.filename + ext);
559
- if ((0, node_fs_1.existsSync)(filepath)) {
560
- (0, node_fs_1.unlinkSync)(filepath);
601
+ if (await (0, utils_js_1.checkFileExists)(filepath)) {
602
+ await node_fs_1.promises.unlink(filepath);
561
603
  utils_js_1.logger.debug('删除文件:', filepath);
562
604
  }
563
- });
605
+ }
564
606
  }
565
607
  }
566
608
  }
@@ -572,20 +614,73 @@ class DLServer {
572
614
  const lang = this.getLangFromRequest(req);
573
615
  res.json({ message: (0, i18n_js_1.t)('api.success.deleted', lang, { count: list.length }), code: 0, count: list.length });
574
616
  });
575
- app.get(/^\/localplay\/(.*)$/, (req, res) => {
617
+ // API to rename download file
618
+ app.post('/api/rename', async (req, res) => {
619
+ const { url, newFilename } = req.body;
620
+ const lang = this.getLangFromRequest(req);
621
+ if (!url || !newFilename) {
622
+ res.json({ code: 1001, message: (0, i18n_js_1.t)('api.error.invalidParams', lang) });
623
+ return;
624
+ }
625
+ // 检查新文件名是否包含非法字符
626
+ const invalidChars = /[<>:"/\\|?*]/;
627
+ if (invalidChars.test(newFilename)) {
628
+ res.json({ code: 1004, message: (0, i18n_js_1.t)('api.error.invalidFilename', lang) });
629
+ return;
630
+ }
631
+ const item = this.dlCache.get(url);
632
+ if (!item) {
633
+ res.json({ code: 1002, message: (0, i18n_js_1.t)('api.error.taskNotFound', lang) });
634
+ return;
635
+ }
636
+ await this.checkItemStatus(item);
637
+ // 只允许重命名已完成且状态正常的任务
638
+ if (item.status !== 'done' || item.errmsg) {
639
+ utils_js_1.logger.error('rename failed:', item.status, (0, console_log_colors_1.red)(item.errmsg), (0, console_log_colors_1.gray)(url));
640
+ res.json({ code: 1003, message: (0, i18n_js_1.t)('api.error.onlyRenameCompleted', lang) });
641
+ return;
642
+ }
643
+ try {
644
+ const oldPath = item.localVideo;
645
+ const oldDir = (0, node_path_1.dirname)(oldPath);
646
+ const oldExt = oldPath.split('.').pop() || '';
647
+ const newFilenameBase = newFilename.replace(/\.[^.]+$/, '');
648
+ const newPath = (0, node_path_1.resolve)(oldDir, `${newFilenameBase}${oldExt ? `.${oldExt}` : ''}`);
649
+ // 检查新文件名是否已存在
650
+ if (await (0, utils_js_1.checkFileExists)(newPath)) {
651
+ res.json({ code: 1007, message: (0, i18n_js_1.t)('api.error.fileExists', lang) });
652
+ return;
653
+ }
654
+ // 重命名文件
655
+ await node_fs_1.promises.rename(oldPath, newPath);
656
+ utils_js_1.logger.debug('重命名文件:', (0, console_log_colors_1.gray)(oldPath), '->', (0, console_log_colors_1.cyan)(newPath));
657
+ // 更新任务信息
658
+ item.localVideo = newPath;
659
+ item.options.filename = item.filename = (0, node_path_1.basename)(newPath);
660
+ this.dlCache.set(url, item);
661
+ this.saveCache();
662
+ this.wsSend('progress', url);
663
+ res.json({ message: (0, i18n_js_1.t)('api.success.renamed', lang), code: 0 });
664
+ }
665
+ catch (error) {
666
+ utils_js_1.logger.error('重命名失败:', error);
667
+ res.json({ code: 1006, message: (0, i18n_js_1.t)('api.error.renameFailed', lang, { error: error.message }) });
668
+ }
669
+ });
670
+ app.get(/^\/localplay\/(.*)$/, async (req, res) => {
576
671
  let filepath = decodeURIComponent(req.params[0]);
577
672
  if (filepath) {
578
673
  let ext = filepath.split('.').pop();
579
674
  if (!ext) {
580
675
  ext = 'm3u8';
581
- if (!(0, node_fs_1.existsSync)(filepath))
676
+ if (!(await (0, utils_js_1.checkFileExists)(filepath)))
582
677
  filepath += '.m3u8';
583
678
  }
584
679
  const allowedDirs = [this.options.cacheDir, this.cfg.dlOptions.saveDir];
585
- if (!(0, node_fs_1.existsSync)(filepath)) {
680
+ if (!(await (0, utils_js_1.checkFileExists)(filepath))) {
586
681
  for (const dir of allowedDirs) {
587
682
  const tpath = (0, node_path_1.resolve)(dir, filepath);
588
- if ((0, node_fs_1.existsSync)(tpath)) {
683
+ if (await (0, utils_js_1.checkFileExists)(tpath)) {
589
684
  filepath = tpath;
590
685
  break;
591
686
  }
@@ -601,8 +696,8 @@ class DLServer {
601
696
  return;
602
697
  }
603
698
  }
604
- if ((0, node_fs_1.existsSync)(filepath)) {
605
- const stats = (0, node_fs_1.statSync)(filepath);
699
+ if (await (0, utils_js_1.checkFileExists)(filepath)) {
700
+ const stats = await node_fs_1.promises.stat(filepath);
606
701
  const headers = new Headers({
607
702
  'Last-Modified': stats.mtime.toUTCString(),
608
703
  'Access-Control-Allow-Headers': '*',
@@ -614,7 +709,8 @@ class DLServer {
614
709
  });
615
710
  res.setHeaders(headers);
616
711
  if (ext === 'm3u8' || ('ts' === ext && stats.size < 1024 * 1024 * 3)) {
617
- res.send((0, node_fs_1.readFileSync)(filepath));
712
+ const data = await node_fs_1.promises.readFile(filepath);
713
+ res.send(data);
618
714
  utils_js_1.logger.debug('[Localplay]file sent:', (0, console_log_colors_1.gray)(filepath), 'Size:', stats.size, 'bytes');
619
715
  }
620
716
  else {