@lzwme/m3u8-dl 1.3.1 → 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/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
  }
@@ -42,6 +42,7 @@ const console_log_colors_1 = require("console-log-colors");
42
42
  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 m3u8_download_js_1 = require("../lib/m3u8-download.js");
45
+ const getM3u8Urls_js_1 = require("../lib/getM3u8Urls.js");
45
46
  const utils_js_1 = require("../lib/utils.js");
46
47
  const index_js_1 = require("../video-parser/index.js");
47
48
  const rootDir = (0, node_path_1.resolve)(__dirname, '../..');
@@ -128,7 +129,7 @@ class DLServer {
128
129
  return;
129
130
  }
130
131
  this.dlCache.forEach(item => {
131
- 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))) {
132
133
  item.status = 'error';
133
134
  item.errmsg = '已删除';
134
135
  }
@@ -249,7 +250,7 @@ class DLServer {
249
250
  return { app, wss };
250
251
  }
251
252
  startDownload(url, options) {
252
- 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 });
253
254
  const cacheItem = this.dlCache.get(url) || { options, dlOptions, status: 'pending', url };
254
255
  utils_js_1.logger.debug('startDownload', url, dlOptions, cacheItem.status);
255
256
  if (cacheItem.status === 'resume')
@@ -337,24 +338,23 @@ class DLServer {
337
338
  }
