@lzwme/m3u8-dl 1.3.1 → 1.4.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/index.d.ts +1 -0
- package/cjs/index.js +1 -0
- package/cjs/lib/file-download.js +1 -1
- package/cjs/lib/format-options.d.ts +5 -1
- package/cjs/lib/format-options.js +1 -1
- package/cjs/lib/getM3u8Urls.d.ts +3 -0
- package/cjs/lib/getM3u8Urls.js +76 -0
- package/cjs/lib/m3u8-download.js +10 -10
- 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/lib/worker_pool.js +4 -0
- package/cjs/m3u8-batch-download.js +2 -2
- package/cjs/server/download-server.d.ts +1 -0
- package/cjs/server/download-server.js +87 -32
- package/cjs/video-parser/index.js +1 -1
- package/client/index.html +86 -39
- package/client/logo.png +0 -0
- package/client/style.css +8 -0
- package/package.json +11 -11
- package/client/logo.svg +0 -16
package/cjs/index.d.ts
CHANGED
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);
|
package/cjs/lib/file-download.js
CHANGED
|
@@ -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
|
|
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):
|
|
2
|
+
export declare function formatOptions(url: string, opts: M3u8DLOptions): {
|
|
3
|
+
url: string;
|
|
4
|
+
options: M3u8DLOptions;
|
|
5
|
+
urlMd5: string;
|
|
6
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
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, headers = {}, deep = 2, visited = new Set()) {
|
|
8
|
+
const req = new fe_utils_1.Request({ headers: { 'content-type': 'text/html; charset=UTF-8', referer: new URL(url).origin, ...(0, utils_js_1.formatHeaders)(headers) } });
|
|
9
|
+
const { data: html, response } = await req.get(url);
|
|
10
|
+
const m3u8Urls = new Map();
|
|
11
|
+
if (!response.statusCode || response.statusCode >= 400) {
|
|
12
|
+
utils_js_1.logger.error('获取页面失败:', fe_utils_1.color.red(url), response.statusCode, response.statusMessage, html);
|
|
13
|
+
return m3u8Urls;
|
|
14
|
+
}
|
|
15
|
+
// 从 html 中正则匹配提取 m3u8
|
|
16
|
+
const m3u8Regex = /https?:[^\s'"]+\.m3u8(\?[^\s'"]*)?/gi;
|
|
17
|
+
// 1. 直接正则匹配 m3u8 地址
|
|
18
|
+
let match = m3u8Regex.exec(html);
|
|
19
|
+
const title = (/<title>([^<]+)</.exec(html)?.[1].split('-')[0] || '').replace(/在线播放|详情|介绍|《|》/g, '').trim();
|
|
20
|
+
while (match) {
|
|
21
|
+
const href = match[0].replaceAll('\\/', '/');
|
|
22
|
+
match = m3u8Regex.exec(html);
|
|
23
|
+
if (!m3u8Urls.has(href))
|
|
24
|
+
m3u8Urls.set(href, title);
|
|
25
|
+
}
|
|
26
|
+
// 找到了多个链接,修改 title 添加序号
|
|
27
|
+
if (m3u8Urls.size > 3 && !/第.+(集|期)/.test(title)) {
|
|
28
|
+
let idx = 1;
|
|
29
|
+
for (const [key] of m3u8Urls) {
|
|
30
|
+
m3u8Urls.set(key, `${title}第${String(idx++).padStart(2, '0')}集`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// 2. 若未找到且深度大于 0,则获取所有 a 标签的 href 并递归查找
|
|
34
|
+
if (m3u8Urls.size === 0 && deep > 0) {
|
|
35
|
+
utils_js_1.logger.debug('未获取到 m3u8 地址', fe_utils_1.color.gray(url), html.length);
|
|
36
|
+
visited.add(url);
|
|
37
|
+
const aTagRegex = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi;
|
|
38
|
+
let aMatch = aTagRegex.exec(html);
|
|
39
|
+
const origin = new URL(url).origin;
|
|
40
|
+
const subPageUrls = new Map();
|
|
41
|
+
let failedSubPages = 0;
|
|
42
|
+
while (aMatch) {
|
|
43
|
+
const href = aMatch[1] ? new URL(aMatch[1], origin).toString() : '';
|
|
44
|
+
const text = aMatch[2].replace(/<[^>]+>/g, '');
|
|
45
|
+
aMatch = aTagRegex.exec(html);
|
|
46
|
+
if (!href || visited.has(href) || !href.startsWith(origin))
|
|
47
|
+
continue;
|
|
48
|
+
if (!/集|期|HD|高清|抢先|BD/.test(text))
|
|
49
|
+
continue;
|
|
50
|
+
subPageUrls.set(href, text);
|
|
51
|
+
utils_js_1.logger.debug(' > 提取到m3u8链接: ', fe_utils_1.color.gray(href), text);
|
|
52
|
+
}
|
|
53
|
+
for (const [href, text] of subPageUrls) {
|
|
54
|
+
try {
|
|
55
|
+
visited.add(href);
|
|
56
|
+
const subUrls = await getM3u8Urls(href, headers, deep - 1, visited);
|
|
57
|
+
utils_js_1.logger.debug(' > 从子页面提取: ', fe_utils_1.color.gray(href), text, subUrls.size);
|
|
58
|
+
if (subUrls.size === 0 && m3u8Urls.size === 0) {
|
|
59
|
+
failedSubPages++;
|
|
60
|
+
if (failedSubPages > 3) {
|
|
61
|
+
utils_js_1.logger.warn(`连续查找 ${failedSubPages} 个子页面均未获取到,不再继续`, url, href);
|
|
62
|
+
return m3u8Urls;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const [u, t] of subUrls)
|
|
66
|
+
m3u8Urls.set(u, subUrls.size === 1 || /第.+(集|期)/.test(t) ? t : text);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
utils_js_1.logger.warn(' > 尝试访问子页面异常: ', fe_utils_1.color.red(href), err.message);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return m3u8Urls;
|
|
74
|
+
}
|
|
75
|
+
// logger.updateOptions({ levelType: 'debug' });
|
|
76
|
+
// getM3u8Urls(process.argv.slice(2)[0]).then(d => console.log(d));
|
package/cjs/lib/m3u8-download.js
CHANGED
|
@@ -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(
|
|
90
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
}
|
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
|
}
|
package/cjs/lib/worker_pool.js
CHANGED
|
@@ -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
|
|
42
|
-
taskset.set(
|
|
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
|
|
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
|
}
|
|
@@ -197,7 +198,7 @@ class DLServer {
|
|
|
197
198
|
if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(rootDir, 'client/local/cdn'))) {
|
|
198
199
|
indexHtml = indexHtml
|
|
199
200
|
.replaceAll('https://s4.zstatic.net/ajax/libs', 'local/cdn')
|
|
200
|
-
.replaceAll(/integrity
|
|
201
|
+
.replaceAll(/integrity=".+"\n?/g, '')
|
|
201
202
|
.replace('https://cdn.tailwindcss.com/3.4.16', 'local/cdn/tailwindcss/3.4.16/tailwindcss.min.js');
|
|
202
203
|
}
|
|
203
204
|
res.setHeader('content-type', 'text/html').send(indexHtml);
|
|
@@ -248,19 +249,34 @@ class DLServer {
|
|
|
248
249
|
wss.on('listening', () => utils_js_1.logger.info(`WebSocket server listening on port ${(0, console_log_colors_1.green)(this.options.port)}`));
|
|
249
250
|
return { app, wss };
|
|
250
251
|
}
|
|
251
|
-
startDownload(url, options) {
|
|
252
|
-
|
|
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, 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
|
+
}
|
|
263
|
+
const { options: dlOptions } = (0, format_options_js_1.formatOptions)(url, { ...this.cfg.dlOptions, ...options, cacheDir: this.options.cacheDir });
|
|
253
264
|
const cacheItem = this.dlCache.get(url) || { options, dlOptions, status: 'pending', url };
|
|
254
265
|
utils_js_1.logger.debug('startDownload', url, dlOptions, cacheItem.status);
|
|
255
266
|
if (cacheItem.status === 'resume')
|
|
256
|
-
return
|
|
267
|
+
return;
|
|
257
268
|
if (cacheItem.localVideo && !(0, node_fs_1.existsSync)(cacheItem.localVideo))
|
|
258
269
|
delete cacheItem.localVideo;
|
|
270
|
+
if (cacheItem.endTime)
|
|
271
|
+
delete cacheItem.endTime;
|
|
259
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);
|
|
260
276
|
this.dlCache.set(url, cacheItem);
|
|
261
277
|
this.wsSend('progress', url);
|
|
262
278
|
if (cacheItem.status === 'pending')
|
|
263
|
-
return
|
|
279
|
+
return;
|
|
264
280
|
let workPoll = cacheItem.workPoll;
|
|
265
281
|
const opts = {
|
|
266
282
|
...dlOptions,
|
|
@@ -269,7 +285,9 @@ class DLServer {
|
|
|
269
285
|
workPoll = wp;
|
|
270
286
|
},
|
|
271
287
|
onProgress: (_finished, _total, current, stats) => {
|
|
272
|
-
const item = this.dlCache.get(url)
|
|
288
|
+
const item = this.dlCache.get(url);
|
|
289
|
+
if (!item)
|
|
290
|
+
return false; // 已删除
|
|
273
291
|
const status = item.status || 'resume';
|
|
274
292
|
Object.assign(item, { ...stats, current, options: dlOptions, status, workPoll, url });
|
|
275
293
|
this.dlCache.set(url, item);
|
|
@@ -293,12 +311,7 @@ class DLServer {
|
|
|
293
311
|
this.dlCache.set(url, item);
|
|
294
312
|
this.wsSend('progress', url);
|
|
295
313
|
this.saveCache();
|
|
296
|
-
|
|
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]);
|
|
301
|
-
}
|
|
314
|
+
this.startNextPending();
|
|
302
315
|
};
|
|
303
316
|
try {
|
|
304
317
|
if (dlOptions.type === 'parser') {
|
|
@@ -316,7 +329,14 @@ class DLServer {
|
|
|
316
329
|
afterDownload({ filepath: '', errmsg: error.message }, url);
|
|
317
330
|
utils_js_1.logger.error('下载失败:', error);
|
|
318
331
|
}
|
|
319
|
-
|
|
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
|
+
}
|
|
320
340
|
}
|
|
321
341
|
wsSend(type = 'progress', data) {
|
|
322
342
|
if (type === 'tasks' && !data) {
|
|
@@ -337,24 +357,23 @@ class DLServer {
|
|
|
337
357
|
}
|
|
338
358
|
initRouters() {
|
|
339
359
|
const { app } = this;
|
|
340
|
-
// health check
|
|
341
360
|
app.get('/healthcheck', (_req, res) => {
|
|
342
361
|
res.json({ message: 'ok', code: 0 });
|
|
343
362
|
});
|
|
344
|
-
app.post('/config', (req, res) => {
|
|
363
|
+
app.post('/api/config', (req, res) => {
|
|
345
364
|
const config = req.body;
|
|
346
365
|
this.saveConfig(config);
|
|
347
366
|
res.json({ message: 'Config updated successfully', code: 0 });
|
|
348
367
|
});
|
|
349
|
-
app.get('/config', (_req, res) => {
|
|
368
|
+
app.get('/api/config', (_req, res) => {
|
|
350
369
|
res.json({ ...this.cfg.dlOptions, ...this.cfg.webOptions });
|
|
351
370
|
});
|
|
352
371
|
// API to get all download progress
|
|
353
|
-
app.get('/tasks', (_req, res) => {
|
|
372
|
+
app.get('/api/tasks', (_req, res) => {
|
|
354
373
|
res.json(Object.fromEntries(this.dlCacheClone()));
|
|
355
374
|
});
|
|
356
375
|
// API to get queue status
|
|
357
|
-
app.get('/queue/status', (_req, res) => {
|
|
376
|
+
app.get('/api/queue/status', (_req, res) => {
|
|
358
377
|
const pendingTasks = Array.from(this.dlCache.entries()).filter(([_, item]) => item.status === 'pending');
|
|
359
378
|
const activeTasks = Array.from(this.dlCache.entries()).filter(([_, item]) => item.status === 'resume');
|
|
360
379
|
res.json({
|
|
@@ -364,7 +383,7 @@ class DLServer {
|
|
|
364
383
|
});
|
|
365
384
|
});
|
|
366
385
|
// API to clear queue
|
|
367
|
-
app.post('/queue/clear', (_req, res) => {
|
|
386
|
+
app.post('/api/queue/clear', (_req, res) => {
|
|
368
387
|
let count = 0;
|
|
369
388
|
for (const [url, item] of this.dlCache.entries()) {
|
|
370
389
|
if (item.status === 'pending') {
|
|
@@ -377,7 +396,7 @@ class DLServer {
|
|
|
377
396
|
res.json({ message: `已清空 ${count} 个等待中的下载任务`, code: 0 });
|
|
378
397
|
});
|
|
379
398
|
// API to update task priority
|
|
380
|
-
app.post('/priority', (req, res) => {
|
|
399
|
+
app.post('/api/priority', (req, res) => {
|
|
381
400
|
const { url, priority } = req.body;
|
|
382
401
|
const item = this.dlCache.get(url);
|
|
383
402
|
if (!item) {
|
|
@@ -389,7 +408,7 @@ class DLServer {
|
|
|
389
408
|
res.json({ message: '已更新任务优先级', code: 0 });
|
|
390
409
|
});
|
|
391
410
|
// API to start m3u8 download
|
|
392
|
-
app.post('/download', (req, res) => {
|
|
411
|
+
app.post('/api/download', (req, res) => {
|
|
393
412
|
const { url, options = {}, list = [] } = req.body;
|
|
394
413
|
try {
|
|
395
414
|
if (list.length) {
|
|
@@ -409,7 +428,7 @@ class DLServer {
|
|
|
409
428
|
}
|
|
410
429
|
});
|
|
411
430
|
// API to pause download
|
|
412
|
-
app.post('/pause', (req, res) => {
|
|
431
|
+
app.post('/api/pause', (req, res) => {
|
|
413
432
|
const { urls, all = false } = req.body;
|
|
414
433
|
const urlsToPause = all ? [...this.dlCache.keys()] : urls;
|
|
415
434
|
const list = [];
|
|
@@ -417,17 +436,19 @@ class DLServer {
|
|
|
417
436
|
const item = this.dlCache.get(url);
|
|
418
437
|
if (['resume', 'pending'].includes(item?.status)) {
|
|
419
438
|
(0, m3u8_download_js_1.m3u8DLStop)(url, item.workPoll);
|
|
420
|
-
item.status = item.tsSuccess === item.tsCount ? 'done' : 'pause';
|
|
439
|
+
item.status = item.tsSuccess > 0 && item.tsSuccess === item.tsCount ? 'done' : 'pause';
|
|
421
440
|
const { workPoll, ...tItem } = item;
|
|
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
|
|
430
|
-
app.post('/resume', (req, res) => {
|
|
451
|
+
app.post('/api/resume', (req, res) => {
|
|
431
452
|
const { urls, all = false } = req.body;
|
|
432
453
|
const urlsToResume = all ? [...this.dlCache.keys()] : urls;
|
|
433
454
|
const list = [];
|
|
@@ -446,7 +467,7 @@ class DLServer {
|
|
|
446
467
|
res.json({ message: list.length ? `已恢复 ${list.length} 个下载任务` : '没有找到可恢复的下载任务', code: 0, count: list.length });
|
|
447
468
|
});
|
|
448
469
|
// API to delete download
|
|
449
|
-
app.post('/delete', (req, res) => {
|
|
470
|
+
app.post('/api/delete', (req, res) => {
|
|
450
471
|
const { urls, deleteCache = false, deleteVideo = false } = req.body;
|
|
451
472
|
const urlsToDelete = urls;
|
|
452
473
|
const list = [];
|
|
@@ -458,20 +479,27 @@ class DLServer {
|
|
|
458
479
|
list.push(item.url);
|
|
459
480
|
if (deleteCache && item.current?.tsOut) {
|
|
460
481
|
const cacheDir = (0, node_path_1.dirname)(item.current.tsOut);
|
|
461
|
-
if ((0, node_fs_1.existsSync)(cacheDir))
|
|
482
|
+
if ((0, node_fs_1.existsSync)(cacheDir)) {
|
|
462
483
|
(0, node_fs_1.rmSync)(cacheDir, { recursive: true });
|
|
484
|
+
utils_js_1.logger.debug('删除缓存目录:', cacheDir);
|
|
485
|
+
}
|
|
463
486
|
}
|
|
464
487
|
if (deleteVideo) {
|
|
465
488
|
['.ts', '.mp4'].forEach(ext => {
|
|
466
489
|
const filepath = (0, node_path_1.resolve)(item.options.saveDir, item.options.filename + ext);
|
|
467
|
-
if ((0, node_fs_1.existsSync)(filepath))
|
|
490
|
+
if ((0, node_fs_1.existsSync)(filepath)) {
|
|
468
491
|
(0, node_fs_1.unlinkSync)(filepath);
|
|
492
|
+
utils_js_1.logger.debug('删除文件:', filepath);
|
|
493
|
+
}
|
|
469
494
|
});
|
|
470
495
|
}
|
|
471
496
|
}
|
|
472
497
|
}
|
|
473
|
-
if (list.length)
|
|
498
|
+
if (list.length) {
|
|
474
499
|
this.wsSend('delete', list);
|
|
500
|
+
this.saveCache();
|
|
501
|
+
this.startNextPending();
|
|
502
|
+
}
|
|
475
503
|
res.json({ message: `已删除 ${list.length} 个下载任务`, code: 0, count: list.length });
|
|
476
504
|
});
|
|
477
505
|
app.get(/^\/localplay\/(.*)$/, (req, res) => {
|
|
@@ -512,13 +540,40 @@ class DLServer {
|
|
|
512
540
|
'Content-Length': String(stats.size),
|
|
513
541
|
'Content-Type': ext === 'ts' ? 'video/mp2t' : ext === 'm3u8' ? 'application/vnd.apple.mpegurl' : ext === 'mp4' ? 'video/mp4' : 'text/plain',
|
|
514
542
|
});
|
|
515
|
-
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
|
+
}
|
|
516
560
|
return;
|
|
517
561
|
}
|
|
518
562
|
}
|
|
519
|
-
utils_js_1.logger.error('Localplay
|
|
563
|
+
utils_js_1.logger.error('[Localplay]file not found:', (0, console_log_colors_1.red)(filepath));
|
|
520
564
|
res.status(404).send({ message: 'Not Found', code: 404 });
|
|
521
565
|
});
|
|
566
|
+
app.post('/api/getM3u8Urls', (req, res) => {
|
|
567
|
+
const { url, headers } = req.body;
|
|
568
|
+
if (!url) {
|
|
569
|
+
res.json({ code: 1001, message: '无效的 url 参数' });
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
(0, getM3u8Urls_js_1.getM3u8Urls)(url, headers)
|
|
573
|
+
.then(d => res.json({ code: 0, data: Array.from(d) }))
|
|
574
|
+
.catch(err => res.json({ code: 401, message: err.message }));
|
|
575
|
+
}
|
|
576
|
+
});
|
|
522
577
|
}
|
|
523
578
|
}
|
|
524
579
|
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/
|
|
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.
|
|
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"
|
|
@@ -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'"
|
|
@@ -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: '取消' });
|
|
@@ -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') {
|
|
@@ -790,7 +791,7 @@ services:
|
|
|
790
791
|
},
|
|
791
792
|
/** 获取配置 */
|
|
792
793
|
fetchConfig: async function () {
|
|
793
|
-
const config = await T.get('/config');
|
|
794
|
+
const config = await T.get('/api/config');
|
|
794
795
|
if (config.code) {
|
|
795
796
|
console.error('获取配置失败:', config);
|
|
796
797
|
T.alert('获取配置失败: ' + config.message, { icon: 'error' });
|
|
@@ -803,7 +804,7 @@ services:
|
|
|
803
804
|
},
|
|
804
805
|
/** 更新配置 */
|
|
805
806
|
updateConfig: async function () {
|
|
806
|
-
const result = await T.post('/config', this.config);
|
|
807
|
+
const result = await T.post('/api/config', this.config);
|
|
807
808
|
T.toast(result.message || '配置已更新', { icon: result.code ? 'error' : 'success' });
|
|
808
809
|
},
|
|
809
810
|
updateLocalConfig: async function () {
|
|
@@ -824,24 +825,37 @@ 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
|
-
|
|
834
|
+
<div class="flex flex-row gap-4">
|
|
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="">
|
|
836
|
+
<div class="flex flex-row gap-1">
|
|
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">
|
|
838
|
+
提取
|
|
839
|
+
</button>
|
|
840
|
+
</div>
|
|
841
|
+
</div>
|
|
835
842
|
|
|
836
843
|
<div class="mt-4">
|
|
837
|
-
<label class="block text-sm font-bold text-gray-700 mb-1"
|
|
838
|
-
<
|
|
839
|
-
<p class="mt-1 text-sm text-gray-500">若输入多个链接,将依次以"视频名称+第N集"命名</p>
|
|
844
|
+
<label class="block text-sm font-bold text-gray-700 mb-1">视频链接(每行一个,支持m3u8地址及抖音、微博、皮皮虾视频分享链接)</label>
|
|
845
|
+
<textarea id="downloadUrls" class="w-full p-2 border rounded-lg focus:ring-blue-500" rows="3" placeholder="格式: URL | 名称(可选)、名称 | URL"></textarea>
|
|
840
846
|
</div>
|
|
841
847
|
|
|
842
848
|
<div class="mt-4">
|
|
849
|
+
<div class="flex flex-row gap-2 items-center">
|
|
850
|
+
<label class="block text-sm font-bold text-gray-700 mb-1">视频名称</label>
|
|
851
|
+
<input id="filename" class="flex-1 p-2 border rounded-lg focus:border-blue-500" placeholder="请输入视频名称(可选)">
|
|
852
|
+
</div>
|
|
853
|
+
<p class="ml-2 mt-1 text-sm text-gray-500">若输入多个链接,将依次以"视频名称+第N集"命名</p>
|
|
854
|
+
</div>
|
|
855
|
+
|
|
856
|
+
<div class="mt-4 flex flex-row gap-2 items-center">
|
|
843
857
|
<label class="block text-sm font-bold text-gray-700 mb-1">保存位置</label>
|
|
844
|
-
<input id="saveDir" class="
|
|
858
|
+
<input id="saveDir" class="flex-1 p-2 border rounded-lg focus:ring-blue-500" placeholder="请输入保存路径" value="${this.config.saveDir || ''}">
|
|
845
859
|
</div>
|
|
846
860
|
|
|
847
861
|
<div class="mt-4">
|
|
@@ -851,7 +865,7 @@ services:
|
|
|
851
865
|
|
|
852
866
|
<div class="mt-4">
|
|
853
867
|
<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="
|
|
868
|
+
<textarea id="headers" class="w-full p-2 border rounded-lg focus:ring-blue-500" rows="3" placeholder="每行一个(微博视频须设置 Cookie),格式:Key: Value。例如: Referer: https://example.com Cookie: token=123"></textarea>
|
|
855
869
|
</div>
|
|
856
870
|
</div>
|
|
857
871
|
`,
|
|
@@ -864,8 +878,8 @@ services:
|
|
|
864
878
|
preConfirm: () => {
|
|
865
879
|
const urlsText = document.getElementById('downloadUrls').value.trim();
|
|
866
880
|
const filename = document.getElementById('filename').value.trim();
|
|
867
|
-
|
|
868
|
-
const
|
|
881
|
+
let saveDir = document.getElementById('saveDir').value.trim();
|
|
882
|
+
const headers = document.getElementById('headers').value.trim();
|
|
869
883
|
const ignoreSegments = document.getElementById('ignoreSegments').value.trim();
|
|
870
884
|
|
|
871
885
|
if (!urlsText) {
|
|
@@ -886,9 +900,14 @@ services:
|
|
|
886
900
|
return false;
|
|
887
901
|
}
|
|
888
902
|
|
|
903
|
+
if (urls.length > 1 && filename && !saveDir.includes(filename)) {
|
|
904
|
+
if (!saveDir) saveDir = this.config.saveDir;
|
|
905
|
+
saveDir = saveDir.replace(/\/?$/, '') + '/' + filename;
|
|
906
|
+
}
|
|
907
|
+
|
|
889
908
|
return urls.map((item, idx) => ({
|
|
890
909
|
url: item.url,
|
|
891
|
-
filename: item.name || (filename ? `${filename}
|
|
910
|
+
filename: item.name || (filename ? `${filename}${urls.length > 1 ? `第${idx + 1}集` : ''}` : ''),
|
|
892
911
|
saveDir,
|
|
893
912
|
headers,
|
|
894
913
|
ignoreSegments,
|
|
@@ -897,15 +916,40 @@ services:
|
|
|
897
916
|
}).then((result) => {
|
|
898
917
|
if (result.isConfirmed) this.startBatchDownload(result.value);
|
|
899
918
|
});
|
|
919
|
+
|
|
920
|
+
setTimeout(() => {
|
|
921
|
+
const btn = document.getElementById('getM3u8UrlsBtn');
|
|
922
|
+
if (!btn) return;
|
|
923
|
+
|
|
924
|
+
btn.addEventListener('click', async () => {
|
|
925
|
+
const url = document.getElementById('playUrl').value.trim();
|
|
926
|
+
if (!/^https?:/.test(url)) {
|
|
927
|
+
return Swal.showValidationMessage('请输入正确的 URL 地址');
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
btn.setAttribute('disabled', 'disabled');
|
|
931
|
+
btn.innerText = '解析中...';
|
|
932
|
+
|
|
933
|
+
T.post('/api/getM3u8Urls', { url, headers: document.getElementById('headers').value.trim() }).then(r => {
|
|
934
|
+
if (Array.isArray(r.data)) {
|
|
935
|
+
document.getElementById('downloadUrls').value = r.data.map(d => d.join(' | ')).join('\n');
|
|
936
|
+
T.toast(r.message || `解析完成!获取到 ${r.data.length} 个地址`);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
btn.removeAttribute('disabled');
|
|
940
|
+
btn.innerText = '提取';
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
}, 500);
|
|
900
944
|
},
|
|
901
945
|
/** 批量下载 */
|
|
902
946
|
startBatchDownload: async function (list) {
|
|
903
947
|
try {
|
|
904
948
|
list.forEach(async (item, idx) => {
|
|
905
949
|
Object.entries(item).forEach(([key, value]) => !value && delete item[key]);
|
|
906
|
-
this.tasks[item.url] = { status: 'resume', progress: 0, speed: 0, remainingTime: 0, size: 0 };
|
|
950
|
+
if (!/\.html?$/.test(item.url)) this.tasks[item.url] = { status: 'resume', progress: 0, speed: 0, remainingTime: 0, size: 0 };
|
|
907
951
|
});
|
|
908
|
-
const r = await T.post('/download', { list });
|
|
952
|
+
const r = await T.post('/api/download', { list });
|
|
909
953
|
if (!r.code) T.toast(r.message || '批量下载已开始');
|
|
910
954
|
this.forceUpdate();
|
|
911
955
|
} catch (error) {
|
|
@@ -917,7 +961,7 @@ services:
|
|
|
917
961
|
pauseDownload: async function (urls) {
|
|
918
962
|
if (!urls) urls = this.selectedTasks;
|
|
919
963
|
if (typeof urls === 'string') urls = [urls];
|
|
920
|
-
const r = await T.post(
|
|
964
|
+
const r = await T.post('/api/pause', { urls, all: urls[0] === 'all' });
|
|
921
965
|
if (!r.code) T.toast(r.message || '已暂停下载');
|
|
922
966
|
if (urls === this.selectedTasks) this.selectedTasks = [];
|
|
923
967
|
},
|
|
@@ -925,7 +969,7 @@ services:
|
|
|
925
969
|
resumeDownload: async function (urls) {
|
|
926
970
|
if (!urls) urls = this.selectedTasks;
|
|
927
971
|
if (typeof urls === 'string') urls = [urls];
|
|
928
|
-
const r = await T.post(
|
|
972
|
+
const r = await T.post('/api/resume', { urls, all: urls[0] === 'all' });
|
|
929
973
|
if (!r.code) T.toast(r.message || '已恢复下载');
|
|
930
974
|
if (urls === this.selectedTasks) this.selectedTasks = [];
|
|
931
975
|
},
|
|
@@ -965,7 +1009,7 @@ services:
|
|
|
965
1009
|
});
|
|
966
1010
|
|
|
967
1011
|
if (result.isConfirmed) {
|
|
968
|
-
const r = await T.post(
|
|
1012
|
+
const r = await T.post('/api/delete', {
|
|
969
1013
|
urls,
|
|
970
1014
|
deleteCache: result.value.deleteCache,
|
|
971
1015
|
deleteVideo: result.value.deleteVideo
|
|
@@ -984,28 +1028,30 @@ services:
|
|
|
984
1028
|
}
|
|
985
1029
|
},
|
|
986
1030
|
getTasks: async function () {
|
|
987
|
-
this.tasks = await T.get('/tasks');
|
|
1031
|
+
this.tasks = await T.get('/api/tasks');
|
|
988
1032
|
},
|
|
989
1033
|
showTaskDetail(task) {
|
|
990
1034
|
console.log(task);
|
|
1035
|
+
const isResume = task.status === 'resume';
|
|
991
1036
|
const taskInfo = {
|
|
992
1037
|
名称: task.filename || task.localVideo,
|
|
993
1038
|
状态: T.taskStatus[task.status] || task.status,
|
|
994
|
-
大小: `${T.formatSize(task.downloadedSize)} / ${task.size ? T.formatSize(task.size) : ''}`,
|
|
995
|
-
分片: `<span class=text-green-600>${task.tsSuccess}</span> / <span class=text-red-500>${task.tsFailed}</span> / ${task.tsCount}
|
|
996
|
-
进度: `${task.progress}%`,
|
|
997
|
-
平均速度: `${task.avgSpeedDesc}/s`,
|
|
1039
|
+
大小: `${T.formatSize(task.downloadedSize || 0)} / ${task.size ? T.formatSize(task.size) : ''}`,
|
|
1040
|
+
分片: task.tsCount ? `<span class=text-green-600>${task.tsSuccess}</span> / <span class=text-red-500>${task.tsFailed}</span> / ${task.tsCount}` : '-',
|
|
1041
|
+
进度: `${task.progress || '-'}%`,
|
|
1042
|
+
平均速度: `${task.avgSpeedDesc || '-'}/s`,
|
|
998
1043
|
并发线程: task.threadNum,
|
|
999
1044
|
下载地址: task.url,
|
|
1000
1045
|
保存位置: task.localVideo || task.options?.saveDir,
|
|
1001
|
-
开始时间: new Date(task.startTime).toLocaleString(),
|
|
1002
|
-
结束时间: task.endTime && new Date(task.endTime).toLocaleString(),
|
|
1003
|
-
预估还需:
|
|
1046
|
+
开始时间: task.startTime && new Date(task.startTime).toLocaleString(),
|
|
1047
|
+
结束时间: !isResume && task.endTime && new Date(task.endTime).toLocaleString(),
|
|
1048
|
+
预估还需: isResume && task.remainingTime && T.formatTimeCost(task.remainingTime),
|
|
1004
1049
|
相关信息: task.errmsg && `<span class=text-red-600>${task.errmsg}</span>`
|
|
1005
1050
|
};
|
|
1006
|
-
|
|
1051
|
+
T.alert({
|
|
1007
1052
|
title: '任务详情',
|
|
1008
1053
|
width: 1000,
|
|
1054
|
+
icon: '',
|
|
1009
1055
|
html: [
|
|
1010
1056
|
'<div class="flex-col full-width text-left">',
|
|
1011
1057
|
Object.entries(taskInfo).filter(d => d[1]).map(
|
|
@@ -1018,7 +1064,8 @@ services:
|
|
|
1018
1064
|
},
|
|
1019
1065
|
/** 边下边播 */
|
|
1020
1066
|
localPlay: function (task) {
|
|
1021
|
-
const
|
|
1067
|
+
const filepath = task.localVideo || task.localM3u8;
|
|
1068
|
+
const url = location.origin + `/localplay/${encodeURIComponent(filepath)}`;
|
|
1022
1069
|
console.log(task);
|
|
1023
1070
|
Swal.fire({
|
|
1024
1071
|
title: task?.options.filename || task.url,
|
|
@@ -1054,12 +1101,12 @@ services:
|
|
|
1054
1101
|
},
|
|
1055
1102
|
// 更新任务优先级
|
|
1056
1103
|
updatePriority: async function (url, priority) {
|
|
1057
|
-
const r = await T.post('/priority', { url, priority: parseInt(priority) });
|
|
1104
|
+
const r = await T.post('/api/priority', { url, priority: parseInt(priority) });
|
|
1058
1105
|
T.toast(r.message || '已更新优先级');
|
|
1059
1106
|
},
|
|
1060
1107
|
// 获取队列状态
|
|
1061
1108
|
getQueueStatus: async function () {
|
|
1062
|
-
const status = await T.get('/queue/status');
|
|
1109
|
+
const status = await T.get('/api/queue/status');
|
|
1063
1110
|
if (status?.maxConcurrent) this.queueStatus = status;
|
|
1064
1111
|
},
|
|
1065
1112
|
// 清空下载队列
|
|
@@ -1075,7 +1122,7 @@ services:
|
|
|
1075
1122
|
});
|
|
1076
1123
|
|
|
1077
1124
|
if (result.isConfirmed) {
|
|
1078
|
-
const r = await T.post('/queue/clear');
|
|
1125
|
+
const r = await T.post('/api/queue/clear');
|
|
1079
1126
|
T.toast(r.message || '已清空下载队列');
|
|
1080
1127
|
this.getQueueStatus();
|
|
1081
1128
|
}
|
package/client/logo.png
ADDED
|
Binary file
|
package/client/style.css
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lzwme/m3u8-dl",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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,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.1",
|
|
48
|
+
"@eslint/js": "^9.30.1",
|
|
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.12",
|
|
53
53
|
"@types/ws": "^8.18.1",
|
|
54
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
55
|
-
"@typescript-eslint/parser": "^8.
|
|
56
|
-
"eslint": "^9.
|
|
54
|
+
"@typescript-eslint/eslint-plugin": "^8.36.0",
|
|
55
|
+
"@typescript-eslint/parser": "^8.36.0",
|
|
56
|
+
"eslint": "^9.30.1",
|
|
57
57
|
"eslint-config-prettier": "^10.1.5",
|
|
58
|
-
"eslint-plugin-prettier": "^5.5.
|
|
58
|
+
"eslint-plugin-prettier": "^5.5.1",
|
|
59
59
|
"express": "^5.1.0",
|
|
60
60
|
"husky": "^9.1.7",
|
|
61
|
-
"prettier": "^3.
|
|
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.36.0",
|
|
65
|
+
"ws": "^8.18.3"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@lzwme/fe-utils": "^1.9.0",
|
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>
|