@lzwme/m3u8-dl 1.6.0 → 1.8.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.
@@ -43,6 +43,7 @@ const file_download_js_1 = require("../lib/file-download.js");
43
43
  const format_options_js_1 = require("../lib/format-options.js");
44
44
  const getM3u8Urls_js_1 = require("../lib/getM3u8Urls.js");
45
45
  const i18n_js_1 = require("../lib/i18n.js");
46
+ const init_proxy_js_1 = require("../lib/init-proxy.js");
46
47
  const m3u8_download_js_1 = require("../lib/m3u8-download.js");
47
48
  const utils_js_1 = require("../lib/utils.js");
48
49
  const index_js_1 = require("../video-parser/index.js");
@@ -60,7 +61,7 @@ class DLServer {
60
61
  };
61
62
  serverInfo = {
62
63
  version: '',
63
- ariang: (0, node_fs_1.existsSync)((0, node_path_1.resolve)(rootDir, 'client/ariang/index.html')),
64
+ ariang: false,
64
65
  };
65
66
  cfg = {
66
67
  /** 支持 web 设置修改的参数 */
@@ -78,6 +79,10 @@ class DLServer {
78
79
  saveDir: process.env.DS_SAVE_DIR || './downloads',
79
80
  threadNum: 4,
80
81
  ffmpegPath: process.env.DS_FFMPEG_PATH || undefined,
82
+ // 代理配置改为字符串模式:'custom', 'system', 'disabled'
83
+ proxyMode: process.env.DS_PROXY_MODE || 'system',
84
+ proxyUrl: process.env.DS_PROXY_URL || undefined,
85
+ noProxy: process.env.DS_NO_PROXY || undefined,
81
86
  },
82
87
  };
83
88
  /** 下载任务缓存 */
@@ -91,28 +96,31 @@ class DLServer {
91
96
  opts.cacheDir = (0, node_path_1.resolve)(opts.cacheDir);
92
97
  if (!opts.configPath)
93
98
  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
99
  if (opts.token)
100
100
  opts.token = (0, fe_utils_1.md5)(opts.token.trim()).slice(0, 8);
101
101
  this.init();
102
102
  }
103
103
  async init() {
104
- this.readConfig();
104
+ const pkgFile = (0, node_path_1.resolve)(rootDir, 'package.json');
105
+ if (await (0, utils_js_1.checkFileExists)(pkgFile)) {
106
+ const pkg = JSON.parse(await node_fs_1.promises.readFile(pkgFile, 'utf8'));
107
+ this.serverInfo.version = pkg.version;
108
+ }
109
+ this.serverInfo.ariang = await (0, utils_js_1.checkFileExists)((0, node_path_1.resolve)(rootDir, 'client/ariang/index.html'));
110
+ await this.loadConfig();
105
111
  if (this.cfg.dlOptions.debug)
106
112
  utils_js_1.logger.updateOptions({ levelType: 'debug' });
107
- this.loadCache();
113
+ await this.loadCache();
108
114
  await this.createApp();
109
115
  this.initRouters();
110
116
  utils_js_1.logger.debug('Server initialized', 'cacheSize:', this.dlCache.size, this.options, this.cfg.dlOptions);
117
+ // 初始化 global-agent 代理
118
+ (0, init_proxy_js_1.initProxy)(this.cfg.dlOptions);
111
119
  }
