@lzwme/m3u8-dl 1.3.0 → 1.4.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.
package/cjs/cli.js CHANGED
@@ -89,7 +89,9 @@ commander_1.program
89
89
  const opts = getOptions();
90
90
  if (opts.debug)
91
91
  options.debug = true;
92
- console.log(opts, options);
92
+ if (opts.cacheDir)
93
+ options.cacheDir = opts.cacheDir;
94
+ utils_js_1.logger.debug('[cli][server]', opts, options);
93
95
  Promise.resolve().then(() => __importStar(require('./server/download-server.js'))).then(m => {
94
96
  new m.DLServer(options);
95
97
  });
package/cjs/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './lib/m3u8-download';
2
2
  export * from './lib/file-download';
3
+ export * from './lib/getM3u8Urls';
3
4
  export * from './lib/parseM3u8';
4
5
  export * from './video-parser';
package/cjs/index.js CHANGED
@@ -16,5 +16,6 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./lib/m3u8-download"), exports);
18
18
  __exportStar(require("./lib/file-download"), exports);
19
+ __exportStar(require("./lib/getM3u8Urls"), exports);
19
20
  __exportStar(require("./lib/parseM3u8"), exports);
20
21
  __exportStar(require("./video-parser"), exports);
@@ -8,7 +8,7 @@ const format_options_js_1 = require("./format-options.js");
8
8
  const utils_js_1 = require("./utils.js");
