@lzwme/m3u8-dl 1.2.2 → 1.3.1

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
@@ -67,6 +67,7 @@ commander_1.program
67
67
  .option('--no-convert', '下载成功后,是否不合并转换为 mp4 文件。默认为 true。')
68
68
  .option('-H, --headers <headers>', '自定义请求头。格式为 key1=value1\nkey2=value2')
69
69
  .option('-T, --type <type>', '指定下载类型。默认根据URL自动识别,如果是批量下载多个不同 URL 类型,请不要设置。可选值:m3u8, file, parser')
70
+ .option('-I, --ignore-segments <time-segments>', '忽略的视频片段,用-分割起始时间点,多个用逗号分隔。如:0-10,20-30')
70
71
  .action(async (urls) => {
71
72
  const options = getOptions();
72
73
  utils_js_1.logger.debug(urls, options);
@@ -88,7 +89,9 @@ commander_1.program
88
89
  const opts = getOptions();
89
90
  if (opts.debug)
90
91
  options.debug = true;
91
- console.log(opts, options);
92
+ if (opts.cacheDir)
93
+ options.cacheDir = opts.cacheDir;
94
+ utils_js_1.logger.debug('[cli][server]', opts, options);
92
95
  Promise.resolve().then(() => __importStar(require('./server/download-server.js'))).then(m => {
93
96
  new m.DLServer(options);
94
97
  });
@@ -105,8 +105,35 @@ async function m3u8InfoParse(url, options = {}) {
105
105
  utils_js_1.logger.error('[parseM3U8][failed]', e.message);
106
106
  console.log(e);
107
107
  });
108
- if (m3u8Info && m3u8Info?.tsCount > 0)
108
+ if (m3u8Info && m3u8Info?.tsCount > 0) {
109
109
  result.m3u8Info = m3u8Info;
110
+ if (options.ignoreSegments) {
111
+ const timeSegments = options.ignoreSegments
112
+ .split(',')
113
+ .map(d => d.split(/[- ]+/).map(d => +d.trim()))
114
+ .filter(d => d[0] && d[1] && d[0] !== d[1]);
115
+ if (timeSegments.length) {
116
+ const total = m3u8Info.data.length;
117
+ m3u8Info.data = m3u8Info.data.filter(item => {
118
+ for (let [start, end] of timeSegments) {
119
+ if (start > end)
120
+ [start, end] = [end, start];
121
+ if (item.timeline + item.duration / 2 >= start && item.timeline + item.duration / 2 <= end) {
122
+ m3u8Info.duration -= item.duration;
123
+ return false;
124
+ }
125
+ }
126
+ return true;
127
+ });
128
+ const ignoredCount = total - m3u8Info.data.length;
129
+ if (ignoredCount) {
130
+ m3u8Info.tsCount = m3u8Info.data.length;
131
+ utils_js_1.logger.info(`[parseM3U8][ignoreSegments] ignored ${(0, console_log_colors_1.cyanBright)(ignoredCount)} segments`);
132
+ m3u8Info.duration = +Number(m3u8Info.duration).toFixed(2);
133
+ }
134
+ }
135
+ }
136
+ }
110
137
  return result;
111
138
  }
112
139
  /**
@@ -83,7 +83,7 @@ async function parseM3U8(content, cacheDir = './cache', headers) {
83
83
  result.data.push({
84
84
  index: i,
85
85
  duration: item.duration,
86
- timeline: item.timeline,
86
+ timeline: item.timeline || result.duration,
87
87
  uri: item.uri,
88
88
  tsOut: (0, node_path_1.resolve)(cacheDir, `${(0, fe_utils_1.md5)(item.uri)}.ts`),
89
89
  keyUri: item.key?.uri || '',
@@ -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,6 +36,7 @@ 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");
@@ -43,19 +44,21 @@ const format_options_js_1 = require("../lib/format-options.js");
43
44
  const m3u8_download_js_1 = require("../lib/m3u8-download.js");
44
45
  const utils_js_1 = require("../lib/utils.js");
45
46
  const index_js_1 = require("../video-parser/index.js");
47
+ const rootDir = (0, node_path_1.resolve)(__dirname, '../..');
46
48
  class DLServer {
47
49
  app = null;
48
50
  wss = null;
49
51
  /** DS 参数 */
50
52
  options = {
51
53
  port: Number(process.env.DS_PORT) || 6600,
52
- cacheDir: (0, node_path_1.resolve)(process.cwd(), './cache'),
54
+ cacheDir: process.env.DS_CACHE_DIR || (0, node_path_1.resolve)((0, node_os_1.homedir)(), '.m3u8-dl/cache'),
53
55
  token: process.env.DS_SECRET || process.env.DS_TOKEN || '',
54
56
  debug: process.env.DS_DEBUG === '1',
57
+ limitFileAccess: !['0', 'false'].includes(process.env.DS_LIMTE_FILE_ACCESS),
55
58
  };
