@lzwme/m3u8-dl 1.5.0 → 1.6.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 +445 -71
- package/README.zh-CN.md +580 -0
- package/cjs/cli.js +37 -24
- package/cjs/i18n/locales/en.d.ts +108 -0
- package/cjs/i18n/locales/en.js +109 -0
- package/cjs/i18n/locales/zh-CN.d.ts +108 -0
- package/cjs/i18n/locales/zh-CN.js +109 -0
- package/cjs/index.d.ts +1 -1
- package/cjs/index.js +1 -1
- package/cjs/lib/file-download.js +8 -5
- package/cjs/lib/format-options.js +27 -4
- package/cjs/lib/getM3u8Urls.d.ts +6 -0
- package/cjs/lib/getM3u8Urls.js +45 -27
- package/cjs/lib/i18n.d.ts +27 -0
- package/cjs/lib/i18n.js +108 -0
- package/cjs/lib/m3u8-convert.js +2 -7
- package/cjs/lib/m3u8-download.js +36 -15
- package/cjs/lib/utils.js +4 -4
- package/cjs/server/download-server.d.ts +1 -0
- package/cjs/server/download-server.js +112 -39
- package/cjs/types/index.d.ts +1 -1
- package/cjs/types/index.js +1 -1
- package/cjs/types/m3u8.d.ts +4 -2
- package/client/assets/main-ChJ1yjNN.css +1 -0
- package/client/assets/main-DZTEqg-V.js +29 -0
- package/client/index.html +5 -1145
- package/client/m3u8-capture.user.js +94 -0
- package/client/play.html +223 -16
- package/package.json +34 -21
- package/client/style.css +0 -137
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared i18n utility for backend (CLI, SDK, Server)
|
|
3
|
+
*/
|
|
4
|
+
import type { AnyObject } from '@lzwme/fe-utils';
|
|
5
|
+
type Locale = 'zh-CN' | 'en';
|
|
6
|
+
export declare const LANG_CODES: Set<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Detect language from OS or environment
|
|
9
|
+
*/
|
|
10
|
+
export declare function detectLanguage(): Locale;
|
|
11
|
+
/**
|
|
12
|
+
* Set global language context
|
|
13
|
+
*/
|
|
14
|
+
export declare function setLanguage(lang: Locale | null): void;
|
|
15
|
+
/**
|
|
16
|
+
* Get global language context
|
|
17
|
+
*/
|
|
18
|
+
export declare function getLanguage(): Locale | null;
|
|
19
|
+
/**
|
|
20
|
+
* Get language from various sources
|
|
21
|
+
*/
|
|
22
|
+
export declare function getLang(lang?: string): Locale;
|
|
23
|
+
/**
|
|
24
|
+
* Translation function
|
|
25
|
+
*/
|
|
26
|
+
export declare function t(key: string, lang?: string, params?: AnyObject): string;
|
|
27
|
+
export {};
|
package/cjs/lib/i18n.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared i18n utility for backend (CLI, SDK, Server)
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.LANG_CODES = void 0;
|
|
7
|
+
exports.detectLanguage = detectLanguage;
|
|
8
|
+
exports.setLanguage = setLanguage;
|
|
9
|
+
exports.getLanguage = getLanguage;
|
|
10
|
+
exports.getLang = getLang;
|
|
11
|
+
exports.t = t;
|
|
12
|
+
exports.LANG_CODES = new Set(['zh-CN', 'en']);
|
|
13
|
+
let globalLang = null;
|
|
14
|
+
/**
|
|
15
|
+
* Detect language from OS or environment
|
|
16
|
+
*/
|
|
17
|
+
function detectLanguage() {
|
|
18
|
+
// Check environment variable first
|
|
19
|
+
const envLang = process.env.M3U8DL_LANG || process.env.LANG;
|
|
20
|
+
if (envLang) {
|
|
21
|
+
const langCode = envLang.toLowerCase();
|
|
22
|
+
// 支持 zh-CN, zh-TW, zh 等变体映射到 zh-CN
|
|
23
|
+
if (langCode.startsWith('zh')) {
|
|
24
|
+
return 'zh-CN';
|
|
25
|
+
}
|
|
26
|
+
const baseLangCode = langCode.split('-')[0].split('_')[0];
|
|
27
|
+
if (exports.LANG_CODES.has(baseLangCode)) {
|
|
28
|
+
return baseLangCode;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Try to detect from OS locale
|
|
32
|
+
try {
|
|
33
|
+
const osLocale = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
34
|
+
const langCode = osLocale.toLowerCase();
|
|
35
|
+
// 支持 zh-CN, zh-TW 等变体映射到 zh-CN
|
|
36
|
+
if (langCode.startsWith('zh')) {
|
|
37
|
+
return 'zh-CN';
|
|
38
|
+
}
|
|
39
|
+
const baseLangCode = langCode.split('-')[0];
|
|
40
|
+
if (exports.LANG_CODES.has(baseLangCode)) {
|
|
41
|
+
return baseLangCode;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Ignore errors
|
|
46
|
+
}
|
|
47
|
+
// Fallback to English
|
|
48
|
+
return 'en';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Set global language context
|
|
52
|
+
*/
|
|
53
|
+
function setLanguage(lang) {
|
|
54
|
+
globalLang = lang;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Get global language context
|
|
58
|
+
*/
|
|
59
|
+
function getLanguage() {
|
|
60
|
+
return globalLang;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Get language from various sources
|
|
64
|
+
*/
|
|
65
|
+
function getLang(lang) {
|
|
66
|
+
if (lang) {
|
|
67
|
+
const normalizedLang = lang.toLowerCase();
|
|
68
|
+
// 支持向后兼容:将 zh 映射到 zh-CN
|
|
69
|
+
if (normalizedLang === 'zh' || normalizedLang.startsWith('zh')) {
|
|
70
|
+
return 'zh-CN';
|
|
71
|
+
}
|
|
72
|
+
if (exports.LANG_CODES.has(normalizedLang)) {
|
|
73
|
+
return normalizedLang;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (globalLang) {
|
|
77
|
+
return globalLang;
|
|
78
|
+
}
|
|
79
|
+
return detectLanguage();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Translation function
|
|
83
|
+
*/
|
|
84
|
+
function t(key, lang, params) {
|
|
85
|
+
const targetLang = getLang(lang);
|
|
86
|
+
// 将 zh-CN 映射到文件名 zh-CN.ts
|
|
87
|
+
const langFile = targetLang === 'zh-CN' ? 'zh-CN' : targetLang;
|
|
88
|
+
const translations = require(`../i18n/locales/${langFile}.js`);
|
|
89
|
+
// Navigate through nested keys (e.g., 'cli.command.download.description')
|
|
90
|
+
const keys = key.split('.');
|
|
91
|
+
let value = translations.default || translations;
|
|
92
|
+
for (const k of keys) {
|
|
93
|
+
if (value && typeof value === 'object' && k in value) {
|
|
94
|
+
value = value[k];
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// Key not found, return the key itself
|
|
98
|
+
return key;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// If value is a string, replace params if provided
|
|
102
|
+
if (typeof value === 'string' && params) {
|
|
103
|
+
return value.replace(/\{(\w+)\}/g, (match, paramKey) => {
|
|
104
|
+
return params[paramKey] !== undefined ? String(params[paramKey]) : match;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return typeof value === 'string' ? value : key;
|
|
108
|
+
}
|
package/cjs/lib/m3u8-convert.js
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
3
|
exports.m3u8Convert = m3u8Convert;
|
|
7
4
|
const node_fs_1 = require("node:fs");
|
|
8
5
|
const node_path_1 = require("node:path");
|
|
9
6
|
const fe_utils_1 = require("@lzwme/fe-utils");
|
|
10
7
|
const console_log_colors_1 = require("console-log-colors");
|
|
11
|
-
const ffmpeg_static_1 = __importDefault(require("ffmpeg-static"));
|
|
12
8
|
const utils_1 = require("./utils");
|
|
13
9
|
async function m3u8Convert(options, data) {
|
|
14
|
-
const
|
|
15
|
-
const ffmpegBin = useGlobal ? 'ffmpeg' : ffmpeg_static_1.default;
|
|
10
|
+
const ffmpegBin = options.ffmpegPath || 'ffmpeg';
|
|
16
11
|
let ffmpegSupport = (0, utils_1.isSupportFfmpeg)(ffmpegBin);
|
|
17
12
|
let filepath = (0, node_path_1.resolve)(options.saveDir, options.filename);
|
|
18
13
|
if (!ffmpegSupport)
|
|
@@ -40,7 +35,7 @@ async function m3u8Convert(options, data) {
|
|
|
40
35
|
const r = (0, fe_utils_1.execSync)(cmd);
|
|
41
36
|
ffmpegSupport = !r.error;
|
|
42
37
|
if (r.error)
|
|
43
|
-
utils_1.logger.error(`Conversion to mp4 failed. Please confirm that \`${
|
|
38
|
+
utils_1.logger.error(`Conversion to mp4 failed. Please confirm that \`${ffmpegBin}\` is installed and available!`, r.stderr);
|
|
44
39
|
else
|
|
45
40
|
(0, node_fs_1.unlinkSync)(ffconcatFile);
|
|
46
41
|
}
|
package/cjs/lib/m3u8-download.js
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
3
|
exports.workPollPublic = exports.downloadQueue = exports.DownloadQueue = void 0;
|
|
7
4
|
exports.preDownLoad = preDownLoad;
|
|
@@ -12,8 +9,8 @@ const node_path_1 = require("node:path");
|
|
|
12
9
|
const fe_utils_1 = require("@lzwme/fe-utils");
|
|
13
10
|
const helper_1 = require("@lzwme/fe-utils/cjs/common/helper");
|
|
14
11
|
const console_log_colors_1 = require("console-log-colors");
|
|
15
|
-
const ffmpeg_static_1 = __importDefault(require("ffmpeg-static"));
|
|
16
12
|
const format_options_js_1 = require("./format-options.js");
|
|
13
|
+
const i18n_js_1 = require("./i18n.js");
|
|
17
14
|
const local_play_js_1 = require("./local-play.js");
|
|
18
15
|
const m3u8_convert_js_1 = require("./m3u8-convert.js");
|
|
19
16
|
const parseM3u8_js_1 = require("./parseM3u8.js");
|
|
@@ -91,7 +88,7 @@ const cache = {
|
|
|
91
88
|
const tsDlFile = (0, node_path_1.resolve)(__dirname, './ts-download.js');
|
|
92
89
|
exports.workPollPublic = new worker_pool_js_1.WorkerPool(tsDlFile);
|
|
93
90
|
async function m3u8InfoParse(u, o = {}) {
|
|
94
|
-
const ffmpegBin = o.
|
|
91
|
+
const ffmpegBin = o.ffmpegPath || 'ffmpeg';
|
|
95
92
|
const ext = (0, utils_js_1.isSupportFfmpeg)(ffmpegBin) ? '.mp4' : '.ts';
|
|
96
93
|
const { url, options, urlMd5 } = (0, format_options_js_1.formatOptions)(u, o);
|
|
97
94
|
/** 最终合并转换后的文件路径 */
|
|
@@ -105,17 +102,32 @@ async function m3u8InfoParse(u, o = {}) {
|
|
|
105
102
|
}
|
|
106
103
|
if (!options.force && (0, node_fs_1.existsSync)(filepath))
|
|
107
104
|
return result;
|
|
105
|
+
const lang = (0, i18n_js_1.getLang)(o.lang);
|
|
108
106
|
const m3u8Info = await (0, parseM3u8_js_1.parseM3U8)(url, (0, node_path_1.resolve)(options.cacheDir, urlMd5), options.headers).catch(e => {
|
|
109
|
-
utils_js_1.logger.error('
|
|
107
|
+
utils_js_1.logger.error((0, i18n_js_1.t)('download.status.parseFailed', lang), e.message);
|
|
110
108
|
console.log(e);
|
|
111
109
|
});
|
|
112
110
|
if (m3u8Info && m3u8Info?.tsCount > 0) {
|
|
113
111
|
result.m3u8Info = m3u8Info;
|
|
114
112
|
if (options.ignoreSegments) {
|
|
113
|
+
const totalDuration = m3u8Info.duration;
|
|
115
114
|
const timeSegments = options.ignoreSegments
|
|
116
115
|
.split(',')
|
|
117
|
-
.map(d =>
|
|
118
|
-
|
|
116
|
+
.map(d => {
|
|
117
|
+
const trimmed = d.trim();
|
|
118
|
+
// 支持 END-60 格式,表示末尾N秒
|
|
119
|
+
const parts = trimmed.split(/[- ]+/).map(d => d.trim());
|
|
120
|
+
if (parts.length === 2 && parts[0].toUpperCase() === 'END') {
|
|
121
|
+
const seconds = +parts[1];
|
|
122
|
+
if (!Number.isNaN(seconds) && seconds > 0) {
|
|
123
|
+
const start = Math.max(0, totalDuration - seconds);
|
|
124
|
+
return [start, totalDuration];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// 原有的 start-end 格式
|
|
128
|
+
return parts.map(d => +d);
|
|
129
|
+
})
|
|
130
|
+
.filter(d => d[0] !== undefined && d[1] !== undefined && !Number.isNaN(d[0]) && !Number.isNaN(d[1]) && d[0] !== d[1]);
|
|
119
131
|
if (timeSegments.length) {
|
|
120
132
|
const total = m3u8Info.data.length;
|
|
121
133
|
m3u8Info.data = m3u8Info.data.filter(item => {
|
|
@@ -132,7 +144,7 @@ async function m3u8InfoParse(u, o = {}) {
|
|
|
132
144
|
const ignoredCount = total - m3u8Info.data.length;
|
|
133
145
|
if (ignoredCount) {
|
|
134
146
|
m3u8Info.tsCount = m3u8Info.data.length;
|
|
135
|
-
utils_js_1.logger.info(
|
|
147
|
+
utils_js_1.logger.info((0, i18n_js_1.t)('download.status.segmentsIgnored', lang, { count: (0, console_log_colors_1.cyanBright)(ignoredCount) }));
|
|
136
148
|
m3u8Info.duration = +Number(m3u8Info.duration).toFixed(2);
|
|
137
149
|
}
|
|
138
150
|
}
|
|
@@ -189,11 +201,12 @@ async function m3u8Download(url, options = {}) {
|
|
|
189
201
|
});
|
|
190
202
|
}
|
|
191
203
|
// 原有的下载逻辑
|
|
192
|
-
|
|
204
|
+
const lang = (0, i18n_js_1.getLang)(options.lang);
|
|
205
|
+
utils_js_1.logger.info((0, i18n_js_1.t)('download.status.starting', lang), (0, console_log_colors_1.cyanBright)(url));
|
|
193
206
|
const result = await m3u8InfoParse(url, options);
|
|
194
207
|
options = result.options;
|
|
195
208
|
if (!options.force && (0, node_fs_1.existsSync)(result.filepath) && !result.m3u8Info) {
|
|
196
|
-
utils_js_1.logger.info(
|
|
209
|
+
utils_js_1.logger.info((0, i18n_js_1.t)('download.status.fileExists', lang), result.filepath);
|
|
197
210
|
result.isExist = true;
|
|
198
211
|
return result;
|
|
199
212
|
}
|
|
@@ -231,14 +244,14 @@ async function m3u8Download(url, options = {}) {
|
|
|
231
244
|
if (!res || err) {
|
|
232
245
|
if (err) {
|
|
233
246
|
console.log('\n');
|
|
234
|
-
utils_js_1.logger.error('
|
|
247
|
+
utils_js_1.logger.error((0, i18n_js_1.t)('download.status.tsDownloadError', lang), info.index, err, res || '');
|
|
235
248
|
}
|
|
236
249
|
if (typeof info.success !== 'number')
|
|
237
250
|
info.success = 0;
|
|
238
251
|
else
|
|
239
252
|
info.success--;
|
|
240
253
|
if (info.success >= -3) {
|
|
241
|
-
utils_js_1.logger.warn(
|
|
254
|
+
utils_js_1.logger.warn((0, i18n_js_1.t)('download.status.retryTimes', lang, { times: info.success }), info.index, info.uri);
|
|
242
255
|
setTimeout(() => runTask([info]), 1000);
|
|
243
256
|
return;
|
|
244
257
|
}
|
|
@@ -293,7 +306,7 @@ async function m3u8Download(url, options = {}) {
|
|
|
293
306
|
}
|
|
294
307
|
};
|
|
295
308
|
if (options.showProgress) {
|
|
296
|
-
console.info(`\
|
|
309
|
+
console.info(`\n${(0, i18n_js_1.t)('download.status.totalSegments', lang, { count: (0, console_log_colors_1.cyan)(m3u8Info.tsCount), duration: (0, console_log_colors_1.green)(`${m3u8Info.duration}sec`) })}.`, (0, i18n_js_1.t)('download.status.parallelJobs', lang, { count: (0, console_log_colors_1.magenta)(options.threadNum) }));
|
|
297
310
|
}
|
|
298
311
|
result.stats = stats;
|
|
299
312
|
(0, local_play_js_1.toLocalM3u8)(m3u8Info.data);
|
|
@@ -303,16 +316,24 @@ async function m3u8Download(url, options = {}) {
|
|
|
303
316
|
await barrier.wait();
|
|
304
317
|
workPoll.close();
|
|
305
318
|
if (stats.tsFailed > 0) {
|
|
306
|
-
|
|
319
|
+
stats.errmsg = (0, i18n_js_1.t)('download.status.segmentsFailed', lang, { count: stats.tsFailed });
|
|
320
|
+
result.errmsg = stats.errmsg;
|
|
321
|
+
utils_js_1.logger.warn((0, i18n_js_1.t)('download.status.downloadFailedRetry', lang), stats.tsFailed);
|
|
307
322
|
}
|
|
308
323
|
else if (options.convert !== false) {
|
|
324
|
+
stats.errmsg = (0, i18n_js_1.t)('download.status.mergingVideo', lang);
|
|
325
|
+
if (options.onProgress)
|
|
326
|
+
options.onProgress(stats.tsCount, m3u8Info.tsCount, null, stats);
|
|
309
327
|
result.filepath = await (0, m3u8_convert_js_1.m3u8Convert)(options, m3u8Info.data);
|
|
328
|
+
stats.errmsg = result.filepath ? '' : (0, i18n_js_1.t)('download.status.mergeFailed', lang);
|
|
310
329
|
if (result.filepath && (0, node_fs_1.existsSync)(result.filepath)) {
|
|
311
330
|
stats.size = (0, node_fs_1.statSync)(result.filepath).size;
|
|
312
331
|
if (options.delCache)
|
|
313
332
|
(0, fe_utils_1.rmrfAsync)((0, node_path_1.dirname)(m3u8Info.data[0].tsOut));
|
|
314
333
|
}
|
|
315
334
|
}
|
|
335
|
+
if (options.onProgress)
|
|
336
|
+
options.onProgress(stats.tsCount, m3u8Info.tsCount, null, stats);
|
|
316
337
|
}
|
|
317
338
|
utils_js_1.logger.debug('Done!', url, result.m3u8Info);
|
|
318
339
|
return result;
|
package/cjs/lib/utils.js
CHANGED
|
@@ -23,11 +23,11 @@ const getRetry = (url, headers, retries = 3) => (0, fe_utils_1.retry)(() => expo
|
|
|
23
23
|
});
|
|
24
24
|
exports.getRetry = getRetry;
|
|
25
25
|
exports.logger = fe_utils_1.NLogger.getLogger('[M3U8-DL]', { color: fe_utils_1.color });
|
|
26
|
-
|
|
26
|
+
const ffmpegTestCache = {};
|
|
27
27
|
function isSupportFfmpeg(ffmpegBin) {
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
return
|
|
28
|
+
if (!(ffmpegBin in ffmpegTestCache))
|
|
29
|
+
ffmpegTestCache[ffmpegBin] = (0, fe_utils_1.execSync)(`${ffmpegBin} -version`).stderr === '';
|
|
30
|
+
return ffmpegTestCache[ffmpegBin];
|
|
31
31
|
}
|
|
32
32
|
function findFiles(apidir, validate) {
|
|
33
33
|
const files = [];
|
|
@@ -35,14 +35,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.DLServer = void 0;
|
|
37
37
|
const node_fs_1 = require("node:fs");
|
|
38
|
-
const node_path_1 = require("node:path");
|
|
39
38
|
const node_os_1 = require("node:os");
|
|
39
|
+
const node_path_1 = require("node:path");
|
|
40
40
|
const fe_utils_1 = require("@lzwme/fe-utils");
|
|
41
41
|
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
|
-
const m3u8_download_js_1 = require("../lib/m3u8-download.js");
|
|
45
44
|
const getM3u8Urls_js_1 = require("../lib/getM3u8Urls.js");
|
|
45
|
+
const i18n_js_1 = require("../lib/i18n.js");
|
|
46
|
+
const m3u8_download_js_1 = require("../lib/m3u8-download.js");
|
|
46
47
|
const utils_js_1 = require("../lib/utils.js");
|
|
47
48
|
const index_js_1 = require("../video-parser/index.js");
|
|
48
49
|
const rootDir = (0, node_path_1.resolve)(__dirname, '../..');
|
|
@@ -76,6 +77,7 @@ class DLServer {
|
|
|
76
77
|
debug: process.env.DS_DEBUG === '1',
|
|
77
78
|
saveDir: process.env.DS_SAVE_DIR || './downloads',
|
|
78
79
|
threadNum: 4,
|
|
80
|
+
ffmpegPath: process.env.DS_FFMPEG_PATH || undefined,
|
|
79
81
|
},
|
|
80
82
|
};
|
|
81
83
|
/** 下载任务缓存 */
|
|
@@ -172,6 +174,18 @@ class DLServer {
|
|
|
172
174
|
saveConfig(config, configPath) {
|
|
173
175
|
if (!configPath)
|
|
174
176
|
configPath = this.options.configPath;
|
|
177
|
+
// 验证 ffmpegPath 是否存在
|
|
178
|
+
if (config.ffmpegPath?.trim()) {
|
|
179
|
+
const ffmpegPath = config.ffmpegPath.trim();
|
|
180
|
+
if (!(0, node_fs_1.existsSync)(ffmpegPath)) {
|
|
181
|
+
throw new Error(`ffmpeg 路径不存在: ${ffmpegPath}`);
|
|
182
|
+
}
|
|
183
|
+
// 检查是否为文件(不是目录)
|
|
184
|
+
const stats = (0, node_fs_1.statSync)(ffmpegPath);
|
|
185
|
+
if (!stats.isFile()) {
|
|
186
|
+
throw new Error(`ffmpeg 路径不是文件: ${ffmpegPath}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
175
189
|
for (const [key, value] of Object.entries(config)) {
|
|
176
190
|
// @ts-expect-error 忽略类型错误
|
|
177
191
|
if (key in this.cfg.webOptions)
|
|
@@ -192,16 +206,29 @@ class DLServer {
|
|
|
192
206
|
this.app = app;
|
|
193
207
|
this.wss = wss;
|
|
194
208
|
app.use((req, res, next) => {
|
|
195
|
-
|
|
209
|
+
// 处理 SPA 路由:根路径和 /page/* 路径都返回 index.html
|
|
210
|
+
const isIndexPage = ['/', '/index.html'].includes(req.path) || req.path.startsWith('/page/');
|
|
211
|
+
const isPlayPage = req.path.startsWith('/play.html');
|
|
212
|
+
const isApi = req.path.startsWith('/api/');
|
|
213
|
+
if (!isApi && (isIndexPage || isPlayPage)) {
|
|
196
214
|
const version = this.serverInfo.version;
|
|
197
|
-
let
|
|
215
|
+
let htmlContent = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(rootDir, `client/${isPlayPage ? 'play' : 'index'}.html`), 'utf-8').replaceAll('{{version}}', version);
|
|
198
216
|
if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(rootDir, 'client/local/cdn'))) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
217
|
+
// 提取所有 zstatic.net 的 js 和 css 资源地址,若子路径存在于 local/cdn 目录下则替换为本地路径
|
|
218
|
+
const zstaticRegex = /https:\/\/s4\.zstatic\.net\/ajax\/libs\/[^\s"'`<>]+\.(js|css)/g;
|
|
219
|
+
const zstaticMatches = htmlContent.match(zstaticRegex);
|
|
220
|
+
if (zstaticMatches) {
|
|
221
|
+
for (const match of zstaticMatches) {
|
|
222
|
+
const relativePath = match.split('libs/')[1];
|
|
223
|
+
const localPath = (0, node_path_1.resolve)(rootDir, `client/local/cdn/${relativePath}`);
|
|
224
|
+
if ((0, node_fs_1.existsSync)(localPath)) {
|
|
225
|
+
htmlContent = htmlContent.replaceAll(match, `/local/cdn/${relativePath}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
htmlContent = htmlContent.replaceAll(/integrity="[^"]+"\n?/g, '');
|
|
229
|
+
}
|
|
203
230
|
}
|
|
204
|
-
res.setHeader('content-type', 'text/html').send(
|
|
231
|
+
res.setHeader('content-type', 'text/html').send(htmlContent);
|
|
205
232
|
}
|
|
206
233
|
else {
|
|
207
234
|
next();
|
|
@@ -221,9 +248,10 @@ class DLServer {
|
|
|
221
248
|
if (this.options.token && req.headers.authorization !== this.options.token) {
|
|
222
249
|
const ignorePaths = ['/healthcheck', '/localplay'];
|
|
223
250
|
if (!ignorePaths.some(d => req.url.includes(d))) {
|
|
251
|
+
const lang = this.getLangFromRequest(req);
|
|
224
252
|
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
|
225
253
|
utils_js_1.logger.warn('Unauthorized access:', clientIp, req.url, req.headers.authorization);
|
|
226
|
-
res.status(401).json({ message: '
|
|
254
|
+
res.status(401).json({ message: (0, i18n_js_1.t)('api.error.unauthorized', lang), code: 1008 });
|
|
227
255
|
return;
|
|
228
256
|
}
|
|
229
257
|
}
|
|
@@ -338,6 +366,28 @@ class DLServer {
|
|
|
338
366
|
this.wsSend('progress', nextItem[0]);
|
|
339
367
|
}
|
|
340
368
|
}
|
|
369
|
+
getLangFromRequest(req) {
|
|
370
|
+
// Try to get lang from query parameter
|
|
371
|
+
const queryLang = req.query?.lang;
|
|
372
|
+
if (queryLang && i18n_js_1.LANG_CODES.has(queryLang)) {
|
|
373
|
+
return queryLang;
|
|
374
|
+
}
|
|
375
|
+
// Try to get lang from Accept-Language header
|
|
376
|
+
const acceptLanguage = req.headers['accept-language'];
|
|
377
|
+
if (acceptLanguage) {
|
|
378
|
+
const langCode = acceptLanguage.toLowerCase().split(',')[0].split('-')[0].trim();
|
|
379
|
+
if (i18n_js_1.LANG_CODES.has(langCode)) {
|
|
380
|
+
return langCode;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
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
|
+
// Fallback to default
|
|
389
|
+
return (0, i18n_js_1.getLang)();
|
|
390
|
+
}
|
|
341
391
|
wsSend(type = 'progress', data) {
|
|
342
392
|
if (type === 'tasks' && !data) {
|
|
343
393
|
data = Object.fromEntries(this.dlCacheClone());
|
|
@@ -361,29 +411,40 @@ class DLServer {
|
|
|
361
411
|
res.json({ message: 'ok', code: 0 });
|
|
362
412
|
});
|
|
363
413
|
app.post('/api/config', (req, res) => {
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
414
|
+
const lang = this.getLangFromRequest(req);
|
|
415
|
+
try {
|
|
416
|
+
const config = req.body;
|
|
417
|
+
this.saveConfig(config);
|
|
418
|
+
res.json({ message: (0, i18n_js_1.t)('api.success.configUpdated', lang), code: 0 });
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
const errorMessage = error instanceof Error ? error.message : (0, i18n_js_1.t)('api.error.configSaveFailed', lang);
|
|
422
|
+
utils_js_1.logger.error('[saveConfig]', errorMessage);
|
|
423
|
+
res.status(400).json({ message: errorMessage, code: 1 });
|
|
424
|
+
}
|
|
367
425
|
});
|
|
368
426
|
app.get('/api/config', (_req, res) => {
|
|
369
|
-
res.json({ ...this.cfg.dlOptions, ...this.cfg.webOptions });
|
|
427
|
+
res.json({ code: 0, data: { ...this.cfg.dlOptions, ...this.cfg.webOptions } });
|
|
370
428
|
});
|
|
371
429
|
// API to get all download progress
|
|
372
430
|
app.get('/api/tasks', (_req, res) => {
|
|
373
|
-
res.json(Object.fromEntries(this.dlCacheClone()));
|
|
431
|
+
res.json({ code: 0, data: Object.fromEntries(this.dlCacheClone()) });
|
|
374
432
|
});
|
|
375
433
|
// API to get queue status
|
|
376
434
|
app.get('/api/queue/status', (_req, res) => {
|
|
377
435
|
const pendingTasks = Array.from(this.dlCache.entries()).filter(([_, item]) => item.status === 'pending');
|
|
378
436
|
const activeTasks = Array.from(this.dlCache.entries()).filter(([_, item]) => item.status === 'resume');
|
|
379
437
|
res.json({
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
438
|
+
code: 0,
|
|
439
|
+
data: {
|
|
440
|
+
queueLength: pendingTasks.length,
|
|
441
|
+
activeDownloads: activeTasks.map(([url]) => url),
|
|
442
|
+
maxConcurrent: this.cfg.webOptions.maxDownloads,
|
|
443
|
+
},
|
|
383
444
|
});
|
|
384
445
|
});
|
|
385
446
|
// API to clear queue
|
|
386
|
-
app.post('/api/queue/clear', (
|
|
447
|
+
app.post('/api/queue/clear', (req, res) => {
|
|
387
448
|
let count = 0;
|
|
388
449
|
for (const [url, item] of this.dlCache.entries()) {
|
|
389
450
|
if (item.status === 'pending') {
|
|
@@ -393,23 +454,25 @@ class DLServer {
|
|
|
393
454
|
}
|
|
394
455
|
if (count)
|
|
395
456
|
this.wsSend('tasks');
|
|
396
|
-
|
|
457
|
+
const lang = this.getLangFromRequest(req);
|
|
458
|
+
res.json({ message: (0, i18n_js_1.t)('api.success.queueCleared', lang, { count }), code: 0 });
|
|
397
459
|
});
|
|
398
460
|
// API to update task priority
|
|
399
|
-
app.post('/api/priority', (req, res) => {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
});
|
|
461
|
+
// app.post('/api/priority', (req, res) => {
|
|
462
|
+
// const { url, priority } = req.body;
|
|
463
|
+
// const item = this.dlCache.get(url);
|
|
464
|
+
// if (!item) {
|
|
465
|
+
// res.json({ message: '任务不存在', code: 1 });
|
|
466
|
+
// return;
|
|
467
|
+
// }
|
|
468
|
+
// item.options.priority = priority;
|
|
469
|
+
// this.saveCache();
|
|
470
|
+
// res.json({ message: '已更新任务优先级', code: 0 });
|
|
471
|
+
// });
|
|
410
472
|
// API to start m3u8 download
|
|
411
473
|
app.post('/api/download', (req, res) => {
|
|
412
474
|
const { url, options = {}, list = [] } = req.body;
|
|
475
|
+
const lang = this.getLangFromRequest(req);
|
|
413
476
|
try {
|
|
414
477
|
if (list.length) {
|
|
415
478
|
for (const item of list) {
|
|
@@ -420,11 +483,11 @@ class DLServer {
|
|
|
420
483
|
}
|
|
421
484
|
else if (url)
|
|
422
485
|
this.startDownload(url, options);
|
|
423
|
-
res.json({ message:
|
|
486
|
+
res.json({ message: (0, i18n_js_1.t)('api.success.downloadStarted', lang, { count: list.length || 1 }), code: 0 });
|
|
424
487
|
this.wsSend('tasks');
|
|
425
488
|
}
|
|
426
489
|
catch (error) {
|
|
427
|
-
res.status(500).json({ error:
|
|
490
|
+
res.status(500).json({ error: `${(0, i18n_js_1.t)('api.error.downloadFailed', lang)}: ${error.message || ''}` });
|
|
428
491
|
}
|
|
429
492
|
});
|
|
430
493
|
// API to pause download
|
|
@@ -445,7 +508,8 @@ class DLServer {
|
|
|
445
508
|
this.wsSend('progress', list);
|
|
446
509
|
this.startNextPending();
|
|
447
510
|
}
|
|
448
|
-
|
|
511
|
+
const lang = this.getLangFromRequest(req);
|
|
512
|
+
res.json({ message: (0, i18n_js_1.t)('api.success.paused', lang, { count: list.length }), code: 0, count: list.length });
|
|
449
513
|
});
|
|
450
514
|
// API to resume download
|
|
451
515
|
app.post('/api/resume', (req, res) => {
|
|
@@ -464,7 +528,12 @@ class DLServer {
|
|
|
464
528
|
}
|
|
465
529
|
if (list.length)
|
|
466
530
|
this.wsSend('progress', list);
|
|
467
|
-
|
|
531
|
+
const lang = this.getLangFromRequest(req);
|
|
532
|
+
res.json({
|
|
533
|
+
message: list.length ? (0, i18n_js_1.t)('api.success.resumed', lang, { count: list.length }) : (0, i18n_js_1.t)('api.success.noResumableTasks', lang),
|
|
534
|
+
code: 0,
|
|
535
|
+
count: list.length,
|
|
536
|
+
});
|
|
468
537
|
});
|
|
469
538
|
// API to delete download
|
|
470
539
|
app.post('/api/delete', (req, res) => {
|
|
@@ -500,7 +569,8 @@ class DLServer {
|
|
|
500
569
|
this.saveCache();
|
|
501
570
|
this.startNextPending();
|
|
502
571
|
}
|
|
503
|
-
|
|
572
|
+
const lang = this.getLangFromRequest(req);
|
|
573
|
+
res.json({ message: (0, i18n_js_1.t)('api.success.deleted', lang, { count: list.length }), code: 0, count: list.length });
|
|
504
574
|
});
|
|
505
575
|
app.get(/^\/localplay\/(.*)$/, (req, res) => {
|
|
506
576
|
let filepath = decodeURIComponent(req.params[0]);
|
|
@@ -526,7 +596,8 @@ class DLServer {
|
|
|
526
596
|
const isAllow = !this.options.limitFileAccess || allowedDirs.some(d => filepath.startsWith((0, node_path_1.resolve)(d)));
|
|
527
597
|
if (!isAllow) {
|
|
528
598
|
utils_js_1.logger.error('[Localplay] Access denied:', filepath);
|
|
529
|
-
|
|
599
|
+
const lang = this.getLangFromRequest(req);
|
|
600
|
+
res.send({ message: (0, i18n_js_1.t)('api.error.accessDenied', lang), code: 403 });
|
|
530
601
|
return;
|
|
531
602
|
}
|
|
532
603
|
}
|
|
@@ -553,12 +624,14 @@ class DLServer {
|
|
|
553
624
|
}
|
|
554
625
|
}
|
|
555
626
|
utils_js_1.logger.error('[Localplay]file not found:', (0, console_log_colors_1.red)(filepath));
|
|
556
|
-
|
|
627
|
+
const lang = this.getLangFromRequest(req);
|
|
628
|
+
res.status(404).send({ message: (0, i18n_js_1.t)('api.error.notFound', lang), code: 404 });
|
|
557
629
|
});
|
|
558
630
|
app.post('/api/getM3u8Urls', (req, res) => {
|
|
559
631
|
const { url, headers, subUrlRegex } = req.body;
|
|
632
|
+
const lang = this.getLangFromRequest(req);
|
|
560
633
|
if (!url) {
|
|
561
|
-
res.json({ code: 1001, message: '
|
|
634
|
+
res.json({ code: 1001, message: (0, i18n_js_1.t)('api.error.invalidUrl', lang) });
|
|
562
635
|
}
|
|
563
636
|
else {
|
|
564
637
|
(0, getM3u8Urls_js_1.getM3u8Urls)({ url, headers, subUrlRegex })
|
package/cjs/types/index.d.ts
CHANGED
package/cjs/types/index.js
CHANGED
|
@@ -16,5 +16,5 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
__exportStar(require("./common"), exports);
|
|
18
18
|
__exportStar(require("./m3u8"), exports);
|
|
19
|
-
__exportStar(require("./video-search"), exports);
|
|
20
19
|
__exportStar(require("./video-parser"), exports);
|
|
20
|
+
__exportStar(require("./video-search"), exports);
|
package/cjs/types/m3u8.d.ts
CHANGED
|
@@ -140,8 +140,10 @@ export interface M3u8DLOptions {
|
|
|
140
140
|
* - 'parser':下载 VideoParser 支持解析的平台视频文件
|
|
141
141
|
*/
|
|
142
142
|
type?: 'm3u8' | 'file' | 'parser';
|
|
143
|
-
/**
|
|
144
|
-
|
|
143
|
+
/** ffmpeg 可执行文件路径。如果未指定,则尝试使用系统 PATH 中的 'ffmpeg' */
|
|
144
|
+
ffmpegPath?: string;
|
|
145
|
+
/** 语言。可选值:zh-CN, en */
|
|
146
|
+
lang?: 'zh-CN' | 'en';
|
|
145
147
|
}
|
|
146
148
|
export interface M3u8DLResult extends Partial<DownloadResult> {
|
|
147
149
|
/** 下载进度统计 */
|