mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-01-09 13:03:37 +08:00
* feat: pnpm new * Refactor build and release workflows, update dependencies Switch build scripts and workflows from npm to pnpm, update build and artifact paths, and simplify release workflow by removing version detection and changelog steps. Add new dependencies (silk-wasm, express, ws, node-pty-prebuilt-multiarch), update exports in package.json files, and add vite config for napcat-framework. Also, rename manifest.json for framework package and fix static asset copying in shell build config.
360 lines
10 KiB
TypeScript
360 lines
10 KiB
TypeScript
// 更正导入语句
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import * as https from 'https';
|
||
import * as os from 'os';
|
||
import * as compressing from 'compressing'; // 修正导入方式
|
||
import { pipeline } from 'stream/promises';
|
||
import { fileURLToPath } from 'url';
|
||
import { LogWrapper } from './log';
|
||
|
||
const downloadOri = 'https://github.com/NapNeko/ffmpeg-build/releases/download/v1.0.0/ffmpeg-7.1.1-win64.zip';
|
||
const urls = [
|
||
'https://j.1win.ggff.net/' + downloadOri,
|
||
'https://git.yylx.win/' + downloadOri,
|
||
'https://ghfile.geekertao.top/' + downloadOri,
|
||
'https://gh-proxy.net/' + downloadOri,
|
||
'https://ghm.078465.xyz/' + downloadOri,
|
||
'https://gitproxy.127731.xyz/' + downloadOri,
|
||
'https://jiashu.1win.eu.org/' + downloadOri,
|
||
'https://github.tbedu.top/' + downloadOri,
|
||
downloadOri,
|
||
];
|
||
|
||
/**
|
||
* 测试URL是否可用
|
||
* @param url 待测试的URL
|
||
* @returns 如果URL可访问返回true,否则返回false
|
||
*/
|
||
async function testUrl (url: string): Promise<boolean> {
|
||
return new Promise<boolean>((resolve) => {
|
||
const req = https.get(url, { timeout: 5000 }, (res) => {
|
||
// 检查状态码是否表示成功
|
||
const statusCode = res.statusCode || 0;
|
||
if (statusCode >= 200 && statusCode < 300) {
|
||
// 终止请求并返回true
|
||
req.destroy();
|
||
resolve(true);
|
||
} else {
|
||
req.destroy();
|
||
resolve(false);
|
||
}
|
||
});
|
||
|
||
req.on('error', () => {
|
||
resolve(false);
|
||
});
|
||
|
||
req.on('timeout', () => {
|
||
req.destroy();
|
||
resolve(false);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 查找第一个可用的URL
|
||
* @returns 返回第一个可用的URL,如果都不可用则返回null
|
||
*/
|
||
async function findAvailableUrl (): Promise<string | null> {
|
||
for (const url of urls) {
|
||
try {
|
||
const available = await testUrl(url);
|
||
if (available) {
|
||
return url;
|
||
}
|
||
} catch (_error) {
|
||
// 忽略错误
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
/**
|
||
* 下载文件
|
||
* @param url 下载URL
|
||
* @param destPath 目标保存路径
|
||
* @returns 成功返回true,失败返回false
|
||
*/
|
||
async function downloadFile (url: string, destPath: string, progressCallback?: (percent: number) => void): Promise<boolean> {
|
||
return new Promise<boolean>((resolve) => {
|
||
const file = fs.createWriteStream(destPath);
|
||
|
||
const req = https.get(url, (res) => {
|
||
const statusCode = res.statusCode || 0;
|
||
|
||
if (statusCode >= 200 && statusCode < 300) {
|
||
// 获取文件总大小
|
||
const totalSize = parseInt(res.headers['content-length'] || '0', 10);
|
||
let downloadedSize = 0;
|
||
let lastReportedPercent = -1; // 上次报告的百分比
|
||
let lastReportTime = 0; // 上次报告的时间戳
|
||
|
||
// 如果有内容长度和进度回调,则添加数据监听
|
||
if (totalSize > 0 && progressCallback) {
|
||
// 初始报告 0%
|
||
progressCallback(0);
|
||
lastReportTime = Date.now();
|
||
|
||
res.on('data', (chunk) => {
|
||
downloadedSize += chunk.length;
|
||
const currentPercent = Math.floor((downloadedSize / totalSize) * 100);
|
||
const now = Date.now();
|
||
|
||
// 只在以下条件触发回调:
|
||
// 1. 百分比变化至少为1%
|
||
// 2. 距离上次报告至少500毫秒
|
||
// 3. 确保报告100%完成
|
||
if ((currentPercent !== lastReportedPercent &&
|
||
(currentPercent - lastReportedPercent >= 1 || currentPercent === 100)) &&
|
||
(now - lastReportTime >= 1000 || currentPercent === 100)) {
|
||
progressCallback(currentPercent);
|
||
lastReportedPercent = currentPercent;
|
||
lastReportTime = now;
|
||
}
|
||
});
|
||
}
|
||
|
||
pipeline(res, file)
|
||
.then(() => {
|
||
// 确保最后报告100%
|
||
if (progressCallback && lastReportedPercent !== 100) {
|
||
progressCallback(100);
|
||
}
|
||
resolve(true);
|
||
})
|
||
.catch(() => resolve(false));
|
||
} else {
|
||
file.close();
|
||
fs.unlink(destPath, () => { });
|
||
resolve(false);
|
||
}
|
||
});
|
||
|
||
req.on('error', () => {
|
||
file.close();
|
||
fs.unlink(destPath, () => { });
|
||
resolve(false);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 解压缩zip文件中的特定内容
|
||
* 只解压bin目录中的文件到目标目录
|
||
* @param zipPath 压缩文件路径
|
||
* @param extractDir 解压目标路径
|
||
*/
|
||
async function extractBinDirectory (zipPath: string, extractDir: string): Promise<void> {
|
||
// 确保目标目录存在
|
||
if (!fs.existsSync(extractDir)) {
|
||
fs.mkdirSync(extractDir, { recursive: true });
|
||
}
|
||
|
||
// 解压文件
|
||
const zipStream = new compressing.zip.UncompressStream({ source: zipPath });
|
||
|
||
return new Promise<void>((resolve, reject) => {
|
||
// 监听条目事件
|
||
zipStream.on('entry', (header, stream, next) => {
|
||
// 获取文件路径
|
||
const filePath = header.name;
|
||
|
||
// 匹配内层bin目录中的文件
|
||
// 例如:ffmpeg-n7.1.1-6-g48c0f071d4-win64-lgpl-7.1/bin/ffmpeg.exe
|
||
if (filePath.includes('/bin/') && filePath.endsWith('.exe')) {
|
||
// 提取文件名
|
||
const fileName = path.basename(filePath);
|
||
const targetPath = path.join(extractDir, fileName);
|
||
|
||
// 创建写入流
|
||
const writeStream = fs.createWriteStream(targetPath);
|
||
|
||
// 将流管道连接到文件
|
||
stream.pipe(writeStream);
|
||
|
||
// 监听写入完成事件
|
||
writeStream.on('finish', () => {
|
||
next();
|
||
});
|
||
|
||
writeStream.on('error', () => {
|
||
next();
|
||
});
|
||
} else {
|
||
// 跳过不需要的文件
|
||
stream.resume();
|
||
next();
|
||
}
|
||
});
|
||
|
||
zipStream.on('error', (err) => {
|
||
reject(err);
|
||
});
|
||
|
||
zipStream.on('finish', () => {
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 下载并设置FFmpeg
|
||
* @param destDir 目标安装目录,默认为用户临时目录下的ffmpeg文件夹
|
||
* @param tempDir 临时文件目录,默认为系统临时目录
|
||
* @returns 返回ffmpeg可执行文件的路径,如果失败则返回null
|
||
*/
|
||
export async function downloadFFmpeg (
|
||
destDir?: string,
|
||
tempDir?: string,
|
||
progressCallback?: (percent: number, stage: string) => void
|
||
): Promise<string | null> {
|
||
// 仅限Windows
|
||
if (os.platform() !== 'win32') {
|
||
return null;
|
||
}
|
||
|
||
const destinationDir = destDir || path.join(os.tmpdir(), 'ffmpeg');
|
||
const tempDirectory = tempDir || os.tmpdir();
|
||
const zipFilePath = path.join(tempDirectory, 'ffmpeg.zip'); // 临时下载到指定临时目录
|
||
const ffmpegExePath = path.join(destinationDir, 'ffmpeg.exe');
|
||
|
||
// 确保目录存在
|
||
if (!fs.existsSync(destinationDir)) {
|
||
fs.mkdirSync(destinationDir, { recursive: true });
|
||
}
|
||
|
||
// 确保临时目录存在
|
||
if (!fs.existsSync(tempDirectory)) {
|
||
fs.mkdirSync(tempDirectory, { recursive: true });
|
||
}
|
||
|
||
// 如果ffmpeg已经存在,直接返回路径
|
||
if (fs.existsSync(ffmpegExePath)) {
|
||
if (progressCallback) progressCallback(100, '已找到FFmpeg');
|
||
return ffmpegExePath;
|
||
}
|
||
|
||
// 查找可用URL
|
||
if (progressCallback) progressCallback(0, '查找可用下载源');
|
||
const availableUrl = await findAvailableUrl();
|
||
if (!availableUrl) {
|
||
return null;
|
||
}
|
||
|
||
// 下载文件
|
||
if (progressCallback) progressCallback(5, '开始下载FFmpeg');
|
||
const downloaded = await downloadFile(
|
||
availableUrl,
|
||
zipFilePath,
|
||
(percent) => {
|
||
// 下载占总进度的70%
|
||
if (progressCallback) progressCallback(5 + Math.floor(percent * 0.7), '下载FFmpeg');
|
||
}
|
||
);
|
||
|
||
if (!downloaded) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
// 直接解压bin目录文件到目标目录
|
||
if (progressCallback) progressCallback(75, '解压FFmpeg');
|
||
await extractBinDirectory(zipFilePath, destinationDir);
|
||
|
||
// 清理下载文件
|
||
if (progressCallback) progressCallback(95, '清理临时文件');
|
||
try {
|
||
fs.unlinkSync(zipFilePath);
|
||
} catch (_err) {
|
||
// 忽略清理临时文件失败的错误
|
||
}
|
||
|
||
// 检查ffmpeg.exe是否成功解压
|
||
if (fs.existsSync(ffmpegExePath)) {
|
||
if (progressCallback) progressCallback(100, 'FFmpeg安装完成');
|
||
return ffmpegExePath;
|
||
} else {
|
||
return null;
|
||
}
|
||
} catch (_err) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查系统PATH环境变量中是否存在指定可执行文件
|
||
* @param executable 可执行文件名
|
||
* @returns 如果找到返回完整路径,否则返回null
|
||
*/
|
||
function findExecutableInPath (executable: string): string | null {
|
||
// 仅适用于Windows系统
|
||
if (os.platform() !== 'win32') return null;
|
||
|
||
// 获取PATH环境变量
|
||
const pathEnv = process.env['PATH'] || '';
|
||
const pathDirs = pathEnv.split(';');
|
||
|
||
// 检查每个目录
|
||
for (const dir of pathDirs) {
|
||
if (!dir) continue;
|
||
try {
|
||
const filePath = path.join(dir, executable);
|
||
if (fs.existsSync(filePath)) {
|
||
return filePath;
|
||
}
|
||
} catch (_error) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
export async function downloadFFmpegIfNotExists (log: LogWrapper) {
|
||
// 仅限Windows
|
||
if (os.platform() !== 'win32') {
|
||
return {
|
||
path: null,
|
||
reset: false,
|
||
};
|
||
}
|
||
const ffmpegInPath = findExecutableInPath('ffmpeg.exe');
|
||
const ffprobeInPath = findExecutableInPath('ffprobe.exe');
|
||
|
||
if (ffmpegInPath && ffprobeInPath) {
|
||
const ffmpegDir = path.dirname(ffmpegInPath);
|
||
return {
|
||
path: ffmpegDir,
|
||
reset: true,
|
||
};
|
||
}
|
||
|
||
// 如果环境变量中没有,检查项目目录中是否存在
|
||
const currentPath = path.dirname(fileURLToPath(import.meta.url));
|
||
const ffmpeg_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffmpeg.exe'));
|
||
const ffprobe_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffprobe.exe'));
|
||
|
||
if (!ffmpeg_exist || !ffprobe_exist) {
|
||
const url = await downloadFFmpeg(path.join(currentPath, 'ffmpeg'), path.join(currentPath, 'cache'), (percentage: number, message: string) => {
|
||
log.log(`[FFmpeg] [Download] ${percentage}% - ${message}`);
|
||
});
|
||
if (!url) {
|
||
log.log('[FFmpeg] [Error] 下载FFmpeg失败');
|
||
return {
|
||
path: null,
|
||
reset: false,
|
||
};
|
||
}
|
||
return {
|
||
path: path.join(currentPath, 'ffmpeg'),
|
||
reset: true,
|
||
};
|
||
}
|
||
|
||
return {
|
||
path: path.join(currentPath, 'ffmpeg'),
|
||
reset: true,
|
||
};
|
||
}
|