@lzwme/m3u8-dl 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.MD +8 -8
- package/README.zh-CN.md +8 -8
- package/cjs/i18n/locales/en.d.ts +9 -0
- package/cjs/i18n/locales/en.js +9 -0
- package/cjs/i18n/locales/zh-CN.d.ts +9 -0
- package/cjs/i18n/locales/zh-CN.js +9 -0
- package/cjs/index.d.ts +1 -0
- package/cjs/index.js +1 -0
- package/cjs/lib/file-download.js +2 -2
- package/cjs/lib/format-options.d.ts +2 -2
- package/cjs/lib/format-options.js +13 -1
- package/cjs/lib/init-proxy.d.ts +5 -0
- package/cjs/lib/init-proxy.js +93 -0
- package/cjs/lib/local-play.d.ts +1 -1
- package/cjs/lib/local-play.js +1 -1
- package/cjs/lib/m3u8-convert.js +2 -8
- package/cjs/lib/m3u8-download.js +4 -3
- package/cjs/lib/parseM3u8.js +1 -1
- package/cjs/lib/utils.d.ts +4 -2
- package/cjs/lib/utils.js +38 -2
- package/cjs/lib/worker_pool.d.ts +1 -1
- package/cjs/m3u8-batch-download.js +2 -3
- package/cjs/server/download-server.d.ts +11 -4
- package/cjs/server/download-server.js +251 -79
- package/cjs/types/m3u8.d.ts +6 -0
- package/cjs/video-parser/index.d.ts +3 -3
- package/cjs/video-parser/index.js +5 -5
- package/client/assets/main-BC3ZZLoF.css +1 -0
- package/client/assets/main-BWzfTVAm.js +35 -0
- package/client/index.html +2 -2
- package/client/m3u8-capture.user.js +51 -21
- package/client/play.html +81 -31
- package/package.json +23 -16
- package/client/assets/main-ChJ1yjNN.css +0 -1
- package/client/assets/main-DZTEqg-V.js +0 -29
|
@@ -43,6 +43,7 @@ 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 getM3u8Urls_js_1 = require("../lib/getM3u8Urls.js");
|
|
45
45
|
const i18n_js_1 = require("../lib/i18n.js");
|
|
46
|
+
const init_proxy_js_1 = require("../lib/init-proxy.js");
|
|
46
47
|
const m3u8_download_js_1 = require("../lib/m3u8-download.js");
|
|
47
48
|
const utils_js_1 = require("../lib/utils.js");
|
|
48
49
|
const index_js_1 = require("../video-parser/index.js");
|
|
@@ -60,7 +61,7 @@ class DLServer {
|
|
|
60
61
|
};
|
|
61
62
|
serverInfo = {
|
|
62
63
|
version: '',
|
|
63
|
-
ariang:
|
|
64
|
+
ariang: false,
|
|
64
65
|
};
|
|
65
66
|
cfg = {
|
|
66
67
|
/** 支持 web 设置修改的参数 */
|
|
@@ -78,6 +79,10 @@ class DLServer {
|
|
|
78
79
|
saveDir: process.env.DS_SAVE_DIR || './downloads',
|
|
79
80
|
threadNum: 4,
|
|
80
81
|
ffmpegPath: process.env.DS_FFMPEG_PATH || undefined,
|
|
82
|
+
// 代理配置改为字符串模式:'custom', 'system', 'disabled'
|
|
83
|
+
proxyMode: process.env.DS_PROXY_MODE || 'system',
|
|
84
|
+
proxyUrl: process.env.DS_PROXY_URL || undefined,
|
|
85
|
+
noProxy: process.env.DS_NO_PROXY || undefined,
|
|
81
86
|
},
|
|
82
87
|
};
|
|
83
88
|
/** 下载任务缓存 */
|
|
@@ -91,28 +96,31 @@ class DLServer {
|
|
|
91
96
|
opts.cacheDir = (0, node_path_1.resolve)(opts.cacheDir);
|
|
92
97
|
if (!opts.configPath)
|
|
93
98
|
opts.configPath = (0, node_path_1.resolve)(opts.cacheDir, 'config.json');
|
|
94
|
-
const pkgFile = (0, node_path_1.resolve)(rootDir, 'package.json');
|
|
95
|
-
if ((0, node_fs_1.existsSync)(pkgFile)) {
|
|
96
|
-
const pkg = JSON.parse((0, node_fs_1.readFileSync)(pkgFile, 'utf8'));
|
|
97
|
-
this.serverInfo.version = pkg.version;
|
|
98
|
-
}
|
|
99
99
|
if (opts.token)
|
|
100
100
|
opts.token = (0, fe_utils_1.md5)(opts.token.trim()).slice(0, 8);
|
|
101
101
|
this.init();
|
|
102
102
|
}
|
|
103
103
|
async init() {
|
|
104
|
-
|
|
104
|
+
const pkgFile = (0, node_path_1.resolve)(rootDir, 'package.json');
|
|
105
|
+
if (await (0, utils_js_1.checkFileExists)(pkgFile)) {
|
|
106
|
+
const pkg = JSON.parse(await node_fs_1.promises.readFile(pkgFile, 'utf8'));
|
|
107
|
+
this.serverInfo.version = pkg.version;
|
|
108
|
+
}
|
|
109
|
+
this.serverInfo.ariang = await (0, utils_js_1.checkFileExists)((0, node_path_1.resolve)(rootDir, 'client/ariang/index.html'));
|
|
110
|
+
await this.loadConfig();
|
|
105
111
|
if (this.cfg.dlOptions.debug)
|
|
106
112
|
utils_js_1.logger.updateOptions({ levelType: 'debug' });
|
|
107
|
-
this.loadCache();
|
|
113
|
+
await this.loadCache();
|
|
108
114
|
await this.createApp();
|
|
109
115
|
this.initRouters();
|
|
110
116
|
utils_js_1.logger.debug('Server initialized', 'cacheSize:', this.dlCache.size, this.options, this.cfg.dlOptions);
|
|
117
|
+
// 初始化 global-agent 代理
|
|
118
|
+
(0, init_proxy_js_1.initProxy)(this.cfg.dlOptions);
|
|
111
119
|
}
|
|
112
|
-
loadCache() {
|
|
120
|
+
async loadCache() {
|
|
113
121
|
const cacheFile = (0, node_path_1.resolve)(this.options.cacheDir, 'cache.json');
|
|
114
|
-
if ((0,
|
|
115
|
-
JSON.parse(
|
|
122
|
+
if (await (0, utils_js_1.checkFileExists)(cacheFile)) {
|
|
123
|
+
JSON.parse(await node_fs_1.promises.readFile(cacheFile, 'utf8')).forEach(([url, item]) => {
|
|
116
124
|
if (item.status === 'resume')
|
|
117
125
|
item.status = 'pause';
|
|
118
126
|
this.dlCache.set(url, item);
|
|
@@ -122,20 +130,39 @@ class DLServer {
|
|
|
122
130
|
}
|
|
123
131
|
checkDLFileLaest = 0;
|
|
124
132
|
checkDLFileTimer = null;
|
|
125
|
-
checkDLFileIsExists() {
|
|
133
|
+
async checkDLFileIsExists() {
|
|
126
134
|
const now = Date.now();
|
|
127
135
|
const interval = 1000 * 60;
|
|
128
136
|
clearTimeout(this.checkDLFileTimer);
|
|
129
|
-
|
|
130
|
-
|
|
137
|
+
const delay = this.checkDLFileLaest + interval - now;
|
|
138
|
+
if (delay > 0) {
|
|
139
|
+
this.checkDLFileTimer = setTimeout(() => {
|
|
140
|
+
this.checkDLFileIsExists();
|
|
141
|
+
}, delay + 100);
|
|
131
142
|
return;
|
|
132
143
|
}
|
|
133
|
-
this.dlCache.
|
|
134
|
-
|
|
144
|
+
const tasks = [...this.dlCache.values()].map(item => () => this.checkItemStatus(item));
|
|
145
|
+
await (0, fe_utils_1.concurrency)(tasks, 3);
|
|
146
|
+
this.checkDLFileLaest = now;
|
|
147
|
+
}
|
|
148
|
+
async checkItemStatus(item) {
|
|
149
|
+
if (item.status === 'done') {
|
|
150
|
+
if (item.current) {
|
|
151
|
+
if (!item.cacheDir && item.current.tsOut)
|
|
152
|
+
item.cacheDir = (0, node_path_1.dirname)(item.current.tsOut);
|
|
153
|
+
delete item.current;
|
|
154
|
+
}
|
|
155
|
+
if (!(await (0, utils_js_1.checkFileExists)(item.localVideo)) && !(0, node_fs_1.existsSync)(item.localVideo)) {
|
|
135
156
|
item.status = 'error';
|
|
136
157
|
item.errmsg = '已删除';
|
|
137
158
|
}
|
|
138
|
-
}
|
|
159
|
+
}
|
|
160
|
+
else if (item.status === 'error' && item.progress === 100) {
|
|
161
|
+
if (await (0, utils_js_1.checkFileExists)(item.localVideo)) {
|
|
162
|
+
item.status = 'done';
|
|
163
|
+
delete item.errmsg;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
139
166
|
}
|
|
140
167
|
dlCacheClone() {
|
|
141
168
|
const info = [];
|
|
@@ -154,34 +181,32 @@ class DLServer {
|
|
|
154
181
|
clearTimeout(this.cacheSaveTimer);
|
|
155
182
|
this.cacheSaveTimer = setTimeout(() => {
|
|
156
183
|
const cacheFile = (0, node_path_1.resolve)(this.options.cacheDir, 'cache.json');
|
|
157
|
-
const info = this.dlCacheClone();
|
|
158
184
|
(0, fe_utils_1.mkdirp)((0, node_path_1.dirname)(cacheFile));
|
|
159
|
-
|
|
185
|
+
node_fs_1.promises.writeFile(cacheFile, JSON.stringify(this.dlCacheClone()));
|
|
160
186
|
}, 1000);
|
|
161
187
|
}
|
|
162
|
-
|
|
188
|
+
async loadConfig(configPath) {
|
|
163
189
|
try {
|
|
164
190
|
if (!configPath)
|
|
165
191
|
configPath = this.options.configPath;
|
|
166
|
-
if ((0,
|
|
167
|
-
(0, fe_utils_1.assign)(this.cfg, JSON.parse(
|
|
192
|
+
if (await (0, utils_js_1.checkFileExists)(configPath))
|
|
193
|
+
(0, fe_utils_1.assign)(this.cfg, JSON.parse(await node_fs_1.promises.readFile(configPath, 'utf8')));
|
|
168
194
|
}
|
|
169
195
|
catch (error) {
|
|
170
|
-
utils_js_1.logger.error('
|
|
196
|
+
utils_js_1.logger.error('Load config failed:', error);
|
|
171
197
|
}
|
|
172
|
-
return this.cfg.dlOptions;
|
|
173
198
|
}
|
|
174
|
-
saveConfig(config, configPath) {
|
|
199
|
+
async saveConfig(config, configPath) {
|
|
175
200
|
if (!configPath)
|
|
176
201
|
configPath = this.options.configPath;
|
|
177
202
|
// 验证 ffmpegPath 是否存在
|
|
178
203
|
if (config.ffmpegPath?.trim()) {
|
|
179
204
|
const ffmpegPath = config.ffmpegPath.trim();
|
|
180
|
-
if (!(0,
|
|
205
|
+
if (!(await (0, utils_js_1.checkFileExists)(ffmpegPath))) {
|
|
181
206
|
throw new Error(`ffmpeg 路径不存在: ${ffmpegPath}`);
|
|
182
207
|
}
|
|
183
208
|
// 检查是否为文件(不是目录)
|
|
184
|
-
const stats =
|
|
209
|
+
const stats = await node_fs_1.promises.stat(ffmpegPath);
|
|
185
210
|
if (!stats.isFile()) {
|
|
186
211
|
throw new Error(`ffmpeg 路径不是文件: ${ffmpegPath}`);
|
|
187
212
|
}
|
|
@@ -195,7 +220,10 @@ class DLServer {
|
|
|
195
220
|
this.cfg.dlOptions[key] = value;
|
|
196
221
|
}
|
|
197
222
|
(0, fe_utils_1.mkdirp)((0, node_path_1.dirname)(configPath));
|
|
198
|
-
|
|
223
|
+
const result = await node_fs_1.promises.writeFile(configPath, JSON.stringify(this.cfg, null, 2));
|
|
224
|
+
// 重新初始化代理
|
|
225
|
+
await (0, init_proxy_js_1.initProxy)(this.cfg.dlOptions);
|
|
226
|
+
return result;
|
|
199
227
|
}
|
|
200
228
|
async createApp() {
|
|
201
229
|
const { default: express } = await Promise.resolve().then(() => __importStar(require('express')));
|
|
@@ -203,17 +231,17 @@ class DLServer {
|
|
|
203
231
|
const app = express();
|
|
204
232
|
const server = app.listen(this.options.port, () => utils_js_1.logger.info(`Server running on port ${(0, console_log_colors_1.green)(this.options.port)}`));
|
|
205
233
|
const wss = new WebSocketServer({ server });
|
|
234
|
+
const hasLocalCdnDir = await (0, utils_js_1.checkFileExists)((0, node_path_1.resolve)(rootDir, 'client/local/cdn'));
|
|
206
235
|
this.app = app;
|
|
207
236
|
this.wss = wss;
|
|
208
|
-
app.use((req, res, next) => {
|
|
237
|
+
app.use(async (req, res, next) => {
|
|
209
238
|
// 处理 SPA 路由:根路径和 /page/* 路径都返回 index.html
|
|
210
239
|
const isIndexPage = ['/', '/index.html'].includes(req.path) || req.path.startsWith('/page/');
|
|
211
240
|
const isPlayPage = req.path.startsWith('/play.html');
|
|
212
241
|
const isApi = req.path.startsWith('/api/');
|
|
213
242
|
if (!isApi && (isIndexPage || isPlayPage)) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(rootDir, 'client/local/cdn'))) {
|
|
243
|
+
let htmlContent = await node_fs_1.promises.readFile((0, node_path_1.resolve)(rootDir, `client/${isPlayPage ? 'play' : 'index'}.html`), 'utf-8');
|
|
244
|
+
if (hasLocalCdnDir) {
|
|
217
245
|
// 提取所有 zstatic.net 的 js 和 css 资源地址,若子路径存在于 local/cdn 目录下则替换为本地路径
|
|
218
246
|
const zstaticRegex = /https:\/\/s4\.zstatic\.net\/ajax\/libs\/[^\s"'`<>]+\.(js|css)/g;
|
|
219
247
|
const zstaticMatches = htmlContent.match(zstaticRegex);
|
|
@@ -221,7 +249,7 @@ class DLServer {
|
|
|
221
249
|
for (const match of zstaticMatches) {
|
|
222
250
|
const relativePath = match.split('libs/')[1];
|
|
223
251
|
const localPath = (0, node_path_1.resolve)(rootDir, `client/local/cdn/${relativePath}`);
|
|
224
|
-
if ((0,
|
|
252
|
+
if (await (0, utils_js_1.checkFileExists)(localPath)) {
|
|
225
253
|
htmlContent = htmlContent.replaceAll(match, `/local/cdn/${relativePath}`);
|
|
226
254
|
}
|
|
227
255
|
}
|
|
@@ -288,15 +316,34 @@ class DLServer {
|
|
|
288
316
|
if (!options.filename)
|
|
289
317
|
options.filename = item[1];
|
|
290
318
|
}
|
|
291
|
-
const { options: dlOptions } = (0, format_options_js_1.formatOptions)(url, { ...this.cfg.dlOptions, ...options, cacheDir: this.options.cacheDir });
|
|
319
|
+
const { options: dlOptions } = await (0, format_options_js_1.formatOptions)(url, { ...this.cfg.dlOptions, ...options, cacheDir: this.options.cacheDir });
|
|
320
|
+
if (!dlOptions.saveDir)
|
|
321
|
+
dlOptions.saveDir = this.cfg.dlOptions.saveDir;
|
|
292
322
|
const cacheItem = this.dlCache.get(url) || { options, dlOptions, status: 'pending', url };
|
|
293
323
|
utils_js_1.logger.debug('startDownload', url, dlOptions, cacheItem.status);
|
|
294
324
|
if (cacheItem.status === 'resume')
|
|
295
325
|
return;
|
|
296
|
-
if (cacheItem.localVideo
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
326
|
+
if (cacheItem.localVideo) {
|
|
327
|
+
if (await (0, utils_js_1.checkFileExists)(cacheItem.localVideo)) {
|
|
328
|
+
if (cacheItem.status === 'done')
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
delete cacheItem.localVideo;
|
|
333
|
+
if (cacheItem.endTime)
|
|
334
|
+
delete cacheItem.endTime;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else if (!this.dlCache.has(url)) {
|
|
338
|
+
// 如果本地视频已存在,则重命名 filename
|
|
339
|
+
const localVideo = (0, node_path_1.resolve)(dlOptions.saveDir, dlOptions.filename);
|
|
340
|
+
const hasSameNameVideo = [...this.dlCache.values()].some(d => d.dlOptions.saveDir === dlOptions.saveDir && d.dlOptions.filename === dlOptions.filename);
|
|
341
|
+
if (hasSameNameVideo || (await (0, utils_js_1.checkFileExists)(localVideo))) {
|
|
342
|
+
const ext = (0, node_path_1.extname)(localVideo) || '';
|
|
343
|
+
dlOptions.filename = `${(0, node_path_1.basename)(localVideo, ext)}.${Date.now()}${ext}`;
|
|
344
|
+
utils_js_1.logger.info('存在重名视频,重命名filename:', (0, console_log_colors_1.gray)(localVideo), '->', (0, console_log_colors_1.cyan)(dlOptions.filename));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
300
347
|
cacheItem.status = this.downloading >= this.cfg.webOptions.maxDownloads ? 'pending' : 'resume';
|
|
301
348
|
// pending 优先级靠后
|
|
302
349
|
if (cacheItem.status === 'pending' && this.dlCache.has(url))
|
|
@@ -317,24 +364,26 @@ class DLServer {
|
|
|
317
364
|
if (!item)
|
|
318
365
|
return false; // 已删除
|
|
319
366
|
const status = item.status || 'resume';
|
|
320
|
-
|
|
367
|
+
if (!item.cacheDir && current?.tsOut)
|
|
368
|
+
item.cacheDir = (0, node_path_1.dirname)(current.tsOut);
|
|
369
|
+
Object.assign(item, { ...stats, dlOptions, status, workPoll, url });
|
|
321
370
|
this.dlCache.set(url, item);
|
|
322
371
|
this.saveCache();
|
|
323
372
|
this.wsSend('progress', url);
|
|
324
373
|
return status !== 'pause';
|
|
325
374
|
},
|
|
326
375
|
};
|
|
327
|
-
const afterDownload = (r, url) => {
|
|
376
|
+
const afterDownload = async (r, url) => {
|
|
328
377
|
const item = this.dlCache.get(url) || cacheItem;
|
|
329
|
-
if (r.filepath && (0,
|
|
378
|
+
if (r.filepath && (await (0, utils_js_1.checkFileExists)(r.filepath))) {
|
|
330
379
|
item.localVideo = r.filepath;
|
|
331
|
-
item.downloadedSize = (
|
|
380
|
+
item.downloadedSize = (await node_fs_1.promises.stat(r.filepath)).size;
|
|
332
381
|
}
|
|
333
382
|
else if (!r.errmsg && opts.convert !== false)
|
|
334
383
|
r.errmsg = '下载失败';
|
|
335
384
|
item.endTime = Date.now();
|
|
336
385
|
item.errmsg = r.errmsg;
|
|
337
|
-
item.status = r.errmsg ? 'error' : 'done';
|
|
386
|
+
item.status = r.isExist ? 'done' : r.errmsg ? 'error' : 'done';
|
|
338
387
|
utils_js_1.logger.info('Download complete:', item.status, (0, console_log_colors_1.red)(r.errmsg), (0, console_log_colors_1.gray)(url), (0, console_log_colors_1.cyan)(r.filepath));
|
|
339
388
|
this.dlCache.set(url, item);
|
|
340
389
|
this.wsSend('progress', url);
|
|
@@ -343,8 +392,8 @@ class DLServer {
|
|
|
343
392
|
};
|
|
344
393
|
try {
|
|
345
394
|
if (dlOptions.type === 'parser') {
|
|
346
|
-
|
|
347
|
-
|
|
395
|
+
console.log('\n\nDownloading with VideoParser\n\n', dlOptions, url);
|
|
396
|
+
index_js_1.VideoParser.download(url, opts).then(r => afterDownload(r, url));
|
|
348
397
|
}
|
|
349
398
|
else if (dlOptions.type === 'file') {
|
|
350
399
|
(0, file_download_js_1.fileDownload)(url, opts).then(r => afterDownload(r, url));
|
|
@@ -372,6 +421,11 @@ class DLServer {
|
|
|
372
421
|
if (queryLang && i18n_js_1.LANG_CODES.has(queryLang)) {
|
|
373
422
|
return queryLang;
|
|
374
423
|
}
|
|
424
|
+
// Try to get lang from body
|
|
425
|
+
const bodyLang = req.body?.lang;
|
|
426
|
+
if (bodyLang && i18n_js_1.LANG_CODES.has(bodyLang)) {
|
|
427
|
+
return bodyLang;
|
|
428
|
+
}
|
|
375
429
|
// Try to get lang from Accept-Language header
|
|
376
430
|
const acceptLanguage = req.headers['accept-language'];
|
|
377
431
|
if (acceptLanguage) {
|
|
@@ -380,11 +434,6 @@ class DLServer {
|
|
|
380
434
|
return langCode;
|
|
381
435
|
}
|
|
382
436
|
}
|
|
383
|
-
// Try to get lang from body
|
|
384
|
-
const bodyLang = req.body?.lang;
|
|
385
|
-
if (bodyLang && i18n_js_1.LANG_CODES.has(bodyLang)) {
|
|
386
|
-
return bodyLang;
|
|
387
|
-
}
|
|
388
437
|
// Fallback to default
|
|
389
438
|
return (0, i18n_js_1.getLang)();
|
|
390
439
|
}
|
|
@@ -471,19 +520,32 @@ class DLServer {
|
|
|
471
520
|
// });
|
|
472
521
|
// API to start m3u8 download
|
|
473
522
|
app.post('/api/download', (req, res) => {
|
|
474
|
-
const {
|
|
523
|
+
const { list = [] } = req.body;
|
|
475
524
|
const lang = this.getLangFromRequest(req);
|
|
476
525
|
try {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
526
|
+
let duplicateCount = 0;
|
|
527
|
+
let startedCount = 0;
|
|
528
|
+
// 检查并统计重复的 URL,但仍允许下载
|
|
529
|
+
for (const item of list) {
|
|
530
|
+
const { url, ...options } = item;
|
|
531
|
+
if (url) {
|
|
532
|
+
if (this.dlCache.has(url)) {
|
|
533
|
+
duplicateCount++;
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
startedCount++;
|
|
537
|
+
}
|
|
538
|
+
this.startDownload(url, options);
|
|
482
539
|
}
|
|
483
540
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
541
|
+
let message = '';
|
|
542
|
+
if (duplicateCount > 0) {
|
|
543
|
+
message = `${(0, i18n_js_1.t)('api.success.downloadStarted', lang, { count: list.length })},${(0, i18n_js_1.t)('api.error.duplicateDownload', lang, { count: duplicateCount })}`;
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
message = (0, i18n_js_1.t)('api.success.downloadStarted', lang, { count: list.length });
|
|
547
|
+
}
|
|
548
|
+
res.json({ message, code: 0, duplicateCount, startedCount });
|
|
487
549
|
this.wsSend('tasks');
|
|
488
550
|
}
|
|
489
551
|
catch (error) {
|
|
@@ -536,31 +598,84 @@ class DLServer {
|
|
|
536
598
|
});
|
|
537
599
|
});
|
|
538
600
|
// API to delete download
|
|
539
|
-
app.post('/api/delete', (req, res) => {
|
|
601
|
+
app.post('/api/delete', async (req, res) => {
|
|
540
602
|
const { urls, deleteCache = false, deleteVideo = false } = req.body;
|
|
541
603
|
const urlsToDelete = urls;
|
|
542
604
|
const list = [];
|
|
605
|
+
const errors = [];
|
|
543
606
|
for (const url of urlsToDelete) {
|
|
544
607
|
const item = this.dlCache.get(url);
|
|
545
608
|
if (item) {
|
|
609
|
+
utils_js_1.logger.info('delete download task:', (0, console_log_colors_1.gray)(url), (0, console_log_colors_1.cyan)(item.status), (0, console_log_colors_1.cyan)(item.localVideo), deleteCache, deleteVideo);
|
|
546
610
|
(0, m3u8_download_js_1.m3u8DLStop)(url, item.workPoll);
|
|
547
611
|
this.dlCache.delete(url);
|
|
548
612
|
list.push(item.url);
|
|
549
|
-
if (deleteCache
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
(0,
|
|
553
|
-
|
|
613
|
+
if (deleteCache) {
|
|
614
|
+
try {
|
|
615
|
+
const cacheDir = item.cacheDir;
|
|
616
|
+
if (cacheDir && (await (0, utils_js_1.checkFileExists)(cacheDir))) {
|
|
617
|
+
await node_fs_1.promises.rm(cacheDir, { recursive: true, force: true });
|
|
618
|
+
utils_js_1.logger.info('删除缓存目录:', (0, console_log_colors_1.gray)(cacheDir));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
const errorMsg = `删除缓存目录失败: ${error.message}`;
|
|
623
|
+
utils_js_1.logger.error(errorMsg, (0, console_log_colors_1.gray)(item.cacheDir));
|
|
624
|
+
errors.push(errorMsg);
|
|
554
625
|
}
|
|
555
626
|
}
|
|
556
627
|
if (deleteVideo) {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
if (
|
|
560
|
-
|
|
561
|
-
utils_js_1.
|
|
628
|
+
try {
|
|
629
|
+
// 优先使用 item.localVideo(实际文件路径)
|
|
630
|
+
if (item.localVideo) {
|
|
631
|
+
const filepath = item.localVideo;
|
|
632
|
+
if (await (0, utils_js_1.checkFileExists)(filepath)) {
|
|
633
|
+
try {
|
|
634
|
+
await node_fs_1.promises.rm(filepath, { recursive: true, force: true });
|
|
635
|
+
utils_js_1.logger.info('删除文件:', (0, console_log_colors_1.gray)(filepath));
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
const errorMsg = `删除文件失败: ${filepath}, 错误: ${error.message}`;
|
|
639
|
+
utils_js_1.logger.error(errorMsg);
|
|
640
|
+
errors.push(errorMsg);
|
|
641
|
+
// 如果直接删除失败,可能是文件被占用
|
|
642
|
+
}
|
|
643
|
+
}
|
|
562
644
|
}
|
|
563
|
-
|
|
645
|
+
else {
|
|
646
|
+
// 如果 localVideo 不存在,尝试使用 dlOptions 构建路径(格式化后的参数更准确)
|
|
647
|
+
const saveDir = item.dlOptions?.saveDir || item.options?.saveDir;
|
|
648
|
+
const filename = item.dlOptions?.filename || item.options?.filename;
|
|
649
|
+
if (saveDir && filename) {
|
|
650
|
+
// 尝试多种可能的扩展名
|
|
651
|
+
for (const ext of ['', '.ts', '.mp4']) {
|
|
652
|
+
const filepath = (0, node_path_1.resolve)(saveDir, filename + ext);
|
|
653
|
+
if (await (0, utils_js_1.checkFileExists)(filepath)) {
|
|
654
|
+
try {
|
|
655
|
+
await node_fs_1.promises.rm(filepath, { recursive: true, force: true });
|
|
656
|
+
utils_js_1.logger.info('删除文件:', (0, console_log_colors_1.gray)(filepath));
|
|
657
|
+
break; // 找到并删除后退出循环
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
const errorMsg = `删除文件失败: ${filepath}, 错误: ${error.message}`;
|
|
661
|
+
utils_js_1.logger.error(errorMsg);
|
|
662
|
+
errors.push(errorMsg);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
const errorMsg = `无法确定文件路径: saveDir=${saveDir}, filename=${filename}`;
|
|
669
|
+
utils_js_1.logger.warn(errorMsg, (0, console_log_colors_1.gray)(url));
|
|
670
|
+
errors.push(errorMsg);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
const errorMsg = `删除视频文件时发生错误: ${error.message}`;
|
|
676
|
+
utils_js_1.logger.error(errorMsg, (0, console_log_colors_1.gray)(url));
|
|
677
|
+
errors.push(errorMsg);
|
|
678
|
+
}
|
|
564
679
|
}
|
|
565
680
|
}
|
|
566
681
|
}
|
|
@@ -570,22 +685,78 @@ class DLServer {
|
|
|
570
685
|
this.startNextPending();
|
|
571
686
|
}
|
|
572
687
|
const lang = this.getLangFromRequest(req);
|
|
573
|
-
|
|
688
|
+
const message = errors.length
|
|
689
|
+
? `${(0, i18n_js_1.t)('api.success.deleted', lang, { count: list.length })},但有 ${errors.length} 个错误: ${errors.join('; ')}`
|
|
690
|
+
: (0, i18n_js_1.t)('api.success.deleted', lang, { count: list.length });
|
|
691
|
+
res.json({ message, code: errors.length > 0 ? 1 : 0, count: list.length, errors: errors.length > 0 ? errors : undefined });
|
|
692
|
+
});
|
|
693
|
+
// API to rename download file
|
|
694
|
+
app.post('/api/rename', async (req, res) => {
|
|
695
|
+
const { url, newFilename } = req.body;
|
|
696
|
+
const lang = this.getLangFromRequest(req);
|
|
697
|
+
if (!url || !newFilename) {
|
|
698
|
+
res.json({ code: 1001, message: (0, i18n_js_1.t)('api.error.invalidParams', lang) });
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
// 检查新文件名是否包含非法字符
|
|
702
|
+
const invalidChars = /[<>:"/\\|?*]/;
|
|
703
|
+
if (invalidChars.test(newFilename)) {
|
|
704
|
+
res.json({ code: 1004, message: (0, i18n_js_1.t)('api.error.invalidFilename', lang) });
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const item = this.dlCache.get(url);
|
|
708
|
+
if (!item) {
|
|
709
|
+
res.json({ code: 1002, message: (0, i18n_js_1.t)('api.error.taskNotFound', lang) });
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
await this.checkItemStatus(item);
|
|
713
|
+
// 只允许重命名已完成且状态正常的任务
|
|
714
|
+
if (item.status !== 'done' || item.errmsg) {
|
|
715
|
+
utils_js_1.logger.error('rename failed:', item.status, (0, console_log_colors_1.red)(item.errmsg), (0, console_log_colors_1.gray)(url));
|
|
716
|
+
res.json({ code: 1003, message: (0, i18n_js_1.t)('api.error.onlyRenameCompleted', lang) });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
const oldPath = item.localVideo;
|
|
721
|
+
const oldDir = (0, node_path_1.dirname)(oldPath);
|
|
722
|
+
const oldExt = oldPath.split('.').pop() || '';
|
|
723
|
+
const newFilenameBase = newFilename.replace(/\.[^.]+$/, '');
|
|
724
|
+
const newPath = (0, node_path_1.resolve)(oldDir, `${newFilenameBase}${oldExt ? `.${oldExt}` : ''}`);
|
|
725
|
+
// 检查新文件名是否已存在
|
|
726
|
+
if (await (0, utils_js_1.checkFileExists)(newPath)) {
|
|
727
|
+
res.json({ code: 1007, message: (0, i18n_js_1.t)('api.error.fileExists', lang) });
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
// 重命名文件
|
|
731
|
+
await node_fs_1.promises.rename(oldPath, newPath);
|
|
732
|
+
utils_js_1.logger.debug('重命名文件:', (0, console_log_colors_1.gray)(oldPath), '->', (0, console_log_colors_1.cyan)(newPath));
|
|
733
|
+
// 更新任务信息
|
|
734
|
+
item.localVideo = newPath;
|
|
735
|
+
item.options.filename = item.filename = (0, node_path_1.basename)(newPath);
|
|
736
|
+
this.dlCache.set(url, item);
|
|
737
|
+
this.saveCache();
|
|
738
|
+
this.wsSend('progress', url);
|
|
739
|
+
res.json({ message: (0, i18n_js_1.t)('api.success.renamed', lang), code: 0 });
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
utils_js_1.logger.error('重命名失败:', error);
|
|
743
|
+
res.json({ code: 1006, message: (0, i18n_js_1.t)('api.error.renameFailed', lang, { error: error.message }) });
|
|
744
|
+
}
|
|
574
745
|
});
|
|
575
|
-
app.get(/^\/localplay\/(.*)$/, (req, res) => {
|
|
746
|
+
app.get(/^\/localplay\/(.*)$/, async (req, res) => {
|
|
576
747
|
let filepath = decodeURIComponent(req.params[0]);
|
|
577
748
|
if (filepath) {
|
|
578
749
|
let ext = filepath.split('.').pop();
|
|
579
750
|
if (!ext) {
|
|
580
751
|
ext = 'm3u8';
|
|
581
|
-
if (!(0,
|
|
752
|
+
if (!(await (0, utils_js_1.checkFileExists)(filepath)))
|
|
582
753
|
filepath += '.m3u8';
|
|
583
754
|
}
|
|
584
755
|
const allowedDirs = [this.options.cacheDir, this.cfg.dlOptions.saveDir];
|
|
585
|
-
if (!(0,
|
|
756
|
+
if (!(await (0, utils_js_1.checkFileExists)(filepath))) {
|
|
586
757
|
for (const dir of allowedDirs) {
|
|
587
758
|
const tpath = (0, node_path_1.resolve)(dir, filepath);
|
|
588
|
-
if ((0,
|
|
759
|
+
if (await (0, utils_js_1.checkFileExists)(tpath)) {
|
|
589
760
|
filepath = tpath;
|
|
590
761
|
break;
|
|
591
762
|
}
|
|
@@ -601,8 +772,8 @@ class DLServer {
|
|
|
601
772
|
return;
|
|
602
773
|
}
|
|
603
774
|
}
|
|
604
|
-
if ((0,
|
|
605
|
-
const stats =
|
|
775
|
+
if (await (0, utils_js_1.checkFileExists)(filepath)) {
|
|
776
|
+
const stats = await node_fs_1.promises.stat(filepath);
|
|
606
777
|
const headers = new Headers({
|
|
607
778
|
'Last-Modified': stats.mtime.toUTCString(),
|
|
608
779
|
'Access-Control-Allow-Headers': '*',
|
|
@@ -614,7 +785,8 @@ class DLServer {
|
|
|
614
785
|
});
|
|
615
786
|
res.setHeaders(headers);
|
|
616
787
|
if (ext === 'm3u8' || ('ts' === ext && stats.size < 1024 * 1024 * 3)) {
|
|
617
|
-
|
|
788
|
+
const data = await node_fs_1.promises.readFile(filepath);
|
|
789
|
+
res.send(data);
|
|
618
790
|
utils_js_1.logger.debug('[Localplay]file sent:', (0, console_log_colors_1.gray)(filepath), 'Size:', stats.size, 'bytes');
|
|
619
791
|
}
|
|
620
792
|
else {
|
package/cjs/types/m3u8.d.ts
CHANGED
|
@@ -144,6 +144,12 @@ export interface M3u8DLOptions {
|
|
|
144
144
|
ffmpegPath?: string;
|
|
145
145
|
/** 语言。可选值:zh-CN, en */
|
|
146
146
|
lang?: 'zh-CN' | 'en';
|
|
147
|
+
/** 代理模式。可选值:custom, system, disabled */
|
|
148
|
+
proxyMode?: 'custom' | 'system' | 'disabled';
|
|
149
|
+
/** 代理地址。如果 proxyMode 为 'custom',则必须指定 */
|
|
150
|
+
proxyUrl?: string;
|
|
151
|
+
/** 不使用代理的域名。多个域名用逗号分隔 */
|
|
152
|
+
noProxy?: string;
|
|
147
153
|
}
|
|
148
154
|
export interface M3u8DLResult extends Partial<DownloadResult> {
|
|
149
155
|
/** 下载进度统计 */
|
|
@@ -4,8 +4,8 @@ export declare class VideoParser {
|
|
|
4
4
|
/**
|
|
5
5
|
* 解析视频 URL
|
|
6
6
|
*/
|
|
7
|
-
parse(url: string, headers?: Record<string, string>): Promise<ApiResponse<VideoInfo>>;
|
|
8
|
-
download(url: string, options: M3u8DLOptions): Promise<M3u8DLResult>;
|
|
7
|
+
static parse(url: string, headers?: Record<string, string>): Promise<ApiResponse<VideoInfo>>;
|
|
8
|
+
static download(url: string, options: M3u8DLOptions): Promise<M3u8DLResult>;
|
|
9
9
|
/**
|
|
10
10
|
* 根据 URL 获取平台标识
|
|
11
11
|
*/
|
|
@@ -16,5 +16,5 @@ export declare class VideoParser {
|
|
|
16
16
|
/**
|
|
17
17
|
* 获取所有支持的平台列表
|
|
18
18
|
*/
|
|
19
|
-
getSupportedPlatforms(): string[];
|
|
19
|
+
static getSupportedPlatforms(): string[];
|
|
20
20
|
}
|
|
@@ -31,15 +31,15 @@ class VideoParser {
|
|
|
31
31
|
/**
|
|
32
32
|
* 解析视频 URL
|
|
33
33
|
*/
|
|
34
|
-
async parse(url, headers = {}) {
|
|
34
|
+
static async parse(url, headers = {}) {
|
|
35
35
|
const info = VideoParser.getPlatform(url);
|
|
36
36
|
if (!info)
|
|
37
37
|
return { code: 201, message: '不支持的视频平台' };
|
|
38
38
|
const parserClass = VideoParser.platforms[info.platform].class;
|
|
39
39
|
return await parserClass.parse(info.url, headers);
|
|
40
40
|
}
|
|
41
|
-
async download(url, options) {
|
|
42
|
-
const info = await
|
|
41
|
+
static async download(url, options) {
|
|
42
|
+
const info = await VideoParser.parse(url);
|
|
43
43
|
utils_1.logger.debug('解析视频信息', info);
|
|
44
44
|
if (info.code || !info.data?.url)
|
|
45
45
|
return { errmsg: info.message || '解析视频信息失败', options };
|
|
@@ -54,7 +54,7 @@ class VideoParser {
|
|
|
54
54
|
referer: info.data.referer || info.data.url,
|
|
55
55
|
...(0, utils_1.formatHeaders)(options.headers),
|
|
56
56
|
};
|
|
57
|
-
return (0, file_download_1.fileDownload)(url, options);
|
|
57
|
+
return (0, file_download_1.fileDownload)(info.data.url, options);
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
60
|
* 根据 URL 获取平台标识
|
|
@@ -79,7 +79,7 @@ class VideoParser {
|
|
|
79
79
|
/**
|
|
80
80
|
* 获取所有支持的平台列表
|
|
81
81
|
*/
|
|
82
|
-
getSupportedPlatforms() {
|
|
82
|
+
static getSupportedPlatforms() {
|
|
83
83
|
return Object.keys(VideoParser.platforms);
|
|
84
84
|
}
|
|
85
85
|
}
|