338
339
  initRouters() {
339
340
  const { app } = this;
340
- // health check
341
341
  app.get('/healthcheck', (_req, res) => {
342
342
  res.json({ message: 'ok', code: 0 });
343
343
  });
344
- app.post('/config', (req, res) => {
344
+ app.post('/api/config', (req, res) => {
345
345
  const config = req.body;
346
346
  this.saveConfig(config);
347
347
  res.json({ message: 'Config updated successfully', code: 0 });
348
348
  });
349
- app.get('/config', (_req, res) => {
349
+ app.get('/api/config', (_req, res) => {
350
350
  res.json({ ...this.cfg.dlOptions, ...this.cfg.webOptions });
351
351
  });
352
352
  // API to get all download progress
353
- app.get('/tasks', (_req, res) => {
353
+ app.get('/api/tasks', (_req, res) => {
354
354
  res.json(Object.fromEntries(this.dlCacheClone()));
355
355
  });
356
356
  // API to get queue status
357
- app.get('/queue/status', (_req, res) => {
357
+ app.get('/api/queue/status', (_req, res) => {
358
358
  const pendingTasks = Array.from(this.dlCache.entries()).filter(([_, item]) => item.status === 'pending');
359
359
  const activeTasks = Array.from(this.dlCache.entries()).filter(([_, item]) => item.status === 'resume');
360
360
  res.json({
@@ -364,7 +364,7 @@ class DLServer {
364
364
  });
365
365
  });
366
366
  // API to clear queue
367
- app.post('/queue/clear', (_req, res) => {
367
+ app.post('/api/queue/clear', (_req, res) => {
368
368
  let count = 0;
369
369
  for (const [url, item] of this.dlCache.entries()) {
370
370
  if (item.status === 'pending') {
@@ -377,7 +377,7 @@ class DLServer {
377
377
  res.json({ message: `已清空 ${count} 个等待中的下载任务`, code: 0 });
378
378
  });
379
379
  // API to update task priority
380
- app.post('/priority', (req, res) => {
380
+ app.post('/api/priority', (req, res) => {
381
381
  const { url, priority } = req.body;
382
382
  const item = this.dlCache.get(url);
383
383
  if (!item) {
@@ -389,7 +389,7 @@ class DLServer {
389
389
  res.json({ message: '已更新任务优先级', code: 0 });
390
390
  });
391
391
  // API to start m3u8 download
392
- app.post('/download', (req, res) => {
392
+ app.post('/api/download', (req, res) => {
393
393
  const { url, options = {}, list = [] } = req.body;
394
394
  try {
395
395
  if (list.length) {
@@ -409,7 +409,7 @@ class DLServer {
409
409
  }
410
410
  });
411
411
  // API to pause download
412
- app.post('/pause', (req, res) => {
412
+ app.post('/api/pause', (req, res) => {
413
413
  const { urls, all = false } = req.body;
414
414
  const urlsToPause = all ? [...this.dlCache.keys()] : urls;
415
415
  const list = [];
@@ -417,7 +417,7 @@ class DLServer {
417
417
  const item = this.dlCache.get(url);
418
418
  if (['resume', 'pending'].includes(item?.status)) {
419
419
  (0, m3u8_download_js_1.m3u8DLStop)(url, item.workPoll);
420
- item.status = item.tsSuccess === item.tsCount ? 'done' : 'pause';
420
+ item.status = item.tsSuccess > 0 && item.tsSuccess === item.tsCount ? 'done' : 'pause';
421
421
  const { workPoll, ...tItem } = item;
422
422
  list.push(tItem);
423
423
  }
@@ -427,7 +427,7 @@ class DLServer {
427
427
  res.json({ message: `已暂停 ${list.length} 个下载任务`, code: 0, count: list.length });
428
428
  });
429
429
  // API to resume download
430
- app.post('/resume', (req, res) => {
430
+ app.post('/api/resume', (req, res) => {
431
431
  const { urls, all = false } = req.body;
432
432
  const urlsToResume = all ? [...this.dlCache.keys()] : urls;
433
433
  const list = [];
@@ -446,7 +446,7 @@ class DLServer {
446
446
  res.json({ message: list.length ? `已恢复 ${list.length} 个下载任务` : '没有找到可恢复的下载任务', code: 0, count: list.length });
447
447
  });
448
448
  // API to delete download
449
- app.post('/delete', (req, res) => {
449
+ app.post('/api/delete', (req, res) => {
450
450
  const { urls, deleteCache = false, deleteVideo = false } = req.body;
451
451
  const urlsToDelete = urls;
452
452
  const list = [];
@@ -458,20 +458,26 @@ class DLServer {
458
458
  list.push(item.url);
459
459
  if (deleteCache && item.current?.tsOut) {
460
460
  const cacheDir = (0, node_path_1.dirname)(item.current.tsOut);
461
- if ((0, node_fs_1.existsSync)(cacheDir))
461
+ if ((0, node_fs_1.existsSync)(cacheDir)) {
462
462
  (0, node_fs_1.rmSync)(cacheDir, { recursive: true });
463
+ utils_js_1.logger.debug('删除缓存目录:', cacheDir);
464
+ }
463
465
  }
464
466
  if (deleteVideo) {
465
467
  ['.ts', '.mp4'].forEach(ext => {
466
468
  const filepath = (0, node_path_1.resolve)(item.options.saveDir, item.options.filename + ext);
467
- if ((0, node_fs_1.existsSync)(filepath))
469
+ if ((0, node_fs_1.existsSync)(filepath)) {
468
470
  (0, node_fs_1.unlinkSync)(filepath);
471
+ utils_js_1.logger.debug('删除文件:', filepath);
472
+ }
469
473
  });
470
474
  }
471
475
  }
472
476
  }
473
- if (list.length)
477
+ if (list.length) {
474
478
  this.wsSend('delete', list);
479
+ this.saveCache();
480
+ }
475
481
  res.json({ message: `已删除 ${list.length} 个下载任务`, code: 0, count: list.length });
476
482
  });
477
483
  app.get(/^\/localplay\/(.*)$/, (req, res) => {
@@ -519,6 +525,17 @@ class DLServer {
519
525
  utils_js_1.logger.error('Localplay file not found:', filepath);
520
526
  res.status(404).send({ message: 'Not Found', code: 404 });
521
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
+ });
522
539
  }
523
540
  }
524
541
  exports.DLServer = DLServer;
package/client/index.html CHANGED
@@ -7,7 +7,7 @@
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
9
  <title>M3U8 下载器</title>
10
- <link rel="icon" type="image/svg+xml" href="logo.svg">
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"
@@ -521,7 +521,7 @@ services:
521
521
  alert(msg, p) {
522
522
  p = typeof msg === 'object' ? msg : Object.assign({ text: msg }, p);
523
523
  if (!p.toast) p.allowOutsideClick = false;
524
- return 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
525
  },
526
526
  confirm(msg, p) {
527
527
  return this.alert(msg, { showConfirmButton: true, showCancelButton: true, showCloseButton: true, confirmButtonText: '确认', cancelButtonText: '取消' });
@@ -790,7 +790,7 @@ services:
790
790
  },
791
791
  /** 获取配置 */
792
792
  fetchConfig: async function () {
793
- const config = await T.get('/config');
793
+ const config = await T.get('/api/config');
794
794
  if (config.code) {
795
795
  console.error('获取配置失败:', config);
796
796
  T.alert('获取配置失败: ' + config.message, { icon: 'error' });
@@ -803,7 +803,7 @@ services:
803
803
  },
804
804
  /** 更新配置 */
805
805
  updateConfig: async function () {
806
- const result = await T.post('/config', this.config);
806
+ const result = await T.post('/api/config', this.config);
807
807
  T.toast(result.message || '配置已更新', { icon: result.code ? 'error' : 'success' });
808
808
  },
809
809
  updateLocalConfig: async function () {
@@ -830,18 +830,31 @@ services:
830
830
  width: '800px',
831
831
  html: `
832
832
  <div class="text-left">
833
- <label class="block text-sm font-bold text-gray-700 mb-1">视频链接(每行一个,支持m3u8地址及抖音、微博、皮皮虾视频分享链接)</label>
834
- <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>
835
841
 
836
842
  <div class="mt-4">
837
- <label class="block text-sm font-bold text-gray-700 mb-1">视频名称(可选)</label>
838
- <input id="filename" class="w-full p-2 border rounded-lg focus:border-blue-500" placeholder="请输入视频名称">
839
- <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>
840
845
  </div>
841
846
 
842
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">
843
856
  <label class="block text-sm font-bold text-gray-700 mb-1">保存位置</label>
844
- <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 || ''}">
845
858
  </div>
846
859
 
847
860
  <div class="mt-4">
@@ -851,7 +864,7 @@ services:
851
864
 
852
865
  <div class="mt-4">
853
866
  <label class="block text-sm font-bold text-gray-700 mb-1">自定义请求头</label>
854
- <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>
855
868
  </div>
856
869
  </div>
857
870
  `,
@@ -897,6 +910,31 @@ services:
897
910
  }).then((result) => {
898
911
  if (result.isConfirmed) this.startBatchDownload(result.value);
899
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);
900
938
  },
901
939
  /** 批量下载 */
902
940
  startBatchDownload: async function (list) {
@@ -905,7 +943,7 @@ services:
905
943
  Object.entries(item).forEach(([key, value]) => !value && delete item[key]);
906
944
  this.tasks[item.url] = { status: 'resume', progress: 0, speed: 0, remainingTime: 0, size: 0 };
907
945
  });
908
- const r = await T.post('/download', { list });
946
+ const r = await T.post('/api/download', { list });
909
947
  if (!r.code) T.toast(r.message || '批量下载已开始');
910
948
  this.forceUpdate();
911
949
  } catch (error) {
@@ -917,7 +955,7 @@ services:
917
955
  pauseDownload: async function (urls) {
918
956
  if (!urls) urls = this.selectedTasks;
919
957
  if (typeof urls === 'string') urls = [urls];
920
- const r = await T.post(`/pause`, { urls, all: urls[0] === 'all' });
958
+ const r = await T.post('/api/pause', { urls, all: urls[0] === 'all' });
921
959
  if (!r.code) T.toast(r.message || '已暂停下载');
922
960
  if (urls === this.selectedTasks) this.selectedTasks = [];
923
961
  },
@@ -925,7 +963,7 @@ services:
925
963
  resumeDownload: async function (urls) {
926
964
  if (!urls) urls = this.selectedTasks;
927
965
  if (typeof urls === 'string') urls = [urls];
928
- const r = await T.post(`/resume`, { urls, all: urls[0] === 'all' });
966
+ const r = await T.post('/api/resume', { urls, all: urls[0] === 'all' });
929
967
  if (!r.code) T.toast(r.message || '已恢复下载');
930
968
  if (urls === this.selectedTasks) this.selectedTasks = [];
931
969
  },
@@ -965,7 +1003,7 @@ services:
965
1003
  });
966
1004
 
967
1005
  if (result.isConfirmed) {
968
- const r = await T.post(`/delete`, {
1006
+ const r = await T.post('/api/delete', {
969
1007
  urls,
970
1008
  deleteCache: result.value.deleteCache,
971
1009
  deleteVideo: result.value.deleteVideo
@@ -984,7 +1022,7 @@ services:
984
1022
  }
985
1023
  },
986
1024
  getTasks: async function () {
987
- this.tasks = await T.get('/tasks');
1025
+ this.tasks = await T.get('/api/tasks');
988
1026
  },
989
1027
  showTaskDetail(task) {
990
1028
  console.log(task);
@@ -1003,9 +1041,10 @@ services:
1003
1041
  预估还需: !task.endTime && task.remainingTime && T.formatTimeCost(task.remainingTime),
1004
1042
  相关信息: task.errmsg && `<span class=text-red-600>${task.errmsg}</span>`
1005
1043
  };
1006
- Swal.fire({
1044
+ T.alert({
1007
1045
  title: '任务详情',
1008
1046
  width: 1000,
1047
+ icon: '',
1009
1048
  html: [
1010
1049
  '<div class="flex-col full-width text-left">',
1011
1050
  Object.entries(taskInfo).filter(d => d[1]).map(
@@ -1054,12 +1093,12 @@ services:
1054
1093
  },
1055
1094
  // 更新任务优先级
1056
1095
  updatePriority: async function (url, priority) {
1057
- const r = await T.post('/priority', { url, priority: parseInt(priority) });
1096
+ const r = await T.post('/api/priority', { url, priority: parseInt(priority) });
1058
1097
  T.toast(r.message || '已更新优先级');
1059
1098
  },
1060
1099
  // 获取队列状态
1061
1100
  getQueueStatus: async function () {
1062
- const status = await T.get('/queue/status');
1101
+ const status = await T.get('/api/queue/status');
1063
1102
  if (status?.maxConcurrent) this.queueStatus = status;
1064
1103
  },
1065
1104
  // 清空下载队列
@@ -1075,7 +1114,7 @@ services:
1075
1114
  });
1076
1115
 
1077
1116
  if (result.isConfirmed) {
1078
- const r = await T.post('/queue/clear');
1117
+ const r = await T.post('/api/queue/clear');
1079
1118
  T.toast(r.message || '已清空下载队列');
1080
1119
  this.getQueueStatus();
1081
1120
  }
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.1",
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": "^2.0.0",
47
+ "@biomejs/biome": "^2.0.6",
48
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.3",
52
+ "@types/node": "^24.0.4",
53
53
  "@types/ws": "^8.18.1",
54
- "@typescript-eslint/eslint-plugin": "^8.34.1",
55
- "@typescript-eslint/parser": "^8.34.1",
54
+ "@typescript-eslint/eslint-plugin": "^8.35.0",
55
+ "@typescript-eslint/parser": "^8.35.0",
56
56
  "eslint": "^9.29.0",
57
57
  "eslint-config-prettier": "^10.1.5",
58
- "eslint-plugin-prettier": "^5.5.0",
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.1",
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>