import { Context, Schema } from 'koishi'; import { access, constants, readdir, stat, mkdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { resolve, delimiter, relative } from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import * as os from 'node:os'; import { FFmpeg } from './ffmpeg'; import { DownloadTask } from './downloader'; import { SilkService } from './silk'; export * from './ffmpeg'; import registry from 'get-registry'; declare module 'koishi' { interface Context { silk?: SilkService; } } const platform = os.platform(); const arch = os.arch(); export const name = 'ffmpeg-path'; export const reusable = false; export const filter = false; export const inject = { required: ['logger'], implements: ['ffmpeg'] }; export const usage = ` ---
Termux 相关 -- 环境配置 由于 Termux 的特殊文件系统结构,您需要手动指定 FFmpeg 的路径。请按照以下步骤操作: 1. **安装 FFmpeg:** 在 Termux 中运行以下命令安装 FFmpeg: \`\`\`bash pkg install ffmpeg -y \`\`\` 2. **查找 FFmpeg 路径:** 安装完成后,使用以下命令找到 FFmpeg 可执行文件的绝对路径: \`\`\`bash which ffmpeg \`\`\` 通常,您会得到类似于 \`/data/data/com.termux/files/usr/bin/ffmpeg\` 的路径。 3. **配置插件:** 在\`path\` 选项设置为您在上一步中找到的路径: \`/data/data/com.termux/files/usr/bin/ffmpeg\` 4. **重载插件:** 保存配置文件并重载插件,以使配置生效。
--- ### 其他平台 对于其他平台(Windows、macOS、Linux),支持自动检测环境变量和自动下载(无需额外安装 downloads 插件)。 如果您希望使用特定版本的 FFmpeg,您可以通过 \`path\` 选项手动指定路径,支持: - 文件的绝对路径或相对路径 - 文件夹的绝对路径或相对路径(自动查找 ffmpeg 可执行文件) - file:// URL 格式(可从浏览器地址栏复制) --- ### 自动检测 & 自动下载 如果您已经安装了 FFmpeg,并且配置好了环境变量,本插件会自动识别可执行文件的路径并使用。 如果您没有安装 FFmpeg,插件会自动下载 FFmpeg 到 \`./data/ffmpeg-path/ffmpeg\` 目录。 但是,请注意: * 自动下载的 FFmpeg 可能不是最新版本。 * 在某些环境中,自动下载可能会失败。 --- ### SILK 音频编码服务 启用 \`enableSilk\` 配置项后,插件会提供 SILK 音频编码服务,用于 QQ 官方平台的语音消息发送,无需额外安装 silk 插件。 --- `; export interface Config { loggerinfo: boolean; path?: string; autoDetect: boolean; autoDownload: boolean; enableSilk: boolean; } export const Config: Schema = Schema.intersect([ Schema.object({ autoDetect: Schema.boolean().default(true).description('自动检测环境变量中的 FFmpeg。'), autoDownload: Schema.boolean().default(true).description('找不到可执行文件时,自动下载 FFmpeg。'), }).description('FFmpeg 设置'), Schema.union([ Schema.object({ autoDetect: Schema.const(true) }), Schema.object({ autoDetect: Schema.const(false).required(), path: Schema.string().role('textarea', { rows: [2, 4] }).description('手动指定 ffmpeg 可执行文件的绝对路径'), }), ]), Schema.object({ enableSilk: Schema.boolean().default(false).description('启用 SILK 音频编码服务(用于 QQ 语音消息)'), }).description('进阶设置'), Schema.object({ loggerinfo: Schema.boolean().default(false).description('日志调试模式'), }).description('开发者选项'), ]); export async function apply(ctx: Context, config: Config) { let executable: string | null = null; let downloadTask: DownloadTask | null = null; const downloadPath = resolve(ctx.baseDir, 'data', 'ffmpeg-path', 'ffmpeg'); ctx.on('ready', () => { monitorAvailability(); }); // 在环境变量中查找 ffmpeg async function findExecutableInPath(): Promise { if (!config.autoDetect) return null; const pathVar = process.env.PATH || ''; const pathDirs = pathVar.split(delimiter); const executableNames = platform === 'win32' ? ['ffmpeg.exe', 'ffmpeg'] : ['ffmpeg']; for (const dir of pathDirs) { for (const name of executableNames) { const fullPath = resolve(dir, name); const checkedPath = await checkPath(fullPath); if (checkedPath) { logInfo(`在环境变量中找到 ffmpeg: ${checkedPath}`); return checkedPath; } } } logInfo('在环境变量 PATH 中未找到有效的 ffmpeg 可执行文件。'); return null; } // 持续监听可用性 async function monitorAvailability() { logInfo(config); // 1. 检查用户指定的 path if (config.path) { const userPath = await checkPath(config.path); if (userPath) { executable = userPath; ctx.logger.info(`使用用户指定的 FFmpeg 路径: ${pathToFileURL(executable).href}`); startServices(); return; } } // 2. 在环境变量中查找 ffmpeg const systemPathExecutable = await findExecutableInPath(); if (systemPathExecutable) { executable = systemPathExecutable; ctx.logger.info(`在环境变量中找到 FFmpeg: ${pathToFileURL(executable).href}`); startServices(); return; } // 3. 尝试 Termux 默认路径 const termuxPath = await checkPath('/data/data/com.termux/files/usr/bin/ffmpeg'); if (termuxPath) { executable = termuxPath; ctx.logger.info(`在 Termux 默认路径找到 FFmpeg: ${pathToFileURL(executable).href}`); startServices(); return; } // 4. 尝试查找下载目录下的 ffmpeg const downloadsDirExecutable = await tryFindDownloadsDir(); if (downloadsDirExecutable) { executable = downloadsDirExecutable; const relativeDir = relative(ctx.baseDir, downloadPath).replace(/\\/g, '/'); const relativeFile = relative(ctx.baseDir, executable).replace(/\\/g, '/'); ctx.logger.info(`使用 ./${relativeDir} 目录下的 FFmpeg 文件: ./${relativeFile}`); startServices(); return; } // 5. 自动下载 FFmpeg if (config.autoDownload) { const downloadedExecutable = await tryDownloadFFmpeg(); if (downloadedExecutable) { executable = downloadedExecutable; startServices(); return; } } ctx.logger.error('无法找到可用的 FFmpeg 可执行文件。插件启动失败。'); } function logInfo(...args: any[]) { if (config.loggerinfo) { (ctx.logger.info as (...args: any[]) => void)(...args); } } // 检查指定的 path 是否可用(支持文件或文件夹,绝对或相对路径,以及 file:// URL) async function checkPath(path: string): Promise { if (!path) return null; // 如果是 file:// URL,转换为普通路径 let normalPath = path; if (path.startsWith('file:///') || path.startsWith('file://')) { try { normalPath = fileURLToPath(path); } catch (error) { ctx.logger.warn(`无法解析 file:// URL: ${path}`); return null; } } // 解析为绝对路径 const absolutePath = resolve(ctx.baseDir, normalPath); if (!existsSync(absolutePath)) { return null; } try { const stats = await stat(absolutePath); // 如果是文件,检查是否可执行 if (stats.isFile()) { await access(absolutePath, constants.F_OK | constants.X_OK); return absolutePath; } // 如果是文件夹,查找 ffmpeg 可执行文件 if (stats.isDirectory()) { const ffmpegPath = resolve(absolutePath, platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); if (existsSync(ffmpegPath)) { const ffmpegStats = await stat(ffmpegPath); if (ffmpegStats.isFile()) { await access(ffmpegPath, constants.F_OK | constants.X_OK); return ffmpegPath; } } } return null; } catch (error) { return null; } } // 尝试查找目录下的 ffmpeg async function tryFindDownloadsDir(): Promise { const dirsToCheck = [ './downloads', // downloads 插件使用的目录 downloadPath, // data/ffmpeg-path/ffmpeg ]; for (const dir of dirsToCheck) { if (!existsSync(dir)) continue; try { const files = await readdir(dir); for (const file of files) { const fullPath = resolve(dir, file); const stats = await stat(fullPath); if (stats.isDirectory()) { const ffmpegPath = resolve(fullPath, platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); const checkedPath = await checkPath(ffmpegPath); if (checkedPath) { return checkedPath; } } } } catch (error) { ctx.logger.warn(`查找 ${dir} 目录下的 FFmpeg 失败: `, error); } } return null; } // 下载 FFmpeg async function tryDownloadFFmpeg(): Promise { try { await mkdir(downloadPath, { recursive: true }); downloadTask = new DownloadTask( ctx, [`npm://@koishijs-assets/ffmpeg?registry=${await registry()}`], bucket(), { output: downloadPath } ); await downloadTask.start(); const path = await downloadTask.promise; const executablePath = platform === 'win32' ? `${path}/ffmpeg.exe` : `${path}/ffmpeg`; return executablePath; } catch (error) { ctx.logger.error('下载 FFmpeg 失败: ', error); return null; } } // 启动服务 function startServices() { if (executable) { ctx.plugin(FFmpeg, executable); // 如果启用 SILK 服务 if (config.enableSilk) { ctx.plugin(SilkService); } } } // 清理下载任务 ctx.on('dispose', () => { if (downloadTask) { downloadTask.cancel(); } }); } function bucket() { let bucket = 'ffmpeg-'; switch (platform) { case 'win32': bucket += 'windows-'; break; case 'linux': bucket += 'linux-'; break; case 'darwin': bucket += 'macos-'; break; default: throw new Error(`不支持的平台: ${platform}`); } switch (arch as string) { case 'arm': bucket += 'armel'; break; case 'arm64': bucket += 'arm64'; break; case 'x86': case 'ia32': bucket += 'i686'; break; case 'x64': bucket += 'amd64'; break; default: throw new Error(`不支持的架构: ${arch}`); } return bucket; }