@anyul/koishi-plugin-rss 5.2.41 → 5.2.42
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 +51 -0
- package/lib/config.js +8 -1
- package/lib/core/item-processor.js +10 -0
- package/lib/core/telegram-video-restore.d.ts +31 -0
- package/lib/core/telegram-video-restore.js +168 -0
- package/lib/index.js +12 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/types.d.ts +18 -0
- package/lib/utils/media.js +35 -9
- package/lib/utils/tdl.d.ts +80 -0
- package/lib/utils/tdl.js +276 -0
- package/lib/utils/video-compress.d.ts +44 -0
- package/lib/utils/video-compress.js +191 -0
- package/package.json +1 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tdl (Telegram Downloader) 集成模块
|
|
3
|
+
*
|
|
4
|
+
* tdl 是 iyear/tdl 提供的 Go 编写独立 CLI 工具,**不是 npm 包**。
|
|
5
|
+
* 当 RSSHub 对 Telegram 超大视频返回 "Video is too big" 占位 blockquote 时,
|
|
6
|
+
* 通过 tdl 直接从 Telegram 拉取原始视频,再由 video-compress 模块按需压缩。
|
|
7
|
+
*
|
|
8
|
+
* 设计原则:
|
|
9
|
+
* - 所有外部调用都用 execFile(非 shell),参数数组化,避免注入。
|
|
10
|
+
* - 任何失败都返回 null 并打 debug 日志,**绝不抛错、绝不阻塞主流程**。
|
|
11
|
+
* - 二进制缺失走同一返回路径,上层无感知。
|
|
12
|
+
*/
|
|
13
|
+
import { Config, proxyAgent } from '../types';
|
|
14
|
+
/**
|
|
15
|
+
* 探测某个可执行文件是否存在于 PATH 中。
|
|
16
|
+
*
|
|
17
|
+
* @param name 二进制名称,如 'tdl' / 'ffmpeg'
|
|
18
|
+
* @param testArgs 用于触发版本输出的参数,如 ['-v'] / ['-version']
|
|
19
|
+
* @returns 存在返回 true,否则 false
|
|
20
|
+
*/
|
|
21
|
+
export declare function detectBinary(name: string, testArgs?: string[]): Promise<boolean>;
|
|
22
|
+
/** 重置二进制缓存(仅测试用) */
|
|
23
|
+
export declare function _resetBinaryCacheForTest(): void;
|
|
24
|
+
export interface TelegramLinkInfo {
|
|
25
|
+
/** 频道用户名或数字 ID(不带 -100 前缀) */
|
|
26
|
+
channel: string;
|
|
27
|
+
/** 频道内消息 ID */
|
|
28
|
+
messageId: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 从 Telegram 链接中解析频道与消息 ID。
|
|
32
|
+
*
|
|
33
|
+
* 支持形态:
|
|
34
|
+
* https://t.me/anyul996/28 -> { channel: 'anyul996', messageId: 28 }
|
|
35
|
+
* https://t.me/c/1234567890/28 -> { channel: 'c/1234567890', messageId: 28 }
|
|
36
|
+
* t.me/anyul996/28 -> 同上
|
|
37
|
+
*
|
|
38
|
+
* @param link 原始链接
|
|
39
|
+
* @returns 解析失败或非 Telegram 链接返回 null
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseTelegramLink(link: string): TelegramLinkInfo | null;
|
|
42
|
+
/**
|
|
43
|
+
* 检测 cheerio 文档中是否包含 Telegram 大视频占位符。
|
|
44
|
+
*
|
|
45
|
+
* RSSHub 对超大视频会渲染 `<blockquote><b>Video is too big</b><br><img.../></blockquote>`,
|
|
46
|
+
* 这里识别 blockquote 内是否含该文本。同时兜底检查全文,兼容个别 RSSHub 版本不包 blockquote 的情况。
|
|
47
|
+
*
|
|
48
|
+
* @param html cheerio 实例
|
|
49
|
+
* @returns 命中返回 true
|
|
50
|
+
*/
|
|
51
|
+
export declare function detectVideoTooBig(html: any): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* 从 cheerio 文档里提取 "Video is too big" 占位 blockquote 内的海报图地址。
|
|
54
|
+
*
|
|
55
|
+
* @param html cheerio 实例
|
|
56
|
+
* @returns 找到则返回图片 URL,否则空串
|
|
57
|
+
*/
|
|
58
|
+
export declare function extractTooBigPoster(html: any): string;
|
|
59
|
+
export interface DownloadWithTdlOptions {
|
|
60
|
+
config: Config;
|
|
61
|
+
/** Telegram 消息链接,如 https://t.me/anyul996/28 */
|
|
62
|
+
link: string;
|
|
63
|
+
/** 订阅级代理配置,启用且 config.tdl.proxyByEnv!=false 时透传给子进程 */
|
|
64
|
+
proxyAgent?: proxyAgent;
|
|
65
|
+
/** 下载超时秒数(默认取 config.tdl.timeout 或 180) */
|
|
66
|
+
timeoutSeconds?: number;
|
|
67
|
+
/** 工作目录(默认系统临时目录下随机子目录) */
|
|
68
|
+
workDir?: string;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 调用 tdl 下载单条 Telegram 消息的视频。
|
|
72
|
+
*
|
|
73
|
+
* 命令形态:`tdl dl -u <link> -d <tmpDir> --descrition false -f`
|
|
74
|
+
* (-f 关闭覆盖确认;下载产物落在 -d 指定目录)
|
|
75
|
+
*
|
|
76
|
+
* @returns 成功返回 mp4 绝对路径;二进制缺失/未登录/超时/无产物时返回 null
|
|
77
|
+
*/
|
|
78
|
+
export declare function downloadWithTdl(opts: DownloadWithTdlOptions): Promise<string | null>;
|
|
79
|
+
/** 静默删除目录,失败不抛错 */
|
|
80
|
+
export declare function safeRemoveDir(dir: string): Promise<void>;
|
package/lib/utils/tdl.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* tdl (Telegram Downloader) 集成模块
|
|
4
|
+
*
|
|
5
|
+
* tdl 是 iyear/tdl 提供的 Go 编写独立 CLI 工具,**不是 npm 包**。
|
|
6
|
+
* 当 RSSHub 对 Telegram 超大视频返回 "Video is too big" 占位 blockquote 时,
|
|
7
|
+
* 通过 tdl 直接从 Telegram 拉取原始视频,再由 video-compress 模块按需压缩。
|
|
8
|
+
*
|
|
9
|
+
* 设计原则:
|
|
10
|
+
* - 所有外部调用都用 execFile(非 shell),参数数组化,避免注入。
|
|
11
|
+
* - 任何失败都返回 null 并打 debug 日志,**绝不抛错、绝不阻塞主流程**。
|
|
12
|
+
* - 二进制缺失走同一返回路径,上层无感知。
|
|
13
|
+
*/
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.detectBinary = detectBinary;
|
|
49
|
+
exports._resetBinaryCacheForTest = _resetBinaryCacheForTest;
|
|
50
|
+
exports.parseTelegramLink = parseTelegramLink;
|
|
51
|
+
exports.detectVideoTooBig = detectVideoTooBig;
|
|
52
|
+
exports.extractTooBigPoster = extractTooBigPoster;
|
|
53
|
+
exports.downloadWithTdl = downloadWithTdl;
|
|
54
|
+
exports.safeRemoveDir = safeRemoveDir;
|
|
55
|
+
const child_process_1 = require("child_process");
|
|
56
|
+
const util_1 = require("util");
|
|
57
|
+
const fs = __importStar(require("fs"));
|
|
58
|
+
const path = __importStar(require("path"));
|
|
59
|
+
const os = __importStar(require("os"));
|
|
60
|
+
const crypto = __importStar(require("crypto"));
|
|
61
|
+
const logger_1 = require("./logger");
|
|
62
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
63
|
+
/** 进程内二进制探测结果缓存,避免每条 RSS 都 fork 探测 */
|
|
64
|
+
const binaryCache = {};
|
|
65
|
+
/**
|
|
66
|
+
* 探测某个可执行文件是否存在于 PATH 中。
|
|
67
|
+
*
|
|
68
|
+
* @param name 二进制名称,如 'tdl' / 'ffmpeg'
|
|
69
|
+
* @param testArgs 用于触发版本输出的参数,如 ['-v'] / ['-version']
|
|
70
|
+
* @returns 存在返回 true,否则 false
|
|
71
|
+
*/
|
|
72
|
+
async function detectBinary(name, testArgs = ['-v']) {
|
|
73
|
+
if (binaryCache[name] !== undefined)
|
|
74
|
+
return binaryCache[name];
|
|
75
|
+
try {
|
|
76
|
+
await execFileAsync(name, testArgs, { timeout: 10000, windowsHide: true });
|
|
77
|
+
binaryCache[name] = true;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
binaryCache[name] = false;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** 重置二进制缓存(仅测试用) */
|
|
86
|
+
function _resetBinaryCacheForTest() {
|
|
87
|
+
for (const k of Object.keys(binaryCache))
|
|
88
|
+
delete binaryCache[k];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 从 Telegram 链接中解析频道与消息 ID。
|
|
92
|
+
*
|
|
93
|
+
* 支持形态:
|
|
94
|
+
* https://t.me/anyul996/28 -> { channel: 'anyul996', messageId: 28 }
|
|
95
|
+
* https://t.me/c/1234567890/28 -> { channel: 'c/1234567890', messageId: 28 }
|
|
96
|
+
* t.me/anyul996/28 -> 同上
|
|
97
|
+
*
|
|
98
|
+
* @param link 原始链接
|
|
99
|
+
* @returns 解析失败或非 Telegram 链接返回 null
|
|
100
|
+
*/
|
|
101
|
+
function parseTelegramLink(link) {
|
|
102
|
+
if (!link || typeof link !== 'string')
|
|
103
|
+
return null;
|
|
104
|
+
const trimmed = link.trim();
|
|
105
|
+
// t.me/<channel>/<msgId>,协议可选
|
|
106
|
+
// 私有频道形如 t.me/c/1234567890/28,保留 c/<id> 段以便 tdl 直接消费
|
|
107
|
+
const match = trimmed.match(/^(?:https?:\/\/)?t\.me\/(c\/\d+|[A-Za-z0-9_]+)\/(\d+)\/?$/i);
|
|
108
|
+
if (!match)
|
|
109
|
+
return null;
|
|
110
|
+
const messageId = parseInt(match[2], 10);
|
|
111
|
+
if (!Number.isFinite(messageId) || messageId <= 0)
|
|
112
|
+
return null;
|
|
113
|
+
return { channel: match[1], messageId };
|
|
114
|
+
}
|
|
115
|
+
/** "Video is too big" 占位文本的匹配正则(大小写不敏感) */
|
|
116
|
+
const VIDEO_TOO_BIG_PATTERN = /video\s+is\s+too\s+big/i;
|
|
117
|
+
/**
|
|
118
|
+
* 检测 cheerio 文档中是否包含 Telegram 大视频占位符。
|
|
119
|
+
*
|
|
120
|
+
* RSSHub 对超大视频会渲染 `<blockquote><b>Video is too big</b><br><img.../></blockquote>`,
|
|
121
|
+
* 这里识别 blockquote 内是否含该文本。同时兜底检查全文,兼容个别 RSSHub 版本不包 blockquote 的情况。
|
|
122
|
+
*
|
|
123
|
+
* @param html cheerio 实例
|
|
124
|
+
* @returns 命中返回 true
|
|
125
|
+
*/
|
|
126
|
+
function detectVideoTooBig(html) {
|
|
127
|
+
if (!html)
|
|
128
|
+
return false;
|
|
129
|
+
try {
|
|
130
|
+
// 优先在 blockquote 内查找
|
|
131
|
+
let hit = false;
|
|
132
|
+
html('blockquote').each((_, el) => {
|
|
133
|
+
if (hit)
|
|
134
|
+
return;
|
|
135
|
+
const text = html(el).text() || '';
|
|
136
|
+
if (VIDEO_TOO_BIG_PATTERN.test(text))
|
|
137
|
+
hit = true;
|
|
138
|
+
});
|
|
139
|
+
if (hit)
|
|
140
|
+
return true;
|
|
141
|
+
// 兜底:全文(极少数 RSSHub 版本不包 blockquote)
|
|
142
|
+
const fullText = html('body').text() || html.root().text() || '';
|
|
143
|
+
return VIDEO_TOO_BIG_PATTERN.test(fullText);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 从 cheerio 文档里提取 "Video is too big" 占位 blockquote 内的海报图地址。
|
|
151
|
+
*
|
|
152
|
+
* @param html cheerio 实例
|
|
153
|
+
* @returns 找到则返回图片 URL,否则空串
|
|
154
|
+
*/
|
|
155
|
+
function extractTooBigPoster(html) {
|
|
156
|
+
if (!html)
|
|
157
|
+
return '';
|
|
158
|
+
try {
|
|
159
|
+
let poster = '';
|
|
160
|
+
html('blockquote').each((_, el) => {
|
|
161
|
+
if (poster)
|
|
162
|
+
return;
|
|
163
|
+
const $el = html(el);
|
|
164
|
+
const text = $el.text() || '';
|
|
165
|
+
if (VIDEO_TOO_BIG_PATTERN.test(text)) {
|
|
166
|
+
const img = $el.find('img').first().attr('src') || '';
|
|
167
|
+
if (img)
|
|
168
|
+
poster = img;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
return poster;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return '';
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 调用 tdl 下载单条 Telegram 消息的视频。
|
|
179
|
+
*
|
|
180
|
+
* 命令形态:`tdl dl -u <link> -d <tmpDir> --descrition false -f`
|
|
181
|
+
* (-f 关闭覆盖确认;下载产物落在 -d 指定目录)
|
|
182
|
+
*
|
|
183
|
+
* @returns 成功返回 mp4 绝对路径;二进制缺失/未登录/超时/无产物时返回 null
|
|
184
|
+
*/
|
|
185
|
+
async function downloadWithTdl(opts) {
|
|
186
|
+
const { config, link, proxyAgent } = opts;
|
|
187
|
+
const timeoutSeconds = opts.timeoutSeconds ?? config.tdl?.timeout ?? 180;
|
|
188
|
+
// 1. 探测 tdl 二进制
|
|
189
|
+
const hasTdl = await detectBinary('tdl', ['-v']);
|
|
190
|
+
if (!hasTdl) {
|
|
191
|
+
(0, logger_1.debug)(config, '未在 PATH 中检测到 tdl,跳过 Telegram 大视频下载(请安装 iyear/tdl 并执行 tdl login)', 'tdl', 'info');
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
// 2. 准备临时目录
|
|
195
|
+
const workDir = opts.workDir || path.join(os.tmpdir(), `rss-owl-tdl-${crypto.randomBytes(6).toString('hex')}`);
|
|
196
|
+
try {
|
|
197
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
(0, logger_1.debug)(config, `tdl 临时目录创建失败: ${err}`, 'tdl', 'error');
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
// 3. 构造子进程环境(按需透传代理)
|
|
204
|
+
const childEnv = { ...process.env };
|
|
205
|
+
const proxyByEnv = config.tdl?.proxyByEnv !== false;
|
|
206
|
+
if (proxyByEnv && proxyAgent?.enabled && proxyAgent.host && proxyAgent.port) {
|
|
207
|
+
const proxyUrl = `${proxyAgent.protocol || 'http'}://${proxyAgent.auth?.enabled
|
|
208
|
+
? `${encodeURIComponent(proxyAgent.auth.username)}:${encodeURIComponent(proxyAgent.auth.password)}@`
|
|
209
|
+
: ''}${proxyAgent.host}:${proxyAgent.port}`;
|
|
210
|
+
childEnv.HTTPS_PROXY = proxyUrl;
|
|
211
|
+
childEnv.HTTP_PROXY = proxyUrl;
|
|
212
|
+
(0, logger_1.debug)(config, `tdl 子进程已透传代理: ${proxyAgent.protocol}://${proxyAgent.host}:${proxyAgent.port}`, 'tdl', 'details');
|
|
213
|
+
}
|
|
214
|
+
const args = ['dl', '-u', link, '-d', workDir, '-f'];
|
|
215
|
+
(0, logger_1.debug)(config, `调用 tdl 下载: tdl ${args.join(' ')}(超时 ${timeoutSeconds}s)`, 'tdl', 'info');
|
|
216
|
+
try {
|
|
217
|
+
await execFileAsync('tdl', args, {
|
|
218
|
+
env: childEnv,
|
|
219
|
+
timeout: timeoutSeconds * 1000,
|
|
220
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
221
|
+
windowsHide: true,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
(0, logger_1.debug)(config, `tdl 下载失败: ${err?.message || err}(可能未登录 tdl login,或消息不含可下载媒体)`, 'tdl', 'error');
|
|
226
|
+
// 即便失败也清理临时目录
|
|
227
|
+
await safeRemoveDir(workDir);
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
// 4. 扫描产物,挑体积最大的视频文件
|
|
231
|
+
const videoPath = pickLargestVideo(workDir);
|
|
232
|
+
if (!videoPath) {
|
|
233
|
+
(0, logger_1.debug)(config, `tdl 下载完成但目录内未发现视频文件: ${workDir}`, 'tdl', 'info');
|
|
234
|
+
await safeRemoveDir(workDir);
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
(0, logger_1.debug)(config, `tdl 下载成功: ${videoPath}`, 'tdl', 'info');
|
|
238
|
+
return videoPath;
|
|
239
|
+
}
|
|
240
|
+
/** 在目录中挑选体积最大的视频文件(mp4/mov/webm/mkv) */
|
|
241
|
+
function pickLargestVideo(dir) {
|
|
242
|
+
const videoExts = new Set(['.mp4', '.mov', '.webm', '.mkv', '.m4v']);
|
|
243
|
+
let best = null;
|
|
244
|
+
try {
|
|
245
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
246
|
+
for (const entry of entries) {
|
|
247
|
+
if (!entry.isFile())
|
|
248
|
+
continue;
|
|
249
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
250
|
+
if (!videoExts.has(ext))
|
|
251
|
+
continue;
|
|
252
|
+
const full = path.join(dir, entry.name);
|
|
253
|
+
try {
|
|
254
|
+
const stat = fs.statSync(full);
|
|
255
|
+
if (!best || stat.size > best.size)
|
|
256
|
+
best = { path: full, size: stat.size };
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// 单文件 stat 失败忽略
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
return best?.path ?? null;
|
|
267
|
+
}
|
|
268
|
+
/** 静默删除目录,失败不抛错 */
|
|
269
|
+
async function safeRemoveDir(dir) {
|
|
270
|
+
try {
|
|
271
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
// 忽略清理失败
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 视频压缩模块(基于外部 ffmpeg)
|
|
3
|
+
*
|
|
4
|
+
* 仅在 tdl 下载产物超过阈值时触发,调用 ffmpeg 重新编码以降低体积,
|
|
5
|
+
* 便于通过 OneBot / Telegram 等适配器发送。
|
|
6
|
+
*
|
|
7
|
+
* ffmpeg 也是外部二进制(非 npm 包)。按用户决策:
|
|
8
|
+
* - ffmpeg 缺失 → 整条视频跳过(返回 null),上游按"无视频"处理。
|
|
9
|
+
* - 压缩失败 → 同样返回 null。
|
|
10
|
+
*
|
|
11
|
+
* 所有外部调用使用 execFile(非 shell),参数数组化。
|
|
12
|
+
*/
|
|
13
|
+
import { Config } from '../types';
|
|
14
|
+
export interface CompressVideoResult {
|
|
15
|
+
/** 最终可发送的视频绝对路径(原始或压缩后) */
|
|
16
|
+
path: string;
|
|
17
|
+
/** 是否真的执行了压缩 */
|
|
18
|
+
compressed: boolean;
|
|
19
|
+
/** 原始字节数 */
|
|
20
|
+
originalSize: number;
|
|
21
|
+
/** 最终字节数 */
|
|
22
|
+
finalSize: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 判断是否需要压缩:体积超过阈值即触发。
|
|
26
|
+
*
|
|
27
|
+
* @param config 全局配置
|
|
28
|
+
*/
|
|
29
|
+
export declare function shouldCompress(filePath: string, config: Config): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* 按需压缩:体积超过阈值则压缩,否则原样返回。
|
|
32
|
+
*
|
|
33
|
+
* 调用方负责在发送完成后清理临时文件(含原始下载目录)。
|
|
34
|
+
*
|
|
35
|
+
* @returns 成功(压缩或无需压缩)返回结果对象;ffmpeg 缺失/失败导致整条跳过时返回 null
|
|
36
|
+
*/
|
|
37
|
+
export declare function compressVideoIfNeeded(inputPath: string, config: Config): Promise<CompressVideoResult | null>;
|
|
38
|
+
/**
|
|
39
|
+
* 清理一次 tdl+压缩流程产生的临时文件。
|
|
40
|
+
*
|
|
41
|
+
* - 若 finalPath 与 originalDir 不在同目录(压缩后产物在原目录内),则只需删 originalDir。
|
|
42
|
+
* - 调用方传入 tdl 下载目录与最终发送路径即可。
|
|
43
|
+
*/
|
|
44
|
+
export declare function cleanupTdlArtifacts(downloadDir: string, finalPath?: string): Promise<void>;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 视频压缩模块(基于外部 ffmpeg)
|
|
4
|
+
*
|
|
5
|
+
* 仅在 tdl 下载产物超过阈值时触发,调用 ffmpeg 重新编码以降低体积,
|
|
6
|
+
* 便于通过 OneBot / Telegram 等适配器发送。
|
|
7
|
+
*
|
|
8
|
+
* ffmpeg 也是外部二进制(非 npm 包)。按用户决策:
|
|
9
|
+
* - ffmpeg 缺失 → 整条视频跳过(返回 null),上游按"无视频"处理。
|
|
10
|
+
* - 压缩失败 → 同样返回 null。
|
|
11
|
+
*
|
|
12
|
+
* 所有外部调用使用 execFile(非 shell),参数数组化。
|
|
13
|
+
*/
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.shouldCompress = shouldCompress;
|
|
49
|
+
exports.compressVideoIfNeeded = compressVideoIfNeeded;
|
|
50
|
+
exports.cleanupTdlArtifacts = cleanupTdlArtifacts;
|
|
51
|
+
const child_process_1 = require("child_process");
|
|
52
|
+
const util_1 = require("util");
|
|
53
|
+
const fs = __importStar(require("fs"));
|
|
54
|
+
const path = __importStar(require("path"));
|
|
55
|
+
const logger_1 = require("./logger");
|
|
56
|
+
const tdl_1 = require("./tdl");
|
|
57
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
58
|
+
const DEFAULT_COMPRESS_THRESHOLD_MB = 30;
|
|
59
|
+
const DEFAULT_CRF = 30;
|
|
60
|
+
/** ffmpeg 单次压缩超时(ms) */
|
|
61
|
+
const COMPRESS_TIMEOUT_MS = 5 * 60 * 1000;
|
|
62
|
+
/**
|
|
63
|
+
* 读取文件字节数,失败返回 0。
|
|
64
|
+
*/
|
|
65
|
+
function fileSize(filePath) {
|
|
66
|
+
try {
|
|
67
|
+
return fs.statSync(filePath).size;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 判断是否需要压缩:体积超过阈值即触发。
|
|
75
|
+
*
|
|
76
|
+
* @param config 全局配置
|
|
77
|
+
*/
|
|
78
|
+
function shouldCompress(filePath, config) {
|
|
79
|
+
const thresholdMB = config.tdl?.compressThreshold ?? config.basic?.maxVideoSize ?? DEFAULT_COMPRESS_THRESHOLD_MB;
|
|
80
|
+
const thresholdBytes = thresholdMB * 1024 * 1024;
|
|
81
|
+
const size = fileSize(filePath);
|
|
82
|
+
return size > thresholdBytes;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 调用 ffmpeg 压缩单个视频文件。
|
|
86
|
+
*
|
|
87
|
+
* 编码参数:libx264 + CRF(默认30) + veryfast + AAC 96k + faststart
|
|
88
|
+
* 产物落在原文件同目录,文件名加 `.cmp` 后缀,避免覆盖原文件。
|
|
89
|
+
*
|
|
90
|
+
* @returns 压缩成功返回新文件路径;失败返回 null
|
|
91
|
+
*/
|
|
92
|
+
async function runFfmpegCompress(inputPath, config) {
|
|
93
|
+
const hasFfmpeg = await (0, tdl_1.detectBinary)('ffmpeg', ['-version']);
|
|
94
|
+
if (!hasFfmpeg) {
|
|
95
|
+
(0, logger_1.debug)(config, '未在 PATH 中检测到 ffmpeg,按策略跳过整条大视频(请安装 ffmpeg 或调高 maxVideoSize/compressThreshold)', 'compress', 'info');
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const crf = clampCrf(config.tdl?.crf ?? DEFAULT_CRF);
|
|
99
|
+
const parsed = path.parse(inputPath);
|
|
100
|
+
const outputPath = path.join(parsed.dir, `${parsed.name}.cmp${parsed.ext}`);
|
|
101
|
+
// 清理可能残留的输出文件
|
|
102
|
+
try {
|
|
103
|
+
fs.rmSync(outputPath, { force: true });
|
|
104
|
+
}
|
|
105
|
+
catch { /* ignore */ }
|
|
106
|
+
const args = [
|
|
107
|
+
'-y',
|
|
108
|
+
'-i', inputPath,
|
|
109
|
+
'-c:v', 'libx264',
|
|
110
|
+
'-crf', String(crf),
|
|
111
|
+
'-preset', 'veryfast',
|
|
112
|
+
'-c:a', 'aac',
|
|
113
|
+
'-b:a', '96k',
|
|
114
|
+
'-movflags', '+faststart',
|
|
115
|
+
outputPath,
|
|
116
|
+
];
|
|
117
|
+
(0, logger_1.debug)(config, `调用 ffmpeg 压缩: ffmpeg ${args.join(' ')}(CRF=${crf})`, 'compress', 'info');
|
|
118
|
+
try {
|
|
119
|
+
await execFileAsync('ffmpeg', args, {
|
|
120
|
+
timeout: COMPRESS_TIMEOUT_MS,
|
|
121
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
122
|
+
windowsHide: true,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
(0, logger_1.debug)(config, `ffmpeg 压缩失败: ${err?.message || err}`, 'compress', 'error');
|
|
127
|
+
try {
|
|
128
|
+
fs.rmSync(outputPath, { force: true });
|
|
129
|
+
}
|
|
130
|
+
catch { /* ignore */ }
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (!fs.existsSync(outputPath) || fileSize(outputPath) === 0) {
|
|
134
|
+
(0, logger_1.debug)(config, 'ffmpeg 压缩完成但产物无效(空文件或不存在)', 'compress', 'error');
|
|
135
|
+
try {
|
|
136
|
+
fs.rmSync(outputPath, { force: true });
|
|
137
|
+
}
|
|
138
|
+
catch { /* ignore */ }
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return outputPath;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* 按需压缩:体积超过阈值则压缩,否则原样返回。
|
|
145
|
+
*
|
|
146
|
+
* 调用方负责在发送完成后清理临时文件(含原始下载目录)。
|
|
147
|
+
*
|
|
148
|
+
* @returns 成功(压缩或无需压缩)返回结果对象;ffmpeg 缺失/失败导致整条跳过时返回 null
|
|
149
|
+
*/
|
|
150
|
+
async function compressVideoIfNeeded(inputPath, config) {
|
|
151
|
+
const originalSize = fileSize(inputPath);
|
|
152
|
+
if (originalSize === 0) {
|
|
153
|
+
(0, logger_1.debug)(config, `待处理的视频文件为空或不存在: ${inputPath}`, 'compress', 'error');
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
if (!shouldCompress(inputPath, config)) {
|
|
157
|
+
(0, logger_1.debug)(config, `视频体积 ${(originalSize / 1024 / 1024).toFixed(2)} MB 未超阈值,无需压缩`, 'compress', 'details');
|
|
158
|
+
return { path: inputPath, compressed: false, originalSize, finalSize: originalSize };
|
|
159
|
+
}
|
|
160
|
+
const compressedPath = await runFfmpegCompress(inputPath, config);
|
|
161
|
+
if (!compressedPath) {
|
|
162
|
+
// ffmpeg 缺失或压缩失败 → 按用户策略跳过整条视频
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const finalSize = fileSize(compressedPath);
|
|
166
|
+
const ratio = originalSize > 0 ? ((1 - finalSize / originalSize) * 100).toFixed(1) : '0';
|
|
167
|
+
(0, logger_1.debug)(config, `视频压缩完成: ${(originalSize / 1024 / 1024).toFixed(2)} MB -> ${(finalSize / 1024 / 1024).toFixed(2)} MB(节省 ${ratio}%)`, 'compress', 'info');
|
|
168
|
+
return { path: compressedPath, compressed: true, originalSize, finalSize };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* 把 CRF 限定到合理区间 [18, 32]。
|
|
172
|
+
*
|
|
173
|
+
* 18 接近视觉无损、体积大;32 体积小但质量下降明显。
|
|
174
|
+
*/
|
|
175
|
+
function clampCrf(crf) {
|
|
176
|
+
if (!Number.isFinite(crf))
|
|
177
|
+
return DEFAULT_CRF;
|
|
178
|
+
return Math.min(32, Math.max(18, Math.round(crf)));
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* 清理一次 tdl+压缩流程产生的临时文件。
|
|
182
|
+
*
|
|
183
|
+
* - 若 finalPath 与 originalDir 不在同目录(压缩后产物在原目录内),则只需删 originalDir。
|
|
184
|
+
* - 调用方传入 tdl 下载目录与最终发送路径即可。
|
|
185
|
+
*/
|
|
186
|
+
async function cleanupTdlArtifacts(downloadDir, finalPath) {
|
|
187
|
+
// 删除整个下载目录(压缩产物也在其内)
|
|
188
|
+
await (0, tdl_1.safeRemoveDir)(downloadDir);
|
|
189
|
+
// finalPath 无需单独删,因为它就在 downloadDir 内
|
|
190
|
+
void finalPath;
|
|
191
|
+
}
|
package/package.json
CHANGED