影库管家 JS 插件开发指南

从入门到精通 · 完整教学文档
适用于影库管家 v1.0+ | SDK 随软件内置,无需单独安装 | 最后更新:2026-06

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 随软件内置,无需单独目录。

影库管家.exe # 主程序(含 package.nw 压缩包) icon.png # 托盘图标 db/ # 数据库目录 ├── config.toml # 应用配置 ├── db_video.db # 视频数据库(vod_main/vod_episode/vod_detail) ├── db_actor.db # 演员数据库(actors/actor_faces/vod_actors) ├── db_category.db # 分类数据库(categories) ├── db_face_index.db # 人脸索引数据库(face_index) ├── image.db # 图片数据库(images/galleries/tags 等) ├── article_manager.db # 文章数据库(articles 等) └── ... plugins/ # ★ 插件目录(用户可自由增删) ├── ai-summary/ # 示例插件 │ ├── manifest.toml # 插件清单(必需) │ ├── main.js # 插件入口(必需) │ ├── icon.png # 插件图标(可选) │ ├── package.json # npm 依赖声明(可选) │ ├── node_modules/ # 自带依赖(可选) │ └── public/ # 静态资源(可选) └── hello-world/ # 另一个插件 ├── manifest.toml └── main.js logs/ # 日志目录 └── plugins/ # 插件运行日志 # SDK 随软件内置,无需单独目录 # 插件通过 require('ykgj-plugin-sdk') 加载,自动获取内置的 Plugin 类

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:查看插件数据表
插件运行时由 NW.js 内置 Node.js 提供,开发时无需单独安装 Node.js。如需使用 npm 管理第三方依赖,再另行安装。由于是nwjs 所以不支持原生 Node.js 模块。

定位插件目录

打开影库管家安装目录,找到 plugins/ 文件夹。若不存在则手动创建。每个插件是一个独立子目录,目录名建议与插件 ID 一致。

创建第一个插件目录

在 plugins/ 下新建文件夹,例如 my-plugin/,在其中创建 manifest.toml 和 main.js 两个文件。

2.4 插件加载与运行机制

理解插件的加载和运行机制,有助于编写高效、稳定的插件。本节从窗口生命周期、多触发器调度、消息处理三个维度说明。

2.4.1 懒加载:按需启动窗口

插件启用后并不会立即启动窗口,而是注册触发器等待调用。只有当某个触发器真正需要执行时(如 loop 到时、event 收到事件、manual 被点击),系统才会按需创建插件隐藏窗口。

插件启用 → 注册触发器(不创建窗口)
         ↓
触发器触发 → 创建隐藏窗口 → 加载 main.js → 建立 WS 连接 → 执行 onLoad
         ↓
窗口空闲 → 自动关闭(释放资源)
懒加载设计避免闲置插件占用内存。一个声明了 5 个触发器的插件,在没有任何触发时不会消耗任何运行时资源。

2.4.2 单例窗口:多触发器共享

每个插件全局只有一个隐藏窗口实例(单例)。当一个插件声明了多个触发器(如 loop + manual + event),所有触发器共享同一个窗口和同一个插件代码实例。

插件 ai-summary 声明了 3 个触发器:
  ├─ loop(每 5 分钟扫描)
  ├─ event(监听 video.imported)
  └─ manual(手动触发)

3 个触发器共享同一个窗口和 Plugin 实例
  → this.config 在所有触发器间共享
  → onLoad 只在窗口启动时调用一次
  → onUnload 只在窗口关闭时调用一次
由于多触发器共享同一个 Plugin 实例,在 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 完成后才开始
API 响应(api_response/api_error)不走队列,直接处理,避免被长时间运行的 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事件总线回调onEventidle_timeout_secs 保活
croncron 表达式精确触发onCronidle_timeout_secs 保活
manual用户点击按钮onManual执行期间持有
routeHTTP 请求到达onRoutekeep_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 目录结构要求