112
- loadCache() {
120
+ async loadCache() {
113
121
  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]) => {
122
+ if (await (0, utils_js_1.checkFileExists)(cacheFile)) {
123
+ JSON.parse(await node_fs_1.promises.readFile(cacheFile, 'utf8')).forEach(([url, item]) => {
116
124
  if (item.status === 'resume')
117
125
  item.status = 'pause';
118
126
  this.dlCache.set(url, item);
@@ -122,20 +130,39 @@ class DLServer {
122
130
  }
123
131
  checkDLFileLaest = 0;
124
132
  checkDLFileTimer = null;
125
- checkDLFileIsExists() {
133
+ async checkDLFileIsExists() {
126
134
  const now = Date.now();
127
135
  const interval = 1000 * 60;
128
136
  clearTimeout(this.checkDLFileTimer);
129
- if (now - this.checkDLFileLaest < interval) {
130
- this.checkDLFileTimer = setTimeout(() => this.checkDLFileIsExists(), interval - (now - this.checkDLFileLaest));
137
+ const delay = this.checkDLFileLaest + interval - now;
138
+ if (delay > 0) {
139
+ this.checkDLFileTimer = setTimeout(() => {
140
+ this.checkDLFileIsExists();
141
+ }, delay + 100);
131
142
  return;
132
143
  }
133
- this.dlCache.forEach(item => {
134
- if (item.status === 'done' && (!item.localVideo || !(0, node_fs_1.existsSync)(item.localVideo))) {
144
+ const tasks = [...this.dlCache.values()].map(item => () => this.checkItemStatus(item));
145
+ await (0, fe_utils_1.concurrency)(tasks, 3);
146
+ this.checkDLFileLaest = now;
147
+ }
148
+ async checkItemStatus(item) {
149
+ if (item.status === 'done') {
150
+ if (item.current) {
151
+ if (!item.cacheDir && item.current.tsOut)
152
+ item.cacheDir = (0, node_path_1.dirname)(item.current.tsOut);
153
+ delete item.current;
154
+ }
155
+ if (!(await (0, utils_js_1.checkFileExists)(item.localVideo)) && !(0, node_fs_1.existsSync)(item.localVideo)) {
135
156
  item.status = 'error';
136
157
  item.errmsg = '已删除';
137
158
  }
138
- });
159
+ }
160
+ else if (item.status === 'error' && item.progress === 100) {
161
+ if (await (0, utils_js_1.checkFileExists)(item.localVideo)) {
162
+ item.status = 'done';
163
+ delete item.errmsg;
164
+ }
165
+ }
139
166
  }
140
167
  dlCacheClone() {
141
168
  const info = [];
@@ -154,34 +181,32 @@ class DLServer {
154
181
  clearTimeout(this.cacheSaveTimer);
155
182
  this.cacheSaveTimer = setTimeout(() => {
156
183
  const cacheFile = (0, node_path_1.resolve)(this.options.cacheDir, 'cache.json');
157
- const info = this.dlCacheClone();
158
184
  (0, fe_utils_1.mkdirp)((0, node_path_1.dirname)(cacheFile));
159
- (0, node_fs_1.writeFileSync)(cacheFile, JSON.stringify(info));
185
+ node_fs_1.promises.writeFile(cacheFile, JSON.stringify(this.dlCacheClone()));
160
186
  }, 1000);
161
187
  }
162
- readConfig(configPath) {
188
+ async loadConfig(configPath) {
163
189
  try {
164
190
  if (!configPath)
165
191
  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')));
192
+ if (await (0, utils_js_1.checkFileExists)(configPath))
193
+ (0, fe_utils_1.assign)(this.cfg, JSON.parse(await node_fs_1.promises.readFile(configPath, 'utf8')));
168
194
  }
169
195
  catch (error) {
170
- utils_js_1.logger.error('读取配置失败:', error);
196
+ utils_js_1.logger.error('Load config failed:', error);
171
197
  }
172
- return this.cfg.dlOptions;
173
198
  }
174
- saveConfig(config, configPath) {
199
+ async saveConfig(config, configPath) {
175
200
  if (!configPath)
176
201
  configPath = this.options.configPath;
177
202
  // 验证 ffmpegPath 是否存在
178
203
  if (config.ffmpegPath?.trim()) {
179
204
  const ffmpegPath = config.ffmpegPath.trim();
180
- if (!(0, node_fs_1.existsSync)(ffmpegPath)) {
205
+ if (!(await (0, utils_js_1.checkFileExists)(ffmpegPath))) {
181
206
  throw new Error(`ffmpeg 路径不存在: ${ffmpegPath}`);
182
207
  }
183
208
  // 检查是否为文件(不是目录)
184
- const stats = (0, node_fs_1.statSync)(ffmpegPath);
209
+ const stats = await node_fs_1.promises.stat(ffmpegPath);
185
210
  if (!stats.isFile()) {
186
211
  throw new Error(`ffmpeg 路径不是文件: ${ffmpegPath}`);
187
212
  }
@@ -195,7 +220,10 @@ class DLServer {
195
220
  this.cfg.dlOptions[key] = value;
196
221
  }
197
222
  (0, fe_utils_1.mkdirp)((0, node_path_1.dirname)(configPath));
198
- (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(this.cfg, null, 2));
223
+ const result = await node_fs_1.promises.writeFile(configPath, JSON.stringify(this.cfg, null, 2));
224
+ // 重新初始化代理
225
+ await (0, init_proxy_js_1.initProxy)(this.cfg.dlOptions);
226
+ return result;
199
227
  }
200
228
  async createApp() {
201
229
  const { default: express } = await Promise.resolve().then(() => __importStar(require('express')));
@@ -203,17 +231,17 @@ class DLServer {
203
231
  const app = express();
204
232
  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
233
  const wss = new WebSocketServer({ server });
234
+ const hasLocalCdnDir = await (0, utils_js_1.checkFileExists)((0, node_path_1.resolve)(rootDir, 'client/local/cdn'));
206
235
  this.app = app;
207
236
  this.wss = wss;
208
- app.use((req, res, next) => {
237
+ app.use(async (req, res, next) => {
209
238
  // 处理 SPA 路由:根路径和 /page/* 路径都返回 index.html
210
239
  const isIndexPage = ['/', '/index.html'].includes(req.path) || req.path.startsWith('/page/');
211
240
  const isPlayPage = req.path.startsWith('/play.html');
212
241
  const isApi = req.path.startsWith('/api/');
213
242
  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'))) {
243
+ let htmlContent = await node_fs_1.promises.readFile((0, node_path_1.resolve)(rootDir, `client/${isPlayPage ? 'play' : 'index'}.html`), 'utf-8');
244
+ if (hasLocalCdnDir) {
217
245
  // 提取所有 zstatic.net 的 js 和 css 资源地址,若子路径存在于 local/cdn 目录下则替换为本地路径
218
246
  const zstaticRegex = /https:\/\/s4\.zstatic\.net\/ajax\/libs\/[^\s"'`<>]+\.(js|css)/g;
219
247
  const zstaticMatches = htmlContent.match(zstaticRegex);
@@ -221,7 +249,7 @@ class DLServer {
221
249
  for (const match of zstaticMatches) {
222
250
  const relativePath = match.split('libs/')[1];
223
251
  const localPath = (0, node_path_1.resolve)(rootDir, `client/local/cdn/${relativePath}`);
224
- if ((0, node_fs_1.existsSync)(localPath)) {
252
+ if (await (0, utils_js_1.checkFileExists)(localPath)) {
225
253
  htmlContent = htmlContent.replaceAll(match, `/local/cdn/${relativePath}`);
226
254
  }
227
255
  }
@@ -288,15 +316,34 @@ class DLServer {
288
316
  if (!options.filename)
289
317
  options.filename = item[1];
290
318
  }
291
- const { options: dlOptions } = (0, format_options_js_1.formatOptions)(url, { ...this.cfg.dlOptions, ...options, cacheDir: this.options.cacheDir });
319
+ const { options: dlOptions } = await (0, format_options_js_1.formatOptions)(url, { ...this.cfg.dlOptions, ...options, cacheDir: this.options.cacheDir });
320
+ if (!dlOptions.saveDir)
321
+ dlOptions.saveDir = this.cfg.dlOptions.saveDir;
292
322
  const cacheItem = this.dlCache.get(url) || { options, dlOptions, status: 'pending', url };
293
323
  utils_js_1.logger.debug('startDownload', url, dlOptions, cacheItem.status);
294
324
  if (cacheItem.status === 'resume')
295
325
  return;
296
- if (cacheItem.localVideo && !(0, node_fs_1.existsSync)(cacheItem.localVideo))
297
- delete cacheItem.localVideo;
298
- if (cacheItem.endTime)
299
- delete cacheItem.endTime;
326
+ if (cacheItem.localVideo) {
327
+ if (await (0, utils_js_1.checkFileExists)(cacheItem.localVideo)) {
328
+ if (cacheItem.status === 'done')
329
+ return;
330
+ }
331
+ else {
332
+ delete cacheItem.localVideo;
333
+ if (cacheItem.endTime)
334
+ delete cacheItem.endTime;
335
+ }
336
+ }
337
+ else if (!this.dlCache.has(url)) {
338
+ // 如果本地视频已存在,则重命名 filename
339
+ const localVideo = (0, node_path_1.resolve)(dlOptions.saveDir, dlOptions.filename);
340
+ const hasSameNameVideo = [...this.dlCache.values()].some(d => d.dlOptions.saveDir === dlOptions.saveDir && d.dlOptions.filename === dlOptions.filename);
341
+ if (hasSameNameVideo || (await (0, utils_js_1.checkFileExists)(localVideo))) {
342
+ const ext = (0, node_path_1.extname)(localVideo) || '';
343
+ dlOptions.filename = `${(0, node_path_1.basename)(localVideo, ext)}.${Date.now()}${ext}`;
344
+ utils_js_1.logger.info('存在重名视频,重命名filename:', (0, console_log_colors_1.gray)(localVideo), '->', (0, console_log_colors_1.cyan)(dlOptions.filename));
345
+ }
346
+ }
300
347
  cacheItem.status = this.downloading >= this.cfg.webOptions.maxDownloads ? 'pending' : 'resume';
301
348
  // pending 优先级靠后
302
349
  if (cacheItem.status === 'pending' && this.dlCache.has(url))
@@ -317,24 +364,26 @@ class DLServer {
317
364
  if (!item)
318
365
  return false; // 已删除
319
366
  const status = item.status || 'resume';
320
- Object.assign(item, { ...stats, current, options: dlOptions, status, workPoll, url });
367
+ if (!item.cacheDir && current?.tsOut)
368
+ item.cacheDir = (0, node_path_1.dirname)(current.tsOut);
369
+ Object.assign(item, { ...stats, dlOptions, status, workPoll, url });
321
370
  this.dlCache.set(url, item);
322
371
  this.saveCache();
323
372
  this.wsSend('progress', url);
324
373
  return status !== 'pause';
325
374
  },
326
375
  };
327
- const afterDownload = (r, url) => {
376
+ const afterDownload = async (r, url) => {
328
377
  const item = this.dlCache.get(url) || cacheItem;
329
- if (r.filepath && (0, node_fs_1.existsSync)(r.filepath)) {
378
+ if (r.filepath && (await (0, utils_js_1.checkFileExists)(r.filepath))) {
330
379
  item.localVideo = r.filepath;
331
- item.downloadedSize = (0, node_fs_1.statSync)(r.filepath).size;
380
+ item.downloadedSize = (await node_fs_1.promises.stat(r.filepath)).size;
332
381
  }
333
382
  else if (!r.errmsg && opts.convert !== false)
334
383
  r.errmsg = '下载失败';
335
384
  item.endTime = Date.now();
336
385
  item.errmsg = r.errmsg;
337
- item.status = r.errmsg ? 'error' : 'done';
386
+ item.status = r.isExist ? 'done' : r.errmsg ? 'error' : 'done';
338
387
  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));
339
388
  this.dlCache.set(url, item);
340
389
  this.wsSend('progress', url);
@@ -343,8 +392,8 @@ class DLServer {
343
392
  };
344
393
  try {
345
394
  if (dlOptions.type === 'parser') {
346
- const vp = new index_js_1.VideoParser();
347
- vp.download(url, opts).then(r => afterDownload(r, url));
395
+ console.log('\n\nDownloading with VideoParser\n\n', dlOptions, url);
396
+ index_js_1.VideoParser.download(url, opts).then(r => afterDownload(r, url));
348
397
  }
349
398
  else if (dlOptions.type === 'file') {
350
399
  (0, file_download_js_1.fileDownload)(url, opts).then(r => afterDownload(r, url));
@@ -372,6 +421,11 @@ class DLServer {
372
421
  if (queryLang && i18n_js_1.LANG_CODES.has(queryLang)) {
373
422
  return queryLang;
374
423
  }
424
+ // Try to get lang from body
425
+ const bodyLang = req.body?.lang;
426
+ if (bodyLang && i18n_js_1.LANG_CODES.has(bodyLang)) {
427
+ return bodyLang;
428
+ }
375
429
  // Try to get lang from Accept-Language header
376
430
  const acceptLanguage = req.headers['accept-language'];
377
431
  if (acceptLanguage) {
@@ -380,11 +434,6 @@ class DLServer {
380
434
  return langCode;
381
435
  }
382
436
  }
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
437
  // Fallback to default
389
438
  return (0, i18n_js_1.getLang)();
390
439
  }
@@ -471,19 +520,32 @@ class DLServer {
471
520
  // });
472
521
  // API to start m3u8 download
473
522
  app.post('/api/download', (req, res) => {
474
- const { url, options = {}, list = [] } = req.body;
523
+ const { list = [] } = req.body;
475
524
  const lang = this.getLangFromRequest(req);
476
525
  try {
477
- if (list.length) {
478
- for (const item of list) {
479
- const { url, ...options } = item;
480
- if (url)
481
- this.startDownload(url, options);
526
+ let duplicateCount = 0;
527
+ let startedCount = 0;
528
+ // 检查并统计重复的 URL,但仍允许下载
529
+ for (const item of list) {
530
+ const { url, ...options } = item;
531
+ if (url) {
532
+ if (this.dlCache.has(url)) {
533
+ duplicateCount++;
534
+ }
535
+ else {
536
+ startedCount++;
537
+ }
538
+ this.startDownload(url, options);
482
539
  }
483
540
  }
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 });
541
+ let message = '';
542
+ if (duplicateCount > 0) {
543
+ message = `${(0, i18n_js_1.t)('api.success.downloadStarted', lang, { count: list.length })},${(0, i18n_js_1.t)('api.error.duplicateDownload', lang, { count: duplicateCount })}`;
544
+ }
545
+ else {
546
+ message = (0, i18n_js_1.t)('api.success.downloadStarted', lang, { count: list.length });
547
+ }
548
+ res.json({ message, code: 0, duplicateCount, startedCount });
487
549
  this.wsSend('tasks');
488
550
  }
489
551
  catch (error) {
@@ -536,31 +598,84 @@ class DLServer {
536
598
  });
537
599
  });
538
600
  // API to delete download
539
- app.post('/api/delete', (req, res) => {
601
+ app.post('/api/delete', async (req, res) => {
540
602
  const { urls, deleteCache = false, deleteVideo = false } = req.body;
541
603
  const urlsToDelete = urls;
542
604
  const list = [];
605
+ const errors = [];
543
606
  for (const url of urlsToDelete) {
544
607
  const item = this.dlCache.get(url);
545
608
  if (item) {
609
+ utils_js_1.logger.info('delete download task:', (0, console_log_colors_1.gray)(url), (0, console_log_colors_1.cyan)(item.status), (0, console_log_colors_1.cyan)(item.localVideo), deleteCache, deleteVideo);
546
610
  (0, m3u8_download_js_1.m3u8DLStop)(url, item.workPoll);
547
611
  this.dlCache.delete(url);
548
612
  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 });
553
- utils_js_1.logger.debug('删除缓存目录:', cacheDir);
613
+ if (deleteCache) {
614
+ try {
615
+ const cacheDir = item.cacheDir;
616
+ if (cacheDir && (await (0, utils_js_1.checkFileExists)(cacheDir))) {
617
+ await node_fs_1.promises.rm(cacheDir, { recursive: true, force: true });
618
+ utils_js_1.logger.info('删除缓存目录:', (0, console_log_colors_1.gray)(cacheDir));
619
+ }
620
+ }
621
+ catch (error) {
622
+ const errorMsg = `删除缓存目录失败: ${error.message}`;
623
+ utils_js_1.logger.error(errorMsg, (0, console_log_colors_1.gray)(item.cacheDir));
624
+ errors.push(errorMsg);
554
625
  }
555
626
  }
556
627
  if (deleteVideo) {
557
- ['.ts', '.mp4'].forEach(ext => {
558
- 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);
561
- utils_js_1.logger.debug('删除文件:', filepath);
628
+ try {
629
+ // 优先使用 item.localVideo(实际文件路径)
630
+ if (item.localVideo) {
631
+ const filepath = item.localVideo;
632
+ if (await (0, utils_js_1.checkFileExists)(filepath)) {
633
+ try {
634
+ await node_fs_1.promises.rm(filepath, { recursive: true, force: true });
635
+ utils_js_1.logger.info('删除文件:', (0, console_log_colors_1.gray)(filepath));
636
+ }
637
+ catch (error) {
638
+ const errorMsg = `删除文件失败: ${filepath}, 错误: ${error.message}`;
639
+ utils_js_1.logger.error(errorMsg);
640
+ errors.push(errorMsg);
641
+ // 如果直接删除失败,可能是文件被占用
642
+ }
643
+ }
562
644
  }
563
- });
645
+ else {
646
+ // 如果 localVideo 不存在,尝试使用 dlOptions 构建路径(格式化后的参数更准确)
647
+ const saveDir = item.dlOptions?.saveDir || item.options?.saveDir;
648
+ const filename = item.dlOptions?.filename || item.options?.filename;
649
+ if (saveDir && filename) {
650
+ // 尝试多种可能的扩展名
651
+ for (const ext of ['', '.ts', '.mp4']) {
652
+ const filepath = (0, node_path_1.resolve)(saveDir, filename + ext);
653
+ if (await (0, utils_js_1.checkFileExists)(filepath)) {
654
+ try {
655
+ await node_fs_1.promises.rm(filepath, { recursive: true, force: true });
656
+ utils_js_1.logger.info('删除文件:', (0, console_log_colors_1.gray)(filepath));
657
+ break; // 找到并删除后退出循环
658
+ }
659
+ catch (error) {
660
+ const errorMsg = `删除文件失败: ${filepath}, 错误: ${error.message}`;
661
+ utils_js_1.logger.error(errorMsg);
662
+ errors.push(errorMsg);
663
+ }
664
+ }
665
+ }
666
+ }
667
+ else {
668
+ const errorMsg = `无法确定文件路径: saveDir=${saveDir}, filename=${filename}`;
669
+ utils_js_1.logger.warn(errorMsg, (0, console_log_colors_1.gray)(url));
670
+ errors.push(errorMsg);
671
+ }
672
+ }
673
+ }
674
+ catch (error) {
675
+ const errorMsg = `删除视频文件时发生错误: ${error.message}`;
676
+ utils_js_1.logger.error(errorMsg, (0, console_log_colors_1.gray)(url));
677
+ errors.push(errorMsg);
678
+ }
564
679
  }
565
680
  }
566
681
  }
@@ -570,22 +685,78 @@ class DLServer {
570
685
  this.startNextPending();
571
686
  }
572
687
  const lang = this.getLangFromRequest(req);
573
- res.json({ message: (0, i18n_js_1.t)('api.success.deleted', lang, { count: list.length }), code: 0, count: list.length });
688
+ const message = errors.length
689
+ ? `${(0, i18n_js_1.t)('api.success.deleted', lang, { count: list.length })},但有 ${errors.length} 个错误: ${errors.join('; ')}`
690
+ : (0, i18n_js_1.t)('api.success.deleted', lang, { count: list.length });
691
+ res.json({ message, code: errors.length > 0 ? 1 : 0, count: list.length, errors: errors.length > 0 ? errors : undefined });
692
+ });
693
+ // API to rename download file
694
+ app.post('/api/rename', async (req, res) => {
695
+ const { url, newFilename } = req.body;
696
+ const lang = this.getLangFromRequest(req);
697
+ if (!url || !newFilename) {
698
+ res.json({ code: 1001, message: (0, i18n_js_1.t)('api.error.invalidParams', lang) });
699
+ return;
700
+ }
701
+ // 检查新文件名是否包含非法字符
702
+ const invalidChars = /[<>:"/\\|?*]/;
703
+ if (invalidChars.test(newFilename)) {
704
+ res.json({ code: 1004, message: (0, i18n_js_1.t)('api.error.invalidFilename', lang) });
705
+ return;
706
+ }
707
+ const item = this.dlCache.get(url);
708
+ if (!item) {
709
+ res.json({ code: 1002, message: (0, i18n_js_1.t)('api.error.taskNotFound', lang) });
710
+ return;
711
+ }
712
+ await this.checkItemStatus(item);
713
+ // 只允许重命名已完成且状态正常的任务
714
+ if (item.status !== 'done' || item.errmsg) {
715
+ utils_js_1.logger.error('rename failed:', item.status, (0, console_log_colors_1.red)(item.errmsg), (0, console_log_colors_1.gray)(url));
716
+ res.json({ code: 1003, message: (0, i18n_js_1.t)('api.error.onlyRenameCompleted', lang) });
717
+ return;
718
+ }
719
+ try {
720
+ const oldPath = item.localVideo;
721
+ const oldDir = (0, node_path_1.dirname)(oldPath);
722
+ const oldExt = oldPath.split('.').pop() || '';
723
+ const newFilenameBase = newFilename.replace(/\.[^.]+$/, '');
724
+ const newPath = (0, node_path_1.resolve)(oldDir, `${newFilenameBase}${oldExt ? `.${oldExt}` : ''}`);
725
+ // 检查新文件名是否已存在
726
+ if (await (0, utils_js_1.checkFileExists)(newPath)) {
727
+ res.json({ code: 1007, message: (0, i18n_js_1.t)('api.error.fileExists', lang) });
728
+ return;
729
+ }
730
+ // 重命名文件
731
+ await node_fs_1.promises.rename(oldPath, newPath);
732
+ utils_js_1.logger.debug('重命名文件:', (0, console_log_colors_1.gray)(oldPath), '->', (0, console_log_colors_1.cyan)(newPath));
733
+ // 更新任务信息
734
+ item.localVideo = newPath;
735
+ item.options.filename = item.filename = (0, node_path_1.basename)(newPath);
736
+ this.dlCache.set(url, item);
737
+ this.saveCache();
738
+ this.wsSend('progress', url);
739
+ res.json({ message: (0, i18n_js_1.t)('api.success.renamed', lang), code: 0 });
740
+ }
741
+ catch (error) {
742
+ utils_js_1.logger.error('重命名失败:', error);
743
+ res.json({ code: 1006, message: (0, i18n_js_1.t)('api.error.renameFailed', lang, { error: error.message }) });
744
+ }
574
745
  });
575
- app.get(/^\/localplay\/(.*)$/, (req, res) => {
746
+ app.get(/^\/localplay\/(.*)$/, async (req, res) => {
576
747
  let filepath = decodeURIComponent(req.params[0]);
577
748
  if (filepath) {
578
749
  let ext = filepath.split('.').pop();
579
750
  if (!ext) {
580
751
  ext = 'm3u8';
581
- if (!(0, node_fs_1.existsSync)(filepath))
752
+ if (!(await (0, utils_js_1.checkFileExists)(filepath)))
582
753
  filepath += '.m3u8';
583
754
  }
584
755
  const allowedDirs = [this.options.cacheDir, this.cfg.dlOptions.saveDir];
585
- if (!(0, node_fs_1.existsSync)(filepath)) {
756
+ if (!(await (0, utils_js_1.checkFileExists)(filepath))) {
586
757
  for (const dir of allowedDirs) {
587
758
  const tpath = (0, node_path_1.resolve)(dir, filepath);
588
- if ((0, node_fs_1.existsSync)(tpath)) {
759
+ if (await (0, utils_js_1.checkFileExists)(tpath)) {
589
760
  filepath = tpath;
590
761
  break;
591
762
  }
@@ -601,8 +772,8 @@ class DLServer {
601
772
  return;
602
773
  }
603
774
  }
604
- if ((0, node_fs_1.existsSync)(filepath)) {
605
- const stats = (0, node_fs_1.statSync)(filepath);
775
+ if (await (0, utils_js_1.checkFileExists)(filepath)) {
776
+ const stats = await node_fs_1.promises.stat(filepath);
606
777
  const headers = new Headers({
607
778
  'Last-Modified': stats.mtime.toUTCString(),
608
779
  'Access-Control-Allow-Headers': '*',
@@ -614,7 +785,8 @@ class DLServer {
614
785
  });
615
786
  res.setHeaders(headers);
616
787
  if (ext === 'm3u8' || ('ts' === ext && stats.size < 1024 * 1024 * 3)) {
617
- res.send((0, node_fs_1.readFileSync)(filepath));
788
+ const data = await node_fs_1.promises.readFile(filepath);
789
+ res.send(data);
618
790
  utils_js_1.logger.debug('[Localplay]file sent:', (0, console_log_colors_1.gray)(filepath), 'Size:', stats.size, 'bytes');
619
791
  }
620
792
  else {
@@ -144,6 +144,12 @@ export interface M3u8DLOptions {
144
144
  ffmpegPath?: string;
145
145
  /** 语言。可选值:zh-CN, en */
146
146
  lang?: 'zh-CN' | 'en';
147
+ /** 代理模式。可选值:custom, system, disabled */
148
+ proxyMode?: 'custom' | 'system' | 'disabled';
149
+ /** 代理地址。如果 proxyMode 为 'custom',则必须指定 */
150
+ proxyUrl?: string;
151
+ /** 不使用代理的域名。多个域名用逗号分隔 */
152
+ noProxy?: string;
147
153
  }
148
154
  export interface M3u8DLResult extends Partial<DownloadResult> {
149
155
  /** 下载进度统计 */
@@ -4,8 +4,8 @@ export declare class VideoParser {
4
4
  /**
5
5
  * 解析视频 URL
6
6
  */
7
- parse(url: string, headers?: Record<string, string>): Promise<ApiResponse<VideoInfo>>;
8
- download(url: string, options: M3u8DLOptions): Promise<M3u8DLResult>;
7
+ static parse(url: string, headers?: Record<string, string>): Promise<ApiResponse<VideoInfo>>;
8
+ static download(url: string, options: M3u8DLOptions): Promise<M3u8DLResult>;
9
9
  /**
10
10
  * 根据 URL 获取平台标识
11
11
  */
@@ -16,5 +16,5 @@ export declare class VideoParser {
16
16
  /**
17
17
  * 获取所有支持的平台列表
18
18
  */
19
- getSupportedPlatforms(): string[];
19
+ static getSupportedPlatforms(): string[];
20
20
  }
@@ -31,15 +31,15 @@ class VideoParser {
31
31
  /**
32
32
  * 解析视频 URL
33
33
  */
34
- async parse(url, headers = {}) {
34
+ static async parse(url, headers = {}) {
35
35
  const info = VideoParser.getPlatform(url);
36
36
  if (!info)
37
37
  return { code: 201, message: '不支持的视频平台' };
38
38
  const parserClass = VideoParser.platforms[info.platform].class;
39
39
  return await parserClass.parse(info.url, headers);
40
40
  }
41
- async download(url, options) {
42
- const info = await this.parse(url);
41
+ static async download(url, options) {
42
+ const info = await VideoParser.parse(url);
43
43
  utils_1.logger.debug('解析视频信息', info);
44
44
  if (info.code || !info.data?.url)
45
45
  return { errmsg: info.message || '解析视频信息失败', options };
@@ -54,7 +54,7 @@ class VideoParser {
54
54
  referer: info.data.referer || info.data.url,
55
55
  ...(0, utils_1.formatHeaders)(options.headers),
56
56
  };
57
- return (0, file_download_1.fileDownload)(url, options);
57
+ return (0, file_download_1.fileDownload)(info.data.url, options);
58
58
  }
59
59
  /**
60
60
  * 根据 URL 获取平台标识
@@ -79,7 +79,7 @@ class VideoParser {
79
79
  /**
80
80
  * 获取所有支持的平台列表
81
81
  */
82
- getSupportedPlatforms() {
82
+ static getSupportedPlatforms() {
83
83
  return Object.keys(VideoParser.platforms);
84
84
  }
85
85
  }