@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.
- package/cjs/lib/file-download.js +1 -1
- package/cjs/lib/getM3u8Urls.d.ts +10 -1
- package/cjs/lib/getM3u8Urls.js +77 -20
- package/cjs/lib/parseM3u8.d.ts +2 -2
- package/cjs/lib/parseM3u8.js +1 -1
- package/cjs/lib/ts-download.d.ts +2 -2
- package/cjs/lib/ts-download.js +1 -4
- package/cjs/lib/utils.d.ts +4 -4
- package/cjs/lib/utils.js +4 -5
- package/cjs/server/download-server.d.ts +1 -0
- package/cjs/server/download-server.js +56 -18
- package/cjs/video-parser/index.js +1 -1
- package/client/index.html +37 -20
- package/package.json +11 -11
package/cjs/lib/file-download.js
CHANGED
package/cjs/lib/getM3u8Urls.d.ts
CHANGED
|
@@ -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(
|
|
11
|
+
export declare function getM3u8Urls(opts: GetM3u8UrlsOption): Promise<Map<string, string>>;
|
package/cjs/lib/getM3u8Urls.js
CHANGED
|
@@ -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(
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
54
|
+
const subPageUrls = new Map();
|
|
55
|
+
let failedSubPages = 0;
|
|
29
56
|
while (aMatch) {
|
|
30
|
-
const href = aMatch[1] ? new URL(aMatch[1],
|
|
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(
|
|
60
|
+
if (!href || options.visited.has(href) || !href.startsWith(baseUrl))
|
|
34
61
|
continue;
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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);
|
package/cjs/lib/parseM3u8.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type {
|
|
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?:
|
|
8
|
+
export declare function parseM3U8(content: string, cacheDir?: string, headers?: OutgoingHttpHeaders | string): Promise<M3u8Info>;
|
package/cjs/lib/parseM3u8.js
CHANGED
|
@@ -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);
|
package/cjs/lib/ts-download.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type {
|
|
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?:
|
|
3
|
+
export declare function tsDownload(info: TsItemInfo, cryptoInfo: M3u8Crypto, headers?: OutgoingHttpHeaders | string): Promise<boolean>;
|
package/cjs/lib/ts-download.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/cjs/lib/utils.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { type Stats } from 'node:fs';
|
|
2
|
-
import type {
|
|
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?:
|
|
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 |
|
|
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 (
|
|
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
|
}
|
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
}
|
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="'选择任务:' +
|
|
173
|
+
:title="'选择任务:' + task.showName">
|
|
174
174
|
<h3 class="font-bold text-green-600 truncate max-w-[calc(100vw-100px)]" :title="task.url">
|
|
175
|
-
{{ task.
|
|
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) : (
|
|
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
|
|
828
|
+
showNewDownloadDialog() {
|
|
828
829
|
Swal.fire({
|
|
829
830
|
title: '新建下载',
|
|
830
|
-
width: '
|
|
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="
|
|
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
|
-
|
|
881
|
-
const
|
|
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}
|
|
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
|
-
|
|
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
|
-
预估还需:
|
|
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
|
|
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.
|
|
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.
|
|
48
|
-
"@eslint/js": "^9.
|
|
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.
|
|
52
|
+
"@types/node": "^24.0.15",
|
|
53
53
|
"@types/ws": "^8.18.1",
|
|
54
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
55
|
-
"@typescript-eslint/parser": "^8.
|
|
56
|
-
"eslint": "^9.
|
|
57
|
-
"eslint-config-prettier": "^10.1.
|
|
58
|
-
"eslint-plugin-prettier": "^5.5.
|
|
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.
|
|
65
|
-
"ws": "^8.18.
|
|
64
|
+
"typescript-eslint": "^8.37.0",
|
|
65
|
+
"ws": "^8.18.3"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@lzwme/fe-utils": "^1.9.0",
|