my-plugin/ # 插件根目录(名称建议 = 插件 ID) ├── manifest.toml # ★ 必需:插件清单 ├── main.js # ★ 必需:插件入口(CommonJS,导出 Plugin 实例) ├── icon.png # 可选:插件图标(建议 128x128 PNG) ├── README.md # 可选:插件说明文档 ├── package.json # 可选:声明 npm 依赖 ├── node_modules/ # 可选:自带的 npm 依赖 ├── lib/ # 可选:插件内部模块 │ ├── utils.js │ └── api.js └── public/ # 可选:静态资源目录 └── template.html

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.eventtype = "event" 时必填
trigger.path + methodtype = "route" 时必填
trigger.scheduletype = "cron" 时必填,5 字段 cron 表达式(分 时 日 月 周)
trigger.replacestype = "handler" 时必填,必须是现有 handler 名称
trigger.ui_typetype = "ui" 时必填,枚举值:page menu_item context_menu panel
trigger.ui_slottype = "ui" 时必填,非空字符串
trigger.ui_pageui_type = "page" 或 "panel" 时必填
permissions.database格式 表名:read / 表名:write / 表名:write:字段
permissions.filesystemglob 模式,支持 ${video_dir} ${plugin_dir} ${cache_dir}
config.key插件内唯一

4核心 API 详解

插件通过 ctx.api.* 调用宿主能力。SDK 内部将调用转为 WebSocket 消息发送给 Rust 后端,后端校验权限后执行并返回结果。所有 API 均为异步(返回 Promise)。

4.1 插件基类 Plugin

所有插件必须继承 Plugin 基类,并按需重写以下生命周期方法:

onLoad(ctx) 生命周期

插件加载时调用,用于初始化。可在此读取配置、建立连接、注册定时器。

参数:ctx - 插件上下文对象

返回:无

onUnload() 生命周期

插件卸载时调用,用于清理资源(关闭连接、清除定时器等)。

onConfigChanged(oldConfig, newConfig) 生命周期

配置变更时调用(仅插件窗口运行中时触发)。

fetchItems(ctx) loop 触发器

loop 触发器专用:获取待处理项列表。

返回:数组,每个元素会传给 processItem

processItem(item, ctx) loop 触发器

loop 触发器专用:处理单个项目。抛出异常视为失败。

onEvent(event, ctx) event 触发器

event 触发器专用:处理订阅的事件。

参数:event = { type, payload }

onManual(params, ctx) manual 触发器

manual 触发器专用:用户点击"立即执行"时调用。

返回:任意 JSON 可序列化对象,会推送到前端

onRoute(req, ctx) route 触发器

route 触发器专用:处理 HTTP 请求。

参数:req = { method, path, headers, body }

返回:{ status, headers, body }

onCron(ctx) cron 触发器

cron 触发器专用:按 cron 表达式在精确时间点触发执行。

参数:ctx.schedule 为 cron 表达式,ctx.timestamp 为触发时间戳(秒)

返回:任意 JSON 可序列化对象,记录到日志

说明:适合需要精确时间点执行的任务,如每天凌晨清理数据、每周一生成报表等。与 loop 触发器的区别在于 loop 按固定间隔执行,而 cron 可指定复杂的时间规则。

onUiTrigger(payload, ctx) ui 触发器

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)

video.list(filter, limit) 权限:vod_main:read

查询视频列表。

参数:

  • 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, ... }, ...]
video.get(vodId) 权限:vod_main:read

获取单个视频详情。

const video = await ctx.api.video.get(123);
// 返回:{ vod_id, vod_name, vod_content, ... }
video.update(vodId, fields) 权限:vod_main:write

更新视频字段。仅允许更新白名单字段。

允许更新的字段: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: '剧情,悬疑'
});
video.getByPath(path) 权限:vod_main:read

按文件路径查找视频。

const video = await ctx.api.video.getByPath('D:/videos/movie.mp4');