56
59
  serverInfo = {
57
60
  version: '',
58
- ariang: (0, node_fs_1.existsSync)((0, node_path_1.resolve)(__dirname, '../../client/ariang/index.html')),
61
+ ariang: (0, node_fs_1.existsSync)((0, node_path_1.resolve)(rootDir, 'client/ariang/index.html')),
59
62
  };
60
63
  cfg = {
61
64
  /** 支持 web 设置修改的参数 */
@@ -85,7 +88,7 @@ class DLServer {
85
88
  opts.cacheDir = (0, node_path_1.resolve)(opts.cacheDir);
86
89
  if (!opts.configPath)
87
90
  opts.configPath = (0, node_path_1.resolve)(opts.cacheDir, 'config.json');
88
- const pkgFile = (0, node_path_1.resolve)(__dirname, '../../package.json');
91
+ const pkgFile = (0, node_path_1.resolve)(rootDir, 'package.json');
89
92
  if ((0, node_fs_1.existsSync)(pkgFile)) {
90
93
  const pkg = JSON.parse((0, node_fs_1.readFileSync)(pkgFile, 'utf8'));
91
94
  this.serverInfo.version = pkg.version;
@@ -101,7 +104,7 @@ class DLServer {
101
104
  this.loadCache();
102
105
  await this.createApp();
103
106
  this.initRouters();
104
- utils_js_1.logger.debug('Server initialized', this.options, this.cfg.dlOptions);
107
+ utils_js_1.logger.debug('Server initialized', 'cacheSize:', this.dlCache.size, this.options, this.cfg.dlOptions);
105
108
  }
106
109
  loadCache() {
107
110
  const cacheFile = (0, node_path_1.resolve)(this.options.cacheDir, 'cache.json');
@@ -187,8 +190,24 @@ class DLServer {
187
190
  const wss = new WebSocketServer({ server });
188
191
  this.app = app;
189
192
  this.wss = wss;
193
+ app.use((req, res, next) => {
194
+ if (['/', '/index.html'].includes(req.path)) {
195
+ const version = this.serverInfo.version;
196
+ let indexHtml = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(rootDir, 'client/index.html'), 'utf-8').replaceAll('{{version}}', version);
197
+ if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(rootDir, 'client/local/cdn'))) {
198
+ indexHtml = indexHtml
199
+ .replaceAll('https://s4.zstatic.net/ajax/libs', 'local/cdn')
200
+ .replaceAll(/integrity=.+\n/g, '')
201
+ .replace('https://cdn.tailwindcss.com/3.4.16', 'local/cdn/tailwindcss/3.4.16/tailwindcss.min.js');
202
+ }
203
+ res.setHeader('content-type', 'text/html').send(indexHtml);
204
+ }
205
+ else {
206
+ next();
207
+ }
208
+ });
190
209
  app.use(express.json());
191
- app.use(express.static((0, node_path_1.resolve)(__dirname, '../../client')));
210
+ app.use(express.static((0, node_path_1.resolve)(rootDir, 'client')));
192
211
  app.use((req, res, next) => {
193
212
  res.setHeader('Access-Control-Allow-Origin', '*');
194
213
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
@@ -230,20 +249,19 @@ class DLServer {
230
249
  return { app, wss };
231
250
  }
232
251
  startDownload(url, options) {
233
- const cacheItem = this.dlCache.get(url);
234
252
  const dlOptions = (0, format_options_js_1.formatOptions)(url, { ...this.cfg.dlOptions, ...options, cacheDir: this.options.cacheDir })[1];
235
- utils_js_1.logger.debug('startDownload', url, dlOptions, cacheItem?.status);
236
- if (cacheItem?.status === 'resume')
253
+ const cacheItem = this.dlCache.get(url) || { options, dlOptions, status: 'pending', url };
254
+ utils_js_1.logger.debug('startDownload', url, dlOptions, cacheItem.status);
255
+ if (cacheItem.status === 'resume')
237
256
  return cacheItem.options;
238
- if (this.downloading >= this.cfg.webOptions.maxDownloads) {
239
- if (cacheItem)
240
- cacheItem.status = 'pending';
241
- else
242
- this.dlCache.set(url, { options, dlOptions, status: 'pending', url });
243
- return cacheItem?.options || dlOptions;
244
- }
245
- let workPoll = cacheItem?.workPoll;
246
- const defaultItem = { options, dlOptions, status: 'resume', url };
257
+ if (cacheItem.localVideo && !(0, node_fs_1.existsSync)(cacheItem.localVideo))
258
+ delete cacheItem.localVideo;
259
+ cacheItem.status = this.downloading >= this.cfg.webOptions.maxDownloads ? 'pending' : 'resume';
260
+ this.dlCache.set(url, cacheItem);
261
+ this.wsSend('progress', url);
262
+ if (cacheItem.status === 'pending')
263
+ return cacheItem.options;
264
+ let workPoll = cacheItem.workPoll;
247
265
  const opts = {
248
266
  ...dlOptions,
249
267
  showProgress: dlOptions.debug || this.options.debug,
@@ -251,8 +269,8 @@ class DLServer {
251
269
  workPoll = wp;
252
270
  },
253
271
  onProgress: (_finished, _total, current, stats) => {
254
- const item = this.dlCache.get(url) || defaultItem;
255
- const status = item?.status || 'resume';
272
+ const item = this.dlCache.get(url) || cacheItem;
273
+ const status = item.status || 'resume';
256
274
  Object.assign(item, { ...stats, current, options: dlOptions, status, workPoll, url });
257
275
  this.dlCache.set(url, item);
258
276
  this.saveCache();
@@ -261,7 +279,7 @@ class DLServer {
261
279
  },
262
280
  };
263
281
  const afterDownload = (r, url) => {
264
- const item = this.dlCache.get(url) || defaultItem;
282
+ const item = this.dlCache.get(url) || cacheItem;
265
283
  if (r.filepath && (0, node_fs_1.existsSync)(r.filepath)) {
266
284
  item.localVideo = r.filepath;
267
285
  item.downloadedSize = (0, node_fs_1.statSync)(r.filepath).size;
@@ -276,18 +294,12 @@ class DLServer {
276
294
  this.wsSend('progress', url);
277
295
  this.saveCache();
278
296
  // 找到一个 pending 的任务,开始下载
279
- for (const [url, item] of this.dlCache.entries()) {
280
- if (item.status === 'pending') {
281
- this.startDownload(url, item.options);
282
- this.wsSend('progress', url);
283
- break;
284
- }
297
+ const nextItem = this.dlCache.entries().find(([_url, d]) => d.status === 'pending');
298
+ if (nextItem) {
299
+ this.startDownload(nextItem[0], nextItem[1].options);
300
+ this.wsSend('progress', nextItem[0]);
285
301
  }
286
302
  };
287
- if (cacheItem)
288
- cacheItem.status = 'resume';
289
- else
290
- this.dlCache.set(url, defaultItem);
291
303
  try {
292
304
  if (dlOptions.type === 'parser') {
293
305
  const vp = new index_js_1.VideoParser();
@@ -312,12 +324,10 @@ class DLServer {
312
324
  }
313
325
  else if (type === 'progress' && typeof data === 'string') {
314
326
  const item = this.dlCache.get(data);
315
- if (item) {
316
- const { workPoll, ...stats } = item;
317
- data = [{ ...stats, url: data }];
318
- }
319
- else
327
+ if (!item)
320
328
  return;
329
+ const { workPoll, ...stats } = item;
330
+ data = [{ ...stats, url: data }];
321
331
  }
322
332
  // 广播进度信息给所有客户端
323
333
  this.wss.clients.forEach(client => {
@@ -404,11 +414,12 @@ class DLServer {
404
414
  const urlsToPause = all ? [...this.dlCache.keys()] : urls;
405
415
  const list = [];
406
416
  for (const url of urlsToPause) {
407
- const { workPoll, ...item } = this.dlCache.get(url);
417
+ const item = this.dlCache.get(url);
408
418
  if (['resume', 'pending'].includes(item?.status)) {
409
- (0, m3u8_download_js_1.m3u8DLStop)(url, workPoll);
419
+ (0, m3u8_download_js_1.m3u8DLStop)(url, item.workPoll);
410
420
  item.status = item.tsSuccess === item.tsCount ? 'done' : 'pause';
411
- list.push(item);
421
+ const { workPoll, ...tItem } = item;
422
+ list.push(tItem);
412
423
  }
413
424
  }
414
425
  if (list.length)
@@ -472,8 +483,9 @@ class DLServer {
472
483
  if (!(0, node_fs_1.existsSync)(filepath))
473
484
  filepath += '.m3u8';
474
485
  }
486
+ const allowedDirs = [this.options.cacheDir, this.cfg.dlOptions.saveDir];
475
487
  if (!(0, node_fs_1.existsSync)(filepath)) {
476
- for (const dir of [this.options.cacheDir, this.cfg.dlOptions.saveDir]) {
488
+ for (const dir of allowedDirs) {
477
489
  const tpath = (0, node_path_1.resolve)(dir, filepath);
478
490
  if ((0, node_fs_1.existsSync)(tpath)) {
479
491
  filepath = tpath;
@@ -483,7 +495,9 @@ class DLServer {
483
495
  }
484
496
  else {
485
497
  filepath = (0, node_path_1.resolve)(filepath);
486
- if ([this.options.cacheDir, this.cfg.dlOptions.saveDir].some(d => filepath.startsWith((0, node_path_1.resolve)(d)))) {
498
+ const isAllow = !this.options.limitFileAccess || allowedDirs.some(d => filepath.startsWith((0, node_path_1.resolve)(d)));
499
+ if (!isAllow) {
500
+ utils_js_1.logger.error('[Localplay] Access denied:', filepath);
487
501
  res.send({ message: 'Access denied', code: 403 });
488
502
  return;
489
503
  }
@@ -6,7 +6,9 @@ export interface TsItemInfo {
6
6
  m3u8: string;
7
7
  /** ts 文件次序 */
8
8
  index: number;
9
+ /** 视频片段时长 */
9
10
  duration: number;
11
+ /** 时间线(起点) */
10
12
  timeline: number;
11
13
  /** ts 文件下载 url 地址 */
12
14
  uri: string;
@@ -119,6 +121,8 @@ export interface M3u8DLOptions {
119
121
  saveDir?: string;
120
122
  /** 临时文件保存目录。默认为 cache/<md5(url)> */
121
123
  cacheDir?: string;
124
+ /** 忽略的时间片段,单位为秒,多段以逗号分割。示例: 0-10,100-110 */
125
+ ignoreSegments?: string;
122
126
  /** 下载成功后是否删除临时文件。默认为 true。保存临时文件可以在重复下载时识别缓存 */
123
127
  delCache?: boolean;
124
128
  /** 文件已存在时是否仍强制下载和生成。默认为 false,文件已存在则跳过 */
package/client/index.html CHANGED
@@ -6,156 +6,26 @@
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>
9
+ <title>M3U8 下载器</title>
10
10
  <link rel="icon" type="image/svg+xml" href="logo.svg">
11
- <script src="https://cdn.tailwindcss.com"></script>
11
+ <link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/font-awesome/6.7.2/css/all.min.css"
12
+ integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="
13
+ crossorigin="anonymous" referrerpolicy="no-referrer" />
14
+ <link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/sweetalert2/11.16.1/sweetalert2.min.css"
15
+ integrity="sha512-WnmDqbbAeHb7Put2nIAp7KNlnMup0FXVviOctducz1omuXB/hHK3s2vd3QLffK/CvvFUKrpioxdo+/Jo3k/xIw=="
16
+ crossorigin="anonymous" referrerpolicy="no-referrer" />
17
+ <link rel="stylesheet" href="style.css?v={{version}}">
18
+
19
+ <script src="https://cdn.tailwindcss.com/3.4.16"></script>
12
20
  <script src="https://s4.zstatic.net/ajax/libs/vue/2.7.16/vue.min.js"
13
21
  integrity="sha512-Wx8niGbPNCD87mSuF0sBRytwW2+2ZFr7HwVDF8krCb3egstCc4oQfig+/cfg2OHd82KcUlOYxlSDAqdHqK5TCw=="
14
22
  crossorigin="anonymous" referrerpolicy="no-referrer"></script>
15
23
  <script src="https://s4.zstatic.net/ajax/libs/sweetalert2/11.16.1/sweetalert2.min.js"
16
24
  integrity="sha512-LGHBR+kJ5jZSIzhhdfytPoEHzgaYuTRifq9g5l6ja6/k9NAOsAi5dQh4zQF6JIRB8cAYxTRedERUF+97/KuivQ=="
17
25
  crossorigin="anonymous" referrerpolicy="no-referrer"></script>
18
- <script src="https://s4.zstatic.net/ajax/libs/blueimp-md5/2.19.0/js/md5.min.js"
19
- integrity="sha512-8pbzenDolL1l5OPSsoURCx9TEdMFTaeFipASVrMYKhuYtly+k3tcsQYliOEKTmuB1t7yuzAiVo+yd7SJz+ijFQ=="
20
- crossorigin="anonymous" referrerpolicy="no-referrer"></script>
21
- <link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/sweetalert2/11.16.1/sweetalert2.min.css"
22
- integrity="sha512-WnmDqbbAeHb7Put2nIAp7KNlnMup0FXVviOctducz1omuXB/hHK3s2vd3QLffK/CvvFUKrpioxdo+/Jo3k/xIw=="
23
- crossorigin="anonymous" referrerpolicy="no-referrer" />
24
- <link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/font-awesome/6.7.2/css/all.min.css"
25
- integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="
26
- crossorigin="anonymous" referrerpolicy="no-referrer" />
27
-
28
- <style>
29
- #app {
30
- max-width: 1400px;
31
- margin: auto;
32
- min-height: 100vh;
33
- background-color: #f5f5f5;
34
- position: relative;
35
- }
26
+ <script src="https://s4.zstatic.net/ajax/libs/blueimp-md5/2.19.0/js/md5.min.js" crossorigin="anonymous"
27
+ referrerpolicy="no-referrer"></script>
36
28
 
37
- .sidebar {
38
- background-color: #fff;
39
- box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
40
- transition: all 0.3s ease;
41
- position: absolute;
42
- left: 0;
43
- top: 0;
44
- bottom: 0;
45
- width: 16rem;
46
- z-index: 1;
47
- }
48
-
49
- .download-item {
50
- transition: all 0.3s ease;
51
- }
52
-
53
- .download-item:hover {
54
- background-color: #f8f9fa;
55
- }
56
-
57
- .progress-bar {
58
- height: 4px;
59
- transition: width 0.3s ease;
60
- }
61
-
62
- .nav-item {
63
- transition: all 0.3s ease;
64
- }
65
-
66
- .nav-item:hover {
67
- background-color: #f0f0f0;
68
- transform: translateX(4px);
69
- }
70
-
71
- .nav-item.active {
72
- background-color: #e6f3ff;
73
- color: #1890ff;
74
- }
75
-
76
- /* 移动端适配 */
77
- @media (max-width: 768px) {
78
- .sidebar {
79
- transform: translateX(-100%);
80
- }
81
-
82
- .sidebar.show {
83
- transform: translateX(0);
84
- }
85
-
86
- .menu-toggle {
87
- display: block !important;
88
- }
89
-
90
- .main-content {
91
- margin-left: 0 !important;
92
- }
93
- }
94
-
95
- .menu-toggle {
96
- display: none;
97
- position: fixed;
98
- top: 1rem;
99
- left: 1rem;
100
- z-index: 51;
101
- padding: 0.5rem;
102
- background: white;
103
- border-radius: 0.5rem;
104
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
105
- box-shadow: 0 2px 4px #9bdff5;
106
- width: 42px;
107
- height: 42px;
108
- }
109
-
110
- .main-content {
111
- transition: margin-left 0.3s ease;
112
- margin-left: 16rem;
113
- width: calc(100% - 16rem);
114
- }
115
-
116
- /* 自定义toast样式 */
117
- .custom-toast {
118
- position: fixed;
119
- top: 20px;
120
- right: 20px;
121
- min-width: 250px;
122
- max-width: 400px;
123
- padding: 15px;
124
- border-radius: 4px;
125
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
126
- background: #fff;
127
- color: #333;
128
- z-index: 99999;
129
- display: flex;
130
- align-items: center;
131
- transform: translateX(150%);
132
- transition: transform 0.3s ease;
133
- }
134
-
135
- .custom-toast.show {
136
- transform: translateX(0);
137
- }
138
-
139
- .custom-toast.hide {
140
- transform: translateX(150%);
141
- }
142
-
143
- .custom-toast-success {
144
- border-left: 4px solid #28a745;
145
- }
146
-
147
- .custom-toast-error {
148
- border-left: 4px solid #dc3545;
149
- }
150
-
151
- .custom-toast-warning {
152
- border-left: 4px solid #ffc107;
153
- }
154
-
155
- .custom-toast-info {
156
- border-left: 4px solid #17a2b8;
157
- }
158
- </style>
159
29
  </head>
160
30
 
161
31
  <body>
@@ -343,16 +213,6 @@
343
213
  <i class="fas fa-hourglass-half mr-1"></i>
344
214
  <span>剩余: {{ T.formatTimeCost(task.remainingTime || 0) }}</span>
345
215
  </span>
346
- <!-- <span class="flex items-center">
347
- <i class="fas fa-sort-amount-up mr-1"></i>
348
- <select v-model="task.priority" @change="updatePriority(url, task.priority)"
349
- class="text-sm border rounded px-1 py-0.5 focus:ring-blue-500 focus:border-blue-500"
350
- aria-label="设置下载优先级">
351
- <option value="0">普通优先级</option>
352
- <option value="1">高优先级</option>
353
- <option value="2">最高优先级</option>
354
- </select>
355
- </span> -->
356
216
  </div>
357
217
  </div>
358
218
  <div class="flex space-x-2">
@@ -546,6 +406,14 @@
546
406
  class="text-blue-500 hover:text-blue-600">
547
407
  {{serverInfo.version}}</a>
548
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>
549
417
  </div>
550
418
  </div>
551
419
 
@@ -622,6 +490,13 @@ services:
622
490
  'content-type': 'application/json',
623
491
  authorization: localStorage.getItem('token') || '',
624
492
  },
493
+ initTJ() {
494
+ if (!window._hmt) window._hmt = [];
495
+ const hm = document.createElement("script");
496
+ hm.src = "https://hm.baidu.com/hm.js?0b21eda331ac9677a4c546dea88616d0";
497
+ const s = document.getElementsByTagName("script")[0];
498
+ s.parentNode.insertBefore(hm, s);
499
+ },
625
500
  request(method, url, data, headers = {}) {
626
501
  return fetch(url, {
627
502
  method,
@@ -646,7 +521,10 @@ services:
646
521
  alert(msg, p) {
647
522
  p = typeof msg === 'object' ? msg : Object.assign({ text: msg }, p);
648
523
  if (!p.toast) p.allowOutsideClick = false;
649
- Swal.fire(Object.assign({ icon: 'info', showConfirmButton: false, showCloseButton: true }, p));
524
+ return Swal.fire(Object.assign({ icon: 'info', showConfirmButton: false, showCloseButton: true }, p));
525
+ },
526
+ confirm(msg, p) {
527
+ return this.alert(msg, { showConfirmButton: true, showCancelButton: true, showCloseButton: true, confirmButtonText: '确认', cancelButtonText: '取消' });
650
528
  },
651
529
  toast(msg, p) {
652
530
  p = (typeof msg === 'object' ? msg : Object.assign({ text: msg }, p));
@@ -727,13 +605,16 @@ services:
727
605
  };
728
606
 
729
607
  Vue.prototype.T = T;
608
+ T.initTJ();
730
609
  window.APP = new Vue({
731
610
  el: '#app',
732
611
  data: {
733
612
  ws: null,
734
613
  serverInfo: {
735
- version: '',
614
+ version: '{{version}}',
736
615
  ariang: false,
616
+ newVersion: '',
617
+ appUpdateMessage: '',
737
618
  },
738
619
  config: {
739
620
  /** 并发下载线程数。取决于服务器限制,过多可能会容易下载失败。一般建议不超过 8 个。默认为 cpu数 * 2,但不超过 8 */
@@ -793,7 +674,7 @@ services:
793
674
 
794
675
  // 排序:resume > pending > pause > error > done
795
676
  const statusOrder = { resume: 0, pending: 1, pause: 2, error: 3, done: 4 };
796
- 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)));
797
678
 
798
679
  // 更新 queueStatus
799
680
  const queueStatus = {
@@ -814,10 +695,43 @@ services:
814
695
  }
815
696
  },
816
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
+ },
817
730
  forceUpdate: function () {
818
731
  const now = Date.now();
819
732
  if (now - this.forceUpdateTime > 500) {
820
733
  this.forceUpdateTime = now;
734
+ this.tasks = { ...this.tasks };
821
735
  this.$forceUpdate();
822
736
  } else {
823
737
  if (this.forceUpdateTimeout) clearTimeout(this.forceUpdateTimeout);
@@ -836,7 +750,7 @@ services:
836
750
 
837
751
  switch (type) {
838
752
  case 'serverInfo':
839
- this.serverInfo = data;
753
+ Object.assign(this.serverInfo, data);
840
754
  break;
841
755
  case 'tasks':
842
756
  this.tasks = data;
@@ -930,6 +844,11 @@ services:
930
844
  <input id="saveDir" class="w-full p-2 border rounded-lg focus:ring-blue-500" placeholder="请输入保存路径" value="${this.config.saveDir || ''}">
931
845
  </div>
932
846
 
847
+ <div class="mt-4">
848
+ <label class="block text-sm font-bold text-gray-700 mb-1">删除时间片段(适用于移除广告片段的情况)</label>
849
+ <input id="ignoreSegments" class="w-full p-2 border rounded-lg focus:ring-blue-500" placeholder="以-分割起止时间,多个以逗号分隔。示例:0-10,20-100">
850
+ </div>
851
+
933
852
  <div class="mt-4">
934
853
  <label class="block text-sm font-bold text-gray-700 mb-1">自定义请求头</label>
935
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>
@@ -947,6 +866,7 @@ services:
947
866
  const filename = document.getElementById('filename').value.trim();
948
867
  const saveDir = document.getElementById('saveDir').value.trim();
949
868
  const headersText = document.getElementById('headers').value.trim();
869
+ const ignoreSegments = document.getElementById('ignoreSegments').value.trim();
950
870
 
951
871
  if (!urlsText) {
952
872
  Swal.showValidationMessage('请输入至少一个 M3U8 链接');
@@ -971,6 +891,7 @@ services:
971
891
  filename: item.name || (filename ? `${filename}第${idx + 1}集` : ''),
972
892
  saveDir,
973
893
  headers,
894
+ ignoreSegments,
974
895
  }));
975
896
  }
976
897
  }).then((result) => {
@@ -981,6 +902,7 @@ services:
981
902
  startBatchDownload: async function (list) {
982
903
  try {
983
904
  list.forEach(async (item, idx) => {
905
+ Object.entries(item).forEach(([key, value]) => !value && delete item[key]);
984
906
  this.tasks[item.url] = { status: 'resume', progress: 0, speed: 0, remainingTime: 0, size: 0 };
985
907
  });
986
908
  const r = await T.post('/download', { list });
@@ -1048,6 +970,7 @@ services:
1048
970
  deleteCache: result.value.deleteCache,
1049
971
  deleteVideo: result.value.deleteVideo
1050
972
  });
973
+
1051
974
  if (!r.code) {
1052
975
  T.toast(r.message || '已删除选中的下载');
1053
976
  urls.forEach(url => (delete this.tasks[url]));
@@ -1180,6 +1103,7 @@ services:
1180
1103
  T.reqHeaders.authorization = this.token ? `${this.token}` : '';
1181
1104
  this.fetchConfig().then(d => d && this.wsConnect());
1182
1105
  window.addEventListener('resize', this.handleResize);
1106
+ this.initEventsForApp();
1183
1107
  },
1184
1108
  beforeDestroy() {
1185
1109
  window.removeEventListener('resize', this.handleResize);
package/client/play.html CHANGED
@@ -2,43 +2,44 @@
2
2
  <html lang="zh">
3
3
 
4
4
  <head>
5
- <title>m3u8 在线播放</title>
6
- <meta charset="utf-8">
7
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
8
- <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0,viewport-fit=cover" name="viewport" />
9
- <link crossorigin="anonymous" rel="stylesheet" href="https://lib.baomitu.com/dplayer/latest/DPlayer.min.css">
10
- <style>
11
- body {
12
- margin: 0;
13
- }
14
- </style>
5
+ <title>M3U8 在线播放</title>
6
+ <meta charset="utf-8">
7
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
8
+ <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0,viewport-fit=cover"
9
+ name="viewport" />
10
+ <style>
11
+ body {
12
+ margin: 0;
13
+ }
14
+ </style>
15
15
  </head>
16
16
 
17
17
  <body>
18
- <div id="dplayer"></div>
19
- <script crossorigin="anonymous"
20
- integrity="sha512-yi//c0pOEPlBEqUMgK7Ia1VXQT9TwuMHJIRU+T2lyV7YxsMhbF35N/DGYkCFWfC9ebjdupP4xadFyFVTz/sgEg=="
21
- src="https://lib.baomitu.com/hls.js/1.3.0/hls.min.js"></script>
22
- <script crossorigin="anonymous" src="https://lib.baomitu.com/dplayer/latest/DPlayer.min.js"></script>
18
+ <div id="dplayer"></div>
19
+ <script src="https://s4.zstatic.net/ajax/libs/hls.js/1.5.18/hls.min.js"
20
+ integrity="sha512-hARxLWym80kd0Bzl5/93OuW1ujaKfvmJ90yTKak/RB67JuNIjtErU2H7H3bteyfzMuqiSK0tXarT7eK6lEWBBA=="
21
+ crossorigin="anonymous" referrerpolicy="no-referrer"></script>
22
+ <script src="https://s4.zstatic.net/ajax/libs/dplayer/1.26.0/DPlayer.min.js" crossorigin="anonymous"
23
+ referrerpolicy="no-referrer"></script>
23
24
 
24
- <script>
25
- const url = location.href.split('url=')[1];
26
- if (!url) {
27
- document.getElementById('dplayer').innerText = '请传入播放地址参数 url=';
28
- } else {
29
- const dp = new DPlayer({
30
- container: document.getElementById('dplayer'),
31
- autoplay: true,
32
- video: {
33
- url: decodeURIComponent(url),
34
- type: 'auto',
35
- },
36
- pluginOptions: {
37
- hls: {},
38
- },
39
- });
40
- }
41
- </script>
25
+ <script>
26
+ const url = location.href.split('url=')[1];
27
+ if (!url) {
28
+ document.getElementById('dplayer').innerText = '请传入播放地址参数 url=';
29
+ } else {
30
+ const dp = new DPlayer({
31
+ container: document.getElementById('dplayer'),
32
+ autoplay: true,
33
+ video: {
34
+ url: decodeURIComponent(url),
35
+ type: 'auto',
36
+ },
37
+ pluginOptions: {
38
+ hls: {},
39
+ },
40
+ });
41
+ }
42
+ </script>
42
43
  </body>
43
44
 
44
45
  </html>
@@ -0,0 +1,129 @@
1
+ #app {
2
+ max-width: 1400px;
3
+ margin: auto;
4
+ min-height: 100vh;
5
+ background-color: #f5f5f5;
6
+ position: relative;
7
+ }
8
+
9
+ .sidebar {
10
+ background-color: #fff;
11
+ box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
12
+ transition: all 0.3s ease;
13
+ position: absolute;
14
+ left: 0;
15
+ top: 0;
16
+ bottom: 0;
17
+ width: 16rem;
18
+ z-index: 1;
19
+ }
20
+
21
+ .download-item {
22
+ transition: all 0.3s ease;
23
+ }
24
+
25
+ .download-item:hover {
26
+ background-color: #f8f9fa;
27
+ }
28
+
29
+ .progress-bar {
30
+ height: 4px;
31
+ transition: width 0.3s ease;
32
+ }
33
+
34
+ .nav-item {
35
+ transition: all 0.3s ease;
36
+ }
37
+
38
+ .nav-item:hover {
39
+ background-color: #f0f0f0;
40
+ transform: translateX(4px);
41
+ }
42
+
43
+ .nav-item.active {
44
+ background-color: #e6f3ff;
45
+ color: #1890ff;
46
+ }
47
+
48
+ /* 移动端适配 */
49
+ @media (max-width: 768px) {
50
+ .sidebar {
51
+ transform: translateX(-100%);
52
+ }
53
+
54
+ .sidebar.show {
55
+ transform: translateX(0);
56
+ }
57
+
58
+ .menu-toggle {
59
+ display: block !important;
60
+ }
61
+
62
+ .main-content {
63
+ margin-left: 0 !important;
64
+ }
65
+ }
66
+
67
+ .menu-toggle {
68
+ display: none;
69
+ position: fixed;
70
+ top: 1rem;
71
+ left: 1rem;
72
+ z-index: 51;
73
+ padding: 0.5rem;
74
+ background: white;
75
+ border-radius: 0.5rem;
76
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
77
+ box-shadow: 0 2px 4px #9bdff5;
78
+ width: 42px;
79
+ height: 42px;
80
+ }
81
+
82
+ .main-content {
83
+ transition: margin-left 0.3s ease;
84
+ margin-left: 16rem;
85
+ width: calc(100% - 16rem);
86
+ }
87
+
88
+ /* 自定义toast样式 */
89
+ .custom-toast {
90
+ position: fixed;
91
+ top: 20px;
92
+ right: 20px;
93
+ min-width: 250px;
94
+ max-width: 400px;
95
+ padding: 15px 25px 15px 15px;
96
+ border-radius: 4px;
97
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
98
+ background: #fff;
99
+ color: #333;
100
+ z-index: 99999;
101
+ display: flex;
102
+ align-items: center;
103
+ transform: translateX(150%);
104
+ transition: transform 0.3s ease;
105
+ }
106
+
107
+ .custom-toast.show {
108
+ transform: translateX(0);
109
+ }
110
+
111
+ .custom-toast.hide {
112
+ transform: translateX(150%);
113
+ }
114
+
115
+ .custom-toast-success {
116
+ border-left: 4px solid #28a745;
117
+ }
118
+
119
+ .custom-toast-error {
120
+ border-left: 4px solid #dc3545;
121
+ }
122
+
123
+ .custom-toast-warning {
124
+ border-left: 4px solid #ffc107;
125
+ }
126
+
127
+ .custom-toast-info {
128
+ border-left: 4px solid #17a2b8;
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lzwme/m3u8-dl",
3
- "version": "1.2.2",
3
+ "version": "1.3.1",
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.0",
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": "^22.15.30",
52
+ "@types/node": "^24.0.3",
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.34.1",
55
+ "@typescript-eslint/parser": "^8.34.1",
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.0",
59
59
  "express": "^5.1.0",
60
60
  "husky": "^9.1.7",
61
61
  "prettier": "^3.5.3",
62
62
  "standard-version": "^9.5.0",
63
63
  "typescript": "^5.8.3",
64
- "typescript-eslint": "^8.34.0",
64
+ "typescript-eslint": "^8.34.1",
65
65
  "ws": "^8.18.2"
66
66
  },
67
67
  "dependencies": {
@@ -73,7 +73,9 @@
73
73
  },
74
74
  "files": [
75
75
  "cjs",
76
- "client",
76
+ "client/",
77
+ "!client/ariang",
78
+ "!client/local",
77
79
  "!cjs/type.js",
78
80
  "!cjs/cli.d.ts",
79
81
  "bin"