@lzwme/m3u8-dl 1.4.0 → 1.4.2

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.
@@ -39,7 +39,7 @@ async function fileDownload(u, opts) {
39
39
  force: options.force,
40
40
  requestOptions: {
41
41
  headers: {
42
- referer: url,
42
+ referer: new URL(url).origin,
43
43
  ...(0, utils_js_1.formatHeaders)(options.headers),
44
44
  },
45
45
  rejectUnauthorized: false,
@@ -1,2 +1,11 @@
1
+ import type { OutgoingHttpHeaders } from 'node:http';
2
+ export interface GetM3u8UrlsOption {
3
+ url: string;
4
+ /** 播放子页面 URL 特征规则 */
5
+ subUrlRegex?: string | RegExp;
6
+ headers?: OutgoingHttpHeaders | string;
7
+ deep?: number;
8
+ visited?: Set<string>;
9
+ }
1
10
  /** 从指定的 url 页面中提取 m3u8 播放地址。deep 指定搜索页面深度 */
2
- export declare function getM3u8Urls(url: string, deep?: number, visited?: Set<string>): Promise<Map<string, string>>;
11
+ export declare function getM3u8Urls(opts: GetM3u8UrlsOption): Promise<Map<string, string>>;
@@ -3,41 +3,98 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getM3u8Urls = getM3u8Urls;
4
4
  const fe_utils_1 = require("@lzwme/fe-utils");
5
5
  const utils_js_1 = require("./utils.js");
6
+ function getFormatTitle(text) {
7
+ if (typeof text !== 'string' || !text)
8
+ return '';
9
+ if (/^\d+$/.test(text))
10
+ return text;
11
+ const match = /第(\d+)(集|期)/.exec(text);
12
+ if (match)
13
+ return match[0];
14
+ return '';
15
+ }
6
16
  /** 从指定的 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
17
+ async function getM3u8Urls(opts) {
18
+ const options = { headers: {}, deep: 1, visited: new Set(), ...opts };
19
+ const baseUrl = new URL(options.url).origin;
20
+ const req = new fe_utils_1.Request({
21
+ headers: { 'content-type': 'text/html; charset=UTF-8', referer: baseUrl, ...(0, utils_js_1.formatHeaders)(options.headers) },
22
+ reqOptions: { rejectUnauthorized: false },
23
+ });
24
+ const { data: html, response } = await req.get(options.url);
11
25
  const m3u8Urls = new Map();
12
- const m3u8Regex = /https?:[^\s'"]+\.m3u8(\?[^\s'"]*)?/gi;
26
+ if (!response.statusCode || response.statusCode >= 400) {
27
+ utils_js_1.logger.error('获取页面失败:', fe_utils_1.color.red(options.url), response.statusCode, response.statusMessage, html);
28
+ return m3u8Urls;
29
+ }
30
+ // 从 html 中正则匹配提取 m3u8
31
+ const m3u8Regex = /https?:[^\s'":]+\.(m3u8|mp4)(\?[^\s'"]*)?/gi;
13
32
  // 1. 直接正则匹配 m3u8 地址
14
33
  let match = m3u8Regex.exec(html);
34
+ const title = (/<title>([^<]+)</.exec(html)?.[1].split('-')[0] || '').replace(/在线播放|在线观看|详情|介绍|《|》/g, '').trim();
15
35
  while (match) {
16
- const title = (/<title>([^<]+)</.exec(html)?.[1].split('-')[0] || '').replace(/在线播放|《|》/g, '');
17
36
  const href = match[0].replaceAll('\\/', '/');
18
- if (!m3u8Urls.has(href))
19
- m3u8Urls.set(href, title);
20
37
  match = m3u8Regex.exec(html);
38
+ if (!m3u8Urls.has(href))
39
+ m3u8Urls.set(href, getFormatTitle(title) || title);
40
+ }
41
+ // 找到了多个链接,修改 title 添加序号
42
+ if (m3u8Urls.size > 3 && !/第\d+(集|期)/.test(title)) {
43
+ let idx = 1;
44
+ for (const [key] of m3u8Urls) {
45
+ m3u8Urls.set(key, `${title}第${String(++idx).padStart(2, '0')}集`);
46
+ }
21
47
  }
22
48
  // 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);
49
+ if (m3u8Urls.size === 0 && options.deep > 0) {
50
+ utils_js_1.logger.debug('未获取到 m3u8 地址', fe_utils_1.color.gray(options.url), html.length);
51
+ options.visited.add(options.url);
26
52
  const aTagRegex = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi;
27
53
  let aMatch = aTagRegex.exec(html);
28
- const origin = new URL(url).origin;
54
+ const subPageUrls = new Map();
55
+ let failedSubPages = 0;
29
56
  while (aMatch) {
30
- const href = aMatch[1] ? new URL(aMatch[1], origin).toString() : '';
31
- const text = aMatch[2];
57
+ const href = aMatch[1] ? new URL(aMatch[1], baseUrl).toString() : '';
58
+ const text = aMatch[2].replace(/<[^>]+>/g, '');
32
59
  aMatch = aTagRegex.exec(html);
33
- if (!href || visited.has(href) || !href.startsWith(origin) || !/集|HD|高清|播放/.test(text))
60
+ if (!href || options.visited.has(href) || !href.startsWith(baseUrl))
34
61
  continue;
35
- visited.add(href);
62
+ if (options.subUrlRegex) {
63
+ if (typeof options.subUrlRegex === 'string') {
64
+ options.subUrlRegex = new RegExp(options.subUrlRegex.replaceAll(/\*+/g, '.+'));
65
+ }
66
+ if (!options.subUrlRegex.test(href))
67
+ continue;
68
+ }
69
+ else if (!/集|期|HD|高清|抢先|BD/.test(text))
70
+ continue;
71
+ subPageUrls.set(href, text);
72
+ utils_js_1.logger.debug(' > 提取到子页面: ', fe_utils_1.color.gray(href), text);
73
+ }
74
+ for (const [href, text] of subPageUrls) {
36
75
  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);
76
+ options.visited.add(href);
77
+ const subUrls = await getM3u8Urls({ ...options, url: href, deep: options.deep - 1 });
78
+ utils_js_1.logger.debug(' > 从子页面提取: ', fe_utils_1.color.gray(href), text, subUrls);
79
+ if (subUrls.size === 0 && m3u8Urls.size === 0) {
80
+ failedSubPages++;
81
+ if (failedSubPages > 3) {
82
+ utils_js_1.logger.warn(`连续查找 ${failedSubPages} 个子页面均未获取到,不再继续`, options.url, href);
83
+ return m3u8Urls;
84
+ }
85
+ }
86
+ for (const [u, t] of subUrls) {
87
+ let stitle = t;
88
+ for (const s of [text, t, m3u8Urls.get(u) || '']) {
89
+ const ft = getFormatTitle(s);
90
+ if (ft) {
91
+ stitle = ft;
92
+ break;
93
+ }
94
+ }
95
+ utils_js_1.logger.debug(' > m3u8地址: ', fe_utils_1.color.gray(u), fe_utils_1.color.green(stitle));
96
+ m3u8Urls.set(u, stitle.trim());
97
+ }
41
98
  }
42
99
  catch (err) {
43
100
  utils_js_1.logger.warn(' > 尝试访问子页面异常: ', fe_utils_1.color.red(href), err.message);
@@ -1,8 +1,8 @@
1
- import type { IncomingHttpHeaders } from 'node:http';
1
+ import type { OutgoingHttpHeaders } from 'node:http';
2
2
  import type { M3u8Info } from '../types/m3u8';
3
3
  /**
4
4
  * 解析 m3u8 文件
5
5
  * @param content m3u8 文件的内容,可为 http 远程地址、本地文件路径
6
6
  * @param cacheDir 缓存文件保存目录
7
7
  */
8
- export declare function parseM3U8(content: string, cacheDir?: string, headers?: IncomingHttpHeaders): Promise<M3u8Info>;
8
+ export declare function parseM3U8(content: string, cacheDir?: string, headers?: OutgoingHttpHeaders | string): Promise<M3u8Info>;
@@ -15,7 +15,7 @@ async function parseM3U8(content, cacheDir = './cache', headers) {
15
15
  let url = process.cwd();
16
16
  if (content.startsWith('http')) {
17
17
  url = content;
18
- content = (await (0, utils_1.getRetry)(url)).data;
18
+ content = (await (0, utils_1.getRetry)(url, headers)).data;
19
19
  }
20
20
  else if (!content.includes('\n') && (0, node_fs_1.existsSync)(content)) {
21
21
  url = (0, node_path_1.resolve)(process.cwd(), content);
@@ -1,3 +1,3 @@
1
- import type { IncomingHttpHeaders } from 'node:http';
1
+ import type { OutgoingHttpHeaders } from 'node:http';
2
2
  import type { M3u8Crypto, TsItemInfo } from '../types/m3u8';
3
- export declare function tsDownload(info: TsItemInfo, cryptoInfo: M3u8Crypto, headers?: IncomingHttpHeaders): Promise<boolean>;
3
+ export declare function tsDownload(info: TsItemInfo, cryptoInfo: M3u8Crypto, headers?: OutgoingHttpHeaders | string): Promise<boolean>;
@@ -43,10 +43,7 @@ if (!node_worker_threads_1.isMainThread && node_worker_threads_1.parentPort) {
43
43
  const startTime = Date.now();
44
44
  if (data.options.debug)
45
45
  utils_js_1.logger.updateOptions({ levelType: 'debug' });
46
- let headers = data.options?.headers;
47
- if (headers)
48
- headers = (0, utils_js_1.formatHeaders)(headers);
49
- tsDownload(data.info, data.crypto, headers).then(success => {
46
+ tsDownload(data.info, data.crypto, data.options?.headers).then(success => {
50
47
  node_worker_threads_1.parentPort.postMessage({ success, info: data.info, timeCost: Date.now() - startTime });
51
48
  });
52
49
  });
@@ -1,11 +1,11 @@
1
1
  import { type Stats } from 'node:fs';
2
- import type { IncomingHttpHeaders } from 'node:http';
2
+ import type { OutgoingHttpHeaders } from 'node:http';
3
3
  import { NLogger, Request } from '@lzwme/fe-utils';
4
4
  export declare const request: Request;
5
- export declare const getRetry: <T = string>(url: string, headers?: IncomingHttpHeaders, retries?: number) => Promise<{
5
+ export declare const getRetry: <T = string>(url: string, headers?: OutgoingHttpHeaders | string, retries?: number) => Promise<{
6
6
  data: T;
7
7
  buffer: Buffer;
8
- headers: IncomingHttpHeaders;
8
+ headers: import("http").IncomingHttpHeaders;
9
9
  response: import("http").IncomingMessage;
10
10
  }>;
11
11
  export declare const logger: NLogger;
@@ -17,4 +17,4 @@ export declare function getLocation(url: string, method?: string): Promise<strin
17
17
  * 将传入的 headers 转换为统一的小写键对象格式
18
18
  * 如果 headers 是字符串,会先将其解析为对象;如果 headers 为空,则返回空对象。
19
19
  */
20
- export declare function formatHeaders(headers: string | IncomingHttpHeaders): Record<string, string>;
20
+ export declare function formatHeaders(headers: string | OutgoingHttpHeaders): Record<string, string>;
package/cjs/lib/utils.js CHANGED
@@ -13,7 +13,7 @@ exports.request = new fe_utils_1.Request({
13
13
  reqOptions: { rejectUnauthorized: false },
14
14
  });
15
15
  // process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
16
- const getRetry = (url, headers, retries = 3) => (0, fe_utils_1.retry)(() => exports.request.get(url, null, headers, { rejectUnauthorized: false }), 1000, retries, r => {
16
+ const getRetry = (url, headers, retries = 3) => (0, fe_utils_1.retry)(() => exports.request.get(url, null, formatHeaders(headers), { rejectUnauthorized: false }), 1000, retries, r => {
17
17
  if (r.response.statusCode !== 200) {
18
18
  console.log();
19
19
  exports.logger.warn(`[retry][${url}][${r.response.statusCode}]`, r.response.statusMessage || r.data);
@@ -59,10 +59,9 @@ async function getLocation(url, method = 'HEAD') {
59
59
  * 如果 headers 是字符串,会先将其解析为对象;如果 headers 为空,则返回空对象。
60
60
  */
61
61
  function formatHeaders(headers) {
62
- if (typeof headers === 'string') {
63
- headers = Object.fromEntries(headers.split('\n').map(line => line.split(':').map(d => d.trim())));
64
- }
65
- else if (!headers)
62
+ if (!headers)
66
63
  return {};
64
+ if (typeof headers === 'string')
65
+ headers = Object.fromEntries(headers.split('\n').map(line => line.split(':').map(d => d.trim())));
67
66
  return (0, fe_utils_1.toLowcaseKeyObject)(headers);
68
67
  }
@@ -45,6 +45,7 @@ export declare class DLServer {
45
45
  saveConfig(config: M3u8DLOptions, configPath?: string): void;
46
46
  private createApp;
47
47
  private startDownload;
48
+ startNextPending(): void;
48
49
  private wsSend;
49
50
  private initRouters;
50
51
  }
@@ -55,7 +55,7 @@ class DLServer {
55
55
  cacheDir: process.env.DS_CACHE_DIR || (0, node_path_1.resolve)((0, node_os_1.homedir)(), '.m3u8-dl/cache'),
56
56
  token: process.env.DS_SECRET || process.env.DS_TOKEN || '',
57
57
  debug: process.env.DS_DEBUG === '1',
58
- limitFileAccess: !['0', 'false'].includes(process.env.DS_LIMTE_FILE_ACCESS),
58
+ limitFileAccess: ['1', 'true'].includes(process.env.DS_LIMTE_FILE_ACCESS),
59
59
  };
60
60
  serverInfo = {
61
61
  version: '',
@@ -198,7 +198,7 @@ class DLServer {
198
198
  if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(rootDir, 'client/local/cdn'))) {
199
199
  indexHtml = indexHtml
200
200
  .replaceAll('https://s4.zstatic.net/ajax/libs', 'local/cdn')
201
- .replaceAll(/integrity=.+\n/g, '')
201
+ .replaceAll(/integrity="[^"]+"\n?/g, '')
202
202
  .replace('https://cdn.tailwindcss.com/3.4.16', 'local/cdn/tailwindcss/3.4.16/tailwindcss.min.js');
203
203
  }
204
204
  res.setHeader('content-type', 'text/html').send(indexHtml);
@@ -249,19 +249,34 @@ class DLServer {
249
249
  wss.on('listening', () => utils_js_1.logger.info(`WebSocket server listening on port ${(0, console_log_colors_1.green)(this.options.port)}`));
250
250
  return { app, wss };
251
251
  }
252
- startDownload(url, options) {
252
+ async startDownload(url, options) {
253
+ if (!url)
254
+ return utils_js_1.logger.error('[satartDownload]Invalid URL:', url);
255
+ if (url.endsWith('.html')) {
256
+ const item = Array.from(await (0, getM3u8Urls_js_1.getM3u8Urls)({ url, headers: options.headers }))[0];
257
+ if (!item)
258
+ return utils_js_1.logger.error('[startDownload]不是有效(包含)M3U8的地址:', url);
259
+ url = item[0];
260
+ if (!options.filename)
261
+ options.filename = item[1];
262
+ }
253
263
  const { options: dlOptions } = (0, format_options_js_1.formatOptions)(url, { ...this.cfg.dlOptions, ...options, cacheDir: this.options.cacheDir });
254
264
  const cacheItem = this.dlCache.get(url) || { options, dlOptions, status: 'pending', url };
255
265
  utils_js_1.logger.debug('startDownload', url, dlOptions, cacheItem.status);
256
266
  if (cacheItem.status === 'resume')
257
- return cacheItem.options;
267
+ return;
258
268
  if (cacheItem.localVideo && !(0, node_fs_1.existsSync)(cacheItem.localVideo))
259
269
  delete cacheItem.localVideo;
270
+ if (cacheItem.endTime)
271
+ delete cacheItem.endTime;
260
272
  cacheItem.status = this.downloading >= this.cfg.webOptions.maxDownloads ? 'pending' : 'resume';
273
+ // pending 优先级靠后
274
+ if (cacheItem.status === 'pending' && this.dlCache.has(url))
275
+ this.dlCache.delete(url);
261
276
  this.dlCache.set(url, cacheItem);
262
277
  this.wsSend('progress', url);
263
278
  if (cacheItem.status === 'pending')
264
- return cacheItem.options;
279
+ return;
265
280
  let workPoll = cacheItem.workPoll;
266
281
  const opts = {
267
282
  ...dlOptions,
@@ -270,7 +285,9 @@ class DLServer {
270
285
  workPoll = wp;
271
286
  },
272
287
  onProgress: (_finished, _total, current, stats) => {
273
- const item = this.dlCache.get(url) || cacheItem;
288
+ const item = this.dlCache.get(url);
289
+ if (!item)
290
+ return false; // 已删除
274
291
  const status = item.status || 'resume';
275
292
  Object.assign(item, { ...stats, current, options: dlOptions, status, workPoll, url });
276
293
  this.dlCache.set(url, item);
@@ -294,12 +311,7 @@ class DLServer {
294
311
  this.dlCache.set(url, item);
295
312
  this.wsSend('progress', url);
296
313
  this.saveCache();
297
- // 找到一个 pending 的任务,开始下载
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]);
302
- }
314
+ this.startNextPending();
303
315
  };
304
316
  try {
305
317
  if (dlOptions.type === 'parser') {
@@ -317,7 +329,14 @@ class DLServer {
317
329
  afterDownload({ filepath: '', errmsg: error.message }, url);
318
330
  utils_js_1.logger.error('下载失败:', error);
319
331
  }
320
- return dlOptions;
332
+ }
333
+ startNextPending() {
334
+ // 找到一个 pending 的任务,开始下载
335
+ const nextItem = this.dlCache.entries().find(([_url, d]) => d.status === 'pending');
336
+ if (nextItem) {
337
+ this.startDownload(nextItem[0], nextItem[1].options);
338
+ this.wsSend('progress', nextItem[0]);
339
+ }
321
340
  }
322
341
  wsSend(type = 'progress', data) {
323
342
  if (type === 'tasks' && !data) {
@@ -422,8 +441,10 @@ class DLServer {
422
441
  list.push(tItem);
423
442
  }
424
443
  }
425
- if (list.length)
444
+ if (list.length) {
426
445
  this.wsSend('progress', list);
446
+ this.startNextPending();
447
+ }
427
448
  res.json({ message: `已暂停 ${list.length} 个下载任务`, code: 0, count: list.length });
428
449
  });
429
450
  // API to resume download
@@ -477,6 +498,7 @@ class DLServer {
477
498
  if (list.length) {
478
499
  this.wsSend('delete', list);
479
500
  this.saveCache();
501
+ this.startNextPending();
480
502
  }
481
503
  res.json({ message: `已删除 ${list.length} 个下载任务`, code: 0, count: list.length });
482
504
  });
@@ -518,20 +540,36 @@ class DLServer {
518
540
  'Content-Length': String(stats.size),
519
541
  'Content-Type': ext === 'ts' ? 'video/mp2t' : ext === 'm3u8' ? 'application/vnd.apple.mpegurl' : ext === 'mp4' ? 'video/mp4' : 'text/plain',
520
542
  });
521
- res.setHeaders(headers).sendFile(filepath);
543
+ res.setHeaders(headers);
544
+ if (ext === 'm3u8' || ('ts' === ext && stats.size < 1024 * 1024 * 3)) {
545
+ let content = (0, node_fs_1.readFileSync)(filepath);
546
+ if (ext === 'm3u8') {
547
+ const baseDirName = (0, node_path_1.basename)(filepath, '.m3u8');
548
+ content = content
549
+ .toString('utf8')
550
+ .split('\n')
551
+ .map(line => (line.endsWith('.ts') && !line.includes('/') ? `${baseDirName}/${line}` : line))
552
+ .join('\n');
553
+ }
554
+ res.send(content);
555
+ utils_js_1.logger.debug('[Localplay]file sent:', (0, console_log_colors_1.gray)(filepath), 'Size:', stats.size, 'bytes');
556
+ }
557
+ else {
558
+ res.sendFile(filepath);
559
+ }
522
560
  return;
523
561
  }
524
562
  }
525
- utils_js_1.logger.error('Localplay file not found:', filepath);
563
+ utils_js_1.logger.error('[Localplay]file not found:', (0, console_log_colors_1.red)(filepath));
526
564
  res.status(404).send({ message: 'Not Found', code: 404 });
527
565
  });
528
566
  app.post('/api/getM3u8Urls', (req, res) => {
529
- const url = req.body.url;
567
+ const { url, headers, subUrlRegex } = req.body;
530
568
  if (!url) {
531
569
  res.json({ code: 1001, message: '无效的 url 参数' });
532
570
  }
533
571
  else {
534
- (0, getM3u8Urls_js_1.getM3u8Urls)(url)
572
+ (0, getM3u8Urls_js_1.getM3u8Urls)({ url, headers, subUrlRegex })
535
573
  .then(d => res.json({ code: 0, data: Array.from(d) }))
536
574
  .catch(err => res.json({ code: 401, message: err.message }));
537
575
  }
@@ -73,7 +73,7 @@ class VideoParser {
73
73
  }
74
74
  catch (error) {
75
75
  console.error('解析 URL 失败', url, error);
76
- return null;
76
+ return { url, platform: 'unknown' };
77
77
  }
78
78
  }
79
79
  /**
package/client/index.html CHANGED
@@ -170,9 +170,9 @@
170
170
  <input type="checkbox" :checked="selectedTasks.includes(task.url)"
171
171
  @change="toggleTaskSelection(task.url)"
172
172
  class="form-checkbox h-5 w-5 text-blue-500 rounded focus:ring-blue-500 mr-2"
173
- :title="'选择任务:' + (task.localVideo || task.filename || task.url)">
173
+ :title="'选择任务:' + task.showName">
174
174
  <h3 class="font-bold text-green-600 truncate max-w-[calc(100vw-100px)]" :title="task.url">
175
- {{ task.filename || task.localVideo || task.url }}
175
+ {{ task.showName }}
176
176
  </h3>
177
177
  <div class="absolute right-1 top-1 text-xs rounded overflow-hidden">
178
178
  <span v-if="task.status === 'pending'"
@@ -662,7 +662,7 @@ services:
662
662
  if (this.searchQuery) {
663
663
  const query = this.searchQuery.toLowerCase();
664
664
  tasks = tasks.filter(task => {
665
- const filename = (task.localVideo || task.filename || task.url).toLowerCase();
665
+ const filename = (task.localVideo || task.filename || task.dlOptions?.filename || task.url).toLowerCase();
666
666
  return filename.includes(query) || task.url.toLowerCase().includes(query);
667
667
  });
668
668
  }
@@ -674,7 +674,7 @@ services:
674
674
 
675
675
  // 排序:resume > pending > pause > error > done
676
676
  const statusOrder = { resume: 0, pending: 1, pause: 2, error: 3, done: 4 };
677
- 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) : ((a.startTime || 0) - (b.startTime || 0))));
678
678
 
679
679
  // 更新 queueStatus
680
680
  const queueStatus = {
@@ -683,6 +683,7 @@ services:
683
683
  maxConcurrent: this.config.maxDownloads,
684
684
  };
685
685
  tasks.forEach(task => {
686
+ task.showName = task.filename || task.dlOptions?.filename || task.localVideo || task.url;
686
687
  if (task.status === 'pending') {
687
688
  queueStatus.queueLength++;
688
689
  } else if (task.status === 'resume') {
@@ -824,14 +825,14 @@ services:
824
825
  }
825
826
  },
826
827
  /** 显示新建下载弹窗 */
827
- showNewDownloadDialog: function () {
828
+ showNewDownloadDialog() {
828
829
  Swal.fire({
829
830
  title: '新建下载',
830
- width: '800px',
831
+ width: '900px',
831
832
  html: `
832
833
  <div class="text-left">
833
834
  <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
+ <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
836
  <div class="flex flex-row gap-1">
836
837
  <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
  提取
@@ -839,6 +840,13 @@ services:
839
840
  </div>
840
841
  </div>
841
842
 
843
+ <div class="mt-4">
844
+ <div class="flex flex-row gap-2 items-center">
845
+ <input id="subUrlRegex" class="flex-1 p-2 border rounded-lg focus:border-blue-500" placeholder="[实验性](可选)播放页链接特征规则">
846
+ </div>
847
+ <p class="ml-2 mt-1 text-sm text-gray-500">用于从视频列表页准确识别播放地址。如:<code>play/845-1-</code></p>
848
+ </div>
849
+
842
850
  <div class="mt-4">
843
851
  <label class="block text-sm font-bold text-gray-700 mb-1">视频链接(每行一个,支持m3u8地址及抖音、微博、皮皮虾视频分享链接)</label>
844
852
  <textarea id="downloadUrls" class="w-full p-2 border rounded-lg focus:ring-blue-500" rows="3" placeholder="格式: URL | 名称(可选)、名称 | URL"></textarea>
@@ -877,8 +885,8 @@ services:
877
885
  preConfirm: () => {
878
886
  const urlsText = document.getElementById('downloadUrls').value.trim();
879
887
  const filename = document.getElementById('filename').value.trim();
880
- const saveDir = document.getElementById('saveDir').value.trim();
881
- const headersText = document.getElementById('headers').value.trim();
888
+ let saveDir = document.getElementById('saveDir').value.trim();
889
+ const headers = document.getElementById('headers').value.trim();
882
890
  const ignoreSegments = document.getElementById('ignoreSegments').value.trim();
883
891
 
884
892
  if (!urlsText) {
@@ -899,9 +907,14 @@ services:
899
907
  return false;
900
908
  }
901
909
 
910
+ if (urls.length > 1 && filename && !saveDir.includes(filename)) {
911
+ if (!saveDir) saveDir = this.config.saveDir;
912
+ saveDir = saveDir.replace(/\/?$/, '') + '/' + filename;
913
+ }
914
+
902
915
  return urls.map((item, idx) => ({
903
916
  url: item.url,
904
- filename: item.name || (filename ? `${filename}第${idx + 1}集` : ''),
917
+ filename: item.name || (filename ? `${filename}${urls.length > 1 ? `第${idx + 1}集` : ''}` : ''),
905
918
  saveDir,
906
919
  headers,
907
920
  ignoreSegments,
@@ -924,7 +937,9 @@ services:
924
937
  btn.setAttribute('disabled', 'disabled');
925
938
  btn.innerText = '解析中...';
926
939
 
927
- T.post('/api/getM3u8Urls', { url }).then(r => {
940
+ const headers = document.getElementById('headers').value.trim();
941
+ const subUrlRegex = document.getElementById('subUrlRegex').value.trim();
942
+ T.post('/api/getM3u8Urls', { url, headers, subUrlRegex }).then(r => {
928
943
  if (Array.isArray(r.data)) {
929
944
  document.getElementById('downloadUrls').value = r.data.map(d => d.join(' | ')).join('\n');
930
945
  T.toast(r.message || `解析完成!获取到 ${r.data.length} 个地址`);
@@ -941,7 +956,7 @@ services:
941
956
  try {
942
957
  list.forEach(async (item, idx) => {
943
958
  Object.entries(item).forEach(([key, value]) => !value && delete item[key]);
944
- this.tasks[item.url] = { status: 'resume', progress: 0, speed: 0, remainingTime: 0, size: 0 };
959
+ if (!/\.html?$/.test(item.url)) this.tasks[item.url] = { status: 'resume', progress: 0, speed: 0, remainingTime: 0, size: 0 };
945
960
  });
946
961
  const r = await T.post('/api/download', { list });
947
962
  if (!r.code) T.toast(r.message || '批量下载已开始');
@@ -1026,19 +1041,20 @@ services:
1026
1041
  },
1027
1042
  showTaskDetail(task) {
1028
1043
  console.log(task);
1044
+ const isResume = task.status === 'resume';
1029
1045
  const taskInfo = {
1030
1046
  名称: task.filename || task.localVideo,
1031
1047
  状态: T.taskStatus[task.status] || task.status,
1032
- 大小: `${T.formatSize(task.downloadedSize)} / ${task.size ? T.formatSize(task.size) : ''}`,
1033
- 分片: `<span class=text-green-600>${task.tsSuccess}</span> / <span class=text-red-500>${task.tsFailed}</span> / ${task.tsCount}`,
1034
- 进度: `${task.progress}%`,
1035
- 平均速度: `${task.avgSpeedDesc}/s`,
1048
+ 大小: `${T.formatSize(task.downloadedSize || 0)} / ${task.size ? T.formatSize(task.size) : ''}`,
1049
+ 分片: task.tsCount ? `<span class=text-green-600>${task.tsSuccess}</span> / <span class=text-red-500>${task.tsFailed}</span> / ${task.tsCount}` : '-',
1050
+ 进度: `${task.progress || '-'}%`,
1051
+ 平均速度: `${task.avgSpeedDesc || '-'}/s`,
1036
1052
  并发线程: task.threadNum,
1037
1053
  下载地址: task.url,
1038
1054
  保存位置: task.localVideo || task.options?.saveDir,
1039
- 开始时间: new Date(task.startTime).toLocaleString(),
1040
- 结束时间: task.endTime && new Date(task.endTime).toLocaleString(),
1041
- 预估还需: !task.endTime && task.remainingTime && T.formatTimeCost(task.remainingTime),
1055
+ 开始时间: task.startTime && new Date(task.startTime).toLocaleString(),
1056
+ 结束时间: !isResume && task.endTime && new Date(task.endTime).toLocaleString(),
1057
+ 预估还需: isResume && task.remainingTime && T.formatTimeCost(task.remainingTime),
1042
1058
  相关信息: task.errmsg && `<span class=text-red-600>${task.errmsg}</span>`
1043
1059
  };
1044
1060
  T.alert({
@@ -1057,7 +1073,8 @@ services:
1057
1073
  },
1058
1074
  /** 边下边播 */
1059
1075
  localPlay: function (task) {
1060
- const url = location.origin + '/localplay/' + (task.localVideo || task.localM3u8);
1076
+ const filepath = task.localVideo || task.localM3u8;
1077
+ const url = location.origin + `/localplay/${encodeURIComponent(filepath)}`;
1061
1078
  console.log(task);
1062
1079
  Swal.fire({
1063
1080
  title: task?.options.filename || task.url,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lzwme/m3u8-dl",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
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,25 +44,25 @@
44
44
  "registry": "https://registry.npmjs.com"
45
45
  },
46
46
  "devDependencies": {
47
- "@biomejs/biome": "^2.0.6",
48
- "@eslint/js": "^9.29.0",
47
+ "@biomejs/biome": "^2.1.2",
48
+ "@eslint/js": "^9.31.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.4",
52
+ "@types/node": "^24.0.15",
53
53
  "@types/ws": "^8.18.1",
54
- "@typescript-eslint/eslint-plugin": "^8.35.0",
55
- "@typescript-eslint/parser": "^8.35.0",
56
- "eslint": "^9.29.0",
57
- "eslint-config-prettier": "^10.1.5",
58
- "eslint-plugin-prettier": "^5.5.1",
54
+ "@typescript-eslint/eslint-plugin": "^8.37.0",
55
+ "@typescript-eslint/parser": "^8.37.0",
56
+ "eslint": "^9.31.0",
57
+ "eslint-config-prettier": "^10.1.8",
58
+ "eslint-plugin-prettier": "^5.5.3",
59
59
  "express": "^5.1.0",
60
60
  "husky": "^9.1.7",
61
61
  "prettier": "^3.6.2",
62
62
  "standard-version": "^9.5.0",
63
63
  "typescript": "^5.8.3",
64
- "typescript-eslint": "^8.35.0",
65
- "ws": "^8.18.2"
64
+ "typescript-eslint": "^8.37.0",
65
+ "ws": "^8.18.3"
66
66
  },
67
67
  "dependencies": {
68
68
  "@lzwme/fe-utils": "^1.9.0",