4.4 数据库 API(ctx.api.db)

db.query(sql, params, database) 权限:对应表 read

执行只读 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 访问结果数组,不是直接返回数组。
db.execute(sql, params, database) 权限:对应表 write

执行写 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.request(method, url, body, headers) 权限:network 白名单

通用 HTTP 请求。超时 2 分钟。

http.get(url, headers) 权限:network 白名单
const data = await ctx.api.http.get('https://api.example.com/info', {
  'Authorization': 'Bearer xxx'
});
http.post(url, body, headers) 权限:network 白名单
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。

fs.read(path) 权限:filesystem 路径匹配

读取文件内容(文本)。

fs.write(path, content) 权限:filesystem 路径匹配

写入文件内容(覆盖)。

fs.readdir(path) 权限:filesystem 路径匹配

列出目录内容。

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.run(args) 权限:native: ffmpeg

执行 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 }
ffmpeg.probe(path) 权限:native: ffprobe

获取媒体文件信息,超时 1 分钟。

4.8 事件 API(ctx.api.event)

event.publish(topic, payload) 无权限要求

发布自定义事件,其他插件可订阅。

await ctx.api.event.publish('my-plugin.done', {
  vod_id: 123,
  status: 'success'
});
event.subscribe(topic) 无权限要求

运行时动态订阅事件。订阅后,事件到达时会触发 onEvent。

// 在 onLoad 中动态订阅
await ctx.api.event.subscribe('video.updated');

4.9 配置 API(ctx.api.config)

config.get() 无权限要求

获取当前插件配置(已解密)。通常直接使用 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 中声明对应表的读写权限。以下是主要数据库表及其字段说明。

多库分离机制:影库管家采用多数据库分离存储,不同表分布在不同 SQLite 文件中。访问时需通过 db.query/execute 的第三参数 database 指定目标库:
database 参数数据库文件包含的表
"video"db_video.dbvod_main、vod_episode、vod_detail
不传(主库)db_main.dbplugins、plugin_logs、插件自有表(plugin_*)等
—db_actor.dbactors、actor_faces、vod_actors(当前未通过 database 参数暴露,仅主库可访问时无效)
—db_category.dbcategories
—image.dbimages、galleries、tags、image_tags 等
—article_manager.dbarticles、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_idINTEGER视频 ID(主键,自增)
vod_typeINTEGER视频类型(0=其他,1=普通视频)
type_idINTEGER分类 ID
group_idINTEGER分组 ID
vod_nameTEXT视频名称
vod_subTEXT副标题
vod_dirTEXT视频所在目录
vod_dirkeyTEXT目录键(用于去重)
vod_play_fromTEXT播放来源(如 local、采集站名)
vod_play_urlTEXT播放地址
vod_play_serverTEXT播放服务器
vod_extensionTEXT文件扩展名
vod_sizeTEXT文件大小
vod_durationINTEGER时长(秒)
vod_resolutionTEXT分辨率
vod_picTEXT封面图路径
vod_previewTEXT预览图路径
vod_tagTEXT标签(逗号分隔)
vod_classTEXT分类(逗号分隔)
vod_yearTEXT年份
vod_areaTEXT地区
vod_langTEXT语言
vod_actorTEXT演员(逗号分隔)
vod_directorTEXT导演
vod_serialTEXT连载状态
vod_totalINTEGER总集数
vod_blurbTEXT简介
vod_contentTEXT详细介绍/摘要
vod_statusINTEGER状态
vod_lockINTEGER锁定标记
vod_isendINTEGER是否完结
watchedINTEGER是否已观看
watch_progressINTEGER观看进度
collectedTEXT收藏信息
subtitle_statusTEXT字幕状态
vod_scoreTEXT评分
vod_hitsINTEGER点击数
phashTEXT感知哈希(去重用)
vod_pinyinTEXT名称拼音
vod_time_addINTEGER入库时间戳
vod_timeINTEGER更新时间戳
vod_pubdateTEXT发布日期
vod_letterTEXT首字母
vod_remarksTEXT备注
face_recognizedINTEGER是否已识别人脸

