1插件系统概述
1.1 设计理念
影库管家插件系统是一个基于 JavaScript 的扩展机制,允许开发者在不修改主程序的前提下,为软件添加自定义功能。插件系统采用「进程隔离 + 代理通信」的架构设计,确保插件崩溃不会影响主程序稳定性,同时通过统一的权限校验机制保障系统安全。
即插即用
将插件目录复制到 plugins 文件夹即可自动发现并加载,无需重启应用
安全隔离
插件运行在独立进程,通过 WebSocket 与主程序通信,权限声明受控
按需启动
插件窗口按需创建,空闲自动关闭,节省系统资源
Node 能力
基于 NW.js 运行时,插件可使用部分 Node.js API 和 npm 生态,注意不支持需要编译的原生模块
1.2 核心功能
| 功能 | 说明 |
|---|---|
| 多种触发方式 | 支持循环(loop)、事件(event)、手动(manual)、路由(route)、替代(handler)、定时(cron)、UI 扩展(ui)七种触发器 |
| 宿主 API 代理 | 提供视频、数据库、HTTP、文件、FFmpeg、事件等 API,插件通过 SDK 调用 |
| 权限审批机制 | 插件需声明所需权限,用户审批后才能启用,保障数据安全 |
| 配置管理 | 支持字符串、数字、布尔、密码、下拉选择等多种配置项类型 |
| 日志系统 | 插件日志独立存储,支持分页查看和级别筛选 |
| 事件总线 | 插件可订阅宿主事件,也可发布自定义事件供其他插件订阅 |
| 数据表管理 | 插件可声明自己的数据表,启用时自动创建,卸载时可选保留 |
| 自动发现 | 文件监听机制实时检测插件目录变化,复制即用 |
| UI 扩展 | 插件可嵌入自定义页面到主界面菜单、视频右键菜单、详情面板等位置 |
1.3 应用场景
- AI 内容增强:调用大语言模型为视频生成摘要、标签、推荐语
- 批量数据处理:定期扫描数据库,对特定视频进行重命名、分类、清理
- 外部系统集成:对接网盘、字幕站、演员数据库等外部服务
- 自定义工作流:视频入库后自动触发转码、截图、人脸识别等流程
- 数据导出报表:定期生成统计报表,导出为 Excel/JSON
- HTTP 服务扩展:通过路由触发器对外提供 API,集成到其他系统
- 定时任务:通过 cron 触发器在精确时间点执行清理、同步、备份等任务
1.4 架构总览
┌─────────────────────────────────────────────────────────────┐
│ 影库管家主程序 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────┐ │
│ │ PluginManager │ │ PluginBridge │ │ EventBus │ │
│ │ 清单解析 │ │ WS 连接管理 │ │ 发布/订阅 │ │
│ │ 生命周期 │ │ 命令收发 │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬──────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 任务调度器(loop/event/manual/handler/route) │ │
│ │ + cron 调度器(独立组件,按 cron 表达式精确触发) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐│
│ │ Plugin API(插件可调用的宿主能力) ││
│ │ video.* / db.* / http.* / fs.* / ffmpeg.* / event.* ││
│ └──────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
│ WebSocket 双向通信
▼
┌─────────────────────────────────────────────────────────────┐
│ 插件隐藏窗口(独立进程,按需启动) │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ SDK 基类(随软件内置) │ │
│ │ + 插件 main.js(开发者编写的业务代码) │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
2开发环境搭建
2.1 运行时目录结构
理解影库管家的运行时目录布局对插件开发至关重要。插件必须放在 exe 同级目录,不能放在 package.nw 内(会被压缩进 exe 无法修改)。SDK 随软件内置,无需单独目录。
2.2 路径变量约定
本指南后续使用以下变量表示路径:
| 变量 | 含义 | 示例 |
|---|---|---|
{exe_dir} | exe 所在目录 | F:\nwjs-sdk-test\ |
{plugins_dir} | 插件根目录 | {exe_dir}/plugins/ |
{plugin_dir} | 单个插件目录 | {plugins_dir}/{plugin_id}/ |
{cache_dir} | 插件缓存目录 | {exe_dir}/cache/plugins/{plugin_id}/ |
2.3 开发环境准备
安装代码编辑器
推荐使用 VS Code,安装以下扩展提升开发体验:
- ESLint:JavaScript 语法检查
- TOML:manifest.toml 语法高亮
- SQLite:查看插件数据表
定位插件目录
打开影库管家安装目录,找到 plugins/ 文件夹。若不存在则手动创建。每个插件是一个独立子目录,目录名建议与插件 ID 一致。
创建第一个插件目录
在 plugins/ 下新建文件夹,例如 my-plugin/,在其中创建 manifest.toml 和 main.js 两个文件。
2.4 插件加载与运行机制
理解插件的加载和运行机制,有助于编写高效、稳定的插件。本节从窗口生命周期、多触发器调度、消息处理三个维度说明。
2.4.1 懒加载:按需启动窗口
插件启用后并不会立即启动窗口,而是注册触发器等待调用。只有当某个触发器真正需要执行时(如 loop 到时、event 收到事件、manual 被点击),系统才会按需创建插件隐藏窗口。
插件启用 → 注册触发器(不创建窗口)
↓
触发器触发 → 创建隐藏窗口 → 加载 main.js → 建立 WS 连接 → 执行 onLoad
↓
窗口空闲 → 自动关闭(释放资源)
2.4.2 单例窗口:多触发器共享
每个插件全局只有一个隐藏窗口实例(单例)。当一个插件声明了多个触发器(如 loop + manual + event),所有触发器共享同一个窗口和同一个插件代码实例。
插件 ai-summary 声明了 3 个触发器:
├─ loop(每 5 分钟扫描)
├─ event(监听 video.imported)
└─ manual(手动触发)
3 个触发器共享同一个窗口和 Plugin 实例
→ this.config 在所有触发器间共享
→ onLoad 只在窗口启动时调用一次
→ onUnload 只在窗口关闭时调用一次
onLoad 中初始化的状态(如数据库连接、缓存)会被所有触发器复用。避免在单个触发器处理中修改共享状态导致其他触发器行为异常。2.4.3 引用计数:窗口保活机制
系统通过引用计数管理窗口生命周期。每个触发器开始执行时"持有"窗口(计数 +1),执行完毕后"释放"(计数 -1)。只有当计数归零时窗口才会关闭。
时刻 T1:loop 触发,retain() → 计数=1,窗口启动
时刻 T2:event 到达,retain() → 计数=2,复用窗口
时刻 T3:loop 完成,release() → 计数=1,窗口保活
时刻 T4:event 完成,release() → 计数=0,窗口关闭
这种机制确保并发触发器不会互相抢占窗口,也不会因某个触发器提前结束导致其他触发器丢失窗口。
2.4.4 空闲保活:event/cron 专用
event 和 cron 触发器执行完毕后,会设置一个空闲计时器(idle_timeout_secs),在计时器到期前窗口保持运行。这样短时间内多次事件触发不会频繁创建/销毁窗口。
事件 1 到达 → 执行 → 设置 60s 空闲计时器
事件 2 在 30s 后到达 → 取消旧计时器 → 执行 → 重置 60s 计时器
事件 3 在 20s 后到达 → 取消旧计时器 → 执行 → 重置 60s 计时器
... 60s 内无新事件 → 计时器到期 → 关闭窗口
2.4.5 消息串行处理:避免并发乱序
SDK 内部维护消息队列,保证插件代码串行执行。所有来自后端的消息(load/fetch_items/process_item/event/manual/cron 等)按到达顺序入队,逐个处理。
后端发送:load → fetch_items → process_item
↓
SDK 消息队列:[load, fetch_items, process_item]
↓
串行执行:
1. await onLoad(ctx) ← 必须完成才会执行下一个
2. await fetchItems(ctx) ← onLoad 完成后才开始
3. await processItem(item) ← fetchItems 完成后才开始
onLoad 阻塞导致超时。这意味着 ctx.api.* 调用可以并发发起,但生命周期方法(onLoad/onEvent 等)严格串行。2.4.6 WS 连接与认证
插件窗口与后端通过 WebSocket 双向通信。每次窗口启动时,后端生成一次性 token 嵌入窗口 URL,窗口加载后用该 token 建立 WS 连接。token 使用后立即失效,防止重放攻击。
后端生成 token → 嵌入 URL → 窗口加载
→ SDK 用 token 连接 WS → 后端校验 token(一次性消费)
→ 连接成功 → 发送 load 消息 → onLoad 执行
2.4.7 错误处理与自动禁用
插件启动失败(语法错误、依赖缺失、WS 连接超时等)会进入错误状态。连续失败 3 次后,系统自动禁用该插件,避免故障插件反复拖慢系统。
| 错误场景 | 系统行为 |
|---|---|
| main.js 语法错误 | 窗口加载失败,记录错误,fail_count +1 |
| WS 连接超时(10s) | 窗口关闭,fail_count +1 |
| onLoad 抛出异常 | 发送错误响应,窗口保持运行(不计数) |
| 连续失败 3 次 | 自动禁用插件,需手动修复后重新启用 |
| 运行中 WS 断开 | 状态转为错误,pending 请求全部失败 |
2.4.8 热重载机制
系统监听 plugins/ 目录变化,自动发现新增插件或移除已删除插件。但已启用插件的代码变更不会自动重载,需要手动禁用后重新启用。
新增插件目录 → 自动发现并显示在插件列表
删除插件目录 → 自动标记为缺失并禁用
修改 main.js → 需禁用 → 重新启用(窗口重启时加载新代码)
修改 manifest.toml → 需禁用 → 重新启用(重新解析清单)
require.cache)并重新加载 main.js,因此禁用后重新启用即可加载最新代码,无需重启应用。2.4.9 各触发器的调度方式
不同触发器由不同的调度器驱动,但最终都通过同一个 PluginBridge 调用插件方法:
| 触发器 | 调度方式 | 调用方法 | 窗口保活 |
|---|---|---|---|
| loop | 定时器定期触发 | fetchItems → processItem | 运行期间持有 |
| event | 事件总线回调 | onEvent | idle_timeout_secs 保活 |
| cron | cron 表达式精确触发 | onCron | idle_timeout_secs 保活 |
| manual | 用户点击按钮 | onManual | 执行期间持有 |
| route | HTTP 请求到达 | onRoute | keep_alive_secs 保活 |
| handler | 替换原生处理逻辑 | onManual(复用) | 执行期间持有 |
3插件开发规范
3.1 命名规范
| 对象 | 规则 | 示例 |
|---|---|---|
| 插件 ID | 正则 ^[a-z][a-z0-9-]{1,62}$,全局唯一,小写字母+数字+连字符 | ai-summary、video-renamer |
| 插件目录名 | 建议与插件 ID 一致 | plugins/ai-summary/ |
| 入口文件 | 固定为 main.js,CommonJS 模块 | — |
| 清单文件 | 固定为 manifest.toml | — |
| 数据表名 | 自动添加前缀 plugin_{id}_ | plugin_ai_summary_summaries |
| 配置项 key | 小写字母+下划线,插件内唯一 | api_key、max_videos |
| 事件 topic | 点分式命名,建议带插件前缀 | ai-summary.generated |
| 版本号 | 遵循 semver 语义化版本 | 1.0.0、2.1.3 |
3.2 目录结构要求
3.3 代码风格标准
- 模块规范:使用 CommonJS(
require/module.exports),不使用 ES Module - 异步处理:使用
async/await,避免回调地狱 - 错误处理:所有异步操作使用
try/catch包裹,错误通过ctx.logger.error记录 - 资源清理:在
onUnload中释放定时器、连接等资源 - 日志输出:使用
ctx.logger而非console.log,日志会持久化到数据库 - 注释规范:复杂逻辑必须添加中文注释,函数需说明用途、参数、返回值
3.4 manifest.toml 完整 Schema
[plugin]
id = "my-plugin" # 插件 ID(必需,符合命名规范)
name = "我的插件" # 显示名称(必需)
version = "1.0.0" # 版本号(必需,semver)
author = "your-name" # 作者
description = "插件功能描述" # 描述
homepage = "https://..." # 可选:主页 URL
icon = "icon.png" # 可选:图标相对路径
license = "MIT" # 可选:许可证
entry = "main.js" # 入口文件(必需)
enabled = false # 初始启用状态(建议 false,让用户主动启用)
keep_data = false # 卸载时是否保留数据表
[dependencies]
ykgj = ">=1.0.0" # 宿主版本要求
plugins = [] # 依赖的其他插件 ID
# 触发器(可声明多个,共享同一个插件窗口)
[[trigger]]
type = "loop" # 触发类型:loop | event | manual | route
# loop 专用参数
interval_secs = 10 # 执行间隔(秒)
empty_interval_secs = 60 # 无数据时的间隔(秒)
batch_size = 5 # 每批处理数量
should_exit_when_idle = true # 空闲时是否退出窗口
concurrency = 1 # 并发数
[[trigger]]
type = "event"
event = "video.imported" # 订阅的事件类型
idle_timeout_secs = 60 # 无事件后关闭窗口的秒数
[[trigger]]
type = "manual"
display_name = "立即执行" # 前端按钮文字
[[trigger]]
type = "route"
path = "/generate" # 挂载到 /api/v1/plugins/{id}/* 下
method = "POST"
keep_alive_secs = 300 # 请求完成后保持窗口秒数
[[trigger]]
type = "cron" # 定时触发器,按 cron 表达式在精确时间点执行
schedule = "0 3 * * *" # cron 表达式(5 字段:分 时 日 月 周),每天 3:00 执行
catch_up = false # 可选:错过执行时是否补跑(如系统关机期间错过,启动后补跑一次)
timeout_secs = 300 # 可选:单次执行超时秒数,默认 300
idle_timeout_secs = 60 # 可选:执行完后保活秒数,默认 60
[[trigger]]
type = "ui" # UI 扩展触发器,向前端注入自定义 UI
ui_type = "menu_item" # UI 类型:page(整页)| menu_item(菜单项)| context_menu(右键菜单项)| panel(面板)
ui_slot = "main_menu" # 槽位标识,决定 UI 渲染位置(见下方槽位列表)
ui_page = "dashboard.html" # UI 页面路径(相对 plugin_dir/ui/),page 和 panel 类型必须
ui_label = "插件统计" # 显示文本
ui_icon = "fas fa-chart-bar" # 图标(Font Awesome 类名或 URL)
ui_order = 100 # 排序权重,越小越靠前
# 权限声明(必需,用户审批后才能启用)
[permissions]
database = ["vod_main:read", "vod_main:write:vod_content"]
filesystem = ["${video_dir}/*", "${plugin_dir}/*", "${cache_dir}/*"]
network = ["api.example.com", "127.0.0.1:*"]
native = ["ffmpeg", "ffprobe"]
# 用户配置项(可声明多个)
[[config]]
key = "model"
type = "string" # string | number | bool | secret | select | path | multiselect
default = "gpt-4"
label = "模型名称"
required = true
group = "基本设置"
order = 1
placeholder = "请输入模型名称"
pattern = "^[a-zA-Z]"
[[config]]
key = "api_key"
type = "secret"
label = "API Key"
required = true
encrypt = true # 存储时加密
[[config]]
key = "max_count"
type = "number"
default = 50
min = 1
max = 500
label = "最大数量"
[[config]]
key = "language"
type = "select"
default = "zh"
label = "语言"
options = [
{ value = "zh", label = "中文" },
{ value = "en", label = "英文" },
]
# 插件数据表(启用时自动创建)
[[tables]]
name = "records" # 实际表名:plugin_{id}_{name}
schema = """
CREATE TABLE IF NOT EXISTS plugin_my_plugin_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vod_id INTEGER,
result TEXT,
created_at INTEGER
);
"""
# 静态资源(可选)
[assets]
dir = "public"
mount = "/my-plugin"
3.5 校验规则速查
| 字段 | 规则 |
|---|---|
plugin.id | 正则 ^[a-z][a-z0-9-]{1,62}$,全局唯一 |
plugin.version | 必须符合 semver 格式 |
plugin.entry | 文件必须存在于插件目录 |
trigger.type | 枚举值:loop event manual handler route cron ui |
trigger.event | type = "event" 时必填 |
trigger.path + method | type = "route" 时必填 |
trigger.schedule | type = "cron" 时必填,5 字段 cron 表达式(分 时 日 月 周) |
trigger.replaces | type = "handler" 时必填,必须是现有 handler 名称 |
trigger.ui_type | type = "ui" 时必填,枚举值:page menu_item context_menu panel |
trigger.ui_slot | type = "ui" 时必填,非空字符串 |
trigger.ui_page | ui_type = "page" 或 "panel" 时必填 |
permissions.database | 格式 表名:read / 表名:write / 表名:write:字段 |
permissions.filesystem | glob 模式,支持 ${video_dir} ${plugin_dir} ${cache_dir} |
config.key | 插件内唯一 |
4核心 API 详解
插件通过 ctx.api.* 调用宿主能力。SDK 内部将调用转为 WebSocket 消息发送给 Rust 后端,后端校验权限后执行并返回结果。所有 API 均为异步(返回 Promise)。
4.1 插件基类 Plugin
所有插件必须继承 Plugin 基类,并按需重写以下生命周期方法:
插件加载时调用,用于初始化。可在此读取配置、建立连接、注册定时器。
参数:ctx - 插件上下文对象
返回:无
插件卸载时调用,用于清理资源(关闭连接、清除定时器等)。
配置变更时调用(仅插件窗口运行中时触发)。
loop 触发器专用:获取待处理项列表。
返回:数组,每个元素会传给 processItem
loop 触发器专用:处理单个项目。抛出异常视为失败。
event 触发器专用:处理订阅的事件。
参数:event = { type, payload }
manual 触发器专用:用户点击"立即执行"时调用。
返回:任意 JSON 可序列化对象,会推送到前端
route 触发器专用:处理 HTTP 请求。
参数:req = { method, path, headers, body }
返回:{ status, headers, body }
cron 触发器专用:按 cron 表达式在精确时间点触发执行。
参数:ctx.schedule 为 cron 表达式,ctx.timestamp 为触发时间戳(秒)
返回:任意 JSON 可序列化对象,记录到日志
说明:适合需要精确时间点执行的任务,如每天凌晨清理数据、每周一生成报表等。与 loop 触发器的区别在于 loop 按固定间隔执行,而 cron 可指定复杂的时间规则。
ui 触发器专用:当用户点击 UI 扩展项(context_menu / menu_item)时触发。
参数:payload = { slot, context },slot 为槽位标识,context 包含当前上下文(如 video_id)
返回:任意 JSON 可序列化对象,可传回前端执行动作
说明:仅 context_menu 和 menu_item 类型会触发此钩子;page 和 panel 类型不触发,插件页面通过 iframe 直接加载。
4.2 上下文对象 ctx
每个生命周期方法都会收到 ctx 对象,包含配置、日志、任务进度和宿主 API:
ctx = {
config: {}, // 当前插件配置(已解密)
pluginDir: '', // 插件目录绝对路径
batchId: '', // 当前批次 ID(loop 触发器)
batchSize: 10, // 批量大小(loop 触发器)
schedule: '', // cron 表达式(cron 触发器,如 "0 3 * * *")
timestamp: 0, // cron 触发时间戳,秒(cron 触发器)
logger: {
info(msg), // 记录信息日志
warn(msg), // 记录警告日志
error(msg), // 记录错误日志
},
task: {
reportProgress(processed, success, error), // 上报进度
},
api: {
video: { ... }, // 视频 API
db: { ... }, // 数据库 API
http: { ... }, // HTTP API
fs: { ... }, // 文件系统 API
ffmpeg: { ... }, // FFmpeg API
event: { ... }, // 事件 API
config: { ... }, // 配置 API
}
}
4.3 视频 API(ctx.api.video)
查询视频列表。
参数:
filter(Object):过滤条件,如{ vod_type: 1, vod_year: "2024" }limit(Number):返回数量上限
返回:视频对象数组
const videos = await ctx.api.video.list({ vod_type: 1 }, 10);
// 返回:[{ vod_id, vod_name, vod_pic, ... }, ...]
获取单个视频详情。
const video = await ctx.api.video.get(123);
// 返回:{ vod_id, vod_name, vod_content, ... }
更新视频字段。仅允许更新白名单字段。
vod_name vod_sub vod_content vod_tag vod_class vod_pic vod_blurb vod_remarks vod_area vod_lang vod_year vod_version vod_state。其他字段(如 vod_id、vod_type)禁止更新。await ctx.api.video.update(123, {
vod_content: '这是视频摘要',
vod_tag: '剧情,悬疑'
});
按文件路径查找视频。
const video = await ctx.api.video.getByPath('D:/videos/movie.mp4');
4.4 数据库 API(ctx.api.db)
执行只读 SQL 查询,返回包含 rows 字段的结果对象。
参数:
sql(String):SQL 语句,使用?占位params(Array):参数数组database(String,可选):目标数据库标识。访问视频表(vod_main/vod_episode/vod_detail)时需传"video";不传则走主库
// 查询没有摘要的视频(视频表需传 database: "video")
const result = await ctx.api.db.query(
'SELECT vod_id, vod_name FROM vod_main WHERE vod_content IS NULL LIMIT ?',
[10],
'video'
);
// 返回:{ rows: [{ vod_id: 1, vod_name: '...' }, ...] }
const rows = result.rows;
{ rows: [...] },需通过 .rows 访问结果数组,不是直接返回数组。执行写 SQL(INSERT/UPDATE/DELETE),返回受影响行数。
// 写入插件数据表(插件自有表无需 database 参数)
await ctx.api.db.execute(
'INSERT INTO plugin_my_plugin_records (vod_id, result, created_at) VALUES (?, ?, ?)',
[123, 'success', Math.floor(Date.now() / 1000)]
);
// 返回:{ affected_rows: 1 }
// 写入视频表需传 database: "video"
await ctx.api.db.execute(
'UPDATE vod_main SET vod_pic = ? WHERE vod_id = ?',
[picPath, 123],
'video'
);
plugin_{插件ID}_{表名},在 manifest.toml 的 [[tables]] 中声明后自动创建。插件自有表存储在主库,无需传 database 参数。affected_rows(不是 rows_affected)。另外 db.execute 不返回自增主键,如需获取新插入的 ID,需再次查询。4.5 HTTP API(ctx.api.http)
通过宿主代理发送 HTTP 请求,绕过浏览器跨域限制。目标域名必须在 permissions.network 白名单中。
通用 HTTP 请求。超时 2 分钟。
const data = await ctx.api.http.get('https://api.example.com/info', {
'Authorization': 'Bearer xxx'
});
const resp = await ctx.api.http.post(
'https://api.example.com/v1/chat',
{ model: 'gpt-4', messages: [...] },
{ 'Authorization': 'Bearer ' + apiKey }
);
4.6 文件系统 API(ctx.api.fs)
读写文件,路径必须在 permissions.filesystem 声明的 glob 模式内。单文件大小限制 10MB。
读取文件内容(文本)。
写入文件内容(覆盖)。
列出目录内容。
const result = await ctx.api.fs.readdir(ctx.pluginDir + '/data');
// 返回:{ entries: [{ name: 'file.txt', is_dir: false }, ...] }
4.7 FFmpeg API(ctx.api.ffmpeg)
调用 FFmpeg 处理媒体文件。需声明 native: ["ffmpeg"] 或 ["ffprobe"] 权限。
执行 FFmpeg 命令,超时 5 分钟。
const result = await ctx.api.ffmpeg.run([
'-i', 'D:/input.mp4',
'-ss', '00:00:10',
'-vframes', '1',
'D:/output.jpg'
]);
// 返回:{ exit_code: 0, stdout: '...', stderr: '...', duration_ms: 1234 }
获取媒体文件信息,超时 1 分钟。
4.8 事件 API(ctx.api.event)
发布自定义事件,其他插件可订阅。
await ctx.api.event.publish('my-plugin.done', {
vod_id: 123,
status: 'success'
});
运行时动态订阅事件。订阅后,事件到达时会触发 onEvent。
// 在 onLoad 中动态订阅
await ctx.api.event.subscribe('video.updated');
4.9 配置 API(ctx.api.config)
获取当前插件配置(已解密)。通常直接使用 ctx.config 即可。
4.10 日志与进度
// 记录日志(持久化到 plugin_logs 表)
ctx.logger.info('开始处理视频');
ctx.logger.warn('配置项为空,使用默认值');
ctx.logger.error('处理失败: ' + e.message);
// 上报进度(loop 触发器,前端可见)
ctx.task.reportProgress(processed, success, error);
// 示例:已处理 5 个,成功 4 个,失败 1 个
ctx.task.reportProgress(5, 4, 1);
5内置功能与数据库
5.1 内置功能列表
插件可订阅以下宿主事件,感知系统状态变化:
| 事件 | 触发时机 | Payload |
|---|---|---|
app.startup | 应用启动 | {} |
app.shutdown | 应用关闭 | {} |
video.imported | 视频入库后 | { vod_id, vod_name, vod_play_from, vod_type } |
video.updated | 视频信息更新 | { vod_id, changes } |
video.deleted | 视频删除前 | { vod_id } |
video.played | 用户播放视频 | { vod_id, position_ms, duration_ms } |
video.preview.generated | 预览生成完成 | { vod_id, preview_path } |
video.heatmap.generated | 热力图生成完成 | { vod_id, source_key } |
video.subtitle.generated | 字幕生成完成 | { vod_id, subtitle_path } |
video.face.recognized | 人脸识别完成 | { vod_id, actor_ids } |
task.completed | 任何任务完成 | { task_id, task_name } |
plugin.installed | 插件安装 | { plugin_id, version } |
plugin.uninstalled | 插件卸载 | { plugin_id } |
config.changed | 插件配置变更 | { plugin_id, changed_keys, config } |
5.2 可用数据库列表
插件通过 ctx.api.db.query/execute 访问数据库。需在 permissions.database 中声明对应表的读写权限。以下是主要数据库表及其字段说明。
db.query/execute 的第三参数 database 指定目标库:| database 参数 | 数据库文件 | 包含的表 |
|---|---|---|
"video" | db_video.db | vod_main、vod_episode、vod_detail |
| 不传(主库) | db_main.db | plugins、plugin_logs、插件自有表(plugin_*)等 |
| — | db_actor.db | actors、actor_faces、vod_actors(当前未通过 database 参数暴露,仅主库可访问时无效) |
| — | db_category.db | categories |
| — | image.db | images、galleries、tags、image_tags 等 |
| — | article_manager.db | articles、article_authors、article_media 等 |
vod_main、vod_episode、vod_detail 这三个视频表时,必须传 database: "video",否则会因表不存在而报错。插件自有表(plugin_*)存储在主库,无需传该参数。示例:ctx.api.db.query(sql, params, "video")。5.2.1 vod_main(视频主表)
存储视频元数据,是最常用的数据表。
| 字段 | 类型 | 说明 |
|---|---|---|
| vod_id | INTEGER | 视频 ID(主键,自增) |
| vod_type | INTEGER | 视频类型(0=其他,1=普通视频) |
| type_id | INTEGER | 分类 ID |
| group_id | INTEGER | 分组 ID |
| vod_name | TEXT | 视频名称 |
| vod_sub | TEXT | 副标题 |
| vod_dir | TEXT | 视频所在目录 |
| vod_dirkey | TEXT | 目录键(用于去重) |
| vod_play_from | TEXT | 播放来源(如 local、采集站名) |
| vod_play_url | TEXT | 播放地址 |
| vod_play_server | TEXT | 播放服务器 |
| vod_extension | TEXT | 文件扩展名 |
| vod_size | TEXT | 文件大小 |
| vod_duration | INTEGER | 时长(秒) |
| vod_resolution | TEXT | 分辨率 |
| vod_pic | TEXT | 封面图路径 |
| vod_preview | TEXT | 预览图路径 |
| vod_tag | TEXT | 标签(逗号分隔) |
| vod_class | TEXT | 分类(逗号分隔) |
| vod_year | TEXT | 年份 |
| vod_area | TEXT | 地区 |
| vod_lang | TEXT | 语言 |
| vod_actor | TEXT | 演员(逗号分隔) |
| vod_director | TEXT | 导演 |
| vod_serial | TEXT | 连载状态 |
| vod_total | INTEGER | 总集数 |
| vod_blurb | TEXT | 简介 |
| vod_content | TEXT | 详细介绍/摘要 |
| vod_status | INTEGER | 状态 |
| vod_lock | INTEGER | 锁定标记 |
| vod_isend | INTEGER | 是否完结 |
| watched | INTEGER | 是否已观看 |
| watch_progress | INTEGER | 观看进度 |
| collected | TEXT | 收藏信息 |
| subtitle_status | TEXT | 字幕状态 |
| vod_score | TEXT | 评分 |
| vod_hits | INTEGER | 点击数 |
| phash | TEXT | 感知哈希(去重用) |
| vod_pinyin | TEXT | 名称拼音 |
| vod_time_add | INTEGER | 入库时间戳 |
| vod_time | INTEGER | 更新时间戳 |
| vod_pubdate | TEXT | 发布日期 |
| vod_letter | TEXT | 首字母 |
| vod_remarks | TEXT | 备注 |
| face_recognized | INTEGER | 是否已识别人脸 |
5.2.2 vod_episode(视频剧集表)
| 字段 | 类型 | 说明 |
|---|---|---|
| episode_id | INTEGER | 剧集 ID(主键) |
| vod_id | INTEGER | 所属视频 ID(外键) |
| episode_number | INTEGER | 集数 |
| episode_name | TEXT | 剧集名称 |
| episode_play_from | TEXT | 播放来源 |
| episode_play_url | TEXT | 播放地址 |
| episode_cover | TEXT | 剧集封面 |
| episode_preview | TEXT | 预览图 |
| episode_subtitle | TEXT | 字幕路径 |
| episode_duration | INTEGER | 时长(秒) |
| episode_status | INTEGER | 状态 |
| episode_hits | INTEGER | 点击数 |
| episode_time_add | INTEGER | 添加时间戳 |
5.2.3 vod_detail(视频详情表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键 |
| vod_id | INTEGER | 视频 ID(外键,唯一) |
| metadata | TEXT | 元数据 JSON |
| created_at | INTEGER | 创建时间 |
| updated_at | INTEGER | 更新时间 |
5.2.4 actors(演员表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 演员 ID(主键) |
| name | VARCHAR(50) | 姓名(唯一) |
| alias | VARCHAR(100) | 别名 |
| gender | TINYINT | 性别(0=女,1=男) |
| birth_date | VARCHAR(20) | 出生日期 |
| birthplace | VARCHAR(100) | 出生地 |
| nationality | VARCHAR(50) | 国籍 |
| height | INTEGER | 身高(cm) |
| bust/waist/hips | INTEGER | 三围 |
| cup | VARCHAR(10) | 罩杯 |
| debut_year | INTEGER | 出道年份 |
| profession | VARCHAR(100) | 职业 |
| company | VARCHAR(100) | 公司 |
| biography | TEXT | 简介 |
| photo_path | VARCHAR(255) | 照片路径 |
| profile_image | VARCHAR(255) | 头像路径 |
| video_count | INTEGER | 视频数量 |
| is_favorite | INTEGER | 是否收藏 |
| is_hidden | INTEGER | 是否隐藏 |
| status | TINYINT | 状态 |
| created_at | INTEGER | 创建时间 |
| updated_at | INTEGER | 更新时间 |
5.2.5 vod_actors(视频-演员关联表)
| 字段 | 类型 | 说明 |
|---|---|---|
| vod_id | INTEGER | 视频 ID |
| actor_id | INTEGER | 演员 ID |
| role | VARCHAR(100) | 角色 |
| sort | INTEGER | 排序 |
5.2.6 actor_faces(演员人脸表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键 |
| actor_id | INTEGER | 演员 ID |
| face_vector | BLOB | 人脸特征向量 |
| face_image_path | TEXT | 人脸图片路径 |
| confidence | REAL | 置信度 |
| is_primary | INTEGER | 是否主脸 |
| usearch_key | INTEGER | 向量索引键 |
| created_at | INTEGER | 创建时间 |
5.2.7 categories(分类表)
| 字段 | 类型 | 说明 |
|---|---|---|
| type_id | INTEGER | 分类 ID(主键) |
| type_name | TEXT | 分类名称 |
| type_en_name | TEXT | 英文名 |
| parent_id | INTEGER | 父分类 ID |
| type_sort | INTEGER | 排序 |
| type_type | INTEGER | 分类类型 |
| type_mid | INTEGER | 模块 ID |
| icon | TEXT | 图标 |
| is_show | INTEGER | 是否显示 |
5.2.8 images(图片表)
| 字段 | 类型 | 说明 |
|---|---|---|
| image_id | INTEGER | 图片 ID(主键) |
| image_name | VARCHAR(255) | 图片名称 |
| file_path | VARCHAR(500) | 文件路径 |
| file_name | VARCHAR(255) | 文件名 |
| file_extension | VARCHAR(10) | 扩展名 |
| file_size | BIGINT | 文件大小 |
| width / height | INTEGER | 宽高 |
| format | VARCHAR(20) | 格式 |
| color_space | VARCHAR(20) | 色彩空间 |
| bit_depth | INTEGER | 位深 |
| is_animated | BOOLEAN | 是否动图 |
| thumbnail_small | VARCHAR(255) | 小缩略图 |
| thumbnail_cover | VARCHAR(255) | 封面缩略图 |
| type_id | INTEGER | 分类 ID |
| category_path | VARCHAR(255) | 分类路径 |
| gallery_id | INTEGER | 图集 ID |
| chapter_id | INTEGER | 章节 ID |
| sort_order | INTEGER | 排序 |
| source_type | TINYINT | 来源类型 |
| source_url | VARCHAR(500) | 来源 URL |
| dir_key | VARCHAR(100) | 目录键 |
| status | TINYINT | 状态 |
| is_favorite | BOOLEAN | 是否收藏 |
| is_cover | BOOLEAN | 是否封面 |
| rating | REAL | 评分 |
| like_count | INTEGER | 点赞数 |
| view_count | INTEGER | 查看数 |
| phash | VARCHAR(64) | 感知哈希 |
| dhash | VARCHAR(64) | 差异哈希 |
5.2.9 galleries(图集表)
| 字段 | 类型 | 说明 |
|---|---|---|
| gallery_id | INTEGER | 图集 ID(主键) |
| gallery_name | VARCHAR(255) | 图集名称 |
| gallery_desc | TEXT | 描述 |
| type_id | INTEGER | 分类 ID |
| category_path | VARCHAR(255) | 分类路径 |
| cover_image_id | INTEGER | 封面图片 ID |
| cover_path | VARCHAR(500) | 封面路径 |
| source_type | TINYINT | 来源类型 |
| source_url | VARCHAR(500) | 来源 URL |
| dir_path | VARCHAR(500) | 目录路径 |
| dir_key | VARCHAR(100) | 目录键 |
| gallery_type | TINYINT | 图集类型 |
| status | TINYINT | 状态 |
| is_favorite | BOOLEAN | 是否收藏 |
| image_count | INTEGER | 图片数量 |
5.2.10 articles(文章表)
| 字段 | 类型 | 说明 |
|---|---|---|
| article_id | INTEGER | 文章 ID(主键) |
| type_id | INTEGER | 分类 ID |
| author_id | INTEGER | 作者 ID |
| title | VARCHAR(500) | 标题 |
| summary | VARCHAR(2000) | 摘要 |
| publish_year | INTEGER | 发布年份 |
| content_length | INTEGER | 内容长度 |
| media_count | INTEGER | 媒体数量 |
| image_count | INTEGER | 图片数量 |
| video_count | INTEGER | 视频数量 |
| storage_dir | VARCHAR(255) | 存储目录 |
| cover_image | VARCHAR(255) | 封面图 |
| tags | VARCHAR(500) | 标签 |
| status | TINYINT | 状态 |
| is_top | TINYINT | 是否置顶 |
| view_count | INTEGER | 查看数 |
| like_count | INTEGER | 点赞数 |
| dislike_count | INTEGER | 踩数 |
5.2.11 article_authors(文章作者表)
| 字段 | 类型 | 说明 |
|---|---|---|
| author_id | INTEGER | 作者 ID(主键) |
| name | VARCHAR(100) | 姓名 |
| avatar | VARCHAR(255) | 头像 |
| description | VARCHAR(500) | 描述 |
| platform | VARCHAR(50) | 平台 |
| external_id | VARCHAR(255) | 外部 ID |
| article_count | INTEGER | 文章数 |
| total_views | INTEGER | 总查看数 |
| total_likes | INTEGER | 总点赞数 |
5.2.12 article_media(文章媒体表)
| 字段 | 类型 | 说明 |
|---|---|---|
| media_id | INTEGER | 媒体 ID(主键) |
| article_id | INTEGER | 文章 ID(外键) |
| media_type | VARCHAR(20) | 媒体类型(image/video) |
| file_name | VARCHAR(255) | 文件名 |
| file_path | VARCHAR(500) | 文件路径 |
| thumb_path | VARCHAR(500) | 缩略图路径 |
| file_size | INTEGER | 文件大小 |
| width / height | INTEGER | 宽高 |
| duration | INTEGER | 时长 |
| sort_order | INTEGER | 排序 |
5.2.13 comic_series(漫画系列表)
| 字段 | 类型 | 说明 |
|---|---|---|
| series_id | INTEGER | 系列 ID(主键) |
| series_name | VARCHAR(255) | 系列名称 |
| series_alias | VARCHAR(255) | 别名 |
| series_desc | TEXT | 描述 |
| author | VARCHAR(100) | 作者 |
| artist | VARCHAR(100) | 画师 |
| publisher | VARCHAR(100) | 出版社 |
| type_id | INTEGER | 分类 ID |
| total_chapters | INTEGER | 总章节数 |
| status | TINYINT | 状态 |
| is_favorite | BOOLEAN | 是否收藏 |
| rating | REAL | 评分 |
5.2.14 comic_chapters(漫画章节表)
| 字段 | 类型 | 说明 |
|---|---|---|
| chapter_id | INTEGER | 章节 ID(主键) |
| series_id | INTEGER | 系列 ID(外键) |
| chapter_number | INTEGER | 章节号 |
| chapter_title | VARCHAR(255) | 章节标题 |
| volume_number | INTEGER | 卷号 |
| gallery_id | INTEGER | 关联图集 ID |
| page_count | INTEGER | 页数 |
| last_read_page | INTEGER | 最后阅读页 |
| is_read | BOOLEAN | 是否已读 |
5.2.15 tags(标签表)
| 字段 | 类型 | 说明 |
|---|---|---|
| tag_id | INTEGER | 标签 ID(主键) |
| tag_name | VARCHAR(255) | 标签名(唯一) |
| usage_count | INTEGER | 使用次数 |
| created_at | INTEGER | 创建时间 |
5.2.16 image_tags(图片-标签关联表)
| 字段 | 类型 | 说明 |
|---|---|---|
| image_id | INTEGER | 图片 ID |
| tag_id | INTEGER | 标签 ID |
| confidence | REAL | 置信度 |
| is_auto | BOOLEAN | 是否自动生成 |
5.2.17 face_index(人脸索引表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键 |
| video_id | INTEGER | 视频 ID |
| frame_time | REAL | 帧时间(秒) |
| actor_id | INTEGER | 演员 ID |
| actor_face_id | INTEGER | 演员人脸 ID |
5.2.18 plugins(插件表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | TEXT | 插件 ID(主键) |
| name | TEXT | 名称 |
| version | TEXT | 版本 |
| author | TEXT | 作者 |
| description | TEXT | 描述 |
| entry | TEXT | 入口文件 |
| manifest_path | TEXT | 清单路径 |
| plugin_dir | TEXT | 插件目录 |
| state | TEXT | 状态(installed/enabled/disabled/error) |
| enabled_triggers | TEXT | 启用的触发器(JSON) |
| permissions_approved | INTEGER | 权限是否已批准 |
| installed_at | INTEGER | 安装时间 |
| updated_at | INTEGER | 更新时间 |
| last_run_at | INTEGER | 最后运行时间 |
| error_message | TEXT | 错误信息 |
| fail_count | INTEGER | 连续失败次数 |
5.2.19 plugin_logs(插件日志表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 日志 ID(主键) |
| plugin_id | TEXT | 插件 ID |
| level | TEXT | 级别(info/warn/error) |
| message | TEXT | 日志内容 |
| task_id | TEXT | 任务 ID |
| created_at | INTEGER | 创建时间 |
plugin_{插件ID}_{表名},在 manifest.toml 的 [[tables]] 中声明后,启用插件时自动创建。插件对自有数据表拥有完整读写权限,无需在 permissions 中声明。6功能模块教学
6.1 创建最简单的手动触发插件
本节演示如何创建一个 Hello World 插件,通过手动按钮触发执行。
创建插件目录
在 {exe_dir}/plugins/ 下新建 hello-world/ 目录。
编写 manifest.toml
[plugin]
id = "hello-world"
name = "Hello World"
version = "1.0.0"
author = "demo"
description = "最简手动触发示例插件"
entry = "main.js"
enabled = false
[dependencies]
ykgj = ">=1.0.0"
[[trigger]]
type = "manual"
display_name = "执行 Hello"
[permissions]
database = []
filesystem = []
network = []
native = []
[[config]]
key = "name"
type = "string"
default = "World"
label = "称呼"
group = "基本设置"
order = 1
编写 main.js
const { Plugin } = require('ykgj-plugin-sdk');
class HelloWorldPlugin extends Plugin {
async onLoad(ctx) {
ctx.logger.info('Hello World 插件已加载');
}
// 手动触发时执行
async onManual(params, ctx) {
const name = this.config.name || 'World';
const message = `Hello, ${name}!`;
ctx.logger.info(message);
// 返回结果会推送到前端显示
return { message, time: new Date().toISOString() };
}
async onUnload() {
// 清理资源
}
}
// 必须导出 Plugin 实例
module.exports = new HelloWorldPlugin();
启用插件
打开影库管家,进入"插件管理"页面,应能看到 Hello World 插件。点击启用,审批权限后即可使用。点击"立即执行"按钮触发 onManual。
6.2 循环触发:定期扫描数据
loop 触发器适合定期批量处理数据的场景。以下示例每 10 秒扫描一次没有标签的视频并补充默认标签。
// main.js
const { Plugin } = require('ykgj-plugin-sdk');
class AutoTagPlugin extends Plugin {
async onLoad(ctx) {
ctx.logger.info('自动标签插件已加载');
}
// loop 触发:获取待处理项
async fetchItems(ctx) {
// 查询没有标签的视频(视频表需传 database: "video")
const result = await ctx.api.db.query(
'SELECT vod_id, vod_name FROM vod_main ' +
'WHERE vod_tag IS NULL OR vod_tag = "" LIMIT ?',
[ctx.batchSize],
'video'
);
return (result && result.rows) || [];
}
// loop 触发:处理单个项目
async processItem(item, ctx) {
try {
// 根据视频名生成标签(示例逻辑)
const tag = this._guessTag(item.vod_name);
await ctx.api.video.update(item.vod_id, { vod_tag: tag });
ctx.logger.info(`视频 ${item.vod_id} 已添加标签: ${tag}`);
ctx.task.reportProgress(1, 1, 0);
} catch (e) {
ctx.logger.error(`处理视频 ${item.vod_id} 失败: ${e.message}`);
ctx.task.reportProgress(1, 0, 1);
}
}
_guessTag(name) {
// 简单示例:根据名称关键词推断标签
if (/教程|教学|课程/.test(name)) return '教程';
if (/电影|剧场/.test(name)) return '电影';
return '其他';
}
async onUnload() {}
}
module.exports = new AutoTagPlugin();
对应的 manifest.toml 触发器配置:
[[trigger]]
type = "loop"
interval_secs = 10 # 每 10 秒执行一次
empty_interval_secs = 60 # 无数据时 60 秒后再查
batch_size = 5 # 每批处理 5 个
should_exit_when_idle = true
concurrency = 1
[permissions]
database = ["vod_main:read", "vod_main:write:vod_tag"]
6.3 事件触发:响应视频入库
event 触发器在特定事件发生时执行。以下示例在视频入库后自动生成摘要。
const { Plugin } = require('ykgj-plugin-sdk');
class AutoSummaryPlugin extends Plugin {
async onLoad(ctx) {
ctx.logger.info('自动摘要插件已加载');
}
// event 触发:处理订阅的事件
async onEvent(event, ctx) {
// event = { type, payload }
if (event.type === 'video.imported') {
const { vod_id, vod_name } = event.payload;
ctx.logger.info(`新视频入库: ${vod_name} (ID: ${vod_id})`);
try {
// 获取视频详情
const video = await ctx.api.video.get(vod_id);
// 调用 LLM 生成摘要
const summary = await this._generateSummary(video, ctx);
// 更新视频摘要
await ctx.api.video.update(vod_id, { vod_content: summary });
ctx.logger.info(`视频 ${vod_id} 摘要已生成`);
} catch (e) {
ctx.logger.error(`生成摘要失败: ${e.message}`);
}
}
}
async _generateSummary(video, ctx) {
const prompt = `为视频"${video.vod_name}"生成200字简介`;
const resp = await ctx.api.http.post(
'https://api.example.com/v1/chat/completions',
{
model: this.config.model,
messages: [{ role: 'user', content: prompt }],
},
{ Authorization: `Bearer ${this.config.api_key}` }
);
return resp.choices[0].message.content;
}
async onUnload() {}
}
module.exports = new AutoSummaryPlugin();
manifest.toml 中的触发器配置:
[[trigger]]
type = "event"
event = "video.imported" # 订阅视频入库事件
idle_timeout_secs = 60 # 60 秒无事件后关闭窗口
[permissions]
database = ["vod_main:read", "vod_main:write:vod_content"]
network = ["api.example.com"]
6.4 路由触发:提供 HTTP 接口
route 触发器让插件对外提供 HTTP API,可被其他程序调用。以下示例提供一个生成视频报表的接口。
const { Plugin } = require('ykgj-plugin-sdk');
class ReportPlugin extends Plugin {
async onLoad(ctx) {
ctx.logger.info('报表插件已加载');
}
// route 触发:处理 HTTP 请求
async onRoute(req, ctx) {
// req = { method, path, headers, body }
if (req.path === '/stats' && req.method === 'GET') {
// 查询视频统计(视频表需传 database: "video")
const result = await ctx.api.db.query(
'SELECT vod_type, COUNT(*) as count FROM vod_main GROUP BY vod_type',
[],
'video'
);
const stats = result.rows;
return {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 0, data: stats })
};
}
return {
status: 404,
headers: {},
body: JSON.stringify({ error: 'Not Found' })
};
}
async onUnload() {}
}
module.exports = new ReportPlugin();
manifest.toml 配置:
[[trigger]]
type = "route"
path = "/stats" # 完整路径:/api/v1/plugins/report-plugin/stats
method = "GET"
keep_alive_secs = 300 # 请求完成后保持窗口 5 分钟
[permissions]
database = ["vod_main:read"]
调用示例:
curl http://127.0.0.1:38089/api/v1/plugins/report-plugin/stats
6.5 定时触发:cron 表达式定时任务
cron 触发器按 cron 表达式在精确时间点执行,适合需要按时间规则运行的任务,如每天凌晨清理数据、每周一生成报表、每小时同步一次数据等。与 loop 触发器的区别在于:loop 按固定间隔循环执行,而 cron 可指定复杂的时间规则(如"每天 3:00"、"每周一 9:00"、"每月 1 号 0:00")。
分 时 日 月 周。例如 0 3 * * * 表示每天 3:00,0 9 * * 1 表示每周一 9:00。系统内部会在表达式前补 "0 " 作为秒字段,因此最小粒度为分钟级。6.5.1 cron 表达式语法
| 字段 | 取值范围 | 特殊字符 | 说明 |
|---|---|---|---|
| 分(minute) | 0-59 | * / , - | 第 1 字段 |
| 时(hour) | 0-23 | * / , - | 第 2 字段 |
| 日(day of month) | 1-31 | * / , - ? L | 第 3 字段 |
| 月(month) | 1-12 或 JAN-DEC | * / , - | 第 4 字段 |
| 周(day of week) | 0-6 或 SUN-SAT | * / , - ? L # | 第 5 字段(0=周日) |
6.5.2 常用 cron 表达式示例
| 表达式 | 含义 |
|---|---|
0 3 * * * | 每天 3:00 执行 |
0 0 * * * | 每天 0:00(午夜)执行 |
0 9 * * 1 | 每周一 9:00 执行 |
0 0 1 * * | 每月 1 号 0:00 执行 |
0 */2 * * * | 每 2 小时执行一次 |
*/30 * * * * | 每 30 分钟执行一次 |
0 0,12 * * * | 每天 0:00 和 12:00 各执行一次 |
0 9 1-5 * * | 每月 1-5 号 9:00 执行 |
0 0 * * 6,0 | 每周六、周日 0:00 执行 |
6.5.3 示例:每日数据清理插件
以下示例每天凌晨 3:00 清理超过 30 天未访问的视频缓存数据。
manifest.toml 配置:
[plugin]
id = "daily-cleanup"
name = "每日数据清理"
version = "1.0.0"
author = "ykgj"
description = "每天凌晨 3:00 清理过期缓存数据"
entry = "main.js"
enabled = false
[dependencies]
ykgj = ">=1.0.0"
[[trigger]]
type = "cron"
schedule = "0 3 * * *" # 每天 3:00 执行
catch_up = true # 错过执行时补跑(如关机期间错过,启动后补跑一次)
timeout_secs = 600 # 单次执行超时 10 分钟
idle_timeout_secs = 60 # 执行完后保活 60 秒
[permissions]
database = ["vod_main:read"]
filesystem = ["${cache_dir}/*"]
network = []
native = []
[[config]]
key = "keep_days"
type = "number"
default = 30
min = 1
max = 365
label = "保留天数"
group = "基本设置"
[[tables]]
name = "cleanup_logs"
schema = """
CREATE TABLE IF NOT EXISTS plugin_daily_cleanup_cleanup_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cleaned_count INTEGER,
schedule TEXT,
triggered_at INTEGER,
duration_ms INTEGER
);
"""
main.js 实现:
const { Plugin } = require('ykgj-plugin-sdk');
class DailyCleanupPlugin extends Plugin {
async onLoad(ctx) {
ctx.logger.info('每日清理插件已加载');
}
// cron 触发:到时执行
async onCron(ctx) {
const startTime = Date.now();
const triggerTime = new Date(ctx.timestamp * 1000).toLocaleString('zh-CN');
ctx.logger.info(`cron 触发,表达式: ${ctx.schedule},触发时间: ${triggerTime}`);
try {
// 查询超过保留天数未访问的视频
const keepDays = this.config.keep_days || 30;
const cutoff = Math.floor(Date.now() / 1000) - keepDays * 86400;
const result = await ctx.api.db.query(
'SELECT vod_id, vod_name, vod_pic FROM vod_main ' +
'WHERE vod_time < ? AND vod_pic != "" LIMIT 100',
[cutoff],
'video'
);
const rows = result.rows;
ctx.logger.info(`查询到 ${rows.length} 条待清理记录`);
// 清理缓存文件(示例:清理封面图缓存)
let cleaned = 0;
for (const row of rows) {
try {
// 业务逻辑:清理缓存等
cleaned++;
} catch (e) {
ctx.logger.error(`清理视频 ${row.vod_id} 失败: ${e.message}`);
}
}
// 记录清理日志到插件数据表
const duration = Date.now() - startTime;
await ctx.api.db.execute(
'INSERT INTO plugin_daily_cleanup_cleanup_logs ' +
'(cleaned_count, schedule, triggered_at, duration_ms) VALUES (?, ?, ?, ?)',
[cleaned, ctx.schedule, ctx.timestamp, duration]
);
ctx.logger.info(`清理完成,共清理 ${cleaned} 条,耗时 ${duration}ms`);
return { cleaned_count: cleaned, duration_ms: duration };
} catch (e) {
ctx.logger.error(`清理任务失败: ${e.message}`);
throw e;
}
}
async onUnload() {}
}
module.exports = new DailyCleanupPlugin();
catch_up = true 时,若插件启用或应用启动时已错过执行时间(如系统关机期间),会立即补跑一次。这类似于 systemd timer 的 Persistent=true,确保不会因停机而永久错过任务。6.5.4 cron 与 loop 的选择
| 维度 | loop 触发器 | cron 触发器 |
|---|---|---|
| 触发方式 | 按固定间隔循环 | 按 cron 表达式精确时间点 |
| 时间精度 | 秒级(interval_secs) | 分钟级 |
| 典型场景 | 轮询数据库处理待办项 | 定时清理、定时报表、定时同步 |
| 批量处理 | 支持(fetchItems + processItem) | 不支持,单次执行 onCron |
| 错过补跑 | 不支持 | 支持(catch_up = true) |
| 执行超时 | 单项 300s | 可配置(timeout_secs,默认 300s) |
6.6 使用插件数据表
插件可声明自己的数据表存储持久化数据。以下示例记录每次处理的结果。
manifest.toml 声明数据表:
[[tables]]
name = "logs"
schema = """
CREATE TABLE IF NOT EXISTS plugin_my_plugin_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vod_id INTEGER,
action TEXT,
result TEXT,
created_at INTEGER
);
"""
main.js 中使用:
async processItem(item, ctx) {
try {
// 业务逻辑...
await this._doSomething(item, ctx);
// 记录到插件数据表
await ctx.api.db.execute(
'INSERT INTO plugin_my_plugin_logs (vod_id, action, result, created_at) ' +
'VALUES (?, ?, ?, ?)',
[item.vod_id, 'process', 'success', Math.floor(Date.now() / 1000)]
);
} catch (e) {
// 记录失败
await ctx.api.db.execute(
'INSERT INTO plugin_my_plugin_logs (vod_id, action, result, created_at) ' +
'VALUES (?, ?, ?, ?)',
[item.vod_id, 'process', 'failed: ' + e.message, Math.floor(Date.now() / 1000)]
);
throw e;
}
}
6.7 调用 FFmpeg 处理媒体
const { Plugin } = require('ykgj-plugin-sdk');
class ScreenshotPlugin extends Plugin {
async onManual(params, ctx) {
const vodId = params.vod_id;
if (!vodId) return { error: '缺少 vod_id 参数' };
// 获取视频信息
const video = await ctx.api.video.get(vodId);
if (!video) return { error: '视频不存在' };
// 截图保存到插件缓存目录
const outputPath = `${ctx.pluginDir}/screenshot_${vodId}.jpg`;
// 调用 FFmpeg 截取第 10 秒的画面
const result = await ctx.api.ffmpeg.run([
'-i', video.vod_play_url,
'-ss', '00:00:10',
'-vframes', '1',
'-q:v', '2',
outputPath
]);
if (result.exit_code === 0) {
ctx.logger.info(`截图成功: ${outputPath}`);
return { success: true, path: outputPath };
} else {
ctx.logger.error(`截图失败: ${result.stderr}`);
return { success: false, error: result.stderr };
}
}
}
module.exports = new ScreenshotPlugin();
manifest.toml 权限声明:
[permissions]
database = ["vod_main:read"]
filesystem = ["${plugin_dir}/*"]
native = ["ffmpeg"]
6.8 事件总线:插件间通信
插件可发布自定义事件供其他插件订阅,实现插件间协作。
// 插件 A:发布事件
class PluginA extends Plugin {
async processItem(item, ctx) {
// 处理完成后发布事件
await ctx.api.event.publish('plugin-a.done', {
vod_id: item.vod_id,
timestamp: Date.now()
});
}
}
// 插件 B:订阅事件(在 manifest.toml 中声明 event 触发器)
class PluginB extends Plugin {
async onEvent(event, ctx) {
if (event.type === 'plugin-a.done') {
ctx.logger.info(`收到插件 A 的通知: ${JSON.stringify(event.payload)}`);
// 执行后续逻辑...
}
}
}
6.9 文件读写
class FilePlugin extends Plugin {
async onManual(params, ctx) {
// 写入文件到插件目录
const filePath = `${ctx.pluginDir}/data.json`;
await ctx.api.fs.write(filePath, JSON.stringify({
time: new Date().toISOString(),
config: this.config
}, null, 2));
// 读取文件
const content = await ctx.api.fs.read(filePath);
ctx.logger.info(`读取内容: ${content}`);
// 列出目录
const dir = await ctx.api.fs.readdir(ctx.pluginDir);
ctx.logger.info(`插件目录文件: ${dir.entries.map(e => e.name).join(', ')}`);
return { success: true };
}
}
6.10 UI 扩展开发
UI 触发器允许插件向前端注入自定义界面,包括菜单项、右键菜单项、详情面板、整页页面等。这是插件系统的重要扩展能力,让插件不仅能处理后台任务,还能与用户直接交互。
6.10.1 UI 扩展类型与槽位
| ui_type | 说明 | 渲染方式 | 触发钩子 |
|---|---|---|---|
page | 整页嵌入 | 点击菜单项后跳转独立页面(iframe) | 不触发,页面直接加载 |
menu_item | 主菜单项 | 渲染为 el-menu-item,点击跳转或触发 | 触发 onUiTrigger |
context_menu | 右键菜单项 | 渲染为右键菜单项,点击执行操作 | 触发 onUiTrigger |
panel | 面板 | 渲染为 iframe 面板,嵌入详情页侧栏 | 不触发,面板直接加载 |
6.10.2 可用槽位列表
| 槽位标识 | 位置 | 适用类型 | 上下文 |
|---|---|---|---|
main_menu | 主界面左侧菜单底部 | page, menu_item | 无 |
video_list_context_menu | 视频列表右键菜单 | context_menu | { video_id, title } |
video_detail_sidebar | 视频详情页侧栏 | panel | { video_id } |
video_edit_sidebar | 视频编辑页侧栏 | panel | { video_id } |
6.10.3 manifest.toml 声明示例
# 主菜单嵌入整页
[[trigger]]
type = "ui"
ui_type = "page"
ui_slot = "main_menu"
ui_page = "dashboard.html"
ui_label = "插件统计"
ui_icon = "fas fa-chart-bar"
ui_order = 100
# 视频右键菜单扩展
[[trigger]]
type = "ui"
ui_type = "context_menu"
ui_slot = "video_list_context_menu"
ui_label = "用插件处理"
ui_icon = "fas fa-magic"
ui_order = 50
# 视频详情面板
[[trigger]]
type = "ui"
ui_type = "panel"
ui_slot = "video_detail_sidebar"
ui_page = "extra-info.html"
ui_label = "扩展信息"
ui_icon = "fas fa-info-circle"
ui_order = 100
6.10.4 UI 页面开发规范
插件 UI 页面(ui_page)存放在 plugin_dir/ui/ 目录,通过 iframe 加载。开发时需遵循以下规范:
关键规范:
- 资源路径必须用绝对路径:CSS/JS/图片等资源必须用
/api/v1/plugins/{plugin_id}/ui/assets/xxx格式,相对路径会 404 - 样式隔离:iframe 内样式与主界面完全隔离,需自行定义完整样式,建议使用 CSS 变量适配主题
- 高度自适应:panel 类型需通过 postMessage 上报高度,上限 400px,避免挤压主界面布局
- 通信协议:通过 window.postMessage 与主界面通信,接收上下文注入、请求跳转等
6.10.5 UI 页面目录结构
6.10.6 UI 页面 HTML 示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>插件统计</title>
<!-- 资源必须用绝对路径 -->
<link rel="stylesheet" href="/api/v1/plugins/my-plugin/ui/assets/style.css">
</head>
<body>
<div class="demo-card">
<h3><i class="fas fa-chart-bar"></i> 视频统计</h3>
<div id="stats-content">加载中...</div>
</div>
<script src="/api/v1/plugins/my-plugin/ui/assets/app.js"></script>
<script>
// 监听主界面注入的上下文(app.js 已处理 origin 校验,这里只需监听 context-ready 事件)
window.addEventListener('context-ready', function (e) {
window.__pluginContext = e.detail.context;
window.__pluginId = e.detail.pluginId;
// 触发数据加载
loadStats();
});
// 调用插件提供的 route API
async function loadStats() {
const data = await callPluginApi('/api/stats');
document.getElementById('stats-content').innerHTML = '总数: ' + data.total;
// 上报高度(panel 类型必须)
autoResize();
}
// 请求跳转(使用 postToParent,自动处理跨域 origin)
function goVideoList() {
postToParent({
type: 'navigate',
url: '/video'
});
}
</script>
</body>
</html>
6.10.7 共享脚本 app.js 示例
// 跨域说明:nw.js 环境下父页面为 chrome-extension:// 协议,iframe 为 http://127.0.0.1 协议
// 两者不同源,不能用 location.origin 作为 postMessage 的 targetOrigin
// 捕获父窗口 origin(从收到的第一条消息中提取),未收到前用 '*' 兜底
var __parentOrigin = '*';
// 向父窗口发送 postMessage(自动使用正确的 targetOrigin)
function postToParent(msg) {
window.parent.postMessage(msg, __parentOrigin);
}
// 接收主界面注入的上下文(跨域场景下不能与 location.origin 比较)
window.addEventListener('message', function (e) {
var data = e.data;
if (!data || typeof data !== 'object') return;
if (data.type === 'plugin-context') {
__parentOrigin = e.origin; // 捕获父窗口 origin
window.__pluginContext = data.context || {};
window.__pluginId = data.pluginId;
window.dispatchEvent(new CustomEvent('context-ready', {
detail: { context: data.context, pluginId: data.pluginId }
}));
}
});
// 调用插件 route API
async function callPluginApi(path) {
const pluginId = window.__pluginId;
const url = `/api/v1/plugins/${pluginId}${path}`;
const res = await fetch(url);
const json = await res.json();
if (json.code !== 200 && json.code !== 0) {
throw new Error(json.message || 'API 调用失败');
}
return json.data;
}
// 调用主界面后端 API(iframe 与后端同源,直接 fetch)
async function callHostApi(path) {
const res = await fetch(path);
const json = await res.json();
if (json.code !== 200 && json.code !== 0) {
throw new Error(json.message || 'API 调用失败');
}
return json.data;
}
// 上报高度(panel 类型必须,上限 400px)
function requestResize(height) {
postToParent({
type: 'plugin-resize',
pluginId: window.__pluginId,
height: Math.min(height, 400)
});
}
// 自动测量并上报高度
function autoResize() {
const h = Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight
);
if (h > 0) requestResize(h);
}
// DOM 就绪后自动测量
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', autoResize);
} else {
autoResize();
}
6.10.8 main.js 处理 UI 触发
context_menu 和 menu_item 类型点击时会触发 onUiTrigger 钩子:
const { Plugin } = require('ykgj-plugin-sdk');
class MyPlugin extends Plugin {
// UI 触发钩子
async onUiTrigger(payload, ctx) {
const { slot, context } = payload;
if (slot === 'video_list_context_menu') {
const videoId = context.video_id;
const title = context.title;
// 查询视频详情
const video = await ctx.api.video.get(videoId);
ctx.logger.info(`处理视频: ${video.vod_name}`);
// 返回结果给前端(前端可据此显示提示)
return {
action: 'notify',
message: `已处理视频: ${video.vod_name}`,
video_id: videoId
};
}
return { action: 'noop' };
}
// route 触发器:为 UI 页面提供 API
async onRoute(req, ctx) {
if (req.path === '/api/stats' && req.method === 'GET') {
const result = await ctx.api.db.query(
'SELECT COUNT(*) as total FROM vod_main',
[],
'video'
);
return {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 200, data: { total: result.rows[0].total } })
};
}
return { status: 404, headers: {}, body: '{}' };
}
}
module.exports = new MyPlugin();
6.10.9 postMessage 通信协议
| 方向 | 消息类型 | 数据结构 | 说明 |
|---|---|---|---|
| 主界面 → iframe | plugin-context | { type, pluginId, context } | 注入上下文(video_id 等) |
| iframe → 主界面 | plugin-resize | { type, pluginId, height } | 上报高度(panel 类型) |
| iframe → 主界面 | navigate | { type, url } | 请求跳转页面 |
| iframe → 主界面 | plugin-notify | { type, message, level } | 请求显示提示(success/error) |
postToParent() 辅助函数(app.js 提供,自动捕获父窗口 origin)。插件 route 触发器提供的 API 可通过 /api/v1/plugins/{plugin_id}/{path} 访问。7插件打包发布
7.1 插件包格式
插件包为 .ykgj-plugin 扩展名的 ZIP 压缩包,解压后即为完整的插件目录结构。
7.2 打包步骤
测试插件功能
在本地 plugins 目录中完成开发和测试,确保所有功能正常。
清理无用文件
删除 .DS_Store、Thumbs.db、.git 等系统/版本控制文件。
检查依赖
如果使用了 npm 依赖,确保 node_modules/ 已包含在插件目录内(NW.js 不支持运行时安装)。
压缩为 ZIP
将插件目录内的所有文件(不含外层目录)压缩为 ZIP,重命名为 my-plugin.ykgj-plugin。
# PowerShell 示例
Compress-Archive -Path .\my-plugin\* -DestinationPath my-plugin.zip
Rename-Item my-plugin.zip my-plugin.ykgj-plugin
验证包结构
解压后应直接看到 manifest.toml 和 main.js,不能多嵌套一层目录。
7.3 版本管理
| 版本类型 | 规则 | 示例 |
|---|---|---|
| 主版本(MAJOR) | 不兼容的 API 修改 | 1.0.0 → 2.0.0 |
| 次版本(MINOR) | 向下兼容的功能新增 | 1.0.0 → 1.1.0 |
| 修订号(PATCH) | 向下兼容的问题修复 | 1.0.0 → 1.0.1 |
每次发布新版本时,更新 manifest.toml 中的 version 字段。用户安装新版本时会自动替换旧版本。
7.4 发布到插件市场
- 直接分发:将
.ykgj-plugin文件发送给用户,用户在插件管理页面点击"安装"上传 - 手动安装:用户解压包到
{exe_dir}/plugins/目录,自动发现并加载 - Git 仓库:将插件源码托管到 Git,用户克隆到 plugins 目录即可
7.5 manifest.toml 完整示例
[plugin]
id = "video-organizer"
name = "视频整理工具"
version = "1.2.0"
author = "ykgj"
description = "自动按规则整理视频分类和标签"
homepage = "https://github.com/your/video-organizer"
icon = "icon.png"
license = "MIT"
entry = "main.js"
enabled = false
keep_data = true
[dependencies]
ykgj = ">=1.0.0"
plugins = []
[[trigger]]
type = "loop"
interval_secs = 60
empty_interval_secs = 300
batch_size = 20
should_exit_when_idle = true
concurrency = 1
[[trigger]]
type = "manual"
display_name = "立即整理"
[permissions]
database = [
"vod_main:read",
"vod_main:write:vod_tag",
"vod_main:write:vod_class"
]
filesystem = ["${plugin_dir}/*"]
network = []
native = []
[[config]]
key = "auto_categorize"
type = "bool"
default = true
label = "自动分类"
group = "基本设置"
order = 1
[[config]]
key = "tag_rules"
type = "string"
default = ""
label = "标签规则(JSON)"
group = "基本设置"
order = 2
placeholder = '[{"keyword":"教程","tag":"教学"}]'
[[config]]
key = "max_process"
type = "number"
default = 100
min = 1
max = 1000
label = "单次最大处理数"
group = "高级设置"
order = 1
[[tables]]
name = "rules"
schema = """
CREATE TABLE IF NOT EXISTS plugin_video_organizer_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_name TEXT,
keyword TEXT,
target_tag TEXT,
enabled INTEGER DEFAULT 1,
created_at INTEGER
);
"""
8调试与测试指南
8.1 调试方法
8.1.1 查看插件日志
在影库管家"插件管理 → 插件详情 → 日志"页面查看插件运行日志,支持按级别(info/warn/error)筛选和分页。
8.1.2 查看隐藏窗口日志
插件隐藏窗口的 console 输出会写入系统临时目录的日志文件:
%TEMP%\ykgj-plugin-logs\{plugin_id}-{timestamp}.log
8.1.3 手动触发测试
在插件详情页点击"立即执行"按钮,可手动触发 onManual 方法,便于测试。执行结果会通过 WebSocket 推送到前端显示。
8.1.4 热重载
修改插件 main.js 后,下次触发时 SDK 会自动清除模块缓存并重新加载,无需重启应用。修改 manifest.toml 后需禁用并重新启用插件使配置生效。
8.2 常见问题排查
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 插件状态显示"错误" | 启动失败(语法错误/依赖缺失) | 查看日志页面的错误信息;检查 main.js 语法;确认 node_modules 完整 |
| 插件自动禁用 | 连续启动失败 3 次 | 修复错误后重新启用;查看 fail_count 字段 |
| API 调用返回"权限不足" | 未声明对应权限或用户未批准 | 在 manifest.toml 的 permissions 中添加声明;在权限页面批准 |
| API 调用超时 | 网络慢或处理时间过长 | 检查网络;分批处理大数据;http.request 默认 2 分钟超时 |
| video.update 失败 | 更新了非白名单字段 | 仅允许更新 vod_name/vod_content/vod_tag 等白名单字段 |
| 文件读写失败 | 路径未在 permissions.filesystem 声明 | 添加路径到 filesystem 权限,支持 ${plugin_dir} ${cache_dir} 变量 |
| 插件窗口不启动 | 触发器未正确配置或权限未批准 | 检查 manifest.toml 触发器配置;确保权限已批准 |
| require('ykgj-plugin-sdk') 失败 | SDK 加载异常 | 查看隐藏窗口日志 %TEMP%\ykgj-plugin-logs\{plugin_id}-*.log;重新安装软件修复 SDK |
| 插件数据表不存在 | 表未在 manifest.toml 声明或插件未重新启用 | 在 [[tables]] 中声明;禁用后重新启用插件触发建表 |
| 配置项不生效 | 修改 manifest.toml 后未重新启用 | 禁用插件 → 重新启用 → 配置项更新 |
| 查询 vod_main 等视频表报"no such table" | 视频表在独立的 db_video.db,未传 database 参数 | 调用 db.query/execute 时第三参数传 "video";详见 4.4 节与 5.2 节 |
| db.query 返回值取不到数据 | 误以为返回数组,实际返回 { rows: [...] } | 通过 result.rows 访问结果数组 |
8.3 调试技巧
8.3.1 使用 logger 输出调试信息
async processItem(item, ctx) {
ctx.logger.info('收到项目: ' + JSON.stringify(item));
ctx.logger.info('当前配置: ' + JSON.stringify(this.config));
const result = await ctx.api.video.get(item.vod_id);
ctx.logger.info('视频详情: ' + JSON.stringify(result));
}
8.3.2 捕获并记录完整错误堆栈
async processItem(item, ctx) {
try {
await this._doWork(item, ctx);
} catch (e) {
// 记录完整错误信息(含堆栈)
ctx.logger.error(`处理失败: ${e.message}\n${e.stack}`);
throw e; // 重新抛出,让调度器知道任务失败
}
}
8.3.3 验证数据库查询
使用 SQLite 工具(如 DB Browser for SQLite)直接查看数据库,验证插件写入的数据是否正确。数据库文件位于 {exe_dir}/db/ 目录。注意多库分离:视频表(vod_main 等)在 db_video.db,演员表在 db_actor.db,插件自有表在主库 db_main.db。
8.4 单元测试编写
插件可使用 Node.js 标准测试框架(如 Jest、Mocha)进行单元测试。由于插件依赖宿主 API,测试时需 mock ctx.api。
// test/main.test.js
const assert = require('assert');
// Mock ykgj-plugin-sdk
const mockPlugin = {
config: { name: 'Test' },
_ws: null,
_pendingApi: new Map(),
_send: function() { return true; },
_apiCall: function(method, params) {
// 返回 mock 数据
if (method === 'video.get') {
return Promise.resolve({ vod_id: 1, vod_name: '测试视频' });
}
return Promise.resolve({});
}
};
// 加载插件(需将 Plugin 基类 mock 注入)
// 实际测试时建议重构插件,将核心逻辑抽离为独立函数
describe('HelloWorldPlugin', () => {
it('应返回正确的问候消息', async () => {
const message = `Hello, ${mockPlugin.config.name}!`;
assert.strictEqual(message, 'Hello, Test!');
});
});
8.5 集成测试清单
发布前建议完成以下测试:
- ☐ 复制插件目录到 plugins/,验证自动发现
- ☐ 启用插件,验证权限审批流程
- ☐ 配置插件配置项,验证保存和读取
- ☐ 触发各类型触发器(loop/event/manual/route/cron),验证执行
- ☐ 验证 API 调用(video/db/http/fs/ffmpeg)
- ☐ 验证日志输出到日志页面
- ☐ 验证插件数据表创建和数据读写
- ☐ 禁用插件,验证窗口关闭
- ☐ 卸载插件,验证目录和数据清理
- ☐ 模拟插件崩溃,验证主程序不受影响
9实战案例分析
9.1 案例一:AI 视频摘要生成器
场景:定期扫描没有摘要的视频,调用大语言模型生成剧情摘要并写入 vod_content 字段。
manifest.toml
[plugin]
id = "ai-summary"
name = "AI 视频摘要"
version = "1.0.0"
author = "ykgj"
description = "循环扫描没有摘要的视频,调用 LLM 生成剧情摘要"
entry = "main.js"
enabled = false
keep_data = false
[dependencies]
ykgj = ">=1.0.0"
[[trigger]]
type = "loop"
interval_secs = 300
empty_interval_secs = 60
batch_size = 5
should_exit_when_idle = true
concurrency = 1
[[trigger]]
type = "manual"
display_name = "立即生成摘要"
[permissions]
database = ["vod_main:read", "vod_main:write:vod_content"]
filesystem = []
network = ["api.siliconflow.cn"]
native = []
[[config]]
key = "model"
type = "string"
default = "deepseek-ai/DeepSeek-R1"
label = "LLM 模型"
required = true
group = "基本设置"
order = 1
placeholder = "请输入模型名称"
[[config]]
key = "api_key"
type = "secret"
label = "API Key"
required = true
group = "基本设置"
order = 2
encrypt = true
[[config]]
key = "max_videos"
type = "number"
default = 50
label = "最大视频数"
min = 1
max = 500
group = "高级设置"
order = 1
[[config]]
key = "language"
type = "select"
default = "zh"
label = "摘要语言"
group = "高级设置"
order = 2
options = [
{ value = "zh", label = "中文" },
{ value = "en", label = "英文" },
]
[[tables]]
name = "summaries"
schema = """
CREATE TABLE IF NOT EXISTS plugin_ai_summary_summaries (
vod_id INTEGER PRIMARY KEY,
summary TEXT,
model TEXT,
created_at INTEGER,
updated_at INTEGER
);
"""
main.js
const { Plugin } = require('ykgj-plugin-sdk');
class AiSummaryPlugin extends Plugin {
async onLoad(ctx) {
ctx.logger.info('AI 摘要插件已加载');
}
// loop 触发:获取待处理视频
async fetchItems(ctx) {
const result = await ctx.api.db.query(
'SELECT vod_id, vod_name, vod_blurb FROM vod_main ' +
'WHERE vod_type = 1 AND (vod_content IS NULL OR vod_content = "") LIMIT ?',
[ctx.batchSize],
'video'
);
return (result && result.rows) || [];
}
// loop 触发:处理单个视频
async processItem(item, ctx) {
try {
const summary = await this._callLLM(item, ctx);
if (summary) {
// 更新视频摘要
await ctx.api.video.update(item.vod_id, { vod_content: summary });
// 记录到插件数据表
await ctx.api.db.execute(
'INSERT OR REPLACE INTO plugin_ai_summary_summaries ' +
'(vod_id, summary, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
[item.vod_id, summary, this.config.model,
Math.floor(Date.now() / 1000), Math.floor(Date.now() / 1000)]
);
ctx.logger.info(`视频 ${item.vod_id} (${item.vod_name}) 摘要已生成`);
ctx.task.reportProgress(1, 1, 0);
}
} catch (e) {
ctx.logger.error(`处理视频 ${item.vod_id} 失败: ${e.message}`);
ctx.task.reportProgress(1, 0, 1);
}
}
// manual 触发:手动执行
async onManual(params, ctx) {
ctx.logger.info('手动触发摘要生成');
const items = await this.fetchItems(ctx);
if (items.length === 0) {
return { message: '没有待处理的视频', count: 0 };
}
for (const item of items) {
await this.processItem(item, ctx);
}
return { message: '处理完成', count: items.length };
}
// 调用 LLM 生成摘要
async _callLLM(item, ctx) {
const lang = this.config.language === 'zh' ? '中文' : '英文';
const prompt = `为视频"${item.vod_name}"生成一段${lang}简介,200字以内。` +
(item.vod_blurb ? '原始描述:' + item.vod_blurb : '');
const resp = await ctx.api.http.post(
'https://api.siliconflow.cn/v1/chat/completions',
{
model: this.config.model,
messages: [{ role: 'user', content: prompt }],
max_tokens: 500,
},
{ Authorization: `Bearer ${this.config.api_key}` }
);
if (resp && resp.choices && resp.choices[0]) {
return resp.choices[0].message.content;
}
throw new Error('LLM 返回数据格式异常');
}
async onUnload() {}
}
module.exports = new AiSummaryPlugin();
案例要点
- 双触发器:同时声明 loop 和 manual,既可自动运行也可手动触发
- 权限最小化:只声明
vod_main:write:vod_content,仅允许更新摘要字段 - 数据持久化:使用插件数据表记录生成历史,便于追溯
- 进度上报:通过
ctx.task.reportProgress让前端实时看到处理进度 - 错误处理:单个视频失败不影响批次内其他视频
9.2 案例二:视频入库自动截图工具
场景:监听视频入库事件,自动调用 FFmpeg 截取视频封面并更新 vod_pic 字段。
manifest.toml
[plugin]
id = "auto-screenshot"
name = "自动截图工具"
version = "1.0.0"
author = "ykgj"
description = "视频入库后自动截取封面图"
entry = "main.js"
enabled = false
[dependencies]
ykgj = ">=1.0.0"
[[trigger]]
type = "event"
event = "video.imported"
idle_timeout_secs = 60
[permissions]
database = ["vod_main:read", "vod_main:write:vod_pic"]
filesystem = ["${plugin_dir}/*", "${cache_dir}/*"]
network = []
native = ["ffmpeg", "ffprobe"]
[[config]]
key = "screenshot_time"
type = "number"
default = 10
min = 1
max = 600
label = "截图时间点(秒)"
group = "基本设置"
[[config]]
key = "image_quality"
type = "number"
default = 2
min = 1
max = 10
label = "图片质量(1最好,10最差)"
group = "基本设置"
main.js
const { Plugin } = require('ykgj-plugin-sdk');
const path = require('path');
class AutoScreenshotPlugin extends Plugin {
async onLoad(ctx) {
ctx.logger.info('自动截图插件已加载');
}
// event 触发:处理视频入库事件
async onEvent(event, ctx) {
if (event.type !== 'video.imported') return;
const { vod_id, vod_name } = event.payload;
ctx.logger.info(`新视频入库: ${vod_name} (ID: ${vod_id})`);
try {
// 获取视频详情
const video = await ctx.api.video.get(vod_id);
if (!video || !video.vod_play_url) {
ctx.logger.warn(`视频 ${vod_id} 无播放地址,跳过`);
return;
}
// 如果已有封面则跳过
if (video.vod_pic) {
ctx.logger.info(`视频 ${vod_id} 已有封面,跳过`);
return;
}
// 截图保存到插件缓存目录
const outputPath = path.join(ctx.pluginDir, `cover_${vod_id}.jpg`);
const ssTime = this._formatTime(this.config.screenshot_time);
ctx.logger.info(`开始截图: ${video.vod_play_url} -> ${outputPath}`);
const result = await ctx.api.ffmpeg.run([
'-i', video.vod_play_url,
'-ss', ssTime,
'-vframes', '1',
'-q:v', String(this.config.image_quality),
'-y', // 覆盖已存在文件
outputPath
]);
if (result.exit_code === 0) {
// 更新视频封面
await ctx.api.video.update(vod_id, { vod_pic: outputPath });
ctx.logger.info(`视频 ${vod_id} 封面已更新: ${outputPath}`);
} else {
ctx.logger.error(`截图失败: ${result.stderr}`);
}
} catch (e) {
ctx.logger.error(`处理视频 ${vod_id} 失败: ${e.message}`);
}
}
// 格式化时间(秒 -> HH:MM:SS)
_formatTime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return [h, m, s].map(n => String(n).padStart(2, '0')).join(':');
}
async onUnload() {}
}
module.exports = new AutoScreenshotPlugin();
案例要点
- 事件驱动:使用 event 触发器响应
video.imported,无需轮询 - 幂等处理:检查视频是否已有封面,避免重复截图
- FFmpeg 调用:通过
ctx.api.ffmpeg.run执行命令行工具 - 文件路径:截图保存到插件目录,需声明 filesystem 权限
- 时间格式化:将秒数转为 FFmpeg 支持的 HH:MM:SS 格式
9.3 案例三:视频统计报表 API 服务
场景:通过 route 触发器对外提供 HTTP API,返回视频库的统计报表,供外部系统调用。
manifest.toml
[plugin]
id = "stats-api"
name = "统计报表 API"
version = "1.0.0"
author = "ykgj"
description = "提供视频库统计数据的 HTTP API"
entry = "main.js"
enabled = false
[dependencies]
ykgj = ">=1.0.0"
[[trigger]]
type = "route"
path = "/report"
method = "GET"
keep_alive_secs = 300
[[trigger]]
type = "route"
path = "/actors"
method = "GET"
keep_alive_secs = 300
[[trigger]]
type = "manual"
display_name = "测试报表"
[permissions]
database = [
"vod_main:read",
"actors:read",
"vod_actors:read",
"categories:read"
]
filesystem = []
network = []
native = []
[[config]]
key = "cache_secs"
type = "number"
default = 60
min = 0
max = 3600
label = "缓存时间(秒)"
group = "基本设置"
main.js
const { Plugin } = require('ykgj-plugin-sdk');
class StatsApiPlugin extends Plugin {
async onLoad(ctx) {
this._cache = new Map(); // 简单内存缓存
ctx.logger.info('统计报表 API 插件已加载');
}
// route 触发:处理 HTTP 请求
async onRoute(req, ctx) {
try {
if (req.path === '/report') {
return await this._handleReport(ctx);
} else if (req.path === '/actors') {
return await this._handleActors(ctx);
}
return this._json(404, { error: 'Not Found' });
} catch (e) {
ctx.logger.error('API 错误: ' + e.message);
return this._json(500, { error: e.message });
}
}
// manual 触发:测试
async onManual(params, ctx) {
const report = await this._getReport(ctx);
return report;
}
// 视频统计报表
async _handleReport(ctx) {
const cacheKey = 'report';
const cached = this._getCache(cacheKey);
if (cached) return this._json(200, cached);
const report = await this._getReport(ctx);
this._setCache(cacheKey, report);
return this._json(200, { code: 0, data: report });
}
// 演员统计
async _handleActors(ctx) {
const cacheKey = 'actors';
const cached = this._getCache(cacheKey);
if (cached) return this._json(200, cached);
// 查询演员视频数 Top 10
// 注意:actors、vod_actors 表存储在 db_actor.db,当前 db API 的 database 参数
// 暂未暴露 actor 库(仅支持 "video"),此查询需后续版本支持或改用其他方式
const result = await ctx.api.db.query(
'SELECT a.id, a.name, a.photo_path, COUNT(va.vod_id) as video_count ' +
'FROM actors a ' +
'JOIN vod_actors va ON a.id = va.actor_id ' +
'GROUP BY a.id ORDER BY video_count DESC LIMIT 10'
);
const topActors = result.rows;
const data = { top_actors: topActors };
this._setCache(cacheKey, data);
return this._json(200, { code: 0, data: data });
}
// 获取报表数据(vod_main 在视频库,需传 database: "video")
async _getReport(ctx) {
// 视频总数
const totalRes = await ctx.api.db.query(
'SELECT COUNT(*) as total FROM vod_main',
[],
'video'
);
const total = (totalRes.rows[0] && totalRes.rows[0].total) || 0;
// 按类型统计
const byTypeRes = await ctx.api.db.query(
'SELECT vod_type, COUNT(*) as count FROM vod_main GROUP BY vod_type',
[],
'video'
);
// 按年份统计
const byYearRes = await ctx.api.db.query(
'SELECT vod_year, COUNT(*) as count FROM vod_main ' +
'WHERE vod_year != "" GROUP BY vod_year ORDER BY vod_year DESC LIMIT 10',
[],
'video'
);
// 按地区统计
const byAreaRes = await ctx.api.db.query(
'SELECT vod_area, COUNT(*) as count FROM vod_main ' +
'WHERE vod_area != "" GROUP BY vod_area ORDER BY count DESC LIMIT 10',
[],
'video'
);
// 已观看/未观看
const watchedRes = await ctx.api.db.query(
'SELECT watched, COUNT(*) as count FROM vod_main GROUP BY watched',
[],
'video'
);
return {
total,
by_type: byTypeRes.rows,
by_year: byYearRes.rows,
by_area: byAreaRes.rows,
watched: watchedRes.rows
};
}
// 简单缓存
_getCache(key) {
const item = this._cache.get(key);
if (!item) return null;
if (Date.now() - item.time > this.config.cache_secs * 1000) {
this._cache.delete(key);
return null;
}
return item.data;
}
_setCache(key, data) {
this._cache.set(key, { data, time: Date.now() });
}
// JSON 响应工具
_json(status, data) {
return {
status,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify(data)
};
}
async onUnload() {
this._cache.clear();
}
}
module.exports = new StatsApiPlugin();
调用示例
# 获取视频统计报表
curl http://127.0.0.1:38089/api/v1/plugins/stats-api/report
# 获取演员排行
curl http://127.0.0.1:38089/api/v1/plugins/stats-api/actors
案例要点
- 多路由:一个插件声明多个 route 触发器,提供多个 API 端点
- SQL 聚合查询:使用 GROUP BY 统计分类数据
- 内存缓存:减少数据库查询,提升响应速度
- 错误处理:统一 try/catch,返回标准 JSON 错误响应
- CORS 支持:响应头添加
Access-Control-Allow-Origin,便于浏览器调用 - 多库分离:vod_main 在 db_video.db,访问需传
database: "video";actors 等表在独立库,当前 db API 暂未暴露,需后续版本支持
9.4 案例对比总结
| 维度 | AI 摘要 | 自动截图 | 统计 API | 每日清理 |
|---|---|---|---|---|
| 触发器 | loop + manual | event | route + manual | cron |
| 执行模式 | 批量处理 | 单次响应 | 请求-响应 | 定时单次 |
| 主要 API | db, http, video | video, ffmpeg, fs | db | db, fs |
| 数据表 | 有(记录历史) | 无 | 无(内存缓存) | 有(记录日志) |
| 网络访问 | 需要(调用 LLM) | 不需要 | 不需要 | 不需要 |
| 原生命令 | 不需要 | 需要(ffmpeg) | 不需要 | 不需要 |
| 适用场景 | 定期数据处理 | 事件驱动工作流 | 对外提供 API | 精确时间点任务 |
9.5 案例四:UI 扩展插件
场景:开发一个完整的 UI 扩展插件,包含主菜单页面、视频右键菜单、详情面板、编辑面板四种 UI 类型,演示 UI 扩展的完整开发流程。
manifest.toml
[plugin]
id = "example-ui-plugin"
name = "UI 扩展示例"
version = "1.0.0"
author = "demo"
description = "演示插件 UI 扩展功能:主菜单页面、视频右键菜单、详情面板、编辑面板"
entry = "main.js"
enabled = false
[permissions]
database = ["vod_main:read"]
# 1. 主菜单嵌入自定义页面
[[trigger]]
type = "ui"
ui_type = "menu_item"
ui_slot = "main_menu"
ui_page = "dashboard.html"
ui_label = "插件统计"
ui_icon = "fas fa-chart-bar"
ui_order = 100
# 2. 视频列表右键菜单扩展
[[trigger]]
type = "ui"
ui_type = "context_menu"
ui_slot = "video_list_context_menu"
ui_label = "用示例插件处理"
ui_icon = "fas fa-magic"
ui_order = 50
# 3. 视频详情页自定义面板
[[trigger]]
type = "ui"
ui_type = "panel"
ui_slot = "video_detail_sidebar"
ui_page = "extra-info.html"
ui_label = "扩展信息"
ui_icon = "fas fa-info-circle"
ui_order = 100
# 4. 视频编辑页自定义面板
[[trigger]]
type = "ui"
ui_type = "panel"
ui_slot = "video_edit_sidebar"
ui_page = "edit-panel.html"
ui_label = "编辑扩展"
ui_icon = "fas fa-edit"
ui_order = 100
# 5. 同时提供一个 HTTP API 路由(为 UI 页面提供数据)
[[trigger]]
type = "route"
path = "/api/stats"
method = "GET"
keep_alive_secs = 60
main.js
const { Plugin } = require('ykgj-plugin-sdk');
class ExampleUiPlugin extends Plugin {
async onLoad(ctx) {
ctx.logger.info('示例 UI 插件已加载');
}
// UI 触发钩子:处理右键菜单点击
async onUiTrigger(payload, ctx) {
const { slot, context } = payload;
if (slot === 'video_list_context_menu') {
const videoId = context.video_id;
const title = context.title;
ctx.logger.info(`处理视频: ${title} (ID: ${videoId})`);
// 查询视频详情
const video = await ctx.api.video.get(videoId);
ctx.logger.info(`视频详情: ${video?.vod_name || title}`);
return {
action: 'notify',
message: `已处理视频: ${video?.vod_name || title}`,
video_id: videoId
};
}
return { action: 'noop', message: '未处理的槽位: ' + slot };
}
// route 触发器:为 UI 页面提供 API
async onRoute(req, ctx) {
if (req.path === '/api/stats' && req.method === 'GET') {
const result = await ctx.api.db.query(
'SELECT COUNT(*) as total FROM vod_main',
[],
'video'
);
const total = result?.rows?.[0]?.total || 0;
return {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: 200,
data: {
total_videos: total,
plugin_name: 'example-ui-plugin',
timestamp: Date.now()
}
})
};
}
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 404, message: 'Not Found' })
};
}
}
module.exports = new ExampleUiPlugin();
ui/dashboard.html(主菜单整页)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>插件统计</title>
<link rel="stylesheet" href="/api/v1/plugins/example-ui-plugin/ui/assets/style.css">
</head>
<body>
<div class="demo-card">
<h3><i class="fas fa-chart-bar"></i> 视频统计</h3>
<div id="stats-content"><div class="loading">加载中...</div></div>
</div>
<div class="demo-card">
<h3><i class="fas fa-tools"></i> 操作</h3>
<div class="demo-btn-group">
<button class="demo-btn" onclick="refreshStats()">刷新统计</button>
<button class="demo-btn" onclick="goVideoList()">跳转视频列表</button>
</div>
</div>
<script src="/api/v1/plugins/example-ui-plugin/ui/assets/app.js"></script>
<script>
async function loadStats() {
try {
const data = await callPluginApi('/api/stats');
document.getElementById('stats-content').innerHTML =
'<ul class="info-list">' +
'<li><span class="label">视频总数</span><span class="value">' + data.total_videos + '</span></li>' +
'<li><span class="label">插件名称</span><span class="value">' + data.plugin_name + '</span></li>' +
'</ul>';
} catch (e) {
document.getElementById('stats-content').innerHTML =
'<div class="error">加载失败: ' + e.message + '</div>';
}
}
function goVideoList() {
postToParent({ type: 'navigate', url: '/video' });
}
loadStats();
</script>
</body>
</html>
ui/extra-info.html(详情面板)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>扩展信息</title>
<link rel="stylesheet" href="/api/v1/plugins/example-ui-plugin/ui/assets/style.css">
</head>
<body>
<div id="content"><div class="loading">等待视频上下文...</div></div>
<script src="/api/v1/plugins/example-ui-plugin/ui/assets/app.js"></script>
<script>
window.addEventListener('context-ready', async function (e) {
const { context } = e.detail;
const videoId = context?.video_id;
if (!videoId) {
document.getElementById('content').innerHTML = '<div class="error">未收到视频 ID</div>';
return;
}
try {
const video = await callHostApi('/api/v1/videos/' + videoId);
document.getElementById('content').innerHTML =
'<div class="demo-card">' +
'<h3><i class="fas fa-film"></i> 视频扩展信息</h3>' +
'<ul class="info-list">' +
'<li><span class="label">ID</span><span class="value">' + video.vod_id + '</span></li>' +
'<li><span class="label">名称</span><span class="value">' + (video.vod_name || '-') + '</span></li>' +
'</ul></div>';
autoResize();
} catch (e) {
document.getElementById('content').innerHTML = '<div class="error">加载失败</div>';
}
});
</script>
</body>
</html>
案例要点
- 多 UI 类型组合:一个插件可声明多个 UI 触发器,覆盖不同场景
- UI + route 配合:route 触发器为 UI 页面提供数据 API,实现前后端分离
- 样式隔离:iframe 内样式完全独立,需自行定义,使用 CSS 变量适配主题
- 高度自适应:panel 类型通过 postMessage 上报高度,上限 400px
- 上下文注入:主界面通过 postMessage 向 iframe 注入 video_id 等上下文
- 同源优势:UI 页面可直接 fetch 调用后端 API,无需额外权限声明
案例对比总结(补充)
| 维度 | AI 摘要 | 自动截图 | 统计 API | 每日清理 | UI 扩展 |
|---|---|---|---|---|---|
| 触发器 | loop + manual | event | route + manual | cron | ui + route |
| 执行模式 | 批量处理 | 单次响应 | 请求-响应 | 定时单次 | 用户交互 |
| 主要 API | db, http, video | video, ffmpeg, fs | db | db, fs | video, db |
| 数据表 | 有(记录历史) | 无 | 无(内存缓存) | 有(记录日志) | 无 |
| 网络访问 | 需要(调用 LLM) | 不需要 | 不需要 | 不需要 | 不需要 |
| 原生命令 | 不需要 | 需要(ffmpeg) | 不需要 | 不需要 | 不需要 |
| 适用场景 | 定期数据处理 | 事件驱动工作流 | 对外提供 API | 精确时间点任务 | 用户界面扩展 |