9
9
  async function fileDownload(u, opts) {
10
10
  utils_js_1.logger.debug('fileDownload', u, opts);
11
- const [url, options] = (0, format_options_js_1.formatOptions)(u, opts);
11
+ const { url, options } = (0, format_options_js_1.formatOptions)(u, opts);
12
12
  const startTime = Date.now();
13
13
  const stats = {
14
14
  url,
@@ -1,2 +1,6 @@
1
1
  import type { M3u8DLOptions } from '../types';
2
- export declare function formatOptions(url: string, opts: M3u8DLOptions): readonly [string, M3u8DLOptions, string];
2
+ export declare function formatOptions(url: string, opts: M3u8DLOptions): {
3
+ url: string;
4
+ options: M3u8DLOptions;
5
+ urlMd5: string;
6
+ };
@@ -76,5 +76,5 @@ function formatOptions(url, opts) {
76
76
  utils_1.logger.updateOptions({ levelType: 'debug' });
77
77
  utils_1.logger.debug('[m3u8-DL]options', options, url);
78
78
  }
79
- return [url, options, urlMd5];
79
+ return { url, options, urlMd5 };
80
80
  }
@@ -0,0 +1,2 @@
1
+ /** 从指定的 url 页面中提取 m3u8 播放地址。deep 指定搜索页面深度 */
2
+ export declare function getM3u8Urls(url: string, deep?: number, visited?: Set<string>): Promise<Map<string, string>>;
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getM3u8Urls = getM3u8Urls;
4
+ const fe_utils_1 = require("@lzwme/fe-utils");
5
+ const utils_js_1 = require("./utils.js");
6
+ /** 从指定的 url 页面中提取 m3u8 播放地址。deep 指定搜索页面深度 */
7
+ async function getM3u8Urls(url, deep = 2, visited = new Set()) {
8
+ const req = new fe_utils_1.ReqFetch({ headers: { 'content-type': 'text/html; charset=UTF-8' }, reqOptions: {} });
9
+ const { data: html } = await req.get(url);
10
+ // 从 html 中正则匹配提取 m3u8
11
+ const m3u8Urls = new Map();
12
+ const m3u8Regex = /https?:[^\s'"]+\.m3u8(\?[^\s'"]*)?/gi;
13
+ // 1. 直接正则匹配 m3u8 地址
14
+ let match = m3u8Regex.exec(html);
15
+ while (match) {
16
+ const title = (/<title>([^<]+)</.exec(html)?.[1].split('-')[0] || '').replace(/在线播放|《|》/g, '');
17
+ const href = match[0].replaceAll('\\/', '/');
18
+ if (!m3u8Urls.has(href))
19
+ m3u8Urls.set(href, title);
20
+ match = m3u8Regex.exec(html);
21
+ }
22
+ // 2. 若未找到且深度大于 0,则获取所有 a 标签的 href 并递归查找
23
+ if (m3u8Urls.size === 0 && deep > 0) {
24
+ utils_js_1.logger.debug('未获取到 m3u8 地址', fe_utils_1.color.gray(url), html.length);
25
+ visited.add(url);
26
+ const aTagRegex = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi;
27
+ let aMatch = aTagRegex.exec(html);
28
+ const origin = new URL(url).origin;
29
+ while (aMatch) {
30
+ const href = aMatch[1] ? new URL(aMatch[1], origin).toString() : '';
31
+ const text = aMatch[2];
32
+ aMatch = aTagRegex.exec(html);
33
+ if (!href || visited.has(href) || !href.startsWith(origin) || !/集|HD|高清|播放/.test(text))
34
+ continue;
35
+ visited.add(href);
36
+ try {
37
+ const subUrls = await getM3u8Urls(href, deep - 1, visited);
38
+ utils_js_1.logger.debug(' > 从子页面提取: ', fe_utils_1.color.gray(href), text, subUrls.size);
39
+ for (const [u, t] of subUrls)
40
+ m3u8Urls.set(u, t || text);
41
+ }
42
+ catch (err) {
43
+ utils_js_1.logger.warn(' > 尝试访问子页面异常: ', fe_utils_1.color.red(href), err.message);
44
+ }
45
+ }
46
+ }
47
+ return m3u8Urls;
48
+ }
49
+ // logger.updateOptions({ levelType: 'debug' });
50
+ // getM3u8Urls(process.argv.slice(2)[0]).then(d => console.log(d));
@@ -86,9 +86,8 @@ const cache = {
86
86
  };
87
87
  const tsDlFile = (0, node_path_1.resolve)(__dirname, './ts-download.js');
88
88
  exports.workPollPublic = new worker_pool_js_1.WorkerPool(tsDlFile);
89
- async function m3u8InfoParse(url, options = {}) {
90
- let urlMd5 = '';
91
- [url, options, urlMd5] = (0, format_options_js_1.formatOptions)(url, options);
89
+ async function m3u8InfoParse(u, o = {}) {
90
+ const { url, options, urlMd5 } = (0, format_options_js_1.formatOptions)(u, o);
92
91
  const ext = (0, utils_js_1.isSupportFfmpeg)() ? '.mp4' : '.ts';
93
92
  /** 最终合并转换后的文件路径 */
94
93
  let filepath = (0, node_path_1.resolve)(options.saveDir, options.filename);
@@ -194,16 +193,12 @@ async function m3u8Download(url, options = {}) {
194
193
  return result;
195
194
  }
196
195
  if (result.m3u8Info?.tsCount > 0) {
197
- const workPoll = new worker_pool_js_1.WorkerPool(tsDlFile);
198
- let n = options.threadNum - workPoll.numThreads;
199
- if (n > 0)
200
- while (n--)
201
- workPoll.addNewWorker();
196
+ const workPoll = new worker_pool_js_1.WorkerPool(tsDlFile, options.threadNum);
202
197
  const { m3u8Info } = result;
203
198
  const startTime = Date.now();
204
199
  const barrier = new fe_utils_1.Barrier();
205
200
  /** 本地开始播放最少需要下载的 ts 文件数量 */
206
- const playStart = Math.min(options.threadNum + 2, result.m3u8Info.tsCount);
201
+ const playStart = Math.min(options.threadNum + 2, m3u8Info.tsCount);
207
202
  const stats = {
208
203
  url,
209
204
  startTime,
@@ -301,6 +296,7 @@ async function m3u8Download(url, options = {}) {
301
296
  options.onInited(stats, m3u8Info, workPoll);
302
297
  runTask(m3u8Info.data);
303
298
  await barrier.wait();
299
+ workPoll.close();
304
300
  if (stats.tsFailed > 0) {
305
301
  utils_js_1.logger.warn('Download Failed! Please retry!', stats.tsFailed);
306
302
  }
@@ -319,5 +315,9 @@ async function m3u8Download(url, options = {}) {
319
315
  function m3u8DLStop(url, wp = exports.workPollPublic) {
320
316
  if (!wp?.removeTask)
321
317
  return 0;
322
- return wp.removeTask(task => task.url === url);
318
+ const count = wp.removeTask(task => task.url === url);
319
+ // 进行中的任务,最多允许继续下载 10s
320
+ if (count === 0 && wp !== exports.workPollPublic)
321
+ setTimeout(() => wp.close(), 10_000);
322
+ return count;
323
323
  }
@@ -87,6 +87,10 @@ class WorkerPool extends node_events_1.EventEmitter {
87
87
  this.numThreads = this.workers.length;
88
88
  }
89
89
  runTask(task, callback) {
90
+ if (this.totalNum === 0) {
91
+ console.error('未初始化 worker 或已销毁');
92
+ return;
93
+ }
90
94
  if (this.freeWorkers.length === 0) {
91
95
  this.tasks.push({ task, callback });
92
96
  return;
@@ -38,8 +38,8 @@ async function formatUrls(urls, options) {
38
38
  continue;
39
39
  }
40
40
  }
41
- const [u, o] = (0, format_options_1.formatOptions)(url, options);
42
- taskset.set(u, o);
41
+ const r = (0, format_options_1.formatOptions)(url, options);
42
+ taskset.set(r.url, r.options);
43
43
  }
44
44
  return taskset;
45
45
  }
@@ -8,6 +8,8 @@ interface DLServerOptions {
8
8
  debug?: boolean;
9
9
  /** 登录 token,默认取环境变量 DS_SECRET */
10
10
  token?: string;
11
+ /** 是否限制文件访问(localplay视频资源等仅可读取下载和缓存目录) */
12
+ limitFileAccess?: boolean;
11
13
  }
12
14
  interface CacheItem extends Partial<M3u8DLProgressStats> {
13
15
  url: string;
@@ -36,11 +36,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.DLServer = void 0;
37
37
  const node_fs_1 = require("node:fs");
38
38
  const node_path_1 = require("node:path");
39
+ const node_os_1 = require("node:os");
39
40
  const fe_utils_1 = require("@lzwme/fe-utils");
40
41
  const console_log_colors_1 = require("console-log-colors");
41
42
  const file_download_js_1 = require("../lib/file-download.js");
42
43
  const format_options_js_1 = require("../lib/format-options.js");
43
44
  const m3u8_download_js_1 = require("../lib/m3u8-download.js");
45
+ const getM3u8Urls_js_1 = require("../lib/getM3u8Urls.js");
44
46
  const utils_js_1 = require("../lib/utils.js");
45
47
  const index_js_1 = require("../video-parser/index.js");
46
48
  const rootDir = (0, node_path_1.resolve)(__dirname, '../..');
@@ -50,9 +52,10 @@ class DLServer {
50
52
  /** DS 参数 */
51
53
  options = {
52
54
  port: Number(process.env.DS_PORT) || 6600,
53
- cacheDir: (0, node_path_1.resolve)(process.cwd(), './cache'),
55
+ cacheDir: process.env.DS_CACHE_DIR || (0, node_path_1.resolve)((0, node_os_1.homedir)(), '.m3u8-dl/cache'),
54
56
  token: process.env.DS_SECRET || process.env.DS_TOKEN || '',
55
57
  debug: process.env.DS_DEBUG === '1',
58
+ limitFileAccess: !['0', 'false'].includes(process.env.DS_LIMTE_FILE_ACCESS),
56
59
  };
57
60
  serverInfo = {
58
61
  version: '',
@@ -102,7 +105,7 @@ class DLServer {
102
105
  this.loadCache();
103
106
  await this.createApp();
104
107
  this.initRouters();
105
- utils_js_1.logger.debug('Server initialized', this.options, this.cfg.dlOptions);
108
+ utils_js_1.logger.debug('Server initialized', 'cacheSize:', this.dlCache.size, this.options, this.cfg.dlOptions);
106
109
  }
107
110
  loadCache() {
108
111
  const cacheFile = (0, node_path_1.resolve)(this.options.cacheDir, 'cache.json');
@@ -126,7 +129,7 @@ class DLServer {
126
129
  return;
127
130
  }
128
131
  this.dlCache.forEach(item => {
129
- if (item.status === 'done' && item.localVideo && !(0, node_fs_1.existsSync)(item.localVideo)) {
132
+ if (item.status === 'done' && (!item.localVideo || !(0, node_fs_1.existsSync)(item.localVideo))) {
130
133
  item.status = 'error';
131
134
  item.errmsg = '已删除';
132
135
  }
@@ -195,6 +198,7 @@ class DLServer {
195
198
  if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(rootDir, 'client/local/cdn'))) {
196
199
  indexHtml = indexHtml
197
200
  .replaceAll('https://s4.zstatic.net/ajax/libs', 'local/cdn')
201
+ .replaceAll(/integrity=.+\n/g, '')
198
202
  .replace('https://cdn.tailwindcss.com/3.4.16', 'local/cdn/tailwindcss/3.4.16/tailwindcss.min.js');
199
203
  }
200
204
  res.setHeader('content-type', 'text/html').send(indexHtml);
@@ -246,18 +250,18 @@ class DLServer {
246
250
  return { app, wss };
247
251
  }
248
252
  startDownload(url, options) {
249
- const dlOptions = (0, format_options_js_1.formatOptions)(url, { ...this.cfg.dlOptions, ...options, cacheDir: this.options.cacheDir })[1];
253
+ const { options: dlOptions } = (0, format_options_js_1.formatOptions)(url, { ...this.cfg.dlOptions, ...options, cacheDir: this.options.cacheDir });
250
254
  const cacheItem = this.dlCache.get(url) || { options, dlOptions, status: 'pending', url };
251
- utils_js_1.logger.debug('startDownload', url, dlOptions, cacheItem?.status);
255
+ utils_js_1.logger.debug('startDownload', url, dlOptions, cacheItem.status);
252
256
  if (cacheItem.status === 'resume')
253
257
  return cacheItem.options;
254
- if (this.downloading >= this.cfg.webOptions.maxDownloads) {
255
- cacheItem.status = 'pending';
256
- this.dlCache.set(url, cacheItem);
257
- return cacheItem.options;
258
- }
259
- cacheItem.status = 'resume';
258
+ if (cacheItem.localVideo && !(0, node_fs_1.existsSync)(cacheItem.localVideo))
259
+ delete cacheItem.localVideo;
260
+ cacheItem.status = this.downloading >= this.cfg.webOptions.maxDownloads ? 'pending' : 'resume';
260
261
  this.dlCache.set(url, cacheItem);
262
+ this.wsSend('progress', url);
263
+ if (cacheItem.status === 'pending')
264
+ return cacheItem.options;
261
265
  let workPoll = cacheItem.workPoll;
262
266
  const opts = {
263
267
  ...dlOptions,
@@ -291,12 +295,10 @@ class DLServer {
291
295
  this.wsSend('progress', url);
292
296
  this.saveCache();
293
297
  // 找到一个 pending 的任务,开始下载
294
- for (const [url, item] of this.dlCache.entries()) {
295
- if (item.status === 'pending') {
296
- this.startDownload(url, item.options);
297
- this.wsSend('progress', url);
298
- break;
299
- }
298
+ const nextItem = this.dlCache.entries().find(([_url, d]) => d.status === 'pending');
299
+ if (nextItem) {
300
+ this.startDownload(nextItem[0], nextItem[1].options);
301
+ this.wsSend('progress', nextItem[0]);
300
302
  }
301
303
  };
302
304
  try {
@@ -323,12 +325,10 @@ class DLServer {
323
325
  }
324
326
  else if (type === 'progress' && typeof data === 'string') {
325
327
  const item = this.dlCache.get(data);
326
- if (item) {
327
- const { workPoll, ...stats } = item;
328
- data = [{ ...stats, url: data }];
329
- }
330
- else
328
+ if (!item)
331
329
  return;
330
+ const { workPoll, ...stats } = item;
331
+ data = [{ ...stats, url: data }];
332
332
  }
333
333
  // 广播进度信息给所有客户端
334
334
  this.wss.clients.forEach(client => {
@@ -338,24 +338,23 @@ class DLServer {
338
338
  }
339
339
  initRouters() {
340
340
  const { app } = this;
341
- // health check
342
341
  app.get('/healthcheck', (_req, res) => {
343
342
  res.json({ message: 'ok', code: 0 });
344
343
  });
345
- app.post('/config', (req, res) => {
344
+ app.post('/api/config', (req, res) => {
346
345
  const config = req.body;
347
346
  this.saveConfig(config);
348
347
  res.json({ message: 'Config updated successfully', code: 0 });
349
348
  });
350
- app.get('/config', (_req, res) => {
349
+ app.get('/api/config', (_req, res) => {
351
350
  res.json({ ...this.cfg.dlOptions, ...this.cfg.webOptions });
352
351
  });
353
352
  // API to get all download progress
354
- app.get('/tasks', (_req, res) => {
353
+ app.get('/api/tasks', (_req, res) => {
355
354
  res.json(Object.fromEntries(this.dlCacheClone()));
356
355
  });
357
356
  // API to get queue status
358
- app.get('/queue/status', (_req, res) => {
357
+ app.get('/api/queue/status', (_req, res) => {
359
358
  const pendingTasks = Array.from(this.dlCache.entries()).filter(([_, item]) => item.status === 'pending');
360
359
  const activeTasks = Array.from(this.dlCache.entries()).filter(([_, item]) => item.status === 'resume');
361
360
  res.json({
@@ -365,7 +364,7 @@ class DLServer {
365
364
  });
366
365
  });
367
366
  // API to clear queue
368
- app.post('/queue/clear', (_req, res) => {
367
+ app.post('/api/queue/clear', (_req, res) => {
369
368
  let count = 0;
370
369
  for (const [url, item] of this.dlCache.entries()) {
371
370
  if (item.status === 'pending') {
@@ -378,7 +377,7 @@ class DLServer {
378
377
  res.json({ message: `已清空 ${count} 个等待中的下载任务`, code: 0 });
379
378
  });
380
379
  // API to update task priority
381
- app.post('/priority', (req, res) => {
380
+ app.post('/api/priority', (req, res) => {
382
381
  const { url, priority } = req.body;
383
382
  const item = this.dlCache.get(url);
384
383
  if (!item) {
@@ -390,7 +389,7 @@ class DLServer {
390
389
  res.json({ message: '已更新任务优先级', code: 0 });
391
390
  });
392
391
  // API to start m3u8 download
393
- app.post('/download', (req, res) => {
392
+ app.post('/api/download', (req, res) => {
394
393
  const { url, options = {}, list = [] } = req.body;
395
394
  try {
396
395
  if (list.length) {
@@ -410,7 +409,7 @@ class DLServer {
410
409
  }
411
410
  });
412
411
  // API to pause download
413
- app.post('/pause', (req, res) => {
412
+ app.post('/api/pause', (req, res) => {
414
413
  const { urls, all = false } = req.body;
415
414
  const urlsToPause = all ? [...this.dlCache.keys()] : urls;
416
415
  const list = [];
@@ -418,7 +417,7 @@ class DLServer {
418
417
  const item = this.dlCache.get(url);
419
418
  if (['resume', 'pending'].includes(item?.status)) {
420
419
  (0, m3u8_download_js_1.m3u8DLStop)(url, item.workPoll);
421
- item.status = item.tsSuccess === item.tsCount ? 'done' : 'pause';
420
+ item.status = item.tsSuccess > 0 && item.tsSuccess === item.tsCount ? 'done' : 'pause';
422
421
  const { workPoll, ...tItem } = item;
423
422
  list.push(tItem);
424
423
  }
@@ -428,7 +427,7 @@ class DLServer {
428
427
  res.json({ message: `已暂停 ${list.length} 个下载任务`, code: 0, count: list.length });
429
428
  });
430
429
  // API to resume download
431
- app.post('/resume', (req, res) => {
430
+ app.post('/api/resume', (req, res) => {
432
431
  const { urls, all = false } = req.body;
433
432
  const urlsToResume = all ? [...this.dlCache.keys()] : urls;
434
433
  const list = [];
@@ -447,7 +446,7 @@ class DLServer {
447
446
  res.json({ message: list.length ? `已恢复 ${list.length} 个下载任务` : '没有找到可恢复的下载任务', code: 0, count: list.length });
448
447
  });
449
448
  // API to delete download
450
- app.post('/delete', (req, res) => {
449
+ app.post('/api/delete', (req, res) => {
451
450
  const { urls, deleteCache = false, deleteVideo = false } = req.body;
452
451
  const urlsToDelete = urls;
453
452
  const list = [];
@@ -459,20 +458,26 @@ class DLServer {
459
458
  list.push(item.url);
460
459
  if (deleteCache && item.current?.tsOut) {
461
460
  const cacheDir = (0, node_path_1.dirname)(item.current.tsOut);
462
- if ((0, node_fs_1.existsSync)(cacheDir))
461
+ if ((0, node_fs_1.existsSync)(cacheDir)) {
463
462
  (0, node_fs_1.rmSync)(cacheDir, { recursive: true });
463
+ utils_js_1.logger.debug('删除缓存目录:', cacheDir);
464
+ }
464
465
  }
465
466
  if (deleteVideo) {
466
467
  ['.ts', '.mp4'].forEach(ext => {
467
468
  const filepath = (0, node_path_1.resolve)(item.options.saveDir, item.options.filename + ext);
468
- if ((0, node_fs_1.existsSync)(filepath))
469
+ if ((0, node_fs_1.existsSync)(filepath)) {
469
470
  (0, node_fs_1.unlinkSync)(filepath);
471
+ utils_js_1.logger.debug('删除文件:', filepath);
472
+ }
470
473
  });
471
474
  }
472
475
  }
473
476
  }
474
- if (list.length)
477
+ if (list.length) {
475
478
  this.wsSend('delete', list);
479
+ this.saveCache();
480
+ }
476
481
  res.json({ message: `已删除 ${list.length} 个下载任务`, code: 0, count: list.length });
477
482
  });
478
483
  app.get(/^\/localplay\/(.*)$/, (req, res) => {
@@ -484,8 +489,9 @@ class DLServer {
484
489
  if (!(0, node_fs_1.existsSync)(filepath))
485
490
  filepath += '.m3u8';
486
491
  }
492
+ const allowedDirs = [this.options.cacheDir, this.cfg.dlOptions.saveDir];
487
493
  if (!(0, node_fs_1.existsSync)(filepath)) {
488
- for (const dir of [this.options.cacheDir, this.cfg.dlOptions.saveDir]) {
494
+ for (const dir of allowedDirs) {
489
495
  const tpath = (0, node_path_1.resolve)(dir, filepath);
490
496
  if ((0, node_fs_1.existsSync)(tpath)) {
491
497
  filepath = tpath;
@@ -495,7 +501,9 @@ class DLServer {
495
501
  }
496
502
  else {
497
503
  filepath = (0, node_path_1.resolve)(filepath);
498
- if ([this.options.cacheDir, this.cfg.dlOptions.saveDir].some(d => filepath.startsWith((0, node_path_1.resolve)(d)))) {
504
+ const isAllow = !this.options.limitFileAccess || allowedDirs.some(d => filepath.startsWith((0, node_path_1.resolve)(d)));
505
+ if (!isAllow) {
506
+ utils_js_1.logger.error('[Localplay] Access denied:', filepath);
499
507
  res.send({ message: 'Access denied', code: 403 });
500
508
  return;
501
509
  }
@@ -517,6 +525,17 @@ class DLServer {
517
525
  utils_js_1.logger.error('Localplay file not found:', filepath);
518
526
  res.status(404).send({ message: 'Not Found', code: 404 });
519
527
  });
528
+ app.post('/api/getM3u8Urls', (req, res) => {
529
+ const url = req.body.url;
530
+ if (!url) {
531
+ res.json({ code: 1001, message: '无效的 url 参数' });
532
+ }
533
+ else {
534
+ (0, getM3u8Urls_js_1.getM3u8Urls)(url)
535
+ .then(d => res.json({ code: 0, data: Array.from(d) }))
536
+ .catch(err => res.json({ code: 401, message: err.message }));
537
+ }
538
+ });
520
539
  }
521
540
  }
522
541
  exports.DLServer = DLServer;
package/client/index.html CHANGED
@@ -6,8 +6,8 @@
6
6
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
8
  <meta name="apple-mobile-web-app-capable" content="yes">
9
- <title>M3U8 下载管理</title>
10
- <link rel="icon" type="image/svg+xml" href="logo.svg">
9
+ <title>M3U8 下载器</title>
10
+ <link rel="icon" type="image/png" href="logo.png">
11
11
  <link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/font-awesome/6.7.2/css/all.min.css"
12
12
  integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="
13
13
  crossorigin="anonymous" referrerpolicy="no-referrer" />
@@ -36,7 +36,7 @@
36
36
  <div class="sidebar p-4" :class="{ 'show': !sidebarCollapsed }">
37
37
  <div class="mb-8">
38
38
  <div class="flex items-center mb-4">
39
- <img src="logo.svg" alt="M3U8 下载器" class="w-8 h-8 mr-2">
39
+ <img src="logo.png" alt="M3U8 下载器" class="w-8 h-8 mr-2">
40
40
  <h1 class="text-xl font-bold text-gray-800">M3U8 下载器</h1>
41
41
  </div>
42
42
  <button @click="showNewDownloadDialog"
@@ -406,6 +406,14 @@
406
406
  class="text-blue-500 hover:text-blue-600">
407
407
  {{serverInfo.version}}</a>
408
408
  </p>
409
+ <p class="text-gray-600"><strong>检测版本:</strong>
410
+ <button @click="checkNewVersion" v-if="!serverInfo.appUpdateMessage"
411
+ class="px-2 py-1 text-sm bg-green-600 hover:bg-green-700 text-white rounded">
412
+ <i class="fas fa-check mr-1"></i>检测新版本
413
+ </button>
414
+ <span v-if="serverInfo.newVersion" class="text-blue-600">发现新版本![{{serverInfo.newVersion}}]</span>
415
+ <span v-if="serverInfo.appUpdateMessage" class="text-green-600">{{serverInfo.appUpdateMessage}}</span>
416
+ </p>
409
417
  </div>
410
418
  </div>
411
419
 
@@ -513,7 +521,10 @@ services:
513
521
  alert(msg, p) {
514
522
  p = typeof msg === 'object' ? msg : Object.assign({ text: msg }, p);
515
523
  if (!p.toast) p.allowOutsideClick = false;
516
- Swal.fire(Object.assign({ icon: 'info', showConfirmButton: false, showCloseButton: true }, p));
524
+ return Swal.fire(Object.assign({ icon: 'info', showConfirmButton: false, showCloseButton: true, confirmButtonText: '确定', cancelButtonText: '关闭' }, p));
525
+ },
526
+ confirm(msg, p) {
527
+ return this.alert(msg, { showConfirmButton: true, showCancelButton: true, showCloseButton: true, confirmButtonText: '确认', cancelButtonText: '取消' });
517
528
  },
518
529
  toast(msg, p) {
519
530
  p = (typeof msg === 'object' ? msg : Object.assign({ text: msg }, p));
@@ -602,6 +613,8 @@ services:
602
613
  serverInfo: {
603
614
  version: '{{version}}',
604
615
  ariang: false,
616
+ newVersion: '',
617
+ appUpdateMessage: '',
605
618
  },
606
619
  config: {
607
620
  /** 并发下载线程数。取决于服务器限制,过多可能会容易下载失败。一般建议不超过 8 个。默认为 cpu数 * 2,但不超过 8 */
@@ -661,7 +674,7 @@ services:
661
674
 
662
675
  // 排序:resume > pending > pause > error > done
663
676
  const statusOrder = { resume: 0, pending: 1, pause: 2, error: 3, done: 4 };
664
- tasks.sort((a, b) => (statusOrder[a.status] - statusOrder[b.status]) || a.status === 'done' ? (b.filename - a.filename) : (b.endTime - a.endTime));
677
+ tasks.sort((a, b) => (statusOrder[a.status] - statusOrder[b.status]) || (a.status === 'done' ? (b.filename - a.filename) : (b.endTime - a.endTime)));
665
678
 
666
679
  // 更新 queueStatus
667
680
  const queueStatus = {
@@ -682,6 +695,38 @@ services:
682
695
  }
683
696
  },
684
697
  methods: {
698
+ initEventsForApp() {
699
+ if (!window.electron) return;
700
+ const ipc = window.electron.ipc;
701
+ ipc.on('message', (ev) => {
702
+ if (typeof ev.data === 'string') T.toast(ev.data, { icon: 'info' });
703
+ else console.log(ev.data);
704
+ });
705
+
706
+ ipc.on('downloadProgress', (data) => {
707
+ console.log('downloadProgress', data);
708
+ this.serverInfo.appUpdateMessage = `下载中:${Number(data.percent).toFixed(2)}% [${T.formatSpeed(data.bytesPerSecond)}] [${T.formatSize(data.transferred)}/${T.formatSize(data.total)}]`;
709
+ });
710
+ },
711
+ async checkNewVersion() {
712
+ try {
713
+ const r = await fetch(`https://registry.npmmirror.com/@lzwme/m3u8-dl/latest`).then(r => r.json());
714
+ if (r.version) {
715
+ if (r.version === this.serverInfo.version) T.toast(`已是最新版本,无需更新[${r.version}]`);
716
+ else {
717
+ this.serverInfo.newVersion = r.version;
718
+ if (window.electron) {
719
+ window.electron.ipc.send('checkForUpdate');
720
+ } else {
721
+ T.alert(`发现新版本[${r.version}],请前往 https://github.com/lzwme/m3u8-dl/releases 下载最新版本`, { icon: 'success' });
722
+ }
723
+ }
724
+ }
725
+ } catch (error) {
726
+ console.error('检查新版本失败:', error);
727
+ T.alert(`版本检查失败:${error.message}`, { icon: 'error' });
728
+ }
729
+ },
685
730
  forceUpdate: function () {
686
731
  const now = Date.now();
687
732
  if (now - this.forceUpdateTime > 500) {
@@ -705,7 +750,7 @@ services:
705
750
 
706
751
  switch (type) {
707
752
  case 'serverInfo':
708
- this.serverInfo = data;
753
+ Object.assign(this.serverInfo, data);
709
754
  break;
710
755
  case 'tasks':
711
756
  this.tasks = data;
@@ -745,7 +790,7 @@ services:
745
790
  },
746
791
  /** 获取配置 */
747
792
  fetchConfig: async function () {
748
- const config = await T.get('/config');
793
+ const config = await T.get('/api/config');
749
794
  if (config.code) {
750
795
  console.error('获取配置失败:', config);
751
796
  T.alert('获取配置失败: ' + config.message, { icon: 'error' });
@@ -758,7 +803,7 @@ services:
758
803
  },
759
804
  /** 更新配置 */
760
805
  updateConfig: async function () {
761
- const result = await T.post('/config', this.config);
806
+ const result = await T.post('/api/config', this.config);
762
807
  T.toast(result.message || '配置已更新', { icon: result.code ? 'error' : 'success' });
763
808
  },
764
809
  updateLocalConfig: async function () {
@@ -785,18 +830,31 @@ services:
785
830
  width: '800px',
786
831
  html: `
787
832
  <div class="text-left">
788
- <label class="block text-sm font-bold text-gray-700 mb-1">视频链接(每行一个,支持m3u8地址及抖音、微博、皮皮虾视频分享链接)</label>
789
- <textarea id="downloadUrls" class="w-full p-2 border rounded-lg focus:ring-blue-500" rows="3" placeholder="格式: URL | 名称(可选)、名称 | URL"></textarea>
833
+ <div class="flex flex-row gap-4">
834
+ <input type="text" id="playUrl" placeholder="输入视频播放页地址,尝试提取m3u8下载链接" autocomplete="off" id="urlInput" class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400" value="">
835
+ <div class="flex flex-row gap-1">
836
+ <button type="button" id="getM3u8UrlsBtn" class="player-btn px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none">
837
+ 提取
838
+ </button>
839
+ </div>
840
+ </div>
790
841
 
791
842
  <div class="mt-4">
792
- <label class="block text-sm font-bold text-gray-700 mb-1">视频名称(可选)</label>
793
- <input id="filename" class="w-full p-2 border rounded-lg focus:border-blue-500" placeholder="请输入视频名称">
794
- <p class="mt-1 text-sm text-gray-500">若输入多个链接,将依次以"视频名称+第N集"命名</p>
843
+ <label class="block text-sm font-bold text-gray-700 mb-1">视频链接(每行一个,支持m3u8地址及抖音、微博、皮皮虾视频分享链接)</label>
844
+ <textarea id="downloadUrls" class="w-full p-2 border rounded-lg focus:ring-blue-500" rows="3" placeholder="格式: URL | 名称(可选)、名称 | URL"></textarea>
795
845
  </div>
796
846
 
797
847
  <div class="mt-4">
848
+ <div class="flex flex-row gap-2 items-center">
849
+ <label class="block text-sm font-bold text-gray-700 mb-1">视频名称</label>
850
+ <input id="filename" class="flex-1 p-2 border rounded-lg focus:border-blue-500" placeholder="请输入视频名称(可选)">
851
+ </div>
852
+ <p class="ml-2 mt-1 text-sm text-gray-500">若输入多个链接,将依次以"视频名称+第N集"命名</p>
853
+ </div>
854
+
855
+ <div class="mt-4 flex flex-row gap-2 items-center">
798
856
  <label class="block text-sm font-bold text-gray-700 mb-1">保存位置</label>
799
- <input id="saveDir" class="w-full p-2 border rounded-lg focus:ring-blue-500" placeholder="请输入保存路径" value="${this.config.saveDir || ''}">
857
+ <input id="saveDir" class="flex-1 p-2 border rounded-lg focus:ring-blue-500" placeholder="请输入保存路径" value="${this.config.saveDir || ''}">
800
858
  </div>
801
859
 
802
860
  <div class="mt-4">
@@ -806,7 +864,7 @@ services:
806
864
 
807
865
  <div class="mt-4">
808
866
  <label class="block text-sm font-bold text-gray-700 mb-1">自定义请求头</label>
809
- <textarea id="headers" class="w-full p-2 border rounded-lg focus:ring-blue-500" rows="4" placeholder="每行一个请求头(微博视频必须设置 Cookie),格式:Key: Value&#10;例如:&#10;Referer: https://example.com&#10;Cookie: token=123"></textarea>
867
+ <textarea id="headers" class="w-full p-2 border rounded-lg focus:ring-blue-500" rows="3" placeholder="每行一个(微博视频须设置 Cookie),格式:Key: Value。例如:&#10;Referer: https://example.com&#10;Cookie: token=123"></textarea>
810
868
  </div>
811
869
  </div>
812
870
  `,
@@ -852,6 +910,31 @@ services:
852
910
  }).then((result) => {
853
911
  if (result.isConfirmed) this.startBatchDownload(result.value);
854
912
  });
913
+
914
+ setTimeout(() => {
915
+ const btn = document.getElementById('getM3u8UrlsBtn');
916
+ if (!btn) return;
917
+
918
+ btn.addEventListener('click', async () => {
919
+ const url = document.getElementById('playUrl').value.trim();
920
+ if (!/^https?:/.test(url)) {
921
+ return Swal.showValidationMessage('请输入正确的 URL 地址');
922
+ }
923
+
924
+ btn.setAttribute('disabled', 'disabled');
925
+ btn.innerText = '解析中...';
926
+
927
+ T.post('/api/getM3u8Urls', { url }).then(r => {
928
+ if (Array.isArray(r.data)) {
929
+ document.getElementById('downloadUrls').value = r.data.map(d => d.join(' | ')).join('\n');
930
+ T.toast(r.message || `解析完成!获取到 ${r.data.length} 个地址`);
931
+ }
932
+
933
+ btn.removeAttribute('disabled');
934
+ btn.innerText = '提取';
935
+ });
936
+ });
937
+ }, 500);
855
938
  },
856
939
  /** 批量下载 */
857
940
  startBatchDownload: async function (list) {
@@ -860,7 +943,7 @@ services:
860
943
  Object.entries(item).forEach(([key, value]) => !value && delete item[key]);
861
944
  this.tasks[item.url] = { status: 'resume', progress: 0, speed: 0, remainingTime: 0, size: 0 };
862
945
  });
863
- const r = await T.post('/download', { list });
946
+ const r = await T.post('/api/download', { list });
864
947
  if (!r.code) T.toast(r.message || '批量下载已开始');
865
948
  this.forceUpdate();
866
949
  } catch (error) {
@@ -872,7 +955,7 @@ services:
872
955
  pauseDownload: async function (urls) {
873
956
  if (!urls) urls = this.selectedTasks;
874
957
  if (typeof urls === 'string') urls = [urls];
875
- const r = await T.post(`/pause`, { urls, all: urls[0] === 'all' });
958
+ const r = await T.post('/api/pause', { urls, all: urls[0] === 'all' });
876
959
  if (!r.code) T.toast(r.message || '已暂停下载');
877
960
  if (urls === this.selectedTasks) this.selectedTasks = [];
878
961
  },
@@ -880,7 +963,7 @@ services:
880
963
  resumeDownload: async function (urls) {
881
964
  if (!urls) urls = this.selectedTasks;
882
965
  if (typeof urls === 'string') urls = [urls];
883
- const r = await T.post(`/resume`, { urls, all: urls[0] === 'all' });
966
+ const r = await T.post('/api/resume', { urls, all: urls[0] === 'all' });
884
967
  if (!r.code) T.toast(r.message || '已恢复下载');
885
968
  if (urls === this.selectedTasks) this.selectedTasks = [];
886
969
  },
@@ -920,7 +1003,7 @@ services:
920
1003
  });
921
1004
 
922
1005
  if (result.isConfirmed) {
923
- const r = await T.post(`/delete`, {
1006
+ const r = await T.post('/api/delete', {
924
1007
  urls,
925
1008
  deleteCache: result.value.deleteCache,
926
1009
  deleteVideo: result.value.deleteVideo
@@ -939,7 +1022,7 @@ services:
939
1022
  }
940
1023
  },
941
1024
  getTasks: async function () {
942
- this.tasks = await T.get('/tasks');
1025
+ this.tasks = await T.get('/api/tasks');
943
1026
  },
944
1027
  showTaskDetail(task) {
945
1028
  console.log(task);
@@ -958,9 +1041,10 @@ services:
958
1041
  预估还需: !task.endTime && task.remainingTime && T.formatTimeCost(task.remainingTime),
959
1042
  相关信息: task.errmsg && `<span class=text-red-600>${task.errmsg}</span>`
960
1043
  };
961
- Swal.fire({
1044
+ T.alert({
962
1045
  title: '任务详情',
963
1046
  width: 1000,
1047
+ icon: '',
964
1048
  html: [
965
1049
  '<div class="flex-col full-width text-left">',
966
1050
  Object.entries(taskInfo).filter(d => d[1]).map(
@@ -1009,12 +1093,12 @@ services:
1009
1093
  },
1010
1094
  // 更新任务优先级
1011
1095
  updatePriority: async function (url, priority) {
1012
- const r = await T.post('/priority', { url, priority: parseInt(priority) });
1096
+ const r = await T.post('/api/priority', { url, priority: parseInt(priority) });
1013
1097
  T.toast(r.message || '已更新优先级');
1014
1098
  },
1015
1099
  // 获取队列状态
1016
1100
  getQueueStatus: async function () {
1017
- const status = await T.get('/queue/status');
1101
+ const status = await T.get('/api/queue/status');
1018
1102
  if (status?.maxConcurrent) this.queueStatus = status;
1019
1103
  },
1020
1104
  // 清空下载队列
@@ -1030,7 +1114,7 @@ services:
1030
1114
  });
1031
1115
 
1032
1116
  if (result.isConfirmed) {
1033
- const r = await T.post('/queue/clear');
1117
+ const r = await T.post('/api/queue/clear');
1034
1118
  T.toast(r.message || '已清空下载队列');
1035
1119
  this.getQueueStatus();
1036
1120
  }
@@ -1058,6 +1142,7 @@ services:
1058
1142
  T.reqHeaders.authorization = this.token ? `${this.token}` : '';
1059
1143
  this.fetchConfig().then(d => d && this.wsConnect());
1060
1144
  window.addEventListener('resize', this.handleResize);
1145
+ this.initEventsForApp();
1061
1146
  },
1062
1147
  beforeDestroy() {
1063
1148
  window.removeEventListener('resize', this.handleResize);
Binary file
package/client/style.css CHANGED
@@ -1,3 +1,11 @@
1
+ ::placeholder {
2
+ font-size: 14px;
3
+ }
4
+
5
+ .swal2-html-container textarea, input {
6
+ font-size: 16px !important;
7
+ }
8
+
1
9
  #app {
2
10
  max-width: 1400px;
3
11
  margin: auto;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lzwme/m3u8-dl",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
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,24 +44,24 @@
44
44
  "registry": "https://registry.npmjs.com"
45
45
  },
46
46
  "devDependencies": {
47
- "@biomejs/biome": "^1.9.4",
48
- "@eslint/js": "^9.28.0",
47
+ "@biomejs/biome": "^2.0.6",
48
+ "@eslint/js": "^9.29.0",
49
49
  "@lzwme/fed-lint-helper": "^2.6.6",
50
50
  "@types/express": "^5.0.3",
51
51
  "@types/m3u8-parser": "^7.2.2",
52
- "@types/node": "^24.0.1",
52
+ "@types/node": "^24.0.4",
53
53
  "@types/ws": "^8.18.1",
54
- "@typescript-eslint/eslint-plugin": "^8.34.0",
55
- "@typescript-eslint/parser": "^8.34.0",
56
- "eslint": "^9.28.0",
54
+ "@typescript-eslint/eslint-plugin": "^8.35.0",
55
+ "@typescript-eslint/parser": "^8.35.0",
56
+ "eslint": "^9.29.0",
57
57
  "eslint-config-prettier": "^10.1.5",
58
- "eslint-plugin-prettier": "^5.4.1",
58
+ "eslint-plugin-prettier": "^5.5.1",
59
59
  "express": "^5.1.0",
60
60
  "husky": "^9.1.7",
61
- "prettier": "^3.5.3",
61
+ "prettier": "^3.6.2",
62
62
  "standard-version": "^9.5.0",
63
63
  "typescript": "^5.8.3",
64
- "typescript-eslint": "^8.34.0",
64
+ "typescript-eslint": "^8.35.0",
65
65
  "ws": "^8.18.2"
66
66
  },
67
67
  "dependencies": {
package/client/logo.svg DELETED
@@ -1,16 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
3
- <!-- 背景圆形 -->
4
- <circle cx="256" cy="256" r="240" fill="#1890ff" opacity="0.1"/>
5
-
6
- <!-- 下载箭头 -->
7
- <path d="M256 128v192M208 256l48 48 48-48" stroke="#1890ff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
8
-
9
- <!-- M3U8 文字 -->
10
- <text x="256" y="384" font-family="Arial, sans-serif" font-size="64" font-weight="bold" fill="#1890ff" text-anchor="middle">M3U8</text>
11
-
12
- <!-- 速度线条 -->
13
- <path d="M128 192c32-32 64-32 96 0s64 32 96 0" stroke="#1890ff" stroke-width="16" stroke-linecap="round" opacity="0.6"/>
14
- <path d="M128 224c32-32 64-32 96 0s64 32 96 0" stroke="#1890ff" stroke-width="16" stroke-linecap="round" opacity="0.4"/>
15
- <path d="M128 256c32-32 64-32 96 0s64 32 96 0" stroke="#1890ff" stroke-width="16" stroke-linecap="round" opacity="0.2"/>
16
- </svg>