5.2.2 vod_episode(视频剧集表)

字段类型说明
episode_idINTEGER剧集 ID(主键)
vod_idINTEGER所属视频 ID(外键)
episode_numberINTEGER集数
episode_nameTEXT剧集名称
episode_play_fromTEXT播放来源
episode_play_urlTEXT播放地址
episode_coverTEXT剧集封面
episode_previewTEXT预览图
episode_subtitleTEXT字幕路径
episode_durationINTEGER时长(秒)
episode_statusINTEGER状态
episode_hitsINTEGER点击数
episode_time_addINTEGER添加时间戳

5.2.3 vod_detail(视频详情表)

字段类型说明
idINTEGER主键
vod_idINTEGER视频 ID(外键,唯一)
metadataTEXT元数据 JSON
created_atINTEGER创建时间
updated_atINTEGER更新时间

5.2.4 actors(演员表)

字段类型说明
idINTEGER演员 ID(主键)
nameVARCHAR(50)姓名(唯一)
aliasVARCHAR(100)别名
genderTINYINT性别(0=女,1=男)
birth_dateVARCHAR(20)出生日期
birthplaceVARCHAR(100)出生地
nationalityVARCHAR(50)国籍
heightINTEGER身高(cm)
bust/waist/hipsINTEGER三围
cupVARCHAR(10)罩杯
debut_yearINTEGER出道年份
professionVARCHAR(100)职业
companyVARCHAR(100)公司
biographyTEXT简介
photo_pathVARCHAR(255)照片路径
profile_imageVARCHAR(255)头像路径
video_countINTEGER视频数量
is_favoriteINTEGER是否收藏
is_hiddenINTEGER是否隐藏
statusTINYINT状态
created_atINTEGER创建时间
updated_atINTEGER更新时间

5.2.5 vod_actors(视频-演员关联表)

字段类型说明
vod_idINTEGER视频 ID
actor_idINTEGER演员 ID
roleVARCHAR(100)角色
sortINTEGER排序

5.2.6 actor_faces(演员人脸表)

字段类型说明
idINTEGER主键
actor_idINTEGER演员 ID
face_vectorBLOB人脸特征向量
face_image_pathTEXT人脸图片路径
confidenceREAL置信度
is_primaryINTEGER是否主脸
usearch_keyINTEGER向量索引键
created_atINTEGER创建时间

5.2.7 categories(分类表)

字段类型说明
type_idINTEGER分类 ID(主键)
type_nameTEXT分类名称
type_en_nameTEXT英文名
parent_idINTEGER父分类 ID
type_sortINTEGER排序
type_typeINTEGER分类类型
type_midINTEGER模块 ID
iconTEXT图标
is_showINTEGER是否显示

5.2.8 images(图片表)

字段类型说明
image_idINTEGER图片 ID(主键)
image_nameVARCHAR(255)图片名称
file_pathVARCHAR(500)文件路径
file_nameVARCHAR(255)文件名
file_extensionVARCHAR(10)扩展名
file_sizeBIGINT文件大小
width / heightINTEGER宽高
formatVARCHAR(20)格式
color_spaceVARCHAR(20)色彩空间
bit_depthINTEGER位深
is_animatedBOOLEAN是否动图
thumbnail_smallVARCHAR(255)小缩略图
thumbnail_coverVARCHAR(255)封面缩略图
type_idINTEGER分类 ID
category_pathVARCHAR(255)分类路径
gallery_idINTEGER图集 ID
chapter_idINTEGER章节 ID
sort_orderINTEGER排序
source_typeTINYINT来源类型
source_urlVARCHAR(500)来源 URL
dir_keyVARCHAR(100)目录键
statusTINYINT状态
is_favoriteBOOLEAN是否收藏
is_coverBOOLEAN是否封面
ratingREAL评分
like_countINTEGER点赞数
view_countINTEGER查看数
phashVARCHAR(64)感知哈希
dhashVARCHAR(64)差异哈希

