From e4b21e94f5c4518393d7ff03a7f90e1089d68423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Thu, 17 Apr 2025 14:28:51 +0800 Subject: [PATCH] feat: ffmpeg download auto --- .github/workflows/build.yml | 6 - src/common/download-ffmpeg.ts | 308 ++++++++++++++++++++++++++++++++++ src/common/ffmpeg.ts | 20 ++- src/framework/napcat.ts | 7 + src/shell/base.ts | 7 + src/shell/napcat.ts | 3 +- vite.config.ts | 3 - 7 files changed, 336 insertions(+), 18 deletions(-) create mode 100644 src/common/download-ffmpeg.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f7c6a3fa..cd85b93b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,12 +38,6 @@ jobs: - name: Build NapCat.Shell run: | npm i && cd napcat.webui && npm i && cd .. || exit 1 - curl -sSL https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2025-04-16-12-54/ffmpeg-n7.1.1-6-g48c0f071d4-win64-lgpl-7.1.zip -o ffmpeg.zip - unzip -q ffmpeg.zip -d ffmpeg && rm ffmpeg.zip - cd ffmpeg - mv ffmpeg.exe ../external/ffmpeg/ffmpeg.exe - mv ffprobe.exe ../external/ffmpeg/ffprobe.exe - cd .. npm run build:shell && npm run depend || exit 1 rm package-lock.json - name: Upload Artifact diff --git a/src/common/download-ffmpeg.ts b/src/common/download-ffmpeg.ts new file mode 100644 index 00000000..dda93886 --- /dev/null +++ b/src/common/download-ffmpeg.ts @@ -0,0 +1,308 @@ +// 更正导入语句 +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/BtbN/FFmpeg-Builds/releases/download/autobuild-2025-04-16-12-54/ffmpeg-n7.1.1-6-g48c0f071d4-win64-lgpl-7.1.zip" +const urls = [ + "https://github.moeyy.xyz/" + downloadOri, + "https://ghp.ci/" + downloadOri, + "https://gh.api.99988866.xyz/" + downloadOri, + downloadOri +]; + +/** + * 测试URL是否可用 + * @param url 待测试的URL + * @returns 如果URL可访问返回true,否则返回false + */ +async function testUrl(url: string): Promise { + return new Promise((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 { + 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 { + return new Promise((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 { + try { + // 确保目标目录存在 + if (!fs.existsSync(extractDir)) { + fs.mkdirSync(extractDir, { recursive: true }); + } + + // 解压文件 + const zipStream = new compressing.zip.UncompressStream({ source: zipPath }); + + return new Promise((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(); + }); + }); + } catch (err) { + throw err; + } +} + +/** + * 下载并设置FFmpeg + * @param destDir 目标安装目录,默认为用户临时目录下的ffmpeg文件夹 + * @param tempDir 临时文件目录,默认为系统临时目录 + * @returns 返回ffmpeg可执行文件的路径,如果失败则返回null + */ +export async function downloadFFmpeg( + destDir?: string, + tempDir?: string, + progressCallback?: (percent: number, stage: string) => void +): Promise { + // 仅限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; + } +} +export async function downloadFFmpegIfNotExists(log:LogWrapper) { + // 仅限Windows + if (os.platform() !== 'win32') { + return { + path: null, + isExist: false + }; + } + 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) { + await downloadFFmpeg('./ffmpeg', './cache', (percentage: number, message: string) => { + log.log(`[Ffmpeg] [Download] ${percentage}% - ${message}`); + }); + return { + path: path.join(currentPath, 'ffmpeg'), + isExist: false + } + } + return { + path: path.join(currentPath, 'ffmpeg'), + isExist: true + } +} \ No newline at end of file diff --git a/src/common/ffmpeg.ts b/src/common/ffmpeg.ts index 65b84473..ffb285d5 100644 --- a/src/common/ffmpeg.ts +++ b/src/common/ffmpeg.ts @@ -6,25 +6,31 @@ import type { VideoInfo } from './video'; import { fileTypeFromFile } from 'file-type'; import imageSize from 'image-size'; import { fileURLToPath } from 'node:url'; +import { platform } from 'node:os'; const currentPath = dirname(fileURLToPath(import.meta.url)); const execFileAsync = promisify(execFile); const getFFmpegPath = (tool: string): string => { - const exeName = `${tool}.exe`; - const isLocalExeExists = existsSync(path.join(currentPath, 'ffmpeg', exeName)); - if (process.platform === 'win32') { + const exeName = `${tool}.exe`; + const isLocalExeExists = existsSync(path.join(currentPath, 'ffmpeg', exeName)); return isLocalExeExists ? path.join(currentPath, 'ffmpeg', exeName) : exeName; } return tool; }; - -const FFMPEG_CMD = getFFmpegPath('ffmpeg'); -const FFPROBE_CMD = getFFmpegPath('ffprobe'); - +export let FFMPEG_CMD = getFFmpegPath('ffmpeg'); +export let FFPROBE_CMD = getFFmpegPath('ffprobe'); console.log('[Info] ffmpeg:', FFMPEG_CMD); console.log('[Info] ffprobe:', FFPROBE_CMD); export class FFmpegService { // 确保目标目录存在 + public static setFfmpegPath(ffmpegPath: string): void { + if (platform() === 'win32') { + FFMPEG_CMD = path.join(ffmpegPath, 'ffmpeg.exe'); + FFPROBE_CMD = path.join(ffmpegPath, 'ffprobe.exe'); + console.log('[Info] ffmpeg:', FFMPEG_CMD); + console.log('[Info] ffprobe:', FFPROBE_CMD); + } + } private static ensureDirExists(filePath: string): void { const dir = dirname(filePath); if (!existsSync(dir)) { diff --git a/src/framework/napcat.ts b/src/framework/napcat.ts index d96e1ff3..fb5e2bfc 100644 --- a/src/framework/napcat.ts +++ b/src/framework/napcat.ts @@ -9,6 +9,8 @@ import { NodeIKernelLoginService } from '@/core/services'; import { NodeIQQNTWrapperSession, WrapperNodeApi } from '@/core/wrapper'; import { InitWebUi, WebUiConfig, webUiRuntimePort } from '@/webui'; import { NapCatOneBot11Adapter } from '@/onebot'; +import { downloadFFmpegIfNotExists } from '@/common/download-ffmpeg'; +import { FFmpegService } from '@/common/ffmpeg'; //Framework ES入口文件 export async function getWebUiUrl() { @@ -36,6 +38,11 @@ export async function NCoreInitFramework( const logger = new LogWrapper(pathWrapper.logsPath); const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion()); + downloadFFmpegIfNotExists(logger).then(({ path, isExist }) => { + if (!isExist && path) { + FFmpegService.setFfmpegPath(path); + } + }); //直到登录成功后,执行下一步 const selfInfo = await new Promise((resolveSelfInfo) => { const loginListener = new NodeIKernelLoginListener(); diff --git a/src/shell/base.ts b/src/shell/base.ts index 0ca81a7b..927639b5 100644 --- a/src/shell/base.ts +++ b/src/shell/base.ts @@ -31,6 +31,8 @@ import { WebUiDataRuntime } from '@/webui/src/helper/Data'; import { napCatVersion } from '@/common/version'; import { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener'; import { sleep } from '@/common/helper'; +import { downloadFFmpegIfNotExists } from '@/common/download-ffmpeg'; +import { FFmpegService } from '@/common/ffmpeg'; // NapCat Shell App ES 入口文件 async function handleUncaughtExceptions(logger: LogWrapper) { @@ -311,6 +313,11 @@ export async function NCoreInitShell() { const pathWrapper = new NapCatPathWrapper(); const logger = new LogWrapper(pathWrapper.logsPath); handleUncaughtExceptions(logger); + downloadFFmpegIfNotExists(logger).then(({ path, isExist }) => { + if (!isExist && path) { + FFmpegService.setFfmpegPath(path); + } +}); const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion()); diff --git a/src/shell/napcat.ts b/src/shell/napcat.ts index 5a41d404..c3f9bbe8 100644 --- a/src/shell/napcat.ts +++ b/src/shell/napcat.ts @@ -1,7 +1,6 @@ import { NCoreInitShell } from './base'; -import * as net from 'net'; // 引入 net 模块 +import * as net from 'net'; import * as process from 'process'; - if (process.platform === 'win32') { const pid = process.pid; const pipePath = `\\\\.\\pipe\\NapCat_${pid}`; diff --git a/vite.config.ts b/vite.config.ts index 7c8cae11..d2565857 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,7 +23,6 @@ if (process.env.NAPCAT_BUILDSYS == 'linux') { const UniversalBaseConfigPlugin: PluginOption[] = [ cp({ targets: [ - { src: './external/ffmpeg/', dest: 'dist/ffmpeg', flatten: true }, { src: './manifest.json', dest: 'dist' }, { src: './src/core/external/napcat.json', dest: 'dist/config/' }, { src: './src/native/packet', dest: 'dist/moehoo', flatten: false }, @@ -47,7 +46,6 @@ const UniversalBaseConfigPlugin: PluginOption[] = [ const FrameworkBaseConfigPlugin: PluginOption[] = [ cp({ targets: [ - { src: './external/ffmpeg/', dest: 'dist/ffmpeg', flatten: true }, { src: './manifest.json', dest: 'dist' }, { src: './src/core/external/napcat.json', dest: 'dist/config/' }, { src: './src/native/packet', dest: 'dist/moehoo', flatten: false }, @@ -67,7 +65,6 @@ const FrameworkBaseConfigPlugin: PluginOption[] = [ const ShellBaseConfigPlugin: PluginOption[] = [ cp({ targets: [ - { src: './external/ffmpeg/', dest: 'dist/ffmpeg', flatten: true }, { src: './src/native/packet', dest: 'dist/moehoo', flatten: false }, { src: './src/native/pty', dest: 'dist/pty', flatten: false }, { src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false },