5.2.9 galleries(图集表)

字段类型说明
gallery_idINTEGER图集 ID(主键)
gallery_nameVARCHAR(255)图集名称
gallery_descTEXT描述
type_idINTEGER分类 ID
category_pathVARCHAR(255)分类路径
cover_image_idINTEGER封面图片 ID
cover_pathVARCHAR(500)封面路径
source_typeTINYINT来源类型
source_urlVARCHAR(500)来源 URL
dir_pathVARCHAR(500)目录路径
dir_keyVARCHAR(100)目录键
gallery_typeTINYINT图集类型
statusTINYINT状态
is_favoriteBOOLEAN是否收藏
image_countINTEGER图片数量

5.2.10 articles(文章表)

字段类型说明
article_idINTEGER文章 ID(主键)
type_idINTEGER分类 ID
author_idINTEGER作者 ID
titleVARCHAR(500)标题
summaryVARCHAR(2000)摘要
publish_yearINTEGER发布年份
content_lengthINTEGER内容长度
media_countINTEGER媒体数量
image_countINTEGER图片数量
video_countINTEGER视频数量
storage_dirVARCHAR(255)存储目录
cover_imageVARCHAR(255)封面图
tagsVARCHAR(500)标签
statusTINYINT状态
is_topTINYINT是否置顶
view_countINTEGER查看数
like_countINTEGER点赞数
dislike_countINTEGER踩数

5.2.11 article_authors(文章作者表)

字段类型说明
author_idINTEGER作者 ID(主键)
nameVARCHAR(100)姓名
avatarVARCHAR(255)头像
descriptionVARCHAR(500)描述
platformVARCHAR(50)平台
external_idVARCHAR(255)外部 ID
article_countINTEGER文章数
total_viewsINTEGER总查看数
total_likesINTEGER总点赞数

5.2.12 article_media(文章媒体表)

字段类型说明
media_idINTEGER媒体 ID(主键)
article_idINTEGER文章 ID(外键)
media_typeVARCHAR(20)媒体类型(image/video)
file_nameVARCHAR(255)文件名
file_pathVARCHAR(500)文件路径
thumb_pathVARCHAR(500)缩略图路径
file_sizeINTEGER文件大小
width / heightINTEGER宽高
durationINTEGER时长
sort_orderINTEGER排序

5.2.13 comic_series(漫画系列表)

字段类型说明
series_idINTEGER系列 ID(主键)
series_nameVARCHAR(255)系列名称
series_aliasVARCHAR(255)别名
series_descTEXT描述
authorVARCHAR(100)作者
artistVARCHAR(100)画师
publisherVARCHAR(100)出版社
type_idINTEGER分类 ID
total_chaptersINTEGER总章节数
statusTINYINT状态
is_favoriteBOOLEAN是否收藏
ratingREAL评分

5.2.14 comic_chapters(漫画章节表)

字段类型说明
chapter_idINTEGER章节 ID(主键)
series_idINTEGER系列 ID(外键)
chapter_numberINTEGER章节号
chapter_titleVARCHAR(255)章节标题
volume_numberINTEGER卷号
gallery_idINTEGER关联图集 ID
page_countINTEGER页数
last_read_pageINTEGER最后阅读页
is_readBOOLEAN是否已读

5.2.15 tags(标签表)

字段类型说明
tag_idINTEGER标签 ID(主键)
tag_nameVARCHAR(255)标签名(唯一)
usage_countINTEGER使用次数
created_atINTEGER创建时间

5.2.16 image_tags(图片-标签关联表)

字段类型说明
image_idINTEGER图片 ID
tag_idINTEGER标签 ID
confidenceREAL置信度
is_autoBOOLEAN是否自动生成

5.2.17 face_index(人脸索引表)

字段类型说明
idINTEGER主键
video_idINTEGER视频 ID
frame_timeREAL帧时间(秒)
actor_idINTEGER演员 ID
actor_face_idINTEGER演员人脸 ID

5.2.18 plugins(插件表)

字段类型说明
idTEXT插件 ID(主键)
nameTEXT名称
versionTEXT版本
authorTEXT作者
descriptionTEXT描述
entryTEXT入口文件
manifest_pathTEXT清单路径
plugin_dirTEXT插件目录
stateTEXT状态(installed/enabled/disabled/error)
enabled_triggersTEXT启用的触发器(JSON)
permissions_approvedINTEGER权限是否已批准
installed_atINTEGER安装时间
updated_atINTEGER更新时间
last_run_atINTEGER最后运行时间
error_messageTEXT错误信息
fail_countINTEGER连续失败次数

5.2.19 plugin_logs(插件日志表)

字段类型说明
idINTEGER日志 ID(主键)
plugin_idTEXT插件 ID
levelTEXT级别(info/warn/error)
messageTEXT日志内容
task_idTEXT任务 ID
created_atINTEGER创建时间
插件自己的数据表命名格式为 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")。

cron 表达式为标准 5 字段格式:分 时 日 月 周。例如 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 补跑机制:当 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 页面目录结构

my-plugin/ ├── manifest.toml ├── main.js └── ui/ # UI 页面目录 ├── dashboard.html # 整页页面 ├── extra-info.html # 详情面板 ├── edit-panel.html # 编辑面板 └── assets/ # 静态资源 ├── style.css # 共享样式 └── app.js # 共享脚本

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 通信协议

方向消息类型数据结构说明
主界面 → iframeplugin-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)
UI 页面由后端 HTTP 服务器提供(如 http://127.0.0.1:8053),iframe 内可直接 fetch 调用后端 API(同源)。但 iframe 与父页面(chrome-extension:// 协议)跨域,不能用 location.origin 作为 postMessage 的 targetOrigin,必须使用 postToParent() 辅助函数(app.js 提供,自动捕获父窗口 origin)。插件 route 触发器提供的 API 可通过 /api/v1/plugins/{plugin_id}/{path} 访问。

7插件打包发布

7.1 插件包格式

插件包为 .ykgj-plugin 扩展名的 ZIP 压缩包,解压后即为完整的插件目录结构。

my-plugin.ykgj-plugin (实为 ZIP) ├── manifest.toml # 必需 ├── main.js # 必需 ├── icon.png # 可选 ├── package.json # 可选 ├── node_modules/ # 可选(自带依赖) ├── lib/ # 可选 └── public/ # 可选

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!');
  });
});
建议将插件核心业务逻辑抽离为纯函数,接收输入返回输出,不直接依赖 ctx,便于单元测试。

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 + manualeventroute + manualcron
执行模式批量处理单次响应请求-响应定时单次
主要 APIdb, http, videovideo, ffmpeg, fsdbdb, fs
数据表有(记录历史)无无(内存缓存)有(记录日志)
网络访问需要(调用 LLM)不需要不需要不需要
原生命令不需要需要(ffmpeg)不需要不需要
适用场景定期数据处理事件驱动工作流对外提供 API精确时间点任务
通过以上案例可以看出,插件系统覆盖了"定期处理"、"事件响应"、"服务提供"、"定时任务"四种典型场景,结合丰富的宿主 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 + manualeventroute + manualcronui + route
执行模式批量处理单次响应请求-响应定时单次用户交互
主要 APIdb, http, videovideo, ffmpeg, fsdbdb, fsvideo, db
数据表有(记录历史)无无(内存缓存)有(记录日志)无
网络访问需要(调用 LLM)不需要不需要不需要不需要
原生命令不需要需要(ffmpeg)不需要不需要不需要
适用场景定期数据处理事件驱动工作流对外提供 API精确时间点任务用户界面扩展