Compare commits
77 Commits
AutoSignIn
...
InvitesSig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c5e62cf8a | ||
|
|
f9cc36f93c | ||
|
|
f370a0041a | ||
|
|
e58e64dba3 | ||
|
|
1c114b1d68 | ||
|
|
9c9bdedd01 | ||
|
|
431474b441 | ||
|
|
4323e54552 | ||
|
|
c6bdf86f1f | ||
|
|
6b65fe10fe | ||
|
|
92d1fda892 | ||
|
|
0a4884f31e | ||
|
|
baee9de032 | ||
|
|
22aa436ace | ||
|
|
aeea6abe85 | ||
|
|
13b92ddc51 | ||
|
|
c91e24a2ef | ||
|
|
e9889051b3 | ||
|
|
a8dad0a3b0 | ||
|
|
68fe2dd54c | ||
|
|
63dbaf8657 | ||
|
|
cbac2b4d41 | ||
|
|
0910b6cc47 | ||
|
|
3500876b5c | ||
|
|
32b13f5bd6 | ||
|
|
d57de9b28a | ||
|
|
9141667569 | ||
|
|
c6cc8312f7 | ||
|
|
545ac141ff | ||
|
|
9862c81477 | ||
|
|
9fb3e09042 | ||
|
|
1ad19a5b23 | ||
|
|
527327c6cb | ||
|
|
a398dcb0b8 | ||
|
|
f3232dba0a | ||
|
|
e78a371663 | ||
|
|
068838d013 | ||
|
|
57f2ad523c | ||
|
|
615f85f02b | ||
|
|
74f47c7131 | ||
|
|
87224308d6 | ||
|
|
4b413d93a8 | ||
|
|
34e72a7ae3 | ||
|
|
944af59468 | ||
|
|
0f898f283e | ||
|
|
07a4731feb | ||
|
|
d3faafe6ee | ||
|
|
8bff87f1c5 | ||
|
|
889f393d2a | ||
|
|
e008da0c2b | ||
|
|
f3d1aa1ea9 | ||
|
|
77f399ffa0 | ||
|
|
e101d5c2bd | ||
|
|
a0d25abe25 | ||
|
|
bd3f6fe2e5 | ||
|
|
7f41a8a5f2 | ||
|
|
c33e7fe9df | ||
|
|
20e18117ab | ||
|
|
750d5917a2 | ||
|
|
fc23e3639d | ||
|
|
8a5b01f58f | ||
|
|
72bb3320ac | ||
|
|
2a4002032d | ||
|
|
be12618b0f | ||
|
|
4d2bc309ac | ||
|
|
2f78083c7f | ||
|
|
f1355f3400 | ||
|
|
6a03f626be | ||
|
|
5cf62a221a | ||
|
|
9662a4c457 | ||
|
|
3ad3de299c | ||
|
|
e760cd6afa | ||
|
|
8d30ba5c69 | ||
|
|
a9b66c4f43 | ||
|
|
cdc062d681 | ||
|
|
437b2b05d4 | ||
|
|
944919fc34 |
20
docs/FAQ.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# MoviePilot 插件常见问题
|
||||
|
||||
常见问题已从主 README 拆分为独立文档,按主题查阅即可。
|
||||
|
||||
- [1. 如何扩展消息推送渠道?](./faq/01-extend-notification-channel.md)
|
||||
- [2. 如何在插件中实现远程命令响应?](./faq/02-remote-command-handler.md)
|
||||
- [3. 如何在插件中对外暴露API?](./faq/03-expose-plugin-api.md)
|
||||
- [4. 如何在插件中注册公共定时服务?](./faq/04-register-service.md)
|
||||
- [5. 如何通过插件增强MoviePilot的识别功能?](./faq/05-enhance-recognition.md)
|
||||
- [6. 如何扩展内建索引器的索引站点?](./faq/06-extend-indexer-sites.md)
|
||||
- [7. 如何在插件中调用API接口?](./faq/07-call-api-from-plugin.md)
|
||||
- [8. 如何将插件内容显示到仪表板?](./faq/08-render-dashboard.md)
|
||||
- [9. 如何扩展探索功能的媒体数据源?](./faq/09-extend-discovery-source.md)
|
||||
- [10. 如何扩展推荐功能的媒体数据源?](./faq/10-extend-recommend-source.md)
|
||||
- [11. 如何通过插件重载实现系统模块功能?](./faq/11-override-system-module.md)
|
||||
- [12. 如何通过插件扩展支持的存储类型?](./faq/12-extend-storage-type.md)
|
||||
- [13. 如何将插件功能集成到工作流?](./faq/13-integrate-workflow.md)
|
||||
- [14. 如何在插件中通过消息持续与用户交互?](./faq/14-message-interaction.md)
|
||||
- [15. 如何在插件中使用系统级统一缓存?](./faq/15-use-system-cache.md)
|
||||
- [16. 如何在插件中注册智能体工具?](./faq/16-register-agent-tools.md)
|
||||
264
docs/Repository_Guide.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# MoviePilot-Plugins 仓库指南
|
||||
|
||||
本文档面向维护者和插件开发者,说明 `MoviePilot-Plugins` 在整个 MoviePilot 体系中的职责、目录约定、元数据规则、发布流程,以及与 `MoviePilot` / `MoviePilot-Frontend` 两个主仓库的边界。
|
||||
|
||||
## 1. 仓库职责
|
||||
|
||||
`MoviePilot-Plugins` 不是独立运行时,而是插件市场和插件源码仓库。
|
||||
|
||||
- `MoviePilot` 后端仓库负责:
|
||||
- 插件类加载与生命周期管理
|
||||
- 事件与链式扩展
|
||||
- 插件 API / 服务 / 仪表板注册
|
||||
- 配置、插件数据、权限控制
|
||||
- 插件安装、升级、分身、远程组件静态资源服务
|
||||
- `MoviePilot-Frontend` 前端仓库负责:
|
||||
- 插件市场与插件卡片展示
|
||||
- 插件配置页、详情页、仪表板渲染
|
||||
- Vue 联邦远程组件加载
|
||||
- 插件侧栏全页入口
|
||||
- `MoviePilot-Plugins` 负责:
|
||||
- 插件源码目录
|
||||
- 插件市场索引文件
|
||||
- 插件图标资源
|
||||
- 插件开发与维护文档
|
||||
|
||||
因此,开发插件时要避免把“宿主逻辑”误写进本仓库文档。例如:
|
||||
|
||||
- 某个 `get_api()` 为什么没有被挂载,应该先看 `MoviePilot/app/api/endpoints/plugin.py`
|
||||
- 某个 Vue 远程页面为什么没有出现在侧栏,应该先看 `MoviePilot-Frontend` 的联邦加载与菜单逻辑
|
||||
- 某个插件为什么在插件市场里没显示,才应该先看本仓库的 `package.json` / `package.v2.json`
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
本仓库当前采用如下结构:
|
||||
|
||||
```text
|
||||
MoviePilot-Plugins/
|
||||
├── plugins/ # 默认插件目录
|
||||
├── plugins.v2/ # V2 专用插件目录
|
||||
├── icons/ # 插件图标
|
||||
├── docs/ # 文档
|
||||
├── package.json # 默认插件索引
|
||||
├── package.v2.json # V2 优先插件索引
|
||||
└── .github/workflows/ # 自动发布工作流
|
||||
```
|
||||
|
||||
关键约定:
|
||||
|
||||
- 一个插件一个目录。
|
||||
- 目录名必须是插件类名的小写,例如 `class AutoSignIn` 对应目录 `autosignin/`。
|
||||
- 插件主类必须定义在该目录的 `__init__.py` 中。
|
||||
- 插件目录内可附带:
|
||||
- `requirements.txt`:额外 Python 依赖
|
||||
- `README.md`:插件专属使用说明
|
||||
- `dist/assets/`:Vue 联邦构建产物
|
||||
- 其他运行时所需静态文件
|
||||
|
||||
## 3. 元数据文件说明
|
||||
|
||||
### 3.1 `package.json`
|
||||
|
||||
默认插件索引文件,用于:
|
||||
|
||||
- 旧版兼容或默认版本插件
|
||||
- 对 V2 兼容但不需要单独维护代码目录的插件
|
||||
|
||||
如果某个默认插件也能用于 V2,需要在条目上声明:
|
||||
|
||||
```json
|
||||
{
|
||||
"MyPlugin": {
|
||||
"version": "1.2.3",
|
||||
"v2": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 `package.v2.json`
|
||||
|
||||
V2 优先插件索引文件。MoviePilot 在 V2 环境下会优先读取这里的条目;找不到时,才会回退到 `package.json` 中声明了 `"v2": true` 的兼容插件。
|
||||
|
||||
### 3.3 常用字段
|
||||
|
||||
每个索引条目通常包含:
|
||||
|
||||
- `name`:插件展示名
|
||||
- `description`:插件简介
|
||||
- `labels`:标签,多个标签使用英文逗号分隔
|
||||
- `version`:插件版本
|
||||
- `icon`:图标文件名或完整 HTTP URL
|
||||
- `author`:作者
|
||||
- `level`:用户可见级别
|
||||
- `history`:更新日志
|
||||
- `release`:是否使用 GitHub Release 压缩包发布
|
||||
- `v2`:默认索引中的插件是否兼容 V2
|
||||
|
||||
这些字段是“插件市场展示元数据”,而不是运行时唯一真相。真正加载后的插件类仍然需要在代码里声明自己的 `plugin_name`、`plugin_desc`、`plugin_version` 等属性。两者必须同步。
|
||||
|
||||
## 4. 版本选择与加载规则
|
||||
|
||||
MoviePilot 当前的插件版本选择逻辑可以概括为:
|
||||
|
||||
1. 先确定当前宿主版本标识,例如 `v2`
|
||||
2. 优先检查 `package.v2.json` 中是否存在该插件
|
||||
3. 若不存在,再检查 `package.json`
|
||||
4. 只有当 `package.json` 中对应条目显式声明 `"v2": true` 时,才会作为 V2 兼容插件继续使用
|
||||
|
||||
这意味着:
|
||||
|
||||
- 同一个插件若在 `package.v2.json` 中已有专用实现,就不要再依赖 `package.json` 中的兼容声明做“隐式覆盖”。
|
||||
- 新写的 V2 专用插件,优先放 `plugins.v2/`,并把元数据写入 `package.v2.json`。
|
||||
- 真正跨版本共用一套实现时,再使用 `package.json + "v2": true` 的方式。
|
||||
|
||||
## 5. 与宿主仓库的协作边界
|
||||
|
||||
### 5.1 与 `MoviePilot` 后端的边界
|
||||
|
||||
本仓库只保存插件实现,不应复制宿主的公共能力。插件应优先复用后端仓库已经提供的抽象,例如:
|
||||
|
||||
- `_PluginBase`
|
||||
- `eventmanager`
|
||||
- `DownloaderHelper` / `MediaServerHelper` / `NotificationHelper`
|
||||
- `save_data()` / `get_data()` / `get_data_path()`
|
||||
- 插件 API 动态注册
|
||||
- 插件仪表板、服务、工作流动作、智能体工具扩展点
|
||||
|
||||
如果插件需要新增宿主接口,例如:
|
||||
|
||||
- 新的链式事件
|
||||
- 新的插件 API 渲染能力
|
||||
- 新的工作流动作契约
|
||||
- 新的智能体工具注入点
|
||||
|
||||
应先在 `MoviePilot` 中补齐宿主能力,再回到本仓库落插件实现。
|
||||
|
||||
### 5.2 与 `MoviePilot-Frontend` 的边界
|
||||
|
||||
插件有两种主要 UI 方式:
|
||||
|
||||
- Vuetify JSON 配置
|
||||
- Vue 联邦远程组件
|
||||
|
||||
前者的宿主渲染在 `MoviePilot-Frontend` 已经实现,插件只需要返回 JSON 结构;后者需要遵守前端仓库的联邦组件暴露规范、共享依赖规范和侧栏入口规范。
|
||||
|
||||
如果你在本仓库写了 Vue 模式插件,需要同时关注:
|
||||
|
||||
- `MoviePilot-Frontend/docs/module-federation-guide.md`
|
||||
- `MoviePilot-Frontend/src/utils/federationLoader.ts`
|
||||
- `MoviePilot-Frontend` 中与插件页面、侧栏导航、仪表板相关的组件
|
||||
|
||||
## 6. 开发一个插件时的推荐流程
|
||||
|
||||
### 6.1 先判断插件形态
|
||||
|
||||
- 只是扩展后端能力、配置项简单:优先写 Vuetify JSON 模式插件
|
||||
- 需要复杂交互或完整页面:使用 Vue 联邦模式
|
||||
- 只是给现有插件补 V2 兼容:优先评估能否复用 `package.json + "v2": true`
|
||||
- 已经与 V1 / 默认版本差异很大:直接转为 `plugins.v2/ + package.v2.json`
|
||||
|
||||
### 6.2 再落目录与元数据
|
||||
|
||||
最小步骤通常是:
|
||||
|
||||
1. 在 `plugins/` 或 `plugins.v2/` 下新建目录
|
||||
2. 在 `__init__.py` 中实现插件类
|
||||
3. 如有依赖,增加 `requirements.txt`
|
||||
4. 在 `package.json` 或 `package.v2.json` 中补齐元数据
|
||||
5. 如有插件文档,在插件目录补充 `README.md`
|
||||
6. 如有 Vue UI,构建后把产物放进 `dist/assets/`
|
||||
|
||||
### 6.3 维护版本一致性
|
||||
|
||||
发布前至少核对以下三处是否一致:
|
||||
|
||||
- 索引里的 `version`
|
||||
- 插件类里的 `plugin_version`
|
||||
- `history` 中最新一条变更说明
|
||||
|
||||
## 7. 校验建议
|
||||
|
||||
这个仓库没有独立的完整测试宿主,因此校验应该尽量贴近真实运行层。
|
||||
|
||||
### 7.1 Python 插件代码
|
||||
|
||||
建议在宿主环境里做最小校验:
|
||||
|
||||
```bash
|
||||
# 对修改过的插件文件做语法检查
|
||||
python3 -m py_compile plugins.v2/myplugin/__init__.py
|
||||
|
||||
# 或者对整个插件目录做批量编译检查
|
||||
python3 -m compileall plugins.v2/myplugin
|
||||
|
||||
# 顺手检查 diff 中是否有空白符问题
|
||||
git diff --check
|
||||
```
|
||||
|
||||
### 7.2 Vue 远程组件
|
||||
|
||||
如果插件使用独立的前端工程,建议至少执行:
|
||||
|
||||
```bash
|
||||
# 类型检查
|
||||
yarn typecheck
|
||||
|
||||
# 构建联邦产物
|
||||
yarn build
|
||||
```
|
||||
|
||||
然后再把构建产物拷贝到插件目录中的 `dist/assets/`。
|
||||
|
||||
### 7.3 宿主联调
|
||||
|
||||
以下场景必须回到宿主仓库验证:
|
||||
|
||||
- `get_api()` 是否真正注册成功
|
||||
- `get_service()` 是否出现在服务列表
|
||||
- `get_dashboard()` / `get_dashboard_meta()` 是否正常显示
|
||||
- `get_render_mode() == "vue"` 的远程组件是否能成功加载
|
||||
- `get_sidebar_nav()` 是否正确出现在前端侧栏
|
||||
|
||||
## 8. 发布流程
|
||||
|
||||
本仓库的自动发布逻辑位于 `.github/workflows/release.yml`,当前规则如下:
|
||||
|
||||
- 只有当 `package.json` 或 `package.v2.json` 发生变更时,工作流才会触发
|
||||
- 只有索引条目中声明了 `"release": true` 的插件会参与自动打包
|
||||
- 工作流会尝试在 `plugins/<plugin_id_lower>` 和 `plugins.v2/<plugin_id_lower>` 中寻找插件目录
|
||||
- Release Tag 格式为 `插件ID_v插件版本号`
|
||||
- 压缩包文件名格式为 `插件目录小写_v插件版本号.zip`
|
||||
- 若插件目录自上一个 Tag 以来没有变化,则会跳过打包
|
||||
- 若同名 Release / Tag 已存在,工作流会先删除旧对象再重新创建
|
||||
|
||||
这意味着发布一个可下载压缩包的插件时,最少要确认:
|
||||
|
||||
1. 插件目录存在且名称正确
|
||||
2. 索引条目中已声明 `"release": true`
|
||||
3. 索引版本号与代码版本号一致
|
||||
4. 目标目录自上一个同插件 Tag 以来确实有代码变化
|
||||
|
||||
## 9. 文档维护建议
|
||||
|
||||
如果一次改动同时涉及:
|
||||
|
||||
- 插件能力扩展点变更
|
||||
- 宿主后端新增接口或新契约
|
||||
- 前端新增加载规则或侧栏行为
|
||||
|
||||
应同步更新对应仓库文档,不要只改本仓库 README。
|
||||
|
||||
推荐文档分工:
|
||||
|
||||
- 本仓库 `README.md`:总览与主入口
|
||||
- 本仓库 `docs/FAQ.md`:FAQ 索引与场景入口
|
||||
- 本仓库 `docs/Repository_Guide.md`:仓库维护与发布规则
|
||||
- 本仓库 `docs/V2_Plugin_Development.md`:V2 插件开发主文档
|
||||
- 前端仓库 `docs/module-federation-guide.md`:Vue 联邦远程组件开发规范
|
||||
|
||||
## 10. 开始之前先读哪一份
|
||||
|
||||
- 想知道“这个仓库该怎么维护、改哪个文件、怎么发布”:看本文档
|
||||
- 想直接开发一个 V2 插件:看 `docs/V2_Plugin_Development.md`
|
||||
- 想做 Vue 远程组件或侧栏全页:看前端仓库模块联邦文档
|
||||
- 想按功能场景抄现成模式:看 `docs/FAQ.md` 和 `docs/faq/` 下的独立 FAQ 文档
|
||||
102
docs/faq/01-extend-notification-channel.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# 如何扩展消息推送渠道?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
- 注册 `NoticeMessage` 事件响应,`event_data` 包含消息中的所有数据,参考 `IYUU消息通知` 插件:
|
||||
|
||||
注册事件:
|
||||
```python
|
||||
@eventmanager.register(EventType.NoticeMessage)
|
||||
```
|
||||
|
||||
- 事件对象:
|
||||
```json
|
||||
{
|
||||
"channel": MessageChannel|None,
|
||||
"type": NotificationType|None,
|
||||
"title": str,
|
||||
"text": str,
|
||||
"image": str,
|
||||
"userid": str|int,
|
||||
}
|
||||
```
|
||||
|
||||
- MoviePilot中所有事件清单(V2版本),可以通过实现这些事情来扩展功能,同时插件之前也可以通过发送和监听事件实现联动(V1、V2事件清单有差异,且可能会变化,最新请参考源代码)。
|
||||
```python
|
||||
# 异步广播事件
|
||||
class EventType(Enum):
|
||||
# 插件需要重载
|
||||
PluginReload = "plugin.reload"
|
||||
# 触发插件动作
|
||||
PluginAction = "plugin.action"
|
||||
# 插件触发事件
|
||||
PluginTriggered = "plugin.triggered"
|
||||
# 执行命令
|
||||
CommandExcute = "command.excute"
|
||||
# 站点已删除
|
||||
SiteDeleted = "site.deleted"
|
||||
# 站点已更新
|
||||
SiteUpdated = "site.updated"
|
||||
# 站点已刷新
|
||||
SiteRefreshed = "site.refreshed"
|
||||
# 转移完成
|
||||
TransferComplete = "transfer.complete"
|
||||
# 下载已添加
|
||||
DownloadAdded = "download.added"
|
||||
# 删除历史记录
|
||||
HistoryDeleted = "history.deleted"
|
||||
# 删除下载源文件
|
||||
DownloadFileDeleted = "downloadfile.deleted"
|
||||
# 删除下载任务
|
||||
DownloadDeleted = "download.deleted"
|
||||
# 收到用户外来消息
|
||||
UserMessage = "user.message"
|
||||
# 收到Webhook消息
|
||||
WebhookMessage = "webhook.message"
|
||||
# 发送消息通知
|
||||
NoticeMessage = "notice.message"
|
||||
# 订阅已添加
|
||||
SubscribeAdded = "subscribe.added"
|
||||
# 订阅已调整
|
||||
SubscribeModified = "subscribe.modified"
|
||||
# 订阅已删除
|
||||
SubscribeDeleted = "subscribe.deleted"
|
||||
# 订阅已完成
|
||||
SubscribeComplete = "subscribe.complete"
|
||||
# 系统错误
|
||||
SystemError = "system.error"
|
||||
# 刮削元数据
|
||||
MetadataScrape = "metadata.scrape"
|
||||
# 模块需要重载
|
||||
ModuleReload = "module.reload"
|
||||
|
||||
|
||||
# 同步链式事件
|
||||
class ChainEventType(Enum):
|
||||
# 名称识别
|
||||
NameRecognize = "name.recognize"
|
||||
# 认证验证
|
||||
AuthVerification = "auth.verification"
|
||||
# 认证拦截
|
||||
AuthIntercept = "auth.intercept"
|
||||
# 命令注册
|
||||
CommandRegister = "command.register"
|
||||
# 整理重命名
|
||||
TransferRename = "transfer.rename"
|
||||
# 整理拦截
|
||||
TransferIntercept = "transfer.intercept"
|
||||
# 资源选择
|
||||
ResourceSelection = "resource.selection"
|
||||
# 资源下载
|
||||
ResourceDownload = "resource.download"
|
||||
# 发现数据源
|
||||
DiscoverSource = "discover.source"
|
||||
# 媒体识别转换
|
||||
MediaRecognizeConvert = "media.recognize.convert"
|
||||
# 推荐数据源
|
||||
RecommendSource = "recommend.source"
|
||||
# 工作流执行
|
||||
WorkflowExecution = "workflow.execution"
|
||||
# 存储操作选择
|
||||
StorageOperSelection = "storage.operation"
|
||||
```
|
||||
30
docs/faq/02-remote-command-handler.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 如何在插件中实现远程命令响应?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
- 实现 `get_command()` 方法,按以下格式返回命令列表:
|
||||
```json
|
||||
[{
|
||||
"cmd": "/douban_sync", // 动作ID,必须以/开始
|
||||
"event": EventType.PluginAction,// 事件类型,固定值
|
||||
"desc": "命令名称",
|
||||
"category": "命令菜单(微信)",
|
||||
"data": {
|
||||
"action": "douban_sync" // 动作标识
|
||||
}
|
||||
}]
|
||||
```
|
||||
|
||||
- 注册 `PluginAction` 事件响应,根据 `event_data.action` 是否为插件设定的动作标识来判断是否为本插件事件:
|
||||
|
||||
注册事件:
|
||||
```python
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
```
|
||||
|
||||
事件判定:
|
||||
```python
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "douban_sync":
|
||||
return
|
||||
```
|
||||
17
docs/faq/03-expose-plugin-api.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 如何在插件中对外暴露API?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
- 实现 `get_api()` 方法,按以下格式返回API列表:
|
||||
```json
|
||||
[{
|
||||
"path": "/refresh_by_domain", // API路径,必须以/开始
|
||||
"endpoint": self.refresh_by_domain, // API响应方法
|
||||
"methods": ["GET"], // 请求方式:GET/POST/PUT/DELETE
|
||||
"summary": "刷新站点数据", // API名称
|
||||
"description": "刷新对应域名的站点数据", // API描述
|
||||
}]
|
||||
```
|
||||
注意:在插件中暴露API接口时注意安全控制,推荐使用`settings.API_TOKEN`进行身份验证。
|
||||
|
||||
- 在对应的方法中实现API响应方法逻辑,通过 `http://localhost:3001/docs` 查看API文档和调试
|
||||
15
docs/faq/04-register-service.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 如何在插件中注册公共定时服务?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
- 注册公共定时服务后,可以在`设定-服务`中查看运行状态和手动启动,更加便捷。
|
||||
- 实现 `get_service()` 方法,按以下格式返回服务注册信息:
|
||||
```json
|
||||
[{
|
||||
"id": "服务ID", // 不能与其它服务ID重复
|
||||
"name": "服务名称", // 显示在服务列表中的名称
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx, // 服务方法
|
||||
"kwargs": {} // 定时器参数,参考APScheduler
|
||||
}]
|
||||
```
|
||||
33
docs/faq/05-enhance-recognition.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 如何通过插件增强MoviePilot的识别功能?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
- V1按如下步骤实现,V2版本直接实现对应链式事件即可,参考ChatGPT插件。注意:只有主程序无法识别时才会触发。
|
||||
- 注册 `NameRecognize` 事件,实现识别逻辑。
|
||||
```python
|
||||
@eventmanager.register(EventType.NameRecognize)
|
||||
```
|
||||
|
||||
- 完成识别后发送 `NameRecognizeResult` 事件,将识别结果注入主程序
|
||||
```python
|
||||
eventmanager.send_event(
|
||||
EventType.NameRecognizeResult,
|
||||
{
|
||||
'title': title, # 原传入标题
|
||||
'name': str, # 识别的名称
|
||||
'year': str, # 识别的年份
|
||||
'season': int, # 识别的季号
|
||||
'episode': int, # 识别的集号
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
- 注意:识别请求需要在15秒内响应,否则结果会被丢弃;**插件未启用或参数不完整时应立即回复空结果事件,避免主程序等待;** 多个插件开启识别功能时,以先收到的识别结果事件为准。
|
||||
```python
|
||||
eventmanager.send_event(
|
||||
EventType.NameRecognizeResult,
|
||||
{
|
||||
'title': title # 结果只含原标题,代表空识别结果事件
|
||||
}
|
||||
)
|
||||
```
|
||||
259
docs/faq/06-extend-indexer-sites.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# 如何扩展内建索引器的索引站点?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
- 通过调用 `SitesHelper().add_indexer(domain: str, indexer: dict)` 方法,新增或修改内建索引器的支持范围,其中`indexer`为站点配置Json,格式示例如下:
|
||||
|
||||
示例一:
|
||||
```json
|
||||
{
|
||||
"id": "nyaa",
|
||||
"name": "Nyaa",
|
||||
"domain": "https://nyaa.si/",
|
||||
"encoding": "UTF-8",
|
||||
"public": true,
|
||||
"proxy": true,
|
||||
"result_num": 100,
|
||||
"timeout": 30,
|
||||
"search": {
|
||||
"paths": [
|
||||
{
|
||||
"path": "?f=0&c=0_0&q={keyword}",
|
||||
"method": "get"
|
||||
}
|
||||
]
|
||||
},
|
||||
"browse": {
|
||||
"path": "?p={page}",
|
||||
"start": 1
|
||||
},
|
||||
"torrents": {
|
||||
"list": {
|
||||
"selector": "table.torrent-list > tbody > tr"
|
||||
},
|
||||
"fields": {
|
||||
"id": {
|
||||
"selector": "a[href*=\"/view/\"]",
|
||||
"attribute": "href",
|
||||
"filters": [
|
||||
{
|
||||
"name": "re_search",
|
||||
"args": [
|
||||
"\\d+",
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"selector": "td:nth-child(2) > a"
|
||||
},
|
||||
"details": {
|
||||
"selector": "td:nth-child(2) > a",
|
||||
"attribute": "href"
|
||||
},
|
||||
"download": {
|
||||
"selector": "td:nth-child(3) > a[href*=\"/download/\"]",
|
||||
"attribute": "href"
|
||||
},
|
||||
"date_added": {
|
||||
"selector": "td:nth-child(5)"
|
||||
},
|
||||
"size": {
|
||||
"selector": "td:nth-child(4)"
|
||||
},
|
||||
"seeders": {
|
||||
"selector": "td:nth-child(6)"
|
||||
},
|
||||
"leechers": {
|
||||
"selector": "td:nth-child(7)"
|
||||
},
|
||||
"grabs": {
|
||||
"selector": "td:nth-child(8)"
|
||||
},
|
||||
"downloadvolumefactor": {
|
||||
"case": {
|
||||
"*": 0
|
||||
}
|
||||
},
|
||||
"uploadvolumefactor": {
|
||||
"case": {
|
||||
"*": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
示例二:
|
||||
```json
|
||||
{
|
||||
"id": "xxx",
|
||||
"name": "站点名称",
|
||||
"domain": "https://www.xxx.com/",
|
||||
"ext_domains": [
|
||||
"https://www.xxx1.com/",
|
||||
"https://www.xxx2.com/"
|
||||
],
|
||||
"encoding": "UTF-8",
|
||||
"public": false,
|
||||
"search": {
|
||||
"paths": [
|
||||
{
|
||||
"path": "torrents.php",
|
||||
"method": "get"
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"search": "{keyword}",
|
||||
"search_area": 4
|
||||
},
|
||||
"batch": {
|
||||
"delimiter": " ",
|
||||
"space_replace": "_"
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"movie": [
|
||||
{
|
||||
"id": 401,
|
||||
"cat": "Movies",
|
||||
"desc": "Movies电影"
|
||||
},
|
||||
{
|
||||
"id": 405,
|
||||
"cat": "Anime",
|
||||
"desc": "Animations动漫"
|
||||
},
|
||||
{
|
||||
"id": 404,
|
||||
"cat": "Documentary",
|
||||
"desc": "Documentaries纪录片"
|
||||
}
|
||||
],
|
||||
"tv": [
|
||||
{
|
||||
"id": 402,
|
||||
"cat": "TV",
|
||||
"desc": "TV Series电视剧"
|
||||
},
|
||||
{
|
||||
"id": 403,
|
||||
"cat": "TV",
|
||||
"desc": "TV Shows综艺"
|
||||
},
|
||||
{
|
||||
"id": 404,
|
||||
"cat": "Documentary",
|
||||
"desc": "Documentaries纪录片"
|
||||
},
|
||||
{
|
||||
"id": 405,
|
||||
"cat": "Anime",
|
||||
"desc": "Animations动漫"
|
||||
}
|
||||
]
|
||||
},
|
||||
"torrents": {
|
||||
"list": {
|
||||
"selector": "table.torrents > tr:has(\"table.torrentname\")"
|
||||
},
|
||||
"fields": {
|
||||
"id": {
|
||||
"selector": "a[href*=\"details.php?id=\"]",
|
||||
"attribute": "href",
|
||||
"filters": [
|
||||
{
|
||||
"name": "re_search",
|
||||
"args": [
|
||||
"\\d+",
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"title_default": {
|
||||
"selector": "a[href*=\"details.php?id=\"]"
|
||||
},
|
||||
"title_optional": {
|
||||
"optional": true,
|
||||
"selector": "a[title][href*=\"details.php?id=\"]",
|
||||
"attribute": "title"
|
||||
},
|
||||
"title": {
|
||||
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}"
|
||||
},
|
||||
"details": {
|
||||
"selector": "a[href*=\"details.php?id=\"]",
|
||||
"attribute": "href"
|
||||
},
|
||||
"download": {
|
||||
"selector": "a[href*=\"download.php?id=\"]",
|
||||
"attribute": "href"
|
||||
},
|
||||
"imdbid": {
|
||||
"selector": "div.imdb_100 > a",
|
||||
"attribute": "href",
|
||||
"filters": [
|
||||
{
|
||||
"name": "re_search",
|
||||
"args": [
|
||||
"tt\\d+",
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"date_elapsed": {
|
||||
"selector": "td:nth-child(4) > span",
|
||||
"optional": true
|
||||
},
|
||||
"date_added": {
|
||||
"selector": "td:nth-child(4) > span",
|
||||
"attribute": "title",
|
||||
"optional": true
|
||||
},
|
||||
"size": {
|
||||
"selector": "td:nth-child(5)"
|
||||
},
|
||||
"seeders": {
|
||||
"selector": "td:nth-child(6)"
|
||||
},
|
||||
"leechers": {
|
||||
"selector": "td:nth-child(7)"
|
||||
},
|
||||
"grabs": {
|
||||
"selector": "td:nth-child(8)"
|
||||
},
|
||||
"downloadvolumefactor": {
|
||||
"case": {
|
||||
"img.pro_free": 0,
|
||||
"img.pro_free2up": 0,
|
||||
"img.pro_50pctdown": 0.5,
|
||||
"img.pro_50pctdown2up": 0.5,
|
||||
"img.pro_30pctdown": 0.3,
|
||||
"*": 1
|
||||
}
|
||||
},
|
||||
"uploadvolumefactor": {
|
||||
"case": {
|
||||
"img.pro_50pctdown2up": 2,
|
||||
"img.pro_free2up": 2,
|
||||
"img.pro_2up": 2,
|
||||
"*": 1
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"selector": "td:nth-child(2) > table > tr > td.embedded > span[style]",
|
||||
"contents": -1
|
||||
},
|
||||
"labels": {
|
||||
"selector": "td:nth-child(2) > table > tr > td.embedded > span.tags"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- 需要注意的是,如果你没有完成用户认证,通过插件配置进去的索引站点也是无法正常使用的。
|
||||
- **请不要添加对黄赌毒站点的支持,否则随时封闭接口。**
|
||||
23
docs/faq/07-call-api-from-plugin.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 如何在插件中调用API接口?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v1.8.4+` 版本)**
|
||||
- 在插件的数据页面支持`GET/POST`API接口调用,可调用插件自身、主程序或其它插件的API。
|
||||
- 在`get_page`中定义好元素的事件,以及相应的API参数,具体可参考插件`豆瓣想看`:
|
||||
```json
|
||||
{
|
||||
"component": "VDialogCloseBtn", // 触发事件的元素
|
||||
"events": {
|
||||
"click": { // 点击事件
|
||||
"api": "plugin/DoubanSync/delete_history", // API的相对路径
|
||||
"method": "get", // GET/POST
|
||||
"params": {
|
||||
// API上送参数
|
||||
"doubanid": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- 每次API调用完成后,均会自动刷新一次插件数据页。
|
||||
47
docs/faq/08-render-dashboard.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 如何将插件内容显示到仪表板?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v1.8.7+` 版本)**
|
||||
- 将插件的内容显示到仪表盘,并支持定义占据的单元格大小,插件产生的仪表板仅管理员可见。
|
||||
- 1. 根据插件需要展示的Widget内容规划展示内容的样式和规格,也可设计多个规格样式并提供配置项供用户选择。
|
||||
- 2. 实现 `get_dashboard_meta` 方法,定义仪表板key及名称,支持一个插件有多个仪表板:
|
||||
```python
|
||||
def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]:
|
||||
"""
|
||||
获取插件仪表盘元信息
|
||||
返回示例:
|
||||
[{
|
||||
"key": "dashboard1", // 仪表盘的key,在当前插件范围唯一
|
||||
"name": "仪表盘1" // 仪表盘的名称
|
||||
}, {
|
||||
"key": "dashboard2",
|
||||
"name": "仪表盘2"
|
||||
}]
|
||||
"""
|
||||
pass
|
||||
```
|
||||
- 3. 实现 `get_dashboard` 方法,根据key返回仪表盘的详细配置信息,包括仪表盘的cols列配置(适配不同屏幕),以及仪表盘的页面配置json,具体可参考插件`站点数据统计`:
|
||||
```python
|
||||
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
|
||||
"""
|
||||
获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(自动刷新等);3、仪表板页面元素配置json(含数据)
|
||||
1、col配置参考:
|
||||
{
|
||||
"cols": 12, "md": 6
|
||||
}
|
||||
2、全局配置参考:
|
||||
{
|
||||
"refresh": 10, // 自动刷新时间,单位秒
|
||||
"border": True, // 是否显示边框,默认True,为False时取消组件边框和边距,由插件自行控制
|
||||
"title": "组件标题", // 组件标题,如有将显示该标题,否则显示插件名称
|
||||
"subtitle": "组件子标题", // 组件子标题,缺省时不展示子标题
|
||||
}
|
||||
3、页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/
|
||||
|
||||
kwargs参数可获取的值:1、user_agent:浏览器UA
|
||||
|
||||
:param key: 仪表盘key,根据指定的key返回相应的仪表盘数据,缺省时返回一个固定的仪表盘数据(兼容旧版)
|
||||
"""
|
||||
pass
|
||||
```
|
||||
61
docs/faq/09-extend-discovery-source.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# 如何扩展探索功能的媒体数据源?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v2.2.7+` 版本)**
|
||||
- 探索功能仅内置`TheMovieDb`、`豆瓣`和`Bangumi`数据源,可通过插件扩展探索功能的数据源范围,按以下方法开发插件(参考`TheTVDB探索`插件):
|
||||
- 1. 实现`ChainEventType.DiscoverSource`链式事件响应,将额外的媒体数据源塞入事件数据`extra_sources`数组中(注意:如果事件中已经有其它数据源,需要叠加而不是替换,避免影响其它插件塞入的数据)
|
||||
|
||||
- `name`:数据源名称
|
||||
- `mediaid_prefix`:数据源的唯一ID
|
||||
- `api_path`:数据获取API相对路径,需要在插件中实现API接口功能,GET模式接收过滤参数(注意:page参数默认需要有),返回`List[schemas.MediaInfo])`格式数据(注意:mediaid_prefix和media_id需要赋值,用于唯一索引媒体详细信息和转换媒体数据)。
|
||||
- `filter_params`:数据源过滤参数名的字典,相关参数会传入插件API的GET请求中
|
||||
- `filter_ui`:数据过滤选项的UI配置json,与插件配置表单方式一致
|
||||
- `depends`: UI依赖关系字典Dict[str, list],关过滤条件存在依赖关系时需要设置,以便上级条件变化时清空下级条件值
|
||||
|
||||
```python
|
||||
class DiscoverMediaSource(BaseModel):
|
||||
"""
|
||||
探索媒体数据源的基类
|
||||
"""
|
||||
name: str = Field(..., description="数据源名称")
|
||||
mediaid_prefix: str = Field(..., description="媒体ID的前缀,不含:")
|
||||
api_path: str = Field(..., description="媒体数据源API地址")
|
||||
filter_params: Optional[Dict[str, Any]] = Field(default=None, description="过滤参数")
|
||||
filter_ui: Optional[List[dict]] = Field(default=[], description="过滤参数UI配置")
|
||||
|
||||
class DiscoverSourceEventData(ChainEventData):
|
||||
"""
|
||||
DiscoverSource 事件的数据模型
|
||||
Attributes:
|
||||
# 输出参数
|
||||
extra_sources (List[DiscoverMediaSource]): 额外媒体数据源
|
||||
"""
|
||||
# 输出参数
|
||||
extra_sources: List[DiscoverMediaSource] = Field(default_factory=list, description="额外媒体数据源")
|
||||
```
|
||||
|
||||
- 2. 实现`ChainEventType.MediaRecognizeConvert`链式事件响应(**可选**,如不实现则默认按标题重新识别媒体信息),根据媒体ID和转换类型,返回TheMovieDb或豆瓣的媒体数据,将转换后的数据注入事件数据`media_dict`中,可参考`app/chain/media.py`中的`get_tmdbinfo_by_bangumiid`。
|
||||
|
||||
- `mediaid`:媒体ID,格式为`mediaid_prefix:media_id`,如 tmdb:12345、douban:1234567
|
||||
- `convert_type`:转换类型,仅支持:themoviedb/douban,需要转换为对应的媒体数据并返回
|
||||
- `media_dict`:转换后的媒体数据,格式为`TheMovieDb/豆瓣`的媒体数据
|
||||
|
||||
```python
|
||||
class MediaRecognizeConvertEventData(ChainEventData):
|
||||
"""
|
||||
MediaRecognizeConvert 事件的数据模型
|
||||
Attributes:
|
||||
# 输入参数
|
||||
mediaid (str): 媒体ID,格式为`前缀:ID值`,如 tmdb:12345、douban:1234567
|
||||
convert_type (str): 转换类型 仅支持:themoviedb/douban,需要转换为对应的媒体数据并返回
|
||||
# 输出参数
|
||||
media_dict (dict): TheMovieDb/豆瓣的媒体数据
|
||||
"""
|
||||
# 输入参数
|
||||
mediaid: str = Field(..., description="媒体ID")
|
||||
convert_type: str = Field(..., description="转换类型(themoviedb/douban)")
|
||||
# 输出参数
|
||||
media_dict: dict = Field(default=dict, description="转换后的媒体信息(TheMovieDb/豆瓣)")
|
||||
```
|
||||
- 3. 启用插件后,点击探索功能将自动生成额外的数据源标签及页面,页面中选择不同的过滤条件时会重新触发API请求。
|
||||
28
docs/faq/10-extend-recommend-source.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 如何扩展推荐功能的媒体数据源?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v2.2.8+` 版本)**
|
||||
- 实现`ChainEventType.RecommendSource`链式事件响应,将额外的媒体数据源塞入事件数据`extra_sources`数组中(注意:如果事件中已经有其它数据源,需要叠加而不是替换,避免影响其它插件塞入的数据)
|
||||
|
||||
- `name`:数据源名称
|
||||
- `api_path`:数据获取API相对路径,需要在插件中实现API接口功能,GET模式接收过滤参数(注意:page参数默认需要有),返回`List[schemas.MediaInfo])`格式数据,参考`app/api/endpoints/recommend.py` 中的 `tmdb_trending`。
|
||||
|
||||
```python
|
||||
class RecommendMediaSource(BaseModel):
|
||||
"""
|
||||
推荐媒体数据源的基类
|
||||
"""
|
||||
name: str = Field(..., description="数据源名称")
|
||||
api_path: str = Field(..., description="媒体数据源API地址")
|
||||
|
||||
class RecommendSourceEventData(ChainEventData):
|
||||
"""
|
||||
RecommendSource 事件的数据模型
|
||||
Attributes:
|
||||
# 输出参数
|
||||
extra_sources (List[RecommendMediaSource]): 额外媒体数据源
|
||||
"""
|
||||
# 输出参数
|
||||
extra_sources: List[RecommendMediaSource] = Field(default_factory=list, description="额外媒体数据源")
|
||||
```
|
||||
19
docs/faq/11-override-system-module.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 如何通过插件重载实现系统模块功能?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v2.4.4+` 版本)**
|
||||
- MoviePilot中通过`chain`层实现业务逻辑,在`modules`中实现各自独立的功能模块。`chain`处理链通过查找`modules`中实现了所需方法(比如: post_message)的所有模块并按一定的规则执行,从而编排各模块能力来实现复杂的业务功能。v2.4.4+版本中赋于插件胁持系统模块的能力,可以通过插件来重新实现系统所有内置模块的功能,比如支持新的下载器、媒体服务器等(在用户界面中配合新增自定义下载器和媒体服务器)。
|
||||
- 1. 在插件中实现`get_module`方法,申明插件要重载的模块方法。所有可用的模块方法名参考`chain`目录下的处理链文件(run_module方法的第一个参数),公共处理在`chain/__init__.py`中,方法入参和出参需要保持一致。
|
||||
```python
|
||||
def get_module(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取插件模块声明,用于胁持系统模块实现(方法名:方法实现)
|
||||
{
|
||||
"id1": self.xxx1,
|
||||
"id2": self.xxx2,
|
||||
}
|
||||
"""
|
||||
pass
|
||||
```
|
||||
- 2. 在插件中实现声名的方法逻辑,处理链执行时,会优先处理插件声明的方法。如果插件方法未实现或者返回`None`,将继续执行下一个插件或者系统模块的相同声明方法;如果对应的方法需要返回是的列表对象,则会执行所有插件和系统模块的方法后将结果组合返回。
|
||||
319
docs/faq/12-extend-storage-type.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# 如何通过插件扩展支持的存储类型?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v2.4.4+` 版本)**
|
||||
- 1. 用户在系统设定存储中新增自定义存储,并设定一个自定义类型和名称,该类型与插件绑定,用于插件判断使用。或者在插件启动时直接注册自定义存储。
|
||||
```python
|
||||
# 检查是否有xxx网盘选项,如没有则自动添加
|
||||
storage_helper = StorageHelper()
|
||||
storages = StorageHelper().get_storagies()
|
||||
if not any(s.type == "xxx" for s in storages):
|
||||
# 添加存储配置
|
||||
storage_helper.add_storage("xxx", name="xxx网盘", conf={})
|
||||
```
|
||||
- 2. 在插件的存储操作类中,实现以下对应的文件操作(具体可参考:`app/modules/filemanager/storages/__init__.py`),不支持的可跳过
|
||||
```python
|
||||
class XxxApi:
|
||||
|
||||
def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:
|
||||
"""
|
||||
浏览文件
|
||||
"""
|
||||
pass
|
||||
|
||||
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
创建目录
|
||||
:param fileitem: 父目录
|
||||
:param name: 目录名
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
获取目录,如目录不存在则创建
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
获取文件或目录,不存在返回None
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_parent(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
获取父目录
|
||||
"""
|
||||
return self.get_item(Path(fileitem.path).parent)
|
||||
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
"""
|
||||
删除文件
|
||||
"""
|
||||
pass
|
||||
|
||||
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
|
||||
"""
|
||||
重命名文件
|
||||
"""
|
||||
pass
|
||||
|
||||
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Path:
|
||||
"""
|
||||
下载文件,保存到本地,返回本地临时文件地址
|
||||
:param fileitem: 文件项
|
||||
:param path: 文件保存路径
|
||||
"""
|
||||
pass
|
||||
|
||||
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
上传文件
|
||||
:param fileitem: 上传目录项
|
||||
:param path: 本地文件路径
|
||||
:param new_name: 上传后文件名
|
||||
"""
|
||||
pass
|
||||
|
||||
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
获取文件详情
|
||||
"""
|
||||
pass
|
||||
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
复制文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
pass
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
pass
|
||||
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
"""
|
||||
硬链接文件
|
||||
"""
|
||||
pass
|
||||
|
||||
def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
"""
|
||||
软链接文件
|
||||
"""
|
||||
pass
|
||||
|
||||
def usage(self) -> Optional[schemas.StorageUsage]:
|
||||
"""
|
||||
存储使用情况
|
||||
"""
|
||||
pass
|
||||
```
|
||||
- 3. 实现 `ChainEventType.StorageOperSelection`链式事件响应,根据传入的存储对象名称判断是否为该插件支持的存储,如是则返回存储操作对象
|
||||
```python
|
||||
@eventmanager.register(ChainEventType.StorageOperSelection)
|
||||
def storage_oper_selection(self, event: Event):
|
||||
"""
|
||||
监听存储选择事件,返回当前类为操作对象
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
event_data: StorageOperSelectionEventData = event.event_data
|
||||
if event_data.storage == "xxx":
|
||||
event_data.storage_oper = self.api # api为插件的存储操作对象
|
||||
```
|
||||
|
||||
- 4. 参考 [《如何通过插件重载实现系统模块功能?》](./11-override-system-module.md) 实现 `get_module`,在插件中声明和实现以下模块方法(具体可参考:`app/modules/filemanager/__init__.py`),其实就是对上一步的方法再做一下封装:
|
||||
```python
|
||||
def get_module(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取插件模块声明,用于胁持系统模块实现(方法名:方法实现)
|
||||
{
|
||||
"id1": self.xxx1,
|
||||
"id2": self.xxx2,
|
||||
}
|
||||
"""
|
||||
return {
|
||||
"list_files": self.list_files,
|
||||
"any_files": self.any_files,
|
||||
"download_file": self.download_file,
|
||||
"upload_file": self.upload_file,
|
||||
"delete_file": self.delete_file,
|
||||
"rename_file": self.rename_file,
|
||||
"get_file_item": self.get_file_item,
|
||||
"get_parent_item": self.get_parent_item,
|
||||
"snapshot_storage": self.snapshot_storage,
|
||||
"storage_usage": self.storage_usage,
|
||||
"support_transtype": self.support_transtype
|
||||
}
|
||||
|
||||
def list_files(self, fileitem: schemas.FileItem, recursion: bool = False) -> Optional[List[schemas.FileItem]]:
|
||||
"""
|
||||
查询当前目录下所有目录和文件
|
||||
"""
|
||||
|
||||
if fileitem.storage != "xxx":
|
||||
return None
|
||||
|
||||
def __get_files(_item: FileItem, _r: Optional[bool] = False):
|
||||
"""
|
||||
递归处理
|
||||
"""
|
||||
_items = self.api.list(_item)
|
||||
if _items:
|
||||
if _r:
|
||||
for t in _items:
|
||||
if t.type == "dir":
|
||||
__get_files(t, _r)
|
||||
else:
|
||||
result.append(t)
|
||||
else:
|
||||
result.extend(_items)
|
||||
|
||||
# 返回结果
|
||||
result = []
|
||||
__get_files(fileitem, recursion)
|
||||
|
||||
return result
|
||||
|
||||
def any_files(self, fileitem: schemas.FileItem, extensions: list = None) -> Optional[bool]:
|
||||
"""
|
||||
查询当前目录下是否存在指定扩展名任意文件
|
||||
"""
|
||||
if fileitem.storage != "xxx":
|
||||
return None
|
||||
|
||||
def __any_file(_item: FileItem):
|
||||
"""
|
||||
递归处理
|
||||
"""
|
||||
_items = self.api.list(_item)
|
||||
if _items:
|
||||
if not extensions:
|
||||
return True
|
||||
for t in _items:
|
||||
if (t.type == "file"
|
||||
and t.extension
|
||||
and f".{t.extension.lower()}" in extensions):
|
||||
return True
|
||||
elif t.type == "dir":
|
||||
if __any_file(t):
|
||||
return True
|
||||
return False
|
||||
|
||||
# 返回结果
|
||||
return __any_file(fileitem)
|
||||
|
||||
def download_file(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
|
||||
"""
|
||||
下载文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 本地保存路径
|
||||
"""
|
||||
if fileitem.storage != "xxx":
|
||||
return None
|
||||
|
||||
return self.api.download(fileitem, path)
|
||||
|
||||
def upload_file(self, fileitem: schemas.FileItem, path: Path,
|
||||
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
上传文件
|
||||
:param fileitem: 保存目录项
|
||||
:param path: 本地文件路径
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
if fileitem.storage != "xxx":
|
||||
return None
|
||||
|
||||
return self.api.upload(fileitem, path, new_name)
|
||||
|
||||
def delete_file(self, fileitem: schemas.FileItem) -> Optional[bool]:
|
||||
"""
|
||||
删除文件或目录
|
||||
"""
|
||||
if fileitem.storage != "xxx":
|
||||
return None
|
||||
|
||||
return self.api.delete(fileitem)
|
||||
|
||||
def rename_file(self, fileitem: schemas.FileItem, name: str) -> Optional[bool]:
|
||||
"""
|
||||
重命名文件或目录
|
||||
"""
|
||||
if fileitem.storage != "xxx":
|
||||
return None
|
||||
|
||||
return self.api.rename(fileitem, name)
|
||||
|
||||
def get_file_item(self, storage: str, path: Path) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
根据路径获取文件项
|
||||
"""
|
||||
if storage != "xxx":
|
||||
return None
|
||||
|
||||
return self.api.get_item(path)
|
||||
|
||||
def get_parent_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
获取上级目录项
|
||||
"""
|
||||
if fileitem.storage != "xxx":
|
||||
return None
|
||||
|
||||
return self.api.get_parent(fileitem)
|
||||
|
||||
def snapshot_storage(self, storage: str, path: Path) -> Optional[Dict[str, float]]:
|
||||
"""
|
||||
快照存储
|
||||
"""
|
||||
if storage != "xxx":
|
||||
return None
|
||||
|
||||
files_info = {}
|
||||
|
||||
def __snapshot_file(_fileitm: schemas.FileItem):
|
||||
"""
|
||||
递归获取文件信息
|
||||
"""
|
||||
if _fileitm.type == "dir":
|
||||
for sub_file in self.api.list(_fileitm):
|
||||
__snapshot_file(sub_file)
|
||||
else:
|
||||
files_info[_fileitm.path] = _fileitm.size
|
||||
|
||||
fileitem = self.api.get_item(path)
|
||||
if not fileitem:
|
||||
return {}
|
||||
|
||||
__snapshot_file(fileitem)
|
||||
|
||||
return files_info
|
||||
|
||||
def storage_usage(self, storage: str) -> Optional[schemas.StorageUsage]:
|
||||
"""
|
||||
存储使用情况
|
||||
"""
|
||||
return self.api.usage()
|
||||
|
||||
@staticmethod
|
||||
def support_transtype(storage: str) -> Optional[dict]:
|
||||
"""
|
||||
获取支持的整理方式
|
||||
"""
|
||||
return {
|
||||
"move": "移动",
|
||||
"copy": "复制"
|
||||
}
|
||||
```
|
||||
24
docs/faq/13-integrate-workflow.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 如何将插件功能集成到工作流?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 v2.4.8+ 版本)**
|
||||
- 插件实现以下接口,声明插件支持的动作实现
|
||||
```python
|
||||
def get_actions(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件工作流动作
|
||||
[{
|
||||
"id": "动作ID",
|
||||
"name": "动作名称",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 需要附加传递的参数
|
||||
}]
|
||||
|
||||
对实现函数的要求:
|
||||
1、函数的第一个参数固定为 ActionContent 实例,如需要传递额外参数,在kwargs中定义
|
||||
2、函数的返回:执行状态 True / False,更新后的 ActionContent 实例
|
||||
"""
|
||||
pass
|
||||
```
|
||||
- 编辑工作流流程,添加`调用插件`组件,选择该插件的对应动作,将插件的功能串接到工作流程中
|
||||
162
docs/faq/14-message-interaction.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# 如何在插件中通过消息持续与用户交互?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 v2.5.7+ 版本)**
|
||||
- 插件可以通过实现命令响应和消息按钮回调实现与用户的持续交互对话,支持多轮对话和菜单式操作,适用于支持按钮回调的通知渠道(如Telegram、Slack等)。
|
||||
|
||||
- 1. 实现远程命令响应,参考 [《如何在插件中实现远程命令响应?》](./02-remote-command-handler.md) 实现 `get_command()` 方法和 `PluginAction` 事件响应:
|
||||
```python
|
||||
def get_command(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件远程命令
|
||||
"""
|
||||
return [{
|
||||
"cmd": "/interactive_demo",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "交互演示",
|
||||
"category": "插件交互",
|
||||
"data": {
|
||||
"action": "interactive_demo"
|
||||
}
|
||||
}]
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def command_action(self, event: Event):
|
||||
"""
|
||||
远程命令响应
|
||||
"""
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "interactive_demo":
|
||||
return
|
||||
|
||||
# 获取用户信息
|
||||
channel = event_data.get("channel")
|
||||
source = event_data.get("source")
|
||||
user = event_data.get("user")
|
||||
|
||||
# 发送带有交互按钮的消息
|
||||
self._send_main_menu(channel, source, user)
|
||||
```
|
||||
|
||||
- 2. 注册 `MessageAction` 事件响应,处理用户的按钮回调:
|
||||
```python
|
||||
@eventmanager.register(EventType.MessageAction)
|
||||
def message_action(self, event: Event):
|
||||
"""
|
||||
处理消息按钮回调
|
||||
"""
|
||||
event_data = event.event_data
|
||||
if not event_data:
|
||||
return
|
||||
|
||||
# 检查是否为本插件的回调
|
||||
plugin_id = event_data.get("plugin_id")
|
||||
if plugin_id != self.__class__.__name__:
|
||||
return
|
||||
|
||||
# 获取回调数据
|
||||
text = event_data.get("text", "")
|
||||
channel = event_data.get("channel")
|
||||
source = event_data.get("source")
|
||||
userid = event_data.get("userid")
|
||||
# 获取原始消息ID和聊天ID(用于直接更新原消息)
|
||||
original_message_id = event_data.get("original_message_id")
|
||||
original_chat_id = event_data.get("original_chat_id")
|
||||
|
||||
# 根据回调内容处理不同的交互
|
||||
if text == "menu1":
|
||||
self._handle_menu1(channel, source, userid, original_message_id, original_chat_id)
|
||||
elif text == "menu2":
|
||||
self._handle_menu2(channel, source, userid, original_message_id, original_chat_id)
|
||||
elif text == "back":
|
||||
self._send_main_menu(channel, source, userid, original_message_id, original_chat_id)
|
||||
elif text.startswith("action_"):
|
||||
action_id = text.replace("action_", "")
|
||||
self._handle_action(action_id, channel, source, userid, original_message_id, original_chat_id)
|
||||
```
|
||||
|
||||
- 3. 实现具体的交互处理方法,在消息中使用 `[PLUGIN]插件ID|内容` 格式的按钮:
|
||||
```python
|
||||
def _send_main_menu(self, channel, source, userid, original_message_id=None, original_chat_id=None):
|
||||
"""
|
||||
发送主菜单
|
||||
"""
|
||||
buttons = [
|
||||
[
|
||||
{"text": "🎬 媒体管理", "callback_data": f"[PLUGIN]{self.__class__.__name__}|menu1"},
|
||||
{"text": "⚙️ 系统设置", "callback_data": f"[PLUGIN]{self.__class__.__name__}|menu2"}
|
||||
],
|
||||
[
|
||||
{"text": "📊 查看状态", "callback_data": f"[PLUGIN]{self.__class__.__name__}|status"}
|
||||
]
|
||||
]
|
||||
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="🤖 插件交互演示",
|
||||
text="请选择要执行的操作:",
|
||||
userid=userid,
|
||||
buttons=buttons,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id
|
||||
)
|
||||
|
||||
def _handle_menu1(self, channel, source, userid, original_message_id, original_chat_id):
|
||||
"""
|
||||
处理媒体管理菜单
|
||||
"""
|
||||
buttons = [
|
||||
[
|
||||
{"text": "🔍 搜索媒体", "callback_data": f"[PLUGIN]{self.__class__.__name__}|action_search"},
|
||||
{"text": "📥 下载管理", "callback_data": f"[PLUGIN]{self.__class__.__name__}|action_download"}
|
||||
],
|
||||
[
|
||||
{"text": "🔙 返回主菜单", "callback_data": f"[PLUGIN]{self.__class__.__name__}|back"}
|
||||
]
|
||||
]
|
||||
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="🎬 媒体管理",
|
||||
text="选择媒体管理功能:",
|
||||
userid=userid,
|
||||
buttons=buttons,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id
|
||||
)
|
||||
|
||||
def _handle_action(self, action_id, channel, source, userid, original_message_id, original_chat_id):
|
||||
"""
|
||||
处理具体动作
|
||||
"""
|
||||
if action_id == "search":
|
||||
# 执行搜索逻辑
|
||||
result = "搜索功能已执行"
|
||||
elif action_id == "download":
|
||||
# 执行下载逻辑
|
||||
result = "下载管理已开启"
|
||||
else:
|
||||
result = "未知操作"
|
||||
|
||||
# 发送执行结果并提供返回按钮
|
||||
buttons = [
|
||||
[{"text": "🔙 返回主菜单", "callback_data": f"[PLUGIN]{self.__class__.__name__}|back"}]
|
||||
]
|
||||
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="✅ 操作完成",
|
||||
text=result,
|
||||
userid=userid,
|
||||
buttons=buttons,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id
|
||||
)
|
||||
```
|
||||
|
||||
- 注意事项:
|
||||
- 回调按钮的 `callback_data` 必须使用 `[PLUGIN]插件ID|内容` 格式,其中插件ID为插件类名
|
||||
- 只有支持按钮回调的通知渠道(如Telegram、Slack)才能使用此功能
|
||||
- 建议在交互中保存用户状态数据,以支持复杂的多步骤操作
|
||||
- 可以结合插件数据存储功能保存用户的交互历史和偏好设置
|
||||
186
docs/faq/15-use-system-cache.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# 如何在插件中使用系统级统一缓存?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v2.7.4+` 版本)**
|
||||
- MoviePilot提供了统一的缓存系统,支持内存缓存、文件系统缓存和Redis缓存自动管理,当有Redis时优先使用Redis,否则使用内存或文件系统。插件可以通过系统提供的缓存接口实现高效的缓存管理,无需关心系统设置。
|
||||
|
||||
- 1. 使用缓存装饰器:
|
||||
```python
|
||||
from app.core.cache import cached
|
||||
|
||||
class MyPlugin(_PluginBase):
|
||||
@cached(region="my_plugin", ttl=3600)
|
||||
def get_data(self, key: str):
|
||||
"""
|
||||
使用缓存装饰器,缓存结果1小时
|
||||
"""
|
||||
# 复杂的计算或网络请求
|
||||
return expensive_operation(key)
|
||||
|
||||
@cached(region="my_plugin_async", ttl=1800, skip_none=True)
|
||||
async def get_async_data(self, key: str):
|
||||
"""
|
||||
异步函数缓存,跳过None值
|
||||
"""
|
||||
return await async_expensive_operation(key)
|
||||
```
|
||||
|
||||
- 2. 使用TTLCache类:
|
||||
```python
|
||||
from app.core.cache import TTLCache
|
||||
|
||||
class MyPlugin(_PluginBase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 创建缓存实例,最大128项,TTL 30分钟
|
||||
self.cache = TTLCache(region="my_plugin", maxsize=128, ttl=1800)
|
||||
|
||||
def process_data(self, key: str):
|
||||
# 检查缓存
|
||||
if key in self.cache:
|
||||
return self.cache[key]
|
||||
|
||||
# 计算并缓存结果
|
||||
result = expensive_operation(key)
|
||||
self.cache[key] = result
|
||||
return result
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
清理插件缓存
|
||||
"""
|
||||
self.cache.clear()
|
||||
```
|
||||
|
||||
- 3. 使用文件缓存后端(适用于大文件缓存):
|
||||
```python
|
||||
from app.core.cache import FileCache, AsyncFileCache
|
||||
from pathlib import Path
|
||||
|
||||
class MyPlugin(_PluginBase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 获取文件缓存后端,支持Redis和文件系统
|
||||
self.file_cache = FileCache(
|
||||
base=Path("/tmp/my_plugin_cache"),
|
||||
ttl=86400 # 24小时
|
||||
)
|
||||
|
||||
def cache_large_file(self, key: str, data: bytes):
|
||||
"""
|
||||
缓存大文件数据
|
||||
"""
|
||||
self.file_cache.set(key, data, region="large_files")
|
||||
|
||||
def get_cached_file(self, key: str) -> Optional[bytes]:
|
||||
"""
|
||||
获取缓存的文件数据
|
||||
"""
|
||||
return self.file_cache.get(key, region="large_files")
|
||||
|
||||
async def async_cache_operations(self):
|
||||
"""
|
||||
异步文件缓存操作
|
||||
"""
|
||||
async_cache = AsyncFileCache(
|
||||
base=Path("/tmp/my_plugin_async_cache"),
|
||||
ttl=3600
|
||||
)
|
||||
|
||||
# 异步设置缓存
|
||||
await async_cache.set("async_key", b"async_data", region="async_files")
|
||||
|
||||
# 异步获取缓存
|
||||
data = await async_cache.get("async_key", region="async_files")
|
||||
|
||||
await async_cache.close()
|
||||
```
|
||||
|
||||
- 4. 直接使用缓存后端(高级用法):
|
||||
```python
|
||||
from app.core.cache import Cache
|
||||
|
||||
class MyPlugin(_PluginBase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 直接获取缓存后端实例,系统自动选择Redis或内存缓存
|
||||
self.cache_backend = Cache(maxsize=256, ttl=3600)
|
||||
|
||||
def custom_cache_operation(self, key: str, value: Any):
|
||||
"""
|
||||
自定义缓存操作
|
||||
"""
|
||||
# 设置缓存
|
||||
self.cache_backend.set(key, value, region="custom_region")
|
||||
|
||||
# 检查缓存是否存在
|
||||
if self.cache_backend.exists(key, region="custom_region"):
|
||||
# 获取缓存
|
||||
cached_value = self.cache_backend.get(key, region="custom_region")
|
||||
return cached_value
|
||||
|
||||
return None
|
||||
|
||||
def iterate_cache_items(self):
|
||||
"""
|
||||
遍历缓存项
|
||||
"""
|
||||
for key, value in self.cache_backend.items(region="custom_region"):
|
||||
print(f"缓存键: {key}, 值: {value}")
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
清理缓存
|
||||
"""
|
||||
self.cache_backend.clear(region="custom_region")
|
||||
self.cache_backend.close()
|
||||
```
|
||||
|
||||
- 5. 缓存装饰器参数说明:
|
||||
```python
|
||||
@cached(
|
||||
region="my_plugin", # 缓存区域,用于隔离不同插件的缓存
|
||||
maxsize=512, # 最大缓存条目数(仅内存缓存有效)
|
||||
ttl=1800, # 缓存存活时间(秒)
|
||||
skip_none=True, # 是否跳过None值缓存
|
||||
skip_empty=False # 是否跳过空值缓存(空列表、空字典等)
|
||||
)
|
||||
def my_function(self, param):
|
||||
pass
|
||||
```
|
||||
|
||||
- 6. 缓存管理功能:
|
||||
```python
|
||||
class MyPlugin(_PluginBase):
|
||||
@cached(region="my_plugin")
|
||||
def cached_function(self, param):
|
||||
return expensive_operation(param)
|
||||
|
||||
def clear_my_cache(self):
|
||||
"""
|
||||
清理指定区域的缓存
|
||||
"""
|
||||
self.cached_function.cache_clear()
|
||||
|
||||
def get_cache_info(self):
|
||||
"""
|
||||
获取缓存信息
|
||||
"""
|
||||
cache_region = self.cached_function.cache_region
|
||||
return f"缓存区域: {cache_region}"
|
||||
```
|
||||
|
||||
- 7. 缓存后端自动选择:
|
||||
- 系统会根据配置自动选择缓存后端:
|
||||
- `CACHE_BACKEND_TYPE=redis`:使用Redis作为缓存后端
|
||||
- `CACHE_BACKEND_TYPE=memory`:使用内存缓存(cachetools)
|
||||
- 插件代码无需修改,系统会自动处理缓存后端的切换
|
||||
|
||||
- 8. 最佳实践:
|
||||
- 为每个插件使用独立的缓存区域(region),避免缓存键冲突
|
||||
- 合理设置TTL,避免缓存过期时间过长导致数据过期
|
||||
- 对于频繁访问的数据使用较长的TTL,对于实时性要求高的数据使用较短的TTL
|
||||
- 使用`skip_none=True`避免缓存无意义的None值
|
||||
- 大文件或二进制数据建议使用文件缓存后端
|
||||
- 在插件卸载时清理相关缓存,避免内存泄漏
|
||||
103
docs/faq/16-register-agent-tools.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 如何在插件中注册智能体工具?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v2.8.0+` 版本)**
|
||||
- MoviePilot的AI智能体功能支持通过插件扩展工具能力,插件可以注册自定义工具供智能体调用,实现更丰富的功能扩展。
|
||||
- 1. 实现 `get_agent_tools()` 方法,返回工具类列表:
|
||||
```python
|
||||
def get_agent_tools(self) -> List[Type]:
|
||||
"""
|
||||
获取插件智能体工具
|
||||
返回工具类列表,每个工具类必须继承自 MoviePilotTool
|
||||
"""
|
||||
return [MyCustomTool, AnotherTool]
|
||||
```
|
||||
|
||||
- 2. 创建工具类,必须继承自 `MoviePilotTool` 并实现相关要求:
|
||||
```python
|
||||
from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.chain import ToolChain
|
||||
|
||||
class MyToolInput(BaseModel):
|
||||
"""工具输入参数模型"""
|
||||
explanation: str = Field(..., description="工具使用说明")
|
||||
query: str = Field(..., description="查询内容")
|
||||
limit: Optional[int] = Field(10, description="返回结果数量限制")
|
||||
|
||||
class MyCustomTool(MoviePilotTool):
|
||||
"""自定义工具示例"""
|
||||
# 工具名称,用于智能体识别和调用
|
||||
name: str = "my_custom_tool"
|
||||
|
||||
# 工具描述,用于智能体理解工具功能,建议详细描述工具用途和使用场景
|
||||
description: str = "This tool is used to perform custom operations. Use it when you need to query or process specific data."
|
||||
|
||||
# 输入参数模型,定义工具接收的参数及其类型和说明
|
||||
args_schema: Type[BaseModel] = MyToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据订阅参数生成友好的提示消息"""
|
||||
pass
|
||||
|
||||
async def run(self, query: str, limit: Optional[int] = None, **kwargs) -> str:
|
||||
"""
|
||||
实现工具的核心逻辑(异步方法)
|
||||
:param query: 查询内容
|
||||
:param limit: 结果数量限制
|
||||
:param kwargs: 其他参数,包含 explanation(工具使用说明)
|
||||
:return: 工具执行结果,返回字符串格式
|
||||
"""
|
||||
try:
|
||||
# 获取上下文信息(系统自动注入)
|
||||
session_id = self._session_id
|
||||
user_id = self._user_id
|
||||
channel = self._channel
|
||||
source = self._source
|
||||
username = self._username
|
||||
|
||||
# 执行工具逻辑
|
||||
result = await self._perform_operation(query, limit)
|
||||
|
||||
# 可以通过 send_tool_message 发送消息给用户
|
||||
await self.send_tool_message(f"操作完成: {result}", title="工具执行")
|
||||
|
||||
# 返回执行结果
|
||||
return f"成功处理查询 '{query}',返回 {len(result)} 条结果"
|
||||
except Exception as e:
|
||||
return f"执行失败: {str(e)}"
|
||||
|
||||
async def _perform_operation(self, query: str, limit: int):
|
||||
"""内部方法,执行具体操作"""
|
||||
# 实现具体业务逻辑
|
||||
pass
|
||||
```
|
||||
|
||||
- 3. 工具类可用的上下文属性和方法:
|
||||
- `self._session_id`: 当前会话ID
|
||||
- `self._user_id`: 用户ID
|
||||
- `self._channel`: 消息渠道(如 Telegram、Slack 等)
|
||||
- `self._source`: 消息来源
|
||||
- `self._username`: 用户名
|
||||
- `self.send_tool_message(message: str, title: str = "")`: 发送消息给用户
|
||||
- `ToolChain()`: 访问处理链功能,可调用系统其他功能
|
||||
|
||||
- 4. 工具类实现要求:
|
||||
- **必须继承自 `app.agent.tools.base.MoviePilotTool`**
|
||||
- **必须实现 `run` 方法**(异步方法),接收参数并返回字符串结果
|
||||
- **必须实现 `get_tool_message` 方法**,以显示友好的工具执行提示给用户
|
||||
- **必须定义 `name` 属性**(字符串),工具的唯一标识
|
||||
- **必须定义 `description` 属性**(字符串),详细描述工具功能,帮助智能体理解何时使用该工具
|
||||
- **可选定义 `args_schema` 属性**(Pydantic模型类),用于定义输入参数的结构和验证
|
||||
|
||||
- 5. 注意事项:
|
||||
- 工具的描述(`description`)应该清晰明确,帮助智能体理解工具的功能和使用场景
|
||||
- 工具的参数模型(`args_schema`)应该包含详细的字段描述,帮助智能体正确构造参数
|
||||
- 工具执行结果应该返回有意义的字符串,便于智能体理解和向用户展示
|
||||
- 工具可以通过 `send_tool_message` 方法向用户发送实时消息,提升交互体验
|
||||
- 工具类在初始化时会自动注入会话和用户信息,可以通过私有属性访问
|
||||
- 如果工具需要访问插件实例,需要自行通过 `PluginManager` 获取
|
||||
- 工具执行时间应该尽量短,避免阻塞智能体的响应
|
||||
- 建议在工具执行过程中添加适当的错误处理和日志记录
|
||||
BIN
icons/AliDnsDDNS.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
icons/agentresourceofficer.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
31
icons/agentresourceofficer.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Agent Resource Officer">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="88" y1="52" x2="424" y2="460" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0f766e"/>
|
||||
<stop offset="0.55" stop-color="#155e75"/>
|
||||
<stop offset="1" stop-color="#1d4ed8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="chip" x1="150" y1="142" x2="362" y2="370" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#dbeafe"/>
|
||||
</linearGradient>
|
||||
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="20" flood-color="#06233a" flood-opacity="0.28"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||
<path d="M105 315c50 75 151 100 233 58 61-31 92-89 90-150" fill="none" stroke="#67e8f9" stroke-width="18" stroke-linecap="round" opacity="0.58"/>
|
||||
<path d="M408 178c-51-67-149-87-226-48-55 28-86 78-91 133" fill="none" stroke="#bbf7d0" stroke-width="18" stroke-linecap="round" opacity="0.54"/>
|
||||
<circle cx="98" cy="268" r="19" fill="#a7f3d0"/>
|
||||
<circle cx="414" cy="176" r="19" fill="#bae6fd"/>
|
||||
<g filter="url(#softShadow)">
|
||||
<rect x="138" y="150" width="236" height="222" rx="58" fill="url(#chip)"/>
|
||||
<rect x="176" y="206" width="160" height="91" rx="38" fill="#0f172a" opacity="0.9"/>
|
||||
<circle cx="217" cy="250" r="15" fill="#22d3ee"/>
|
||||
<circle cx="295" cy="250" r="15" fill="#86efac"/>
|
||||
<path d="M220 320h72" stroke="#0f172a" stroke-width="17" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M256 150v-42" stroke="#dbeafe" stroke-width="18" stroke-linecap="round"/>
|
||||
<circle cx="256" cy="93" r="23" fill="#dbeafe"/>
|
||||
</g>
|
||||
<path d="M160 395h192" stroke="#e0f2fe" stroke-width="20" stroke-linecap="round" opacity="0.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
BIN
icons/airecoginzerforwarder.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
27
icons/airecoginzerforwarder.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="44" y1="36" x2="206" y2="222" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#42D0FF"/>
|
||||
<stop offset="1" stop-color="#0EA5E9"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="46" y="50" width="164" height="152" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#0B4A69" flood-opacity="0.18"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<circle cx="128" cy="128" r="106" fill="url(#bg)"/>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="68" y="72" width="96" height="96" rx="24" fill="white"/>
|
||||
<rect x="86" y="96" width="60" height="12" rx="6" fill="#0E7490"/>
|
||||
<rect x="86" y="116" width="40" height="12" rx="6" fill="#38BDF8"/>
|
||||
<rect x="86" y="136" width="52" height="12" rx="6" fill="#7DD3FC"/>
|
||||
</g>
|
||||
|
||||
<path d="M160 124C173.333 124 184 113.333 184 100C184 86.6667 173.333 76 160 76" stroke="white" stroke-width="14" stroke-linecap="round"/>
|
||||
<path d="M173 62L195 84L173 106" stroke="white" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<circle cx="183" cy="154" r="25" fill="#0C4A6E"/>
|
||||
<path d="M171 154H195" stroke="white" stroke-width="12" stroke-linecap="round"/>
|
||||
<path d="M183 142V166" stroke="white" stroke-width="12" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
icons/airecognizerenhancer.png
Normal file
|
After Width: | Height: | Size: 217 KiB |
31
icons/airecognizerenhancer.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="AI Recognizer Enhancer">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="74" y1="54" x2="438" y2="458" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#1e3a8a"/>
|
||||
<stop offset="0.52" stop-color="#6d28d9"/>
|
||||
<stop offset="1" stop-color="#be185d"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="lens" x1="143" y1="149" x2="369" y2="375" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#e0e7ff"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#16072f" flood-opacity="0.34"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||
<path d="M116 172h280" stroke="#fdf2f8" stroke-width="20" stroke-linecap="round" opacity="0.58"/>
|
||||
<path d="M116 340h280" stroke="#bfdbfe" stroke-width="20" stroke-linecap="round" opacity="0.48"/>
|
||||
<path d="M137 126l46 92M239 126l46 92M341 126l46 92" stroke="#f0abfc" stroke-width="18" stroke-linecap="round" opacity="0.72"/>
|
||||
<g filter="url(#shadow)">
|
||||
<path d="M96 256c39-67 94-101 160-101s121 34 160 101c-39 67-94 101-160 101S135 323 96 256Z" fill="url(#lens)"/>
|
||||
<circle cx="256" cy="256" r="70" fill="#111827"/>
|
||||
<circle cx="256" cy="256" r="42" fill="#38bdf8"/>
|
||||
<circle cx="274" cy="238" r="15" fill="#f8fafc"/>
|
||||
</g>
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M165 256h-34M381 256h-34M256 141v-34M256 405v-34" stroke="#d9f99d" stroke-width="18"/>
|
||||
<path d="M177 360l-24 24M359 153l-24 24M177 153l-24-24M359 360l24 24" stroke="#fde68a" stroke-width="14" opacity="0.9"/>
|
||||
</g>
|
||||
<path d="M210 418h92" stroke="#ffffff" stroke-width="18" stroke-linecap="round" opacity="0.78"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
BIN
icons/feishucommandbridgelong.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
icons/hdhive.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
icons/quark.ico
Normal file
|
After Width: | Height: | Size: 66 KiB |
331
package.json
@@ -26,7 +26,7 @@
|
||||
"name": "AI字幕自动生成(v2)",
|
||||
"description": "使用whisper自动生成视频文件字幕,使用大模型翻译字幕成中文。",
|
||||
"labels": "字幕",
|
||||
"version": "2.5",
|
||||
"version": "2.5.1",
|
||||
"icon": "autosubtitles.jpeg",
|
||||
"author": "TimoYoung",
|
||||
"level": 1,
|
||||
@@ -39,7 +39,8 @@
|
||||
"v2.1": "支持清除历史记录",
|
||||
"v2.2": "fix",
|
||||
"v2.3": "支持独立的大模型调用配置",
|
||||
"v2.5": "适配openai api v1"
|
||||
"v2.5": "适配openai api v1",
|
||||
"v2.5.1": "更新依赖"
|
||||
}
|
||||
},
|
||||
"CustomSites": {
|
||||
@@ -191,12 +192,13 @@
|
||||
"name": "自定义Hosts",
|
||||
"description": "修改系统hosts文件,加速网络访问。",
|
||||
"labels": "网络",
|
||||
"version": "1.2",
|
||||
"version": "1.2.1",
|
||||
"icon": "hosts.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.2.1": "更新依赖",
|
||||
"v1.2": "支持写入注释",
|
||||
"v1.1": "关闭插件时自动恢复系统hosts"
|
||||
}
|
||||
@@ -219,12 +221,13 @@
|
||||
"name": "Cloudflare IP优选",
|
||||
"description": "🌩 测试 Cloudflare CDN 延迟和速度,自动优选IP。",
|
||||
"labels": "网络,站点",
|
||||
"version": "1.5",
|
||||
"version": "1.5.1",
|
||||
"icon": "cloudflare.jpg",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.5.1": "更新依赖",
|
||||
"v1.5": "适配CloudflareSpeedTest新版名称",
|
||||
"v1.4": "修复立即运行一次",
|
||||
"v1.3": "调整插件开启状态判断条件",
|
||||
@@ -467,13 +470,13 @@
|
||||
"name": "药丸签到",
|
||||
"description": "药丸论坛签到。",
|
||||
"labels": "站点",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"icon": "invites.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.0.3": "增加启用浏览器仿真功能发送请求",
|
||||
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
|
||||
"v2.0.1": "尝试修复签到失败问题,新增使用代理、Cookie自动更新功能",
|
||||
"v2.0.0": "修复签到失败问题,新增账户登录签到功能、新增签到失败重试机制,美化界面UI",
|
||||
@@ -679,12 +682,14 @@
|
||||
"name": "共享识别词",
|
||||
"description": "从Github、Etherpad等远程文件中获取共享识别词并应用。",
|
||||
"labels": "识别",
|
||||
"version": "2.3",
|
||||
"version": "2.4.1",
|
||||
"icon": "words.png",
|
||||
"author": "honue",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v2.4.1": "官方etherpad共享识别词因总是有人恶意修改导致识别异常,移除官方共享支持",
|
||||
"v2.4": "支持 JSON 格式远程识别词集合订阅",
|
||||
"v2.3": "更换默认共享识别词地址"
|
||||
}
|
||||
},
|
||||
@@ -782,8 +787,7 @@
|
||||
"v1.4": "支持仪表板组件显示",
|
||||
"v1.3": "修复观众做种数据异常问题",
|
||||
"v1.2": "修复契约检查无数据返回的问题"
|
||||
},
|
||||
"v2": true
|
||||
}
|
||||
},
|
||||
"FeiShuMsg": {
|
||||
"name": "飞书机器人消息通知",
|
||||
@@ -973,7 +977,6 @@
|
||||
"icon": "Wecom_A.png",
|
||||
"author": "RamenRa",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.7.3": "修复检测登录的元素",
|
||||
"v1.7.2": "||wan参数细分,修复使用||wan时立即检测一次实际不生效,修复v1第三方备用通知可能无效,调整验证码获取",
|
||||
@@ -1056,5 +1059,313 @@
|
||||
"author": "cddjr",
|
||||
"level": 1,
|
||||
"v2": true
|
||||
},
|
||||
"AliDnsDDNS": {
|
||||
"name": "阿里云 DDNS",
|
||||
"description": "定时检测公网 IP,自动更新阿里云 DNS 解析记录,支持泛域名(* 记录)及 IPv6(AAAA)。",
|
||||
"labels": "网络",
|
||||
"version": "1.0",
|
||||
"icon": "AliDnsDDNS.png",
|
||||
"author": "dtzsghnr",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.0": "初始版本,支持 IPv4/IPv6、泛域名、多记录配置、更新历史详情页"
|
||||
}
|
||||
},
|
||||
"AIRecognizerEnhancer": {
|
||||
"name": "AI识别增强",
|
||||
"description": "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。",
|
||||
"labels": "AI,识别,LLM,本地兜底,MoviePilot,TMDB",
|
||||
"version": "0.1.12",
|
||||
"icon": "airecognizerenhancer.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"0.1.12": "兼容 MoviePilot 最新 LLM 路径与异步 get_llm 接口,修复最新版 MP 下插件加载失败问题。",
|
||||
"0.1.11": "同步运行态版本,保持本地结构化识别、失败样本闭环和识别词建议能力一致。",
|
||||
"0.1.10": "新增识别词建议模型退化时的精确规则兜底,保证批量建议/批量写入在上游异常时仍能尽量落地。",
|
||||
"0.1.9": "新增失败样本精简摘要接口,并让批量建议/批量写入附带低 token 文本摘要,便于智能体直接消费。",
|
||||
"0.1.8": "新增失败样本批量建议与批量写入接口,可一次处理一批失败样本,进一步减少人工逐条操作。",
|
||||
"0.1.7": "新增失败样本批量复查接口,可批量重跑样本并在确认修复后批量出队。",
|
||||
"0.1.6": "新增失败样本复查接口,可按当前识别词与当前识别器重跑样本,并在确认修复后自动出队。",
|
||||
"0.1.5": "新增失败样本出队动作,支持按索引移除单条样本,并在写入识别词后自动移除已处理样本。",
|
||||
"0.1.4": "新增失败样本洞察接口,自动归纳重复问题、失败原因和优先处理样本,帮助更快挑出值得写识别词的样本。",
|
||||
"0.1.3": "新增失败样本摘要、样本清理、样本去重和保留上限控制,让样本工作流更适合长期运行与智能体使用。",
|
||||
"0.1.2": "新增按失败样本直接生成建议和直接写入规则的快捷 API,进一步缩短从失败样本到 CustomIdentifiers 的闭环。",
|
||||
"0.1.1": "新增失败样本查看、自定义识别词建议和一键追加写入能力,让 AI 识别增强开始和 MoviePilot 原生 CustomIdentifiers 闭环联动。",
|
||||
"0.1.0": "首个可用版本,复用 MoviePilot 当前 LLM 配置,在原生识别失败后通过 Chain NameRecognize 做本地结构化兜底。"
|
||||
}
|
||||
},
|
||||
"AgentResourceOfficer": {
|
||||
"name": "Agent影视助手",
|
||||
"description": "龙虾agent稳定控制 MP:飞书入口、盘搜/影巢搜索、115/夸克转存、智能评分推荐。",
|
||||
"labels": "Agent,影巢,HDHive,115,夸克,Quark,智能体,转存,解锁",
|
||||
"version": "0.2.71",
|
||||
"icon": "agentresourceofficer.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"0.2.71": "新增流媒体推荐:聚合 Netflix、Disney+、Apple TV+、Prime Video 四大平台,基于 TMDB discover 按热度/评分推荐本月上新、近期热门电影和剧集;结果页改为只读列表,仅支持显式前缀触发。",
|
||||
"0.2.70": "最后一轮主线收口:取消标题级云盘转存/云盘搜索入口,统一保留前缀搜索与编号续接;修复 PT 指定集/最新集筛选、下载路径透传、分页与旧别名拦截,并同步外部智能体 Skill/命令文档。",
|
||||
"0.2.69": "修复外部智能体跨机器接入暴露的问题:补齐 115 直转依赖,Cookie 修复改为通过远端 MoviePilot API 安全写回,安装 Skill 时自动准备浏览器 Cookie 工具依赖,并增强 PanSou 跨机提示与 MP 推荐空结果回退。",
|
||||
"0.2.68": "收口云盘搜索/转存/影巢签到恢复链:固定“转存/下载/云盘搜索/更新检查”口径,补齐 115/夸克默认目录清理、影巢立即签到与 Cookie 一键修复命令,并同步主页与 Skill 文档。",
|
||||
"0.2.67": "收口外部智能体入口细节:隐藏 workbuddy_quickstart 旧 recipe 展示名,为 external-agent / commands 增加 deprecated alias 语义,并统一当前状态文档。",
|
||||
"0.2.66": "为 request_templates 增加三类入口的 entry_playbooks,直接给出 helper 命令、HTTP 端点、Tool 名称和推荐读取字段,进一步减少外部智能体与 MP 内置智能体的接入编排逻辑。",
|
||||
"0.2.65": "为 request_templates 和 helper 增加模板编排元数据,明确服务端/客户端角色、三类入口范式,以及 startup -> decide -> route -> followup 的推荐最小执行流。",
|
||||
"0.2.64": "把外部智能体执行契约与最小执行循环下沉到 request_templates 返回;新接入的智能体现在可以直接从模板元数据拿到 startup -> decide -> route -> policy -> followup 脚手架。",
|
||||
"0.2.63": "为 compact 顶层短命令增加执行语义字段:command_policy、preferred_requires_confirmation、fallback_requires_confirmation、can_auto_run_preferred;外部智能体现在可以机械判断 直接读 还是 先确认再写。",
|
||||
"0.2.62": "把 error_summary、followup_summary、score_summary.decision 三层短命令继续上浮到 compact 主响应顶层;外部智能体现在只读 preferred_command / compact_commands 和 command_source 就能续跑。",
|
||||
"0.2.61": "为 compact 失败回执增加统一 error_summary;外部智能体现在可以直接读取失败标签、建议说明,以及 preferred_command / compact_commands 这样的最短恢复命令。",
|
||||
"0.2.60": "为 score_summary.decision 和 followup_summary 增加 preferred_command、fallback_command 与 compact_commands;mp_recent_activity 也补齐 followup_summary,外部智能体可直接读取最短下一步命令。",
|
||||
"0.2.59": "新增统一 跟进 入口;有已执行计划时自动追执行后状态,有片名时直接查生命周期,否则退回最近活动,外部智能体只保留一个短入口也能续接。",
|
||||
"0.2.58": "压缩本地/PT 高跟踪入口;新增 后续、状态、记录、入库、诊断、最近 等短命令,并让推荐命令优先吐这套更省 token 的自然语言写法。",
|
||||
"0.2.57": "把写入动作后的追踪提示下沉为统一 followup_summary;执行计划、统一后续追踪和本地/PT 诊断现在都会返回稳定的后续标签、建议说明和推荐命令。",
|
||||
"0.2.56": "把评分后的确认提示下沉为统一 decision 摘要,score_summary 现在会稳定返回决策标签、建议说明和推荐命令,便于飞书、外部智能体和 MP 内置入口共用同一套下一步提示。",
|
||||
"0.2.55": "新增插件级智能体默认评分策略设置,允许统一配置 PT 最低做种数、建议确认分数线、自动入库分数线与默认自动化开关;新会话默认偏好与评分策略公开数据现在统一读取这些值。",
|
||||
"0.2.54": "新增 preferences_onboarding 模板组、评分策略自然语言只读入口与 helper 命令;补齐偏好/评分 smoke 覆盖,并修正能力摘要里的 auto_ingest 默认值。",
|
||||
"0.2.53": "新增本地/PT 入库诊断主线;补齐 mp_ingest_status、mp_ingest_failures、mp_recent_activity、mp_local_diagnose,并让生命周期/执行后追踪统一返回 diagnosis_summary。",
|
||||
"0.2.52": "调整 recover 优先级:当前会话最近一条计划已执行时,恢复入口会优先推荐 query_execution_followup,而不是退回会话检查或新任务。",
|
||||
"0.2.51": "把 execution_followup 下沉为正式 request template 和 followup recipe,外部智能体可以通过低 token 模板直接续接执行后追踪。",
|
||||
"0.2.50": "新增 query_execution_followup 统一只读入口,并补齐 assistant/action compact 的 error_code、recommended_action 和 follow_up_hint,方便外部智能体一跳续接执行后追踪。",
|
||||
"0.2.49": "新增 query_execution_followup 统一只读入口,外部智能体可按最近已执行计划自动追踪下载、订阅或入库后续状态。",
|
||||
"0.2.48": "把 recommended_action 和 follow_up_hint 下沉到 plan_execute 原始 data 与用户可读消息里,非 compact 调用也能直接续接下一步。",
|
||||
"0.2.47": "在 execute_plan compact 结果中补充 recommended_action 和 follow_up_hint,让外部智能体执行计划后能直接读取建议下一步。",
|
||||
"0.2.46": "把 execute_plan 的 follow-up 样本加入 selfcheck,并纳入 live smoke 回归,避免 PT 下载、订阅与云盘转存的后续动作模板回退。",
|
||||
"0.2.45": "执行 plan_id 成功后,按 PT 下载、订阅或云盘转存 workflow 返回更明确的后续动作模板,方便外部智能体继续追踪状态。",
|
||||
"0.2.44": "统一 assistant/plan/execute 的 compact 回执;失败态和执行态现在都会返回稳定的 write_effect、error_code、result_summary 与结果列表摘要,方便外部智能体续接。",
|
||||
"0.2.43": "调整 recover 优先级为业务续接优先于偏好初始化;已有 PT/云盘会话时,恢复入口会先推荐继续当前任务。",
|
||||
"0.2.42": "补齐 compact session/recover 协议里的 action_templates;外部智能体读取会话状态或恢复入口时,也能拿到完整的结构化下一步模板。",
|
||||
"0.2.41": "补齐 PT 只读会话的 action_templates;下载任务、站点、下载器、订阅列表等场景现在会给外部智能体正确的结构化下一步模板。",
|
||||
"0.2.40": "收紧 PT 只读会话的下一步建议;下载任务、站点、下载器、订阅列表等场景不再给出误导性的控制动作提示。",
|
||||
"0.2.39": "修复 workflow/tool 直调下的控制计划安全;空下载任务或空订阅列表时,不再为 mp_download_control / mp_subscribe_control 生成无效 plan_id。",
|
||||
"0.2.38": "修复空订阅列表下的订阅控制安全;自然语言编号必须命中当前会话列表,避免把“搜索订阅 1”误写成订阅 ID=1 的计划。",
|
||||
"0.2.37": "新增 mp_pt_mainline 与 mp_recommendation 请求模板 recipe,外部智能体可低 token 拉取 MP 原生 PT 主线与推荐主线模板,不再猜 workflow body。",
|
||||
"0.2.36": "优化评分展示文案;硬性阻断显示为硬风险,普通偏好未命中显示为提醒,避免智能体把软提醒误判为不可用。",
|
||||
"0.2.35": "修正 MP 推荐回退过滤;热门电影、热门电视剧 在回退到 tmdb_trending 时仍保留电影/电视剧类型,不再混入另一类结果。",
|
||||
"0.2.34": "修正 MP 原生搜索结果的下载提示;明确下载资源 序号会先生成下载计划,不会静默下载。",
|
||||
"0.2.33": "统一 MP 原生命令前缀解析;下载历史蜘蛛侠、追踪蜘蛛侠、入库失败蜘蛛侠、暂停订阅1 等无空格/冒号写法不再误落到资源搜索。",
|
||||
"0.2.32": "修复订阅列表自然语言解析;订阅列表 蜘蛛侠、订阅列表:蜘蛛侠、订阅列表蜘蛛侠 现在稳定走只读查询,不会被通用订阅写入计划覆盖。",
|
||||
"0.2.31": "收紧 compact 协议中的评分摘要返回;普通站点、下载器、任务诊断不再继承上一轮搜索的 score_summary,避免外部智能体误读上下文。",
|
||||
"0.2.30": "细化评分风险结构:hard_risk_reasons 表示真正阻断自动化的风险,risk_reasons 保留为确认前提醒,避免软提醒被误算为阻断。",
|
||||
"0.2.29": "收口 MP 原生 PT 主线:补齐做种/热度/字幕/站点等评分理由,下载/订阅/控制统一走 plan_id 确认链路,并强化 MP 原生推荐续接。",
|
||||
"0.2.28": "插件展示名统一改为 Agent影视助手,并同步仓库文档、Skill 文案和兼容插件引用。",
|
||||
"0.2.27": "优化盘搜和影巢资源列表的下一步提示;默认引导外部智能体先生成计划,再确认执行。",
|
||||
"0.2.26": "新增云盘写入计划入口;盘搜和影巢资源可用“计划选择 1”先生成 plan_id,再确认执行。",
|
||||
"0.2.25": "修复云盘会话最佳/详情选择安全;盘搜和影巢资源阶段的“最佳片源”只展示详情,不会误选最后一条执行。",
|
||||
"0.2.24": "补齐 PT 下载自动化闭环;仅在用户开启自动入库且评分达标、无硬风险时,下载选择和下载最佳才会直接提交。",
|
||||
"0.2.23": "新增偏好画像自然语言入口;可用“偏好”“保存偏好 ...”“重置偏好”查看、保存或重置智能体片源偏好。",
|
||||
"0.2.22": "新增计划确认自然语言入口;可用“执行计划”或“执行 plan-xxx”确认执行已生成的下载、订阅或控制计划。",
|
||||
"0.2.21": "新增“下载最佳”入口;在 MP 搜索会话中按最高评分 PT 候选生成下载计划,仍需用户确认 plan_id 后才会下载。",
|
||||
"0.2.20": "新增 MP 搜索最佳候选详情入口;智能体可用“最佳片源”或 mp_search_best 直接查看当前评分最高 PT 候选。",
|
||||
"0.2.19": "新增 MP 搜索结果详情入口;MP 搜索后“选择 1”会先展示 PT 详情、评分理由和风险,再由用户确认是否下载。",
|
||||
"0.2.18": "新增 MP 原生媒体识别详情入口;智能体可用“识别 片名”或 mp_media_detail 工作流确认 TMDB/Douban/IMDB 信息后再搜索、下载或订阅。",
|
||||
"0.2.17": "新增 MP 生命周期追踪聚合入口;智能体可用“追踪 片名”一次查看下载任务、下载历史和整理/入库历史。",
|
||||
"0.2.16": "新增 MP 下载历史查询,并按 hash 关联整理/入库状态;智能体可用“下载历史 片名”追踪资源是否已提交下载和是否落库。",
|
||||
"0.2.15": "新增 MP 整理/入库历史查询;智能体可用“入库历史”“入库失败 片名”判断下载后是否已落库,接口只返回脱敏摘要。",
|
||||
"0.2.14": "新增 MP 订阅列表查询与订阅控制计划;智能体可查看订阅规则,并对搜索、暂停、恢复、删除订阅生成 plan_id 后确认执行。",
|
||||
"0.2.13": "新增 MP 下载器与 PT 站点环境诊断入口;只返回启用状态、优先级、绑定下载器和 Cookie 是否存在,不暴露 Cookie 明文。",
|
||||
"0.2.12": "补齐 MP 原生下载任务查询与任务控制入口;智能体可查看下载中任务,并对暂停、恢复、删除生成 plan_id 后确认执行。",
|
||||
"0.2.11": "MP 下载/订阅命令支持无空格自然写法,例如“下载1”“下载第1个”“订阅蜘蛛侠”“订阅并搜索蜘蛛侠”;自然语言写入默认生成 plan_id,确认后才执行。",
|
||||
"0.2.10": "推荐列表选择支持自然语言指定后续来源,例如“选择 1 盘搜”“选择1影巢”“选 2 mp”,飞书与智能体可不用结构化 mode 参数。",
|
||||
"0.2.09": "热门推荐入口支持自然语言别名,例如“看看最近有什么热门影视”“豆瓣热门电影”“正在热映”“今日番剧”,智能体和飞书可直接用人话触发 MP 推荐。",
|
||||
"0.2.08": "MP 热门推荐列表支持保存会话并按编号继续搜索,智能体可把推荐条目直接转入 MP 原生搜索、影巢或盘搜。",
|
||||
"0.2.07": "影巢搜索默认使用自动媒体类型识别,未指定电影/剧集时不再提前按电影过滤,修复新剧搜索被误判无结果的问题。",
|
||||
"0.2.06": "新增 scoring_policy 能力,结构化暴露插件内置云盘/PT 评分规则与硬门槛,方便智能体解释但不重打分。",
|
||||
"0.2.05": "新增低 token score_summary,帮助智能体直接读取云盘和 PT 评分推荐、风险与确认建议。",
|
||||
"0.2.04": "增强智能体偏好引导协议,主响应返回低 token preference_status,并在未初始化时优先提示保存偏好。",
|
||||
"0.2.03": "新增智能体偏好画像、云盘/PT 分源评分、MP 原生搜索下载订阅推荐工作流,并让写入动作优先生成 plan_id。",
|
||||
"0.2.02": "新增影巢资源搜索/解锁总开关与单资源积分上限,降低外部智能体误解锁高积分资源的风险。",
|
||||
"0.2.01": "移除 get_state 中的主动 Agent Tool 重载,避免插件状态轮询时反复打印工具加载日志。",
|
||||
"0.1.119": "新增本插件内置影巢签到日志,可通过 API、飞书或智能体查看最近签到、自动刷新 Cookie 和失败原因。",
|
||||
"0.1.118": "本插件内置影巢 Cookie 自动刷新:签到兜底失败时可使用账号密码自动登录、保存新 Cookie 并重试。",
|
||||
"0.1.117": "影巢签到收口到本插件:新增定时签到配置、默认赌狗模式、网页 Cookie 兜底和智能入口签到命令。",
|
||||
"0.1.116": "新增 workbuddy_quickstart 请求模板和 route_text 模板,方便 WorkBuddy、微信侧智能体复现标准接入口。",
|
||||
"0.1.115": "assistant/route 支持 MP搜索、原生搜索、搜索资源、搜索 前缀,统一外部智能体与飞书入口的原生 MP 搜索用法。",
|
||||
"0.1.114": "飞书冲突检测会结合旧桥接配置、health 和 get_state,避免把已禁用但仍加载的旧插件误判为冲突。",
|
||||
"0.1.113": "飞书健康检查补充 ready_to_start、safe_to_enable、缺失项和迁移建议,方便判断是否能从旧桥接迁移。",
|
||||
"0.1.112": "修正 assistant/startup 在无可恢复会话时仍推荐 continue 的问题,避免外部智能体被空会话误导。",
|
||||
"0.1.111": "飞书配置页补充回复 ID 类型和命令白名单,便于从旧飞书桥接完整迁移。",
|
||||
"0.1.110": "飞书健康检查新增旧桥接运行状态和冲突提示,避免双飞书入口抢消息。",
|
||||
"0.1.109": "新增 MP 原生 Tool agent_resource_officer_feishu_health,支持内置智能助手检查飞书入口状态。",
|
||||
"0.1.108": "内置可选飞书入口 Channel,并为 assistant 回执补充 write_effect/error_code 标准字段。",
|
||||
"0.1.107": "assistant/startup 会根据恢复状态动态推荐 bootstrap 或 continue 模板流程。",
|
||||
"0.1.106": "assistant/startup 会带 recommended_request_templates,外部智能体启动后可直接按推荐参数拉取低 token 模板流程。",
|
||||
"0.1.105": "assistant/request_templates 的文本摘要会直接显示推荐流程、首步调用和确认提示,方便低 token 场景直接阅读。",
|
||||
"0.1.104": "recommended_recipe_detail 会带 first_confirmation_template 和 confirmation_message,方便外部智能体在写入前提示用户确认。",
|
||||
"0.1.103": "recipe= 支持 plan、maintain、continue、bootstrap 等短别名,回执会带 requested_recipe、selected_recipe 和 recipe_aliases。",
|
||||
"0.1.102": "assistant/request_templates 支持 recipe= 参数,可直接按 safe_bootstrap、plan_then_confirm、continue_existing_session 或 maintenance_cycle 拉取整套推荐流程。",
|
||||
"0.1.101": "推荐调用会带 url_template,外部智能体可用 {base_url} 和 {MP_API_TOKEN} 直接拼出 HTTP 调用地址。",
|
||||
"0.1.100": "assistant/request_templates 与推荐调用会明确给出 auth.mode=query_apikey,避免外部智能体误用 Bearer 鉴权。",
|
||||
"0.1.99": "recommended_recipe_detail 会带完整 calls 列表,外部智能体可按推荐流程逐步执行。",
|
||||
"0.1.98": "recommended_recipe_detail 会带 first_call,直接给出首个模板的 HTTP 调用和 MP Tool 调用参数,外部智能体可直接执行第一步。",
|
||||
"0.1.97": "assistant/request_templates 回执会带 recommended_recipe_detail,直接给出推荐流程的首个模板、确认模板和写入模板,外部智能体可直接照此编排。",
|
||||
"0.1.96": "assistant/request_templates 回执会直接给出 recommended_recipe 与 recommended_recipe_reason,外部智能体不必再自己挑选最适合的 recipe。",
|
||||
"0.1.95": "recipes 会直接带 requires_confirmation、has_write_effect 和最小 cache_ttl_seconds,自检也会验证这些汇总特征。",
|
||||
"0.1.94": "assistant/request_templates 回执会带场景化 recipes,外部智能体可直接选择安全启动、先计划后执行、继续既有会话等预设流程。",
|
||||
"0.1.93": "assistant/request_templates 回执会带 recommended_sequence,直接给出推荐调用顺序,外部智能体可以少做一层启动编排。",
|
||||
"0.1.92": "request_templates 每个模板都会带 cache_scope 和 cache_ttl_seconds,execution_policy 也会汇总 cacheable_templates 与 non_cacheable_templates,方便外部智能体决定缓存策略。",
|
||||
"0.1.91": "assistant/request_templates 支持 include_templates=false,可只返回模板名、无效项和执行策略,进一步减少 token。",
|
||||
"0.1.90": "请求模板协议增加 schema_version=request_templates.v1,startup/toolbox 也携带 request_templates_schema_version,方便外部智能体做兼容判断。",
|
||||
"0.1.89": "assistant/request_templates 回执会带 execution_policy 汇总,直接列出可免确认执行、需要确认执行和存在写入副作用的模板名。",
|
||||
"0.1.88": "request_templates 每个模板都会带 side_effect 和 requires_confirmation,外部智能体可区分只读、dry-run、计划写入和真实执行动作。",
|
||||
"0.1.87": "request_templates 每个模板都会带 description,外部智能体可以直接判断模板用途,减少额外解释和 token 消耗。",
|
||||
"0.1.86": "request_templates 每个模板都会带 tool_args,区分 HTTP 参数和 MP Tool 参数,避免外部智能体误用 body/query。",
|
||||
"0.1.85": "request_templates 每个模板都会带对应的 MP 原生 tool 名,外部智能体可在 HTTP 调用和 MP Tool 调用之间直接切换。",
|
||||
"0.1.84": "assistant/request_templates 支持 POST JSON body 传入 names/limit,方便结构化智能体直接用 body 请求过滤模板。",
|
||||
"0.1.83": "assistant/startup 的核心 tools/endpoints 和 capabilities compact 推荐启动列表显式包含请求模板入口,外部智能体只读启动包也能发现模板能力。",
|
||||
"0.1.82": "assistant/request_templates 支持 names/name/template 过滤,只返回指定模板,并回传 selected_names 与 invalid_names;原生 Tool 同步支持 names 参数。",
|
||||
"0.1.81": "新增 assistant/request_templates 只读入口和 agent_resource_officer_request_templates 原生 Tool,外部智能体可只拉请求模板而不拉完整启动包。",
|
||||
"0.1.80": "assistant/startup 与 assistant/toolbox 直接返回统一 request_templates,并由 assistant/selfcheck 检查模板齐全性,方便外部智能体按模板调用。",
|
||||
"0.1.79": "assistant/startup.maintenance 直接返回 safe_to_execute、execute_method、dry_run_method、execute_endpoint 和 execute_body,外部智能体无需猜维护调用方式。",
|
||||
"0.1.78": "assistant/maintain 在 POST 执行维护后写入 assistant/history,方便外部智能体审计维护动作;GET dry-run 仍不写历史。",
|
||||
"0.1.77": "assistant/selfcheck 新增 maintain dry-run 和维护模板 compact 检查,确保维护协议本身也纳入健康检查。",
|
||||
"0.1.76": "assistant/maintain 的 GET 请求固定为 dry-run,即使带 execute=true 也不会执行清理;只有 POST execute=true 才会实际维护。",
|
||||
"0.1.75": "assistant/capabilities 增加 assistant_maintain 字段说明,并把 assistant/maintain 纳入 compact endpoint 和推荐启动链路。",
|
||||
"0.1.74": "assistant/selfcheck 新增 maintain endpoint 和 maintain Tool 检查,确保维护入口已正确纳入外部智能体工具清单。",
|
||||
"0.1.73": "新增 assistant/maintain 与 agent_resource_officer_maintain,支持 dry-run 查看低风险维护建议,也支持 execute=true 执行过期会话和已执行计划清理。",
|
||||
"0.1.72": "assistant/startup.maintenance 增加 stale_sessions、saved_plans_executed 和 recommended_actions,外部智能体可直接判断是否值得做低风险维护清理。",
|
||||
"0.1.71": "assistant/plans compact 回执中 total 改为当前过滤命中数,并补充 total_all,避免外部智能体把全部计划数误判为待执行计划数。",
|
||||
"0.1.70": "assistant/startup.maintenance 增加低风险清理模板:清理过期会话、清理已执行计划;不会自动清理待执行计划。",
|
||||
"0.1.69": "assistant/startup 增加 maintenance 计数,直接返回活跃会话、保存计划和待执行计划数量,便于外部智能体判断恢复或清理。",
|
||||
"0.1.68": "assistant/startup 直接携带恢复用 session、session_id 和 action_templates,外部智能体可拿启动包直接执行推荐恢复动作。",
|
||||
"0.1.67": "新增 assistant/startup 与 agent_resource_officer_startup,一次返回启动状态、自检结果、核心工具、端点、默认目录和恢复建议,减少外部智能体开场多次探测。",
|
||||
"0.1.66": "assistant/pulse 和 compact assistant/capabilities 推荐启动链路加入 assistant/selfcheck,便于外部智能体开场自检协议健康。",
|
||||
"0.1.65": "新增 agent_resource_officer_selfcheck 原生 Tool,让 MP 智能助手可直接执行 Agent影视助手 compact 协议自检。",
|
||||
"0.1.64": "新增 assistant/selfcheck 轻量协议自检,快速确认 compact 模板、布尔解析和基础协议字段是否健康。",
|
||||
"0.1.63": "统一 dry_run、stop_on_error、include_raw_results、prefer_unexecuted、all_plans、stale_only、all_sessions、execute 等 POST 布尔字段解析,避免字符串 false/0/off 被误判。",
|
||||
"0.1.62": "统一 POST JSON compact 参数的布尔解析,避免外部智能体传入字符串 false/0/off 时被误判为开启精简回执。",
|
||||
"0.1.61": "action_templates 默认为支持精简回执的 assistant 端点注入 compact=true,外部智能体原样回放模板即可保持低 token。",
|
||||
"0.1.60": "assistant/route 与 assistant/pick 新增 compact=true 低 token 回执,减少智能入口搜索、选择、翻页和落盘主链路的嵌套负载。",
|
||||
"0.1.59": "assistant/action 新增 compact=true 低 token 回执,外部智能体原样回放 action_template 时可直接获取单动作摘要。",
|
||||
"0.1.58": "assistant/capabilities 与 assistant/readiness 新增 compact=true 低 token 回执,减少外部智能体启动阶段的能力发现和就绪检查负载。",
|
||||
"0.1.57": "assistant/actions、assistant/workflow 与 assistant/plan/execute 新增 compact=true 低 token 回执,减少批量执行、工作流计划和计划执行链路的嵌套负载。",
|
||||
"0.1.56": "assistant/history 与 assistant/plans 新增 compact=true 低 token 回执,便于外部智能体低成本查看执行历史和保存计划。",
|
||||
"0.1.55": "assistant/session 与 assistant/sessions 新增 compact=true 低 token 回执,减少外部智能体查看会话状态时的嵌套负载。",
|
||||
"0.1.54": "新增 assistant/toolbox 与 agent_resource_officer_toolbox 轻量工具清单,便于外部智能体低 token 获取端点、工具、工作流和命令示例。",
|
||||
"0.1.53": "新增 assistant/pulse 与 agent_resource_officer_pulse 轻量启动探针,返回版本、关键服务状态、警告和最佳恢复建议。",
|
||||
"0.1.52": "assistant/recover 新增 compact=true 低 token 回执,agent_resource_officer_recover 默认使用精简恢复信息,适合外部智能体高频轮询。",
|
||||
"0.1.51": "新增 assistant/recover 与 agent_resource_officer_recover 单入口恢复能力,可自动选择最值得恢复的会话或计划,并支持 execute=true 直接续跑。",
|
||||
"0.1.50": "assistant/session 与 assistant/sessions 统一到标准回执包裹字段,同时保留兼容摘要字段,降低外部智能体分支判断。",
|
||||
"0.1.49": "新增统一 recovery 字段,并让 assistant/action 支持 execute_session_latest_plan,外部智能体可按恢复协议直接续跑。",
|
||||
"0.1.48": "assistant/sessions 现在也会显示只有 dry_run 计划、尚未生成会话缓存的 session,便于从会话列表直接恢复。",
|
||||
"0.1.47": "assistant/sessions 新增待执行计划摘要与 execute_session_latest_plan 模板,外部智能体可从会话列表直接恢复计划。",
|
||||
"0.1.46": "assistant/action 新增 execute_latest_plan 与 execute_plan 动作,action_templates.action_body 可原样回传执行计划。",
|
||||
"0.1.45": "session_state 与 readiness 新增计划恢复动作模板,外部智能体可直接复用 execute_latest_plan 执行待处理计划。",
|
||||
"0.1.44": "assistant/plan/execute 现可按 session/session_id 自动恢复并执行最近计划,进一步减少外部智能体对 plan_id 的依赖。",
|
||||
"0.1.43": "新增 assistant/plans 与 assistant/plans/clear 计划管理入口,外部智能体可查询、恢复和清理 dry_run 保存计划。",
|
||||
"0.1.42": "dry_run 工作流计划新增 plan_id 持久化与 assistant/plan/execute 执行入口,外部智能体可先生成计划再按 plan_id 执行。",
|
||||
"0.1.41": "预设工作流新增 dry_run 计划模式,外部智能体可先生成步骤计划和可执行请求体,确认后再实际执行,降低误操作风险。",
|
||||
"0.1.40": "新增 assistant/history 与 history Tool,记录最近批量动作和预设工作流执行摘要,便于外部智能体判断进度、排障和恢复上下文。",
|
||||
"0.1.39": "新增 assistant/readiness 与 readiness Tool,外部智能体可先检查版本、服务状态、活跃会话、推荐入口和启动提示,再决定是否开始执行。",
|
||||
"0.1.38": "新增 assistant/workflow 与 run_workflow Tool,外部智能体可用预设工作流短参数完成盘搜、影巢、直链和 115 状态等常见任务。",
|
||||
"0.1.37": "新增 assistant/actions 与 execute_actions Tool,外部智能体可一次提交多个 action_body 顺序执行,默认仅返回精简执行摘要,进一步减少往返和 token 消耗。",
|
||||
"0.1.36": "新增 assistant/action 与 execute_action Tool,外部智能体可直接执行 action_templates 返回的动作模板名,不必自己做动作到接口的映射。",
|
||||
"0.1.35": "统一回执与 session_state 新增 protocol_version 和 action_templates,外部智能体可直接按返回模板继续调用,不再自己拼下一步参数。",
|
||||
"0.1.34": "新增 session_id 精准恢复与 assistant 会话批量清理能力,外部智能体可按 session_id 继续,也可按过滤条件回收旧会话。",
|
||||
"0.1.33": "新增活跃会话列表 API 与原生 Tool,并将 assistant 会话整体纳入持久化恢复,便于外部智能体在断线、重启和多会话场景下继续执行。",
|
||||
"0.1.32": "统一智能入口与继续选择回执新增 session/session_state/next_actions 结构化工作流字段,外部智能体可直接按回执继续编排,进一步减少文本解析。",
|
||||
"0.1.31": "统一智能入口新增结构化参数模式与能力探测接口,外部智能体可直接传 mode/keyword/url/action 等字段,不必再拼自然语言命令。",
|
||||
"0.1.30": "新增统一智能入口会话状态/清理 API 与原生 Tool,便于外部智能体先查当前阶段、建议动作和待继续 115 任务,再决定下一步调用。",
|
||||
"0.1.29": "新增 Agent影视助手 帮助 Tool,并让统一智能入口在空输入或帮助语义下直接返回推荐用法,降低 MP 智能助手首次调用门槛。",
|
||||
"0.1.28": "新增 Agent影视助手 统一智能入口原生 Tool:smart_entry / smart_pick,MP 智能助手可直接复用飞书同款处理/选择主链。",
|
||||
"0.1.27": "更新 Agent影视助手 页面与表单文案,明确已接入 115 扫码、统一智能入口与 MP 原生 Agent Tool,避免仍显示骨架态提示。",
|
||||
"0.1.26": "补充 P115StrmHelper 插件目录自动入 path 的兜底导入逻辑,降低 115 执行层对运行态模块路径的敏感度。",
|
||||
"0.1.25": "新增 115 待处理任务标准 API:查看、继续、取消,便于飞书、CLI 与外部脚本直接调用。",
|
||||
"0.1.24": "新增 115 待处理任务原生 Agent Tool:查看、继续、取消,MP 智能助手可直接调用待处理任务能力。",
|
||||
"0.1.23": "待继续的 115 任务新增时间、重试次数与最近错误摘要,并自动清理过旧会话,避免持久化状态长期堆积。",
|
||||
"0.1.22": "待继续的 115 任务现在会持久化保存,重启后仍可用;并新增 115任务 指令可单独查看当前待处理任务。",
|
||||
"0.1.21": "新增待继续 115 任务摘要、继续115任务 与 取消115任务 指令;没有扫码会话时也可直接尝试续跑待处理任务。",
|
||||
"0.1.20": "115 转存失败时会记住当前任务;扫码成功后回复 检查115登录,可自动继续上次未完成的 115 操作。",
|
||||
"0.1.19": "115帮助 与 115状态 现在会返回可直接照抄的发送示例,登录前后分别给出更明确的下一步动作。",
|
||||
"0.1.18": "115 转存失败时新增统一状态诊断与下一步引导,影巢解锁、直链转存和智能入口都复用同一套失败提示。",
|
||||
"0.1.17": "115 状态与登录相关回执新增下一步建议,并补充 115帮助 智能入口语义。",
|
||||
"0.1.16": "新增 115状态 原生 Agent Tool 与智能入口语义,未处于登录轮询时也可直接查看当前 115 状态。",
|
||||
"0.1.15": "115 扫码成功后新增运行状态摘要,直接返回默认目录、会话来源与当前可用状态。",
|
||||
"0.1.14": "智能入口新增 115登录 / 检查115登录 语义,可直接服务飞书桥接与 MP 智能助手。",
|
||||
"0.1.13": "新增 115 扫码登录原生 Agent Tool,智能助手可直接发起二维码并轮询登录状态。",
|
||||
"0.1.12": "115 直转层新增 p115client 同款扫码登录接口与会话校验,默认不再推荐网页版 Cookie。",
|
||||
"0.1.11": "新增 115 独立直转执行层,可优先使用独立 Cookie 或已加载客户端直接转存分享链接,失败时再回退 P115StrmHelper。",
|
||||
"0.1.10": "补齐 P115StrmHelper 新版 MoviePilot 兼容补丁说明与复现脚本,115 健康检查已验证可用。",
|
||||
"0.1.9": "影巢候选会话支持分页和详情/审查按需补主演,原生 Agent Tool 与飞书 auto 后端可复用同一能力。",
|
||||
"0.1.8": "非 Premium 用户现在也可回退复用 HDHiveDailySign 的网页 Cookie 与用户快照,补齐签到和账号信息兜底。",
|
||||
"0.1.7": "补齐影巢账号、签到、配额、今日用量与每周免费额度 API,让 Agent影视助手 开始承接用户态能力。",
|
||||
"0.1.6": "新增 Agent影视助手 自己的智能入口 API,支持盘搜搜索、影巢搜索、直链路由和按编号继续执行。",
|
||||
"0.1.5": "补齐会话搜索/选择接口的统一文本输出,并在健康接口中返回插件版本,便于桥接与智能体复用。",
|
||||
"0.1.4": "夸克执行层补充缺少 Cookie 时的自动刷新尝试,原生工具与 API 路由更稳。",
|
||||
"0.1.3": "修复原生 Agent Tool 夸克分享路由参数错误,补齐 115 主链路兼容恢复。",
|
||||
"0.1.2": "新增原生 Agent Tool:影巢会话搜索、会话继续选择、通用分享链接路由。",
|
||||
"0.1.1": "打通运行时配置加载,补充候选计数,并兼容 index/choice/selection/number 选片字段。",
|
||||
"0.1.0": "首个可用版本,已接入夸克转存、115 转存、影巢搜索/解锁,以及解锁后自动路由到对应网盘执行层。"
|
||||
}
|
||||
},
|
||||
"FeishuCommandBridgeLong": {
|
||||
"name": "飞书命令桥接",
|
||||
"description": "旧飞书长连接兼容/备份入口;新用户建议优先使用 Agent影视助手 内置飞书入口。",
|
||||
"labels": "飞书,长连接,115,影巢,夸克,智能体,命令",
|
||||
"version": "0.5.26",
|
||||
"icon": "feishucommandbridgelong.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"0.5.26": "更新插件市场描述,明确本插件定位为旧飞书长连接兼容/备份入口,新用户优先使用 Agent影视助手 内置飞书入口。",
|
||||
"0.5.25": "飞书里的 115 登录、待任务与直链转存现在统一走 Agent影视助手 主线,保证失败留单、扫码续跑、取消任务都落在同一会话链里。",
|
||||
"0.5.24": "同步飞书桥接运行态版本,配合 115任务 新别名与持久化待处理任务能力发布。",
|
||||
"0.5.23": "飞书桥接新增 115任务 别名和快捷示例,方便查看当前待继续的 115 任务。",
|
||||
"0.5.22": "飞书桥接补充 继续115任务 与 取消115任务 别名和快捷示例,便于直接控制待处理 115 任务。",
|
||||
"0.5.21": "飞书快捷示例补充 115帮助 与带 path 的直链转存写法,方便直接照抄使用。",
|
||||
"0.5.20": "飞书桥接现在会直接透传 Agent影视助手 返回的 115 失败诊断,不再重复包裹错误前缀。",
|
||||
"0.5.19": "飞书桥接新增 115帮助 别名,并复用 Agent影视助手 返回的引导式 115 状态/登录回执。",
|
||||
"0.5.18": "飞书现在可直接发起 115 扫码登录并回传二维码图片,也支持回复检查115登录继续轮询 Agent影视助手 会话。",
|
||||
"0.5.17": "切到 Agent影视助手 后端时,详情/审查和 n 下一页会透传给新主线,不再退回 unsupported。",
|
||||
"0.5.16": "当切到 Agent影视助手 后端时,飞书桥接的智能入口与继续选择可整条委托给 Agent影视助手 处理,桥接层进一步变薄。",
|
||||
"0.5.15": "当切到 Agent影视助手 后端时,飞书桥接的影巢搜索/选片/解锁会话也可直接走新主线,不再只接最后一跳转存。",
|
||||
"0.5.14": "新增执行后端开关,旧桥接可继续直连快路径,也可按需切换到 Agent影视助手 新主线。",
|
||||
"0.5.13": "飞书桥接保留旧入口,但执行层优先委托 Agent影视助手,影巢/115/夸克开始走新主干。",
|
||||
"0.5.12": "详情/审查 现在只补当前页主演,并改为并发补查,减少候选较多时的等待时间。",
|
||||
"0.5.11": "影巢候选影片默认不再预查主演,首屏更快;如需补充当前候选页全部主演,可直接回复详情或审查。",
|
||||
"0.5.10": "影巢候选影片列表支持按每页 10 条分页展示,并可直接回复 n 下一页继续翻页;候选请求上限同步提高,适合蜘蛛侠这类多版本片名。",
|
||||
"0.5.9": "飞书桥接新增本地 TMDB API Key 配置,影巢候选影片现在可稳定补充 1 到 2 个主演名,且不会把密钥写进仓库。",
|
||||
"0.5.8": "影巢候选影片列表补充 1 到 2 个主演名,帮助快速区分同名作品;继续保留先选影片再看资源的两段式流程。",
|
||||
"0.5.7": "影巢搜索改为先选影片再看资源;资源列表按 115 前 6 条与夸克前 6 条分区展示,交互与盘搜保持一致。",
|
||||
"0.5.6": "精简夸克转存回执,仅保留关键结果;盘搜列表增加 115/夸克分区提示,便于快速选择。",
|
||||
"0.5.5": "盘搜搜索增加相关性过滤,并将 115 / 夸克各自展示数调整为前 6 条,减少无关结果干扰。",
|
||||
"0.5.4": "盘搜搜索改为固定展示 115 前 10 条与夸克前 10 条,统一连续编号,方便直接按序号转存。",
|
||||
"0.5.3": "新增盘搜搜索结果缓存与按编号直转 115 / 夸克,和影巢搜索保持同样的选择式落地体验。",
|
||||
"0.5.2": "支持飞书直接发送 115 / 夸克裸链接,自动识别并转存,不再需要处理前缀。",
|
||||
"0.5.1": "新增 MP搜索 / 影巢搜索 / 盘搜搜索 三种前缀入口,默认搜索保持 MP 原生搜索。",
|
||||
"0.5.0": "新增处理/选择双命令与智能体 API,统一分流夸克链接、115 链接与影巢搜索解锁流程。",
|
||||
"0.4.0": "新增夸克分享转存命令,可直接桥接 QuarkShareSaver 完成落盘。",
|
||||
"0.3.0": "新增飞书内建媒体工作流:搜索 PT 资源、按序号下载、添加订阅、订阅后立即搜索。",
|
||||
"0.2.3": "统一插件身份为 FeishuCommandBridgeLong,修复插件市场安装状态匹配。",
|
||||
"0.2.2": "支持飞书长连接、事件去重、115 手动整理回执、增量与全量 STRM 命令桥接。"
|
||||
}
|
||||
},
|
||||
"HdhiveOpenApi": {
|
||||
"name": "影巢 OpenAPI",
|
||||
"description": "通过 HDHive Open API 完成签到、关键词/TMDB 搜索、资源解锁、115 转存、分享管理与配额查询。",
|
||||
"labels": "影巢,HDHive,OpenAPI,TMDB,115,解锁,签到",
|
||||
"version": "0.3.0",
|
||||
"icon": "hdhive.ico",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"0.3.0": "支持关键词搜索、TMDB 候选解析、115 自动转存、分享管理、签到与配额查询。"
|
||||
}
|
||||
},
|
||||
"QuarkShareSaver": {
|
||||
"name": "夸克分享转存",
|
||||
"description": "把夸克分享链接直接转存到自己的夸克网盘目录,适合作为智能体和飞书的稳定执行入口。",
|
||||
"labels": "夸克,Quark,分享,转存,网盘,智能体,飞书",
|
||||
"version": "0.1.0",
|
||||
"icon": "quark.ico",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"0.1.0": "首个轻量版本,支持夸克分享解析、目录自动创建、转存执行,以及智能体和飞书调用。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
418
package.v2.json
@@ -117,13 +117,14 @@
|
||||
"name": "ChatGPT",
|
||||
"description": "消息交互支持与ChatGPT对话。",
|
||||
"labels": "消息通知,识别",
|
||||
"version": "2.1.8",
|
||||
"version": "2.1.9",
|
||||
"icon": "Chatgpt_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.1.9": "更新依赖库",
|
||||
"v2.1.8": "修复 OpenAI API >=1.0.0 兼容性问题",
|
||||
"v2.1.7":"独立安装OpenAi SDK依赖",
|
||||
"v2.1.7": "独立安装OpenAi SDK依赖",
|
||||
"v2.1.6": "支持自定义辅助识别提示词",
|
||||
"v2.1.5": "兼容一些模型返回json数据信息用markdown语法包裹的情况",
|
||||
"v2.1.4": "不处理http链接",
|
||||
@@ -139,11 +140,12 @@
|
||||
"name": "自动转移做种",
|
||||
"description": "定期转移下载器中的做种任务到另一个下载器。",
|
||||
"labels": "做种",
|
||||
"version": "1.10.2",
|
||||
"version": "1.10.3",
|
||||
"icon": "seed.png",
|
||||
"author": "jxxghp",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.10.3": "更新依赖库",
|
||||
"v1.10.2": "增加保留原标签和原分类的选项",
|
||||
"v1.10.1": "优化“立即运行一次”按钮位置",
|
||||
"v1.10": "支持跳过校验(仅支持 qBittorrent)",
|
||||
@@ -286,11 +288,12 @@
|
||||
"name": "青蛙辅种助手",
|
||||
"description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。",
|
||||
"labels": "做种",
|
||||
"version": "3.0.1",
|
||||
"version": "3.0.2",
|
||||
"icon": "qingwa.png",
|
||||
"author": "233@qingwa",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v3.0.2": "更新依赖库",
|
||||
"v3.0.1": "遗漏了一个私有属性",
|
||||
"v3.0": "兼容MoviePilot V2 版本"
|
||||
}
|
||||
@@ -453,11 +456,12 @@
|
||||
"name": "绕过Trackers",
|
||||
"description": "提供tracker服务器IP地址列表,帮助IPv6连接绕过OpenClash。",
|
||||
"labels": "工具",
|
||||
"version": "1.5.2",
|
||||
"version": "1.5.3",
|
||||
"icon": "Clash_A.png",
|
||||
"author": "wumode",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.5.3": "修复 Rousi 种子获取问题",
|
||||
"v1.5.2": "支持从站点首页获取最新 Trackers",
|
||||
"v1.5.1": "新增 Tracker",
|
||||
"v1.5.0": "新增 Page 界面; 支持通过`/check_ip` 命令检查IP; 改进 UI",
|
||||
@@ -475,11 +479,12 @@
|
||||
"name": "IMDb源",
|
||||
"description": "让探索,推荐和媒体识别支持IMDb数据源。",
|
||||
"labels": "探索",
|
||||
"version": "1.6.7",
|
||||
"version": "1.6.8",
|
||||
"icon": "IMDb_IOS-OSX_App.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.6.8": "兼容 MoviePilot v2.11.0 识别链新增 share_meta 参数,修复辅助识别模式下刮削报错",
|
||||
"v1.6.7": "优化界面显示; 增加榜单排名显示; 添加制作公司过滤项",
|
||||
"v1.6.6": "优化主页组件链接跳转",
|
||||
"v1.6.5": "仪表盘组件支持图片缓存",
|
||||
@@ -514,12 +519,16 @@
|
||||
"name": "Clash Rule Provider",
|
||||
"description": "随时为Clash添加一些额外的规则。",
|
||||
"labels": "工具",
|
||||
"version": "2.1.2",
|
||||
"version": "2.1.6",
|
||||
"icon": "Mihomo_Meta_A.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.1.6": "修复依赖冲突",
|
||||
"v2.1.5": "优化仪表盘连接鉴权;优化订阅更新提示",
|
||||
"v2.1.4": "支持 xhttp 协议",
|
||||
"v2.1.3": "修复代理删除问题",
|
||||
"v2.1.2": "修复规则集序列化错误",
|
||||
"v2.1.1": "增强数据管理功能",
|
||||
"v2.0.10": "适配 MoviePilot 2.8.4",
|
||||
@@ -534,7 +543,7 @@
|
||||
"v2.0.1": "支持独立的订阅链接配置, 覆写代理组和出站代理; 优化数据结构; 修复分享链接解析问题",
|
||||
"v1.4.2": "优化移动端 UI; 支持显示节点链接",
|
||||
"v1.4.1": "修复配置模板保存错误, 请重新配置Clash模板",
|
||||
"v1.4.0": "优化 UI; 支持连接多个 Clash Dashboards",
|
||||
"v1.4.0": "优化 UI; 支持连接多个 Clash Dashboards",
|
||||
"v1.3.3": "通过emoji识别国家; 按国家分组节点; mrs格式支持",
|
||||
"v1.3.2": "注册插件动作",
|
||||
"v1.3.1": "支持配置 Hosts",
|
||||
@@ -558,11 +567,12 @@
|
||||
"name": "美剧生词标注",
|
||||
"description": "根据CEFR等级,为英语影视剧标注高级词汇。",
|
||||
"labels": "英语",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.5",
|
||||
"icon": "LexiAnnot.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.2.5": "langchain 1.x 兼容 (主程序版本需高于 2.9.17)",
|
||||
"v1.2.4": "增强数据校验",
|
||||
"v1.2.3": "优化提示词",
|
||||
"v1.2.1": "改进字幕样式获取方法",
|
||||
@@ -627,5 +637,395 @@
|
||||
"history": {
|
||||
"v2.0.0": "首次发布:可配置报告模块、历史记录页面、下载器通用化、存储路径可配置"
|
||||
}
|
||||
},
|
||||
"TvFirstWatch": {
|
||||
"name": "首播试看",
|
||||
"description": "定时抓取RSS,只下载电视剧前N集,自动跳过合集和过大文件。",
|
||||
"labels": "订阅,RSS",
|
||||
"version": "1.0.0",
|
||||
"icon": "rss.png",
|
||||
"author": "Raymond38324",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.0.0": "首次发布"
|
||||
}
|
||||
},
|
||||
"WechatClawBot": {
|
||||
"name": "WechatClawBot消息推送",
|
||||
"description": "支持使用微信(通过ClawBot)发送消息通知。",
|
||||
"labels": "消息通知,微信",
|
||||
"version": "0.2.1",
|
||||
"icon": "Wechat_A.png",
|
||||
"author": "mijjjj",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v0.2.1": "修复详情页状态信息换行显示问题",
|
||||
"v0.2.0": "优化配置页UI布局,修复回复消息携带多余类型前缀的问题",
|
||||
"v0.1.0": "初始版本"
|
||||
}
|
||||
},
|
||||
"AIRecognizerEnhancer": {
|
||||
"name": "AI识别增强",
|
||||
"description": "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。",
|
||||
"labels": "AI,识别,LLM,本地兜底,MoviePilot,TMDB",
|
||||
"version": "0.1.12",
|
||||
"icon": "airecognizerenhancer.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"0.1.12": "兼容 MoviePilot 最新 LLM 路径与异步 get_llm 接口,修复最新版 MP 下插件加载失败问题。",
|
||||
"0.1.11": "同步运行态版本,保持本地结构化识别、失败样本闭环和识别词建议能力一致。",
|
||||
"0.1.10": "新增识别词建议模型退化时的精确规则兜底,保证批量建议/批量写入在上游异常时仍能尽量落地。",
|
||||
"0.1.9": "新增失败样本精简摘要接口,并让批量建议/批量写入附带低 token 文本摘要,便于智能体直接消费。",
|
||||
"0.1.8": "新增失败样本批量建议与批量写入接口,可一次处理一批失败样本,进一步减少人工逐条操作。",
|
||||
"0.1.7": "新增失败样本批量复查接口,可批量重跑样本并在确认修复后批量出队。",
|
||||
"0.1.6": "新增失败样本复查接口,可按当前识别词与当前识别器重跑样本,并在确认修复后自动出队。",
|
||||
"0.1.5": "新增失败样本出队动作,支持按索引移除单条样本,并在写入识别词后自动移除已处理样本。",
|
||||
"0.1.4": "新增失败样本洞察接口,自动归纳重复问题、失败原因和优先处理样本,帮助更快挑出值得写识别词的样本。",
|
||||
"0.1.3": "新增失败样本摘要、样本清理、样本去重和保留上限控制,让样本工作流更适合长期运行与智能体使用。",
|
||||
"0.1.2": "新增按失败样本直接生成建议和直接写入规则的快捷 API,进一步缩短从失败样本到 CustomIdentifiers 的闭环。",
|
||||
"0.1.1": "新增失败样本查看、自定义识别词建议和一键追加写入能力,让 AI 识别增强开始和 MoviePilot 原生 CustomIdentifiers 闭环联动。",
|
||||
"0.1.0": "首个可用版本,复用 MoviePilot 当前 LLM 配置,在原生识别失败后通过 Chain NameRecognize 做本地结构化兜底。"
|
||||
}
|
||||
},
|
||||
"AgentResourceOfficer": {
|
||||
"name": "Agent影视助手",
|
||||
"description": "龙虾agent稳定控制 MP:飞书入口、盘搜/影巢搜索、115/夸克转存、智能评分推荐。",
|
||||
"labels": "Agent,影巢,HDHive,115,夸克,Quark,智能体,转存,解锁",
|
||||
"version": "0.2.72",
|
||||
"icon": "agentresourceofficer.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"0.2.72": "影巢自动登录兜底流程改用 CloakBrowser,移除插件对 Playwright 浏览器调用的直接依赖。",
|
||||
"0.2.71": "新增流媒体推荐:聚合 Netflix、Disney+、Apple TV+、Prime Video 四大平台,基于 TMDB discover 按热度/评分推荐本月上新、近期热门电影和剧集;结果页改为只读列表,仅支持显式前缀触发。",
|
||||
"0.2.70": "最后一轮主线收口:取消标题级云盘转存/云盘搜索入口,统一保留前缀搜索与编号续接;修复 PT 指定集/最新集筛选、下载路径透传、分页与旧别名拦截,并同步外部智能体 Skill/命令文档。",
|
||||
"0.2.69": "修复外部智能体跨机器接入暴露的问题:补齐 115 直转依赖,Cookie 修复改为通过远端 MoviePilot API 安全写回,安装 Skill 时自动准备浏览器 Cookie 工具依赖,并增强 PanSou 跨机提示与 MP 推荐空结果回退。",
|
||||
"0.2.68": "收口云盘搜索/转存/影巢签到恢复链:固定“转存/下载/云盘搜索/更新检查”口径,补齐 115/夸克默认目录清理、影巢立即签到与 Cookie 一键修复命令,并同步主页与 Skill 文档。",
|
||||
"0.2.67": "收口外部智能体入口细节:隐藏 workbuddy_quickstart 旧 recipe 展示名,为 external-agent / commands 增加 deprecated alias 语义,并统一当前状态文档。",
|
||||
"0.2.66": "为 request_templates 增加三类入口的 entry_playbooks,直接给出 helper 命令、HTTP 端点、Tool 名称和推荐读取字段,进一步减少外部智能体与 MP 内置智能体的接入编排逻辑。",
|
||||
"0.2.65": "为 request_templates 和 helper 增加模板编排元数据,明确服务端/客户端角色、三类入口范式,以及 startup -> decide -> route -> followup 的推荐最小执行流。",
|
||||
"0.2.64": "把外部智能体执行契约与最小执行循环下沉到 request_templates 返回;新接入的智能体现在可以直接从模板元数据拿到 startup -> decide -> route -> policy -> followup 脚手架。",
|
||||
"0.2.63": "为 compact 顶层短命令增加执行语义字段:command_policy、preferred_requires_confirmation、fallback_requires_confirmation、can_auto_run_preferred;外部智能体现在可以机械判断 直接读 还是 先确认再写。",
|
||||
"0.2.62": "把 error_summary、followup_summary、score_summary.decision 三层短命令继续上浮到 compact 主响应顶层;外部智能体现在只读 preferred_command / compact_commands 和 command_source 就能续跑。",
|
||||
"0.2.61": "为 compact 失败回执增加统一 error_summary;外部智能体现在可以直接读取失败标签、建议说明,以及 preferred_command / compact_commands 这样的最短恢复命令。",
|
||||
"0.2.60": "为 score_summary.decision 和 followup_summary 增加 preferred_command、fallback_command 与 compact_commands;mp_recent_activity 也补齐 followup_summary,外部智能体可直接读取最短下一步命令。",
|
||||
"0.2.59": "新增统一 跟进 入口;有已执行计划时自动追执行后状态,有片名时直接查生命周期,否则退回最近活动,外部智能体只保留一个短入口也能续接。",
|
||||
"0.2.58": "压缩本地/PT 高跟踪入口;新增 后续、状态、记录、入库、诊断、最近 等短命令,并让推荐命令优先吐这套更省 token 的自然语言写法。",
|
||||
"0.2.57": "把写入动作后的追踪提示下沉为统一 followup_summary;执行计划、统一后续追踪和本地/PT 诊断现在都会返回稳定的后续标签、建议说明和推荐命令。",
|
||||
"0.2.56": "把评分后的确认提示下沉为统一 decision 摘要,score_summary 现在会稳定返回决策标签、建议说明和推荐命令,便于飞书、外部智能体和 MP 内置入口共用同一套下一步提示。",
|
||||
"0.2.55": "新增插件级智能体默认评分策略设置,允许统一配置 PT 最低做种数、建议确认分数线、自动入库分数线与默认自动化开关;新会话默认偏好与评分策略公开数据现在统一读取这些值。",
|
||||
"0.2.54": "新增 preferences_onboarding 模板组、评分策略自然语言只读入口与 helper 命令;补齐偏好/评分 smoke 覆盖,并修正能力摘要里的 auto_ingest 默认值。",
|
||||
"0.2.53": "新增本地/PT 入库诊断主线;补齐 mp_ingest_status、mp_ingest_failures、mp_recent_activity、mp_local_diagnose,并让生命周期/执行后追踪统一返回 diagnosis_summary。",
|
||||
"0.2.52": "调整 recover 优先级:当前会话最近一条计划已执行时,恢复入口会优先推荐 query_execution_followup,而不是退回会话检查或新任务。",
|
||||
"0.2.51": "把 execution_followup 下沉为正式 request template 和 followup recipe,外部智能体可以通过低 token 模板直接续接执行后追踪。",
|
||||
"0.2.50": "新增 query_execution_followup 统一只读入口,并补齐 assistant/action compact 的 error_code、recommended_action 和 follow_up_hint,方便外部智能体一跳续接执行后追踪。",
|
||||
"0.2.49": "新增 query_execution_followup 统一只读入口,外部智能体可按最近已执行计划自动追踪下载、订阅或入库后续状态。",
|
||||
"0.2.48": "把 recommended_action 和 follow_up_hint 下沉到 plan_execute 原始 data 与用户可读消息里,非 compact 调用也能直接续接下一步。",
|
||||
"0.2.47": "在 execute_plan compact 结果中补充 recommended_action 和 follow_up_hint,让外部智能体执行计划后能直接读取建议下一步。",
|
||||
"0.2.46": "把 execute_plan 的 follow-up 样本加入 selfcheck,并纳入 live smoke 回归,避免 PT 下载、订阅与云盘转存的后续动作模板回退。",
|
||||
"0.2.45": "执行 plan_id 成功后,按 PT 下载、订阅或云盘转存 workflow 返回更明确的后续动作模板,方便外部智能体继续追踪状态。",
|
||||
"0.2.44": "统一 assistant/plan/execute 的 compact 回执;失败态和执行态现在都会返回稳定的 write_effect、error_code、result_summary 与结果列表摘要,方便外部智能体续接。",
|
||||
"0.2.43": "调整 recover 优先级为业务续接优先于偏好初始化;已有 PT/云盘会话时,恢复入口会先推荐继续当前任务。",
|
||||
"0.2.42": "补齐 compact session/recover 协议里的 action_templates;外部智能体读取会话状态或恢复入口时,也能拿到完整的结构化下一步模板。",
|
||||
"0.2.41": "补齐 PT 只读会话的 action_templates;下载任务、站点、下载器、订阅列表等场景现在会给外部智能体正确的结构化下一步模板。",
|
||||
"0.2.40": "收紧 PT 只读会话的下一步建议;下载任务、站点、下载器、订阅列表等场景不再给出误导性的控制动作提示。",
|
||||
"0.2.39": "修复 workflow/tool 直调下的控制计划安全;空下载任务或空订阅列表时,不再为 mp_download_control / mp_subscribe_control 生成无效 plan_id。",
|
||||
"0.2.38": "修复空订阅列表下的订阅控制安全;自然语言编号必须命中当前会话列表,避免把“搜索订阅 1”误写成订阅 ID=1 的计划。",
|
||||
"0.2.37": "新增 mp_pt_mainline 与 mp_recommendation 请求模板 recipe,外部智能体可低 token 拉取 MP 原生 PT 主线与推荐主线模板,不再猜 workflow body。",
|
||||
"0.2.36": "优化评分展示文案;硬性阻断显示为硬风险,普通偏好未命中显示为提醒,避免智能体把软提醒误判为不可用。",
|
||||
"0.2.35": "修正 MP 推荐回退过滤;热门电影、热门电视剧 在回退到 tmdb_trending 时仍保留电影/电视剧类型,不再混入另一类结果。",
|
||||
"0.2.34": "修正 MP 原生搜索结果的下载提示;明确下载资源 序号会先生成下载计划,不会静默下载。",
|
||||
"0.2.33": "统一 MP 原生命令前缀解析;下载历史蜘蛛侠、追踪蜘蛛侠、入库失败蜘蛛侠、暂停订阅1 等无空格/冒号写法不再误落到资源搜索。",
|
||||
"0.2.32": "修复订阅列表自然语言解析;订阅列表 蜘蛛侠、订阅列表:蜘蛛侠、订阅列表蜘蛛侠 现在稳定走只读查询,不会被通用订阅写入计划覆盖。",
|
||||
"0.2.31": "收紧 compact 协议中的评分摘要返回;普通站点、下载器、任务诊断不再继承上一轮搜索的 score_summary,避免外部智能体误读上下文。",
|
||||
"0.2.30": "细化评分风险结构:hard_risk_reasons 表示真正阻断自动化的风险,risk_reasons 保留为确认前提醒,避免软提醒被误算为阻断。",
|
||||
"0.2.29": "收口 MP 原生 PT 主线:补齐做种/热度/字幕/站点等评分理由,下载/订阅/控制统一走 plan_id 确认链路,并强化 MP 原生推荐续接。",
|
||||
"0.2.28": "插件展示名统一改为 Agent影视助手,并同步仓库文档、Skill 文案和兼容插件引用。",
|
||||
"0.2.27": "优化盘搜和影巢资源列表的下一步提示;默认引导外部智能体先生成计划,再确认执行。",
|
||||
"0.2.26": "新增云盘写入计划入口;盘搜和影巢资源可用“计划选择 1”先生成 plan_id,再确认执行。",
|
||||
"0.2.25": "修复云盘会话最佳/详情选择安全;盘搜和影巢资源阶段的“最佳片源”只展示详情,不会误选最后一条执行。",
|
||||
"0.2.24": "补齐 PT 下载自动化闭环;仅在用户开启自动入库且评分达标、无硬风险时,下载选择和下载最佳才会直接提交。",
|
||||
"0.2.23": "新增偏好画像自然语言入口;可用“偏好”“保存偏好 ...”“重置偏好”查看、保存或重置智能体片源偏好。",
|
||||
"0.2.22": "新增计划确认自然语言入口;可用“执行计划”或“执行 plan-xxx”确认执行已生成的下载、订阅或控制计划。",
|
||||
"0.2.21": "新增“下载最佳”入口;在 MP 搜索会话中按最高评分 PT 候选生成下载计划,仍需用户确认 plan_id 后才会下载。",
|
||||
"0.2.20": "新增 MP 搜索最佳候选详情入口;智能体可用“最佳片源”或 mp_search_best 直接查看当前评分最高 PT 候选。",
|
||||
"0.2.19": "新增 MP 搜索结果详情入口;MP 搜索后“选择 1”会先展示 PT 详情、评分理由和风险,再由用户确认是否下载。",
|
||||
"0.2.18": "新增 MP 原生媒体识别详情入口;智能体可用“识别 片名”或 mp_media_detail 工作流确认 TMDB/Douban/IMDB 信息后再搜索、下载或订阅。",
|
||||
"0.2.17": "新增 MP 生命周期追踪聚合入口;智能体可用“追踪 片名”一次查看下载任务、下载历史和整理/入库历史。",
|
||||
"0.2.16": "新增 MP 下载历史查询,并按 hash 关联整理/入库状态;智能体可用“下载历史 片名”追踪资源是否已提交下载和是否落库。",
|
||||
"0.2.15": "新增 MP 整理/入库历史查询;智能体可用“入库历史”“入库失败 片名”判断下载后是否已落库,接口只返回脱敏摘要。",
|
||||
"0.2.14": "新增 MP 订阅列表查询与订阅控制计划;智能体可查看订阅规则,并对搜索、暂停、恢复、删除订阅生成 plan_id 后确认执行。",
|
||||
"0.2.13": "新增 MP 下载器与 PT 站点环境诊断入口;只返回启用状态、优先级、绑定下载器和 Cookie 是否存在,不暴露 Cookie 明文。",
|
||||
"0.2.12": "补齐 MP 原生下载任务查询与任务控制入口;智能体可查看下载中任务,并对暂停、恢复、删除生成 plan_id 后确认执行。",
|
||||
"0.2.11": "MP 下载/订阅命令支持无空格自然写法,例如“下载1”“下载第1个”“订阅蜘蛛侠”“订阅并搜索蜘蛛侠”;自然语言写入默认生成 plan_id,确认后才执行。",
|
||||
"0.2.10": "推荐列表选择支持自然语言指定后续来源,例如“选择 1 盘搜”“选择1影巢”“选 2 mp”,飞书与智能体可不用结构化 mode 参数。",
|
||||
"0.2.09": "热门推荐入口支持自然语言别名,例如“看看最近有什么热门影视”“豆瓣热门电影”“正在热映”“今日番剧”,智能体和飞书可直接用人话触发 MP 推荐。",
|
||||
"0.2.08": "MP 热门推荐列表支持保存会话并按编号继续搜索,智能体可把推荐条目直接转入 MP 原生搜索、影巢或盘搜。",
|
||||
"0.2.07": "影巢搜索默认使用自动媒体类型识别,未指定电影/剧集时不再提前按电影过滤,修复新剧搜索被误判无结果的问题。",
|
||||
"0.2.06": "新增 scoring_policy 能力,结构化暴露插件内置云盘/PT 评分规则与硬门槛,方便智能体解释但不重打分。",
|
||||
"0.2.05": "新增低 token score_summary,帮助智能体直接读取云盘和 PT 评分推荐、风险与确认建议。",
|
||||
"0.2.04": "增强智能体偏好引导协议,主响应返回低 token preference_status,并在未初始化时优先提示保存偏好。",
|
||||
"0.2.03": "新增智能体偏好画像、云盘/PT 分源评分、MP 原生搜索下载订阅推荐工作流,并让写入动作优先生成 plan_id。",
|
||||
"0.2.02": "新增影巢资源搜索/解锁总开关与单资源积分上限,降低外部智能体误解锁高积分资源的风险。",
|
||||
"0.2.01": "移除 get_state 中的主动 Agent Tool 重载,避免插件状态轮询时反复打印工具加载日志。",
|
||||
"0.1.119": "新增本插件内置影巢签到日志,可通过 API、飞书或智能体查看最近签到、自动刷新 Cookie 和失败原因。",
|
||||
"0.1.118": "本插件内置影巢 Cookie 自动刷新:签到兜底失败时可使用账号密码自动登录、保存新 Cookie 并重试。",
|
||||
"0.1.117": "影巢签到收口到本插件:新增定时签到配置、默认赌狗模式、网页 Cookie 兜底和智能入口签到命令。",
|
||||
"0.1.116": "新增 workbuddy_quickstart 请求模板和 route_text 模板,方便 WorkBuddy、微信侧智能体复现标准接入口。",
|
||||
"0.1.115": "assistant/route 支持 MP搜索、原生搜索、搜索资源、搜索 前缀,统一外部智能体与飞书入口的原生 MP 搜索用法。",
|
||||
"0.1.114": "飞书冲突检测会结合旧桥接配置、health 和 get_state,避免把已禁用但仍加载的旧插件误判为冲突。",
|
||||
"0.1.113": "飞书健康检查补充 ready_to_start、safe_to_enable、缺失项和迁移建议,方便判断是否能从旧桥接迁移。",
|
||||
"0.1.112": "修正 assistant/startup 在无可恢复会话时仍推荐 continue 的问题,避免外部智能体被空会话误导。",
|
||||
"0.1.111": "飞书配置页补充回复 ID 类型和命令白名单,便于从旧飞书桥接完整迁移。",
|
||||
"0.1.110": "飞书健康检查新增旧桥接运行状态和冲突提示,避免双飞书入口抢消息。",
|
||||
"0.1.109": "新增 MP 原生 Tool agent_resource_officer_feishu_health,支持内置智能助手检查飞书入口状态。",
|
||||
"0.1.108": "内置可选飞书入口 Channel,并为 assistant 回执补充 write_effect/error_code 标准字段。",
|
||||
"0.1.107": "assistant/startup 会根据恢复状态动态推荐 bootstrap 或 continue 模板流程。",
|
||||
"0.1.106": "assistant/startup 会带 recommended_request_templates,外部智能体启动后可直接按推荐参数拉取低 token 模板流程。",
|
||||
"0.1.105": "assistant/request_templates 的文本摘要会直接显示推荐流程、首步调用和确认提示,方便低 token 场景直接阅读。",
|
||||
"0.1.104": "recommended_recipe_detail 会带 first_confirmation_template 和 confirmation_message,方便外部智能体在写入前提示用户确认。",
|
||||
"0.1.103": "recipe= 支持 plan、maintain、continue、bootstrap 等短别名,回执会带 requested_recipe、selected_recipe 和 recipe_aliases。",
|
||||
"0.1.102": "assistant/request_templates 支持 recipe= 参数,可直接按 safe_bootstrap、plan_then_confirm、continue_existing_session 或 maintenance_cycle 拉取整套推荐流程。",
|
||||
"0.1.101": "推荐调用会带 url_template,外部智能体可用 {base_url} 和 {MP_API_TOKEN} 直接拼出 HTTP 调用地址。",
|
||||
"0.1.100": "assistant/request_templates 与推荐调用会明确给出 auth.mode=query_apikey,避免外部智能体误用 Bearer 鉴权。",
|
||||
"0.1.99": "recommended_recipe_detail 会带完整 calls 列表,外部智能体可按推荐流程逐步执行。",
|
||||
"0.1.98": "recommended_recipe_detail 会带 first_call,直接给出首个模板的 HTTP 调用和 MP Tool 调用参数,外部智能体可直接执行第一步。",
|
||||
"0.1.97": "assistant/request_templates 回执会带 recommended_recipe_detail,直接给出推荐流程的首个模板、确认模板和写入模板,外部智能体可直接照此编排。",
|
||||
"0.1.96": "assistant/request_templates 回执会直接给出 recommended_recipe 与 recommended_recipe_reason,外部智能体不必再自己挑选最适合的 recipe。",
|
||||
"0.1.95": "recipes 会直接带 requires_confirmation、has_write_effect 和最小 cache_ttl_seconds,自检也会验证这些汇总特征。",
|
||||
"0.1.94": "assistant/request_templates 回执会带场景化 recipes,外部智能体可直接选择安全启动、先计划后执行、继续既有会话等预设流程。",
|
||||
"0.1.93": "assistant/request_templates 回执会带 recommended_sequence,直接给出推荐调用顺序,外部智能体可以少做一层启动编排。",
|
||||
"0.1.92": "request_templates 每个模板都会带 cache_scope 和 cache_ttl_seconds,execution_policy 也会汇总 cacheable_templates 与 non_cacheable_templates,方便外部智能体决定缓存策略。",
|
||||
"0.1.91": "assistant/request_templates 支持 include_templates=false,可只返回模板名、无效项和执行策略,进一步减少 token。",
|
||||
"0.1.90": "请求模板协议增加 schema_version=request_templates.v1,startup/toolbox 也携带 request_templates_schema_version,方便外部智能体做兼容判断。",
|
||||
"0.1.89": "assistant/request_templates 回执会带 execution_policy 汇总,直接列出可免确认执行、需要确认执行和存在写入副作用的模板名。",
|
||||
"0.1.88": "request_templates 每个模板都会带 side_effect 和 requires_confirmation,外部智能体可区分只读、dry-run、计划写入和真实执行动作。",
|
||||
"0.1.87": "request_templates 每个模板都会带 description,外部智能体可以直接判断模板用途,减少额外解释和 token 消耗。",
|
||||
"0.1.86": "request_templates 每个模板都会带 tool_args,区分 HTTP 参数和 MP Tool 参数,避免外部智能体误用 body/query。",
|
||||
"0.1.85": "request_templates 每个模板都会带对应的 MP 原生 tool 名,外部智能体可在 HTTP 调用和 MP Tool 调用之间直接切换。",
|
||||
"0.1.84": "assistant/request_templates 支持 POST JSON body 传入 names/limit,方便结构化智能体直接用 body 请求过滤模板。",
|
||||
"0.1.83": "assistant/startup 的核心 tools/endpoints 和 capabilities compact 推荐启动列表显式包含请求模板入口,外部智能体只读启动包也能发现模板能力。",
|
||||
"0.1.82": "assistant/request_templates 支持 names/name/template 过滤,只返回指定模板,并回传 selected_names 与 invalid_names;原生 Tool 同步支持 names 参数。",
|
||||
"0.1.81": "新增 assistant/request_templates 只读入口和 agent_resource_officer_request_templates 原生 Tool,外部智能体可只拉请求模板而不拉完整启动包。",
|
||||
"0.1.80": "assistant/startup 与 assistant/toolbox 直接返回统一 request_templates,并由 assistant/selfcheck 检查模板齐全性,方便外部智能体按模板调用。",
|
||||
"0.1.79": "assistant/startup.maintenance 直接返回 safe_to_execute、execute_method、dry_run_method、execute_endpoint 和 execute_body,外部智能体无需猜维护调用方式。",
|
||||
"0.1.78": "assistant/maintain 在 POST 执行维护后写入 assistant/history,方便外部智能体审计维护动作;GET dry-run 仍不写历史。",
|
||||
"0.1.77": "assistant/selfcheck 新增 maintain dry-run 和维护模板 compact 检查,确保维护协议本身也纳入健康检查。",
|
||||
"0.1.76": "assistant/maintain 的 GET 请求固定为 dry-run,即使带 execute=true 也不会执行清理;只有 POST execute=true 才会实际维护。",
|
||||
"0.1.75": "assistant/capabilities 增加 assistant_maintain 字段说明,并把 assistant/maintain 纳入 compact endpoint 和推荐启动链路。",
|
||||
"0.1.74": "assistant/selfcheck 新增 maintain endpoint 和 maintain Tool 检查,确保维护入口已正确纳入外部智能体工具清单。",
|
||||
"0.1.73": "新增 assistant/maintain 与 agent_resource_officer_maintain,支持 dry-run 查看低风险维护建议,也支持 execute=true 执行过期会话和已执行计划清理。",
|
||||
"0.1.72": "assistant/startup.maintenance 增加 stale_sessions、saved_plans_executed 和 recommended_actions,外部智能体可直接判断是否值得做低风险维护清理。",
|
||||
"0.1.71": "assistant/plans compact 回执中 total 改为当前过滤命中数,并补充 total_all,避免外部智能体把全部计划数误判为待执行计划数。",
|
||||
"0.1.70": "assistant/startup.maintenance 增加低风险清理模板:清理过期会话、清理已执行计划;不会自动清理待执行计划。",
|
||||
"0.1.69": "assistant/startup 增加 maintenance 计数,直接返回活跃会话、保存计划和待执行计划数量,便于外部智能体判断恢复或清理。",
|
||||
"0.1.68": "assistant/startup 直接携带恢复用 session、session_id 和 action_templates,外部智能体可拿启动包直接执行推荐恢复动作。",
|
||||
"0.1.67": "新增 assistant/startup 与 agent_resource_officer_startup,一次返回启动状态、自检结果、核心工具、端点、默认目录和恢复建议,减少外部智能体开场多次探测。",
|
||||
"0.1.66": "assistant/pulse 和 compact assistant/capabilities 推荐启动链路加入 assistant/selfcheck,便于外部智能体开场自检协议健康。",
|
||||
"0.1.65": "新增 agent_resource_officer_selfcheck 原生 Tool,让 MP 智能助手可直接执行 Agent影视助手 compact 协议自检。",
|
||||
"0.1.64": "新增 assistant/selfcheck 轻量协议自检,快速确认 compact 模板、布尔解析和基础协议字段是否健康。",
|
||||
"0.1.63": "统一 dry_run、stop_on_error、include_raw_results、prefer_unexecuted、all_plans、stale_only、all_sessions、execute 等 POST 布尔字段解析,避免字符串 false/0/off 被误判。",
|
||||
"0.1.62": "统一 POST JSON compact 参数的布尔解析,避免外部智能体传入字符串 false/0/off 时被误判为开启精简回执。",
|
||||
"0.1.61": "action_templates 默认为支持精简回执的 assistant 端点注入 compact=true,外部智能体原样回放模板即可保持低 token。",
|
||||
"0.1.60": "assistant/route 与 assistant/pick 新增 compact=true 低 token 回执,减少智能入口搜索、选择、翻页和落盘主链路的嵌套负载。",
|
||||
"0.1.59": "assistant/action 新增 compact=true 低 token 回执,外部智能体原样回放 action_template 时可直接获取单动作摘要。",
|
||||
"0.1.58": "assistant/capabilities 与 assistant/readiness 新增 compact=true 低 token 回执,减少外部智能体启动阶段的能力发现和就绪检查负载。",
|
||||
"0.1.57": "assistant/actions、assistant/workflow 与 assistant/plan/execute 新增 compact=true 低 token 回执,减少批量执行、工作流计划和计划执行链路的嵌套负载。",
|
||||
"0.1.56": "assistant/history 与 assistant/plans 新增 compact=true 低 token 回执,便于外部智能体低成本查看执行历史和保存计划。",
|
||||
"0.1.55": "assistant/session 与 assistant/sessions 新增 compact=true 低 token 回执,减少外部智能体查看会话状态时的嵌套负载。",
|
||||
"0.1.54": "新增 assistant/toolbox 与 agent_resource_officer_toolbox 轻量工具清单,便于外部智能体低 token 获取端点、工具、工作流和命令示例。",
|
||||
"0.1.53": "新增 assistant/pulse 与 agent_resource_officer_pulse 轻量启动探针,返回版本、关键服务状态、警告和最佳恢复建议。",
|
||||
"0.1.52": "assistant/recover 新增 compact=true 低 token 回执,agent_resource_officer_recover 默认使用精简恢复信息,适合外部智能体高频轮询。",
|
||||
"0.1.51": "新增 assistant/recover 与 agent_resource_officer_recover 单入口恢复能力,可自动选择最值得恢复的会话或计划,并支持 execute=true 直接续跑。",
|
||||
"0.1.50": "assistant/session 与 assistant/sessions 统一到标准回执包裹字段,同时保留兼容摘要字段,降低外部智能体分支判断。",
|
||||
"0.1.49": "新增统一 recovery 字段,并让 assistant/action 支持 execute_session_latest_plan,外部智能体可按恢复协议直接续跑。",
|
||||
"0.1.48": "assistant/sessions 现在也会显示只有 dry_run 计划、尚未生成会话缓存的 session,便于从会话列表直接恢复。",
|
||||
"0.1.47": "assistant/sessions 新增待执行计划摘要与 execute_session_latest_plan 模板,外部智能体可从会话列表直接恢复计划。",
|
||||
"0.1.46": "assistant/action 新增 execute_latest_plan 与 execute_plan 动作,action_templates.action_body 可原样回传执行计划。",
|
||||
"0.1.45": "session_state 与 readiness 新增计划恢复动作模板,外部智能体可直接复用 execute_latest_plan 执行待处理计划。",
|
||||
"0.1.44": "assistant/plan/execute 现可按 session/session_id 自动恢复并执行最近计划,进一步减少外部智能体对 plan_id 的依赖。",
|
||||
"0.1.43": "新增 assistant/plans 与 assistant/plans/clear 计划管理入口,外部智能体可查询、恢复和清理 dry_run 保存计划。",
|
||||
"0.1.42": "dry_run 工作流计划新增 plan_id 持久化与 assistant/plan/execute 执行入口,外部智能体可先生成计划再按 plan_id 执行。",
|
||||
"0.1.41": "预设工作流新增 dry_run 计划模式,外部智能体可先生成步骤计划和可执行请求体,确认后再实际执行,降低误操作风险。",
|
||||
"0.1.40": "新增 assistant/history 与 history Tool,记录最近批量动作和预设工作流执行摘要,便于外部智能体判断进度、排障和恢复上下文。",
|
||||
"0.1.39": "新增 assistant/readiness 与 readiness Tool,外部智能体可先检查版本、服务状态、活跃会话、推荐入口和启动提示,再决定是否开始执行。",
|
||||
"0.1.38": "新增 assistant/workflow 与 run_workflow Tool,外部智能体可用预设工作流短参数完成盘搜、影巢、直链和 115 状态等常见任务。",
|
||||
"0.1.37": "新增 assistant/actions 与 execute_actions Tool,外部智能体可一次提交多个 action_body 顺序执行,默认仅返回精简执行摘要,进一步减少往返和 token 消耗。",
|
||||
"0.1.36": "新增 assistant/action 与 execute_action Tool,外部智能体可直接执行 action_templates 返回的动作模板名,不必自己做动作到接口的映射。",
|
||||
"0.1.35": "统一回执与 session_state 新增 protocol_version 和 action_templates,外部智能体可直接按返回模板继续调用,不再自己拼下一步参数。",
|
||||
"0.1.34": "新增 session_id 精准恢复与 assistant 会话批量清理能力,外部智能体可按 session_id 继续,也可按过滤条件回收旧会话。",
|
||||
"0.1.33": "新增活跃会话列表 API 与原生 Tool,并将 assistant 会话整体纳入持久化恢复,便于外部智能体在断线、重启和多会话场景下继续执行。",
|
||||
"0.1.32": "统一智能入口与继续选择回执新增 session/session_state/next_actions 结构化工作流字段,外部智能体可直接按回执继续编排,进一步减少文本解析。",
|
||||
"0.1.31": "统一智能入口新增结构化参数模式与能力探测接口,外部智能体可直接传 mode/keyword/url/action 等字段,不必再拼自然语言命令。",
|
||||
"0.1.30": "新增统一智能入口会话状态/清理 API 与原生 Tool,便于外部智能体先查当前阶段、建议动作和待继续 115 任务,再决定下一步调用。",
|
||||
"0.1.29": "新增 Agent影视助手 帮助 Tool,并让统一智能入口在空输入或帮助语义下直接返回推荐用法,降低 MP 智能助手首次调用门槛。",
|
||||
"0.1.28": "新增 Agent影视助手 统一智能入口原生 Tool:smart_entry / smart_pick,MP 智能助手可直接复用飞书同款处理/选择主链。",
|
||||
"0.1.27": "更新 Agent影视助手 页面与表单文案,明确已接入 115 扫码、统一智能入口与 MP 原生 Agent Tool,避免仍显示骨架态提示。",
|
||||
"0.1.26": "补充 P115StrmHelper 插件目录自动入 path 的兜底导入逻辑,降低 115 执行层对运行态模块路径的敏感度。",
|
||||
"0.1.25": "新增 115 待处理任务标准 API:查看、继续、取消,便于飞书、CLI 与外部脚本直接调用。",
|
||||
"0.1.24": "新增 115 待处理任务原生 Agent Tool:查看、继续、取消,MP 智能助手可直接调用待处理任务能力。",
|
||||
"0.1.23": "待继续的 115 任务新增时间、重试次数与最近错误摘要,并自动清理过旧会话,避免持久化状态长期堆积。",
|
||||
"0.1.22": "待继续的 115 任务现在会持久化保存,重启后仍可用;并新增 115任务 指令可单独查看当前待处理任务。",
|
||||
"0.1.21": "新增待继续 115 任务摘要、继续115任务 与 取消115任务 指令;没有扫码会话时也可直接尝试续跑待处理任务。",
|
||||
"0.1.20": "115 转存失败时会记住当前任务;扫码成功后回复 检查115登录,可自动继续上次未完成的 115 操作。",
|
||||
"0.1.19": "115帮助 与 115状态 现在会返回可直接照抄的发送示例,登录前后分别给出更明确的下一步动作。",
|
||||
"0.1.18": "115 转存失败时新增统一状态诊断与下一步引导,影巢解锁、直链转存和智能入口都复用同一套失败提示。",
|
||||
"0.1.17": "115 状态与登录相关回执新增下一步建议,并补充 115帮助 智能入口语义。",
|
||||
"0.1.16": "新增 115状态 原生 Agent Tool 与智能入口语义,未处于登录轮询时也可直接查看当前 115 状态。",
|
||||
"0.1.15": "115 扫码成功后新增运行状态摘要,直接返回默认目录、会话来源与当前可用状态。",
|
||||
"0.1.14": "智能入口新增 115登录 / 检查115登录 语义,可直接服务飞书桥接与 MP 智能助手。",
|
||||
"0.1.13": "新增 115 扫码登录原生 Agent Tool,智能助手可直接发起二维码并轮询登录状态。",
|
||||
"0.1.12": "115 直转层新增 p115client 同款扫码登录接口与会话校验,默认不再推荐网页版 Cookie。",
|
||||
"0.1.11": "新增 115 独立直转执行层,可优先使用独立 Cookie 或已加载客户端直接转存分享链接,失败时再回退 P115StrmHelper。",
|
||||
"0.1.10": "补齐 P115StrmHelper 新版 MoviePilot 兼容补丁说明与复现脚本,115 健康检查已验证可用。",
|
||||
"0.1.9": "影巢候选会话支持分页和详情/审查按需补主演,原生 Agent Tool 与飞书 auto 后端可复用同一能力。",
|
||||
"0.1.8": "非 Premium 用户现在也可回退复用 HDHiveDailySign 的网页 Cookie 与用户快照,补齐签到和账号信息兜底。",
|
||||
"0.1.7": "补齐影巢账号、签到、配额、今日用量与每周免费额度 API,让 Agent影视助手 开始承接用户态能力。",
|
||||
"0.1.6": "新增 Agent影视助手 自己的智能入口 API,支持盘搜搜索、影巢搜索、直链路由和按编号继续执行。",
|
||||
"0.1.5": "补齐会话搜索/选择接口的统一文本输出,并在健康接口中返回插件版本,便于桥接与智能体复用。",
|
||||
"0.1.4": "夸克执行层补充缺少 Cookie 时的自动刷新尝试,原生工具与 API 路由更稳。",
|
||||
"0.1.3": "修复原生 Agent Tool 夸克分享路由参数错误,补齐 115 主链路兼容恢复。",
|
||||
"0.1.2": "新增原生 Agent Tool:影巢会话搜索、会话继续选择、通用分享链接路由。",
|
||||
"0.1.1": "打通运行时配置加载,补充候选计数,并兼容 index/choice/selection/number 选片字段。",
|
||||
"0.1.0": "首个可用版本,已接入夸克转存、115 转存、影巢搜索/解锁,以及解锁后自动路由到对应网盘执行层。"
|
||||
}
|
||||
},
|
||||
"FeishuCommandBridgeLong": {
|
||||
"name": "飞书命令桥接",
|
||||
"description": "旧飞书长连接兼容/备份入口;新用户建议优先使用 Agent影视助手 内置飞书入口。",
|
||||
"labels": "飞书,长连接,115,影巢,夸克,智能体,命令",
|
||||
"version": "0.5.26",
|
||||
"icon": "feishucommandbridgelong.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"0.5.26": "更新插件市场描述,明确本插件定位为旧飞书长连接兼容/备份入口,新用户优先使用 Agent影视助手 内置飞书入口。",
|
||||
"0.5.25": "飞书里的 115 登录、待任务与直链转存现在统一走 Agent影视助手 主线,保证失败留单、扫码续跑、取消任务都落在同一会话链里。",
|
||||
"0.5.24": "同步飞书桥接运行态版本,配合 115任务 新别名与持久化待处理任务能力发布。",
|
||||
"0.5.23": "飞书桥接新增 115任务 别名和快捷示例,方便查看当前待继续的 115 任务。",
|
||||
"0.5.22": "飞书桥接补充 继续115任务 与 取消115任务 别名和快捷示例,便于直接控制待处理 115 任务。",
|
||||
"0.5.21": "飞书快捷示例补充 115帮助 与带 path 的直链转存写法,方便直接照抄使用。",
|
||||
"0.5.20": "飞书桥接现在会直接透传 Agent影视助手 返回的 115 失败诊断,不再重复包裹错误前缀。",
|
||||
"0.5.19": "飞书桥接新增 115帮助 别名,并复用 Agent影视助手 返回的引导式 115 状态/登录回执。",
|
||||
"0.5.18": "飞书现在可直接发起 115 扫码登录并回传二维码图片,也支持回复检查115登录继续轮询 Agent影视助手 会话。",
|
||||
"0.5.17": "切到 Agent影视助手 后端时,详情/审查和 n 下一页会透传给新主线,不再退回 unsupported。",
|
||||
"0.5.16": "当切到 Agent影视助手 后端时,飞书桥接的智能入口与继续选择可整条委托给 Agent影视助手 处理,桥接层进一步变薄。",
|
||||
"0.5.15": "当切到 Agent影视助手 后端时,飞书桥接的影巢搜索/选片/解锁会话也可直接走新主线,不再只接最后一跳转存。",
|
||||
"0.5.14": "新增执行后端开关,旧桥接可继续直连快路径,也可按需切换到 Agent影视助手 新主线。",
|
||||
"0.5.13": "飞书桥接保留旧入口,但执行层优先委托 Agent影视助手,影巢/115/夸克开始走新主干。",
|
||||
"0.5.12": "详情/审查 现在只补当前页主演,并改为并发补查,减少候选较多时的等待时间。",
|
||||
"0.5.11": "影巢候选影片默认不再预查主演,首屏更快;如需补充当前候选页全部主演,可直接回复详情或审查。",
|
||||
"0.5.10": "影巢候选影片列表支持按每页 10 条分页展示,并可直接回复 n 下一页继续翻页;候选请求上限同步提高,适合蜘蛛侠这类多版本片名。",
|
||||
"0.5.9": "飞书桥接新增本地 TMDB API Key 配置,影巢候选影片现在可稳定补充 1 到 2 个主演名,且不会把密钥写进仓库。",
|
||||
"0.5.8": "影巢候选影片列表补充 1 到 2 个主演名,帮助快速区分同名作品;继续保留先选影片再看资源的两段式流程。",
|
||||
"0.5.7": "影巢搜索改为先选影片再看资源;资源列表按 115 前 6 条与夸克前 6 条分区展示,交互与盘搜保持一致。",
|
||||
"0.5.6": "精简夸克转存回执,仅保留关键结果;盘搜列表增加 115/夸克分区提示,便于快速选择。",
|
||||
"0.5.5": "盘搜搜索增加相关性过滤,并将 115 / 夸克各自展示数调整为前 6 条,减少无关结果干扰。",
|
||||
"0.5.4": "盘搜搜索改为固定展示 115 前 10 条与夸克前 10 条,统一连续编号,方便直接按序号转存。",
|
||||
"0.5.3": "新增盘搜搜索结果缓存与按编号直转 115 / 夸克,和影巢搜索保持同样的选择式落地体验。",
|
||||
"0.5.2": "支持飞书直接发送 115 / 夸克裸链接,自动识别并转存,不再需要处理前缀。",
|
||||
"0.5.1": "新增 MP搜索 / 影巢搜索 / 盘搜搜索 三种前缀入口,默认搜索保持 MP 原生搜索。",
|
||||
"0.5.0": "新增处理/选择双命令与智能体 API,统一分流夸克链接、115 链接与影巢搜索解锁流程。",
|
||||
"0.4.0": "新增夸克分享转存命令,可直接桥接 QuarkShareSaver 完成落盘。",
|
||||
"0.3.0": "新增飞书内建媒体工作流:搜索 PT 资源、按序号下载、添加订阅、订阅后立即搜索。",
|
||||
"0.2.3": "统一插件身份为 FeishuCommandBridgeLong,修复插件市场安装状态匹配。",
|
||||
"0.2.2": "支持飞书长连接、事件去重、115 手动整理回执、增量与全量 STRM 命令桥接。"
|
||||
}
|
||||
},
|
||||
"HdhiveOpenApi": {
|
||||
"name": "影巢 OpenAPI",
|
||||
"description": "通过 HDHive Open API 完成签到、关键词/TMDB 搜索、资源解锁、115 转存、分享管理与配额查询。",
|
||||
"labels": "影巢,HDHive,OpenAPI,TMDB,115,解锁,签到",
|
||||
"version": "0.3.0",
|
||||
"icon": "hdhive.ico",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"0.3.0": "支持关键词搜索、TMDB 候选解析、115 自动转存、分享管理、签到与配额查询。"
|
||||
}
|
||||
},
|
||||
"QuarkShareSaver": {
|
||||
"name": "夸克分享转存",
|
||||
"description": "把夸克分享链接直接转存到自己的夸克网盘目录,适合作为智能体和飞书的稳定执行入口。",
|
||||
"labels": "夸克,Quark,分享,转存,网盘,智能体,飞书",
|
||||
"version": "0.1.0",
|
||||
"icon": "quark.ico",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"0.1.0": "首个轻量版本,支持夸克分享解析、目录自动创建、转存执行,以及智能体和飞书调用。"
|
||||
}
|
||||
},
|
||||
"AutoAuction": {
|
||||
"name": "朱雀交易行自动上架",
|
||||
"description": "自动上架上传或灵石到交易行,支持定时任务和历史记录",
|
||||
"labels": "交易行自动化",
|
||||
"version": "1.0.1",
|
||||
"icon": "auction.png",
|
||||
"author": "no_reply",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.0.0": "初始版本,支持交易行自动上架和定时任务",
|
||||
"v1.0.1": "修复定时任务重复触发问题"
|
||||
}
|
||||
},
|
||||
"DynamicWeChat": {
|
||||
"name": "动态企微可信IP",
|
||||
"description": "修改企微应用可信IP,支持Srever酱等第三方通知。验证码以?结尾发送到企业微信应用",
|
||||
"labels": "消息通知",
|
||||
"version": "2.0.0",
|
||||
"icon": "Wecom_A.png",
|
||||
"author": "RamenRa",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v2.0.0": "V2 专用大版本改用 CloakBrowser 启动企业微信浏览器流程,默认插件不再声明 V2 兼容。",
|
||||
"v1.7.3": "修复检测登录的元素",
|
||||
"v1.7.2": "||wan参数细分,修复使用||wan时立即检测一次实际不生效,修复v1第三方备用通知可能无效,调整验证码获取",
|
||||
"v1.7.1": "允许使用'||wan2'选项及无法使用'立即检测一次'",
|
||||
"v1.7.0": "使用第三方通知时可IP变动后通知,拟支持多网络出口检查。",
|
||||
"v1.6.0": "忽略因网络波动导致获取ip错误。自定义的类合并为helper.py。后续核心功能没问题将不再更新",
|
||||
"v1.5.2": "可以从指定url获取ip,修复不使用cc时cookie失效过快,v1可配置第三方为备用通知,server酱可以将文本发送到server3,二维码给服务号",
|
||||
"v1.5.1": "修复v2微信通知,可以指定微信通知ID",
|
||||
"v1.5.0": "支持企微应用通知和第Serve酱等第三方推送。按要求修改插件名称",
|
||||
"v1.4.1": "完善面板说明",
|
||||
"v1.4.0": "修复强制更改IP时配置面板延时过长的问题。庆祝v2进入正式版,显示了一个没用的参数"
|
||||
}
|
||||
},
|
||||
"InvitesSignin": {
|
||||
"name": "药丸签到",
|
||||
"description": "药丸论坛签到。",
|
||||
"labels": "站点",
|
||||
"version": "3.0.0",
|
||||
"icon": "invites.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v3.0.0": "V2 专用大版本的浏览器仿真改用 CloakBrowser 获取页面,默认插件不再声明 V2 兼容。",
|
||||
"v2.0.3": "增加启用浏览器仿真功能发送请求",
|
||||
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
|
||||
"v2.0.1": "尝试修复签到失败问题,新增使用代理、Cookie自动更新功能",
|
||||
"v2.0.0": "修复签到失败问题,新增账户登录签到功能、新增签到失败重试机制,美化界面UI",
|
||||
"v1.4.1": "更新签到域名前缀",
|
||||
"v1.4": "自定义保留消息天数"
|
||||
}
|
||||
},
|
||||
"ContractCheck": {
|
||||
"name": "契约检查",
|
||||
"description": "定时检查保种契约达成情况。",
|
||||
"labels": "做种",
|
||||
"version": "2.0.0",
|
||||
"icon": "contract.png",
|
||||
"author": "DzAvril",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.0.0": "V2 专用大版本的渲染模式改用 CloakBrowser 获取站点页面,默认插件不再声明 V2 兼容。",
|
||||
"v1.4.1": "增加站点猪猪",
|
||||
"v1.4": "支持仪表板组件显示",
|
||||
"v1.3": "修复观众做种数据异常问题",
|
||||
"v1.2": "修复契约检查无数据返回的问题"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
223
plugins.v2/agentresourceofficer/ARCHITECTURE.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Agent影视助手架构草案
|
||||
|
||||
`Agent影视助手` 是重构后的资源工作流主插件,重点不是把旧代码简单拼一起,而是把职责重新压平。
|
||||
|
||||
## 设计目标
|
||||
|
||||
- 一个插件承接“搜索 -> 选择 -> 解锁 -> 转存 -> 签到/用户态 -> 远程入口”
|
||||
- 智能体、飞书、CLI、后续 MP Agent Tool 共享同一套执行服务
|
||||
- 会话交互与底层执行解耦,避免继续把大量业务逻辑堆在消息入口层
|
||||
|
||||
## 模块分层
|
||||
|
||||
### 1. adapters
|
||||
|
||||
负责不同外部入口和外部平台接入:
|
||||
|
||||
- `feishu`
|
||||
- `hdhive`
|
||||
- `quark`
|
||||
- `pansou`
|
||||
- 后续 `agent_tool`
|
||||
|
||||
原则:
|
||||
|
||||
- 只负责协议和输入输出转换
|
||||
- 不负责复杂业务编排
|
||||
|
||||
### 2. services
|
||||
|
||||
负责核心业务能力:
|
||||
|
||||
- `search_service`
|
||||
- `unlock_service`
|
||||
- `transfer_service`
|
||||
- `signin_service`
|
||||
- `user_service`
|
||||
|
||||
原则:
|
||||
|
||||
- 统一返回结构
|
||||
- 尽量不感知飞书、页面、CLI 等具体入口
|
||||
|
||||
### 3. session
|
||||
|
||||
负责交互上下文:
|
||||
|
||||
- 搜索候选缓存
|
||||
- 翻页状态
|
||||
- 选择上下文
|
||||
- 详情/审查补充信息(已支持候选页按需补主演)
|
||||
|
||||
原则:
|
||||
|
||||
- 入口层共享同一套会话数据
|
||||
- 后续优先支持内存 + 轻量持久化
|
||||
|
||||
### 4. models
|
||||
|
||||
负责统一数据模型:
|
||||
|
||||
- 搜索候选
|
||||
- 资源条目
|
||||
- 解锁结果
|
||||
- 转存结果
|
||||
- 用户信息
|
||||
|
||||
目标:
|
||||
|
||||
- 减少旧插件之间字段名不一致的问题
|
||||
|
||||
## 首期配置模型
|
||||
|
||||
### 基础
|
||||
|
||||
- `enabled`
|
||||
- `notify`
|
||||
- `debug`
|
||||
|
||||
### 影巢
|
||||
|
||||
- `hdhive_base_url`
|
||||
- `hdhive_api_key`
|
||||
- `hdhive_default_path`
|
||||
- `hdhive_candidate_page_size`
|
||||
|
||||
### 夸克
|
||||
|
||||
- `quark_cookie`
|
||||
- `quark_default_path`
|
||||
- `quark_timeout`
|
||||
- `quark_auto_import_cookiecloud`
|
||||
|
||||
### 飞书
|
||||
|
||||
- `feishu_enabled`
|
||||
- `feishu_app_id`
|
||||
- `feishu_app_secret`
|
||||
- `feishu_verification_token`
|
||||
- `feishu_allow_all`
|
||||
- `feishu_allowed_chat_ids`
|
||||
- `feishu_allowed_user_ids`
|
||||
|
||||
### 智能体 / 工具层预留
|
||||
|
||||
- `agent_tools_enabled`
|
||||
- `tool_debug`
|
||||
|
||||
## 迁移映射
|
||||
|
||||
### 从 `QuarkShareSaver`
|
||||
|
||||
优先迁入:
|
||||
|
||||
- 分享链接解析
|
||||
- 目录创建
|
||||
- 转存执行
|
||||
- CookieCloud 自动导入
|
||||
|
||||
当前已开始拆出:
|
||||
|
||||
- `services/quark_transfer.py`
|
||||
|
||||
### 从 `P115StrmHelper` 协同层
|
||||
|
||||
当前已开始拆出:
|
||||
|
||||
- `services/p115_transfer.py`
|
||||
|
||||
### 从 `HdhiveOpenApi`
|
||||
|
||||
随后迁入:
|
||||
|
||||
- 搜索
|
||||
- 候选解析
|
||||
- 解锁
|
||||
- 用户信息
|
||||
- 配额
|
||||
- 分享管理
|
||||
|
||||
当前已开始拆出:
|
||||
|
||||
- `services/hdhive_openapi.py`
|
||||
|
||||
### 从 `HDHiveDailySign`
|
||||
|
||||
补入:
|
||||
|
||||
- 普通签到
|
||||
- 赌狗签到
|
||||
- 自动登录与状态记录
|
||||
|
||||
### 从 `FeishuCommandBridgeLong`
|
||||
|
||||
最后收口:
|
||||
|
||||
- 飞书长连接入口
|
||||
- 自然语言别名解析
|
||||
- 搜索/选择会话衔接
|
||||
|
||||
## 暂不迁入的内容
|
||||
|
||||
- `P115StrmHelper` 仍作为 115 落地执行层保留,不直接并入 `Agent影视助手`
|
||||
|
||||
> 更新说明:PT 搜索、下载、订阅、推荐、入库追踪相关工作流已经收口到 `Agent影视助手` 主线,不再依赖旧桥接插件作为主入口。
|
||||
|
||||
## P115StrmHelper 兼容补丁
|
||||
|
||||
新版 MoviePilot 移除了旧版 `TransferOverwriteCheck` 事件时,部分 `P115StrmHelper` 版本会因为导入 `TransferOverwriteCheckEventData` 失败而无法加载,进而导致 115 自动转存不可用。
|
||||
|
||||
仓库提供了幂等补丁脚本:
|
||||
|
||||
```bash
|
||||
MP_CONTAINER=moviepilot-v2 ./scripts/patch-p115strmhelper-mp-compat.sh
|
||||
```
|
||||
|
||||
补丁只跳过缺失事件的注册,不改动 `P115StrmHelper` 的分享转存主流程。运行环境已验证 `AgentResourceOfficer` 的 `p115/health` 可返回 `p115_ready=true`。
|
||||
|
||||
## 115 轻量直转层
|
||||
|
||||
`Agent影视助手` 从 `0.1.17` 开始支持 115 分享链接轻量直转 + 扫码会话登录:
|
||||
|
||||
- 支持生成和轮询 `p115client` 同款 115 扫码二维码,拿到 `UID / CID / SEID / KID` 这类客户端会话后自动写回插件配置
|
||||
- 配置扫码得到的 115 会话时,直接用该会话创建 115 客户端并调用 `share_receive`
|
||||
- 未配置独立扫码会话时,优先复用已加载的 115 客户端,不再必须走 `sharetransferhelper`
|
||||
- 直转失败时回退 `P115StrmHelper` 的分享转存主流程
|
||||
|
||||
这个能力只负责“分享链接落到 115 目标目录”。STRM 生成、302、增量/全量同步、媒体库整理仍保持由 `P115StrmHelper` 承担。
|
||||
这里特意没有走网页版 CookieCloud,也没有直接拿 MP 系统内置的 `u115` OAuth Token 来代替扫码会话,因为分享转存链路仍然更适合复用 `p115client` 的客户端会话模型。
|
||||
|
||||
## 首个里程碑
|
||||
|
||||
第一个可用版本只追求三件事:
|
||||
|
||||
1. 夸克分享链接直接转存
|
||||
2. 影巢搜索并解锁
|
||||
3. 飞书调用同一套执行服务
|
||||
|
||||
当前进度:
|
||||
|
||||
- 已拆出夸克执行服务
|
||||
- 已拆出影巢基础 OpenAPI 服务
|
||||
- 已拆出 115 转存执行服务
|
||||
- 已补上 Agent影视助手 自己的统一智能入口(assistant route / pick)
|
||||
- 主插件已具备:
|
||||
- 夸克健康检查
|
||||
- 夸克转存
|
||||
- 影巢健康检查
|
||||
- 影巢搜索
|
||||
- 影巢关键词候选搜索
|
||||
- 影巢解锁
|
||||
- 115 依赖健康检查
|
||||
- 115 分享转存
|
||||
- 影巢解锁后自动路由到夸克执行层
|
||||
- 影巢解锁后自动路由到 115 执行层
|
||||
- 影巢会话搜索与按编号继续选择
|
||||
- 盘搜搜索与按编号继续执行
|
||||
- 统一智能入口对直链、盘搜、影巢三类输入的会话分流
|
||||
- 原生 Agent Tool 直接发起和轮询 115 扫码登录
|
||||
- 智能入口 `assistant/route` 可直接理解 `115登录` / `检查115登录`
|
||||
- 扫码登录成功后可直接返回 115 运行状态摘要,便于飞书与 MP 智能助手继续执行
|
||||
- 智能入口与原生 Agent Tool 都可直接返回 `115状态` 摘要,不依赖是否存在待检查会话
|
||||
- 待继续的 115 任务已具备轻量持久化、时间/重试/错误摘要,并提供查看、继续、取消三个原生 Agent Tool 和标准 API
|
||||
- `115状态` / `检查115登录` / `115帮助` 统一补充下一步建议,减少人工猜测下一条命令
|
||||
224
plugins.v2/agentresourceofficer/README.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Agent影视助手
|
||||
|
||||
`Agent影视助手` 是这个仓库的主线插件,重点解决一件事:
|
||||
|
||||
把 `飞书命令入口`、`外部智能体`、`盘搜`、`影巢`、`115`、`夸克`、`MoviePilot 原生搜索 / PT 下载` 收进同一套稳定工作流。
|
||||
|
||||
当前版本:`0.2.71`
|
||||
|
||||
当前 helper 版本:`0.1.51`
|
||||
|
||||
当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.71
|
||||
|
||||
如果你是第一次用这个仓库,先把这个插件跑通就够了。
|
||||
|
||||
---
|
||||
|
||||
## 适合谁
|
||||
|
||||
- 你想把飞书当成类似 `TG / 企业微信` 的资源命令入口。
|
||||
- 你想让 `OpenClaw`、`Hermes`、`WorkBuddy` 这类外部智能体稳定控制 MoviePilot。
|
||||
- 你想统一处理“找资源 -> 选资源 -> 转存到 115 / 夸克”的流程。
|
||||
- 你也想把 MoviePilot 原生 `MP搜索 / PT搜索 / 下载 / 订阅 / 更新检查` 放进同一套命令入口。
|
||||
- 你希望智能体不要自己乱拼影巢、盘搜、115、夸克接口,而是统一交给插件执行。
|
||||
|
||||
---
|
||||
|
||||
## 两种主要用法
|
||||
|
||||
### 1. 不使用外部智能体,只用飞书命令入口
|
||||
|
||||
如果你不想接外部智能体,只想要一个命令窗口,可以只配置飞书。
|
||||
|
||||
配好后,直接在飞书里发:
|
||||
|
||||
```text
|
||||
盘搜搜索 片名
|
||||
影巢搜索 片名
|
||||
转存 片名
|
||||
夸克转存 片名
|
||||
下载 片名
|
||||
更新检查 片名
|
||||
115登录
|
||||
影巢签到
|
||||
```
|
||||
|
||||
这种用法更像 TG / 企业微信机器人入口:飞书负责收消息,插件负责执行。
|
||||
|
||||
### 2. 使用外部智能体
|
||||
|
||||
如果你要接 `OpenClaw`、`Hermes`、`WorkBuddy`,建议安装 `agent-resource-officer skill / helper`。
|
||||
|
||||
外部智能体负责理解用户需求和展示结果;资源搜索、转存、下载、签到、Cookie 修复都交给插件。
|
||||
|
||||
重点文档:
|
||||
|
||||
- [外部智能体接入](../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md)
|
||||
- [跨机器部署](../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md)
|
||||
- 全部命令:`docs/ALL_COMMANDS.md`
|
||||
|
||||
### MCP 和 Skill 怎么分工
|
||||
|
||||
如果你的智能体客户端支持 MoviePilot 官方 MCP,可以一起接。
|
||||
|
||||
- MCP 更适合查 MoviePilot 管理信息,比如插件列表、下载器状态、站点状态、历史记录、工作流。
|
||||
- `agent-resource-officer skill / helper` 更适合资源流,比如盘搜、影巢、115/夸克转存、PT 编号下载、翻页、盘搜/影巢详情和 Cookie 修复。
|
||||
- `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流,也建议优先交给 `agent-resource-officer`,避免智能体绕过插件规则。
|
||||
|
||||
MCP 地址通常是:
|
||||
|
||||
```text
|
||||
http://你的MP地址:3000/api/v1/mcp
|
||||
X-API-KEY=你的 MoviePilot API_TOKEN
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心命令
|
||||
|
||||
### 搜索
|
||||
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `搜索 <片名>` | 默认优先走 MP/PT;如果 MP/PT 已关闭,再按当前启用源回退 |
|
||||
| `盘搜搜索 <片名>` | 先查盘搜;盘搜没结果时按开关补查影巢 |
|
||||
| `影巢搜索 <片名>` | 先查影巢;影巢没结果时按开关补查盘搜 |
|
||||
| `MP搜索 <片名>` / `PT搜索 <片名>` | 走 MoviePilot 原生搜索 / PT 搜索 |
|
||||
| `盘搜更新检查 <片名>` | 只看盘搜侧更新资源 |
|
||||
| `影巢更新检查 <片名>` | 只看影巢侧更新资源 |
|
||||
|
||||
补充:
|
||||
|
||||
- `搜索 第 3 集`、`搜索 E03` 这类带集数线索的写法,会直接按 MP/PT 搜索,不再回退到云盘。
|
||||
- `检查 大君夫人`、`检查大君夫人` 这类写法,会按更新检查处理;但 `检查115登录` 仍然保留为 115 登录检查。
|
||||
- `更新检查 xx 剧` / `检查 xx 剧` 这类带剧集意图的写法,会按 MP/PT 搜索;云盘侧更新检查请显式使用 `盘搜更新检查` 或 `影巢更新检查`。
|
||||
|
||||
### 转存 / 下载
|
||||
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `转存 <片名>` | 默认等同 `115转存 <片名>` |
|
||||
| `115转存 <片名>` | 搜索后优先转存到 115 |
|
||||
| `夸克转存 <片名>` | 搜索后优先转存到夸克 |
|
||||
| `下载 <片名>` | 走 MoviePilot 原生 PT 下载链,先找片并列出 PT 候选 |
|
||||
|
||||
注意:
|
||||
|
||||
- `转存 <片名>` 默认是 115,不会自动改成夸克。
|
||||
- 只有明确说 `夸克转存 <片名>` 才走夸克。
|
||||
- `下载 <片名>` 是 PT 下载,不是云盘转存。
|
||||
- PT 搜索结果里直接回编号会立即下载。
|
||||
- `下载1` 是给当前 PT 结果生成下载计划,不是确认旧计划。
|
||||
- 云盘/影巢结果才有详情卡;想看详情用 `选择 1 详情` 或 `详情 1`。
|
||||
|
||||
### 选择 / 翻页
|
||||
|
||||
```text
|
||||
1
|
||||
下载1
|
||||
选择 1 详情
|
||||
n
|
||||
```
|
||||
|
||||
- `1`:PT 结果里直接下载;云盘结果里继续转存/解锁。
|
||||
- `下载1`:给第 1 条 PT 结果生成下载计划。
|
||||
- `选择 1 详情` / `详情 1`:只用于云盘/影巢详情。
|
||||
- `n`:下一页。
|
||||
|
||||
完整命令见:`docs/ALL_COMMANDS.md`
|
||||
|
||||
---
|
||||
|
||||
## 主要能力
|
||||
|
||||
### 云盘资源
|
||||
|
||||
- 盘搜搜索
|
||||
- 影巢搜索 / 解锁
|
||||
- `云盘搜索` 已废弃,收到后只会提示改用 `盘搜搜索` / `影巢搜索`
|
||||
- 115 转存
|
||||
- 夸克转存
|
||||
- 更新检查
|
||||
- 编号选择、详情、翻页
|
||||
- 智能建议与候选推荐
|
||||
|
||||
### MoviePilot 原生能力
|
||||
|
||||
- MP / PT 搜索
|
||||
- PT 下载计划
|
||||
- 订阅
|
||||
- 下载任务
|
||||
- 下载历史
|
||||
- 入库历史
|
||||
- 站点状态 / 下载器状态
|
||||
- 热门探索 / 推荐
|
||||
|
||||
### 账号与修复
|
||||
|
||||
- 115 扫码登录 / 状态检查
|
||||
- 影巢签到 / 签到日志
|
||||
- 影巢 Cookie 修复
|
||||
- 夸克 Cookie 修复
|
||||
|
||||
`115登录` / `115转存` 现在不再强依赖 `P115StrmHelper`;有它时更适合做 115 整理、STRM 和旧登录态复用,没有它也可以直接扫码后完成 115 转存。
|
||||
|
||||
Cookie 修复会用到本机浏览器登录态。如果 MoviePilot 在 NAS、智能体在电脑上,修复命令读取的是智能体电脑上的浏览器 Cookie,再写回 NAS 上的 MoviePilot。
|
||||
|
||||
---
|
||||
|
||||
## 和旧插件的关系
|
||||
|
||||
`Agent影视助手` 是把旧的分散能力收成一条主线。
|
||||
|
||||
| 旧插件 | 主要用途 | 现在建议 |
|
||||
|---|---|---|
|
||||
| `FeishuCommandBridgeLong` | 旧飞书入口 | 新环境优先用 Agent影视助手内置飞书入口 |
|
||||
| `HdhiveOpenApi` | 影巢独立能力 | 主能力已收进 Agent影视助手 |
|
||||
| `QuarkShareSaver` | 夸克独立转存 | 主能力已收进 Agent影视助手 |
|
||||
| `HDHiveDailySign` | 旧影巢签到兜底 | 新环境优先走 Agent影视助手修复链 |
|
||||
|
||||
旧组合仍然能用,但更适合兼容老环境;新装建议优先用 `Agent影视助手`。
|
||||
|
||||
---
|
||||
|
||||
## 新手最容易踩的坑
|
||||
|
||||
### 外部智能体乱改命令
|
||||
|
||||
常见错误:
|
||||
|
||||
- 把 `盘搜搜索`、`影巢搜索`、`MP搜索` 这些明确命令改写成别的入口
|
||||
- 把 `下载` 当成云盘转存
|
||||
- 把云盘详情当成直接选择,或把 PT 编号下载当成详情
|
||||
- 重排插件返回的编号
|
||||
|
||||
解决方式:让智能体安装并读取 `agent-resource-officer skill`。长线程跑偏时,直接对智能体说:
|
||||
|
||||
```text
|
||||
校准影视技能
|
||||
```
|
||||
|
||||
外部智能体收到这句时,应先检查并拉取 `MoviePilot-Plugins` 仓库最新版,再重新加载影视技能规则;如果工作区有本地改动,就跳过自动拉取并说明原因。
|
||||
|
||||
### 跨机器地址填错
|
||||
|
||||
如果 MoviePilot 在 NAS,智能体在电脑上,`ARO_BASE_URL` 要填 NAS 地址:
|
||||
|
||||
```text
|
||||
ARO_BASE_URL=http://你的NAS地址:3000
|
||||
```
|
||||
|
||||
不要填 `127.0.0.1`,那只代表智能体自己这台机器。
|
||||
|
||||
### 夸克失败不一定是 Cookie 失效
|
||||
|
||||
分享受限、分享者封禁、`41031` 不一定是 Cookie 问题。只有明确提示登录态失效时,才优先走夸克 Cookie 修复。
|
||||
|
||||
---
|
||||
|
||||
## 进一步阅读
|
||||
|
||||
- [插件安装说明](../docs/PLUGIN_INSTALL.md)
|
||||
- [外部智能体接入](../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md)
|
||||
- [跨机器部署](../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md)
|
||||
- 全部命令:`docs/ALL_COMMANDS.md`
|
||||
27976
plugins.v2/agentresourceofficer/__init__.py
Normal file
870
plugins.v2/agentresourceofficer/agenttool.py
Normal file
@@ -0,0 +1,870 @@
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.core.plugin import PluginManager
|
||||
|
||||
from .schemas import (
|
||||
AssistantCapabilitiesToolInput,
|
||||
AssistantExecuteActionToolInput,
|
||||
AssistantExecuteActionsToolInput,
|
||||
AssistantExecutePlanToolInput,
|
||||
AssistantHistoryToolInput,
|
||||
AssistantHelpToolInput,
|
||||
AssistantMaintainToolInput,
|
||||
AssistantPickToolInput,
|
||||
AssistantPreferencesToolInput,
|
||||
AssistantPlansClearToolInput,
|
||||
AssistantPlansToolInput,
|
||||
AssistantPulseToolInput,
|
||||
AssistantReadinessToolInput,
|
||||
AssistantRecoverToolInput,
|
||||
AssistantRequestTemplatesToolInput,
|
||||
AssistantRouteToolInput,
|
||||
AssistantSessionClearToolInput,
|
||||
AssistantSessionsClearToolInput,
|
||||
AssistantSessionsToolInput,
|
||||
AssistantSessionStateToolInput,
|
||||
AssistantSelfcheckToolInput,
|
||||
AssistantStartupToolInput,
|
||||
AssistantToolboxToolInput,
|
||||
AssistantWorkflowToolInput,
|
||||
FeishuChannelHealthToolInput,
|
||||
HDHiveSearchSessionToolInput,
|
||||
HDHiveSessionPickToolInput,
|
||||
P115CancelPendingToolInput,
|
||||
P115PendingToolInput,
|
||||
P115QRCodeCheckToolInput,
|
||||
P115QRCodeStartToolInput,
|
||||
P115ResumePendingToolInput,
|
||||
P115StatusToolInput,
|
||||
ShareRouteToolInput,
|
||||
)
|
||||
|
||||
|
||||
def _get_plugin():
|
||||
return PluginManager().running_plugins.get("AgentResourceOfficer")
|
||||
|
||||
|
||||
class HDHiveSearchSessionTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_hdhive_search"
|
||||
description: str = "Search HDHive by title, return candidate titles and a reusable session_id for the next selection step."
|
||||
args_schema: Type[BaseModel] = HDHiveSearchSessionToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
keyword = kwargs.get("keyword", "")
|
||||
return f"正在通过 Agent影视助手搜索影巢候选:{keyword}"
|
||||
|
||||
async def run(self, keyword: str, media_type: str = "auto", year: str = None, path: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_hdhive_search_session(
|
||||
keyword=keyword,
|
||||
media_type=media_type,
|
||||
year=year,
|
||||
target_path=path,
|
||||
)
|
||||
|
||||
|
||||
class HDHiveSessionPickTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_hdhive_pick"
|
||||
description: str = "Continue a previous HDHive session by selecting either a candidate title or a resource item."
|
||||
args_schema: Type[BaseModel] = HDHiveSessionPickToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
session_id = kwargs.get("session_id", "")
|
||||
choice = kwargs.get("choice", "")
|
||||
return f"正在继续 Agent影视助手 会话:{session_id},选择 {choice}"
|
||||
|
||||
async def run(self, session_id: str, choice: int = 0, path: str = None, action: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_hdhive_pick_session(
|
||||
session_id=session_id,
|
||||
index=choice,
|
||||
target_path=path,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
class ShareRouteTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_route_share"
|
||||
description: str = "Route a 115 or Quark share link into the configured transfer pipeline and save it into the target path."
|
||||
args_schema: Type[BaseModel] = ShareRouteToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 路由分享链接"
|
||||
|
||||
async def run(self, url: str, path: str = None, access_code: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_route_share(
|
||||
share_url=url,
|
||||
access_code=access_code,
|
||||
target_path=path,
|
||||
)
|
||||
|
||||
|
||||
class AssistantRouteTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_smart_entry"
|
||||
description: str = "Use the unified Agent影视助手 smart entry for HDHive search, PanSou search, 115 login, or direct 115/Quark share links."
|
||||
args_schema: Type[BaseModel] = AssistantRouteToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
text = kwargs.get("text") or kwargs.get("keyword") or kwargs.get("url") or kwargs.get("action") or ""
|
||||
return f"正在通过 Agent影视助手 统一入口处理:{text}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
text: str = None,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
path: str = None,
|
||||
mode: str = None,
|
||||
keyword: str = None,
|
||||
url: str = None,
|
||||
access_code: str = None,
|
||||
media_type: str = None,
|
||||
year: str = None,
|
||||
client_type: str = None,
|
||||
action: str = None,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_route(
|
||||
text=text,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
target_path=path,
|
||||
mode=mode,
|
||||
keyword=keyword,
|
||||
share_url=url,
|
||||
access_code=access_code,
|
||||
media_type=media_type,
|
||||
year=year,
|
||||
client_type=client_type,
|
||||
action=action,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantPickTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_smart_pick"
|
||||
description: str = "Continue the unified Agent影视助手 smart-entry session by choosing an item, requesting details, or moving to the next page."
|
||||
args_schema: Type[BaseModel] = AssistantPickToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
session = kwargs.get("session", "default")
|
||||
choice = kwargs.get("choice", 0)
|
||||
action = kwargs.get("action", "")
|
||||
tail = f"动作 {action}" if action else f"选择 {choice}"
|
||||
return f"正在继续 Agent影视助手 统一会话:{session},{tail}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
choice: int = 0,
|
||||
action: str = None,
|
||||
mode: str = None,
|
||||
path: str = None,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_pick(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
index=choice,
|
||||
action=action,
|
||||
mode=mode,
|
||||
target_path=path,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantHelpTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_help"
|
||||
description: str = "Show the recommended Agent影视助手 workflow for MoviePilot Agent, including smart-entry examples, pick examples, and 115 login guidance."
|
||||
args_schema: Type[BaseModel] = AssistantHelpToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 使用帮助"
|
||||
|
||||
async def run(self, session: str = "default", session_id: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_help(session=session, session_id=session_id)
|
||||
|
||||
|
||||
class AssistantCapabilitiesTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_capabilities"
|
||||
description: str = "Show the current Agent影视助手 execution capabilities, supported structured smart-entry fields, defaults, and recommended call patterns for external agents."
|
||||
args_schema: Type[BaseModel] = AssistantCapabilitiesToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 能力说明"
|
||||
|
||||
async def run(self, compact: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_capabilities(compact=compact)
|
||||
|
||||
|
||||
class AssistantReadinessTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_readiness"
|
||||
description: str = "Check whether Agent影视助手 is ready for external agents, including version, services, suggested entrypoints, and startup warnings."
|
||||
args_schema: Type[BaseModel] = AssistantReadinessToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在检查 Agent影视助手 启动就绪状态"
|
||||
|
||||
async def run(self, compact: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_readiness(compact=compact)
|
||||
|
||||
|
||||
class FeishuChannelHealthTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_feishu_health"
|
||||
description: str = "Check Agent影视助手 built-in Feishu Channel status, including whether it is enabled, running, and configured."
|
||||
args_schema: Type[BaseModel] = FeishuChannelHealthToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在检查 Agent影视助手 内置飞书入口状态"
|
||||
|
||||
async def run(self, compact: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_feishu_health(compact=compact)
|
||||
|
||||
|
||||
class AssistantPulseTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_pulse"
|
||||
description: str = "Return a compact Agent影视助手 startup pulse: version, service readiness, warnings, and best recovery hint for external agents."
|
||||
args_schema: Type[BaseModel] = AssistantPulseToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在检查 Agent影视助手 轻量启动状态"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_pulse()
|
||||
|
||||
|
||||
class AssistantStartupTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_startup"
|
||||
description: str = "Return one compact startup bundle for external agents: pulse, self-check result, key tools, endpoints, defaults, and recovery hint."
|
||||
args_schema: Type[BaseModel] = AssistantStartupToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在读取 Agent影视助手 启动聚合信息"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_startup()
|
||||
|
||||
|
||||
class AssistantMaintainTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_maintain"
|
||||
description: str = "Inspect or execute low-risk Agent影视助手 maintenance: clear stale assistant sessions and executed saved plans."
|
||||
args_schema: Type[BaseModel] = AssistantMaintainToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在检查 Agent影视助手 维护建议"
|
||||
|
||||
async def run(self, execute: bool = False, limit: int = 100, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_maintain(execute=execute, limit=limit)
|
||||
|
||||
|
||||
class AssistantToolboxTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_toolbox"
|
||||
description: str = "Return a compact Agent影视助手 toolbox manifest: recommended tools, endpoints, workflows, actions, defaults, and command examples."
|
||||
args_schema: Type[BaseModel] = AssistantToolboxToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在读取 Agent影视助手 轻量工具清单"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_toolbox()
|
||||
|
||||
|
||||
class AssistantRequestTemplatesTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_request_templates"
|
||||
description: str = "Return compact HTTP request templates for external agents to call Agent影视助手 assistant endpoints without guessing request bodies."
|
||||
args_schema: Type[BaseModel] = AssistantRequestTemplatesToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在读取 Agent影视助手 请求模板"
|
||||
|
||||
async def run(self, limit: int = 100, names: str = None, recipe: str = None, include_templates: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_request_templates(
|
||||
limit=limit,
|
||||
names=names,
|
||||
recipe=recipe,
|
||||
include_templates=include_templates,
|
||||
)
|
||||
|
||||
|
||||
class AssistantSelfcheckTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_selfcheck"
|
||||
description: str = "Run a compact Agent影视助手 protocol self-check for compact templates, boolean parsing, and basic assistant protocol health."
|
||||
args_schema: Type[BaseModel] = AssistantSelfcheckToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在执行 Agent影视助手 协议自检"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_selfcheck()
|
||||
|
||||
|
||||
class AssistantHistoryTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_history"
|
||||
description: str = "Show recent Agent影视助手 assistant executions so external agents can debug progress, retries, and the last completed action."
|
||||
args_schema: Type[BaseModel] = AssistantHistoryToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 最近执行历史"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
compact: bool = True,
|
||||
limit: int = 20,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_history(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
compact=compact,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantExecuteActionTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_execute_action"
|
||||
description: str = "Execute a named Agent影视助手 action template directly, so external agents can reuse action_templates without manually mapping each next step."
|
||||
args_schema: Type[BaseModel] = AssistantExecuteActionToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return f"正在执行 Agent影视助手 动作模板:{kwargs.get('name', '')}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
name: str,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
choice: int = None,
|
||||
path: str = None,
|
||||
keyword: str = None,
|
||||
media_type: str = None,
|
||||
year: str = None,
|
||||
url: str = None,
|
||||
access_code: str = None,
|
||||
client_type: str = None,
|
||||
source: str = None,
|
||||
kind: str = None,
|
||||
has_pending_p115: bool = None,
|
||||
stale_only: bool = False,
|
||||
all_sessions: bool = False,
|
||||
limit: int = 100,
|
||||
plan_id: str = None,
|
||||
prefer_unexecuted: bool = True,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_execute_action(
|
||||
name=name,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
choice=choice,
|
||||
target_path=path,
|
||||
keyword=keyword,
|
||||
media_type=media_type,
|
||||
year=year,
|
||||
share_url=url,
|
||||
access_code=access_code,
|
||||
client_type=client_type,
|
||||
source=source,
|
||||
kind=kind,
|
||||
has_pending_p115=has_pending_p115,
|
||||
stale_only=stale_only,
|
||||
all_sessions=all_sessions,
|
||||
limit=limit,
|
||||
plan_id=plan_id,
|
||||
prefer_unexecuted=prefer_unexecuted,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantExecuteActionsTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_execute_actions"
|
||||
description: str = "Execute a sequence of Agent影视助手 action templates in one request, so external agents can reduce round trips and reuse action_templates directly."
|
||||
args_schema: Type[BaseModel] = AssistantExecuteActionsToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
actions = kwargs.get("actions") or []
|
||||
return f"正在批量执行 Agent影视助手 动作模板:{len(actions)} 步"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
actions: list,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
stop_on_error: bool = True,
|
||||
include_raw_results: bool = False,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_execute_actions(
|
||||
actions=actions,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
stop_on_error=stop_on_error,
|
||||
include_raw_results=include_raw_results,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantWorkflowTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_run_workflow"
|
||||
description: str = "Run a preset Agent影视助手 workflow such as pansou_transfer, hdhive_unlock, mp_search_best, mp_search_detail, mp_search_download, mp_subscribe, mp_recommend, share_transfer, or p115_status with compact inputs."
|
||||
args_schema: Type[BaseModel] = AssistantWorkflowToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return f"正在运行 Agent影视助手 预设工作流:{kwargs.get('name', '')}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
name: str,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
keyword: str = None,
|
||||
choice: int = None,
|
||||
candidate_choice: int = None,
|
||||
resource_choice: int = None,
|
||||
path: str = None,
|
||||
url: str = None,
|
||||
access_code: str = None,
|
||||
media_type: str = None,
|
||||
year: str = None,
|
||||
client_type: str = None,
|
||||
source: str = None,
|
||||
limit: int = 20,
|
||||
dry_run: bool = False,
|
||||
stop_on_error: bool = True,
|
||||
include_raw_results: bool = False,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_workflow(
|
||||
name=name,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
keyword=keyword,
|
||||
choice=choice,
|
||||
candidate_choice=candidate_choice,
|
||||
resource_choice=resource_choice,
|
||||
target_path=path,
|
||||
share_url=url,
|
||||
access_code=access_code,
|
||||
media_type=media_type,
|
||||
year=year,
|
||||
client_type=client_type,
|
||||
source=source,
|
||||
limit=limit,
|
||||
dry_run=dry_run,
|
||||
stop_on_error=stop_on_error,
|
||||
include_raw_results=include_raw_results,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantPreferencesTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_preferences"
|
||||
description: str = "Read, save, or reset Agent影视助手 source preferences for scoring cloud-drive and PT results before automated actions."
|
||||
args_schema: Type[BaseModel] = AssistantPreferencesToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
if kwargs.get("reset"):
|
||||
return "正在重置 Agent影视助手 智能体偏好画像"
|
||||
if kwargs.get("preferences"):
|
||||
return "正在保存 Agent影视助手 智能体偏好画像"
|
||||
return "正在读取 Agent影视助手 智能体偏好画像"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
user_key: str = None,
|
||||
preferences: dict = None,
|
||||
reset: bool = False,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_preferences(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
user_key=user_key,
|
||||
preferences=preferences,
|
||||
reset=reset,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantExecutePlanTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_execute_plan"
|
||||
description: str = "Execute a saved Agent影视助手 dry-run workflow plan by plan_id, or recover the latest plan by session/session_id."
|
||||
args_schema: Type[BaseModel] = AssistantExecutePlanToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return f"正在执行 Agent影视助手 已保存计划:{kwargs.get('plan_id', '') or kwargs.get('session_id', '') or kwargs.get('session', '')}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
plan_id: str = None,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
prefer_unexecuted: bool = True,
|
||||
stop_on_error: bool = True,
|
||||
include_raw_results: bool = False,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_execute_plan(
|
||||
plan_id=plan_id,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
prefer_unexecuted=prefer_unexecuted,
|
||||
stop_on_error=stop_on_error,
|
||||
include_raw_results=include_raw_results,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantPlansTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_plans"
|
||||
description: str = "List saved Agent影视助手 dry-run workflow plans so agents can recover and execute the right plan_id."
|
||||
args_schema: Type[BaseModel] = AssistantPlansToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 已保存计划"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
executed: bool = None,
|
||||
include_actions: bool = False,
|
||||
compact: bool = True,
|
||||
limit: int = 20,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_plans(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
executed=executed,
|
||||
include_actions=include_actions,
|
||||
compact=compact,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantPlansClearTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_plans_clear"
|
||||
description: str = "Clear saved Agent影视助手 workflow plans by plan_id, session, executed state, or all_plans."
|
||||
args_schema: Type[BaseModel] = AssistantPlansClearToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在清理 Agent影视助手 已保存计划"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
plan_id: str = None,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
executed: bool = None,
|
||||
all_plans: bool = False,
|
||||
limit: int = 100,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_plans_clear(
|
||||
plan_id=plan_id,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
executed=executed,
|
||||
all_plans=all_plans,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantRecoverTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_recover"
|
||||
description: str = "Inspect the best Agent影视助手 recovery action, or execute it directly, so external agents can resume work through one stable entrypoint."
|
||||
args_schema: Type[BaseModel] = AssistantRecoverToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
target = kwargs.get("session_id") or kwargs.get("session") or "全局"
|
||||
action = "并直接恢复" if kwargs.get("execute") else "恢复建议"
|
||||
return f"正在查看 Agent影视助手 {target} 的{action}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
execute: bool = False,
|
||||
prefer_unexecuted: bool = True,
|
||||
stop_on_error: bool = True,
|
||||
include_raw_results: bool = False,
|
||||
compact: bool = True,
|
||||
limit: int = 20,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_recover(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
execute=execute,
|
||||
prefer_unexecuted=prefer_unexecuted,
|
||||
stop_on_error=stop_on_error,
|
||||
include_raw_results=include_raw_results,
|
||||
compact=compact,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantSessionStateTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_session_state"
|
||||
description: str = "Inspect the current Agent影视助手 assistant session, including stage, current page, selected candidate, and pending 115 task."
|
||||
args_schema: Type[BaseModel] = AssistantSessionStateToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
session = kwargs.get("session", "default")
|
||||
return f"正在查看 Agent影视助手 会话状态:{session}"
|
||||
|
||||
async def run(self, session: str = "default", session_id: str = None, compact: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_session_state(session=session, session_id=session_id, compact=compact)
|
||||
|
||||
|
||||
class AssistantSessionClearTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_session_clear"
|
||||
description: str = "Clear the current Agent影视助手 assistant session cache."
|
||||
args_schema: Type[BaseModel] = AssistantSessionClearToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
session = kwargs.get("session", "default")
|
||||
return f"正在清理 Agent影视助手 会话:{session}"
|
||||
|
||||
async def run(self, session: str = "default", session_id: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_session_clear(session=session, session_id=session_id)
|
||||
|
||||
|
||||
class AssistantSessionsTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_sessions"
|
||||
description: str = "List active Agent影视助手 assistant sessions so external agents can recover, inspect, and resume the right workflow."
|
||||
args_schema: Type[BaseModel] = AssistantSessionsToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 活跃会话列表"
|
||||
|
||||
async def run(self, kind: str = None, has_pending_p115: bool = None, compact: bool = True, limit: int = 20, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_sessions(
|
||||
kind=kind,
|
||||
has_pending_p115=has_pending_p115,
|
||||
compact=compact,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantSessionsClearTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_sessions_clear"
|
||||
description: str = "Clear one or more Agent影视助手 assistant sessions by session_id, session name, filters, or full reset."
|
||||
args_schema: Type[BaseModel] = AssistantSessionsClearToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在清理 Agent影视助手 活跃会话"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
kind: str = None,
|
||||
has_pending_p115: bool = None,
|
||||
stale_only: bool = False,
|
||||
all_sessions: bool = False,
|
||||
limit: int = 100,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_sessions_clear(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
kind=kind,
|
||||
has_pending_p115=has_pending_p115,
|
||||
stale_only=stale_only,
|
||||
all_sessions=all_sessions,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class P115QRCodeStartTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_qrcode_start"
|
||||
description: str = "Generate a 115 login QR code using the p115client-compatible client session flow."
|
||||
args_schema: Type[BaseModel] = P115QRCodeStartToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
client_type = kwargs.get("client_type", "alipaymini")
|
||||
return f"正在通过 Agent影视助手 生成 115 扫码二维码:{client_type}"
|
||||
|
||||
async def run(self, client_type: str = "alipaymini", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_qrcode_start(client_type=client_type)
|
||||
|
||||
|
||||
class P115QRCodeCheckTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_qrcode_check"
|
||||
description: str = "Check the status of a previous 115 QR-code login and save the client session when login succeeds."
|
||||
args_schema: Type[BaseModel] = P115QRCodeCheckToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 检查 115 扫码状态"
|
||||
|
||||
async def run(self, uid: str, time: str, sign: str, client_type: str = "alipaymini", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_qrcode_check(
|
||||
uid=uid,
|
||||
time_value=time,
|
||||
sign=sign,
|
||||
client_type=client_type,
|
||||
)
|
||||
|
||||
|
||||
class P115StatusTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_status"
|
||||
description: str = "Show the current 115 transfer readiness, default target path, and current session source."
|
||||
args_schema: Type[BaseModel] = P115StatusToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 查看 115 当前状态"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_status()
|
||||
|
||||
|
||||
class P115PendingTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_pending"
|
||||
description: str = "Show the pending 115 transfer task for an assistant session, including target path, retry count, and last error."
|
||||
args_schema: Type[BaseModel] = P115PendingToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 查看待继续的 115 任务"
|
||||
|
||||
async def run(self, session: str = "default", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_pending(session=session)
|
||||
|
||||
|
||||
class P115ResumePendingTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_resume_pending"
|
||||
description: str = "Retry the pending 115 transfer task for an assistant session."
|
||||
args_schema: Type[BaseModel] = P115ResumePendingToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 继续待处理的 115 任务"
|
||||
|
||||
async def run(self, session: str = "default", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_resume(session=session)
|
||||
|
||||
|
||||
class P115CancelPendingTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_cancel_pending"
|
||||
description: str = "Cancel and clear the pending 115 transfer task for an assistant session."
|
||||
args_schema: Type[BaseModel] = P115CancelPendingToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 取消待处理的 115 任务"
|
||||
|
||||
async def run(self, session: str = "default", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_cancel(session=session)
|
||||
1936
plugins.v2/agentresourceofficer/feishu_channel.py
Normal file
4
plugins.v2/agentresourceofficer/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
requests
|
||||
cloudscraper
|
||||
lark-oapi==1.5.3
|
||||
p115client==0.0.8.4.8
|
||||
259
plugins.v2/agentresourceofficer/schemas.py
Normal file
@@ -0,0 +1,259 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HDHiveSearchSessionToolInput(BaseModel):
|
||||
keyword: str = Field(..., description="要搜索的影片或剧集名称")
|
||||
media_type: str = Field(default="auto", description="媒体类型,auto / movie / tv;不确定时用 auto")
|
||||
year: Optional[str] = Field(default=None, description="可选年份,用于缩小候选范围")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录,不填则使用默认目录")
|
||||
|
||||
|
||||
class HDHiveSessionPickToolInput(BaseModel):
|
||||
session_id: str = Field(..., description="上一步搜索返回的会话 ID")
|
||||
choice: int = Field(default=0, description="当前阶段要选择的编号,从 1 开始;详情或翻页时可为 0")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录,不填则使用会话中的目录")
|
||||
action: Optional[str] = Field(default=None, description="可选动作:detail/details/review/详情/审查 或 next/n/下一页")
|
||||
|
||||
|
||||
class ShareRouteToolInput(BaseModel):
|
||||
url: str = Field(..., description="115 或夸克分享链接")
|
||||
path: Optional[str] = Field(default=None, description="目标目录")
|
||||
access_code: Optional[str] = Field(default=None, description="提取码,可选")
|
||||
|
||||
|
||||
class AssistantRouteToolInput(BaseModel):
|
||||
text: Optional[str] = Field(default=None, description="统一智能入口文本,例如 盘搜搜索 片名、影巢搜索 片名、115登录 或直接粘贴 115/夸克分享链接")
|
||||
session: Optional[str] = Field(default="default", description="会话标识,用于关联后续选择、115 待任务与扫码续跑")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,适合外部智能体按 sessions 列表中的精确会话继续使用")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录,不填则按当前模式使用默认目录")
|
||||
mode: Optional[str] = Field(default=None, description="结构化模式:mp / pansou / hdhive")
|
||||
keyword: Optional[str] = Field(default=None, description="结构化搜索关键词")
|
||||
url: Optional[str] = Field(default=None, description="结构化分享链接,支持 115 / 夸克")
|
||||
access_code: Optional[str] = Field(default=None, description="结构化提取码")
|
||||
media_type: Optional[str] = Field(default=None, description="结构化媒体类型:auto / movie / tv")
|
||||
year: Optional[str] = Field(default=None, description="结构化年份")
|
||||
client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型")
|
||||
action: Optional[str] = Field(default=None, description="结构化动作:p115_qrcode_start / p115_qrcode_check / p115_status / p115_help / p115_pending / p115_resume / p115_cancel / assistant_help")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantPickToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识,需与上一步统一智能入口保持一致")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
choice: int = Field(default=0, description="选择的编号,从 1 开始;详情或翻页时可为 0")
|
||||
action: Optional[str] = Field(default=None, description="可选动作:detail/details/review/详情/审查 或 next/n/下一页")
|
||||
mode: Optional[str] = Field(default=None, description="推荐列表后续搜索方式:mp / hdhive / pansou")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录,不填则沿用会话目录")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantHelpToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="可选会话标识;如该会话存在待继续的 115 任务,帮助里会附带任务摘要")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
|
||||
|
||||
class AssistantSessionStateToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则查看 default 会话当前状态")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantSessionClearToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则清理 default 会话")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
|
||||
|
||||
class AssistantCapabilitiesToolInput(BaseModel):
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantReadinessToolInput(BaseModel):
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class FeishuChannelHealthToolInput(BaseModel):
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantPulseToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class AssistantStartupToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class AssistantMaintainToolInput(BaseModel):
|
||||
execute: Optional[bool] = Field(default=False, description="是否立即执行低风险维护;默认只返回建议")
|
||||
limit: Optional[int] = Field(default=100, description="单次最多清理多少条")
|
||||
|
||||
|
||||
class AssistantToolboxToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class AssistantRequestTemplatesToolInput(BaseModel):
|
||||
limit: Optional[int] = Field(default=100, description="模板中批量类请求默认 limit,范围由插件限制")
|
||||
names: Optional[str] = Field(default=None, description="可选模板名,多个用逗号或空格分隔,例如 maintain_execute,workflow_dry_run")
|
||||
recipe: Optional[str] = Field(default=None, description="可选推荐流程名或别名,例如 plan / maintain / continue / bootstrap")
|
||||
include_templates: Optional[bool] = Field(default=True, description="是否返回完整模板内容;关闭时只返回名称、无效项和执行策略")
|
||||
|
||||
|
||||
class AssistantSelfcheckToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class AssistantHistoryToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;不填则返回全部最近执行记录")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
limit: Optional[int] = Field(default=20, description="最多返回多少条执行记录")
|
||||
|
||||
|
||||
class AssistantExecuteActionToolInput(BaseModel):
|
||||
name: str = Field(..., description="要执行的动作模板名,例如 pick_pansou_result / candidate_next_page / resume_pending_115")
|
||||
session: Optional[str] = Field(default="default", description="可选会话名")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
choice: Optional[int] = Field(default=None, description="需要选择编号时传入")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录")
|
||||
keyword: Optional[str] = Field(default=None, description="搜索类动作使用的关键词")
|
||||
media_type: Optional[str] = Field(default=None, description="搜索类动作使用的媒体类型")
|
||||
year: Optional[str] = Field(default=None, description="搜索类动作使用的年份")
|
||||
url: Optional[str] = Field(default=None, description="直链类动作使用的分享链接")
|
||||
access_code: Optional[str] = Field(default=None, description="可选提取码")
|
||||
client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型")
|
||||
source: Optional[str] = Field(default=None, description="MP 推荐来源,例如 tmdb_trending / douban_movie_hot / bangumi_calendar")
|
||||
kind: Optional[str] = Field(default=None, description="批量清理会话时的类型过滤")
|
||||
has_pending_p115: Optional[bool] = Field(default=None, description="批量清理会话时是否仅清理带待继续 115 的会话")
|
||||
stale_only: Optional[bool] = Field(default=False, description="批量清理会话时是否只清理过期会话")
|
||||
all_sessions: Optional[bool] = Field(default=False, description="批量清理会话时是否清理全部会话")
|
||||
limit: Optional[int] = Field(default=100, description="批量清理会话时的最多处理条数")
|
||||
plan_id: Optional[str] = Field(default=None, description="计划动作使用的 plan_id")
|
||||
prefer_unexecuted: Optional[bool] = Field(default=True, description="计划动作未指定 plan_id 时是否优先选择未执行计划")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantExecuteActionsToolInput(BaseModel):
|
||||
actions: List[Dict[str, Any]] = Field(..., description="动作模板执行数组,每项可直接复用 action_templates 里的 action_body")
|
||||
session: Optional[str] = Field(default="default", description="批量动作默认会话名;子动作未显式传 session/session_id 时自动继承")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否立即停止后续执行")
|
||||
include_raw_results: Optional[bool] = Field(default=False, description="是否附带每一步原始返回;默认关闭以减少 token 与负载")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantWorkflowToolInput(BaseModel):
|
||||
name: str = Field(..., description="预设工作流名,例如 pansou_search / pansou_transfer / hdhive_candidates / hdhive_unlock / mp_search / mp_search_download / mp_subscribe / mp_recommend / mp_recommend_search / share_transfer / p115_status")
|
||||
session: Optional[str] = Field(default="default", description="工作流会话名")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
keyword: Optional[str] = Field(default=None, description="搜索关键词")
|
||||
choice: Optional[int] = Field(default=None, description="通用选择编号,盘搜转存默认使用 1")
|
||||
candidate_choice: Optional[int] = Field(default=None, description="影巢候选影片编号")
|
||||
resource_choice: Optional[int] = Field(default=None, description="影巢资源编号")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录")
|
||||
url: Optional[str] = Field(default=None, description="分享链接")
|
||||
access_code: Optional[str] = Field(default=None, description="提取码")
|
||||
media_type: Optional[str] = Field(default=None, description="媒体类型,auto / movie / tv")
|
||||
mode: Optional[str] = Field(default=None, description="推荐后续搜索方式,mp / hdhive / pansou")
|
||||
year: Optional[str] = Field(default=None, description="年份")
|
||||
client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型")
|
||||
source: Optional[str] = Field(default=None, description="MP 推荐来源,例如 tmdb_trending / douban_movie_hot / bangumi_calendar")
|
||||
limit: Optional[int] = Field(default=20, description="推荐数量上限")
|
||||
dry_run: Optional[bool] = Field(default=False, description="只生成工作流计划,不实际执行")
|
||||
stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否停止")
|
||||
include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantPreferencesToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="偏好画像会话名;建议外部智能体固定传自己的用户会话")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
user_key: Optional[str] = Field(default=None, description="可选用户键;用于跨 session 共享同一套偏好")
|
||||
preferences: Optional[Dict[str, Any]] = Field(default=None, description="要保存的偏好画像;不传则只读取")
|
||||
reset: Optional[bool] = Field(default=False, description="是否重置偏好画像")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantExecutePlanToolInput(BaseModel):
|
||||
plan_id: Optional[str] = Field(default=None, description="可选 dry_run 返回的 plan_id;不传时可按 session/session_id 自动选择最近计划")
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;未传 plan_id 时可按会话自动选择最近计划")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
prefer_unexecuted: Optional[bool] = Field(default=True, description="自动选计划时是否优先只选未执行计划")
|
||||
stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否停止")
|
||||
include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantPlansToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;不填则返回全部最近计划")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
executed: Optional[bool] = Field(default=None, description="可选过滤:true 只看已执行,false 只看未执行")
|
||||
include_actions: Optional[bool] = Field(default=False, description="是否附带计划动作明细;默认关闭以减少 token")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
limit: Optional[int] = Field(default=20, description="最多返回多少条计划")
|
||||
|
||||
|
||||
class AssistantPlansClearToolInput(BaseModel):
|
||||
plan_id: Optional[str] = Field(default=None, description="可选计划 ID;传入时只清理这一条")
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;按会话清理")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
executed: Optional[bool] = Field(default=None, description="可选过滤:true 只清理已执行,false 只清理未执行")
|
||||
all_plans: Optional[bool] = Field(default=False, description="清理全部计划;未指定 plan_id/session/session_id/executed 时需要显式打开")
|
||||
limit: Optional[int] = Field(default=100, description="批量清理时最多清理多少条")
|
||||
|
||||
|
||||
class AssistantRecoverToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;不传则自动从全局活跃会话和待执行计划里挑选最佳恢复项")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
execute: Optional[bool] = Field(default=False, description="是否直接执行推荐恢复动作;默认只返回恢复建议")
|
||||
prefer_unexecuted: Optional[bool] = Field(default=True, description="执行保存计划时是否优先选择未执行计划")
|
||||
stop_on_error: Optional[bool] = Field(default=True, description="执行恢复动作时遇到失败是否停止")
|
||||
include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果;默认关闭以减少 token")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启,只返回恢复所需关键字段")
|
||||
limit: Optional[int] = Field(default=20, description="全局恢复扫描时最多查看多少个会话")
|
||||
|
||||
|
||||
class AssistantSessionsToolInput(BaseModel):
|
||||
kind: Optional[str] = Field(default=None, description="按会话类型过滤,例如 assistant_pansou / assistant_hdhive / assistant_p115_login")
|
||||
has_pending_p115: Optional[bool] = Field(default=None, description="是否只看带待继续 115 任务的会话")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
limit: Optional[int] = Field(default=20, description="最多返回多少条活跃会话摘要")
|
||||
|
||||
|
||||
class AssistantSessionsClearToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;只清理这一个会话")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID;只清理这一个会话")
|
||||
kind: Optional[str] = Field(default=None, description="按会话类型批量清理")
|
||||
has_pending_p115: Optional[bool] = Field(default=None, description="是否只清理带待继续 115 任务的会话")
|
||||
stale_only: Optional[bool] = Field(default=False, description="只清理已过期但仍残留的 assistant 会话")
|
||||
all_sessions: Optional[bool] = Field(default=False, description="清理全部 assistant 会话;用于重置外部智能体状态")
|
||||
limit: Optional[int] = Field(default=100, description="批量清理时最多清理多少条")
|
||||
|
||||
|
||||
class P115QRCodeStartToolInput(BaseModel):
|
||||
client_type: Optional[str] = Field(default="alipaymini", description="115 扫码客户端类型,默认 alipaymini")
|
||||
|
||||
|
||||
class P115QRCodeCheckToolInput(BaseModel):
|
||||
uid: str = Field(..., description="上一步二维码返回的 uid")
|
||||
time: str = Field(..., description="上一步二维码返回的 time")
|
||||
sign: str = Field(..., description="上一步二维码返回的 sign")
|
||||
client_type: Optional[str] = Field(default="alipaymini", description="客户端类型,需与生成二维码时保持一致")
|
||||
|
||||
|
||||
class P115StatusToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class P115PendingToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则查看 default 会话")
|
||||
|
||||
|
||||
class P115ResumePendingToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则继续 default 会话的待处理 115 任务")
|
||||
|
||||
|
||||
class P115CancelPendingToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则取消 default 会话的待处理 115 任务")
|
||||
1
plugins.v2/agentresourceofficer/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Service modules for Agent影视助手."""
|
||||
1114
plugins.v2/agentresourceofficer/services/hdhive_openapi.py
Normal file
818
plugins.v2/agentresourceofficer/services/p115_transfer.py
Normal file
@@ -0,0 +1,818 @@
|
||||
import importlib
|
||||
import re
|
||||
import sys
|
||||
from base64 import b64encode
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
try:
|
||||
from app.core.config import settings
|
||||
except Exception:
|
||||
settings = None
|
||||
try:
|
||||
from app.core.plugin import PluginManager
|
||||
except Exception:
|
||||
PluginManager = None
|
||||
|
||||
|
||||
class P115TransferService:
|
||||
"""Reusable 115 share transfer execution layer for Agent影视助手."""
|
||||
|
||||
CLIENT_COOKIE_REQUIRED_KEYS = {"UID", "CID", "SEID"}
|
||||
QR_CLIENT_TYPES = {
|
||||
"web",
|
||||
"android",
|
||||
"115android",
|
||||
"ios",
|
||||
"115ios",
|
||||
"alipaymini",
|
||||
"wechatmini",
|
||||
"115ipad",
|
||||
"tv",
|
||||
"qandroid",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
default_target_path: str = "/待整理",
|
||||
cookie: str = "",
|
||||
prefer_direct: bool = True,
|
||||
) -> None:
|
||||
self.default_target_path = self.normalize_pan_path(default_target_path) or "/待整理"
|
||||
self.cookie = self.normalize_text(cookie)
|
||||
self.prefer_direct = bool(prefer_direct)
|
||||
|
||||
def set_cookie(self, cookie: str = "") -> None:
|
||||
self.cookie = self.normalize_text(cookie)
|
||||
|
||||
@staticmethod
|
||||
def normalize_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
@staticmethod
|
||||
def normalize_pan_path(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if not text.startswith("/"):
|
||||
text = f"/{text}"
|
||||
return text.rstrip("/") or "/"
|
||||
|
||||
@staticmethod
|
||||
def _ensure_helper_import_paths() -> None:
|
||||
candidate_dirs = [
|
||||
"/app/app/plugins",
|
||||
"/config/plugins",
|
||||
]
|
||||
for base in candidate_dirs:
|
||||
path = Path(base)
|
||||
if path.exists():
|
||||
text = str(path)
|
||||
if text not in sys.path:
|
||||
sys.path.append(text)
|
||||
|
||||
@staticmethod
|
||||
def is_115_share_url(url: str) -> bool:
|
||||
host = urlparse(url).netloc.lower()
|
||||
return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host
|
||||
|
||||
def ensure_115_share_url(self, url: str, access_code: str = "") -> str:
|
||||
clean_url = self.normalize_text(url)
|
||||
if not clean_url:
|
||||
return ""
|
||||
access_code = self.normalize_text(access_code)
|
||||
parsed = urlparse(clean_url)
|
||||
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
if access_code and "password" not in query:
|
||||
query["password"] = access_code
|
||||
clean_url = urlunparse(parsed._replace(query=urlencode(query)))
|
||||
return clean_url
|
||||
|
||||
@staticmethod
|
||||
def _extract_115_payload(url: str) -> Tuple[str, str]:
|
||||
clean_url = str(url or "").strip()
|
||||
if not clean_url:
|
||||
return "", ""
|
||||
try:
|
||||
from p115client.util import share_extract_payload
|
||||
|
||||
payload = share_extract_payload(clean_url) or {}
|
||||
return str(payload.get("share_code") or "").strip(), str(payload.get("receive_code") or "").strip()
|
||||
except Exception:
|
||||
parsed = urlparse(clean_url)
|
||||
share_code = ""
|
||||
match = re.search(r"/s/([^/?#]+)", parsed.path or "")
|
||||
if match:
|
||||
share_code = match.group(1).strip()
|
||||
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
receive_code = str(query.get("password") or query.get("receive_code") or query.get("pwd") or "").strip()
|
||||
return share_code, receive_code
|
||||
|
||||
@classmethod
|
||||
def parse_cookie_pairs(cls, cookie: str) -> Dict[str, str]:
|
||||
pairs: Dict[str, str] = {}
|
||||
for part in cls.normalize_text(cookie).strip(";").split(";"):
|
||||
if "=" not in part:
|
||||
continue
|
||||
key, value = part.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key and value:
|
||||
pairs[key] = value
|
||||
return pairs
|
||||
|
||||
@classmethod
|
||||
def validate_client_cookie(cls, cookie: str) -> Tuple[bool, str]:
|
||||
if not cls.normalize_text(cookie):
|
||||
return False, "未配置独立 115 Cookie"
|
||||
pairs = cls.parse_cookie_pairs(cookie)
|
||||
missing = sorted(cls.CLIENT_COOKIE_REQUIRED_KEYS - set(pairs))
|
||||
if missing:
|
||||
return False, f"当前 115 Cookie 缺少 {'/'.join(missing)},看起来不是扫码客户端 Cookie;不建议使用网页版 Cookie"
|
||||
return True, ""
|
||||
|
||||
def cookie_state(self) -> Dict[str, Any]:
|
||||
configured = bool(self.normalize_text(self.cookie))
|
||||
pairs = self.parse_cookie_pairs(self.cookie)
|
||||
cookie_keys = sorted(pairs.keys())
|
||||
if not configured:
|
||||
return {
|
||||
"configured": False,
|
||||
"valid": False,
|
||||
"mode": "none",
|
||||
"cookie_keys": [],
|
||||
"message": "未配置独立 115 会话。新环境请先发“115登录”扫码;P115StrmHelper 仅作为旧环境兼容 fallback。",
|
||||
}
|
||||
cookie_ok, cookie_message = self.validate_client_cookie(self.cookie)
|
||||
return {
|
||||
"configured": True,
|
||||
"valid": cookie_ok,
|
||||
"mode": "client_cookie" if cookie_ok else "invalid_cookie",
|
||||
"cookie_keys": cookie_keys,
|
||||
"message": "" if cookie_ok else cookie_message,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def normalize_qrcode_client_type(cls, client_type: Any) -> str:
|
||||
text = cls.normalize_text(client_type).lower()
|
||||
return text if text in cls.QR_CLIENT_TYPES else "alipaymini"
|
||||
|
||||
@staticmethod
|
||||
def jsonable(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (str, int, float, bool, list, dict)):
|
||||
return value
|
||||
if is_dataclass(value):
|
||||
return asdict(value)
|
||||
if hasattr(value, "model_dump"):
|
||||
try:
|
||||
return value.model_dump()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(value, "__dict__"):
|
||||
return {k: v for k, v in vars(value).items() if not k.startswith("_")}
|
||||
return str(value)
|
||||
|
||||
def tz_now(self) -> datetime:
|
||||
if settings is not None:
|
||||
try:
|
||||
return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai")))
|
||||
except Exception:
|
||||
pass
|
||||
return datetime.now()
|
||||
|
||||
@staticmethod
|
||||
def _safe_int(value: Any, default: int = -1) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _response_error(resp: Any) -> str:
|
||||
if not isinstance(resp, dict):
|
||||
return str(resp or "")
|
||||
for key in ("error", "message", "msg", "errno"):
|
||||
value = resp.get(key)
|
||||
if value not in (None, ""):
|
||||
return str(value)
|
||||
return str(resp)
|
||||
|
||||
@classmethod
|
||||
def _is_already_saved_message(cls, value: Any) -> bool:
|
||||
text = cls.normalize_text(value)
|
||||
return any(
|
||||
marker in text
|
||||
for marker in (
|
||||
"已经转存",
|
||||
"已转存",
|
||||
"已经保存",
|
||||
"已保存",
|
||||
"already",
|
||||
"exist",
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _response_ok(resp: Any) -> bool:
|
||||
if not isinstance(resp, dict):
|
||||
return False
|
||||
if resp.get("state") is True:
|
||||
return True
|
||||
if resp.get("code") in (0, "0") and resp.get("state") not in (False, 0):
|
||||
return True
|
||||
if resp.get("errno") in (0, "0") and resp.get("state") not in (False, 0):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _p115_request_kwargs(*, app: bool = False) -> Dict[str, Any]:
|
||||
try:
|
||||
P115TransferService._ensure_helper_import_paths()
|
||||
from app.plugins.p115strmhelper.core.config import configer
|
||||
|
||||
return configer.get_ios_ua_app(app=app) or {}
|
||||
except Exception:
|
||||
try:
|
||||
P115TransferService._ensure_helper_import_paths()
|
||||
from p115strmhelper.core.config import configer
|
||||
|
||||
return configer.get_ios_ua_app(app=app) or {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_servicer_from_loaded_plugin() -> Tuple[Optional[Any], Optional[str]]:
|
||||
if PluginManager is None:
|
||||
return None, "PluginManager 不可用"
|
||||
try:
|
||||
plugin = PluginManager().running_plugins.get("P115StrmHelper")
|
||||
except Exception as exc:
|
||||
return None, f"读取 P115StrmHelper 运行态失败: {exc}"
|
||||
if not plugin:
|
||||
return None, "P115StrmHelper 未加载"
|
||||
|
||||
module_names = []
|
||||
plugin_module = getattr(plugin.__class__, "__module__", "") or ""
|
||||
if plugin_module:
|
||||
module_names.append(f"{plugin_module}.service")
|
||||
module_names.extend(
|
||||
[
|
||||
"app.plugins.p115strmhelper.service",
|
||||
"p115strmhelper.service",
|
||||
]
|
||||
)
|
||||
|
||||
for module_name in module_names:
|
||||
try:
|
||||
self._ensure_helper_import_paths()
|
||||
module = sys.modules.get(module_name) or importlib.import_module(module_name)
|
||||
servicer = getattr(module, "servicer", None)
|
||||
if servicer is not None:
|
||||
return servicer, None
|
||||
except Exception:
|
||||
continue
|
||||
return None, "P115StrmHelper 运行态已加载,但未找到 service.servicer"
|
||||
|
||||
def _get_loaded_p115_client(self) -> Tuple[Optional[Any], str]:
|
||||
servicer, helper_error = self._resolve_servicer_from_loaded_plugin()
|
||||
if not servicer:
|
||||
return None, helper_error or "P115StrmHelper 未加载"
|
||||
client = getattr(servicer, "client", None)
|
||||
if not client:
|
||||
return None, "P115StrmHelper 未登录 115 或客户端不可用"
|
||||
return client, "p115strmhelper_client"
|
||||
|
||||
def _get_cookie_p115_client(self) -> Tuple[Optional[Any], str]:
|
||||
if not self.cookie:
|
||||
return None, "未配置独立 115 Cookie"
|
||||
cookie_ok, cookie_message = self.validate_client_cookie(self.cookie)
|
||||
if not cookie_ok:
|
||||
return None, cookie_message
|
||||
try:
|
||||
from p115client import P115Client
|
||||
|
||||
return P115Client(
|
||||
self.cookie,
|
||||
check_for_relogin=False,
|
||||
ensure_cookies=False,
|
||||
console_qrcode=False,
|
||||
), "direct_cookie"
|
||||
except Exception as exc:
|
||||
return None, f"独立 115 Cookie 初始化失败: {exc}"
|
||||
|
||||
@classmethod
|
||||
def create_qrcode_login(cls, client_type: str = "alipaymini") -> Tuple[bool, Dict[str, Any], str]:
|
||||
final_client_type = cls.normalize_qrcode_client_type(client_type)
|
||||
try:
|
||||
from p115client import P115Client, check_response
|
||||
|
||||
resp = P115Client.login_qrcode_token()
|
||||
check_response(resp)
|
||||
resp_info = resp.get("data", {}) if isinstance(resp, dict) else {}
|
||||
uid = str(resp_info.get("uid") or "")
|
||||
qrcode_time = str(resp_info.get("time") or "")
|
||||
sign = str(resp_info.get("sign") or "")
|
||||
qrcode = P115Client.login_qrcode(uid)
|
||||
if not isinstance(qrcode, (bytes, bytearray)):
|
||||
return False, {}, "获取二维码失败:返回内容类型异常"
|
||||
return True, {
|
||||
"uid": uid,
|
||||
"time": qrcode_time,
|
||||
"sign": sign,
|
||||
"client_type": final_client_type,
|
||||
"tips": "请使用 115 App 扫码登录",
|
||||
"qrcode": f"data:image/png;base64,{b64encode(qrcode).decode('utf-8')}",
|
||||
}, "success"
|
||||
except Exception as exc:
|
||||
return False, {}, f"获取 115 登录二维码失败: {exc}"
|
||||
|
||||
@classmethod
|
||||
def check_qrcode_login(
|
||||
cls,
|
||||
*,
|
||||
uid: str,
|
||||
time_value: str,
|
||||
sign: str,
|
||||
client_type: str = "alipaymini",
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
final_client_type = cls.normalize_qrcode_client_type(client_type)
|
||||
try:
|
||||
from p115client import P115Client, check_response
|
||||
|
||||
payload = {"uid": uid, "time": time_value, "sign": sign}
|
||||
resp = P115Client.login_qrcode_scan_status(payload)
|
||||
if not isinstance(resp, dict):
|
||||
return False, {}, "检查二维码状态失败:返回内容类型异常"
|
||||
check_response(resp)
|
||||
status_code = (resp.get("data") or {}).get("status")
|
||||
except Exception as exc:
|
||||
return False, {}, f"检查二维码状态失败: {exc}"
|
||||
|
||||
if status_code == 0:
|
||||
return True, {"status": "waiting", "client_type": final_client_type}, "等待扫码"
|
||||
if status_code == 1:
|
||||
return True, {"status": "scanned", "client_type": final_client_type}, "已扫码,等待确认"
|
||||
if status_code == -1 or status_code is None:
|
||||
return False, {"status": "expired", "client_type": final_client_type}, "二维码已过期"
|
||||
if status_code == -2:
|
||||
return False, {"status": "cancelled", "client_type": final_client_type}, "用户取消登录"
|
||||
if status_code != 2:
|
||||
return False, {"status": "unknown", "client_type": final_client_type}, f"未知二维码状态: {status_code}"
|
||||
|
||||
try:
|
||||
from p115client import P115Client, check_response
|
||||
|
||||
resp = P115Client.login_qrcode_scan_result(uid, app=final_client_type)
|
||||
if not isinstance(resp, dict):
|
||||
return False, {}, "获取登录结果失败:返回内容类型异常"
|
||||
check_response(resp)
|
||||
except Exception as exc:
|
||||
return False, {}, f"获取登录结果失败: {exc}"
|
||||
|
||||
cookie_data = (resp.get("data") or {}).get("cookie") if isinstance(resp, dict) else None
|
||||
if not isinstance(cookie_data, dict):
|
||||
return False, {}, "登录成功但未返回 Cookie"
|
||||
cookie = "; ".join(f"{name}={value}" for name, value in cookie_data.items() if name and value).strip()
|
||||
cookie_ok, cookie_message = cls.validate_client_cookie(cookie)
|
||||
if not cookie_ok:
|
||||
return False, {}, cookie_message
|
||||
return True, {
|
||||
"status": "success",
|
||||
"client_type": final_client_type,
|
||||
"cookie": cookie,
|
||||
"cookie_keys": sorted(cls.parse_cookie_pairs(cookie).keys()),
|
||||
}, "登录成功"
|
||||
|
||||
def get_direct_client(self) -> Tuple[Optional[Any], str, str]:
|
||||
client, source = self._get_cookie_p115_client()
|
||||
if client:
|
||||
return client, source, ""
|
||||
cookie_error = source
|
||||
client, source = self._get_loaded_p115_client()
|
||||
if client:
|
||||
return client, source, ""
|
||||
return None, "none", source or cookie_error
|
||||
|
||||
@classmethod
|
||||
def _import_servicer_fallback(cls) -> Tuple[Optional[Any], Optional[str]]:
|
||||
last_error = ""
|
||||
for module_name in [
|
||||
"app.plugins.p115strmhelper.service",
|
||||
"p115strmhelper.service",
|
||||
]:
|
||||
try:
|
||||
cls._ensure_helper_import_paths()
|
||||
service_module = importlib.import_module(module_name)
|
||||
servicer = getattr(service_module, "servicer", None)
|
||||
if servicer is not None:
|
||||
return servicer, None
|
||||
last_error = f"{module_name} 未暴露 servicer"
|
||||
except Exception as exc:
|
||||
last_error = f"{module_name} 导入失败: {exc}"
|
||||
return None, last_error or "P115StrmHelper 未安装或无法导入"
|
||||
|
||||
def get_share_helper(self) -> Tuple[Optional[Any], Optional[str]]:
|
||||
servicer, helper_error = self._resolve_servicer_from_loaded_plugin()
|
||||
if not servicer:
|
||||
servicer, helper_error = self._import_servicer_fallback()
|
||||
if not servicer:
|
||||
return None, f"P115StrmHelper 未安装或无法导入: {helper_error}"
|
||||
if not servicer:
|
||||
return None, "P115StrmHelper 未初始化"
|
||||
if not getattr(servicer, "client", None):
|
||||
return None, "P115StrmHelper 未登录 115 或客户端不可用"
|
||||
helper = getattr(servicer, "sharetransferhelper", None)
|
||||
if not helper:
|
||||
return None, "P115StrmHelper 分享转存模块不可用"
|
||||
return helper, None
|
||||
|
||||
def health(self) -> Tuple[bool, Dict[str, Any], str]:
|
||||
cookie_state = self.cookie_state()
|
||||
direct_client, direct_source, direct_error = self.get_direct_client()
|
||||
direct_ready = direct_client is not None
|
||||
helper, helper_error = self.get_share_helper()
|
||||
helper_ready = bool(helper and not helper_error)
|
||||
ready = direct_ready or helper_ready
|
||||
message = "" if ready else direct_error or helper_error or "115 转存不可用"
|
||||
return ready, {
|
||||
"ready": ready,
|
||||
"direct_ready": direct_ready,
|
||||
"direct_source": direct_source if direct_ready else "",
|
||||
"direct_message": "" if direct_ready else direct_error,
|
||||
"helper_ready": helper_ready,
|
||||
"helper_message": "" if helper_ready else helper_error,
|
||||
"cookie_state": cookie_state,
|
||||
"message": message or "success",
|
||||
}, message
|
||||
|
||||
def _get_or_create_path_cid(self, client: Any, path: str) -> int:
|
||||
return self._get_path_cid(client, path, create=True)
|
||||
|
||||
def _get_path_cid(self, client: Any, path: str, *, create: bool = True) -> int:
|
||||
target_path = self.normalize_pan_path(path) or "/"
|
||||
if target_path == "/":
|
||||
return 0
|
||||
get_kwargs = self._p115_request_kwargs(app=False)
|
||||
mkdir_kwargs = self._p115_request_kwargs(app=True)
|
||||
try:
|
||||
resp = client.fs_dir_getid(target_path, **get_kwargs)
|
||||
pid = self._safe_int(resp.get("id") if isinstance(resp, dict) else None, -1)
|
||||
if pid > 0:
|
||||
return pid
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not create:
|
||||
return -1
|
||||
|
||||
try:
|
||||
resp = client.fs_makedirs_app(target_path, pid=0, **mkdir_kwargs)
|
||||
cid = self._safe_int(resp.get("cid") if isinstance(resp, dict) else None, -1)
|
||||
if cid >= 0:
|
||||
return cid
|
||||
if self._response_ok(resp):
|
||||
cid = self._safe_int((resp.get("data") or {}).get("cid") if isinstance(resp.get("data"), dict) else None, -1)
|
||||
if cid >= 0:
|
||||
return cid
|
||||
raise RuntimeError(self._response_error(resp))
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"无法创建或定位 115 目录 {target_path}: {exc}") from exc
|
||||
|
||||
def list_directory_current_layer(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]:
|
||||
target_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理"
|
||||
result = {
|
||||
"time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"ok": False,
|
||||
"path": target_path,
|
||||
"items": [],
|
||||
"file_count": 0,
|
||||
"folder_count": 0,
|
||||
"removed_count": 0,
|
||||
"message": "",
|
||||
}
|
||||
client, source, client_error = self.get_direct_client()
|
||||
if not client:
|
||||
result["message"] = client_error or "没有可用的 115 客户端"
|
||||
result["direct_source"] = source
|
||||
return False, result, result["message"]
|
||||
|
||||
cid = self._get_path_cid(client, target_path, create=False)
|
||||
if cid < 0:
|
||||
result["ok"] = True
|
||||
result["direct_source"] = source
|
||||
result["message"] = "115 默认目录不存在,视为空目录"
|
||||
return True, result, result["message"]
|
||||
|
||||
payload = {
|
||||
"cid": int(cid),
|
||||
"limit": 1150,
|
||||
"offset": 0,
|
||||
"show_dir": 1,
|
||||
"cur": 1,
|
||||
"count_folders": 1,
|
||||
}
|
||||
items: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
try:
|
||||
while True:
|
||||
resp = client.fs_files(payload, **self._p115_request_kwargs(app=False))
|
||||
if not isinstance(resp, dict):
|
||||
result["message"] = "读取 115 目录失败:返回内容异常"
|
||||
result["direct_source"] = source
|
||||
return False, result, result["message"]
|
||||
batch = resp.get("data") or []
|
||||
total = self._safe_int(resp.get("count"), total)
|
||||
for entry in batch:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
fid = self._safe_int(entry.get("fid"), -1)
|
||||
item_cid = self._safe_int(entry.get("cid"), -1)
|
||||
is_dir = fid < 0
|
||||
item_id = item_cid if is_dir else fid
|
||||
if item_id < 0:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"id": item_id,
|
||||
"name": self.normalize_text(entry.get("n") or entry.get("fn") or entry.get("file_name")),
|
||||
"is_dir": is_dir,
|
||||
"type": "folder" if is_dir else "file",
|
||||
"raw": entry,
|
||||
}
|
||||
)
|
||||
payload["offset"] = int(payload["offset"]) + len(batch)
|
||||
if not batch or len(batch) < int(payload["limit"]) or int(payload["offset"]) >= total:
|
||||
break
|
||||
except Exception as exc:
|
||||
result["message"] = f"读取 115 目录失败: {exc}"
|
||||
result["direct_source"] = source
|
||||
return False, result, result["message"]
|
||||
|
||||
file_count = len([item for item in items if not item.get("is_dir")])
|
||||
folder_count = len([item for item in items if item.get("is_dir")])
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"direct_source": source,
|
||||
"cid": cid,
|
||||
"items": items,
|
||||
"file_count": file_count,
|
||||
"folder_count": folder_count,
|
||||
"message": "success",
|
||||
}
|
||||
)
|
||||
return True, result, "success"
|
||||
|
||||
def delete_items(self, items: list[dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]:
|
||||
client, source, client_error = self.get_direct_client()
|
||||
result = {
|
||||
"ok": False,
|
||||
"direct_source": source,
|
||||
"removed_count": 0,
|
||||
"message": "",
|
||||
}
|
||||
if not client:
|
||||
result["message"] = client_error or "没有可用的 115 客户端"
|
||||
return False, result, result["message"]
|
||||
|
||||
ids = [str(self._safe_int(item.get("id"), -1)) for item in items or [] if self._safe_int(item.get("id"), -1) >= 0]
|
||||
if not ids:
|
||||
result.update({"ok": True, "message": "115 默认目录当前层已是空目录"})
|
||||
return True, result, result["message"]
|
||||
|
||||
try:
|
||||
resp = client.fs_delete(ids, **self._p115_request_kwargs(app=False))
|
||||
except Exception as exc:
|
||||
result["message"] = f"删除 115 目录内容失败: {exc}"
|
||||
return False, result, result["message"]
|
||||
|
||||
if not self._response_ok(resp):
|
||||
result["message"] = self._response_error(resp) or "删除 115 目录内容失败"
|
||||
result["raw"] = self.jsonable(resp)
|
||||
return False, result, result["message"]
|
||||
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"removed_count": len(ids),
|
||||
"message": "115 默认目录已清空当前层",
|
||||
"raw": self.jsonable(resp),
|
||||
}
|
||||
)
|
||||
return True, result, result["message"]
|
||||
|
||||
def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]:
|
||||
target_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理"
|
||||
listed_ok, listed_result, listed_message = self.list_directory_current_layer(target_path)
|
||||
if not listed_ok:
|
||||
return False, listed_result, listed_message
|
||||
|
||||
items = listed_result.get("items") or []
|
||||
if not items:
|
||||
listed_result["message"] = "115 默认目录当前层已是空目录"
|
||||
return True, listed_result, listed_result["message"]
|
||||
|
||||
delete_ok, delete_result, delete_message = self.delete_items(items)
|
||||
merged = dict(listed_result)
|
||||
merged.update(
|
||||
{
|
||||
"ok": delete_ok,
|
||||
"removed_count": delete_result.get("removed_count", 0),
|
||||
"direct_source": delete_result.get("direct_source", listed_result.get("direct_source")),
|
||||
"delete_raw": delete_result.get("raw"),
|
||||
"message": delete_message,
|
||||
}
|
||||
)
|
||||
return delete_ok, merged, delete_message
|
||||
|
||||
def transfer_share_direct(
|
||||
self,
|
||||
*,
|
||||
url: str = "",
|
||||
access_code: str = "",
|
||||
path: str = "",
|
||||
trigger: str = "Agent影视助手",
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
transfer_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理"
|
||||
share_url = self.ensure_115_share_url(url or "", access_code or "")
|
||||
result = {
|
||||
"time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"ok": False,
|
||||
"trigger": trigger,
|
||||
"strategy": "direct",
|
||||
"path": transfer_path,
|
||||
"url": share_url,
|
||||
"message": "",
|
||||
"data": {},
|
||||
}
|
||||
if not share_url:
|
||||
result["message"] = "没有可用于 115 转存的分享链接"
|
||||
return False, result, result["message"]
|
||||
if not self.is_115_share_url(share_url):
|
||||
result["message"] = "当前链接不是 115 分享链接,无法直接转存到 115"
|
||||
return False, result, result["message"]
|
||||
|
||||
share_code, receive_code = self._extract_115_payload(share_url)
|
||||
if not share_code or not receive_code:
|
||||
result["message"] = "解析 115 分享链接失败,缺少分享码或提取码"
|
||||
return False, result, result["message"]
|
||||
|
||||
client, source, client_error = self.get_direct_client()
|
||||
if not client:
|
||||
result["message"] = client_error or "没有可用的 115 直转客户端"
|
||||
result["data"] = {"direct_source": source}
|
||||
return False, result, result["message"]
|
||||
|
||||
try:
|
||||
parent_id = self._get_or_create_path_cid(client, transfer_path)
|
||||
except Exception as exc:
|
||||
result["message"] = str(exc)
|
||||
result["data"] = {"direct_source": source}
|
||||
return False, result, result["message"]
|
||||
|
||||
payload = {
|
||||
"share_code": share_code,
|
||||
"receive_code": receive_code,
|
||||
"file_id": 0,
|
||||
"cid": int(parent_id),
|
||||
"is_check": 0,
|
||||
}
|
||||
try:
|
||||
resp = client.share_receive(payload, **self._p115_request_kwargs(app=False))
|
||||
except Exception as exc:
|
||||
result["message"] = f"调用 115 直转接口失败: {exc}"
|
||||
result["data"] = {"direct_source": source, "parent_id": parent_id}
|
||||
return False, result, result["message"]
|
||||
|
||||
if not self._response_ok(resp):
|
||||
result["message"] = self._response_error(resp) or "115 直转失败"
|
||||
result["data"] = {
|
||||
"direct_source": source,
|
||||
"parent_id": parent_id,
|
||||
"raw": self.jsonable(resp),
|
||||
}
|
||||
if self._is_already_saved_message(result["message"]):
|
||||
result["ok"] = True
|
||||
result["message"] = "115 直转已存在"
|
||||
return True, result, result["message"]
|
||||
return False, result, result["message"]
|
||||
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"message": "115 直转成功",
|
||||
"data": {
|
||||
"direct_source": source,
|
||||
"share_code": share_code,
|
||||
"receive_code": receive_code,
|
||||
"save_parent": transfer_path,
|
||||
"parent_id": parent_id,
|
||||
"raw": self.jsonable(resp),
|
||||
},
|
||||
}
|
||||
)
|
||||
return True, result, result["message"]
|
||||
|
||||
def transfer_share(
|
||||
self,
|
||||
*,
|
||||
url: str = "",
|
||||
access_code: str = "",
|
||||
path: str = "",
|
||||
trigger: str = "Agent影视助手",
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
transfer_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理"
|
||||
share_url = self.ensure_115_share_url(url or "", access_code or "")
|
||||
result = {
|
||||
"time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"ok": False,
|
||||
"trigger": trigger,
|
||||
"path": transfer_path,
|
||||
"url": share_url,
|
||||
"message": "",
|
||||
"data": {},
|
||||
}
|
||||
if not share_url:
|
||||
result["message"] = "没有可用于 115 转存的分享链接"
|
||||
return False, result, result["message"]
|
||||
if not self.is_115_share_url(share_url):
|
||||
result["message"] = "当前链接不是 115 分享链接,无法直接转存到 115"
|
||||
return False, result, result["message"]
|
||||
|
||||
if self.prefer_direct:
|
||||
direct_ok, direct_result, direct_message = self.transfer_share_direct(
|
||||
url=share_url,
|
||||
access_code=access_code,
|
||||
path=transfer_path,
|
||||
trigger=trigger,
|
||||
)
|
||||
if direct_ok:
|
||||
return True, direct_result, direct_message
|
||||
result["data"]["direct_fallback"] = direct_result
|
||||
|
||||
helper, helper_error = self.get_share_helper()
|
||||
if helper_error or not helper:
|
||||
direct_error = ((result.get("data") or {}).get("direct_fallback") or {}).get("message")
|
||||
result["message"] = (
|
||||
"115 转存不可用:请先发“115登录”完成扫码,或检查 115 直转依赖。"
|
||||
f" 直转状态:{direct_error or '未知'};兼容 fallback:{helper_error or '不可用'}"
|
||||
)
|
||||
return False, result, result["message"]
|
||||
|
||||
try:
|
||||
transfer_result = helper.add_share_115(
|
||||
share_url,
|
||||
notify=False,
|
||||
pan_path=transfer_path,
|
||||
)
|
||||
except Exception as exc:
|
||||
result["message"] = f"调用 P115StrmHelper 转存失败: {exc}"
|
||||
return False, result, result["message"]
|
||||
|
||||
if not transfer_result or not transfer_result[0]:
|
||||
error_message = ""
|
||||
if isinstance(transfer_result, tuple):
|
||||
if len(transfer_result) > 2:
|
||||
error_message = self.normalize_text(transfer_result[2])
|
||||
elif len(transfer_result) > 1:
|
||||
error_message = self.normalize_text(transfer_result[1])
|
||||
if self._is_already_saved_message(error_message):
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"strategy": "p115strmhelper",
|
||||
"message": "115 转存已存在",
|
||||
"data": {"raw": self.jsonable(transfer_result)},
|
||||
}
|
||||
)
|
||||
return True, result, result["message"]
|
||||
result["message"] = error_message or "115 转存失败"
|
||||
result["data"] = {"raw": self.jsonable(transfer_result)}
|
||||
return False, result, result["message"]
|
||||
|
||||
media_info = transfer_result[1] if len(transfer_result) > 1 else None
|
||||
save_parent = transfer_result[2] if len(transfer_result) > 2 else transfer_path
|
||||
parent_id = transfer_result[3] if len(transfer_result) > 3 else None
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"strategy": "p115strmhelper",
|
||||
"message": "115 转存成功",
|
||||
"data": {
|
||||
"media_info": self.jsonable(media_info),
|
||||
"save_parent": save_parent,
|
||||
"parent_id": parent_id,
|
||||
},
|
||||
}
|
||||
)
|
||||
return True, result, result["message"]
|
||||
675
plugins.v2/agentresourceofficer/services/quark_transfer.py
Normal file
@@ -0,0 +1,675 @@
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import parse_qsl, urlparse, urlencode
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
|
||||
from app.log import logger
|
||||
|
||||
try:
|
||||
from app.core.config import settings
|
||||
except Exception:
|
||||
settings = None
|
||||
|
||||
|
||||
class QuarkTransferService:
|
||||
"""
|
||||
Reusable execution layer migrated out of QuarkShareSaver.
|
||||
|
||||
This service intentionally focuses on transfer execution and directory
|
||||
resolution. UI, plugin form logic, and entry adapters stay outside.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cookie: str = "",
|
||||
timeout: int = 30,
|
||||
default_target_path: str = "/飞书",
|
||||
auto_import_cookiecloud: bool = True,
|
||||
cookie_refresh_callback: Optional[Callable[[], str]] = None,
|
||||
) -> None:
|
||||
self.cookie = self.clean_text(cookie)
|
||||
self.timeout = max(10, self.safe_int(timeout, 30))
|
||||
self.default_target_path = self.normalize_path(default_target_path or "/飞书")
|
||||
self.auto_import_cookiecloud = auto_import_cookiecloud
|
||||
self.cookie_refresh_callback = cookie_refresh_callback
|
||||
self.path_cache: Dict[str, str] = {"/": "0"}
|
||||
|
||||
@staticmethod
|
||||
def clean_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
@staticmethod
|
||||
def safe_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def normalize_path(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return "/"
|
||||
if not text.startswith("/"):
|
||||
text = f"/{text}"
|
||||
text = re.sub(r"/+", "/", text)
|
||||
return text.rstrip("/") or "/"
|
||||
|
||||
@staticmethod
|
||||
def extract_url(raw_text: str) -> str:
|
||||
match = re.search(r"https?://[^\s<>\"']+", raw_text)
|
||||
if match:
|
||||
return match.group(0).rstrip(".,);]")
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def extract_share_info(cls, share_text: str, access_code: str = "") -> Tuple[str, str, str]:
|
||||
raw = cls.clean_text(share_text)
|
||||
share_url = cls.extract_url(raw) or raw
|
||||
parsed = urlparse(share_url)
|
||||
pwd_id_match = re.search(r"/s/([^/?#]+)", parsed.path)
|
||||
pwd_id = pwd_id_match.group(1).strip() if pwd_id_match else ""
|
||||
|
||||
code = cls.clean_text(access_code)
|
||||
if not code:
|
||||
query = dict(parse_qsl(parsed.query))
|
||||
code = cls.clean_text(query.get("pwd") or query.get("passcode") or query.get("code"))
|
||||
if not code and raw:
|
||||
for token in raw.replace(share_url, " ").split():
|
||||
text = token.strip()
|
||||
if not text:
|
||||
continue
|
||||
if "=" in text:
|
||||
key, value = text.split("=", 1)
|
||||
if key.strip().lower() in {"pwd", "passcode", "code", "提取码"}:
|
||||
code = cls.clean_text(value)
|
||||
break
|
||||
elif len(text) <= 8 and not text.startswith("/"):
|
||||
code = text
|
||||
break
|
||||
|
||||
return share_url, pwd_id, code
|
||||
|
||||
@staticmethod
|
||||
def is_quark_share_url(share_url: str) -> bool:
|
||||
hostname = urlparse(share_url).hostname or ""
|
||||
hostname = hostname.lower().strip(".")
|
||||
return hostname.endswith("quark.cn")
|
||||
|
||||
@classmethod
|
||||
def validate_share_url(cls, share_url: str) -> Tuple[bool, str]:
|
||||
if not share_url:
|
||||
return False, "未识别到有效夸克分享链接"
|
||||
if cls.is_quark_share_url(share_url):
|
||||
return True, ""
|
||||
hostname = urlparse(share_url).hostname or "未知域名"
|
||||
return False, f"当前链接域名为 {hostname},这不是夸克分享链接,请换成 pan.quark.cn 的分享链接"
|
||||
|
||||
def set_cookie(self, cookie: str) -> None:
|
||||
self.cookie = self.clean_text(cookie)
|
||||
|
||||
def _tz_now(self) -> datetime:
|
||||
if settings is not None:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai")))
|
||||
except Exception:
|
||||
pass
|
||||
return datetime.now()
|
||||
|
||||
def _build_headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Cookie": self.cookie,
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/137.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Origin": "https://pan.quark.cn",
|
||||
"Referer": "https://pan.quark.cn/",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _common_params() -> Dict[str, Any]:
|
||||
now = int(time.time() * 1000)
|
||||
return {
|
||||
"pr": "ucpro",
|
||||
"fr": "pc",
|
||||
"uc_param_str": "",
|
||||
"__dt": random.randint(100, 9999),
|
||||
"__t": now,
|
||||
}
|
||||
|
||||
def _refresh_cookie(self) -> bool:
|
||||
if not self.auto_import_cookiecloud or not self.cookie_refresh_callback:
|
||||
return False
|
||||
try:
|
||||
cookie = self.clean_text(self.cookie_refresh_callback())
|
||||
except Exception as exc:
|
||||
logger.warning(f"[Agent影视助手] 刷新夸克 Cookie 失败: {exc}")
|
||||
return False
|
||||
if not cookie:
|
||||
return False
|
||||
self.cookie = cookie
|
||||
return True
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
allow_cookie_retry: bool = True,
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
final_url = url
|
||||
if params:
|
||||
query = urlencode([(key, "" if value is None else value) for key, value in params.items()])
|
||||
final_url = f"{url}?{query}" if query else url
|
||||
|
||||
payload = None
|
||||
if json_body is not None:
|
||||
payload = json.dumps(json_body).encode("utf-8")
|
||||
|
||||
try:
|
||||
request = UrlRequest(
|
||||
url=final_url,
|
||||
data=payload,
|
||||
headers=self._build_headers(),
|
||||
method=method.upper(),
|
||||
)
|
||||
with urlopen(request, timeout=self.timeout) as response:
|
||||
status_code = getattr(response, "status", 200)
|
||||
raw_body = response.read()
|
||||
except HTTPError as exc:
|
||||
status_code = exc.code
|
||||
raw_body = exc.read() if hasattr(exc, "read") else b""
|
||||
except URLError as exc:
|
||||
return False, {}, f"请求失败: {exc.reason}"
|
||||
except Exception as exc:
|
||||
return False, {}, f"请求失败: {exc}"
|
||||
|
||||
try:
|
||||
data = json.loads(raw_body.decode("utf-8"))
|
||||
except Exception:
|
||||
text = raw_body.decode("utf-8", errors="ignore")[:300]
|
||||
return False, {}, f"接口返回非 JSON: HTTP {status_code} {text}"
|
||||
|
||||
if status_code in {401, 403} and allow_cookie_retry and self._refresh_cookie():
|
||||
return self._request(
|
||||
method,
|
||||
url,
|
||||
params=params,
|
||||
json_body=json_body,
|
||||
allow_cookie_retry=False,
|
||||
)
|
||||
|
||||
if status_code != 200:
|
||||
if isinstance(data, dict):
|
||||
code = self.clean_text(data.get("code"))
|
||||
detail = self.clean_text(data.get("message") or data.get("msg"))
|
||||
if detail:
|
||||
if code:
|
||||
return False, data, f"HTTP {status_code} [{code}]: {detail}"
|
||||
return False, data, f"HTTP {status_code}: {detail}"
|
||||
return False, data if isinstance(data, dict) else {}, f"HTTP {status_code}"
|
||||
|
||||
if isinstance(data, dict):
|
||||
message = str(data.get("message") or data.get("msg") or "").strip()
|
||||
ok = data.get("status") == 200 or data.get("code") == 0 or message == "ok"
|
||||
if ok:
|
||||
return True, data, ""
|
||||
return False, data, message or "接口返回失败"
|
||||
|
||||
return False, {}, "接口返回格式错误"
|
||||
|
||||
def get_stoken(self, pwd_id: str, access_code: str = "") -> Tuple[bool, str, str]:
|
||||
ok, data, message = self._request(
|
||||
"POST",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token",
|
||||
params=self._common_params(),
|
||||
json_body={"pwd_id": pwd_id, "passcode": access_code or ""},
|
||||
)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
|
||||
stoken = self.clean_text((data.get("data") or {}).get("stoken"))
|
||||
if not stoken:
|
||||
return False, "", "未获取到 stoken,可能是提取码错误或 Cookie 失效"
|
||||
return True, stoken, ""
|
||||
|
||||
def get_share_items(self, pwd_id: str, stoken: str) -> Tuple[bool, List[Dict[str, Any]], str]:
|
||||
items: List[Dict[str, Any]] = []
|
||||
page = 1
|
||||
while True:
|
||||
params = self._common_params()
|
||||
params.update(
|
||||
{
|
||||
"pwd_id": pwd_id,
|
||||
"stoken": stoken,
|
||||
"pdir_fid": "0",
|
||||
"force": "0",
|
||||
"_page": str(page),
|
||||
"_size": "50",
|
||||
"_sort": "file_type:asc,updated_at:desc",
|
||||
}
|
||||
)
|
||||
ok, data, message = self._request(
|
||||
"GET",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail",
|
||||
params=params,
|
||||
)
|
||||
if not ok:
|
||||
return False, [], message
|
||||
|
||||
payload = data.get("data") or {}
|
||||
meta = data.get("metadata") or {}
|
||||
current = payload.get("list") or []
|
||||
for item in current:
|
||||
items.append(
|
||||
{
|
||||
"fid": str(item.get("fid") or ""),
|
||||
"file_name": str(item.get("file_name") or ""),
|
||||
"dir": bool(item.get("dir")),
|
||||
"file_type": item.get("file_type"),
|
||||
"pdir_fid": str(item.get("pdir_fid") or ""),
|
||||
"share_fid_token": str(item.get("share_fid_token") or ""),
|
||||
}
|
||||
)
|
||||
|
||||
total = self.safe_int(meta.get("_total"), 0)
|
||||
count = self.safe_int(meta.get("_count"), len(current))
|
||||
size = max(1, self.safe_int(meta.get("_size"), 50))
|
||||
if total <= len(items) or count < size:
|
||||
break
|
||||
page += 1
|
||||
|
||||
if not items:
|
||||
return False, [], "分享链接为空,或当前账号无权查看内容"
|
||||
return True, items, ""
|
||||
|
||||
def list_children(self, parent_fid: str) -> Tuple[bool, List[Dict[str, Any]], str]:
|
||||
page = 1
|
||||
result: List[Dict[str, Any]] = []
|
||||
while True:
|
||||
params = {
|
||||
"pr": "ucpro",
|
||||
"fr": "pc",
|
||||
"uc_param_str": "",
|
||||
"pdir_fid": parent_fid,
|
||||
"_page": page,
|
||||
"_size": 100,
|
||||
"_fetch_total": 1,
|
||||
"_fetch_sub_dirs": 0,
|
||||
"_sort": "file_type:asc,updated_at:desc",
|
||||
}
|
||||
ok, data, message = self._request(
|
||||
"GET",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/file/sort",
|
||||
params=params,
|
||||
)
|
||||
if not ok:
|
||||
return False, [], message
|
||||
|
||||
current = ((data.get("data") or {}).get("list")) or []
|
||||
for item in current:
|
||||
result.append(
|
||||
{
|
||||
"fid": str(item.get("fid") or ""),
|
||||
"name": str(item.get("file_name") or ""),
|
||||
"dir": int(item.get("file_type") or 0) == 0,
|
||||
"size": item.get("size") or 0,
|
||||
"updated_at": item.get("updated_at") or 0,
|
||||
"raw": item,
|
||||
}
|
||||
)
|
||||
if len(current) < 100:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return True, result, ""
|
||||
|
||||
def delete_items(self, items: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]:
|
||||
source_items = [item for item in (items or []) if isinstance(item, dict)]
|
||||
|
||||
def build_fids(candidates: List[Dict[str, Any]]) -> List[str]:
|
||||
result: List[str] = []
|
||||
for item in candidates:
|
||||
fid = self.clean_text(item.get("fid"))
|
||||
if fid:
|
||||
result.append(fid)
|
||||
return result
|
||||
|
||||
def item_label(item: Dict[str, Any]) -> str:
|
||||
return self.clean_text(item.get("name") or item.get("file_name") or item.get("fid"))
|
||||
|
||||
def call_delete(candidates: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]:
|
||||
fids = build_fids(candidates)
|
||||
if not fids:
|
||||
return False, {}, "默认目录当前层没有可删除项目"
|
||||
payloads = [
|
||||
{
|
||||
"action_type": 2,
|
||||
"exclude_fids": [],
|
||||
"filelist": [{"fid": fid} for fid in fids],
|
||||
},
|
||||
{
|
||||
"action_type": 2,
|
||||
"exclude_fids": [],
|
||||
"filelist": fids,
|
||||
},
|
||||
{
|
||||
# Some web scripts historically used this misspelled key.
|
||||
"actoin_type": 2,
|
||||
"exclude_fids": [],
|
||||
"filelist": fids,
|
||||
},
|
||||
]
|
||||
last_data: Dict[str, Any] = {}
|
||||
last_message = ""
|
||||
for index, payload in enumerate(payloads, start=1):
|
||||
ok, data, message = self._request(
|
||||
"POST",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/file/delete",
|
||||
params={
|
||||
"pr": "ucpro",
|
||||
"fr": "pc",
|
||||
"uc_param_str": "",
|
||||
},
|
||||
json_body=payload,
|
||||
)
|
||||
if ok:
|
||||
if isinstance(data, dict):
|
||||
data["delete_payload_variant"] = index
|
||||
return True, data, ""
|
||||
last_data = data if isinstance(data, dict) else {}
|
||||
last_message = message or last_message
|
||||
return False, last_data, last_message or "夸克删除失败"
|
||||
|
||||
filelist: List[Dict[str, Any]] = []
|
||||
for item in source_items:
|
||||
fid = self.clean_text((item or {}).get("fid")) if isinstance(item, dict) else ""
|
||||
if fid:
|
||||
filelist.append({"fid": fid})
|
||||
if not filelist:
|
||||
return False, {}, "默认目录当前层没有可删除项目"
|
||||
|
||||
ok, data, message = call_delete(source_items)
|
||||
if ok:
|
||||
data["deleted_count"] = len(filelist)
|
||||
data["delete_mode"] = "batch"
|
||||
return True, data, ""
|
||||
|
||||
if len(source_items) <= 1:
|
||||
return False, data, message or "夸克删除失败"
|
||||
|
||||
deleted_count = 0
|
||||
failed_items: List[Dict[str, Any]] = []
|
||||
for item in source_items:
|
||||
single_ok, single_data, single_message = call_delete([item])
|
||||
if single_ok:
|
||||
deleted_count += 1
|
||||
continue
|
||||
failed_items.append({
|
||||
"fid": self.clean_text(item.get("fid")),
|
||||
"name": item_label(item),
|
||||
"message": single_message or "删除失败",
|
||||
"result": single_data,
|
||||
})
|
||||
|
||||
result = {
|
||||
"deleted_count": deleted_count,
|
||||
"failed_count": len(failed_items),
|
||||
"failed_items": failed_items[:20],
|
||||
"delete_mode": "single_fallback",
|
||||
"batch_error": message or "夸克批量删除失败",
|
||||
"batch_result": data,
|
||||
}
|
||||
if failed_items:
|
||||
return False, result, f"夸克逐项删除后仍有 {len(failed_items)} 项失败"
|
||||
return True, result, ""
|
||||
|
||||
def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]:
|
||||
ok, target_fid, normalized_path = self.ensure_target_dir(path or self.default_target_path)
|
||||
if not ok:
|
||||
return False, {}, target_fid or "定位夸克目录失败"
|
||||
|
||||
ok, children, message = self.list_children(target_fid)
|
||||
if not ok:
|
||||
return False, {}, message or "读取夸克目录失败"
|
||||
|
||||
files = [item for item in children if not bool(item.get("dir"))]
|
||||
folders = [item for item in children if bool(item.get("dir"))]
|
||||
if not children:
|
||||
return True, {
|
||||
"target_path": normalized_path,
|
||||
"target_fid": target_fid,
|
||||
"removed_count": 0,
|
||||
"file_count": 0,
|
||||
"folder_count": 0,
|
||||
"items": [],
|
||||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}, "默认目录当前层为空"
|
||||
|
||||
ok, delete_result, message = self.delete_items(children)
|
||||
removed_count = self.safe_int((delete_result or {}).get("deleted_count"), len(children) if ok else 0)
|
||||
if not ok:
|
||||
return False, {
|
||||
"target_path": normalized_path,
|
||||
"target_fid": target_fid,
|
||||
"file_count": len(files),
|
||||
"folder_count": len(folders),
|
||||
"removed_count": removed_count,
|
||||
"items": [self.clean_text(item.get("name")) for item in children[:20]],
|
||||
"failed_items": (delete_result or {}).get("failed_items") or [],
|
||||
"delete_result": delete_result,
|
||||
}, message or "夸克清空默认目录失败"
|
||||
|
||||
return True, {
|
||||
"target_path": normalized_path,
|
||||
"target_fid": target_fid,
|
||||
"removed_count": removed_count,
|
||||
"file_count": len(files),
|
||||
"folder_count": len(folders),
|
||||
"items": [self.clean_text(item.get("name")) for item in children[:20]],
|
||||
"delete_result": delete_result,
|
||||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}, "success"
|
||||
|
||||
def find_child_dir(self, parent_fid: str, name: str) -> Tuple[bool, str, str]:
|
||||
ok, items, message = self.list_children(parent_fid)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
for item in items:
|
||||
if item.get("dir") and item.get("name") == name:
|
||||
return True, str(item.get("fid") or ""), ""
|
||||
return True, "", ""
|
||||
|
||||
def create_folder(self, parent_fid: str, name: str) -> Tuple[bool, str, str]:
|
||||
ok, data, message = self._request(
|
||||
"POST",
|
||||
"https://pan.quark.cn/1/clouddrive/file/create",
|
||||
json_body={
|
||||
"pdir_fid": parent_fid,
|
||||
"file_name": name,
|
||||
"dir_path": "",
|
||||
"dir_init_lock": False,
|
||||
},
|
||||
)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
|
||||
folder = data.get("data") or {}
|
||||
folder_id = self.clean_text(folder.get("fid") or folder.get("file_id"))
|
||||
if not folder_id:
|
||||
return False, "", "创建目录成功但未返回 fid"
|
||||
return True, folder_id, ""
|
||||
|
||||
def ensure_target_dir(self, path: str) -> Tuple[bool, str, str]:
|
||||
normalized = self.normalize_path(path or self.default_target_path)
|
||||
if normalized == "/":
|
||||
return True, "0", normalized
|
||||
cached = self.path_cache.get(normalized)
|
||||
if cached:
|
||||
return True, cached, normalized
|
||||
|
||||
current_fid = "0"
|
||||
built = ""
|
||||
for part in [segment for segment in normalized.split("/") if segment]:
|
||||
built = f"{built}/{part}" if built else f"/{part}"
|
||||
cached = self.path_cache.get(built)
|
||||
if cached:
|
||||
current_fid = cached
|
||||
continue
|
||||
|
||||
ok, found_fid, message = self.find_child_dir(current_fid, part)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
if not found_fid:
|
||||
ok, found_fid, message = self.create_folder(current_fid, part)
|
||||
if not ok:
|
||||
return False, "", f"创建目录失败 {built}: {message}"
|
||||
self.path_cache[built] = found_fid
|
||||
current_fid = found_fid
|
||||
return True, current_fid, normalized
|
||||
|
||||
def create_save_task(
|
||||
self,
|
||||
pwd_id: str,
|
||||
stoken: str,
|
||||
items: List[Dict[str, Any]],
|
||||
to_pdir_fid: str,
|
||||
) -> Tuple[bool, str, str]:
|
||||
fid_list = [str(item.get("fid") or "") for item in items if item.get("fid")]
|
||||
fid_token_list = [
|
||||
str(item.get("share_fid_token") or "")
|
||||
for item in items
|
||||
if item.get("fid") and item.get("share_fid_token")
|
||||
]
|
||||
if not fid_list or len(fid_list) != len(fid_token_list):
|
||||
return False, "", "分享内容缺少 fid 或 share_fid_token,无法转存"
|
||||
|
||||
params = self._common_params()
|
||||
ok, data, message = self._request(
|
||||
"POST",
|
||||
"https://drive.quark.cn/1/clouddrive/share/sharepage/save",
|
||||
params=params,
|
||||
json_body={
|
||||
"fid_list": fid_list,
|
||||
"fid_token_list": fid_token_list,
|
||||
"to_pdir_fid": to_pdir_fid,
|
||||
"pwd_id": pwd_id,
|
||||
"stoken": stoken,
|
||||
"pdir_fid": "0",
|
||||
"scene": "link",
|
||||
},
|
||||
)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
|
||||
task_id = self.clean_text((data.get("data") or {}).get("task_id"))
|
||||
if not task_id:
|
||||
return False, "", "未获取到转存任务 ID"
|
||||
return True, task_id, ""
|
||||
|
||||
def wait_task(self, task_id: str, retry: int = 20) -> Tuple[bool, Dict[str, Any], str]:
|
||||
for index in range(retry):
|
||||
time.sleep(1.0 if index == 0 else 1.5)
|
||||
params = {
|
||||
"pr": "ucpro",
|
||||
"fr": "pc",
|
||||
"uc_param_str": "",
|
||||
"task_id": task_id,
|
||||
"retry_index": index,
|
||||
"__dt": 21192,
|
||||
"__t": int(time.time() * 1000),
|
||||
}
|
||||
ok, data, message = self._request(
|
||||
"GET",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/task",
|
||||
params=params,
|
||||
)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
|
||||
task = data.get("data") or {}
|
||||
status = self.safe_int(task.get("status"), -1)
|
||||
if status == 2:
|
||||
return True, task, ""
|
||||
if status in {3, 4, 5, 6, 7}:
|
||||
return False, task, self.clean_text(task.get("message")) or "夸克任务执行失败"
|
||||
|
||||
return False, {}, "等待夸克转存任务超时"
|
||||
|
||||
def check_cookie(self) -> Tuple[bool, str]:
|
||||
ok, _, message = self.list_children("0")
|
||||
if ok:
|
||||
return True, ""
|
||||
return False, message or "Cookie 校验失败"
|
||||
|
||||
def transfer_share(
|
||||
self,
|
||||
share_text: str,
|
||||
access_code: str = "",
|
||||
target_path: str = "",
|
||||
*,
|
||||
trigger: str = "Agent影视助手",
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
share_url, pwd_id, final_code = self.extract_share_info(share_text, access_code)
|
||||
ok, message = self.validate_share_url(share_url)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
if not pwd_id:
|
||||
return False, {}, "未识别到有效夸克分享链接"
|
||||
if not self.cookie:
|
||||
self._refresh_cookie()
|
||||
if not self.cookie:
|
||||
return False, {}, "未配置夸克 Cookie"
|
||||
|
||||
ok, stoken, message = self.get_stoken(pwd_id, final_code)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
|
||||
ok, share_items, message = self.get_share_items(pwd_id, stoken)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
|
||||
ok, target_fid, normalized_path = self.ensure_target_dir(target_path or self.default_target_path)
|
||||
if not ok:
|
||||
return False, {}, target_fid
|
||||
|
||||
ok, task_id, message = self.create_save_task(pwd_id, stoken, share_items, target_fid)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
|
||||
ok, task, message = self.wait_task(task_id)
|
||||
if not ok:
|
||||
return False, {"task_id": task_id}, message
|
||||
|
||||
item_names = [str(item.get("file_name") or "") for item in share_items if item.get("file_name")]
|
||||
result = {
|
||||
"share_url": share_url,
|
||||
"pwd_id": pwd_id,
|
||||
"access_code": final_code,
|
||||
"target_path": normalized_path,
|
||||
"target_fid": target_fid,
|
||||
"task_id": task_id,
|
||||
"saved_count": len(share_items),
|
||||
"items": item_names[:20],
|
||||
"task": task,
|
||||
"trigger": trigger,
|
||||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
return True, result, "success"
|
||||
388
plugins.v2/agentresourceofficer/services/streaming_recommend.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
流媒体推荐 — TMDB Discover 直连服务
|
||||
|
||||
直接调用 TMDB discover API,不走 MoviePilot RecommendChain。
|
||||
原因:RecommendChain 不支持 with_watch_providers + 时间窗口组合筛选。
|
||||
|
||||
支持的能力:
|
||||
- 按流媒体平台聚合(Netflix / Disney+ / Apple TV+ / Prime Video)
|
||||
- 严格按时间窗口过滤(本月 / 近N天)
|
||||
- 按热度 + 评分 + 投票人数综合排序
|
||||
- 区分电影 / 剧集 / 全部
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
|
||||
# TMDB Watch Provider ID 映射
|
||||
PROVIDER_MAP: Dict[str, int] = {
|
||||
"netflix": 8,
|
||||
"disney": 337,
|
||||
"disney+": 337,
|
||||
"apple": 384,
|
||||
"apple tv+": 384,
|
||||
"prime": 10,
|
||||
"prime video": 10,
|
||||
"amazon": 10,
|
||||
}
|
||||
|
||||
# 默认聚合平台
|
||||
DEFAULT_PROVIDER_IDS: List[int] = [8, 337, 384, 10]
|
||||
|
||||
# 默认地区(CN / US)
|
||||
DEFAULT_WATCH_REGION = "US"
|
||||
|
||||
# 综合排序权重
|
||||
SCORE_WEIGHTS = {
|
||||
"popularity": 0.4,
|
||||
"vote_average": 0.35,
|
||||
"vote_count_norm": 0.15,
|
||||
"freshness": 0.1,
|
||||
}
|
||||
|
||||
|
||||
class StreamingRecommendService:
|
||||
"""TMDB Discover 直连,返回流媒体推荐列表"""
|
||||
|
||||
def __init__(self, tmdb_api_key: str):
|
||||
self._api_key = tmdb_api_key.strip()
|
||||
|
||||
# ─── 公开入口 ────────────────────────────────────────────────
|
||||
|
||||
async def query(
|
||||
self,
|
||||
*,
|
||||
media_type: str = "all",
|
||||
intent: str = "hot",
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
window_days: int = 90,
|
||||
providers: Optional[List[int]] = None,
|
||||
watch_region: str = DEFAULT_WATCH_REGION,
|
||||
limit: int = 15,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
查询流媒体推荐。
|
||||
|
||||
返回:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str,
|
||||
"items": [ { index, title, year, media_type, release_date,
|
||||
popularity, vote_average, vote_count,
|
||||
providers_str, reason } ],
|
||||
"query_params": { ... },
|
||||
}
|
||||
"""
|
||||
if not self._api_key:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "TMDB API Key 未配置,无法查询流媒体推荐。",
|
||||
"items": [],
|
||||
"query_params": {},
|
||||
}
|
||||
|
||||
provider_ids = providers or DEFAULT_PROVIDER_IDS
|
||||
media_type = (media_type or "all").lower()
|
||||
intent = (intent or "hot").lower()
|
||||
|
||||
# ── 时间窗口 ──
|
||||
final_start, final_end = self._resolve_time_range(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
window_days=window_days,
|
||||
)
|
||||
|
||||
# ── 分别查电影和剧集 ──
|
||||
all_items: List[Dict[str, Any]] = []
|
||||
|
||||
if media_type in ("movie", "all"):
|
||||
movie_items = await self._discover(
|
||||
media_category="movie",
|
||||
intent=intent,
|
||||
start_date=final_start,
|
||||
end_date=final_end,
|
||||
provider_ids=provider_ids,
|
||||
watch_region=watch_region,
|
||||
limit=limit if media_type == "movie" else limit * 2,
|
||||
)
|
||||
all_items.extend(movie_items)
|
||||
|
||||
if media_type in ("tv", "all"):
|
||||
tv_items = await self._discover(
|
||||
media_category="tv",
|
||||
intent=intent,
|
||||
start_date=final_start,
|
||||
end_date=final_end,
|
||||
provider_ids=provider_ids,
|
||||
watch_region=watch_region,
|
||||
limit=limit if media_type == "tv" else limit * 2,
|
||||
)
|
||||
all_items.extend(tv_items)
|
||||
|
||||
# ── 综合排序并截断 ──
|
||||
ranked = self._rank(all_items, intent=intent)
|
||||
trimmed = ranked[:limit]
|
||||
|
||||
# ── 编号 & 推荐理由 ──
|
||||
for idx, item in enumerate(trimmed, start=1):
|
||||
item["index"] = idx
|
||||
item["reason"] = self._generate_reason(item, intent)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "",
|
||||
"items": trimmed,
|
||||
"query_params": {
|
||||
"media_type": media_type,
|
||||
"intent": intent,
|
||||
"start_date": final_start,
|
||||
"end_date": final_end,
|
||||
"provider_ids": provider_ids,
|
||||
"watch_region": watch_region,
|
||||
"count": len(trimmed),
|
||||
},
|
||||
}
|
||||
|
||||
# ─── TMDB Discover 直连 ──────────────────────────────────────
|
||||
|
||||
async def _discover(
|
||||
self,
|
||||
*,
|
||||
media_category: str,
|
||||
intent: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
provider_ids: List[int],
|
||||
watch_region: str,
|
||||
limit: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
调用 TMDB discover/movie 或 discover/tv。
|
||||
返回标准化的条目列表。
|
||||
"""
|
||||
endpoint = "movie" if media_category == "movie" else "tv"
|
||||
date_field = (
|
||||
"primary_release_date.gte" if media_category == "movie"
|
||||
else "first_air_date.gte"
|
||||
)
|
||||
date_field_end = (
|
||||
"primary_release_date.lte" if media_category == "movie"
|
||||
else "first_air_date.lte"
|
||||
)
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"api_key": self._api_key,
|
||||
"language": "zh-CN",
|
||||
"sort_by": "popularity.desc",
|
||||
"watch_region": watch_region,
|
||||
"with_watch_providers": "|".join(str(p) for p in provider_ids),
|
||||
"with_watch_monetization_types": "flatrate",
|
||||
"vote_count.gte": self._min_vote_count(intent),
|
||||
"page": 1,
|
||||
}
|
||||
|
||||
# 严格时间过滤
|
||||
if start_date:
|
||||
params[date_field] = start_date
|
||||
if end_date:
|
||||
params[date_field_end] = end_date
|
||||
|
||||
url = f"https://api.themoviedb.org/3/discover/{endpoint}?" + urlencode(params)
|
||||
|
||||
try:
|
||||
request = UrlRequest(url=url, headers={"Accept": "application/json"})
|
||||
with urlopen(request, timeout=20) as response:
|
||||
payload = json.loads(response.read().decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
return []
|
||||
|
||||
raw_results = payload.get("results") or []
|
||||
if not isinstance(raw_results, list):
|
||||
return []
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for raw in raw_results:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
item = self._normalize_item(raw, media_category)
|
||||
if item:
|
||||
items.append(item)
|
||||
|
||||
return items[:limit * 2]
|
||||
|
||||
def _normalize_item(self, raw: Dict[str, Any], media_category: str) -> Optional[Dict[str, Any]]:
|
||||
"""把 TMDB 原始条目转为标准格式"""
|
||||
title = (
|
||||
raw.get("title")
|
||||
or raw.get("name")
|
||||
or raw.get("original_title")
|
||||
or raw.get("original_name")
|
||||
or ""
|
||||
).strip()
|
||||
if not title:
|
||||
return None
|
||||
|
||||
release_date = raw.get("release_date") or raw.get("primary_release_date") or raw.get("first_air_date") or ""
|
||||
year = str(release_date)[:4] if release_date else ""
|
||||
|
||||
popularity = float(raw.get("popularity") or 0)
|
||||
vote_average = float(raw.get("vote_average") or 0)
|
||||
vote_count = int(raw.get("vote_count") or 0)
|
||||
|
||||
# 处理 media_type
|
||||
raw_type = raw.get("media_type") or ""
|
||||
if media_category == "movie":
|
||||
display_type = "电影"
|
||||
elif media_category == "tv":
|
||||
display_type = "剧集"
|
||||
else:
|
||||
display_type = "电影" if raw_type == "movie" else "剧集"
|
||||
|
||||
# provider_ids 从原数据获取
|
||||
provider_ids_raw = raw.get("origin_country") or []
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"year": year,
|
||||
"media_type": display_type,
|
||||
"release_date": release_date,
|
||||
"popularity": round(popularity, 1),
|
||||
"vote_average": round(vote_average, 1),
|
||||
"vote_count": vote_count,
|
||||
"tmdb_id": raw.get("id"),
|
||||
"provider_ids_raw": provider_ids_raw,
|
||||
}
|
||||
|
||||
# ─── 综合排序 ────────────────────────────────────────────────
|
||||
|
||||
def _rank(self, items: List[Dict[str, Any]], intent: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
按综合分排序。
|
||||
intent 影响权重:big_titles 偏重评分,hot 偏重热度,new 偏重新鲜度。
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
|
||||
weights = dict(SCORE_WEIGHTS)
|
||||
if intent == "big_titles":
|
||||
weights["vote_average"] = 0.45
|
||||
weights["popularity"] = 0.25
|
||||
weights["vote_count_norm"] = 0.2
|
||||
weights["freshness"] = 0.1
|
||||
elif intent == "new":
|
||||
weights["freshness"] = 0.3
|
||||
weights["popularity"] = 0.3
|
||||
weights["vote_average"] = 0.25
|
||||
weights["vote_count_norm"] = 0.15
|
||||
|
||||
# 归一化基准
|
||||
max_pop = max((i.get("popularity") or 0) for i in items) or 1
|
||||
max_votes = max((i.get("vote_count") or 0) for i in items) or 1
|
||||
|
||||
today = date.today()
|
||||
|
||||
def score(item: Dict[str, Any]) -> float:
|
||||
pop = (item.get("popularity") or 0) / max_pop
|
||||
avg = (item.get("vote_average") or 0) / 10.0
|
||||
vc = (item.get("vote_count") or 0) / max_votes
|
||||
# 新鲜度:发布越近分越高(90天内线性衰减)
|
||||
try:
|
||||
rd = item.get("release_date") or ""
|
||||
days_ago = (today - date.fromisoformat(rd[:10])).days if rd and len(rd) >= 10 else 180
|
||||
except Exception:
|
||||
days_ago = 180
|
||||
freshness = max(0.0, 1.0 - days_ago / 180.0)
|
||||
return (
|
||||
weights["popularity"] * pop
|
||||
+ weights["vote_average"] * avg
|
||||
+ weights["vote_count_norm"] * vc
|
||||
+ weights["freshness"] * freshness
|
||||
)
|
||||
|
||||
items.sort(key=score, reverse=True)
|
||||
return items
|
||||
|
||||
# ─── 推荐理由 ────────────────────────────────────────────────
|
||||
|
||||
def _generate_reason(self, item: Dict[str, Any], intent: str) -> str:
|
||||
"""基于数据生成一句话推荐理由,不经过 LLM"""
|
||||
avg = item.get("vote_average") or 0
|
||||
pop = item.get("popularity") or 0
|
||||
votes = item.get("vote_count") or 0
|
||||
|
||||
if intent == "big_titles":
|
||||
if avg >= 8.0 and votes >= 500:
|
||||
return "高口碑大作"
|
||||
if avg >= 7.0 and votes >= 200:
|
||||
return "口碑不错"
|
||||
return "值得关注"
|
||||
|
||||
if intent == "new":
|
||||
if pop >= 500:
|
||||
return "新上线即爆"
|
||||
if avg >= 7.0:
|
||||
return "新上线口碑佳"
|
||||
return "新上线"
|
||||
|
||||
# hot
|
||||
if avg >= 8.0 and pop >= 600:
|
||||
return "口碑热度双高"
|
||||
if pop >= 800:
|
||||
return "热度爆棚"
|
||||
if avg >= 7.5:
|
||||
return "口碑出色"
|
||||
if votes >= 1000:
|
||||
return "大众高关注"
|
||||
return "近期热门"
|
||||
|
||||
# ─── 时间范围 ────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _resolve_time_range(
|
||||
*,
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
window_days: int = 90,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
给定起止日期或窗口天数,返回严格时间范围(YYYY-MM-DD)。
|
||||
- specific_month / this_month:严格按自然月
|
||||
- recent:从今天往前推 window_days 天
|
||||
- last_month:上一个自然月
|
||||
"""
|
||||
today = date.today()
|
||||
|
||||
# 如果都给了就直接用
|
||||
if start_date and end_date:
|
||||
return start_date[:10], end_date[:10]
|
||||
|
||||
# 如果只给了 start_date,end_date 默认今天
|
||||
if start_date and not end_date:
|
||||
return start_date[:10], today.isoformat()
|
||||
|
||||
# 如果只给了 end_date,start_date 往前推 window_days
|
||||
if end_date and not start_date:
|
||||
try:
|
||||
end_d = date.fromisoformat(end_date[:10])
|
||||
except Exception:
|
||||
end_d = today
|
||||
start_d = end_d - timedelta(days=window_days)
|
||||
return start_d.isoformat(), end_d.isoformat()
|
||||
|
||||
# 都没给:默认最近 window_days
|
||||
start_d = today - timedelta(days=window_days)
|
||||
return start_d.isoformat(), today.isoformat()
|
||||
|
||||
@staticmethod
|
||||
def _min_vote_count(intent: str) -> int:
|
||||
"""不同 intent 的最低投票人数门槛"""
|
||||
if intent == "big_titles":
|
||||
return 300
|
||||
if intent == "new":
|
||||
return 30
|
||||
return 100
|
||||
83
plugins.v2/airecognizerenhancer/ARCHITECTURE.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# AI识别增强架构草案
|
||||
|
||||
`AI识别增强` 用来承接 MoviePilot 原生识别失败后的本地 AI 兜底链路。
|
||||
|
||||
## 设计目标
|
||||
|
||||
- 摆脱外部 AI Gateway 的强依赖
|
||||
- 直接使用 MoviePilot 已启用的 LLM 配置
|
||||
- 输出结构化识别结果,而不是只回传一段自由文本
|
||||
|
||||
## 模块分层
|
||||
|
||||
### 1. hooks
|
||||
|
||||
负责接住识别失败事件和后续整理事件。
|
||||
|
||||
### 2. llm
|
||||
|
||||
负责封装对 MP 当前 LLM 的调用:
|
||||
|
||||
- 标准提示词
|
||||
- 结构化返回约束
|
||||
- 超时与错误兜底
|
||||
|
||||
### 3. normalize
|
||||
|
||||
负责把 AI 输出转换成可继续进入 MP 整理链路的数据:
|
||||
|
||||
- 标题
|
||||
- 年份
|
||||
- 类型
|
||||
- 季
|
||||
- 集
|
||||
- 置信度
|
||||
|
||||
### 4. actions
|
||||
|
||||
负责根据结果执行后续动作:
|
||||
|
||||
- 二次识别
|
||||
- 二次整理
|
||||
- 记录失败样本
|
||||
|
||||
## 首期配置模型
|
||||
|
||||
- `enabled`
|
||||
- `notify`
|
||||
- `debug`
|
||||
- `confidence_threshold`
|
||||
- `request_timeout`
|
||||
- `max_retries`
|
||||
- `save_failed_samples`
|
||||
|
||||
## 二期规划
|
||||
|
||||
- 生成自定义识别词建议
|
||||
- 失败样本聚合分析
|
||||
- 提供给 MP Agent / Skill 直接调起
|
||||
|
||||
## 首个里程碑
|
||||
|
||||
第一个可用版本只追求:
|
||||
|
||||
1. 原生识别失败后自动触发本地 LLM 判断
|
||||
2. 拿到结构化结果后自动二次整理
|
||||
3. 能明确记录“成功 / 放弃 / 失败原因”
|
||||
|
||||
## 当前实现状态
|
||||
|
||||
- 已接住 `ChainEventType.NameRecognize`
|
||||
- 已复用 `LLMHelper.get_llm(streaming=False)` 做结构化输出
|
||||
- 已提供手动调试接口用于验证标题识别结果
|
||||
- 已支持查看低置信度样本,并继续生成为 MoviePilot 自定义识别词建议
|
||||
- 已支持直接基于失败样本生成建议并一键写入 `CustomIdentifiers`
|
||||
- 已支持失败样本摘要列表、样本清理、样本去重和保留上限控制
|
||||
- 已支持失败样本洞察汇总,自动挑出重复问题和优先处理样本
|
||||
- 已支持失败样本出队:写入识别词后自动移除,或单独按索引移除
|
||||
- 已支持失败样本复查:按当前识别词和当前识别器重跑,并可自动把已修复样本出队
|
||||
- 已支持失败样本批量复查:可批量重跑并按结果批量出队
|
||||
- 已支持失败样本批量建议与批量写入:可批量生成建议并批量落库
|
||||
- 已支持低 token 精简摘要输出,适合作为智能体批处理入口
|
||||
- 已支持识别词建议模型退化时自动切换到精确规则兜底,优先保证稳定落地
|
||||
- 下一步重点会放在提示词打磨、失败样本回放和识别词建议质量提升
|
||||
101
plugins.v2/airecognizerenhancer/README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# AI识别增强
|
||||
|
||||
`AI识别增强` 用来补强 MoviePilot 原生整理链里的识别阶段。
|
||||
|
||||
它的核心思路很简单:
|
||||
|
||||
- 复用 MoviePilot 当前已经启用的 LLM 配置
|
||||
- 在原生识别失败或置信度不足时,做一次本地结构化识别兜底
|
||||
- 把结果回写给 MoviePilot,继续走原生二次识别和后续整理链
|
||||
|
||||
## 适合什么场景
|
||||
|
||||
- 文件名比较脏,混有压制组、分辨率、语言、站点标记
|
||||
- 同一部剧经常出现英文名、别名、原名、翻译名混用
|
||||
- 网盘挂载、手动整理、历史资源补录时,原生识别偶尔不稳定
|
||||
- 你想把失败样本沉淀下来,后面持续优化 `CustomIdentifiers`
|
||||
|
||||
## 和 MoviePilot 原版智能体的区别
|
||||
|
||||
MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次”的能力。
|
||||
|
||||
这和 `AI识别增强` 有重叠,但定位不同:
|
||||
|
||||
- **MP 原版智能体**
|
||||
- 更偏“一次性补救”
|
||||
- 适合偶发失败、想省事的场景
|
||||
|
||||
- **AI识别增强**
|
||||
- 更偏“识别失败治理层”
|
||||
- 除了补救当前这次,还能:
|
||||
- 保存失败样本
|
||||
- 汇总样本洞察
|
||||
- 生成 `CustomIdentifiers` 建议
|
||||
- 写入识别词
|
||||
- 重放 / 复查 / 批量出队
|
||||
|
||||
一句话区分:
|
||||
|
||||
- 原版智能体:自动接管一次
|
||||
- `AI识别增强`:把失败样本沉淀下来,长期减少同类失败
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 监听 `ChainEventType.NameRecognize`
|
||||
- 用当前 LLM 结构化判断标题、年份、类型、季集
|
||||
- 回写 `name / year / season / episode`
|
||||
- 交回 MoviePilot 原生链路继续二次识别
|
||||
- 保存低置信度失败样本
|
||||
- 提供失败样本工作清单、洞察、重放、删除和清空能力
|
||||
- 生成并应用 `CustomIdentifiers` 建议
|
||||
|
||||
## 主要接口
|
||||
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/health`
|
||||
- 查看插件状态、LLM 提供方、模型、阈值和超时配置
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/recognize`
|
||||
- 对单个标题做一次本地结构化识别测试
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/failed_samples`
|
||||
- 查看最近保存的失败样本
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_worklist`
|
||||
- 返回适合继续处理的失败样本摘要列表
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_insights`
|
||||
- 汇总失败原因、重复问题和优先处理样本
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/replay_failed_sample`
|
||||
- 用当前识别词和当前识别器重放复查某条失败样本
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/suggest_identifiers_from_sample`
|
||||
- 直接基于失败样本生成识别词建议
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/apply_suggested_identifier`
|
||||
- 把建议规则写入系统 `CustomIdentifiers`
|
||||
|
||||
其余批量接口和清理接口可以按需要继续使用,详细路径以插件 `get_api()` 暴露结果为准。
|
||||
|
||||
## 配置建议
|
||||
|
||||
- 先确认 MoviePilot 本身已经配置好可用的 LLM
|
||||
- 建议保持“保存失败样本”开启
|
||||
- 如果你经常处理历史资源或网盘资源,建议定期查看:
|
||||
- `failed_samples`
|
||||
- `sample_worklist`
|
||||
- `sample_insights`
|
||||
|
||||
## 已验证情况
|
||||
|
||||
当前版本:`0.1.12`
|
||||
|
||||
当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68
|
||||
|
||||
这版已经验证过:
|
||||
|
||||
- 最新版 MoviePilot 下可以正常加载
|
||||
- 正常中文标题识别可用
|
||||
- 英文别名、韩文原名、中文别名可识别回标准媒体信息
|
||||
- 低置信度标题会落失败样本
|
||||
- `replay_failed_sample` 复查链可用
|
||||
|
||||
## 说明
|
||||
|
||||
- 这个插件不依赖外部 AI Gateway 回调链
|
||||
- 重点是增强识别,不负责替代 MoviePilot 全部整理流程
|
||||
- 如果你只是偶发整理失败,原版智能体可能已经够用
|
||||
- 如果你长期受命名混乱困扰,这个插件更有价值
|
||||
2043
plugins.v2/airecognizerenhancer/__init__.py
Normal file
537
plugins.v2/autoauction/__init__.py
Normal file
@@ -0,0 +1,537 @@
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Tuple, Optional
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas.types import EventType, NotificationType
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
class AutoAuction(_PluginBase):
|
||||
plugin_name = "朱雀交易行自动上架"
|
||||
plugin_desc = "自动上架灵石或上传到交易行"
|
||||
plugin_icon = "auction.png"
|
||||
plugin_version = "1.0.1"
|
||||
plugin_author = "no_reply"
|
||||
author_url = "https://github.com/jxxghp/MoviePilot-Plugins"
|
||||
plugin_config_prefix = "autoauction_"
|
||||
plugin_order = 50
|
||||
auth_level = 2
|
||||
|
||||
_enabled: bool = False
|
||||
_onlyonce: bool = False
|
||||
_tasks: List[Dict[str, Any]] = []
|
||||
_history: List[Dict[str, Any]] = []
|
||||
_global_cron: str = ""
|
||||
_csrf_token: str = ""
|
||||
_notify_enabled: bool = True
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
_is_running: bool = False
|
||||
_running_lock: threading.Lock = threading.Lock()
|
||||
_last_run_time: float = 0
|
||||
_min_interval_seconds: int = 60
|
||||
|
||||
ZHUQUE_DOMAIN = "zhuque.in"
|
||||
LIST_API = "https://zhuque.in/api/transaction/list"
|
||||
CREATE_API = "https://zhuque.in/api/transaction/create"
|
||||
CREATE_SUCCESS_CODE = "CREATE_TRANSACTION_SUCCESS"
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
config = config or {}
|
||||
|
||||
self._enabled = config.get("enabled", False)
|
||||
onlyonce = config.get("onlyonce", False)
|
||||
|
||||
tasks_json = config.get("tasks_json")
|
||||
|
||||
if tasks_json is None:
|
||||
self._tasks = []
|
||||
elif isinstance(tasks_json, str):
|
||||
try:
|
||||
parsed = json.loads(tasks_json) if tasks_json.strip() else []
|
||||
self._tasks = parsed if isinstance(parsed, list) else []
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"任务配置JSON解析失败: {tasks_json}")
|
||||
self._tasks = []
|
||||
elif isinstance(tasks_json, list):
|
||||
self._tasks = tasks_json
|
||||
else:
|
||||
self._tasks = []
|
||||
|
||||
self._global_cron = config.get("global_cron", "") or ""
|
||||
self._csrf_token = config.get("csrf_token", "") or ""
|
||||
self._notify_enabled = config.get("notify_enabled", True)
|
||||
|
||||
self._history = self.get_data("history") or []
|
||||
|
||||
if self._scheduler:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
|
||||
if onlyonce and self._tasks:
|
||||
logger.info("拍卖行上架立即执行一次")
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
self._scheduler.add_job(
|
||||
func=self.run_all_tasks,
|
||||
trigger='date',
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="拍卖行上架-立即执行"
|
||||
)
|
||||
self._scheduler.start()
|
||||
logger.info("调度器已启动,等待3秒后执行")
|
||||
|
||||
self._onlyonce = False
|
||||
self.update_config({"onlyonce": False})
|
||||
logger.info("已重置onlyonce状态")
|
||||
|
||||
self._save_config()
|
||||
|
||||
def _save_config(self):
|
||||
tasks_json = json.dumps(self._tasks, ensure_ascii=False)
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"onlyonce": self._onlyonce,
|
||||
"notify_enabled": self._notify_enabled,
|
||||
"global_cron": self._global_cron,
|
||||
"csrf_token": self._csrf_token,
|
||||
"tasks_json": tasks_json
|
||||
})
|
||||
logger.info(f"配置已保存: tasks_json={tasks_json[:100]}...")
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"path": "/list",
|
||||
"endpoint": self.get_listings,
|
||||
"methods": ["GET"],
|
||||
"auth": "bear",
|
||||
"summary": "获取当前挂单列表",
|
||||
"description": "获取拍卖行当前挂单列表",
|
||||
},
|
||||
{
|
||||
"path": "/create",
|
||||
"endpoint": self.create_listing,
|
||||
"methods": ["POST"],
|
||||
"auth": "bear",
|
||||
"summary": "手动上架商品",
|
||||
"description": "手动上架商品到拍卖行",
|
||||
},
|
||||
{
|
||||
"path": "/run",
|
||||
"endpoint": self.run_all_tasks,
|
||||
"methods": ["POST"],
|
||||
"auth": "bear",
|
||||
"summary": "执行所有配置",
|
||||
"description": "执行所有上架配置",
|
||||
}
|
||||
]
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
|
||||
return [
|
||||
{
|
||||
"component": "VForm",
|
||||
"content": [
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "enabled",
|
||||
"label": "启用插件",
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "onlyonce",
|
||||
"label": "立即执行一次",
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "notify_enabled",
|
||||
"label": "发送通知",
|
||||
"hide-details": True
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VCronField",
|
||||
"props": {
|
||||
"model": "global_cron",
|
||||
"label": "执行周期",
|
||||
"placeholder": "0 9 * * *"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "csrf_token",
|
||||
"label": "CSRF Token",
|
||||
"hint": "从浏览器开发者工具获取"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VCardText",
|
||||
"props": {
|
||||
"class": "text-pre-wrap"
|
||||
},
|
||||
"text": "配置格式:[{\"bonus\": 146285, \"unit\": \"TiB\", \"upload\": 1, \"type\": 2}]\n\n说明:\n- bonus: 挂牌灵石数量\n- unit: 单位,可选值为 \"TiB\"、\"GiB\"\n- upload: 挂牌上传量\n- type: 1出售灵石/2出售上传"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "tasks_json",
|
||||
"label": "上架配置列表 (JSON)",
|
||||
"rows": 8
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"onlyonce": False,
|
||||
"notify_enabled": True,
|
||||
"global_cron": "",
|
||||
"csrf_token": "",
|
||||
"tasks_json": "[]"
|
||||
}
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
if not self._enabled:
|
||||
return []
|
||||
|
||||
services = []
|
||||
|
||||
if self._global_cron and self._global_cron.strip():
|
||||
cron = self._global_cron.strip()
|
||||
if cron.count(" ") == 4:
|
||||
try:
|
||||
services.append({
|
||||
"id": "AutoAuction.Global",
|
||||
"name": "拍卖行上架-全局任务",
|
||||
"trigger": CronTrigger.from_crontab(cron),
|
||||
"func": self.run_all_tasks,
|
||||
"kwargs": {},
|
||||
"misfire_grace_time": self._min_interval_seconds * 2,
|
||||
"max_instances": 1,
|
||||
"coalesce": True
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"全局cron配置错误: {str(e)}")
|
||||
|
||||
return services
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
history_by_date = defaultdict(list)
|
||||
for record in self._history:
|
||||
date = record.get("time", "")[:10]
|
||||
history_by_date[date].append(record)
|
||||
|
||||
items = []
|
||||
for date in sorted(history_by_date.keys(), reverse=True):
|
||||
records = history_by_date[date]
|
||||
if records:
|
||||
type_text = "出售上传" if records[0].get("type") == 2 else "出售灵石"
|
||||
items.append({
|
||||
"component": "VListSubheader",
|
||||
"props": {"class": "text-grey"},
|
||||
"text": f"{date} {type_text}"
|
||||
})
|
||||
for record in records:
|
||||
time_str = record.get("time", "")[11:]
|
||||
items.append({
|
||||
"component": "VListItem",
|
||||
"props": {
|
||||
"title": f"上传 {record.get('upload')} {record.get('unit')} | 灵石 {record.get('bonus')} | 上架时间: {time_str}"
|
||||
}
|
||||
})
|
||||
|
||||
if not items:
|
||||
items.append({
|
||||
"component": "VListItem",
|
||||
"props": {
|
||||
"title": "暂无上架记录",
|
||||
"subtitle": "执行上架后将显示历史记录"
|
||||
}
|
||||
})
|
||||
|
||||
return [{"component": "VList", "props": {"nav": True}, "content": items}]
|
||||
|
||||
def stop_service(self):
|
||||
if self._scheduler:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
|
||||
def _get_zhuque_site(self) -> Optional[Dict[str, Any]]:
|
||||
for site in SitesHelper().get_indexers():
|
||||
site_url = site.get("url", "") or ""
|
||||
if site_url and self.ZHUQUE_DOMAIN in site_url:
|
||||
return site
|
||||
return None
|
||||
|
||||
def _get_zhuque_cookie(self) -> Optional[str]:
|
||||
site = self._get_zhuque_site()
|
||||
if site:
|
||||
return site.get("cookie")
|
||||
return None
|
||||
|
||||
def _get_csrf_token(self) -> Optional[str]:
|
||||
if self._csrf_token:
|
||||
return self._csrf_token
|
||||
|
||||
cookie = self._get_zhuque_cookie()
|
||||
if not cookie:
|
||||
return None
|
||||
|
||||
try:
|
||||
req = RequestUtils(cookies=cookie, headers={"User-Agent": "Mozilla/5.0"})
|
||||
res = req.get_res(url="https://zhuque.in/bonus/transaction/upload")
|
||||
|
||||
if res and res.status_code == 200:
|
||||
html = res.text
|
||||
|
||||
patterns = [
|
||||
r'x-csrf-token["\']?\s*[:=]\s*["\']([^"\']+)["\']',
|
||||
r'<meta[^>]*name=["\']csrf-token["\'][^>]*content=["\']([^"\']+)["\']',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, html, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"获取CSRF Token异常: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_listings(self, page: int = 1, size: int = 20, type: int = 2) -> Dict[str, Any]:
|
||||
cookie = self._get_zhuque_cookie()
|
||||
|
||||
if not cookie:
|
||||
return {"success": False, "message": "站点Cookie不存在"}
|
||||
|
||||
try:
|
||||
res = RequestUtils(
|
||||
cookies=cookie,
|
||||
headers={"User-Agent": "Mozilla/5.0"}
|
||||
).get_res(url=f"{self.LIST_API}?page={page}&size={size}&type={type}&onlyUnsold=true&onlyRelated=false")
|
||||
|
||||
if res and res.status_code == 200:
|
||||
data = res.json()
|
||||
return {"success": True, "data": data}
|
||||
else:
|
||||
return {"success": False, "message": f"获取列表失败: {res.status_code if res else '无响应'}"}
|
||||
except Exception as e:
|
||||
logger.error(f"获取挂单列表失败: {str(e)}")
|
||||
return {"success": False, "message": f"获取列表异常: {str(e)}"}
|
||||
|
||||
def create_listing(self, bonus: int = None, unit: str = None,
|
||||
upload: int = None, type: int = 2) -> Dict[str, Any]:
|
||||
cookie = self._get_zhuque_cookie()
|
||||
csrf_token = self._get_csrf_token()
|
||||
|
||||
if not cookie:
|
||||
logger.error("站点Cookie不存在")
|
||||
return {"success": False, "message": "站点Cookie不存在"}
|
||||
|
||||
payload = {
|
||||
"type": type,
|
||||
"unit": unit or "TiB",
|
||||
"bonus": bonus or 0,
|
||||
"upload": upload or 1
|
||||
}
|
||||
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Origin": "https://zhuque.in",
|
||||
"Referer": "https://zhuque.in/bonus/transaction/upload",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
}
|
||||
if csrf_token:
|
||||
headers["x-csrf-token"] = csrf_token
|
||||
|
||||
try:
|
||||
req = RequestUtils(cookies=cookie, headers=headers)
|
||||
res = req.post_res(url=self.CREATE_API, json=payload)
|
||||
|
||||
if not res:
|
||||
logger.error("上架请求无响应")
|
||||
return {"success": False, "message": "上架失败: 无响应"}
|
||||
|
||||
if res.status_code == 200:
|
||||
result = res.json()
|
||||
logger.info(f"上架响应内容: {result}")
|
||||
if result.get("status") == 200:
|
||||
code = result.get("data", {}).get("code")
|
||||
if code == self.CREATE_SUCCESS_CODE:
|
||||
transaction_id = result.get("data", {}).get("transactionId")
|
||||
logger.info(f"上架成功: transactionId={transaction_id}")
|
||||
return {"success": True, "data": result.get("data")}
|
||||
else:
|
||||
logger.error(f"上架失败: code={code}")
|
||||
return {"success": False, "message": f"上架失败: {code}"}
|
||||
else:
|
||||
logger.error(f"上架失败: status={result.get('status')}")
|
||||
return {"success": False, "message": f"上架失败: status={result.get('status')}"}
|
||||
else:
|
||||
logger.error(f"上架失败: {res.status_code if res else '无响应'}")
|
||||
return {"success": False, "message": f"上架失败: {res.status_code if res else '无响应'}"}
|
||||
except Exception as e:
|
||||
logger.error(f"上架异常: {str(e)}")
|
||||
return {"success": False, "message": f"上架异常: {str(e)}"}
|
||||
|
||||
def run_all_tasks(self) -> Dict[str, Any]:
|
||||
tz = pytz.timezone(settings.TZ)
|
||||
now = datetime.now(tz=tz)
|
||||
current_time = now.timestamp()
|
||||
|
||||
with self._running_lock:
|
||||
if self._is_running:
|
||||
logger.warn("上架任务正在执行中,跳过本次调用")
|
||||
return {"success": False, "message": "任务正在执行中"}
|
||||
if current_time - self._last_run_time < self._min_interval_seconds:
|
||||
logger.warn(f"上架任务执行间隔太短({self._min_interval_seconds}秒),跳过本次调用")
|
||||
return {"success": False, "message": f"执行间隔太短,请等待{self._min_interval_seconds}秒"}
|
||||
today = now.strftime('%Y-%m-%d')
|
||||
last_run_date = self.get_data("last_run_date") or ""
|
||||
if last_run_date == today:
|
||||
logger.warn(f"今日({today})已执行过上架任务,跳过本次调用")
|
||||
return {"success": False, "message": f"今日({today})已执行过上架任务"}
|
||||
self._is_running = True
|
||||
self._last_run_time = current_time
|
||||
|
||||
try:
|
||||
logger.info(f"开始执行上架任务,共 {len(self._tasks)} 个配置")
|
||||
results = []
|
||||
success_records = []
|
||||
|
||||
for idx, task in enumerate(self._tasks):
|
||||
result = self.create_listing(
|
||||
bonus=task.get("bonus"),
|
||||
unit=task.get("unit"),
|
||||
upload=task.get("upload"),
|
||||
type=task.get("type", 2)
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
transaction_id = result.get("data", {}).get("transactionId")
|
||||
record_time = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
self._history.insert(0, {
|
||||
"upload": task.get("upload"),
|
||||
"bonus": task.get("bonus"),
|
||||
"unit": task.get("unit"),
|
||||
"type": task.get("type", 2),
|
||||
"time": record_time,
|
||||
"transactionId": transaction_id
|
||||
})
|
||||
if len(self._history) > 50:
|
||||
self._history = self._history[:50]
|
||||
success_records.append(f"上传 {task.get('upload')} {task.get('unit')} | 灵石 {task.get('bonus')} | 上架时间: {record_time[11:]}")
|
||||
results.append({"index": idx + 1, "success": True})
|
||||
logger.info(f"配置 {idx + 1} 上架成功")
|
||||
else:
|
||||
logger.error(f"配置 {idx + 1} 上架失败: {result.get('message')}")
|
||||
results.append({"index": idx + 1, "success": False, "error": result.get('message')})
|
||||
|
||||
if self._history:
|
||||
self.save_data("history", self._history)
|
||||
|
||||
self.save_data("last_run_date", today)
|
||||
|
||||
if self._notify_enabled and success_records:
|
||||
try:
|
||||
type_text = "出售上传" if self._tasks[0].get("type", 2) == 2 else "出售灵石" if self._tasks[0].get("type", 2) == 1 else ""
|
||||
text_lines = [f"{today} {type_text}"]
|
||||
for record in success_records:
|
||||
text_lines.append(record)
|
||||
text = "\n".join(text_lines)
|
||||
self.post_message(
|
||||
mtype=NotificationType.Plugin,
|
||||
title="拍卖行上架",
|
||||
text=text
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发送通知异常: {str(e)}")
|
||||
|
||||
return {"success": True, "results": results}
|
||||
finally:
|
||||
self._is_running = False
|
||||
@@ -17,7 +17,7 @@ class ChatGPT(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "Chatgpt_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.1.8"
|
||||
plugin_version = "2.1.9"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
|
||||
1
plugins.v2/chatgpt/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
cacheout~=0.16.0
|
||||
@@ -36,7 +36,7 @@ class ClashRuleProvider(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "Mihomo_Meta_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.1.2"
|
||||
plugin_version = "2.1.6"
|
||||
# 插件作者
|
||||
plugin_author = "wumode"
|
||||
# 作者主页
|
||||
|
||||
@@ -15163,7 +15163,7 @@ const _sfc_main$2 = /* @__PURE__ */ _defineComponent$2({
|
||||
});
|
||||
if (props.allowRefresh && componentConfig.clash_available) {
|
||||
evtSource = new EventSource(
|
||||
"api/v1/plugin/ClashRuleProvider/clash/ws/traffic?secret=" + componentConfig.secret
|
||||
"api/v1/plugin/ClashRuleProvider/clash/ws/traffic?secret=" + encodeURIComponent(componentConfig.secret)
|
||||
);
|
||||
evtSource.addEventListener("traffic", (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
@@ -15177,7 +15177,7 @@ const _sfc_main$2 = /* @__PURE__ */ _defineComponent$2({
|
||||
}
|
||||
});
|
||||
connectionsEvtSource = new EventSource(
|
||||
"api/v1/plugin/ClashRuleProvider/clash/ws/connections?secret=" + componentConfig.secret
|
||||
"api/v1/plugin/ClashRuleProvider/clash/ws/connections?secret=" + encodeURIComponent(componentConfig.secret)
|
||||
);
|
||||
connectionsEvtSource.addEventListener("connections", (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
@@ -11,21 +11,21 @@
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.subscription-card[data-v-97c0f367] {
|
||||
.subscription-card[data-v-b5b6e9bb] {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.subscription-card[data-v-97c0f367]:hover {
|
||||
.subscription-card[data-v-b5b6e9bb]:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.1);
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
.card-header[data-v-97c0f367] {
|
||||
.card-header[data-v-b5b6e9bb] {
|
||||
background: rgba(var(--v-theme-surface-variant), 0.05);
|
||||
}
|
||||
.bg-surface-variant-lighten[data-v-97c0f367] {
|
||||
.bg-surface-variant-lighten[data-v-b5b6e9bb] {
|
||||
background: rgba(var(--v-theme-surface-variant), 0.02);
|
||||
}
|
||||
.stats-grid[data-v-97c0f367] {
|
||||
.stats-grid[data-v-b5b6e9bb] {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
@@ -10418,7 +10418,7 @@ const _sfc_main$d = /* @__PURE__ */ _defineComponent$d({
|
||||
async function deleteProxy(name) {
|
||||
loading.value = true;
|
||||
try {
|
||||
await props.api.delete(`/plugin/ClashRuleProvider/proxies/${name}`);
|
||||
await props.api.delete(`/plugin/ClashRuleProvider/proxies/${encodeURIComponent(name)}`);
|
||||
emit("refresh", ["proxies", "clash-outbounds"]);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
@@ -10863,14 +10863,22 @@ const _sfc_main$c = /* @__PURE__ */ _defineComponent$c({
|
||||
loading.value = true;
|
||||
emit("start-loading");
|
||||
try {
|
||||
await props.api.put("plugin/ClashRuleProvider/refresh", {
|
||||
const result = await props.api.put("plugin/ClashRuleProvider/refresh", {
|
||||
url: props.url
|
||||
});
|
||||
emit("show-snackbar", {
|
||||
show: true,
|
||||
message: "订阅更新成功",
|
||||
color: "success"
|
||||
});
|
||||
if (result.success) {
|
||||
emit("show-snackbar", {
|
||||
show: true,
|
||||
message: "订阅更新成功",
|
||||
color: "success"
|
||||
});
|
||||
} else {
|
||||
emit("show-snackbar", {
|
||||
show: true,
|
||||
message: "订阅更新失败",
|
||||
color: "error"
|
||||
});
|
||||
}
|
||||
emit("refresh", [
|
||||
"status",
|
||||
"clash-outbounds",
|
||||
@@ -10887,6 +10895,7 @@ const _sfc_main$c = /* @__PURE__ */ _defineComponent$c({
|
||||
}
|
||||
}
|
||||
async function toggleSubscription(val) {
|
||||
if (val === null) return;
|
||||
emit("start-loading");
|
||||
try {
|
||||
await props.api.post("plugin/ClashRuleProvider/subscription-info", {
|
||||
@@ -11089,7 +11098,7 @@ const _sfc_main$c = /* @__PURE__ */ _defineComponent$c({
|
||||
}
|
||||
});
|
||||
|
||||
const SubscriptionCard = /* @__PURE__ */ _export_sfc(_sfc_main$c, [["__scopeId", "data-v-97c0f367"]]);
|
||||
const SubscriptionCard = /* @__PURE__ */ _export_sfc(_sfc_main$c, [["__scopeId", "data-v-b5b6e9bb"]]);
|
||||
|
||||
const {defineComponent:_defineComponent$b} = await importShared('vue');
|
||||
|
||||
@@ -2,14 +2,14 @@ const currentImports = {};
|
||||
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
|
||||
let moduleMap = {
|
||||
"./Page":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Page-CJILOVp4.css"], false, './Page');
|
||||
return __federation_import('./__federation_expose_Page-DhQfGEOD.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
dynamicLoadingCss(["__federation_expose_Page-BVPPK5SA.css"], false, './Page');
|
||||
return __federation_import('./__federation_expose_Page-DfFWx370.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Config":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Config-CwbjkOP2.css"], false, './Config');
|
||||
return __federation_import('./__federation_expose_Config-CY46uj5g.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Dashboard":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Dashboard-CFBdUa27.css"], false, './Dashboard');
|
||||
return __federation_import('./__federation_expose_Dashboard-CybypqLB.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
|
||||
return __federation_import('./__federation_expose_Dashboard-CABqciWS.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
|
||||
const seen = {};
|
||||
const dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => {
|
||||
const metaUrl = import.meta.url;
|
||||
|
||||
@@ -27,10 +27,83 @@ class WsOpts(BaseModel):
|
||||
v2ray_http_upgrade_fast_open: Optional[bool] = Field(None, alias='v2ray-http-upgrade-fast-open')
|
||||
|
||||
|
||||
class XhttpReuseSettings(BaseModel):
|
||||
max_concurrency: Optional[str] = Field(None, alias='max-concurrency')
|
||||
max_connections: Optional[str] = Field(None, alias='max-connections')
|
||||
c_max_reuse_times: Optional[str] = Field(None, alias='c-max-reuse-times')
|
||||
h_max_request_times: Optional[str] = Field(None, alias='h-max-request-times')
|
||||
h_max_reusable_secs: Optional[str] = Field(None, alias='h-max-reusable-secs')
|
||||
h_keep_alive_period: Optional[int] = Field(None, alias='h-keep-alive-period')
|
||||
|
||||
|
||||
class XhttpDownloadSettings(BaseModel):
|
||||
# xhttp part
|
||||
path: Optional[str] = None
|
||||
host: Optional[str] = None
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
no_grpc_header: Optional[bool] = Field(None, alias='no-grpc-header')
|
||||
x_padding_bytes: Optional[str] = Field(None, alias='x-padding-bytes')
|
||||
x_padding_obfs_mode: Optional[bool] = Field(None, alias='x-padding-obfs-mode')
|
||||
x_padding_key: Optional[str] = Field(None, alias='x-padding-key')
|
||||
x_padding_header: Optional[str] = Field(None, alias='x-padding-header')
|
||||
x_padding_placement: Optional[str] = Field(None, alias='x-padding-placement')
|
||||
x_padding_method: Optional[str] = Field(None, alias='x-padding-method')
|
||||
uplink_http_method: Optional[str] = Field(None, alias='uplink-http-method')
|
||||
session_placement: Optional[str] = Field(None, alias='session-placement')
|
||||
session_key: Optional[str] = Field(None, alias='session-key')
|
||||
seq_placement: Optional[str] = Field(None, alias='seq-placement')
|
||||
seq_key: Optional[str] = Field(None, alias='seq-key')
|
||||
uplink_data_placement: Optional[str] = Field(None, alias='uplink-data-placement')
|
||||
uplink_data_key: Optional[str] = Field(None, alias='uplink-data-key')
|
||||
uplink_chunk_size: Optional[int] = Field(None, alias='uplink-chunk-size')
|
||||
sc_max_each_post_bytes: Optional[int] = Field(None, alias='sc-max-each-post-bytes')
|
||||
sc_min_posts_interval_ms: Optional[int] = Field(None, alias='sc-min-posts-interval-ms')
|
||||
reuse_settings: Optional[XhttpReuseSettings] = Field(None, alias='reuse-settings')
|
||||
|
||||
# proxy part
|
||||
server: Optional[str] = None
|
||||
port: Optional[int] = None
|
||||
tls: Optional[bool] = None
|
||||
alpn: Optional[List[str]] = None
|
||||
skip_cert_verify: Optional[bool] = Field(None, alias='skip-cert-verify')
|
||||
fingerprint: Optional[str] = None
|
||||
certificate: Optional[str] = None
|
||||
private_key: Optional[str] = Field(None, alias='private-key')
|
||||
servername: Optional[str] = None
|
||||
client_fingerprint: Optional[str] = Field(None, alias='client-fingerprint')
|
||||
|
||||
|
||||
class XhttpOpts(BaseModel):
|
||||
host: Optional[str] = None
|
||||
path: str = '/'
|
||||
mode: Literal["auto", "stream-one", "stream-up", "packet-up"] | None = None
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
no_grpc_header: Optional[bool] = Field(None, alias='no-grpc-header')
|
||||
x_padding_bytes: Optional[str] = Field(None, alias='x-padding-bytes')
|
||||
x_padding_obfs_mode: Optional[bool] = Field(None, alias='x-padding-obfs-mode')
|
||||
x_padding_key: Optional[str] = Field(None, alias='x-padding-key')
|
||||
x_padding_header: Optional[str] = Field(None, alias='x-padding-header')
|
||||
x_padding_placement: Optional[str] = Field(None, alias='x-padding-placement')
|
||||
x_padding_method: Optional[str] = Field(None, alias='x-padding-method')
|
||||
uplink_http_method: Optional[str] = Field(None, alias='uplink-http-method')
|
||||
session_placement: Optional[str] = Field(None, alias='session-placement')
|
||||
session_key: Optional[str] = Field(None, alias='session-key')
|
||||
seq_placement: Optional[str] = Field(None, alias='seq-placement')
|
||||
seq_key: Optional[str] = Field(None, alias='seq-key')
|
||||
uplink_data_placement: Optional[str] = Field(None, alias='uplink-data-placement')
|
||||
uplink_data_key: Optional[str] = Field(None, alias='uplink-data-key')
|
||||
uplink_chunk_size: Optional[int] = Field(None, alias='uplink-chunk-size')
|
||||
sc_max_each_post_bytes: Optional[int] = Field(None, alias='sc-max-each-post-bytes')
|
||||
sc_min_posts_interval_ms: Optional[int] = Field(None, alias='sc-min-posts-interval-ms')
|
||||
reuse_settings: Optional[XhttpReuseSettings] = Field(None, alias='reuse-settings')
|
||||
download_settings: Optional[XhttpDownloadSettings] = Field(None, alias='download-settings')
|
||||
|
||||
|
||||
class NetworkMixin(BaseModel):
|
||||
# Transport settings
|
||||
network: Optional[Literal['tcp', 'http', 'h2', 'grpc', 'ws', 'kcp']] = None
|
||||
network: Optional[Literal['tcp', 'http', 'h2', 'grpc', 'ws', 'kcp', 'xhttp']] = None
|
||||
http_opts: Optional[HttpOpts] = Field(None, alias='http-opts')
|
||||
h2_opts: Optional[H2Opts] = Field(None, alias='h2-opts')
|
||||
grpc_opts: Optional[GrpcOpts] = Field(None, alias='grpc-opts')
|
||||
ws_opts: Optional[WsOpts] = Field(None, alias='ws-opts')
|
||||
xhttp_opts: Optional[XhttpOpts] = Field(None, alias='xhttp-opts')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
websockets
|
||||
sse_starlette~=3.1.1
|
||||
sse_starlette>=3.0.0
|
||||
PyYAML~=6.0.2
|
||||
jsonpatch~=1.33
|
||||
simpleeval~=1.0.3
|
||||
1073
plugins.v2/contractcheck/__init__.py
Normal file
323
plugins.v2/contractcheck/siteuserinfo/__init__.py
Normal file
@@ -0,0 +1,323 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import re
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
from requests import Session
|
||||
from lxml import etree
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.cloudflare import under_challenge
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.site import SiteUtils
|
||||
|
||||
SITE_BASE_ORDER = 1000
|
||||
|
||||
|
||||
# 站点框架
|
||||
class SiteSchema(Enum):
|
||||
DiscuzX = "Discuz!"
|
||||
Gazelle = "Gazelle"
|
||||
Ipt = "IPTorrents"
|
||||
NexusPhp = "NexusPhp"
|
||||
NexusProject = "NexusProject"
|
||||
NexusRabbit = "NexusRabbit"
|
||||
NexusHhanclub = "NexusHhanclub"
|
||||
SmallHorse = "Small Horse"
|
||||
Unit3d = "Unit3d"
|
||||
TorrentLeech = "TorrentLeech"
|
||||
FileList = "FileList"
|
||||
TNode = "TNode"
|
||||
NexusTtg = "NexusTtg"
|
||||
|
||||
|
||||
class ISiteUserInfo(metaclass=ABCMeta):
|
||||
# 站点模版
|
||||
schema = SiteSchema.NexusPhp
|
||||
# 站点解析时判断顺序,值越小越先解析
|
||||
order = SITE_BASE_ORDER
|
||||
|
||||
def __init__(self, site_name: str,
|
||||
url: str,
|
||||
site_cookie: str,
|
||||
index_html: str,
|
||||
session: Session = None,
|
||||
ua: str = None,
|
||||
emulate: bool = False,
|
||||
proxy: bool = None):
|
||||
super().__init__()
|
||||
# 站点信息
|
||||
self.site_name = None
|
||||
self.site_url = None
|
||||
# 用户信息
|
||||
self.userid = None
|
||||
|
||||
# 种子标题,种子大小
|
||||
self.torrent_title_size = []
|
||||
# 种子总大小 (数量,大小)
|
||||
self.total_seeding_size = [0, 0]
|
||||
# 官种总大小 (数量,大小)
|
||||
self.official_seeding_size = [0, 0]
|
||||
|
||||
# 站点官组
|
||||
self.official_team = {
|
||||
"观众": ["Audies", "ADE", "ADWeb", "ADAudio", "ADeBook", "ADMusic"],
|
||||
"UBits": ["UBits"],
|
||||
"听听歌": ["TTG", "WiKi", "DoA", "NGB", "ARiN"],
|
||||
"馒头": ["MTeam", "MTeamTV"],
|
||||
"朋友": ["FRDS"],
|
||||
"猪猪": ["PigoHD","PigoWeb","PigoNF"]
|
||||
}
|
||||
|
||||
# 错误信息
|
||||
self.err_msg = None
|
||||
# 内部数据
|
||||
self._base_url = None
|
||||
self._site_cookie = None
|
||||
self._index_html = None
|
||||
self._addition_headers = None
|
||||
|
||||
# 站点页面
|
||||
self._user_detail_page = "userdetails.php?id="
|
||||
self._torrent_seeding_page = "getusertorrentlistajax.php?userid="
|
||||
self._torrent_seeding_params = None
|
||||
self._torrent_seeding_headers = None
|
||||
|
||||
split_url = urlsplit(url)
|
||||
self.site_name = site_name
|
||||
self.site_url = url
|
||||
self._base_url = f"{split_url.scheme}://{split_url.netloc}"
|
||||
self._site_cookie = site_cookie
|
||||
self._index_html = index_html
|
||||
self._session = session if session else None
|
||||
self._ua = ua
|
||||
|
||||
self._emulate = emulate
|
||||
self._proxy = proxy
|
||||
|
||||
def site_schema(self) -> SiteSchema:
|
||||
"""
|
||||
站点解析模型
|
||||
:return: 站点解析模型
|
||||
"""
|
||||
return self.schema
|
||||
|
||||
@classmethod
|
||||
def match(cls, html_text: str) -> bool:
|
||||
"""
|
||||
是否匹配当前解析模型
|
||||
:param html_text: 站点首页html
|
||||
:return: 是否匹配
|
||||
"""
|
||||
pass
|
||||
|
||||
# 用于契约检查插件获取保种信息
|
||||
def parse_official_seeding_info(self):
|
||||
"""
|
||||
解析站点保种信息
|
||||
:return:
|
||||
"""
|
||||
if not self._parse_logged_in(self._index_html):
|
||||
return
|
||||
self._parse_site_page(self._index_html)
|
||||
|
||||
# 某些站点已统计官种,直接解析
|
||||
if self.site_name == "憨憨":
|
||||
seeding_size = self._get_page_content(
|
||||
urljoin(
|
||||
self._base_url,
|
||||
f"getusertorrentlistajax.php?userid={self.userid}&type=size",
|
||||
)
|
||||
)
|
||||
if seeding_size:
|
||||
seeding_size = json.loads(seeding_size)
|
||||
self.total_seeding_size = (
|
||||
seeding_size.get("total_count", 0),
|
||||
self._size_to_byte(seeding_size.get("total_size", 0)),
|
||||
)
|
||||
self.official_seeding_size = (
|
||||
seeding_size.get("total_official_count", 0),
|
||||
self._size_to_byte(seeding_size.get("total_official_size", 0)),
|
||||
)
|
||||
else:
|
||||
logger.error(f"获取官种信息失败")
|
||||
elif self.site_name == "春天":
|
||||
html_text = self._get_page_content(
|
||||
urljoin(
|
||||
self._base_url,
|
||||
f"getusertorrentlistajax.php?userid={self.userid}&type=seeding",
|
||||
)
|
||||
)
|
||||
html = etree.HTML(html_text)
|
||||
if not html:
|
||||
return
|
||||
total_num = int(html.xpath('//body[1]/b[1]/text()')[0])
|
||||
total_size = html.xpath('//body[1]/b[2]/text()')
|
||||
official_num = int(html.xpath('//body[1]/b[3]/text()')[0])
|
||||
official_size = html.xpath('//body[1]/b[4]/text()')
|
||||
self.total_seeding_size = (total_num if total_num else 0, self._size_to_byte(total_size[0]) if total_size else 0)
|
||||
self.official_seeding_size = (official_num if official_num else 0, self._size_to_byte(official_size[0]) if official_size else 0)
|
||||
else:
|
||||
self._parse_seeding_pages()
|
||||
if len(self.torrent_title_size) == 0:
|
||||
logger.error(f"{self.site_name}:获取种子信息失败")
|
||||
return
|
||||
total_num = 0
|
||||
total_size = 0
|
||||
official_num = 0
|
||||
official_size = 0
|
||||
for torrent in self.torrent_title_size:
|
||||
self.total_seeding_size[0] += 1
|
||||
self.total_seeding_size[1] += torrent[1]
|
||||
if any(team in torrent[0] for team in self.official_team.get(self.site_name, [])):
|
||||
self.official_seeding_size[0] += 1
|
||||
self.official_seeding_size[1] += torrent[1]
|
||||
|
||||
logger.info(f"{self.site_name} 官种信息 {self.official_seeding_size} 总种信息 {self.total_seeding_size}")
|
||||
|
||||
# 将各种格式大小统一转为Byte
|
||||
def _size_to_byte(self, size: str) -> float:
|
||||
if str is None:
|
||||
return 0
|
||||
if size.endswith("TB"):
|
||||
return float(size[:-2]) * 1024 * 1024 * 1024 * 1024
|
||||
if size.endswith("GB"):
|
||||
return float(size[:-2]) * 1024 * 1024 * 1024
|
||||
elif size.endswith("MB"):
|
||||
return float(size[:-2]) * 1024 * 1024
|
||||
elif size.endswith("KB"):
|
||||
return float(size[:-2]) * 1024
|
||||
elif size.endswith("B"):
|
||||
return float(size[:-1])
|
||||
else:
|
||||
return 0
|
||||
|
||||
def _parse_seeding_pages(self):
|
||||
if self._torrent_seeding_page:
|
||||
# 处理特殊站点
|
||||
if self.site_name == "听听歌":
|
||||
self._torrent_seeding_page = self._user_detail_page
|
||||
elif self.site_name == "馒头":
|
||||
self._torrent_seeding_page = f"getusertorrentlist.php?userid={self.userid}&type=seeding"
|
||||
elif self.site_name == "观众":
|
||||
self._torrent_seeding_headers = {"Referer": urljoin(self._base_url, self._user_detail_page)}
|
||||
logger.info(f" {self.site_name} {self._torrent_seeding_headers}")
|
||||
|
||||
# 第一页
|
||||
next_page = self._parse_user_torrent_seeding_info(
|
||||
self._get_page_content(urljoin(self._base_url, self._torrent_seeding_page),
|
||||
self._torrent_seeding_params,
|
||||
self._torrent_seeding_headers))
|
||||
|
||||
# 其他页处理
|
||||
while next_page:
|
||||
next_page = self._parse_user_torrent_seeding_info(
|
||||
self._get_page_content(urljoin(urljoin(self._base_url, self._torrent_seeding_page), next_page),
|
||||
self._torrent_seeding_params,
|
||||
self._torrent_seeding_headers),
|
||||
multi_page=True)
|
||||
|
||||
@staticmethod
|
||||
def _prepare_html_text(html_text):
|
||||
"""
|
||||
处理掉HTML中的干扰部分
|
||||
"""
|
||||
return re.sub(r"#\d+", "", re.sub(r"\d+px", "", html_text))
|
||||
|
||||
def _get_page_content(self, url: str, params: dict = None, headers: dict = None):
|
||||
"""
|
||||
:param url: 网页地址
|
||||
:param params: post参数
|
||||
:param headers: 额外的请求头
|
||||
:return:
|
||||
"""
|
||||
req_headers = None
|
||||
proxies = settings.PROXY if self._proxy else None
|
||||
if self._ua or headers or self._addition_headers:
|
||||
req_headers = {}
|
||||
if headers:
|
||||
req_headers.update(headers)
|
||||
|
||||
req_headers.update({
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"User-Agent": f"{self._ua}"
|
||||
})
|
||||
|
||||
if self._addition_headers:
|
||||
req_headers.update(self._addition_headers)
|
||||
|
||||
if params:
|
||||
res = RequestUtils(cookies=self._site_cookie,
|
||||
session=self._session,
|
||||
timeout=60,
|
||||
proxies=proxies,
|
||||
headers=req_headers).post_res(url=url, data=params)
|
||||
else:
|
||||
res = RequestUtils(cookies=self._site_cookie,
|
||||
session=self._session,
|
||||
timeout=60,
|
||||
proxies=proxies,
|
||||
headers=req_headers).get_res(url=url)
|
||||
if res is not None and res.status_code in (200, 500, 403):
|
||||
# 如果cloudflare 有防护,尝试使用浏览器仿真
|
||||
if under_challenge(res.text):
|
||||
logger.warn(
|
||||
f"{self.site_name} 检测到Cloudflare,请更新Cookie和UA")
|
||||
return ""
|
||||
if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE):
|
||||
res.encoding = "utf-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
return res.text
|
||||
|
||||
return ""
|
||||
|
||||
@abstractmethod
|
||||
def _parse_site_page(self, html_text: str):
|
||||
"""
|
||||
解析站点相关信息页面
|
||||
:param html_text:
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
def _parse_logged_in(self, html_text):
|
||||
"""
|
||||
解析用户是否已经登陆
|
||||
:param html_text:
|
||||
:return: True/False
|
||||
"""
|
||||
logged_in = SiteUtils.is_logged_in(html_text)
|
||||
if not logged_in:
|
||||
self.err_msg = "未检测到已登陆,请检查cookies是否过期"
|
||||
logger.warn(f"{self.site_name} 未登录,跳过后续操作")
|
||||
|
||||
return logged_in
|
||||
|
||||
@abstractmethod
|
||||
def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]:
|
||||
"""
|
||||
解析用户的做种相关信息
|
||||
:param html_text:
|
||||
:param multi_page: 是否多页数据
|
||||
:return: 下页地址
|
||||
"""
|
||||
pass
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
转化为字典
|
||||
"""
|
||||
attributes = [
|
||||
attr for attr in dir(self)
|
||||
if not callable(getattr(self, attr)) and not attr.startswith("_")
|
||||
]
|
||||
return {
|
||||
attr: getattr(self, attr).value
|
||||
if isinstance(getattr(self, attr), SiteSchema)
|
||||
else getattr(self, attr) for attr in attributes
|
||||
}
|
||||
96
plugins.v2/contractcheck/siteuserinfo/nexus_php.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from app.log import logger
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
from . import ISiteUserInfo, SITE_BASE_ORDER, SiteSchema
|
||||
|
||||
|
||||
class NexusPhpSiteUserInfo(ISiteUserInfo):
|
||||
schema = SiteSchema.NexusPhp
|
||||
order = SITE_BASE_ORDER * 2
|
||||
|
||||
@classmethod
|
||||
def match(cls, html_text: str) -> bool:
|
||||
"""
|
||||
默认使用NexusPhp解析
|
||||
:param html_text:
|
||||
:return:
|
||||
"""
|
||||
return True
|
||||
|
||||
def _parse_site_page(self, html_text: str):
|
||||
html_text = self._prepare_html_text(html_text)
|
||||
|
||||
user_detail = re.search(r"userdetails.php\?id=(\d+)", html_text)
|
||||
if user_detail and user_detail.group().strip():
|
||||
self._user_detail_page = user_detail.group().strip().lstrip('/')
|
||||
self.userid = user_detail.group(1)
|
||||
self._torrent_seeding_page = f"getusertorrentlistajax.php?userid={self.userid}&type=seeding"
|
||||
else:
|
||||
user_detail = re.search(r"(userdetails)", html_text)
|
||||
if user_detail and user_detail.group().strip():
|
||||
self._user_detail_page = user_detail.group().strip().lstrip('/')
|
||||
self.userid = None
|
||||
self._torrent_seeding_page = None
|
||||
|
||||
def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]:
|
||||
"""
|
||||
做种相关信息
|
||||
:param html_text:
|
||||
:param multi_page: 是否多页数据
|
||||
:return: 下页地址
|
||||
"""
|
||||
html = etree.HTML(str(html_text).replace(r'\/', '/'))
|
||||
if not html:
|
||||
return None
|
||||
|
||||
# 首页存在扩展链接,使用扩展链接
|
||||
seeding_url_text = html.xpath('//a[contains(@href,"torrents.php") '
|
||||
'and contains(@href,"seeding")]/@href')
|
||||
if multi_page is False and seeding_url_text and seeding_url_text[0].strip():
|
||||
self._torrent_seeding_page = seeding_url_text[0].strip()
|
||||
return self._torrent_seeding_page
|
||||
|
||||
title_col = 2
|
||||
size_col = 3
|
||||
seeders_col = 4
|
||||
# 搜索size列
|
||||
size_col_xpath = '//tr[position()=1]/' \
|
||||
'td[(img[@class="size"] and img[@alt="size"])' \
|
||||
' or (text() = "大小")' \
|
||||
' or (a/img[@class="size" and @alt="size"])]'
|
||||
if html.xpath(size_col_xpath):
|
||||
size_col = len(html.xpath(f'{size_col_xpath}/preceding-sibling::td')) + 1
|
||||
# 搜索title列
|
||||
title_col_xpath = '//tr[position()=1]/' \
|
||||
'td[(text() = "标题")]'
|
||||
if html.xpath(title_col_xpath):
|
||||
title_col = len(html.xpath(f'{title_col_xpath}/preceding-sibling::td')) + 1
|
||||
|
||||
page_torrent_info = []
|
||||
# 如果 table class="torrents",则增加table[@class="torrents"]
|
||||
table_class = '//table[@class="torrents"]' if html.xpath('//table[@class="torrents"]') else ''
|
||||
seeding_sizes = html.xpath(f'{table_class}//tr[position()>1]/td[{size_col}]')
|
||||
seeding_torrents = html.xpath(f'{table_class}//tr[position()>1]/td[{title_col}]/a/@title')
|
||||
if seeding_sizes:
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
page_torrent_info.append([seeding_torrents[i], size])
|
||||
|
||||
self.torrent_title_size.extend(page_torrent_info)
|
||||
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁")]/@href')
|
||||
if next_page_text:
|
||||
next_page = next_page_text[-1].strip()
|
||||
# fix up page url
|
||||
if self.userid not in next_page:
|
||||
next_page = f'{next_page}&userid={self.userid}&type=seeding'
|
||||
|
||||
return next_page
|
||||
48
plugins.v2/contractcheck/siteuserinfo/nexus_ttg.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
|
||||
from lxml import etree
|
||||
from typing import Optional
|
||||
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
from . import SITE_BASE_ORDER, SiteSchema
|
||||
from .nexus_php import NexusPhpSiteUserInfo
|
||||
|
||||
|
||||
class NexusTtgSiteUserInfo(NexusPhpSiteUserInfo):
|
||||
schema = SiteSchema.NexusTtg
|
||||
order = SITE_BASE_ORDER + 20
|
||||
|
||||
@classmethod
|
||||
def match(cls, html_text: str) -> bool:
|
||||
return 'totheglory.im' in html_text
|
||||
|
||||
def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]:
|
||||
"""
|
||||
做种相关信息
|
||||
:param html_text:
|
||||
:param multi_page: 是否多页数据
|
||||
:return: 下页地址
|
||||
"""
|
||||
html = etree.HTML(str(html_text).replace(r'\/', '/'))
|
||||
if not html:
|
||||
return None
|
||||
|
||||
title_col = 2
|
||||
size_col = 4
|
||||
|
||||
page_torrent_info = []
|
||||
|
||||
table_class = '//div[@id="ka2"]/table'
|
||||
seeding_sizes = html.xpath(f'{table_class}//tr[position()>1]/td[{size_col}]')
|
||||
seeding_torrents = html.xpath(f'{table_class}//tr[position()>1]/td[{title_col}]/a/b/text()')
|
||||
if seeding_sizes:
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
page_torrent_info.append([seeding_torrents[i], size])
|
||||
|
||||
self.torrent_title_size.extend(page_torrent_info)
|
||||
|
||||
# 不存在下页数据
|
||||
return False
|
||||
@@ -179,7 +179,7 @@ class CrossSeed(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "qingwa.png"
|
||||
# 插件版本
|
||||
plugin_version = "3.0.1"
|
||||
plugin_version = "3.0.2"
|
||||
# 插件作者
|
||||
plugin_author = "233@qingwa"
|
||||
# 作者主页
|
||||
|
||||
1
plugins.v2/crossseed/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
fast-bencode~=1.1.7
|
||||
1364
plugins.v2/dynamicwechat/__init__.py
Normal file
532
plugins.v2/dynamicwechat/helper.py
Normal file
@@ -0,0 +1,532 @@
|
||||
import re
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import base64
|
||||
import hashlib
|
||||
from typing import Dict, Any
|
||||
from Crypto import Random
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from app.modules.wechat import WeChat
|
||||
from app.schemas.types import NotificationType, MessageChannel
|
||||
|
||||
|
||||
def bytes_to_key(data: bytes, salt: bytes, output=48) -> bytes:
|
||||
# 兼容v2 将bytes_to_key和encrypt导入
|
||||
assert len(salt) == 8, len(salt)
|
||||
data += salt
|
||||
key = hashlib.md5(data).digest()
|
||||
final_key = key
|
||||
while len(final_key) < output:
|
||||
key = hashlib.md5(key + data).digest()
|
||||
final_key += key
|
||||
return final_key[:output]
|
||||
|
||||
|
||||
def encrypt(message: bytes, passphrase: bytes) -> bytes:
|
||||
"""
|
||||
CryptoJS 加密原文
|
||||
|
||||
This is a modified copy of https://stackoverflow.com/questions/36762098/how-to-decrypt-password-from-javascript-cryptojs-aes-encryptpassword-passphras
|
||||
"""
|
||||
salt = Random.new().read(8)
|
||||
key_iv = bytes_to_key(passphrase, salt, 32 + 16)
|
||||
key = key_iv[:32]
|
||||
iv = key_iv[32:]
|
||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||
length = 16 - (len(message) % 16)
|
||||
data = message + (chr(length) * length).encode()
|
||||
return base64.b64encode(b"Salted__" + salt + aes.encrypt(data))
|
||||
|
||||
|
||||
class PyCookieCloud:
|
||||
def __init__(self, url: str, uuid: str, password: str):
|
||||
self.url: str = url
|
||||
self.uuid: str = uuid
|
||||
self.password: str = password
|
||||
|
||||
def check_connection(self) -> bool:
|
||||
"""
|
||||
Test the connection to the CookieCloud server.
|
||||
|
||||
:return: True if the connection is successful, False otherwise.
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(self.url, timeout=3) # 设置超时为3秒
|
||||
return resp.status_code == 200
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
def update_cookie(self, formatted_cookies: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Update cookie data to CookieCloud.
|
||||
|
||||
:param formatted_cookies: cookie value to update.
|
||||
:return: if update success, return True, else return False.
|
||||
"""
|
||||
if '.work.weixin.qq.com' not in formatted_cookies:
|
||||
formatted_cookies['.work.weixin.qq.com'] = []
|
||||
formatted_cookies['.work.weixin.qq.com'].append({
|
||||
'name': '_upload_type',
|
||||
'value': 'A',
|
||||
'domain': '.work.weixin.qq.com',
|
||||
'path': '/',
|
||||
'expires': -1,
|
||||
'httpOnly': False,
|
||||
'secure': False,
|
||||
'sameSite': 'Lax'
|
||||
})
|
||||
|
||||
cookie = {'cookie_data': formatted_cookies}
|
||||
raw_data = json.dumps(cookie)
|
||||
encrypted_data = encrypt(raw_data.encode('utf-8'), self.get_the_key().encode('utf-8')).decode('utf-8')
|
||||
cookie_cloud_request = requests.post(self.url + '/update',
|
||||
json={'uuid': self.uuid, 'encrypted': encrypted_data})
|
||||
if cookie_cloud_request.status_code == 200:
|
||||
if cookie_cloud_request.json().get('action') == 'done':
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_the_key(self) -> str:
|
||||
"""
|
||||
Get the key used to encrypt and decrypt data.
|
||||
|
||||
:return: the key.
|
||||
"""
|
||||
md5 = hashlib.md5()
|
||||
md5.update((self.uuid + '-' + self.password).encode('utf-8'))
|
||||
return md5.hexdigest()[:16]
|
||||
|
||||
@staticmethod
|
||||
def load_cookie_lifetime(settings_file: str = None):
|
||||
if os.path.exists(settings_file):
|
||||
with open(settings_file, 'r') as file:
|
||||
settings = json.load(file)
|
||||
return settings.get('_cookie_lifetime', 0)
|
||||
else:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def save_cookie_lifetime(settings_file, cookie_lifetime):
|
||||
data = {}
|
||||
if os.path.exists(settings_file):
|
||||
with open(settings_file, 'r') as file:
|
||||
data = json.load(file)
|
||||
|
||||
# 只更新 _cookie_lifetime 字段,其它字段保持不变
|
||||
data['_cookie_lifetime'] = cookie_lifetime
|
||||
|
||||
with open(settings_file, 'w') as file:
|
||||
json.dump(data, file, indent=4)
|
||||
|
||||
@staticmethod
|
||||
def increase_cookie_lifetime(settings_file, seconds: int):
|
||||
current_lifetime = PyCookieCloud.load_cookie_lifetime(settings_file)
|
||||
new_lifetime = current_lifetime + seconds
|
||||
# 保存新的 _cookie_lifetime
|
||||
PyCookieCloud.save_cookie_lifetime(settings_file, new_lifetime)
|
||||
|
||||
|
||||
class MySender:
|
||||
def __init__(self, token=None, func=None):
|
||||
self.tokens = token.split('||') if token and '||' in token else [token] if token else []
|
||||
self.channels = [MySender._detect_channel(t) for t in self.tokens]
|
||||
self.current_index = 0 # 当前使用的 token 和 channel 的索引
|
||||
self.first_text_sent = False # 是否已发送过纯文本消息
|
||||
self.init_success = bool(self.tokens) # 标识初始化是否成功
|
||||
self.post_message_func = func # V2 微信模式的 post_message 方法
|
||||
|
||||
@property
|
||||
def other_channel(self):
|
||||
"""
|
||||
返回非 WeChat 通道及其对应 token 的列表
|
||||
:return: [(channel, token), ...]
|
||||
"""
|
||||
return [(channel, token) for channel, token in zip(self.channels, self.tokens) if channel.lower() != "wechat"]
|
||||
|
||||
@staticmethod
|
||||
def _detect_channel(token):
|
||||
"""根据 token 确定通知渠道"""
|
||||
if "WeChat" in token or "wechat" in token:
|
||||
return "WeChat"
|
||||
|
||||
letters_only = ''.join(re.findall(r'[A-Za-z]', token))
|
||||
if token.lower().startswith("sct"):
|
||||
return "ServerChan"
|
||||
elif letters_only.isupper():
|
||||
return "AnPush"
|
||||
else:
|
||||
return "PushPlus"
|
||||
|
||||
def send(self, title, content=None, image=None, force_send=False, diy_channel=None, diy_token=None):
|
||||
"""发送消息"""
|
||||
if not self.init_success:
|
||||
return
|
||||
|
||||
# 对纯文本消息进行限制
|
||||
if not image and not force_send:
|
||||
if self.first_text_sent:
|
||||
return
|
||||
self.first_text_sent = True
|
||||
|
||||
# 如果指定了自定义通道,直接尝试发送
|
||||
if diy_channel:
|
||||
return self._try_send(title, content, image, channel=diy_channel, diy_token=diy_token)
|
||||
|
||||
# 尝试按顺序发送,直到成功或遍历所有通道
|
||||
for _ in range(len(self.tokens)):
|
||||
token = self.tokens[self.current_index]
|
||||
channel = self.channels[self.current_index]
|
||||
try:
|
||||
result = self._try_send(title, content, image, channel, token=token)
|
||||
if result is None: # 成功时返回 None
|
||||
return
|
||||
except Exception as e:
|
||||
pass # 忽略单个错误,继续尝试下一个通道
|
||||
self.current_index = (self.current_index + 1) % len(self.tokens)
|
||||
return f"所有的通知方式都发送失败"
|
||||
|
||||
def _try_send(self, title, content, image, channel, token=None, diy_token=None):
|
||||
"""尝试使用指定通道发送消息"""
|
||||
if channel == "WeChat" and self.post_message_func:
|
||||
return self._send_v2_wechat(title, content, image, token)
|
||||
elif channel == "WeChat":
|
||||
return self._send_wechat(title, content, image, token)
|
||||
elif channel == "ServerChan":
|
||||
return self._send_serverchan(title, content, image, diy_token)
|
||||
elif channel == "AnPush":
|
||||
return self._send_anpush(title, content, image, diy_token)
|
||||
elif channel == "PushPlus":
|
||||
return self._send_pushplus(title, content, image, diy_token)
|
||||
else:
|
||||
return f"未知的通知方式: {channel}"
|
||||
|
||||
@staticmethod
|
||||
def _send_wechat(title, content, image, token):
|
||||
wechat = WeChat()
|
||||
if token and ',' in token:
|
||||
channel, actual_userid = token.split(',', 1)
|
||||
else:
|
||||
actual_userid = None
|
||||
if image:
|
||||
send_status = wechat.send_msg(title='企业微信登录二维码', image=image, link=image, userid=actual_userid)
|
||||
else:
|
||||
send_status = wechat.send_msg(title=title, text=content, userid=actual_userid)
|
||||
|
||||
if not send_status:
|
||||
return "微信通知发送错误"
|
||||
return None
|
||||
|
||||
def _send_serverchan(self, title, content, image, diy_token=None):
|
||||
if diy_token:
|
||||
tmp_tokens = diy_token
|
||||
else:
|
||||
tmp_tokens = self.tokens[self.current_index]
|
||||
if ',' in tmp_tokens:
|
||||
before_comma, after_comma = tmp_tokens.split(',', 1)
|
||||
if before_comma.startswith('sctp') and image:
|
||||
token = after_comma # 图片发到公众号
|
||||
else:
|
||||
token = before_comma # 发到 server3
|
||||
else:
|
||||
token = tmp_tokens
|
||||
|
||||
if token.startswith('sctp'):
|
||||
match = re.match(r'sctp(\d+)t', token)
|
||||
if match:
|
||||
num = match.group(1)
|
||||
url = f'https://{num}.push.ft07.com/send/{token}.send'
|
||||
else:
|
||||
return '错误的Server3 Sendkey'
|
||||
else:
|
||||
url = f'https://sctapi.ftqq.com/{token}.send'
|
||||
|
||||
params = {'title': title, 'desp': f'' if image else content}
|
||||
headers = {'Content-Type': 'application/json;charset=utf-8'}
|
||||
response = requests.post(url, json=params, headers=headers)
|
||||
result = response.json()
|
||||
if result.get('code') != 0:
|
||||
return f"Server酱通知错误: {result.get('message')}"
|
||||
return None
|
||||
|
||||
def _send_anpush(self, title, content, image, diy_token=None):
|
||||
if diy_token:
|
||||
token = diy_token
|
||||
else:
|
||||
token = self.tokens[self.current_index] # 获取当前通道对应的 token
|
||||
if ',' in token:
|
||||
channel, token = token.split(',', 1)
|
||||
else:
|
||||
return "AnPush可能没有配置消息通道ID"
|
||||
url = f"https://api.anpush.com/push/{token}"
|
||||
payload = {
|
||||
"title": title,
|
||||
"content": f"<img src=\"{image}\" width=\"100%\">" if image else content,
|
||||
"channel": channel
|
||||
}
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
response = requests.post(url, headers=headers, data=payload)
|
||||
result = response.json()
|
||||
# 判断返回的code和msgIds
|
||||
if result.get('code') != 200:
|
||||
return f"AnPush: {result.get('msg')}"
|
||||
elif not result.get('data') or not result['data'].get('msgIds'):
|
||||
return "AnPush 消息通道未找到"
|
||||
return None
|
||||
|
||||
def _send_pushplus(self, title, content, image, diy_token=None):
|
||||
if diy_token:
|
||||
token = diy_token
|
||||
else:
|
||||
token = self.tokens[self.current_index] # 获取当前通道对应的 token
|
||||
pushplus_url = f"http://www.pushplus.plus/send/{token}"
|
||||
# PushPlus发送逻辑
|
||||
data = {
|
||||
"title": title,
|
||||
"content": f"企业微信登录二维码<br/><img src='{image}' />" if image else content,
|
||||
"template": "html"
|
||||
}
|
||||
response = requests.post(pushplus_url, json=data)
|
||||
result = response.json()
|
||||
if result.get('code') != 200:
|
||||
return f"PushPlus send failed: {result.get('msg')}"
|
||||
return None
|
||||
|
||||
def _send_v2_wechat(self, title, content, image, token):
|
||||
"""V2 微信通知发送"""
|
||||
if token and ',' in token:
|
||||
_, actual_userid = token.split(',', 1)
|
||||
else:
|
||||
actual_userid = None
|
||||
self.post_message_func(
|
||||
channel=MessageChannel.Wechat,
|
||||
mtype=NotificationType.Plugin,
|
||||
title=title,
|
||||
text=content,
|
||||
image=image,
|
||||
link=image,
|
||||
userid=actual_userid
|
||||
)
|
||||
return None # 由于self.post_message()了None外,没有其他返回值。无法判断是否发送成功,V2直接默认成功
|
||||
|
||||
def reset_limit(self):
|
||||
"""解除限制,允许再次发送纯文本消息"""
|
||||
self.first_text_sent = False
|
||||
|
||||
|
||||
class IpLocationParser:
|
||||
def __init__(self, settings_file_path, max_ips=3):
|
||||
self._settings_file_path = settings_file_path
|
||||
self._max_ips = max_ips # 最大历史IP数量
|
||||
self._ips = self.read_ips("ips") # 初始化时读取已存储的 IP 地址
|
||||
|
||||
@staticmethod
|
||||
def _parse(page, url):
|
||||
# 定义 URL 到解析函数的映射
|
||||
parser_methods = {
|
||||
"https://ip.orz.tools": IpLocationParser._parse_ip_orz_tools,
|
||||
"https://ip.skk.moe/multi": IpLocationParser._parse_ip_skk_moe,
|
||||
"https://ip.m27.tech": IpLocationParser._parse_ip_m27,
|
||||
}
|
||||
parser_method = parser_methods.get(url)
|
||||
if parser_method is None:
|
||||
return [], []
|
||||
return parser_method(page)
|
||||
|
||||
@staticmethod
|
||||
def _remove_duplicates(ipv4_addresses, locations):
|
||||
"""去重并保持 IP 地址和归属地的对应关系"""
|
||||
seen = set()
|
||||
unique_ipv4 = []
|
||||
unique_locations = []
|
||||
|
||||
for ip, location in zip(ipv4_addresses, locations):
|
||||
if ip not in seen:
|
||||
seen.add(ip)
|
||||
unique_ipv4.append(ip)
|
||||
unique_locations.append(location)
|
||||
|
||||
return unique_ipv4, unique_locations
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_ipv4(ip):
|
||||
"""验证是否是合法的 IPv4 地址"""
|
||||
return re.match(r'^\d{1,3}(\.\d{1,3}){3}$', ip) is not None
|
||||
|
||||
@staticmethod
|
||||
def _parse_ip_orz_tools(page):
|
||||
rows = page.query_selector_all('#results3 .row')
|
||||
# print(f"ip_orz_tools共找到 {len(rows)} 行数据")
|
||||
ipv4_addresses, locations = [], []
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
row_html = row.inner_html()
|
||||
|
||||
# 提取 IP 地址
|
||||
ip_match = re.search(r'data-name="([^"]+)"', row_html)
|
||||
if ip_match:
|
||||
ip = ip_match.group(1).strip()
|
||||
if not IpLocationParser._is_valid_ipv4(ip):
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
# 提取位置数据
|
||||
loc_element = row.query_selector('.loc.cell')
|
||||
location = loc_element.inner_text().strip() if loc_element else "未知"
|
||||
|
||||
ipv4_addresses.append(ip)
|
||||
locations.append(location)
|
||||
|
||||
return IpLocationParser._remove_duplicates(ipv4_addresses, locations)
|
||||
|
||||
@staticmethod
|
||||
def _parse_ip_skk_moe(page):
|
||||
rows = page.query_selector_all(
|
||||
'body > div > section > div.x1n2onr6.xw2csxc.x10fe3q7.x116uinm.xdpxx8g > table > tbody > tr'
|
||||
)
|
||||
# print(f"skk共找到 {len(rows)} 行数据")
|
||||
ipv4_addresses, locations = [], []
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
ip_element = row.query_selector('th')
|
||||
loc_element = row.query_selector('td:nth-child(3)') # 假设归属地在第 3 列
|
||||
|
||||
if ip_element and loc_element:
|
||||
ip = ip_element.inner_text().strip()
|
||||
if not IpLocationParser._is_valid_ipv4(ip):
|
||||
continue
|
||||
location = loc_element.inner_text().strip()
|
||||
|
||||
ipv4_addresses.append(ip)
|
||||
locations.append(location)
|
||||
|
||||
return IpLocationParser._remove_duplicates(ipv4_addresses, locations)
|
||||
|
||||
@staticmethod
|
||||
def _parse_ip_m27(page):
|
||||
"""解析 https://ip.m27.tech 页面中的 IP 和归属地"""
|
||||
rows = page.query_selector_all(
|
||||
'body > div > div.panel.panel-success > div.panel-body > table > tbody > tr'
|
||||
)
|
||||
# print(f"共找到 {len(rows)} 行数据")
|
||||
ipv4_addresses, locations = [], []
|
||||
|
||||
for row in rows:
|
||||
row_text = row.inner_text().strip()
|
||||
# 提取 IP 地址
|
||||
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', row_text)
|
||||
if ip_match:
|
||||
ip = ip_match.group(1).strip()
|
||||
if not IpLocationParser._is_valid_ipv4(ip):
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
#
|
||||
# 提取归属地
|
||||
location_match = re.search(r'(China|中国).*', row_text)
|
||||
location = location_match.group(0).strip() if location_match else "未知"
|
||||
|
||||
ipv4_addresses.append(ip)
|
||||
locations.append(location)
|
||||
|
||||
return IpLocationParser._remove_duplicates(ipv4_addresses, locations)
|
||||
|
||||
@staticmethod
|
||||
def get_ipv4(page, url: str) -> str:
|
||||
"""返回多个中国 IP 地址,逗号分隔"""
|
||||
# 导航到目标页面
|
||||
page.goto(url)
|
||||
# 等待一段时间,让所有动态渲染的内容加载完成
|
||||
page.wait_for_timeout(8000) # 等待 8 秒钟
|
||||
# 调用解析器解析数据
|
||||
ipv4_addresses, locations = IpLocationParser._parse(page, url)
|
||||
# 筛选出属于中国的 IP 地址
|
||||
china_ips = [
|
||||
ip for ip, location in zip(ipv4_addresses, locations)
|
||||
if 'China' in location or '中国' in location
|
||||
]
|
||||
# 返回逗号分隔的字符串
|
||||
return ';'.join(china_ips)
|
||||
|
||||
def _limit_and_deduplicate_ips(self, ips):
|
||||
"""
|
||||
去重并限制 IP 地址数量,最多保存 _max_ips 个 IP 地址。
|
||||
"""
|
||||
# 去重并保留顺序
|
||||
unique_ips = list(dict.fromkeys(ips))
|
||||
return unique_ips[:self._max_ips] # 保留最多 _max_ips 个 IP 地址
|
||||
|
||||
def _read_ips_from_json(self, field):
|
||||
"""
|
||||
从 JSON 文件中读取指定字段的 IP 地址。
|
||||
"""
|
||||
if not os.path.exists(self._settings_file_path):
|
||||
return "" # 文件不存在,返回空字符串
|
||||
|
||||
try:
|
||||
with open(self._settings_file_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
# 获取字段内容并返回分号分隔的字符串
|
||||
return ";".join(data.get(field, []))
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return "" # 读取失败,返回空字符串
|
||||
|
||||
def _overwrite_ips_in_json(self, field, new_ips):
|
||||
"""
|
||||
覆盖写入指定字段的 IP 地址。
|
||||
:param field: 要更新的字段名
|
||||
:param new_ips: 新的 IP 地址列表或分号分隔的字符串
|
||||
"""
|
||||
# 如果输入是字符串,将其转换为列表
|
||||
if isinstance(new_ips, str):
|
||||
new_ips = new_ips.split(";")
|
||||
|
||||
# 去重并限制 IP 数量
|
||||
new_ips = self._limit_and_deduplicate_ips(new_ips)
|
||||
|
||||
# 读取现有数据(如果文件不存在,则初始化空数据)
|
||||
if os.path.exists(self._settings_file_path):
|
||||
try:
|
||||
with open(self._settings_file_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
data = {}
|
||||
else:
|
||||
data = {}
|
||||
|
||||
# 更新指定字段
|
||||
data[field] = new_ips
|
||||
|
||||
# 写入文件
|
||||
with open(self._settings_file_path, 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
def read_ips(self, field) -> str:
|
||||
"""
|
||||
获取 JSON 文件中指定字段的所有 IP 地址,返回分号分隔的字符串。
|
||||
"""
|
||||
return self._read_ips_from_json(field)
|
||||
|
||||
def overwrite_ips(self, field, new_ips):
|
||||
"""
|
||||
覆盖写入指定字段的新 IP 地址。
|
||||
"""
|
||||
self._overwrite_ips_in_json(field, new_ips)
|
||||
|
||||
def add_ips(self, field, new_ips):
|
||||
"""
|
||||
增量添加指定字段中的 IP 地址。
|
||||
:param field: 要更新的字段名
|
||||
:param new_ips: 要添加的 IP 地址列表或分号分隔的字符串
|
||||
"""
|
||||
# 获取当前的 IP 地址
|
||||
current_ips = self.read_ips(field).split(";") if self.read_ips(field) else []
|
||||
|
||||
# 合并新 IP 地址并去重、限制数量
|
||||
updated_ips = self._limit_and_deduplicate_ips(new_ips.split(";") + current_ips)
|
||||
|
||||
# 写入更新后的 IP 地址
|
||||
self.overwrite_ips(field, updated_ips)
|
||||
|
||||
87
plugins.v2/dynamicwechat/src/UpdateHelp.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import hashlib
|
||||
from typing import Dict, Any
|
||||
import json
|
||||
import requests
|
||||
from urllib.parse import urljoin
|
||||
from Cryptodome import Random
|
||||
from Cryptodome.Cipher import AES
|
||||
import base64
|
||||
|
||||
BLOCK_SIZE = 16
|
||||
|
||||
|
||||
def pad(data):
|
||||
length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
|
||||
return data + (chr(length) * length).encode()
|
||||
|
||||
|
||||
def bytes_to_key(data, salt, output=48):
|
||||
# extended from https://gist.github.com/gsakkis/4546068
|
||||
assert len(salt) == 8, len(salt)
|
||||
data += salt
|
||||
key = hashlib.md5(data).digest()
|
||||
final_key = key
|
||||
while len(final_key) < output:
|
||||
key = hashlib.md5(key + data).digest()
|
||||
final_key += key
|
||||
return final_key[:output]
|
||||
|
||||
|
||||
def encrypt(message, passphrase):
|
||||
salt = Random.new().read(8)
|
||||
key_iv = bytes_to_key(passphrase, salt, 32 + 16)
|
||||
key = key_iv[:32]
|
||||
iv = key_iv[32:]
|
||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||
return base64.b64encode(b"Salted__" + salt + aes.encrypt(pad(message)))
|
||||
|
||||
|
||||
class PyCookieCloud:
|
||||
def __init__(self, url: str, uuid: str, password: str):
|
||||
self.url: str = url
|
||||
self.uuid: str = uuid
|
||||
self.password: str = password
|
||||
|
||||
def check_connection(self) -> bool:
|
||||
"""
|
||||
Test the connection to the CookieCloud server.
|
||||
|
||||
:return: True if the connection is successful, False otherwise.
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(self.url)
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return False
|
||||
|
||||
def update_cookie(self, cookie: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Update cookie data to CookieCloud.
|
||||
|
||||
:param cookie: cookie value to update, if this cookie does not contain 'cookie_data' key, it will be added into 'cookie_data'.
|
||||
:return: if update success, return True, else return False.
|
||||
"""
|
||||
if 'cookie_data' not in cookie:
|
||||
cookie = {'cookie_data': cookie}
|
||||
raw_data = json.dumps(cookie)
|
||||
encrypted_data = encrypt(raw_data.encode('utf-8'), self.get_the_key().encode('utf-8')).decode('utf-8')
|
||||
cookie_cloud_request = requests.post(urljoin(self.url, '/update'),
|
||||
data={'uuid': self.uuid, 'encrypted': encrypted_data})
|
||||
if cookie_cloud_request.status_code == 200:
|
||||
if cookie_cloud_request.json()['action'] == 'done':
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_the_key(self) -> str:
|
||||
"""
|
||||
Get the key used to encrypt and decrypt data.
|
||||
|
||||
:return: the key.
|
||||
"""
|
||||
md5 = hashlib.md5()
|
||||
md5.update((self.uuid + '-' + self.password).encode('utf-8'))
|
||||
return md5.hexdigest()[:16]
|
||||
109
plugins.v2/feishucommandbridgelong/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# FeishuCommandBridgeLong
|
||||
|
||||
MoviePilot 的飞书长连接桥接插件。当前定位是兼容/备份入口;新用户更推荐直接使用 `Agent影视助手` 内置的飞书入口。
|
||||
|
||||
## 这版的定位
|
||||
|
||||
- 保留旧飞书桥接的轻量远程操作体验
|
||||
- 作为迁移期兼容插件继续可用
|
||||
- 新功能优先进入 `Agent影视助手`,避免飞书入口和资源执行逻辑继续分叉
|
||||
- 如果只想装一个插件完成云盘资源整合 + 飞书入口,优先安装并开启 `Agent影视助手` 的内置飞书入口
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 飞书长连接接收 `im.message.receive_v1`
|
||||
- 智能单入口:自动识别片名、115 链接、夸克链接、盘搜搜索
|
||||
- 影巢两段式搜索:先选影片,再看资源
|
||||
- `详情` / `审查` / `n 下一页` 会话续接
|
||||
- MoviePilot 原生搜索、下载、订阅、订阅搜索
|
||||
- `P115StrmHelper` 的手动整理、增量 STRM、全量 STRM
|
||||
- 115 扫码登录与状态查询
|
||||
- 待继续 115 任务查看、继续、取消
|
||||
|
||||
## 执行后端
|
||||
|
||||
- `旧桥接直连`
|
||||
适合保持现有飞书操作习惯,速度快。
|
||||
- `自动优先新主线,失败回落旧桥接`
|
||||
优先委托 `Agent影视助手`,失败再退回旧桥接。
|
||||
- `仅走 Agent影视助手 新主线`
|
||||
调试和后续统一主干时更合适。
|
||||
|
||||
日常老环境可以继续用 `旧桥接直连`。新环境建议改用 `Agent影视助手` 内置飞书入口;如果暂时仍使用本插件,建议切到 `仅走 Agent影视助手 新主线`,让资源动作统一落到 Agent影视助手。
|
||||
|
||||
## 新推荐入口
|
||||
|
||||
`Agent影视助手` 已内置可选 `Feishu Channel`,开启后可以直接接收飞书长连接消息,并复用同一套 `assistant/route`、`assistant/pick`、115 扫码和待任务续跑能力。
|
||||
|
||||
迁移建议:
|
||||
|
||||
1. 在本插件里先关闭 `启用插件`。
|
||||
2. 到 `Agent影视助手` 中打开 `启用内置飞书入口`。
|
||||
3. 迁移同一组飞书 `App ID / App Secret / Verification Token / 白名单`。
|
||||
4. 确认 `GET /api/v1/plugin/AgentResourceOfficer/feishu/health` 显示运行正常。
|
||||
|
||||
## 常用飞书命令
|
||||
|
||||
```txt
|
||||
处理 流浪地球2
|
||||
影巢搜索 流浪地球2
|
||||
yc流浪地球2
|
||||
2流浪地球2
|
||||
|
||||
盘搜搜索 流浪地球2
|
||||
ps流浪地球2
|
||||
1流浪地球2
|
||||
|
||||
链接 https://115cdn.com/s/xxxx path=/待整理
|
||||
链接 https://pan.quark.cn/s/xxxx path=/飞书
|
||||
|
||||
选择 1
|
||||
选择 1 path=/最新动画
|
||||
|
||||
详情
|
||||
审查
|
||||
n 下一页
|
||||
```
|
||||
|
||||
## 115 相关命令
|
||||
|
||||
```txt
|
||||
115登录
|
||||
115扫码
|
||||
检查115登录
|
||||
115登录状态
|
||||
115状态
|
||||
115帮助
|
||||
115任务
|
||||
继续115任务
|
||||
取消115任务
|
||||
```
|
||||
|
||||
- 当飞书桥接走 `Agent影视助手` 新主线时,`115登录` 会直接拉起扫码登录流程
|
||||
- 如果飞书回复里带了二维码图片,直接用 115 App 扫码即可
|
||||
- 某次 115 转存因为登录或会话问题失败后,可直接回复 `115任务` 查看当前待处理任务
|
||||
- 登录成功后回复 `检查115登录`,会自动尝试继续上一次待处理的 115 任务
|
||||
|
||||
## 智能单入口说明
|
||||
|
||||
- 发片名:进入影巢或盘搜搜索流程
|
||||
- 发 115 / 夸克链接:自动识别并转存,其中 115 链接会优先委托 `Agent影视助手`,确保失败后的待任务、扫码续跑和取消任务都在同一条会话链里
|
||||
- `path=/目录`、`位置=目录` 都支持
|
||||
- 裸链接也支持,不一定要带 `处理` 或 `链接` 前缀
|
||||
|
||||
## 智能体 API
|
||||
|
||||
插件提供两条更适合外部智能体调用的入口:
|
||||
|
||||
```txt
|
||||
POST /api/v1/plugin/FeishuCommandBridgeLong/assistant/route
|
||||
POST /api/v1/plugin/FeishuCommandBridgeLong/assistant/pick
|
||||
```
|
||||
|
||||
`route` 负责分流,`pick` 负责继续选择。飞书消息入口和这两条 API 用的是同一套会话逻辑。
|
||||
|
||||
## 依赖
|
||||
|
||||
```txt
|
||||
lark-oapi==1.5.3
|
||||
```
|
||||
4111
plugins.v2/feishucommandbridgelong/__init__.py
Normal file
1
plugins.v2/feishucommandbridgelong/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
lark-oapi==1.5.3
|
||||
2012
plugins.v2/hdhiveopenapi/__init__.py
Normal file
@@ -1,3 +1,4 @@
|
||||
import inspect
|
||||
import re
|
||||
import urllib.parse
|
||||
from datetime import datetime
|
||||
@@ -34,7 +35,7 @@ class ImdbSource(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "IMDb_IOS-OSX_App.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.6.7"
|
||||
plugin_version = "1.6.8"
|
||||
# 插件作者
|
||||
plugin_author = "wumode"
|
||||
# 作者主页
|
||||
@@ -64,42 +65,65 @@ class ImdbSource(_PluginBase):
|
||||
_original_async_method: Optional[Callable[..., Coroutine[Any, Any, Optional[MediaInfo]]]] = None
|
||||
_staff_picks_cache: Optional[StaffPickApiResponse] = None
|
||||
|
||||
@staticmethod
|
||||
def _extract_method_kwargs(method: Optional[Callable], chain_self, args: tuple, kwargs: dict) -> dict:
|
||||
if not method:
|
||||
return dict(kwargs)
|
||||
|
||||
try:
|
||||
signature = inspect.signature(method)
|
||||
bound = signature.bind_partial(chain_self, *args, **kwargs)
|
||||
arguments = dict(bound.arguments)
|
||||
first_param = next(iter(signature.parameters), None)
|
||||
if first_param:
|
||||
arguments.pop(first_param, None)
|
||||
return arguments
|
||||
except TypeError:
|
||||
arguments = dict(kwargs)
|
||||
if args:
|
||||
arguments.setdefault("meta", args[0])
|
||||
if len(args) > 1:
|
||||
arguments.setdefault("mtype", args[1])
|
||||
return arguments
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
|
||||
plugin_instance: ImdbSource = self
|
||||
|
||||
def patched_recognize_media(chain_self, meta: MetaBase = None,
|
||||
mtype: Optional[MediaType] = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
doubanid: Optional[str] = None,
|
||||
bangumiid: Optional[int] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
cache: bool = True):
|
||||
def patched_recognize_media(chain_self, *args, **kwargs):
|
||||
# 调用原始方法
|
||||
if not plugin_instance._original_method:
|
||||
return None
|
||||
result = plugin_instance._original_method(chain_self, meta, mtype, tmdbid, doubanid, bangumiid,
|
||||
episode_group, cache)
|
||||
result = plugin_instance._original_method(chain_self, *args, **kwargs)
|
||||
if result is None and ImdbSource._enabled and ImdbSource._recognize_media:
|
||||
logger.info(f"通过插件 {ImdbSource.plugin_name} 执行:recognize_media ...")
|
||||
return plugin_instance.recognize_media(meta, mtype)
|
||||
plugin_kwargs = plugin_instance._extract_method_kwargs(
|
||||
plugin_instance._original_method,
|
||||
chain_self,
|
||||
args,
|
||||
kwargs,
|
||||
)
|
||||
meta = plugin_kwargs.pop("meta", None)
|
||||
mtype = plugin_kwargs.pop("mtype", None)
|
||||
return plugin_instance.recognize_media(meta=meta, mtype=mtype, **plugin_kwargs)
|
||||
return result
|
||||
|
||||
async def patched_async_recognize_media(chain_self, meta: MetaBase = None,
|
||||
mtype: Optional[MediaType] = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
doubanid: Optional[str] = None,
|
||||
bangumiid: Optional[int] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
cache: bool = True):
|
||||
async def patched_async_recognize_media(chain_self, *args, **kwargs):
|
||||
# 调用原始方法
|
||||
if not plugin_instance._original_async_method:
|
||||
return None
|
||||
result = await plugin_instance._original_async_method(chain_self, meta, mtype, tmdbid, doubanid, bangumiid,
|
||||
episode_group, cache)
|
||||
result = await plugin_instance._original_async_method(chain_self, *args, **kwargs)
|
||||
if result is None and ImdbSource._enabled and ImdbSource._recognize_media:
|
||||
logger.info(f"通过插件 {ImdbSource.plugin_name} 执行:async_recognize_media ...")
|
||||
return await plugin_instance.async_recognize_media(meta, mtype)
|
||||
plugin_kwargs = plugin_instance._extract_method_kwargs(
|
||||
plugin_instance._original_async_method,
|
||||
chain_self,
|
||||
args,
|
||||
kwargs,
|
||||
)
|
||||
meta = plugin_kwargs.pop("meta", None)
|
||||
mtype = plugin_kwargs.pop("mtype", None)
|
||||
return await plugin_instance.async_recognize_media(meta=meta, mtype=mtype, **plugin_kwargs)
|
||||
return result
|
||||
|
||||
# 给 patch 函数加唯一标记
|
||||
|
||||
1129
plugins.v2/invitessignin/__init__.py
Normal file
@@ -60,7 +60,7 @@ class LexiAnnot(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "LexiAnnot.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.2.4"
|
||||
plugin_version = "1.2.5"
|
||||
# 插件作者
|
||||
plugin_author = "wumode"
|
||||
# 作者主页
|
||||
@@ -126,7 +126,7 @@ class LexiAnnot(_PluginBase):
|
||||
self._send_notify = config.get("send_notify")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._show_vocabulary_detail = config.get("show_vocabulary_detail")
|
||||
self._sentence_translation = config.get("sentence_translation")
|
||||
self._sentence_translation = bool(config.get("sentence_translation"))
|
||||
self._in_place = config.get("in_place")
|
||||
self._enable_gemini = config.get("enable_gemini")
|
||||
self._gemini_model = config.get("gemini_model") or "gemini-2.5-flash"
|
||||
@@ -1127,8 +1127,8 @@ class LexiAnnot(_PluginBase):
|
||||
tasks_to_delete = [task_id]
|
||||
else:
|
||||
tasks_to_delete = []
|
||||
for task_id in tasks_to_delete:
|
||||
del self._tasks[task_id]
|
||||
for t_id in tasks_to_delete:
|
||||
del self._tasks[t_id]
|
||||
self.save_tasks()
|
||||
|
||||
def task_interface(self, params: TasksApiParams) -> Response:
|
||||
@@ -1748,7 +1748,7 @@ class LexiAnnot(_PluginBase):
|
||||
return ass
|
||||
|
||||
@staticmethod
|
||||
def hex_to_rgb(hex_color) -> Optional[Tuple]:
|
||||
def hex_to_rgb(hex_color: str | None) -> tuple[int, ...] | None:
|
||||
if not hex_color:
|
||||
return None
|
||||
pattern = r"^#[0-9a-fA-F]{6}$"
|
||||
@@ -1796,7 +1796,7 @@ class LexiAnnot(_PluginBase):
|
||||
return track_lang in lang
|
||||
return track_lang == lang
|
||||
|
||||
supported_codec = ["S_TEXT/UTF8", "S_TEXT/ASS"]
|
||||
supported_codec = ["S_TEXT/UTF8", "S_TEXT/ASS", "tx3g"]
|
||||
subtitles = []
|
||||
try:
|
||||
media_info: pymediainfo.MediaInfo = pymediainfo.MediaInfo.parse(video_path)
|
||||
@@ -1893,7 +1893,7 @@ class LexiAnnot(_PluginBase):
|
||||
provider=llm_provider,
|
||||
model_name=llm_model_name,
|
||||
base_url=llm_base_url,
|
||||
api_key=llm_apikey,
|
||||
api_key=llm_apikey or '',
|
||||
temperature=model_temperature,
|
||||
max_retries=self._max_retries,
|
||||
proxy=self._use_proxy,
|
||||
|
||||
@@ -3,7 +3,7 @@ import threading
|
||||
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain.output_parsers import PydanticOutputParser
|
||||
from langchain_core.output_parsers import PydanticOutputParser
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -39,7 +39,7 @@ _patterns = [
|
||||
filter_patterns: list[re.Pattern] = [re.compile(p) for p in _patterns]
|
||||
pos_interests = {"NOUN", "VERB", "ADJ", "ADV", "ADP", "CCONJ", "SCONJ"}
|
||||
|
||||
UNIVERSAL_POS_MAP: dict[UniversalPos, str] = {
|
||||
UNIVERSAL_POS_MAP: dict[UniversalPos, str | None] = {
|
||||
UniversalPos.ADJ: "adj.",
|
||||
UniversalPos.ADV: "adv.",
|
||||
UniversalPos.INTJ: "int.",
|
||||
|
||||
45
plugins.v2/quarksharesaver/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# QuarkShareSaver
|
||||
|
||||
轻量夸克分享转存插件。
|
||||
|
||||
它只负责一件事:
|
||||
|
||||
- 把夸克分享链接直接转存到你自己的夸克网盘目录
|
||||
|
||||
适合的调用方式:
|
||||
|
||||
- 智能体调用插件 API
|
||||
- 飞书桥接发送简短命令
|
||||
|
||||
推荐接口:
|
||||
|
||||
- `GET /api/v1/plugin/QuarkShareSaver/health`
|
||||
- `GET /api/v1/plugin/QuarkShareSaver/folders?path=/`
|
||||
- `POST /api/v1/plugin/QuarkShareSaver/share/info`
|
||||
- `POST /api/v1/plugin/QuarkShareSaver/transfer`
|
||||
|
||||
`transfer` 请求体示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://pan.quark.cn/s/xxxxxxxx",
|
||||
"access_code": "abcd",
|
||||
"path": "/来自分享/夸克"
|
||||
}
|
||||
```
|
||||
|
||||
飞书推荐命令:
|
||||
|
||||
```text
|
||||
夸克转存 https://pan.quark.cn/s/xxxxxxxx pwd=abcd path=/最新动画
|
||||
```
|
||||
|
||||
配置重点:
|
||||
|
||||
- `Cookie` 使用浏览器登录 `pan.quark.cn` 后复制完整 Cookie
|
||||
- `默认保存目录` 建议填一个固定路径,例如 `/来自分享/夸克`
|
||||
|
||||
这类轻插件更适合做“稳定执行层”:
|
||||
|
||||
- 智能体负责理解意图和补参数
|
||||
- 插件负责真正转存
|
||||
1113
plugins.v2/quarksharesaver/__init__.py
Normal file
@@ -2,6 +2,7 @@ import asyncio
|
||||
import base64
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
@@ -62,7 +63,7 @@ class ToBypassTrackers(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "Clash_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.5.2"
|
||||
plugin_version = "1.5.3"
|
||||
# 插件作者
|
||||
plugin_author = "wumode"
|
||||
# 作者主页
|
||||
@@ -715,7 +716,7 @@ class ToBypassTrackers(_PluginBase):
|
||||
tracker_file = Path(self.get_data_path() / "trackers.json")
|
||||
try:
|
||||
if tracker_file.exists():
|
||||
trackers: dict[str, list[str]] = json.loads(tracker_file.read_text())
|
||||
trackers = json.loads(tracker_file.read_text(encoding="utf-8"))
|
||||
else:
|
||||
file = settings.ROOT_PATH / 'app' / 'plugins' / self.__class__.__name__.lower() / 'sites' / 'trackers'
|
||||
with open(file, "r", encoding="utf-8") as f:
|
||||
@@ -725,6 +726,63 @@ class ToBypassTrackers(_PluginBase):
|
||||
logger.error(f"trackers 加载错误:{e}")
|
||||
return trackers
|
||||
|
||||
@staticmethod
|
||||
def _get_redict_url(url: str, ua: str | None = None, cookie: str | None = None) -> str | None:
|
||||
"""
|
||||
获取下载链接, url格式:[base64]url
|
||||
"""
|
||||
# 获取[]中的内容
|
||||
m = re.search(r"\[(.*)](.*)", url)
|
||||
if m:
|
||||
# 参数
|
||||
base64_str = m.group(1)
|
||||
# URL
|
||||
url = m.group(2)
|
||||
if not base64_str:
|
||||
return url
|
||||
# 解码参数
|
||||
req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8')
|
||||
req_params: Dict[str, dict] = json.loads(req_str)
|
||||
# 是否使用cookie
|
||||
if not req_params.get('cookie'):
|
||||
cookie = None
|
||||
# 代理
|
||||
proxy = req_params.get('proxy')
|
||||
# 请求头
|
||||
if req_params.get('header'):
|
||||
headers = req_params.get('header')
|
||||
else:
|
||||
headers = None
|
||||
if req_params.get('method') == 'get':
|
||||
# GET请求
|
||||
res = RequestUtils(
|
||||
ua=ua,
|
||||
cookies=cookie,
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if proxy else None
|
||||
).get_res(url, params=req_params.get('params'))
|
||||
else:
|
||||
# POST请求
|
||||
res = RequestUtils(
|
||||
ua=ua,
|
||||
cookies=cookie,
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if proxy else None
|
||||
).post_res(url, params=req_params.get('params'))
|
||||
if not res:
|
||||
return None
|
||||
if not req_params.get('result'):
|
||||
return res.text
|
||||
else:
|
||||
data = res.json()
|
||||
for key in str(req_params.get('result')).split("."):
|
||||
data = data.get(key)
|
||||
if not data:
|
||||
return None
|
||||
logger.info(f"获取到下载地址:{data}")
|
||||
return data
|
||||
return None
|
||||
|
||||
def refresh_trackers(self):
|
||||
"""更新 Tracker 服务器列表"""
|
||||
logger.info("开始从站点获取最新 Tracker 服务器 ...")
|
||||
@@ -735,7 +793,14 @@ class ToBypassTrackers(_PluginBase):
|
||||
torrents = torrents_chain.browse(domain=site.domain)
|
||||
if not torrents:
|
||||
continue
|
||||
torrent_url = torrents[0].enclosure
|
||||
torrent_info = torrents[0]
|
||||
torrent_url = torrent_info.enclosure
|
||||
if torrent_url.startswith('['):
|
||||
torrent_url = ToBypassTrackers._get_redict_url(
|
||||
url=torrent_url, ua=torrent_info.site_ua, cookie=torrent_info.site_cookie
|
||||
)
|
||||
if torrent_url is None:
|
||||
continue
|
||||
_, content, _, _, error_msg = TorrentHelper().download_torrent(
|
||||
url=torrent_url,
|
||||
cookie=site.cookie,
|
||||
@@ -748,16 +813,17 @@ class ToBypassTrackers(_PluginBase):
|
||||
except BencodeDecodingError as e:
|
||||
logger.error(f"解析 {site.name} 种子文件失败: {e}")
|
||||
continue
|
||||
servers: list[str] = []
|
||||
for urls in torrent.announce_urls:
|
||||
servers: set[str] = set()
|
||||
for urls in torrent.announce_urls or []:
|
||||
for url in urls:
|
||||
parsed = urlparse(url)
|
||||
if parsed.hostname:
|
||||
servers.append(parsed.hostname)
|
||||
servers.add(parsed.hostname)
|
||||
if servers:
|
||||
trackers[site.domain] = servers
|
||||
trackers[site.domain] = list(servers)
|
||||
logger.info(f"更新 {site.name} trackers -> {trackers[site.domain]}")
|
||||
tracker_file = Path(self.get_data_path() / "trackers.json")
|
||||
tracker_file.write_text(json.dumps(trackers, indent=4))
|
||||
tracker_file.write_text(json.dumps(trackers, indent=4), encoding="utf-8")
|
||||
logger.info("已更新 Tracker 服务器列表")
|
||||
|
||||
def bypassed_ips(self, protocol: Literal['4', '6']) -> Response:
|
||||
@@ -941,7 +1007,7 @@ class ToBypassTrackers(_PluginBase):
|
||||
for domain in site_domains:
|
||||
domain_name_map[domain] = do_sites[site]
|
||||
else:
|
||||
logger.warn(f"不支持的站点: {do_sites[site]}({site})")
|
||||
logger.warn(f"不支持的站点: {do_sites[site]}({site}),请执行一次trackers更新。")
|
||||
unsupported_msg.append(f'【{do_sites[site]}】不支持的站点')
|
||||
for custom_tracker in self._custom_trackers.split('\n'):
|
||||
if custom_tracker:
|
||||
|
||||
@@ -28,7 +28,7 @@ class TorrentTransfer(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "seed.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.10.2"
|
||||
plugin_version = "1.10.3"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
|
||||
1
plugins.v2/torrenttransfer/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
fast-bencode~=1.1.7
|
||||
844
plugins.v2/tvfirstwatch/__init__.py
Normal file
@@ -0,0 +1,844 @@
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
import feedparser
|
||||
import requests
|
||||
|
||||
from app.chain.download import DownloadChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context, MediaInfo, TorrentInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.core.event import eventmanager
|
||||
from app.schemas.types import MediaType, EventType
|
||||
|
||||
lock = Lock()
|
||||
|
||||
EPISODE_PATTERNS = [
|
||||
re.compile(r"[Ss]\d{1,2}[Ee](\d{1,3})", re.IGNORECASE),
|
||||
re.compile(r"\bEP?\.?(\d{1,3})\b", re.IGNORECASE),
|
||||
re.compile(r"第\s*(\d{1,3})\s*集"),
|
||||
re.compile(r"[【\[((](\d{1,3})[】\]))]"),
|
||||
re.compile(r"\b(\d{1,3})\s*of\s*\d+\b", re.IGNORECASE),
|
||||
]
|
||||
|
||||
TV_HINTS = re.compile(
|
||||
r"\b(S\d{1,2}E\d{1,3}|EP?\d{1,3}|第\d+集|Season|Complete|HDTV|WEB-?DL)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
COMPLETE_HINTS = re.compile(
|
||||
r"\b(Complete|全集|全季|Season\s*\d+\s*Complete|S\d+\s*Complete)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class TvFirstWatch(_PluginBase):
|
||||
plugin_name = "首播试看"
|
||||
plugin_desc = "定时抓取 RSS, 只下载剧集前 N 集(首播试看),防重复推送。"
|
||||
plugin_icon = "rss.png"
|
||||
plugin_version = "1.0.0"
|
||||
plugin_author = "Raymond38324"
|
||||
author_url = "https://github.com/Raymond38324"
|
||||
plugin_config_prefix = "tvfirstwatch_"
|
||||
plugin_order = 25
|
||||
auth_level = 2
|
||||
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
_downloadchain: Optional[DownloadChain] = None
|
||||
_history_path: Optional[Path] = None
|
||||
|
||||
_enabled: bool = False
|
||||
_onlyonce: bool = False
|
||||
_cron: str = "*/30 * * * *"
|
||||
_rss_urls: str = ""
|
||||
_max_episode: int = 2
|
||||
_whitelist: str = "1080p,2160p,4K,HEVC,H.265"
|
||||
_blacklist: str = "720p,CAM,HDTS"
|
||||
_save_path: str = ""
|
||||
_max_storage_gb: int = 0
|
||||
_default_size_gb: float = 2.0
|
||||
_max_single_size_gb: float = 10.0
|
||||
|
||||
def init_plugin(self, config: dict = None) -> None:
|
||||
self._downloadchain = DownloadChain()
|
||||
self.stop_service()
|
||||
|
||||
if config:
|
||||
self._enabled = _to_bool(config.get("enabled", False), False)
|
||||
self._onlyonce = _to_bool(config.get("onlyonce", False), False)
|
||||
self._cron = config.get("cron", "*/30 * * * *") or "*/30 * * * *"
|
||||
self._rss_urls = config.get("rss_urls", "")
|
||||
self._max_episode = _to_int(config.get("max_episode", 2), 2)
|
||||
self._whitelist = config.get("whitelist", "")
|
||||
self._blacklist = config.get("blacklist", "")
|
||||
self._save_path = config.get("save_path", "")
|
||||
self._max_storage_gb = _to_int(config.get("max_storage_gb", 0), 0)
|
||||
self._default_size_gb = _to_float(config.get("default_size_gb", 2.0), 2.0)
|
||||
self._max_single_size_gb = _to_float(
|
||||
config.get("max_single_size_gb", 10.0), 10.0
|
||||
)
|
||||
|
||||
self._history_path = self.get_data_path() / "history.json"
|
||||
|
||||
if self._onlyonce:
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
logger.info("[首播试看] 立即运行一次")
|
||||
self._scheduler.add_job(
|
||||
func=self._check_all_feeds,
|
||||
trigger="date",
|
||||
run_date=datetime.datetime.now(tz=pytz.timezone(settings.TZ))
|
||||
+ datetime.timedelta(seconds=3),
|
||||
)
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.start()
|
||||
|
||||
self._onlyonce = False
|
||||
self.__update_config()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def get_command(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"cmd": "/tvfirst_check",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "首播试看立即检查",
|
||||
"category": "订阅",
|
||||
"data": {"action": "check_feeds"},
|
||||
}
|
||||
]
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"path": "/clear_history",
|
||||
"endpoint": self._clear_history,
|
||||
"methods": ["GET"],
|
||||
"summary": "清空首播试看下载历史",
|
||||
},
|
||||
{
|
||||
"path": "/storage_status",
|
||||
"endpoint": self._storage_status,
|
||||
"methods": ["GET"],
|
||||
"summary": "获取存储空间使用情况",
|
||||
},
|
||||
]
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
if self._enabled and self._cron:
|
||||
return [
|
||||
{
|
||||
"id": "TvFirstWatch",
|
||||
"name": "首播试看轮询",
|
||||
"trigger": CronTrigger.from_crontab(self._cron),
|
||||
"func": self._check_all_feeds,
|
||||
"kwargs": {},
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def _plugin_action(self, event):
|
||||
if not self._enabled:
|
||||
return
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "check_feeds":
|
||||
return
|
||||
logger.info("[首播试看] 收到远程命令,立即执行检查")
|
||||
self._check_all_feeds()
|
||||
|
||||
def stop_service(self) -> None:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"component": "VForm",
|
||||
"content": [
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
_col(4, _switch("enabled", "启用插件")),
|
||||
_col(4, _switch("onlyonce", "立即运行一次")),
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
_col(
|
||||
4,
|
||||
_textfield(
|
||||
"cron",
|
||||
"执行周期(Cron)",
|
||||
placeholder="*/30 * * * *",
|
||||
),
|
||||
),
|
||||
_col(
|
||||
2,
|
||||
_textfield(
|
||||
"max_episode",
|
||||
"最大集号",
|
||||
placeholder="默认 2",
|
||||
),
|
||||
),
|
||||
_col(
|
||||
3,
|
||||
_textfield(
|
||||
"max_storage_gb",
|
||||
"空间上限(GB)",
|
||||
placeholder="0=不限制",
|
||||
),
|
||||
),
|
||||
_col(
|
||||
3,
|
||||
_textfield(
|
||||
"max_single_size_gb",
|
||||
"单集上限(GB)",
|
||||
placeholder="超过跳过",
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
_col(
|
||||
6,
|
||||
_textfield(
|
||||
"default_size_gb",
|
||||
"预估大小(GB)",
|
||||
placeholder="RSS无大小默认值",
|
||||
),
|
||||
),
|
||||
_col(
|
||||
6,
|
||||
_textfield(
|
||||
"save_path",
|
||||
"下载保存路径",
|
||||
placeholder="留空使用MP默认",
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
_col(
|
||||
12,
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "rss_urls",
|
||||
"label": "RSS 地址",
|
||||
"rows": 4,
|
||||
"placeholder": (
|
||||
"每行一个地址,格式:\n"
|
||||
"https://site/rss?passkey=xxx\n"
|
||||
"# 需要Cookie:https://site/rss|Cookie: uid=1; pass=abc"
|
||||
),
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
_col(
|
||||
6,
|
||||
_textfield(
|
||||
"whitelist",
|
||||
"白名单关键字(逗号分隔)",
|
||||
placeholder="1080p,4K,HEVC",
|
||||
),
|
||||
),
|
||||
_col(
|
||||
6,
|
||||
_textfield(
|
||||
"blacklist",
|
||||
"黑名单关键字(逗号分隔)",
|
||||
placeholder="720p,CAM",
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
_col(
|
||||
12,
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "info",
|
||||
"variant": "tonal",
|
||||
"text": (
|
||||
"仅下载集号≤最大集号的电视剧,自动跳过Complete/全集。"
|
||||
"单集上限:超过此大小的种子将跳过(防合集)。"
|
||||
"空间上限:超出将停止下载,0表示不限制。"
|
||||
),
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"onlyonce": False,
|
||||
"cron": "*/30 * * * *",
|
||||
"rss_urls": "",
|
||||
"max_episode": 2,
|
||||
"whitelist": "1080p,2160p,4K,HEVC,H.265",
|
||||
"blacklist": "720p,CAM,HDTS",
|
||||
"save_path": "",
|
||||
"max_storage_gb": 0,
|
||||
"default_size_gb": 2.0,
|
||||
"max_single_size_gb": 10.0,
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
history = self._load_history()
|
||||
total_bytes = self._calculate_total_size(history)
|
||||
total_gb = total_bytes / (1024**3)
|
||||
max_gb = self._max_storage_gb
|
||||
usage_percent = (total_gb / max_gb * 100) if max_gb > 0 else 0
|
||||
count = len(history)
|
||||
|
||||
header_content = [
|
||||
{
|
||||
"component": "div",
|
||||
"props": {"class": "d-flex justify-space-between align-center"},
|
||||
"content": [
|
||||
{
|
||||
"component": "p",
|
||||
"props": {"class": "text-h6 mb-0"},
|
||||
"text": f"已用空间: {total_gb:.2f} GB"
|
||||
+ (
|
||||
f" / {max_gb} GB ({usage_percent:.1f}%)"
|
||||
if max_gb > 0
|
||||
else f" | 共 {count} 条记录"
|
||||
),
|
||||
},
|
||||
{
|
||||
"component": "VBtn",
|
||||
"props": {
|
||||
"color": "error",
|
||||
"variant": "outlined",
|
||||
"size": "small",
|
||||
},
|
||||
"text": "清空历史",
|
||||
"events": {
|
||||
"click": {
|
||||
"api": "plugin/TvFirstWatch/clear_history",
|
||||
"method": "get",
|
||||
"params": {"token": settings.API_TOKEN},
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
if not history:
|
||||
return [
|
||||
{
|
||||
"component": "div",
|
||||
"props": {"class": "pa-4"},
|
||||
"content": header_content
|
||||
+ [
|
||||
{
|
||||
"component": "p",
|
||||
"text": "暂无下载记录",
|
||||
"props": {"class": "text-center mt-4"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
rows = []
|
||||
for key, meta in sorted(
|
||||
history.items(), key=lambda x: x[1].get("added_at", ""), reverse=True
|
||||
):
|
||||
size_str = meta.get("size_str", "-")
|
||||
rows.append(
|
||||
{
|
||||
"component": "tr",
|
||||
"content": [
|
||||
{"component": "td", "text": meta.get("series_name", key)},
|
||||
{"component": "td", "text": str(meta.get("episode", ""))},
|
||||
{"component": "td", "text": size_str},
|
||||
{"component": "td", "text": meta.get("source", "")},
|
||||
{"component": "td", "text": meta.get("added_at", "")},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"component": "div",
|
||||
"props": {"class": "pa-2"},
|
||||
"content": header_content,
|
||||
},
|
||||
{
|
||||
"component": "VTable",
|
||||
"props": {"hover": True},
|
||||
"content": [
|
||||
{
|
||||
"component": "thead",
|
||||
"content": [
|
||||
{
|
||||
"component": "tr",
|
||||
"content": [
|
||||
{"component": "th", "text": "剧名"},
|
||||
{"component": "th", "text": "集号"},
|
||||
{"component": "th", "text": "大小"},
|
||||
{"component": "th", "text": "来源"},
|
||||
{"component": "th", "text": "下载时间"},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{"component": "tbody", "content": rows},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
def _check_all_feeds(self) -> None:
|
||||
if not self._rss_urls:
|
||||
logger.warning("[首播试看] 未配置任何 RSS 地址,跳过。")
|
||||
return
|
||||
|
||||
lines = [l.strip() for l in self._rss_urls.splitlines() if l.strip()]
|
||||
for line in lines:
|
||||
try:
|
||||
self._process_feed(line)
|
||||
except Exception as exc:
|
||||
logger.error("[首播试看] 处理 RSS 源出错: %s — %s", line, exc)
|
||||
|
||||
def _parse_feed_line(self, line: str) -> Tuple[str, dict]:
|
||||
parts = line.split("|", 1)
|
||||
url = parts[0].strip()
|
||||
headers = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/123.0.0.0 Safari/537.36"
|
||||
)
|
||||
}
|
||||
if len(parts) == 2:
|
||||
cookie_part = parts[1].strip()
|
||||
if cookie_part.lower().startswith("cookie:"):
|
||||
headers["Cookie"] = cookie_part[7:].strip()
|
||||
else:
|
||||
headers["Cookie"] = cookie_part
|
||||
return url, headers
|
||||
|
||||
def _process_feed(self, line: str) -> None:
|
||||
url, headers = self._parse_feed_line(line)
|
||||
source = urlparse(url).netloc
|
||||
logger.info("[首播试看] 抓取 RSS: %s", url)
|
||||
|
||||
try:
|
||||
resp = requests.get(url, headers=headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
logger.error("[首播试看] RSS 请求失败 [%s]: %s", source, exc)
|
||||
return
|
||||
|
||||
parsed = feedparser.parse(resp.text)
|
||||
if not parsed.entries:
|
||||
logger.warning("[首播试看] RSS 无条目 [%s]", source)
|
||||
return
|
||||
|
||||
logger.info("[首播试看] [%s] 解析到 %d 个条目", source, len(parsed.entries))
|
||||
for entry in parsed.entries:
|
||||
try:
|
||||
self._process_entry(entry, source)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"[首播试看] 处理条目异常 [%s]: %s", entry.get("title") or "", exc
|
||||
)
|
||||
|
||||
def _process_entry(self, entry, source: str) -> None:
|
||||
title = entry.get("title") or ""
|
||||
if not title:
|
||||
return
|
||||
|
||||
if not self._is_tv(entry):
|
||||
logger.debug("[首播试看][跳过-非TV] %s", title)
|
||||
return
|
||||
|
||||
if COMPLETE_HINTS.search(title):
|
||||
logger.info("[首播试看][跳过-合集] %s | 检测到Complete/全集关键词", title)
|
||||
return
|
||||
|
||||
episodes = _extract_episodes(title)
|
||||
if not episodes:
|
||||
logger.debug("[首播试看][跳过-无集数] %s", title)
|
||||
return
|
||||
|
||||
eps_in_range = [ep for ep in episodes if ep <= self._max_episode]
|
||||
if not eps_in_range:
|
||||
logger.info(
|
||||
"[首播试看][跳过-超限] %s | 识别集号=%s 限制≤%d",
|
||||
title,
|
||||
episodes,
|
||||
self._max_episode,
|
||||
)
|
||||
return
|
||||
|
||||
ok, reason = self._keyword_filter(title)
|
||||
if not ok:
|
||||
logger.info("[首播试看][跳过-关键字] %s | %s", title, reason)
|
||||
return
|
||||
|
||||
series_name = _guess_series_name(title)
|
||||
|
||||
torrent_size, is_estimated = self._get_torrent_size(entry)
|
||||
size_label = "预估" if is_estimated else "实际"
|
||||
size_gb = torrent_size / (1024**3)
|
||||
|
||||
if self._max_single_size_gb > 0 and size_gb > self._max_single_size_gb:
|
||||
logger.info(
|
||||
"[首播试看][跳过-过大] %s | 大小 %.2f GB > 上限 %.1f GB (可能是合集)",
|
||||
title,
|
||||
size_gb,
|
||||
self._max_single_size_gb,
|
||||
)
|
||||
return
|
||||
|
||||
with lock:
|
||||
history = self._load_history()
|
||||
new_eps = [
|
||||
ep
|
||||
for ep in eps_in_range
|
||||
if not self._is_downloaded(history, series_name, ep)
|
||||
]
|
||||
if not new_eps:
|
||||
logger.info(
|
||||
"[首播试看][跳过-已下载] %s | 剧名=%s | 集号=%s",
|
||||
title,
|
||||
series_name,
|
||||
eps_in_range,
|
||||
)
|
||||
return
|
||||
|
||||
if self._max_storage_gb > 0:
|
||||
current_total = self._calculate_total_size(history)
|
||||
max_bytes = self._max_storage_gb * (1024**3)
|
||||
if current_total + torrent_size > max_bytes:
|
||||
logger.warning(
|
||||
"[首播试看][跳过-空间不足] 已用 %.2f GB + 新增 %.2f GB(%s) > 上限 %d GB",
|
||||
current_total / (1024**3),
|
||||
size_gb,
|
||||
size_label,
|
||||
self._max_storage_gb,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"[首播试看][下载] %s | 剧名=%s | 集号=%s | 大小=%.2f GB(%s) | 来源=%s",
|
||||
title,
|
||||
series_name,
|
||||
new_eps,
|
||||
size_gb,
|
||||
size_label,
|
||||
source,
|
||||
)
|
||||
success = self._do_download(entry, title, series_name)
|
||||
|
||||
if success:
|
||||
now = datetime.datetime.now().isoformat(timespec="seconds")
|
||||
size_str = self._format_size(torrent_size, is_estimated)
|
||||
for ep in new_eps:
|
||||
key = _make_key(series_name, ep)
|
||||
history[key] = {
|
||||
"series_name": series_name,
|
||||
"episode": ep,
|
||||
"title": title,
|
||||
"source": source,
|
||||
"added_at": now,
|
||||
"size": torrent_size,
|
||||
"size_str": size_str,
|
||||
"is_estimated": is_estimated,
|
||||
}
|
||||
self._save_history(history)
|
||||
|
||||
def _get_torrent_size(self, entry) -> Tuple[int, bool]:
|
||||
"""
|
||||
获取种子大小。
|
||||
返回: (大小字节数, 是否为预估大小)
|
||||
"""
|
||||
try:
|
||||
if hasattr(entry, "enclosures") and entry.enclosures:
|
||||
for enc in entry.enclosures:
|
||||
length = enc.get("length")
|
||||
if length:
|
||||
return int(length), False
|
||||
if hasattr(entry, "content_length"):
|
||||
return int(entry.content_length), False
|
||||
except Exception:
|
||||
pass
|
||||
default_bytes = int(self._default_size_gb * (1024**3))
|
||||
return default_bytes, True
|
||||
|
||||
@staticmethod
|
||||
def _format_size(size_bytes: int, is_estimated: bool = False) -> str:
|
||||
label = "(预估)" if is_estimated else ""
|
||||
if size_bytes == 0:
|
||||
return "未知" + label
|
||||
elif size_bytes < 1024:
|
||||
return f"{size_bytes} B{label}"
|
||||
elif size_bytes < 1024**2:
|
||||
return f"{size_bytes / 1024:.1f} KB{label}"
|
||||
elif size_bytes < 1024**3:
|
||||
return f"{size_bytes / (1024**2):.1f} MB{label}"
|
||||
else:
|
||||
return f"{size_bytes / (1024**3):.2f} GB{label}"
|
||||
|
||||
@staticmethod
|
||||
def _calculate_total_size(history: dict) -> int:
|
||||
total = 0
|
||||
for meta in history.values():
|
||||
total += meta.get("size", 0)
|
||||
return total
|
||||
|
||||
def _do_download(self, entry, title: str, series_name: str) -> bool:
|
||||
torrent_url = ""
|
||||
for enc in getattr(entry, "enclosures", []):
|
||||
enc_type = enc.get("type") or ""
|
||||
if enc_type.startswith("application/"):
|
||||
torrent_url = enc.get("href") or ""
|
||||
break
|
||||
if not torrent_url:
|
||||
torrent_url = entry.get("link") or ""
|
||||
|
||||
if not torrent_url:
|
||||
logger.error("[首播试看] 条目缺少种子 URL: %s", title)
|
||||
return False
|
||||
|
||||
meta = MetaInfo(title=title)
|
||||
if not meta.name:
|
||||
meta.name = series_name
|
||||
|
||||
mediainfo = self.chain.recognize_media(meta=meta)
|
||||
if not mediainfo:
|
||||
logger.warning("[首播试看] 未识别到媒体信息,使用基本信息: %s", title)
|
||||
mediainfo = MediaInfo()
|
||||
mediainfo.type = MediaType.TV
|
||||
mediainfo.title = series_name
|
||||
|
||||
torrent = TorrentInfo(
|
||||
title=title,
|
||||
enclosure=torrent_url,
|
||||
page_url=entry.get("link", ""),
|
||||
)
|
||||
|
||||
context = Context(
|
||||
meta_info=meta,
|
||||
media_info=mediainfo,
|
||||
torrent_info=torrent,
|
||||
)
|
||||
|
||||
try:
|
||||
did = self._downloadchain.download_single(
|
||||
context=context,
|
||||
torrent_file=None,
|
||||
save_path=self._save_path or None,
|
||||
)
|
||||
if did:
|
||||
logger.info("[首播试看] ✅ DownloadChain 推送成功: %s", title)
|
||||
return True
|
||||
else:
|
||||
logger.error("[首播试看] ❌ DownloadChain 推送失败 [%s]", title)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error("[首播试看] ❌ 下载异常 [%s]: %s", title, exc)
|
||||
return False
|
||||
|
||||
def _keyword_filter(self, title: str) -> Tuple[bool, str]:
|
||||
title_lower = title.lower()
|
||||
for kw in [k.strip() for k in self._blacklist.split(",") if k.strip()]:
|
||||
if kw.lower() in title_lower:
|
||||
return False, f"命中黑名单「{kw}」"
|
||||
wl = [k.strip() for k in self._whitelist.split(",") if k.strip()]
|
||||
if wl and not any(k.lower() in title_lower for k in wl):
|
||||
return False, f"未命中白名单 {wl}"
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def _is_tv(entry) -> bool:
|
||||
cat = ""
|
||||
try:
|
||||
if hasattr(entry, "tags") and entry.tags:
|
||||
term = entry.tags[0].get("term") or ""
|
||||
cat = term.lower()
|
||||
elif hasattr(entry, "category"):
|
||||
cat = (entry.category or "").lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tv_cats = ("tv", "series", "drama", "television", "综艺", "剧集", "连续剧")
|
||||
movie_kw = ("movie", "film", "电影", "纪录片")
|
||||
if any(k in cat for k in tv_cats):
|
||||
return True
|
||||
if any(k in cat for k in movie_kw):
|
||||
return False
|
||||
return bool(TV_HINTS.search(entry.get("title") or ""))
|
||||
|
||||
def _load_history(self) -> dict:
|
||||
if self._history_path and self._history_path.exists():
|
||||
try:
|
||||
return json.loads(self._history_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save_history(self, history: dict) -> None:
|
||||
if self._history_path:
|
||||
self._history_path.write_text(
|
||||
json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_downloaded(history: dict, series_name: str, episode: int) -> bool:
|
||||
return _make_key(series_name, episode) in history
|
||||
|
||||
def _clear_history(self, token: str = "") -> dict:
|
||||
if token != settings.API_TOKEN:
|
||||
return {"success": False, "message": "认证失败"}
|
||||
self._save_history({})
|
||||
logger.info("[首播试看] 下载历史已清空。")
|
||||
return {"success": True, "message": "历史已清空"}
|
||||
|
||||
def _storage_status(self, token: str = "") -> dict:
|
||||
if token != settings.API_TOKEN:
|
||||
return {"success": False, "message": "认证失败"}
|
||||
history = self._load_history()
|
||||
total_bytes = self._calculate_total_size(history)
|
||||
total_gb = total_bytes / (1024**3)
|
||||
count = len(history)
|
||||
return {
|
||||
"success": True,
|
||||
"total_bytes": total_bytes,
|
||||
"total_gb": round(total_gb, 2),
|
||||
"max_gb": self._max_storage_gb,
|
||||
"count": count,
|
||||
}
|
||||
|
||||
def __update_config(self) -> None:
|
||||
self.update_config(
|
||||
{
|
||||
"enabled": self._enabled,
|
||||
"onlyonce": self._onlyonce,
|
||||
"cron": self._cron,
|
||||
"rss_urls": self._rss_urls,
|
||||
"max_episode": self._max_episode,
|
||||
"whitelist": self._whitelist,
|
||||
"blacklist": self._blacklist,
|
||||
"save_path": self._save_path,
|
||||
"max_storage_gb": self._max_storage_gb,
|
||||
"default_size_gb": self._default_size_gb,
|
||||
"max_single_size_gb": self._max_single_size_gb,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _extract_episodes(title: str) -> List[int]:
|
||||
found: set[int] = set()
|
||||
for pat in EPISODE_PATTERNS:
|
||||
for m in pat.finditer(title):
|
||||
try:
|
||||
ep = int(m.group(1))
|
||||
if 0 < ep < 1000:
|
||||
found.add(ep)
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def _guess_series_name(title: str) -> str:
|
||||
name = re.split(
|
||||
r"[\s._\-]*(?:[Ss]\d{1,2}[Ee]\d{1,3}|[Ee][Pp]?\d{1,3}|第\d+集|\d+of\d+|[\[(【]\d+[】\])])",
|
||||
title,
|
||||
maxsplit=1,
|
||||
)[0]
|
||||
name = re.sub(r"^\s*\[.*?\]\s*", "", name)
|
||||
name = re.sub(r"\b(19|20)\d{2}\b", "", name)
|
||||
name = re.sub(r"[\s._\-]+", " ", name).strip(" .-_")
|
||||
return name or title
|
||||
|
||||
|
||||
def _make_key(series_name: str, episode: int) -> str:
|
||||
norm = re.sub(r"\W+", "", series_name.lower())
|
||||
return f"{norm}__ep{episode:03d}"
|
||||
|
||||
|
||||
def _to_bool(value: Any, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return value != 0
|
||||
if isinstance(value, str):
|
||||
v = value.strip().lower()
|
||||
if v in ("1", "true", "yes", "y", "on"):
|
||||
return True
|
||||
if v in ("0", "false", "no", "n", "off", ""):
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _to_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _to_float(value: Any, default: float) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _col(md: int, *children) -> dict:
|
||||
return {
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": md},
|
||||
"content": list(children),
|
||||
}
|
||||
|
||||
|
||||
def _switch(model: str, label: str) -> dict:
|
||||
return {
|
||||
"component": "VSwitch",
|
||||
"props": {"model": model, "label": label},
|
||||
}
|
||||
|
||||
|
||||
def _textfield(model: str, label: str, placeholder: str = "") -> dict:
|
||||
props: dict = {"model": model, "label": label}
|
||||
if placeholder:
|
||||
props["placeholder"] = placeholder
|
||||
return {"component": "VTextField", "props": props}
|
||||
1
plugins.v2/tvfirstwatch/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
feedparser>=6.0.0
|
||||
2119
plugins.v2/wechatclawbot/__init__.py
Normal file
3
plugins.v2/wechatclawbot/ilink/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .client import ILinkClient, ILinkIncomingMessage
|
||||
|
||||
__all__ = ["ILinkClient", "ILinkIncomingMessage"]
|
||||
1158
plugins.v2/wechatclawbot/ilink/client.py
Normal file
223
plugins/agentresourceofficer/ARCHITECTURE.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Agent影视助手架构草案
|
||||
|
||||
`Agent影视助手` 是重构后的资源工作流主插件,重点不是把旧代码简单拼一起,而是把职责重新压平。
|
||||
|
||||
## 设计目标
|
||||
|
||||
- 一个插件承接“搜索 -> 选择 -> 解锁 -> 转存 -> 签到/用户态 -> 远程入口”
|
||||
- 智能体、飞书、CLI、后续 MP Agent Tool 共享同一套执行服务
|
||||
- 会话交互与底层执行解耦,避免继续把大量业务逻辑堆在消息入口层
|
||||
|
||||
## 模块分层
|
||||
|
||||
### 1. adapters
|
||||
|
||||
负责不同外部入口和外部平台接入:
|
||||
|
||||
- `feishu`
|
||||
- `hdhive`
|
||||
- `quark`
|
||||
- `pansou`
|
||||
- 后续 `agent_tool`
|
||||
|
||||
原则:
|
||||
|
||||
- 只负责协议和输入输出转换
|
||||
- 不负责复杂业务编排
|
||||
|
||||
### 2. services
|
||||
|
||||
负责核心业务能力:
|
||||
|
||||
- `search_service`
|
||||
- `unlock_service`
|
||||
- `transfer_service`
|
||||
- `signin_service`
|
||||
- `user_service`
|
||||
|
||||
原则:
|
||||
|
||||
- 统一返回结构
|
||||
- 尽量不感知飞书、页面、CLI 等具体入口
|
||||
|
||||
### 3. session
|
||||
|
||||
负责交互上下文:
|
||||
|
||||
- 搜索候选缓存
|
||||
- 翻页状态
|
||||
- 选择上下文
|
||||
- 详情/审查补充信息(已支持候选页按需补主演)
|
||||
|
||||
原则:
|
||||
|
||||
- 入口层共享同一套会话数据
|
||||
- 后续优先支持内存 + 轻量持久化
|
||||
|
||||
### 4. models
|
||||
|
||||
负责统一数据模型:
|
||||
|
||||
- 搜索候选
|
||||
- 资源条目
|
||||
- 解锁结果
|
||||
- 转存结果
|
||||
- 用户信息
|
||||
|
||||
目标:
|
||||
|
||||
- 减少旧插件之间字段名不一致的问题
|
||||
|
||||
## 首期配置模型
|
||||
|
||||
### 基础
|
||||
|
||||
- `enabled`
|
||||
- `notify`
|
||||
- `debug`
|
||||
|
||||
### 影巢
|
||||
|
||||
- `hdhive_base_url`
|
||||
- `hdhive_api_key`
|
||||
- `hdhive_default_path`
|
||||
- `hdhive_candidate_page_size`
|
||||
|
||||
### 夸克
|
||||
|
||||
- `quark_cookie`
|
||||
- `quark_default_path`
|
||||
- `quark_timeout`
|
||||
- `quark_auto_import_cookiecloud`
|
||||
|
||||
### 飞书
|
||||
|
||||
- `feishu_enabled`
|
||||
- `feishu_app_id`
|
||||
- `feishu_app_secret`
|
||||
- `feishu_verification_token`
|
||||
- `feishu_allow_all`
|
||||
- `feishu_allowed_chat_ids`
|
||||
- `feishu_allowed_user_ids`
|
||||
|
||||
### 智能体 / 工具层预留
|
||||
|
||||
- `agent_tools_enabled`
|
||||
- `tool_debug`
|
||||
|
||||
## 迁移映射
|
||||
|
||||
### 从 `QuarkShareSaver`
|
||||
|
||||
优先迁入:
|
||||
|
||||
- 分享链接解析
|
||||
- 目录创建
|
||||
- 转存执行
|
||||
- CookieCloud 自动导入
|
||||
|
||||
当前已开始拆出:
|
||||
|
||||
- `services/quark_transfer.py`
|
||||
|
||||
### 从 `P115StrmHelper` 协同层
|
||||
|
||||
当前已开始拆出:
|
||||
|
||||
- `services/p115_transfer.py`
|
||||
|
||||
### 从 `HdhiveOpenApi`
|
||||
|
||||
随后迁入:
|
||||
|
||||
- 搜索
|
||||
- 候选解析
|
||||
- 解锁
|
||||
- 用户信息
|
||||
- 配额
|
||||
- 分享管理
|
||||
|
||||
当前已开始拆出:
|
||||
|
||||
- `services/hdhive_openapi.py`
|
||||
|
||||
### 从 `HDHiveDailySign`
|
||||
|
||||
补入:
|
||||
|
||||
- 普通签到
|
||||
- 赌狗签到
|
||||
- 自动登录与状态记录
|
||||
|
||||
### 从 `FeishuCommandBridgeLong`
|
||||
|
||||
最后收口:
|
||||
|
||||
- 飞书长连接入口
|
||||
- 自然语言别名解析
|
||||
- 搜索/选择会话衔接
|
||||
|
||||
## 暂不迁入的内容
|
||||
|
||||
- `P115StrmHelper` 仍作为 115 落地执行层保留,不直接并入 `Agent影视助手`
|
||||
|
||||
> 更新说明:PT 搜索、下载、订阅、推荐、入库追踪相关工作流已经收口到 `Agent影视助手` 主线,不再依赖旧桥接插件作为主入口。
|
||||
|
||||
## P115StrmHelper 兼容补丁
|
||||
|
||||
新版 MoviePilot 移除了旧版 `TransferOverwriteCheck` 事件时,部分 `P115StrmHelper` 版本会因为导入 `TransferOverwriteCheckEventData` 失败而无法加载,进而导致 115 自动转存不可用。
|
||||
|
||||
仓库提供了幂等补丁脚本:
|
||||
|
||||
```bash
|
||||
MP_CONTAINER=moviepilot-v2 ./scripts/patch-p115strmhelper-mp-compat.sh
|
||||
```
|
||||
|
||||
补丁只跳过缺失事件的注册,不改动 `P115StrmHelper` 的分享转存主流程。运行环境已验证 `AgentResourceOfficer` 的 `p115/health` 可返回 `p115_ready=true`。
|
||||
|
||||
## 115 轻量直转层
|
||||
|
||||
`Agent影视助手` 从 `0.1.17` 开始支持 115 分享链接轻量直转 + 扫码会话登录:
|
||||
|
||||
- 支持生成和轮询 `p115client` 同款 115 扫码二维码,拿到 `UID / CID / SEID / KID` 这类客户端会话后自动写回插件配置
|
||||
- 配置扫码得到的 115 会话时,直接用该会话创建 115 客户端并调用 `share_receive`
|
||||
- 未配置独立扫码会话时,优先复用已加载的 115 客户端,不再必须走 `sharetransferhelper`
|
||||
- 直转失败时回退 `P115StrmHelper` 的分享转存主流程
|
||||
|
||||
这个能力只负责“分享链接落到 115 目标目录”。STRM 生成、302、增量/全量同步、媒体库整理仍保持由 `P115StrmHelper` 承担。
|
||||
这里特意没有走网页版 CookieCloud,也没有直接拿 MP 系统内置的 `u115` OAuth Token 来代替扫码会话,因为分享转存链路仍然更适合复用 `p115client` 的客户端会话模型。
|
||||
|
||||
## 首个里程碑
|
||||
|
||||
第一个可用版本只追求三件事:
|
||||
|
||||
1. 夸克分享链接直接转存
|
||||
2. 影巢搜索并解锁
|
||||
3. 飞书调用同一套执行服务
|
||||
|
||||
当前进度:
|
||||
|
||||
- 已拆出夸克执行服务
|
||||
- 已拆出影巢基础 OpenAPI 服务
|
||||
- 已拆出 115 转存执行服务
|
||||
- 已补上 Agent影视助手 自己的统一智能入口(assistant route / pick)
|
||||
- 主插件已具备:
|
||||
- 夸克健康检查
|
||||
- 夸克转存
|
||||
- 影巢健康检查
|
||||
- 影巢搜索
|
||||
- 影巢关键词候选搜索
|
||||
- 影巢解锁
|
||||
- 115 依赖健康检查
|
||||
- 115 分享转存
|
||||
- 影巢解锁后自动路由到夸克执行层
|
||||
- 影巢解锁后自动路由到 115 执行层
|
||||
- 影巢会话搜索与按编号继续选择
|
||||
- 盘搜搜索与按编号继续执行
|
||||
- 统一智能入口对直链、盘搜、影巢三类输入的会话分流
|
||||
- 原生 Agent Tool 直接发起和轮询 115 扫码登录
|
||||
- 智能入口 `assistant/route` 可直接理解 `115登录` / `检查115登录`
|
||||
- 扫码登录成功后可直接返回 115 运行状态摘要,便于飞书与 MP 智能助手继续执行
|
||||
- 智能入口与原生 Agent Tool 都可直接返回 `115状态` 摘要,不依赖是否存在待检查会话
|
||||
- 待继续的 115 任务已具备轻量持久化、时间/重试/错误摘要,并提供查看、继续、取消三个原生 Agent Tool 和标准 API
|
||||
- `115状态` / `检查115登录` / `115帮助` 统一补充下一步建议,减少人工猜测下一条命令
|
||||
224
plugins/agentresourceofficer/README.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Agent影视助手
|
||||
|
||||
`Agent影视助手` 是这个仓库的主线插件,重点解决一件事:
|
||||
|
||||
把 `飞书命令入口`、`外部智能体`、`盘搜`、`影巢`、`115`、`夸克`、`MoviePilot 原生搜索 / PT 下载` 收进同一套稳定工作流。
|
||||
|
||||
当前版本:`0.2.71`
|
||||
|
||||
当前 helper 版本:`0.1.51`
|
||||
|
||||
当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.71
|
||||
|
||||
如果你是第一次用这个仓库,先把这个插件跑通就够了。
|
||||
|
||||
---
|
||||
|
||||
## 适合谁
|
||||
|
||||
- 你想把飞书当成类似 `TG / 企业微信` 的资源命令入口。
|
||||
- 你想让 `OpenClaw`、`Hermes`、`WorkBuddy` 这类外部智能体稳定控制 MoviePilot。
|
||||
- 你想统一处理“找资源 -> 选资源 -> 转存到 115 / 夸克”的流程。
|
||||
- 你也想把 MoviePilot 原生 `MP搜索 / PT搜索 / 下载 / 订阅 / 更新检查` 放进同一套命令入口。
|
||||
- 你希望智能体不要自己乱拼影巢、盘搜、115、夸克接口,而是统一交给插件执行。
|
||||
|
||||
---
|
||||
|
||||
## 两种主要用法
|
||||
|
||||
### 1. 不使用外部智能体,只用飞书命令入口
|
||||
|
||||
如果你不想接外部智能体,只想要一个命令窗口,可以只配置飞书。
|
||||
|
||||
配好后,直接在飞书里发:
|
||||
|
||||
```text
|
||||
盘搜搜索 片名
|
||||
影巢搜索 片名
|
||||
转存 片名
|
||||
夸克转存 片名
|
||||
下载 片名
|
||||
更新检查 片名
|
||||
115登录
|
||||
影巢签到
|
||||
```
|
||||
|
||||
这种用法更像 TG / 企业微信机器人入口:飞书负责收消息,插件负责执行。
|
||||
|
||||
### 2. 使用外部智能体
|
||||
|
||||
如果你要接 `OpenClaw`、`Hermes`、`WorkBuddy`,建议安装 `agent-resource-officer skill / helper`。
|
||||
|
||||
外部智能体负责理解用户需求和展示结果;资源搜索、转存、下载、签到、Cookie 修复都交给插件。
|
||||
|
||||
重点文档:
|
||||
|
||||
- [外部智能体接入](../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md)
|
||||
- [跨机器部署](../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md)
|
||||
- 全部命令:`docs/ALL_COMMANDS.md`
|
||||
|
||||
### MCP 和 Skill 怎么分工
|
||||
|
||||
如果你的智能体客户端支持 MoviePilot 官方 MCP,可以一起接。
|
||||
|
||||
- MCP 更适合查 MoviePilot 管理信息,比如插件列表、下载器状态、站点状态、历史记录、工作流。
|
||||
- `agent-resource-officer skill / helper` 更适合资源流,比如盘搜、影巢、115/夸克转存、PT 编号下载、翻页、盘搜/影巢详情和 Cookie 修复。
|
||||
- `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流,也建议优先交给 `agent-resource-officer`,避免智能体绕过插件规则。
|
||||
|
||||
MCP 地址通常是:
|
||||
|
||||
```text
|
||||
http://你的MP地址:3000/api/v1/mcp
|
||||
X-API-KEY=你的 MoviePilot API_TOKEN
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心命令
|
||||
|
||||
### 搜索
|
||||
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `搜索 <片名>` | 默认优先走 MP/PT;如果 MP/PT 已关闭,再按当前启用源回退 |
|
||||
| `盘搜搜索 <片名>` | 先查盘搜;盘搜没结果时按开关补查影巢 |
|
||||
| `影巢搜索 <片名>` | 先查影巢;影巢没结果时按开关补查盘搜 |
|
||||
| `MP搜索 <片名>` / `PT搜索 <片名>` | 走 MoviePilot 原生搜索 / PT 搜索 |
|
||||
| `盘搜更新检查 <片名>` | 只看盘搜侧更新资源 |
|
||||
| `影巢更新检查 <片名>` | 只看影巢侧更新资源 |
|
||||
|
||||
补充:
|
||||
|
||||
- `搜索 第 3 集`、`搜索 E03` 这类带集数线索的写法,会直接按 MP/PT 搜索,不再回退到云盘。
|
||||
- `检查 大君夫人`、`检查大君夫人` 这类写法,会按更新检查处理;但 `检查115登录` 仍然保留为 115 登录检查。
|
||||
- `更新检查 xx 剧` / `检查 xx 剧` 这类带剧集意图的写法,会按 MP/PT 搜索;云盘侧更新检查请显式使用 `盘搜更新检查` 或 `影巢更新检查`。
|
||||
|
||||
### 转存 / 下载
|
||||
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `转存 <片名>` | 默认等同 `115转存 <片名>` |
|
||||
| `115转存 <片名>` | 搜索后优先转存到 115 |
|
||||
| `夸克转存 <片名>` | 搜索后优先转存到夸克 |
|
||||
| `下载 <片名>` | 走 MoviePilot 原生 PT 下载链,先找片并列出 PT 候选 |
|
||||
|
||||
注意:
|
||||
|
||||
- `转存 <片名>` 默认是 115,不会自动改成夸克。
|
||||
- 只有明确说 `夸克转存 <片名>` 才走夸克。
|
||||
- `下载 <片名>` 是 PT 下载,不是云盘转存。
|
||||
- PT 搜索结果里直接回编号会立即下载。
|
||||
- `下载1` 是给当前 PT 结果生成下载计划,不是确认旧计划。
|
||||
- 云盘/影巢结果才有详情卡;想看详情用 `选择 1 详情` 或 `详情 1`。
|
||||
|
||||
### 选择 / 翻页
|
||||
|
||||
```text
|
||||
1
|
||||
下载1
|
||||
选择 1 详情
|
||||
n
|
||||
```
|
||||
|
||||
- `1`:PT 结果里直接下载;云盘结果里继续转存/解锁。
|
||||
- `下载1`:给第 1 条 PT 结果生成下载计划。
|
||||
- `选择 1 详情` / `详情 1`:只用于云盘/影巢详情。
|
||||
- `n`:下一页。
|
||||
|
||||
完整命令见:`docs/ALL_COMMANDS.md`
|
||||
|
||||
---
|
||||
|
||||
## 主要能力
|
||||
|
||||
### 云盘资源
|
||||
|
||||
- 盘搜搜索
|
||||
- 影巢搜索 / 解锁
|
||||
- `云盘搜索` 已废弃,收到后只会提示改用 `盘搜搜索` / `影巢搜索`
|
||||
- 115 转存
|
||||
- 夸克转存
|
||||
- 更新检查
|
||||
- 编号选择、详情、翻页
|
||||
- 智能建议与候选推荐
|
||||
|
||||
### MoviePilot 原生能力
|
||||
|
||||
- MP / PT 搜索
|
||||
- PT 下载计划
|
||||
- 订阅
|
||||
- 下载任务
|
||||
- 下载历史
|
||||
- 入库历史
|
||||
- 站点状态 / 下载器状态
|
||||
- 热门探索 / 推荐
|
||||
|
||||
### 账号与修复
|
||||
|
||||
- 115 扫码登录 / 状态检查
|
||||
- 影巢签到 / 签到日志
|
||||
- 影巢 Cookie 修复
|
||||
- 夸克 Cookie 修复
|
||||
|
||||
`115登录` / `115转存` 现在不再强依赖 `P115StrmHelper`;有它时更适合做 115 整理、STRM 和旧登录态复用,没有它也可以直接扫码后完成 115 转存。
|
||||
|
||||
Cookie 修复会用到本机浏览器登录态。如果 MoviePilot 在 NAS、智能体在电脑上,修复命令读取的是智能体电脑上的浏览器 Cookie,再写回 NAS 上的 MoviePilot。
|
||||
|
||||
---
|
||||
|
||||
## 和旧插件的关系
|
||||
|
||||
`Agent影视助手` 是把旧的分散能力收成一条主线。
|
||||
|
||||
| 旧插件 | 主要用途 | 现在建议 |
|
||||
|---|---|---|
|
||||
| `FeishuCommandBridgeLong` | 旧飞书入口 | 新环境优先用 Agent影视助手内置飞书入口 |
|
||||
| `HdhiveOpenApi` | 影巢独立能力 | 主能力已收进 Agent影视助手 |
|
||||
| `QuarkShareSaver` | 夸克独立转存 | 主能力已收进 Agent影视助手 |
|
||||
| `HDHiveDailySign` | 旧影巢签到兜底 | 新环境优先走 Agent影视助手修复链 |
|
||||
|
||||
旧组合仍然能用,但更适合兼容老环境;新装建议优先用 `Agent影视助手`。
|
||||
|
||||
---
|
||||
|
||||
## 新手最容易踩的坑
|
||||
|
||||
### 外部智能体乱改命令
|
||||
|
||||
常见错误:
|
||||
|
||||
- 把 `盘搜搜索`、`影巢搜索`、`MP搜索` 这些明确命令改写成别的入口
|
||||
- 把 `下载` 当成云盘转存
|
||||
- 把云盘详情当成直接选择,或把 PT 编号下载当成详情
|
||||
- 重排插件返回的编号
|
||||
|
||||
解决方式:让智能体安装并读取 `agent-resource-officer skill`。长线程跑偏时,直接对智能体说:
|
||||
|
||||
```text
|
||||
校准影视技能
|
||||
```
|
||||
|
||||
外部智能体收到这句时,应先检查并拉取 `MoviePilot-Plugins` 仓库最新版,再重新加载影视技能规则;如果工作区有本地改动,就跳过自动拉取并说明原因。
|
||||
|
||||
### 跨机器地址填错
|
||||
|
||||
如果 MoviePilot 在 NAS,智能体在电脑上,`ARO_BASE_URL` 要填 NAS 地址:
|
||||
|
||||
```text
|
||||
ARO_BASE_URL=http://你的NAS地址:3000
|
||||
```
|
||||
|
||||
不要填 `127.0.0.1`,那只代表智能体自己这台机器。
|
||||
|
||||
### 夸克失败不一定是 Cookie 失效
|
||||
|
||||
分享受限、分享者封禁、`41031` 不一定是 Cookie 问题。只有明确提示登录态失效时,才优先走夸克 Cookie 修复。
|
||||
|
||||
---
|
||||
|
||||
## 进一步阅读
|
||||
|
||||
- [插件安装说明](../docs/PLUGIN_INSTALL.md)
|
||||
- [外部智能体接入](../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md)
|
||||
- [跨机器部署](../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md)
|
||||
- 全部命令:`docs/ALL_COMMANDS.md`
|
||||
27976
plugins/agentresourceofficer/__init__.py
Normal file
870
plugins/agentresourceofficer/agenttool.py
Normal file
@@ -0,0 +1,870 @@
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.core.plugin import PluginManager
|
||||
|
||||
from .schemas import (
|
||||
AssistantCapabilitiesToolInput,
|
||||
AssistantExecuteActionToolInput,
|
||||
AssistantExecuteActionsToolInput,
|
||||
AssistantExecutePlanToolInput,
|
||||
AssistantHistoryToolInput,
|
||||
AssistantHelpToolInput,
|
||||
AssistantMaintainToolInput,
|
||||
AssistantPickToolInput,
|
||||
AssistantPreferencesToolInput,
|
||||
AssistantPlansClearToolInput,
|
||||
AssistantPlansToolInput,
|
||||
AssistantPulseToolInput,
|
||||
AssistantReadinessToolInput,
|
||||
AssistantRecoverToolInput,
|
||||
AssistantRequestTemplatesToolInput,
|
||||
AssistantRouteToolInput,
|
||||
AssistantSessionClearToolInput,
|
||||
AssistantSessionsClearToolInput,
|
||||
AssistantSessionsToolInput,
|
||||
AssistantSessionStateToolInput,
|
||||
AssistantSelfcheckToolInput,
|
||||
AssistantStartupToolInput,
|
||||
AssistantToolboxToolInput,
|
||||
AssistantWorkflowToolInput,
|
||||
FeishuChannelHealthToolInput,
|
||||
HDHiveSearchSessionToolInput,
|
||||
HDHiveSessionPickToolInput,
|
||||
P115CancelPendingToolInput,
|
||||
P115PendingToolInput,
|
||||
P115QRCodeCheckToolInput,
|
||||
P115QRCodeStartToolInput,
|
||||
P115ResumePendingToolInput,
|
||||
P115StatusToolInput,
|
||||
ShareRouteToolInput,
|
||||
)
|
||||
|
||||
|
||||
def _get_plugin():
|
||||
return PluginManager().running_plugins.get("AgentResourceOfficer")
|
||||
|
||||
|
||||
class HDHiveSearchSessionTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_hdhive_search"
|
||||
description: str = "Search HDHive by title, return candidate titles and a reusable session_id for the next selection step."
|
||||
args_schema: Type[BaseModel] = HDHiveSearchSessionToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
keyword = kwargs.get("keyword", "")
|
||||
return f"正在通过 Agent影视助手搜索影巢候选:{keyword}"
|
||||
|
||||
async def run(self, keyword: str, media_type: str = "auto", year: str = None, path: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_hdhive_search_session(
|
||||
keyword=keyword,
|
||||
media_type=media_type,
|
||||
year=year,
|
||||
target_path=path,
|
||||
)
|
||||
|
||||
|
||||
class HDHiveSessionPickTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_hdhive_pick"
|
||||
description: str = "Continue a previous HDHive session by selecting either a candidate title or a resource item."
|
||||
args_schema: Type[BaseModel] = HDHiveSessionPickToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
session_id = kwargs.get("session_id", "")
|
||||
choice = kwargs.get("choice", "")
|
||||
return f"正在继续 Agent影视助手 会话:{session_id},选择 {choice}"
|
||||
|
||||
async def run(self, session_id: str, choice: int = 0, path: str = None, action: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_hdhive_pick_session(
|
||||
session_id=session_id,
|
||||
index=choice,
|
||||
target_path=path,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
class ShareRouteTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_route_share"
|
||||
description: str = "Route a 115 or Quark share link into the configured transfer pipeline and save it into the target path."
|
||||
args_schema: Type[BaseModel] = ShareRouteToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 路由分享链接"
|
||||
|
||||
async def run(self, url: str, path: str = None, access_code: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_route_share(
|
||||
share_url=url,
|
||||
access_code=access_code,
|
||||
target_path=path,
|
||||
)
|
||||
|
||||
|
||||
class AssistantRouteTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_smart_entry"
|
||||
description: str = "Use the unified Agent影视助手 smart entry for HDHive search, PanSou search, 115 login, or direct 115/Quark share links."
|
||||
args_schema: Type[BaseModel] = AssistantRouteToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
text = kwargs.get("text") or kwargs.get("keyword") or kwargs.get("url") or kwargs.get("action") or ""
|
||||
return f"正在通过 Agent影视助手 统一入口处理:{text}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
text: str = None,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
path: str = None,
|
||||
mode: str = None,
|
||||
keyword: str = None,
|
||||
url: str = None,
|
||||
access_code: str = None,
|
||||
media_type: str = None,
|
||||
year: str = None,
|
||||
client_type: str = None,
|
||||
action: str = None,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_route(
|
||||
text=text,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
target_path=path,
|
||||
mode=mode,
|
||||
keyword=keyword,
|
||||
share_url=url,
|
||||
access_code=access_code,
|
||||
media_type=media_type,
|
||||
year=year,
|
||||
client_type=client_type,
|
||||
action=action,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantPickTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_smart_pick"
|
||||
description: str = "Continue the unified Agent影视助手 smart-entry session by choosing an item, requesting details, or moving to the next page."
|
||||
args_schema: Type[BaseModel] = AssistantPickToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
session = kwargs.get("session", "default")
|
||||
choice = kwargs.get("choice", 0)
|
||||
action = kwargs.get("action", "")
|
||||
tail = f"动作 {action}" if action else f"选择 {choice}"
|
||||
return f"正在继续 Agent影视助手 统一会话:{session},{tail}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
choice: int = 0,
|
||||
action: str = None,
|
||||
mode: str = None,
|
||||
path: str = None,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_pick(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
index=choice,
|
||||
action=action,
|
||||
mode=mode,
|
||||
target_path=path,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantHelpTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_help"
|
||||
description: str = "Show the recommended Agent影视助手 workflow for MoviePilot Agent, including smart-entry examples, pick examples, and 115 login guidance."
|
||||
args_schema: Type[BaseModel] = AssistantHelpToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 使用帮助"
|
||||
|
||||
async def run(self, session: str = "default", session_id: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_help(session=session, session_id=session_id)
|
||||
|
||||
|
||||
class AssistantCapabilitiesTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_capabilities"
|
||||
description: str = "Show the current Agent影视助手 execution capabilities, supported structured smart-entry fields, defaults, and recommended call patterns for external agents."
|
||||
args_schema: Type[BaseModel] = AssistantCapabilitiesToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 能力说明"
|
||||
|
||||
async def run(self, compact: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_capabilities(compact=compact)
|
||||
|
||||
|
||||
class AssistantReadinessTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_readiness"
|
||||
description: str = "Check whether Agent影视助手 is ready for external agents, including version, services, suggested entrypoints, and startup warnings."
|
||||
args_schema: Type[BaseModel] = AssistantReadinessToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在检查 Agent影视助手 启动就绪状态"
|
||||
|
||||
async def run(self, compact: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_readiness(compact=compact)
|
||||
|
||||
|
||||
class FeishuChannelHealthTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_feishu_health"
|
||||
description: str = "Check Agent影视助手 built-in Feishu Channel status, including whether it is enabled, running, and configured."
|
||||
args_schema: Type[BaseModel] = FeishuChannelHealthToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在检查 Agent影视助手 内置飞书入口状态"
|
||||
|
||||
async def run(self, compact: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_feishu_health(compact=compact)
|
||||
|
||||
|
||||
class AssistantPulseTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_pulse"
|
||||
description: str = "Return a compact Agent影视助手 startup pulse: version, service readiness, warnings, and best recovery hint for external agents."
|
||||
args_schema: Type[BaseModel] = AssistantPulseToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在检查 Agent影视助手 轻量启动状态"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_pulse()
|
||||
|
||||
|
||||
class AssistantStartupTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_startup"
|
||||
description: str = "Return one compact startup bundle for external agents: pulse, self-check result, key tools, endpoints, defaults, and recovery hint."
|
||||
args_schema: Type[BaseModel] = AssistantStartupToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在读取 Agent影视助手 启动聚合信息"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_startup()
|
||||
|
||||
|
||||
class AssistantMaintainTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_maintain"
|
||||
description: str = "Inspect or execute low-risk Agent影视助手 maintenance: clear stale assistant sessions and executed saved plans."
|
||||
args_schema: Type[BaseModel] = AssistantMaintainToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在检查 Agent影视助手 维护建议"
|
||||
|
||||
async def run(self, execute: bool = False, limit: int = 100, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_maintain(execute=execute, limit=limit)
|
||||
|
||||
|
||||
class AssistantToolboxTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_toolbox"
|
||||
description: str = "Return a compact Agent影视助手 toolbox manifest: recommended tools, endpoints, workflows, actions, defaults, and command examples."
|
||||
args_schema: Type[BaseModel] = AssistantToolboxToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在读取 Agent影视助手 轻量工具清单"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_toolbox()
|
||||
|
||||
|
||||
class AssistantRequestTemplatesTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_request_templates"
|
||||
description: str = "Return compact HTTP request templates for external agents to call Agent影视助手 assistant endpoints without guessing request bodies."
|
||||
args_schema: Type[BaseModel] = AssistantRequestTemplatesToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在读取 Agent影视助手 请求模板"
|
||||
|
||||
async def run(self, limit: int = 100, names: str = None, recipe: str = None, include_templates: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_request_templates(
|
||||
limit=limit,
|
||||
names=names,
|
||||
recipe=recipe,
|
||||
include_templates=include_templates,
|
||||
)
|
||||
|
||||
|
||||
class AssistantSelfcheckTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_selfcheck"
|
||||
description: str = "Run a compact Agent影视助手 protocol self-check for compact templates, boolean parsing, and basic assistant protocol health."
|
||||
args_schema: Type[BaseModel] = AssistantSelfcheckToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在执行 Agent影视助手 协议自检"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_selfcheck()
|
||||
|
||||
|
||||
class AssistantHistoryTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_history"
|
||||
description: str = "Show recent Agent影视助手 assistant executions so external agents can debug progress, retries, and the last completed action."
|
||||
args_schema: Type[BaseModel] = AssistantHistoryToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 最近执行历史"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
compact: bool = True,
|
||||
limit: int = 20,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_history(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
compact=compact,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantExecuteActionTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_execute_action"
|
||||
description: str = "Execute a named Agent影视助手 action template directly, so external agents can reuse action_templates without manually mapping each next step."
|
||||
args_schema: Type[BaseModel] = AssistantExecuteActionToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return f"正在执行 Agent影视助手 动作模板:{kwargs.get('name', '')}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
name: str,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
choice: int = None,
|
||||
path: str = None,
|
||||
keyword: str = None,
|
||||
media_type: str = None,
|
||||
year: str = None,
|
||||
url: str = None,
|
||||
access_code: str = None,
|
||||
client_type: str = None,
|
||||
source: str = None,
|
||||
kind: str = None,
|
||||
has_pending_p115: bool = None,
|
||||
stale_only: bool = False,
|
||||
all_sessions: bool = False,
|
||||
limit: int = 100,
|
||||
plan_id: str = None,
|
||||
prefer_unexecuted: bool = True,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_execute_action(
|
||||
name=name,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
choice=choice,
|
||||
target_path=path,
|
||||
keyword=keyword,
|
||||
media_type=media_type,
|
||||
year=year,
|
||||
share_url=url,
|
||||
access_code=access_code,
|
||||
client_type=client_type,
|
||||
source=source,
|
||||
kind=kind,
|
||||
has_pending_p115=has_pending_p115,
|
||||
stale_only=stale_only,
|
||||
all_sessions=all_sessions,
|
||||
limit=limit,
|
||||
plan_id=plan_id,
|
||||
prefer_unexecuted=prefer_unexecuted,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantExecuteActionsTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_execute_actions"
|
||||
description: str = "Execute a sequence of Agent影视助手 action templates in one request, so external agents can reduce round trips and reuse action_templates directly."
|
||||
args_schema: Type[BaseModel] = AssistantExecuteActionsToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
actions = kwargs.get("actions") or []
|
||||
return f"正在批量执行 Agent影视助手 动作模板:{len(actions)} 步"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
actions: list,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
stop_on_error: bool = True,
|
||||
include_raw_results: bool = False,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_execute_actions(
|
||||
actions=actions,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
stop_on_error=stop_on_error,
|
||||
include_raw_results=include_raw_results,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantWorkflowTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_run_workflow"
|
||||
description: str = "Run a preset Agent影视助手 workflow such as pansou_transfer, hdhive_unlock, mp_search_best, mp_search_detail, mp_search_download, mp_subscribe, mp_recommend, share_transfer, or p115_status with compact inputs."
|
||||
args_schema: Type[BaseModel] = AssistantWorkflowToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return f"正在运行 Agent影视助手 预设工作流:{kwargs.get('name', '')}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
name: str,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
keyword: str = None,
|
||||
choice: int = None,
|
||||
candidate_choice: int = None,
|
||||
resource_choice: int = None,
|
||||
path: str = None,
|
||||
url: str = None,
|
||||
access_code: str = None,
|
||||
media_type: str = None,
|
||||
year: str = None,
|
||||
client_type: str = None,
|
||||
source: str = None,
|
||||
limit: int = 20,
|
||||
dry_run: bool = False,
|
||||
stop_on_error: bool = True,
|
||||
include_raw_results: bool = False,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_workflow(
|
||||
name=name,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
keyword=keyword,
|
||||
choice=choice,
|
||||
candidate_choice=candidate_choice,
|
||||
resource_choice=resource_choice,
|
||||
target_path=path,
|
||||
share_url=url,
|
||||
access_code=access_code,
|
||||
media_type=media_type,
|
||||
year=year,
|
||||
client_type=client_type,
|
||||
source=source,
|
||||
limit=limit,
|
||||
dry_run=dry_run,
|
||||
stop_on_error=stop_on_error,
|
||||
include_raw_results=include_raw_results,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantPreferencesTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_preferences"
|
||||
description: str = "Read, save, or reset Agent影视助手 source preferences for scoring cloud-drive and PT results before automated actions."
|
||||
args_schema: Type[BaseModel] = AssistantPreferencesToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
if kwargs.get("reset"):
|
||||
return "正在重置 Agent影视助手 智能体偏好画像"
|
||||
if kwargs.get("preferences"):
|
||||
return "正在保存 Agent影视助手 智能体偏好画像"
|
||||
return "正在读取 Agent影视助手 智能体偏好画像"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
user_key: str = None,
|
||||
preferences: dict = None,
|
||||
reset: bool = False,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_preferences(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
user_key=user_key,
|
||||
preferences=preferences,
|
||||
reset=reset,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantExecutePlanTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_execute_plan"
|
||||
description: str = "Execute a saved Agent影视助手 dry-run workflow plan by plan_id, or recover the latest plan by session/session_id."
|
||||
args_schema: Type[BaseModel] = AssistantExecutePlanToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return f"正在执行 Agent影视助手 已保存计划:{kwargs.get('plan_id', '') or kwargs.get('session_id', '') or kwargs.get('session', '')}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
plan_id: str = None,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
prefer_unexecuted: bool = True,
|
||||
stop_on_error: bool = True,
|
||||
include_raw_results: bool = False,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_execute_plan(
|
||||
plan_id=plan_id,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
prefer_unexecuted=prefer_unexecuted,
|
||||
stop_on_error=stop_on_error,
|
||||
include_raw_results=include_raw_results,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantPlansTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_plans"
|
||||
description: str = "List saved Agent影视助手 dry-run workflow plans so agents can recover and execute the right plan_id."
|
||||
args_schema: Type[BaseModel] = AssistantPlansToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 已保存计划"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
executed: bool = None,
|
||||
include_actions: bool = False,
|
||||
compact: bool = True,
|
||||
limit: int = 20,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_plans(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
executed=executed,
|
||||
include_actions=include_actions,
|
||||
compact=compact,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantPlansClearTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_plans_clear"
|
||||
description: str = "Clear saved Agent影视助手 workflow plans by plan_id, session, executed state, or all_plans."
|
||||
args_schema: Type[BaseModel] = AssistantPlansClearToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在清理 Agent影视助手 已保存计划"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
plan_id: str = None,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
executed: bool = None,
|
||||
all_plans: bool = False,
|
||||
limit: int = 100,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_plans_clear(
|
||||
plan_id=plan_id,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
executed=executed,
|
||||
all_plans=all_plans,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantRecoverTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_recover"
|
||||
description: str = "Inspect the best Agent影视助手 recovery action, or execute it directly, so external agents can resume work through one stable entrypoint."
|
||||
args_schema: Type[BaseModel] = AssistantRecoverToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
target = kwargs.get("session_id") or kwargs.get("session") or "全局"
|
||||
action = "并直接恢复" if kwargs.get("execute") else "恢复建议"
|
||||
return f"正在查看 Agent影视助手 {target} 的{action}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
execute: bool = False,
|
||||
prefer_unexecuted: bool = True,
|
||||
stop_on_error: bool = True,
|
||||
include_raw_results: bool = False,
|
||||
compact: bool = True,
|
||||
limit: int = 20,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_recover(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
execute=execute,
|
||||
prefer_unexecuted=prefer_unexecuted,
|
||||
stop_on_error=stop_on_error,
|
||||
include_raw_results=include_raw_results,
|
||||
compact=compact,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantSessionStateTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_session_state"
|
||||
description: str = "Inspect the current Agent影视助手 assistant session, including stage, current page, selected candidate, and pending 115 task."
|
||||
args_schema: Type[BaseModel] = AssistantSessionStateToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
session = kwargs.get("session", "default")
|
||||
return f"正在查看 Agent影视助手 会话状态:{session}"
|
||||
|
||||
async def run(self, session: str = "default", session_id: str = None, compact: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_session_state(session=session, session_id=session_id, compact=compact)
|
||||
|
||||
|
||||
class AssistantSessionClearTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_session_clear"
|
||||
description: str = "Clear the current Agent影视助手 assistant session cache."
|
||||
args_schema: Type[BaseModel] = AssistantSessionClearToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
session = kwargs.get("session", "default")
|
||||
return f"正在清理 Agent影视助手 会话:{session}"
|
||||
|
||||
async def run(self, session: str = "default", session_id: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_session_clear(session=session, session_id=session_id)
|
||||
|
||||
|
||||
class AssistantSessionsTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_sessions"
|
||||
description: str = "List active Agent影视助手 assistant sessions so external agents can recover, inspect, and resume the right workflow."
|
||||
args_schema: Type[BaseModel] = AssistantSessionsToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 活跃会话列表"
|
||||
|
||||
async def run(self, kind: str = None, has_pending_p115: bool = None, compact: bool = True, limit: int = 20, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_sessions(
|
||||
kind=kind,
|
||||
has_pending_p115=has_pending_p115,
|
||||
compact=compact,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantSessionsClearTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_sessions_clear"
|
||||
description: str = "Clear one or more Agent影视助手 assistant sessions by session_id, session name, filters, or full reset."
|
||||
args_schema: Type[BaseModel] = AssistantSessionsClearToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在清理 Agent影视助手 活跃会话"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
kind: str = None,
|
||||
has_pending_p115: bool = None,
|
||||
stale_only: bool = False,
|
||||
all_sessions: bool = False,
|
||||
limit: int = 100,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_sessions_clear(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
kind=kind,
|
||||
has_pending_p115=has_pending_p115,
|
||||
stale_only=stale_only,
|
||||
all_sessions=all_sessions,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class P115QRCodeStartTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_qrcode_start"
|
||||
description: str = "Generate a 115 login QR code using the p115client-compatible client session flow."
|
||||
args_schema: Type[BaseModel] = P115QRCodeStartToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
client_type = kwargs.get("client_type", "alipaymini")
|
||||
return f"正在通过 Agent影视助手 生成 115 扫码二维码:{client_type}"
|
||||
|
||||
async def run(self, client_type: str = "alipaymini", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_qrcode_start(client_type=client_type)
|
||||
|
||||
|
||||
class P115QRCodeCheckTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_qrcode_check"
|
||||
description: str = "Check the status of a previous 115 QR-code login and save the client session when login succeeds."
|
||||
args_schema: Type[BaseModel] = P115QRCodeCheckToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 检查 115 扫码状态"
|
||||
|
||||
async def run(self, uid: str, time: str, sign: str, client_type: str = "alipaymini", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_qrcode_check(
|
||||
uid=uid,
|
||||
time_value=time,
|
||||
sign=sign,
|
||||
client_type=client_type,
|
||||
)
|
||||
|
||||
|
||||
class P115StatusTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_status"
|
||||
description: str = "Show the current 115 transfer readiness, default target path, and current session source."
|
||||
args_schema: Type[BaseModel] = P115StatusToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 查看 115 当前状态"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_status()
|
||||
|
||||
|
||||
class P115PendingTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_pending"
|
||||
description: str = "Show the pending 115 transfer task for an assistant session, including target path, retry count, and last error."
|
||||
args_schema: Type[BaseModel] = P115PendingToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 查看待继续的 115 任务"
|
||||
|
||||
async def run(self, session: str = "default", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_pending(session=session)
|
||||
|
||||
|
||||
class P115ResumePendingTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_resume_pending"
|
||||
description: str = "Retry the pending 115 transfer task for an assistant session."
|
||||
args_schema: Type[BaseModel] = P115ResumePendingToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 继续待处理的 115 任务"
|
||||
|
||||
async def run(self, session: str = "default", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_resume(session=session)
|
||||
|
||||
|
||||
class P115CancelPendingTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_cancel_pending"
|
||||
description: str = "Cancel and clear the pending 115 transfer task for an assistant session."
|
||||
args_schema: Type[BaseModel] = P115CancelPendingToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 取消待处理的 115 任务"
|
||||
|
||||
async def run(self, session: str = "default", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_cancel(session=session)
|
||||
1936
plugins/agentresourceofficer/feishu_channel.py
Normal file
4
plugins/agentresourceofficer/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
requests
|
||||
cloudscraper
|
||||
lark-oapi==1.5.3
|
||||
p115client==0.0.8.4.8
|
||||
259
plugins/agentresourceofficer/schemas.py
Normal file
@@ -0,0 +1,259 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HDHiveSearchSessionToolInput(BaseModel):
|
||||
keyword: str = Field(..., description="要搜索的影片或剧集名称")
|
||||
media_type: str = Field(default="auto", description="媒体类型,auto / movie / tv;不确定时用 auto")
|
||||
year: Optional[str] = Field(default=None, description="可选年份,用于缩小候选范围")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录,不填则使用默认目录")
|
||||
|
||||
|
||||
class HDHiveSessionPickToolInput(BaseModel):
|
||||
session_id: str = Field(..., description="上一步搜索返回的会话 ID")
|
||||
choice: int = Field(default=0, description="当前阶段要选择的编号,从 1 开始;详情或翻页时可为 0")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录,不填则使用会话中的目录")
|
||||
action: Optional[str] = Field(default=None, description="可选动作:detail/details/review/详情/审查 或 next/n/下一页")
|
||||
|
||||
|
||||
class ShareRouteToolInput(BaseModel):
|
||||
url: str = Field(..., description="115 或夸克分享链接")
|
||||
path: Optional[str] = Field(default=None, description="目标目录")
|
||||
access_code: Optional[str] = Field(default=None, description="提取码,可选")
|
||||
|
||||
|
||||
class AssistantRouteToolInput(BaseModel):
|
||||
text: Optional[str] = Field(default=None, description="统一智能入口文本,例如 盘搜搜索 片名、影巢搜索 片名、115登录 或直接粘贴 115/夸克分享链接")
|
||||
session: Optional[str] = Field(default="default", description="会话标识,用于关联后续选择、115 待任务与扫码续跑")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,适合外部智能体按 sessions 列表中的精确会话继续使用")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录,不填则按当前模式使用默认目录")
|
||||
mode: Optional[str] = Field(default=None, description="结构化模式:mp / pansou / hdhive")
|
||||
keyword: Optional[str] = Field(default=None, description="结构化搜索关键词")
|
||||
url: Optional[str] = Field(default=None, description="结构化分享链接,支持 115 / 夸克")
|
||||
access_code: Optional[str] = Field(default=None, description="结构化提取码")
|
||||
media_type: Optional[str] = Field(default=None, description="结构化媒体类型:auto / movie / tv")
|
||||
year: Optional[str] = Field(default=None, description="结构化年份")
|
||||
client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型")
|
||||
action: Optional[str] = Field(default=None, description="结构化动作:p115_qrcode_start / p115_qrcode_check / p115_status / p115_help / p115_pending / p115_resume / p115_cancel / assistant_help")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantPickToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识,需与上一步统一智能入口保持一致")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
choice: int = Field(default=0, description="选择的编号,从 1 开始;详情或翻页时可为 0")
|
||||
action: Optional[str] = Field(default=None, description="可选动作:detail/details/review/详情/审查 或 next/n/下一页")
|
||||
mode: Optional[str] = Field(default=None, description="推荐列表后续搜索方式:mp / hdhive / pansou")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录,不填则沿用会话目录")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantHelpToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="可选会话标识;如该会话存在待继续的 115 任务,帮助里会附带任务摘要")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
|
||||
|
||||
class AssistantSessionStateToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则查看 default 会话当前状态")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantSessionClearToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则清理 default 会话")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
|
||||
|
||||
class AssistantCapabilitiesToolInput(BaseModel):
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantReadinessToolInput(BaseModel):
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class FeishuChannelHealthToolInput(BaseModel):
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantPulseToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class AssistantStartupToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class AssistantMaintainToolInput(BaseModel):
|
||||
execute: Optional[bool] = Field(default=False, description="是否立即执行低风险维护;默认只返回建议")
|
||||
limit: Optional[int] = Field(default=100, description="单次最多清理多少条")
|
||||
|
||||
|
||||
class AssistantToolboxToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class AssistantRequestTemplatesToolInput(BaseModel):
|
||||
limit: Optional[int] = Field(default=100, description="模板中批量类请求默认 limit,范围由插件限制")
|
||||
names: Optional[str] = Field(default=None, description="可选模板名,多个用逗号或空格分隔,例如 maintain_execute,workflow_dry_run")
|
||||
recipe: Optional[str] = Field(default=None, description="可选推荐流程名或别名,例如 plan / maintain / continue / bootstrap")
|
||||
include_templates: Optional[bool] = Field(default=True, description="是否返回完整模板内容;关闭时只返回名称、无效项和执行策略")
|
||||
|
||||
|
||||
class AssistantSelfcheckToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class AssistantHistoryToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;不填则返回全部最近执行记录")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
limit: Optional[int] = Field(default=20, description="最多返回多少条执行记录")
|
||||
|
||||
|
||||
class AssistantExecuteActionToolInput(BaseModel):
|
||||
name: str = Field(..., description="要执行的动作模板名,例如 pick_pansou_result / candidate_next_page / resume_pending_115")
|
||||
session: Optional[str] = Field(default="default", description="可选会话名")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
choice: Optional[int] = Field(default=None, description="需要选择编号时传入")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录")
|
||||
keyword: Optional[str] = Field(default=None, description="搜索类动作使用的关键词")
|
||||
media_type: Optional[str] = Field(default=None, description="搜索类动作使用的媒体类型")
|
||||
year: Optional[str] = Field(default=None, description="搜索类动作使用的年份")
|
||||
url: Optional[str] = Field(default=None, description="直链类动作使用的分享链接")
|
||||
access_code: Optional[str] = Field(default=None, description="可选提取码")
|
||||
client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型")
|
||||
source: Optional[str] = Field(default=None, description="MP 推荐来源,例如 tmdb_trending / douban_movie_hot / bangumi_calendar")
|
||||
kind: Optional[str] = Field(default=None, description="批量清理会话时的类型过滤")
|
||||
has_pending_p115: Optional[bool] = Field(default=None, description="批量清理会话时是否仅清理带待继续 115 的会话")
|
||||
stale_only: Optional[bool] = Field(default=False, description="批量清理会话时是否只清理过期会话")
|
||||
all_sessions: Optional[bool] = Field(default=False, description="批量清理会话时是否清理全部会话")
|
||||
limit: Optional[int] = Field(default=100, description="批量清理会话时的最多处理条数")
|
||||
plan_id: Optional[str] = Field(default=None, description="计划动作使用的 plan_id")
|
||||
prefer_unexecuted: Optional[bool] = Field(default=True, description="计划动作未指定 plan_id 时是否优先选择未执行计划")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantExecuteActionsToolInput(BaseModel):
|
||||
actions: List[Dict[str, Any]] = Field(..., description="动作模板执行数组,每项可直接复用 action_templates 里的 action_body")
|
||||
session: Optional[str] = Field(default="default", description="批量动作默认会话名;子动作未显式传 session/session_id 时自动继承")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否立即停止后续执行")
|
||||
include_raw_results: Optional[bool] = Field(default=False, description="是否附带每一步原始返回;默认关闭以减少 token 与负载")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantWorkflowToolInput(BaseModel):
|
||||
name: str = Field(..., description="预设工作流名,例如 pansou_search / pansou_transfer / hdhive_candidates / hdhive_unlock / mp_search / mp_search_download / mp_subscribe / mp_recommend / mp_recommend_search / share_transfer / p115_status")
|
||||
session: Optional[str] = Field(default="default", description="工作流会话名")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
keyword: Optional[str] = Field(default=None, description="搜索关键词")
|
||||
choice: Optional[int] = Field(default=None, description="通用选择编号,盘搜转存默认使用 1")
|
||||
candidate_choice: Optional[int] = Field(default=None, description="影巢候选影片编号")
|
||||
resource_choice: Optional[int] = Field(default=None, description="影巢资源编号")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录")
|
||||
url: Optional[str] = Field(default=None, description="分享链接")
|
||||
access_code: Optional[str] = Field(default=None, description="提取码")
|
||||
media_type: Optional[str] = Field(default=None, description="媒体类型,auto / movie / tv")
|
||||
mode: Optional[str] = Field(default=None, description="推荐后续搜索方式,mp / hdhive / pansou")
|
||||
year: Optional[str] = Field(default=None, description="年份")
|
||||
client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型")
|
||||
source: Optional[str] = Field(default=None, description="MP 推荐来源,例如 tmdb_trending / douban_movie_hot / bangumi_calendar")
|
||||
limit: Optional[int] = Field(default=20, description="推荐数量上限")
|
||||
dry_run: Optional[bool] = Field(default=False, description="只生成工作流计划,不实际执行")
|
||||
stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否停止")
|
||||
include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantPreferencesToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="偏好画像会话名;建议外部智能体固定传自己的用户会话")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
user_key: Optional[str] = Field(default=None, description="可选用户键;用于跨 session 共享同一套偏好")
|
||||
preferences: Optional[Dict[str, Any]] = Field(default=None, description="要保存的偏好画像;不传则只读取")
|
||||
reset: Optional[bool] = Field(default=False, description="是否重置偏好画像")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantExecutePlanToolInput(BaseModel):
|
||||
plan_id: Optional[str] = Field(default=None, description="可选 dry_run 返回的 plan_id;不传时可按 session/session_id 自动选择最近计划")
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;未传 plan_id 时可按会话自动选择最近计划")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
prefer_unexecuted: Optional[bool] = Field(default=True, description="自动选计划时是否优先只选未执行计划")
|
||||
stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否停止")
|
||||
include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantPlansToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;不填则返回全部最近计划")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
executed: Optional[bool] = Field(default=None, description="可选过滤:true 只看已执行,false 只看未执行")
|
||||
include_actions: Optional[bool] = Field(default=False, description="是否附带计划动作明细;默认关闭以减少 token")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
limit: Optional[int] = Field(default=20, description="最多返回多少条计划")
|
||||
|
||||
|
||||
class AssistantPlansClearToolInput(BaseModel):
|
||||
plan_id: Optional[str] = Field(default=None, description="可选计划 ID;传入时只清理这一条")
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;按会话清理")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
executed: Optional[bool] = Field(default=None, description="可选过滤:true 只清理已执行,false 只清理未执行")
|
||||
all_plans: Optional[bool] = Field(default=False, description="清理全部计划;未指定 plan_id/session/session_id/executed 时需要显式打开")
|
||||
limit: Optional[int] = Field(default=100, description="批量清理时最多清理多少条")
|
||||
|
||||
|
||||
class AssistantRecoverToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;不传则自动从全局活跃会话和待执行计划里挑选最佳恢复项")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
execute: Optional[bool] = Field(default=False, description="是否直接执行推荐恢复动作;默认只返回恢复建议")
|
||||
prefer_unexecuted: Optional[bool] = Field(default=True, description="执行保存计划时是否优先选择未执行计划")
|
||||
stop_on_error: Optional[bool] = Field(default=True, description="执行恢复动作时遇到失败是否停止")
|
||||
include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果;默认关闭以减少 token")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启,只返回恢复所需关键字段")
|
||||
limit: Optional[int] = Field(default=20, description="全局恢复扫描时最多查看多少个会话")
|
||||
|
||||
|
||||
class AssistantSessionsToolInput(BaseModel):
|
||||
kind: Optional[str] = Field(default=None, description="按会话类型过滤,例如 assistant_pansou / assistant_hdhive / assistant_p115_login")
|
||||
has_pending_p115: Optional[bool] = Field(default=None, description="是否只看带待继续 115 任务的会话")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
limit: Optional[int] = Field(default=20, description="最多返回多少条活跃会话摘要")
|
||||
|
||||
|
||||
class AssistantSessionsClearToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;只清理这一个会话")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID;只清理这一个会话")
|
||||
kind: Optional[str] = Field(default=None, description="按会话类型批量清理")
|
||||
has_pending_p115: Optional[bool] = Field(default=None, description="是否只清理带待继续 115 任务的会话")
|
||||
stale_only: Optional[bool] = Field(default=False, description="只清理已过期但仍残留的 assistant 会话")
|
||||
all_sessions: Optional[bool] = Field(default=False, description="清理全部 assistant 会话;用于重置外部智能体状态")
|
||||
limit: Optional[int] = Field(default=100, description="批量清理时最多清理多少条")
|
||||
|
||||
|
||||
class P115QRCodeStartToolInput(BaseModel):
|
||||
client_type: Optional[str] = Field(default="alipaymini", description="115 扫码客户端类型,默认 alipaymini")
|
||||
|
||||
|
||||
class P115QRCodeCheckToolInput(BaseModel):
|
||||
uid: str = Field(..., description="上一步二维码返回的 uid")
|
||||
time: str = Field(..., description="上一步二维码返回的 time")
|
||||
sign: str = Field(..., description="上一步二维码返回的 sign")
|
||||
client_type: Optional[str] = Field(default="alipaymini", description="客户端类型,需与生成二维码时保持一致")
|
||||
|
||||
|
||||
class P115StatusToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class P115PendingToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则查看 default 会话")
|
||||
|
||||
|
||||
class P115ResumePendingToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则继续 default 会话的待处理 115 任务")
|
||||
|
||||
|
||||
class P115CancelPendingToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则取消 default 会话的待处理 115 任务")
|
||||
1
plugins/agentresourceofficer/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Service modules for Agent影视助手."""
|
||||
1113
plugins/agentresourceofficer/services/hdhive_openapi.py
Normal file
818
plugins/agentresourceofficer/services/p115_transfer.py
Normal file
@@ -0,0 +1,818 @@
|
||||
import importlib
|
||||
import re
|
||||
import sys
|
||||
from base64 import b64encode
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
try:
|
||||
from app.core.config import settings
|
||||
except Exception:
|
||||
settings = None
|
||||
try:
|
||||
from app.core.plugin import PluginManager
|
||||
except Exception:
|
||||
PluginManager = None
|
||||
|
||||
|
||||
class P115TransferService:
|
||||
"""Reusable 115 share transfer execution layer for Agent影视助手."""
|
||||
|
||||
CLIENT_COOKIE_REQUIRED_KEYS = {"UID", "CID", "SEID"}
|
||||
QR_CLIENT_TYPES = {
|
||||
"web",
|
||||
"android",
|
||||
"115android",
|
||||
"ios",
|
||||
"115ios",
|
||||
"alipaymini",
|
||||
"wechatmini",
|
||||
"115ipad",
|
||||
"tv",
|
||||
"qandroid",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
default_target_path: str = "/待整理",
|
||||
cookie: str = "",
|
||||
prefer_direct: bool = True,
|
||||
) -> None:
|
||||
self.default_target_path = self.normalize_pan_path(default_target_path) or "/待整理"
|
||||
self.cookie = self.normalize_text(cookie)
|
||||
self.prefer_direct = bool(prefer_direct)
|
||||
|
||||
def set_cookie(self, cookie: str = "") -> None:
|
||||
self.cookie = self.normalize_text(cookie)
|
||||
|
||||
@staticmethod
|
||||
def normalize_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
@staticmethod
|
||||
def normalize_pan_path(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if not text.startswith("/"):
|
||||
text = f"/{text}"
|
||||
return text.rstrip("/") or "/"
|
||||
|
||||
@staticmethod
|
||||
def _ensure_helper_import_paths() -> None:
|
||||
candidate_dirs = [
|
||||
"/app/app/plugins",
|
||||
"/config/plugins",
|
||||
]
|
||||
for base in candidate_dirs:
|
||||
path = Path(base)
|
||||
if path.exists():
|
||||
text = str(path)
|
||||
if text not in sys.path:
|
||||
sys.path.append(text)
|
||||
|
||||
@staticmethod
|
||||
def is_115_share_url(url: str) -> bool:
|
||||
host = urlparse(url).netloc.lower()
|
||||
return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host
|
||||
|
||||
def ensure_115_share_url(self, url: str, access_code: str = "") -> str:
|
||||
clean_url = self.normalize_text(url)
|
||||
if not clean_url:
|
||||
return ""
|
||||
access_code = self.normalize_text(access_code)
|
||||
parsed = urlparse(clean_url)
|
||||
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
if access_code and "password" not in query:
|
||||
query["password"] = access_code
|
||||
clean_url = urlunparse(parsed._replace(query=urlencode(query)))
|
||||
return clean_url
|
||||
|
||||
@staticmethod
|
||||
def _extract_115_payload(url: str) -> Tuple[str, str]:
|
||||
clean_url = str(url or "").strip()
|
||||
if not clean_url:
|
||||
return "", ""
|
||||
try:
|
||||
from p115client.util import share_extract_payload
|
||||
|
||||
payload = share_extract_payload(clean_url) or {}
|
||||
return str(payload.get("share_code") or "").strip(), str(payload.get("receive_code") or "").strip()
|
||||
except Exception:
|
||||
parsed = urlparse(clean_url)
|
||||
share_code = ""
|
||||
match = re.search(r"/s/([^/?#]+)", parsed.path or "")
|
||||
if match:
|
||||
share_code = match.group(1).strip()
|
||||
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
receive_code = str(query.get("password") or query.get("receive_code") or query.get("pwd") or "").strip()
|
||||
return share_code, receive_code
|
||||
|
||||
@classmethod
|
||||
def parse_cookie_pairs(cls, cookie: str) -> Dict[str, str]:
|
||||
pairs: Dict[str, str] = {}
|
||||
for part in cls.normalize_text(cookie).strip(";").split(";"):
|
||||
if "=" not in part:
|
||||
continue
|
||||
key, value = part.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key and value:
|
||||
pairs[key] = value
|
||||
return pairs
|
||||
|
||||
@classmethod
|
||||
def validate_client_cookie(cls, cookie: str) -> Tuple[bool, str]:
|
||||
if not cls.normalize_text(cookie):
|
||||
return False, "未配置独立 115 Cookie"
|
||||
pairs = cls.parse_cookie_pairs(cookie)
|
||||
missing = sorted(cls.CLIENT_COOKIE_REQUIRED_KEYS - set(pairs))
|
||||
if missing:
|
||||
return False, f"当前 115 Cookie 缺少 {'/'.join(missing)},看起来不是扫码客户端 Cookie;不建议使用网页版 Cookie"
|
||||
return True, ""
|
||||
|
||||
def cookie_state(self) -> Dict[str, Any]:
|
||||
configured = bool(self.normalize_text(self.cookie))
|
||||
pairs = self.parse_cookie_pairs(self.cookie)
|
||||
cookie_keys = sorted(pairs.keys())
|
||||
if not configured:
|
||||
return {
|
||||
"configured": False,
|
||||
"valid": False,
|
||||
"mode": "none",
|
||||
"cookie_keys": [],
|
||||
"message": "未配置独立 115 会话。新环境请先发“115登录”扫码;P115StrmHelper 仅作为旧环境兼容 fallback。",
|
||||
}
|
||||
cookie_ok, cookie_message = self.validate_client_cookie(self.cookie)
|
||||
return {
|
||||
"configured": True,
|
||||
"valid": cookie_ok,
|
||||
"mode": "client_cookie" if cookie_ok else "invalid_cookie",
|
||||
"cookie_keys": cookie_keys,
|
||||
"message": "" if cookie_ok else cookie_message,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def normalize_qrcode_client_type(cls, client_type: Any) -> str:
|
||||
text = cls.normalize_text(client_type).lower()
|
||||
return text if text in cls.QR_CLIENT_TYPES else "alipaymini"
|
||||
|
||||
@staticmethod
|
||||
def jsonable(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (str, int, float, bool, list, dict)):
|
||||
return value
|
||||
if is_dataclass(value):
|
||||
return asdict(value)
|
||||
if hasattr(value, "model_dump"):
|
||||
try:
|
||||
return value.model_dump()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(value, "__dict__"):
|
||||
return {k: v for k, v in vars(value).items() if not k.startswith("_")}
|
||||
return str(value)
|
||||
|
||||
def tz_now(self) -> datetime:
|
||||
if settings is not None:
|
||||
try:
|
||||
return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai")))
|
||||
except Exception:
|
||||
pass
|
||||
return datetime.now()
|
||||
|
||||
@staticmethod
|
||||
def _safe_int(value: Any, default: int = -1) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _response_error(resp: Any) -> str:
|
||||
if not isinstance(resp, dict):
|
||||
return str(resp or "")
|
||||
for key in ("error", "message", "msg", "errno"):
|
||||
value = resp.get(key)
|
||||
if value not in (None, ""):
|
||||
return str(value)
|
||||
return str(resp)
|
||||
|
||||
@classmethod
|
||||
def _is_already_saved_message(cls, value: Any) -> bool:
|
||||
text = cls.normalize_text(value)
|
||||
return any(
|
||||
marker in text
|
||||
for marker in (
|
||||
"已经转存",
|
||||
"已转存",
|
||||
"已经保存",
|
||||
"已保存",
|
||||
"already",
|
||||
"exist",
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _response_ok(resp: Any) -> bool:
|
||||
if not isinstance(resp, dict):
|
||||
return False
|
||||
if resp.get("state") is True:
|
||||
return True
|
||||
if resp.get("code") in (0, "0") and resp.get("state") not in (False, 0):
|
||||
return True
|
||||
if resp.get("errno") in (0, "0") and resp.get("state") not in (False, 0):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _p115_request_kwargs(*, app: bool = False) -> Dict[str, Any]:
|
||||
try:
|
||||
P115TransferService._ensure_helper_import_paths()
|
||||
from app.plugins.p115strmhelper.core.config import configer
|
||||
|
||||
return configer.get_ios_ua_app(app=app) or {}
|
||||
except Exception:
|
||||
try:
|
||||
P115TransferService._ensure_helper_import_paths()
|
||||
from p115strmhelper.core.config import configer
|
||||
|
||||
return configer.get_ios_ua_app(app=app) or {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_servicer_from_loaded_plugin() -> Tuple[Optional[Any], Optional[str]]:
|
||||
if PluginManager is None:
|
||||
return None, "PluginManager 不可用"
|
||||
try:
|
||||
plugin = PluginManager().running_plugins.get("P115StrmHelper")
|
||||
except Exception as exc:
|
||||
return None, f"读取 P115StrmHelper 运行态失败: {exc}"
|
||||
if not plugin:
|
||||
return None, "P115StrmHelper 未加载"
|
||||
|
||||
module_names = []
|
||||
plugin_module = getattr(plugin.__class__, "__module__", "") or ""
|
||||
if plugin_module:
|
||||
module_names.append(f"{plugin_module}.service")
|
||||
module_names.extend(
|
||||
[
|
||||
"app.plugins.p115strmhelper.service",
|
||||
"p115strmhelper.service",
|
||||
]
|
||||
)
|
||||
|
||||
for module_name in module_names:
|
||||
try:
|
||||
self._ensure_helper_import_paths()
|
||||
module = sys.modules.get(module_name) or importlib.import_module(module_name)
|
||||
servicer = getattr(module, "servicer", None)
|
||||
if servicer is not None:
|
||||
return servicer, None
|
||||
except Exception:
|
||||
continue
|
||||
return None, "P115StrmHelper 运行态已加载,但未找到 service.servicer"
|
||||
|
||||
def _get_loaded_p115_client(self) -> Tuple[Optional[Any], str]:
|
||||
servicer, helper_error = self._resolve_servicer_from_loaded_plugin()
|
||||
if not servicer:
|
||||
return None, helper_error or "P115StrmHelper 未加载"
|
||||
client = getattr(servicer, "client", None)
|
||||
if not client:
|
||||
return None, "P115StrmHelper 未登录 115 或客户端不可用"
|
||||
return client, "p115strmhelper_client"
|
||||
|
||||
def _get_cookie_p115_client(self) -> Tuple[Optional[Any], str]:
|
||||
if not self.cookie:
|
||||
return None, "未配置独立 115 Cookie"
|
||||
cookie_ok, cookie_message = self.validate_client_cookie(self.cookie)
|
||||
if not cookie_ok:
|
||||
return None, cookie_message
|
||||
try:
|
||||
from p115client import P115Client
|
||||
|
||||
return P115Client(
|
||||
self.cookie,
|
||||
check_for_relogin=False,
|
||||
ensure_cookies=False,
|
||||
console_qrcode=False,
|
||||
), "direct_cookie"
|
||||
except Exception as exc:
|
||||
return None, f"独立 115 Cookie 初始化失败: {exc}"
|
||||
|
||||
@classmethod
|
||||
def create_qrcode_login(cls, client_type: str = "alipaymini") -> Tuple[bool, Dict[str, Any], str]:
|
||||
final_client_type = cls.normalize_qrcode_client_type(client_type)
|
||||
try:
|
||||
from p115client import P115Client, check_response
|
||||
|
||||
resp = P115Client.login_qrcode_token()
|
||||
check_response(resp)
|
||||
resp_info = resp.get("data", {}) if isinstance(resp, dict) else {}
|
||||
uid = str(resp_info.get("uid") or "")
|
||||
qrcode_time = str(resp_info.get("time") or "")
|
||||
sign = str(resp_info.get("sign") or "")
|
||||
qrcode = P115Client.login_qrcode(uid)
|
||||
if not isinstance(qrcode, (bytes, bytearray)):
|
||||
return False, {}, "获取二维码失败:返回内容类型异常"
|
||||
return True, {
|
||||
"uid": uid,
|
||||
"time": qrcode_time,
|
||||
"sign": sign,
|
||||
"client_type": final_client_type,
|
||||
"tips": "请使用 115 App 扫码登录",
|
||||
"qrcode": f"data:image/png;base64,{b64encode(qrcode).decode('utf-8')}",
|
||||
}, "success"
|
||||
except Exception as exc:
|
||||
return False, {}, f"获取 115 登录二维码失败: {exc}"
|
||||
|
||||
@classmethod
|
||||
def check_qrcode_login(
|
||||
cls,
|
||||
*,
|
||||
uid: str,
|
||||
time_value: str,
|
||||
sign: str,
|
||||
client_type: str = "alipaymini",
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
final_client_type = cls.normalize_qrcode_client_type(client_type)
|
||||
try:
|
||||
from p115client import P115Client, check_response
|
||||
|
||||
payload = {"uid": uid, "time": time_value, "sign": sign}
|
||||
resp = P115Client.login_qrcode_scan_status(payload)
|
||||
if not isinstance(resp, dict):
|
||||
return False, {}, "检查二维码状态失败:返回内容类型异常"
|
||||
check_response(resp)
|
||||
status_code = (resp.get("data") or {}).get("status")
|
||||
except Exception as exc:
|
||||
return False, {}, f"检查二维码状态失败: {exc}"
|
||||
|
||||
if status_code == 0:
|
||||
return True, {"status": "waiting", "client_type": final_client_type}, "等待扫码"
|
||||
if status_code == 1:
|
||||
return True, {"status": "scanned", "client_type": final_client_type}, "已扫码,等待确认"
|
||||
if status_code == -1 or status_code is None:
|
||||
return False, {"status": "expired", "client_type": final_client_type}, "二维码已过期"
|
||||
if status_code == -2:
|
||||
return False, {"status": "cancelled", "client_type": final_client_type}, "用户取消登录"
|
||||
if status_code != 2:
|
||||
return False, {"status": "unknown", "client_type": final_client_type}, f"未知二维码状态: {status_code}"
|
||||
|
||||
try:
|
||||
from p115client import P115Client, check_response
|
||||
|
||||
resp = P115Client.login_qrcode_scan_result(uid, app=final_client_type)
|
||||
if not isinstance(resp, dict):
|
||||
return False, {}, "获取登录结果失败:返回内容类型异常"
|
||||
check_response(resp)
|
||||
except Exception as exc:
|
||||
return False, {}, f"获取登录结果失败: {exc}"
|
||||
|
||||
cookie_data = (resp.get("data") or {}).get("cookie") if isinstance(resp, dict) else None
|
||||
if not isinstance(cookie_data, dict):
|
||||
return False, {}, "登录成功但未返回 Cookie"
|
||||
cookie = "; ".join(f"{name}={value}" for name, value in cookie_data.items() if name and value).strip()
|
||||
cookie_ok, cookie_message = cls.validate_client_cookie(cookie)
|
||||
if not cookie_ok:
|
||||
return False, {}, cookie_message
|
||||
return True, {
|
||||
"status": "success",
|
||||
"client_type": final_client_type,
|
||||
"cookie": cookie,
|
||||
"cookie_keys": sorted(cls.parse_cookie_pairs(cookie).keys()),
|
||||
}, "登录成功"
|
||||
|
||||
def get_direct_client(self) -> Tuple[Optional[Any], str, str]:
|
||||
client, source = self._get_cookie_p115_client()
|
||||
if client:
|
||||
return client, source, ""
|
||||
cookie_error = source
|
||||
client, source = self._get_loaded_p115_client()
|
||||
if client:
|
||||
return client, source, ""
|
||||
return None, "none", source or cookie_error
|
||||
|
||||
@classmethod
|
||||
def _import_servicer_fallback(cls) -> Tuple[Optional[Any], Optional[str]]:
|
||||
last_error = ""
|
||||
for module_name in [
|
||||
"app.plugins.p115strmhelper.service",
|
||||
"p115strmhelper.service",
|
||||
]:
|
||||
try:
|
||||
cls._ensure_helper_import_paths()
|
||||
service_module = importlib.import_module(module_name)
|
||||
servicer = getattr(service_module, "servicer", None)
|
||||
if servicer is not None:
|
||||
return servicer, None
|
||||
last_error = f"{module_name} 未暴露 servicer"
|
||||
except Exception as exc:
|
||||
last_error = f"{module_name} 导入失败: {exc}"
|
||||
return None, last_error or "P115StrmHelper 未安装或无法导入"
|
||||
|
||||
def get_share_helper(self) -> Tuple[Optional[Any], Optional[str]]:
|
||||
servicer, helper_error = self._resolve_servicer_from_loaded_plugin()
|
||||
if not servicer:
|
||||
servicer, helper_error = self._import_servicer_fallback()
|
||||
if not servicer:
|
||||
return None, f"P115StrmHelper 未安装或无法导入: {helper_error}"
|
||||
if not servicer:
|
||||
return None, "P115StrmHelper 未初始化"
|
||||
if not getattr(servicer, "client", None):
|
||||
return None, "P115StrmHelper 未登录 115 或客户端不可用"
|
||||
helper = getattr(servicer, "sharetransferhelper", None)
|
||||
if not helper:
|
||||
return None, "P115StrmHelper 分享转存模块不可用"
|
||||
return helper, None
|
||||
|
||||
def health(self) -> Tuple[bool, Dict[str, Any], str]:
|
||||
cookie_state = self.cookie_state()
|
||||
direct_client, direct_source, direct_error = self.get_direct_client()
|
||||
direct_ready = direct_client is not None
|
||||
helper, helper_error = self.get_share_helper()
|
||||
helper_ready = bool(helper and not helper_error)
|
||||
ready = direct_ready or helper_ready
|
||||
message = "" if ready else direct_error or helper_error or "115 转存不可用"
|
||||
return ready, {
|
||||
"ready": ready,
|
||||
"direct_ready": direct_ready,
|
||||
"direct_source": direct_source if direct_ready else "",
|
||||
"direct_message": "" if direct_ready else direct_error,
|
||||
"helper_ready": helper_ready,
|
||||
"helper_message": "" if helper_ready else helper_error,
|
||||
"cookie_state": cookie_state,
|
||||
"message": message or "success",
|
||||
}, message
|
||||
|
||||
def _get_or_create_path_cid(self, client: Any, path: str) -> int:
|
||||
return self._get_path_cid(client, path, create=True)
|
||||
|
||||
def _get_path_cid(self, client: Any, path: str, *, create: bool = True) -> int:
|
||||
target_path = self.normalize_pan_path(path) or "/"
|
||||
if target_path == "/":
|
||||
return 0
|
||||
get_kwargs = self._p115_request_kwargs(app=False)
|
||||
mkdir_kwargs = self._p115_request_kwargs(app=True)
|
||||
try:
|
||||
resp = client.fs_dir_getid(target_path, **get_kwargs)
|
||||
pid = self._safe_int(resp.get("id") if isinstance(resp, dict) else None, -1)
|
||||
if pid > 0:
|
||||
return pid
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not create:
|
||||
return -1
|
||||
|
||||
try:
|
||||
resp = client.fs_makedirs_app(target_path, pid=0, **mkdir_kwargs)
|
||||
cid = self._safe_int(resp.get("cid") if isinstance(resp, dict) else None, -1)
|
||||
if cid >= 0:
|
||||
return cid
|
||||
if self._response_ok(resp):
|
||||
cid = self._safe_int((resp.get("data") or {}).get("cid") if isinstance(resp.get("data"), dict) else None, -1)
|
||||
if cid >= 0:
|
||||
return cid
|
||||
raise RuntimeError(self._response_error(resp))
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"无法创建或定位 115 目录 {target_path}: {exc}") from exc
|
||||
|
||||
def list_directory_current_layer(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]:
|
||||
target_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理"
|
||||
result = {
|
||||
"time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"ok": False,
|
||||
"path": target_path,
|
||||
"items": [],
|
||||
"file_count": 0,
|
||||
"folder_count": 0,
|
||||
"removed_count": 0,
|
||||
"message": "",
|
||||
}
|
||||
client, source, client_error = self.get_direct_client()
|
||||
if not client:
|
||||
result["message"] = client_error or "没有可用的 115 客户端"
|
||||
result["direct_source"] = source
|
||||
return False, result, result["message"]
|
||||
|
||||
cid = self._get_path_cid(client, target_path, create=False)
|
||||
if cid < 0:
|
||||
result["ok"] = True
|
||||
result["direct_source"] = source
|
||||
result["message"] = "115 默认目录不存在,视为空目录"
|
||||
return True, result, result["message"]
|
||||
|
||||
payload = {
|
||||
"cid": int(cid),
|
||||
"limit": 1150,
|
||||
"offset": 0,
|
||||
"show_dir": 1,
|
||||
"cur": 1,
|
||||
"count_folders": 1,
|
||||
}
|
||||
items: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
try:
|
||||
while True:
|
||||
resp = client.fs_files(payload, **self._p115_request_kwargs(app=False))
|
||||
if not isinstance(resp, dict):
|
||||
result["message"] = "读取 115 目录失败:返回内容异常"
|
||||
result["direct_source"] = source
|
||||
return False, result, result["message"]
|
||||
batch = resp.get("data") or []
|
||||
total = self._safe_int(resp.get("count"), total)
|
||||
for entry in batch:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
fid = self._safe_int(entry.get("fid"), -1)
|
||||
item_cid = self._safe_int(entry.get("cid"), -1)
|
||||
is_dir = fid < 0
|
||||
item_id = item_cid if is_dir else fid
|
||||
if item_id < 0:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"id": item_id,
|
||||
"name": self.normalize_text(entry.get("n") or entry.get("fn") or entry.get("file_name")),
|
||||
"is_dir": is_dir,
|
||||
"type": "folder" if is_dir else "file",
|
||||
"raw": entry,
|
||||
}
|
||||
)
|
||||
payload["offset"] = int(payload["offset"]) + len(batch)
|
||||
if not batch or len(batch) < int(payload["limit"]) or int(payload["offset"]) >= total:
|
||||
break
|
||||
except Exception as exc:
|
||||
result["message"] = f"读取 115 目录失败: {exc}"
|
||||
result["direct_source"] = source
|
||||
return False, result, result["message"]
|
||||
|
||||
file_count = len([item for item in items if not item.get("is_dir")])
|
||||
folder_count = len([item for item in items if item.get("is_dir")])
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"direct_source": source,
|
||||
"cid": cid,
|
||||
"items": items,
|
||||
"file_count": file_count,
|
||||
"folder_count": folder_count,
|
||||
"message": "success",
|
||||
}
|
||||
)
|
||||
return True, result, "success"
|
||||
|
||||
def delete_items(self, items: list[dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]:
|
||||
client, source, client_error = self.get_direct_client()
|
||||
result = {
|
||||
"ok": False,
|
||||
"direct_source": source,
|
||||
"removed_count": 0,
|
||||
"message": "",
|
||||
}
|
||||
if not client:
|
||||
result["message"] = client_error or "没有可用的 115 客户端"
|
||||
return False, result, result["message"]
|
||||
|
||||
ids = [str(self._safe_int(item.get("id"), -1)) for item in items or [] if self._safe_int(item.get("id"), -1) >= 0]
|
||||
if not ids:
|
||||
result.update({"ok": True, "message": "115 默认目录当前层已是空目录"})
|
||||
return True, result, result["message"]
|
||||
|
||||
try:
|
||||
resp = client.fs_delete(ids, **self._p115_request_kwargs(app=False))
|
||||
except Exception as exc:
|
||||
result["message"] = f"删除 115 目录内容失败: {exc}"
|
||||
return False, result, result["message"]
|
||||
|
||||
if not self._response_ok(resp):
|
||||
result["message"] = self._response_error(resp) or "删除 115 目录内容失败"
|
||||
result["raw"] = self.jsonable(resp)
|
||||
return False, result, result["message"]
|
||||
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"removed_count": len(ids),
|
||||
"message": "115 默认目录已清空当前层",
|
||||
"raw": self.jsonable(resp),
|
||||
}
|
||||
)
|
||||
return True, result, result["message"]
|
||||
|
||||
def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]:
|
||||
target_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理"
|
||||
listed_ok, listed_result, listed_message = self.list_directory_current_layer(target_path)
|
||||
if not listed_ok:
|
||||
return False, listed_result, listed_message
|
||||
|
||||
items = listed_result.get("items") or []
|
||||
if not items:
|
||||
listed_result["message"] = "115 默认目录当前层已是空目录"
|
||||
return True, listed_result, listed_result["message"]
|
||||
|
||||
delete_ok, delete_result, delete_message = self.delete_items(items)
|
||||
merged = dict(listed_result)
|
||||
merged.update(
|
||||
{
|
||||
"ok": delete_ok,
|
||||
"removed_count": delete_result.get("removed_count", 0),
|
||||
"direct_source": delete_result.get("direct_source", listed_result.get("direct_source")),
|
||||
"delete_raw": delete_result.get("raw"),
|
||||
"message": delete_message,
|
||||
}
|
||||
)
|
||||
return delete_ok, merged, delete_message
|
||||
|
||||
def transfer_share_direct(
|
||||
self,
|
||||
*,
|
||||
url: str = "",
|
||||
access_code: str = "",
|
||||
path: str = "",
|
||||
trigger: str = "Agent影视助手",
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
transfer_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理"
|
||||
share_url = self.ensure_115_share_url(url or "", access_code or "")
|
||||
result = {
|
||||
"time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"ok": False,
|
||||
"trigger": trigger,
|
||||
"strategy": "direct",
|
||||
"path": transfer_path,
|
||||
"url": share_url,
|
||||
"message": "",
|
||||
"data": {},
|
||||
}
|
||||
if not share_url:
|
||||
result["message"] = "没有可用于 115 转存的分享链接"
|
||||
return False, result, result["message"]
|
||||
if not self.is_115_share_url(share_url):
|
||||
result["message"] = "当前链接不是 115 分享链接,无法直接转存到 115"
|
||||
return False, result, result["message"]
|
||||
|
||||
share_code, receive_code = self._extract_115_payload(share_url)
|
||||
if not share_code or not receive_code:
|
||||
result["message"] = "解析 115 分享链接失败,缺少分享码或提取码"
|
||||
return False, result, result["message"]
|
||||
|
||||
client, source, client_error = self.get_direct_client()
|
||||
if not client:
|
||||
result["message"] = client_error or "没有可用的 115 直转客户端"
|
||||
result["data"] = {"direct_source": source}
|
||||
return False, result, result["message"]
|
||||
|
||||
try:
|
||||
parent_id = self._get_or_create_path_cid(client, transfer_path)
|
||||
except Exception as exc:
|
||||
result["message"] = str(exc)
|
||||
result["data"] = {"direct_source": source}
|
||||
return False, result, result["message"]
|
||||
|
||||
payload = {
|
||||
"share_code": share_code,
|
||||
"receive_code": receive_code,
|
||||
"file_id": 0,
|
||||
"cid": int(parent_id),
|
||||
"is_check": 0,
|
||||
}
|
||||
try:
|
||||
resp = client.share_receive(payload, **self._p115_request_kwargs(app=False))
|
||||
except Exception as exc:
|
||||
result["message"] = f"调用 115 直转接口失败: {exc}"
|
||||
result["data"] = {"direct_source": source, "parent_id": parent_id}
|
||||
return False, result, result["message"]
|
||||
|
||||
if not self._response_ok(resp):
|
||||
result["message"] = self._response_error(resp) or "115 直转失败"
|
||||
result["data"] = {
|
||||
"direct_source": source,
|
||||
"parent_id": parent_id,
|
||||
"raw": self.jsonable(resp),
|
||||
}
|
||||
if self._is_already_saved_message(result["message"]):
|
||||
result["ok"] = True
|
||||
result["message"] = "115 直转已存在"
|
||||
return True, result, result["message"]
|
||||
return False, result, result["message"]
|
||||
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"message": "115 直转成功",
|
||||
"data": {
|
||||
"direct_source": source,
|
||||
"share_code": share_code,
|
||||
"receive_code": receive_code,
|
||||
"save_parent": transfer_path,
|
||||
"parent_id": parent_id,
|
||||
"raw": self.jsonable(resp),
|
||||
},
|
||||
}
|
||||
)
|
||||
return True, result, result["message"]
|
||||
|
||||
def transfer_share(
|
||||
self,
|
||||
*,
|
||||
url: str = "",
|
||||
access_code: str = "",
|
||||
path: str = "",
|
||||
trigger: str = "Agent影视助手",
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
transfer_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理"
|
||||
share_url = self.ensure_115_share_url(url or "", access_code or "")
|
||||
result = {
|
||||
"time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"ok": False,
|
||||
"trigger": trigger,
|
||||
"path": transfer_path,
|
||||
"url": share_url,
|
||||
"message": "",
|
||||
"data": {},
|
||||
}
|
||||
if not share_url:
|
||||
result["message"] = "没有可用于 115 转存的分享链接"
|
||||
return False, result, result["message"]
|
||||
if not self.is_115_share_url(share_url):
|
||||
result["message"] = "当前链接不是 115 分享链接,无法直接转存到 115"
|
||||
return False, result, result["message"]
|
||||
|
||||
if self.prefer_direct:
|
||||
direct_ok, direct_result, direct_message = self.transfer_share_direct(
|
||||
url=share_url,
|
||||
access_code=access_code,
|
||||
path=transfer_path,
|
||||
trigger=trigger,
|
||||
)
|
||||
if direct_ok:
|
||||
return True, direct_result, direct_message
|
||||
result["data"]["direct_fallback"] = direct_result
|
||||
|
||||
helper, helper_error = self.get_share_helper()
|
||||
if helper_error or not helper:
|
||||
direct_error = ((result.get("data") or {}).get("direct_fallback") or {}).get("message")
|
||||
result["message"] = (
|
||||
"115 转存不可用:请先发“115登录”完成扫码,或检查 115 直转依赖。"
|
||||
f" 直转状态:{direct_error or '未知'};兼容 fallback:{helper_error or '不可用'}"
|
||||
)
|
||||
return False, result, result["message"]
|
||||
|
||||
try:
|
||||
transfer_result = helper.add_share_115(
|
||||
share_url,
|
||||
notify=False,
|
||||
pan_path=transfer_path,
|
||||
)
|
||||
except Exception as exc:
|
||||
result["message"] = f"调用 P115StrmHelper 转存失败: {exc}"
|
||||
return False, result, result["message"]
|
||||
|
||||
if not transfer_result or not transfer_result[0]:
|
||||
error_message = ""
|
||||
if isinstance(transfer_result, tuple):
|
||||
if len(transfer_result) > 2:
|
||||
error_message = self.normalize_text(transfer_result[2])
|
||||
elif len(transfer_result) > 1:
|
||||
error_message = self.normalize_text(transfer_result[1])
|
||||
if self._is_already_saved_message(error_message):
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"strategy": "p115strmhelper",
|
||||
"message": "115 转存已存在",
|
||||
"data": {"raw": self.jsonable(transfer_result)},
|
||||
}
|
||||
)
|
||||
return True, result, result["message"]
|
||||
result["message"] = error_message or "115 转存失败"
|
||||
result["data"] = {"raw": self.jsonable(transfer_result)}
|
||||
return False, result, result["message"]
|
||||
|
||||
media_info = transfer_result[1] if len(transfer_result) > 1 else None
|
||||
save_parent = transfer_result[2] if len(transfer_result) > 2 else transfer_path
|
||||
parent_id = transfer_result[3] if len(transfer_result) > 3 else None
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"strategy": "p115strmhelper",
|
||||
"message": "115 转存成功",
|
||||
"data": {
|
||||
"media_info": self.jsonable(media_info),
|
||||
"save_parent": save_parent,
|
||||
"parent_id": parent_id,
|
||||
},
|
||||
}
|
||||
)
|
||||
return True, result, result["message"]
|
||||
675
plugins/agentresourceofficer/services/quark_transfer.py
Normal file
@@ -0,0 +1,675 @@
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import parse_qsl, urlparse, urlencode
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
|
||||
from app.log import logger
|
||||
|
||||
try:
|
||||
from app.core.config import settings
|
||||
except Exception:
|
||||
settings = None
|
||||
|
||||
|
||||
class QuarkTransferService:
|
||||
"""
|
||||
Reusable execution layer migrated out of QuarkShareSaver.
|
||||
|
||||
This service intentionally focuses on transfer execution and directory
|
||||
resolution. UI, plugin form logic, and entry adapters stay outside.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cookie: str = "",
|
||||
timeout: int = 30,
|
||||
default_target_path: str = "/飞书",
|
||||
auto_import_cookiecloud: bool = True,
|
||||
cookie_refresh_callback: Optional[Callable[[], str]] = None,
|
||||
) -> None:
|
||||
self.cookie = self.clean_text(cookie)
|
||||
self.timeout = max(10, self.safe_int(timeout, 30))
|
||||
self.default_target_path = self.normalize_path(default_target_path or "/飞书")
|
||||
self.auto_import_cookiecloud = auto_import_cookiecloud
|
||||
self.cookie_refresh_callback = cookie_refresh_callback
|
||||
self.path_cache: Dict[str, str] = {"/": "0"}
|
||||
|
||||
@staticmethod
|
||||
def clean_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
@staticmethod
|
||||
def safe_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def normalize_path(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return "/"
|
||||
if not text.startswith("/"):
|
||||
text = f"/{text}"
|
||||
text = re.sub(r"/+", "/", text)
|
||||
return text.rstrip("/") or "/"
|
||||
|
||||
@staticmethod
|
||||
def extract_url(raw_text: str) -> str:
|
||||
match = re.search(r"https?://[^\s<>\"']+", raw_text)
|
||||
if match:
|
||||
return match.group(0).rstrip(".,);]")
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def extract_share_info(cls, share_text: str, access_code: str = "") -> Tuple[str, str, str]:
|
||||
raw = cls.clean_text(share_text)
|
||||
share_url = cls.extract_url(raw) or raw
|
||||
parsed = urlparse(share_url)
|
||||
pwd_id_match = re.search(r"/s/([^/?#]+)", parsed.path)
|
||||
pwd_id = pwd_id_match.group(1).strip() if pwd_id_match else ""
|
||||
|
||||
code = cls.clean_text(access_code)
|
||||
if not code:
|
||||
query = dict(parse_qsl(parsed.query))
|
||||
code = cls.clean_text(query.get("pwd") or query.get("passcode") or query.get("code"))
|
||||
if not code and raw:
|
||||
for token in raw.replace(share_url, " ").split():
|
||||
text = token.strip()
|
||||
if not text:
|
||||
continue
|
||||
if "=" in text:
|
||||
key, value = text.split("=", 1)
|
||||
if key.strip().lower() in {"pwd", "passcode", "code", "提取码"}:
|
||||
code = cls.clean_text(value)
|
||||
break
|
||||
elif len(text) <= 8 and not text.startswith("/"):
|
||||
code = text
|
||||
break
|
||||
|
||||
return share_url, pwd_id, code
|
||||
|
||||
@staticmethod
|
||||
def is_quark_share_url(share_url: str) -> bool:
|
||||
hostname = urlparse(share_url).hostname or ""
|
||||
hostname = hostname.lower().strip(".")
|
||||
return hostname.endswith("quark.cn")
|
||||
|
||||
@classmethod
|
||||
def validate_share_url(cls, share_url: str) -> Tuple[bool, str]:
|
||||
if not share_url:
|
||||
return False, "未识别到有效夸克分享链接"
|
||||
if cls.is_quark_share_url(share_url):
|
||||
return True, ""
|
||||
hostname = urlparse(share_url).hostname or "未知域名"
|
||||
return False, f"当前链接域名为 {hostname},这不是夸克分享链接,请换成 pan.quark.cn 的分享链接"
|
||||
|
||||
def set_cookie(self, cookie: str) -> None:
|
||||
self.cookie = self.clean_text(cookie)
|
||||
|
||||
def _tz_now(self) -> datetime:
|
||||
if settings is not None:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai")))
|
||||
except Exception:
|
||||
pass
|
||||
return datetime.now()
|
||||
|
||||
def _build_headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Cookie": self.cookie,
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/137.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Origin": "https://pan.quark.cn",
|
||||
"Referer": "https://pan.quark.cn/",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _common_params() -> Dict[str, Any]:
|
||||
now = int(time.time() * 1000)
|
||||
return {
|
||||
"pr": "ucpro",
|
||||
"fr": "pc",
|
||||
"uc_param_str": "",
|
||||
"__dt": random.randint(100, 9999),
|
||||
"__t": now,
|
||||
}
|
||||
|
||||
def _refresh_cookie(self) -> bool:
|
||||
if not self.auto_import_cookiecloud or not self.cookie_refresh_callback:
|
||||
return False
|
||||
try:
|
||||
cookie = self.clean_text(self.cookie_refresh_callback())
|
||||
except Exception as exc:
|
||||
logger.warning(f"[Agent影视助手] 刷新夸克 Cookie 失败: {exc}")
|
||||
return False
|
||||
if not cookie:
|
||||
return False
|
||||
self.cookie = cookie
|
||||
return True
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
allow_cookie_retry: bool = True,
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
final_url = url
|
||||
if params:
|
||||
query = urlencode([(key, "" if value is None else value) for key, value in params.items()])
|
||||
final_url = f"{url}?{query}" if query else url
|
||||
|
||||
payload = None
|
||||
if json_body is not None:
|
||||
payload = json.dumps(json_body).encode("utf-8")
|
||||
|
||||
try:
|
||||
request = UrlRequest(
|
||||
url=final_url,
|
||||
data=payload,
|
||||
headers=self._build_headers(),
|
||||
method=method.upper(),
|
||||
)
|
||||
with urlopen(request, timeout=self.timeout) as response:
|
||||
status_code = getattr(response, "status", 200)
|
||||
raw_body = response.read()
|
||||
except HTTPError as exc:
|
||||
status_code = exc.code
|
||||
raw_body = exc.read() if hasattr(exc, "read") else b""
|
||||
except URLError as exc:
|
||||
return False, {}, f"请求失败: {exc.reason}"
|
||||
except Exception as exc:
|
||||
return False, {}, f"请求失败: {exc}"
|
||||
|
||||
try:
|
||||
data = json.loads(raw_body.decode("utf-8"))
|
||||
except Exception:
|
||||
text = raw_body.decode("utf-8", errors="ignore")[:300]
|
||||
return False, {}, f"接口返回非 JSON: HTTP {status_code} {text}"
|
||||
|
||||
if status_code in {401, 403} and allow_cookie_retry and self._refresh_cookie():
|
||||
return self._request(
|
||||
method,
|
||||
url,
|
||||
params=params,
|
||||
json_body=json_body,
|
||||
allow_cookie_retry=False,
|
||||
)
|
||||
|
||||
if status_code != 200:
|
||||
if isinstance(data, dict):
|
||||
code = self.clean_text(data.get("code"))
|
||||
detail = self.clean_text(data.get("message") or data.get("msg"))
|
||||
if detail:
|
||||
if code:
|
||||
return False, data, f"HTTP {status_code} [{code}]: {detail}"
|
||||
return False, data, f"HTTP {status_code}: {detail}"
|
||||
return False, data if isinstance(data, dict) else {}, f"HTTP {status_code}"
|
||||
|
||||
if isinstance(data, dict):
|
||||
message = str(data.get("message") or data.get("msg") or "").strip()
|
||||
ok = data.get("status") == 200 or data.get("code") == 0 or message == "ok"
|
||||
if ok:
|
||||
return True, data, ""
|
||||
return False, data, message or "接口返回失败"
|
||||
|
||||
return False, {}, "接口返回格式错误"
|
||||
|
||||
def get_stoken(self, pwd_id: str, access_code: str = "") -> Tuple[bool, str, str]:
|
||||
ok, data, message = self._request(
|
||||
"POST",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token",
|
||||
params=self._common_params(),
|
||||
json_body={"pwd_id": pwd_id, "passcode": access_code or ""},
|
||||
)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
|
||||
stoken = self.clean_text((data.get("data") or {}).get("stoken"))
|
||||
if not stoken:
|
||||
return False, "", "未获取到 stoken,可能是提取码错误或 Cookie 失效"
|
||||
return True, stoken, ""
|
||||
|
||||
def get_share_items(self, pwd_id: str, stoken: str) -> Tuple[bool, List[Dict[str, Any]], str]:
|
||||
items: List[Dict[str, Any]] = []
|
||||
page = 1
|
||||
while True:
|
||||
params = self._common_params()
|
||||
params.update(
|
||||
{
|
||||
"pwd_id": pwd_id,
|
||||
"stoken": stoken,
|
||||
"pdir_fid": "0",
|
||||
"force": "0",
|
||||
"_page": str(page),
|
||||
"_size": "50",
|
||||
"_sort": "file_type:asc,updated_at:desc",
|
||||
}
|
||||
)
|
||||
ok, data, message = self._request(
|
||||
"GET",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail",
|
||||
params=params,
|
||||
)
|
||||
if not ok:
|
||||
return False, [], message
|
||||
|
||||
payload = data.get("data") or {}
|
||||
meta = data.get("metadata") or {}
|
||||
current = payload.get("list") or []
|
||||
for item in current:
|
||||
items.append(
|
||||
{
|
||||
"fid": str(item.get("fid") or ""),
|
||||
"file_name": str(item.get("file_name") or ""),
|
||||
"dir": bool(item.get("dir")),
|
||||
"file_type": item.get("file_type"),
|
||||
"pdir_fid": str(item.get("pdir_fid") or ""),
|
||||
"share_fid_token": str(item.get("share_fid_token") or ""),
|
||||
}
|
||||
)
|
||||
|
||||
total = self.safe_int(meta.get("_total"), 0)
|
||||
count = self.safe_int(meta.get("_count"), len(current))
|
||||
size = max(1, self.safe_int(meta.get("_size"), 50))
|
||||
if total <= len(items) or count < size:
|
||||
break
|
||||
page += 1
|
||||
|
||||
if not items:
|
||||
return False, [], "分享链接为空,或当前账号无权查看内容"
|
||||
return True, items, ""
|
||||
|
||||
def list_children(self, parent_fid: str) -> Tuple[bool, List[Dict[str, Any]], str]:
|
||||
page = 1
|
||||
result: List[Dict[str, Any]] = []
|
||||
while True:
|
||||
params = {
|
||||
"pr": "ucpro",
|
||||
"fr": "pc",
|
||||
"uc_param_str": "",
|
||||
"pdir_fid": parent_fid,
|
||||
"_page": page,
|
||||
"_size": 100,
|
||||
"_fetch_total": 1,
|
||||
"_fetch_sub_dirs": 0,
|
||||
"_sort": "file_type:asc,updated_at:desc",
|
||||
}
|
||||
ok, data, message = self._request(
|
||||
"GET",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/file/sort",
|
||||
params=params,
|
||||
)
|
||||
if not ok:
|
||||
return False, [], message
|
||||
|
||||
current = ((data.get("data") or {}).get("list")) or []
|
||||
for item in current:
|
||||
result.append(
|
||||
{
|
||||
"fid": str(item.get("fid") or ""),
|
||||
"name": str(item.get("file_name") or ""),
|
||||
"dir": int(item.get("file_type") or 0) == 0,
|
||||
"size": item.get("size") or 0,
|
||||
"updated_at": item.get("updated_at") or 0,
|
||||
"raw": item,
|
||||
}
|
||||
)
|
||||
if len(current) < 100:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return True, result, ""
|
||||
|
||||
def delete_items(self, items: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]:
|
||||
source_items = [item for item in (items or []) if isinstance(item, dict)]
|
||||
|
||||
def build_fids(candidates: List[Dict[str, Any]]) -> List[str]:
|
||||
result: List[str] = []
|
||||
for item in candidates:
|
||||
fid = self.clean_text(item.get("fid"))
|
||||
if fid:
|
||||
result.append(fid)
|
||||
return result
|
||||
|
||||
def item_label(item: Dict[str, Any]) -> str:
|
||||
return self.clean_text(item.get("name") or item.get("file_name") or item.get("fid"))
|
||||
|
||||
def call_delete(candidates: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]:
|
||||
fids = build_fids(candidates)
|
||||
if not fids:
|
||||
return False, {}, "默认目录当前层没有可删除项目"
|
||||
payloads = [
|
||||
{
|
||||
"action_type": 2,
|
||||
"exclude_fids": [],
|
||||
"filelist": [{"fid": fid} for fid in fids],
|
||||
},
|
||||
{
|
||||
"action_type": 2,
|
||||
"exclude_fids": [],
|
||||
"filelist": fids,
|
||||
},
|
||||
{
|
||||
# Some web scripts historically used this misspelled key.
|
||||
"actoin_type": 2,
|
||||
"exclude_fids": [],
|
||||
"filelist": fids,
|
||||
},
|
||||
]
|
||||
last_data: Dict[str, Any] = {}
|
||||
last_message = ""
|
||||
for index, payload in enumerate(payloads, start=1):
|
||||
ok, data, message = self._request(
|
||||
"POST",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/file/delete",
|
||||
params={
|
||||
"pr": "ucpro",
|
||||
"fr": "pc",
|
||||
"uc_param_str": "",
|
||||
},
|
||||
json_body=payload,
|
||||
)
|
||||
if ok:
|
||||
if isinstance(data, dict):
|
||||
data["delete_payload_variant"] = index
|
||||
return True, data, ""
|
||||
last_data = data if isinstance(data, dict) else {}
|
||||
last_message = message or last_message
|
||||
return False, last_data, last_message or "夸克删除失败"
|
||||
|
||||
filelist: List[Dict[str, Any]] = []
|
||||
for item in source_items:
|
||||
fid = self.clean_text((item or {}).get("fid")) if isinstance(item, dict) else ""
|
||||
if fid:
|
||||
filelist.append({"fid": fid})
|
||||
if not filelist:
|
||||
return False, {}, "默认目录当前层没有可删除项目"
|
||||
|
||||
ok, data, message = call_delete(source_items)
|
||||
if ok:
|
||||
data["deleted_count"] = len(filelist)
|
||||
data["delete_mode"] = "batch"
|
||||
return True, data, ""
|
||||
|
||||
if len(source_items) <= 1:
|
||||
return False, data, message or "夸克删除失败"
|
||||
|
||||
deleted_count = 0
|
||||
failed_items: List[Dict[str, Any]] = []
|
||||
for item in source_items:
|
||||
single_ok, single_data, single_message = call_delete([item])
|
||||
if single_ok:
|
||||
deleted_count += 1
|
||||
continue
|
||||
failed_items.append({
|
||||
"fid": self.clean_text(item.get("fid")),
|
||||
"name": item_label(item),
|
||||
"message": single_message or "删除失败",
|
||||
"result": single_data,
|
||||
})
|
||||
|
||||
result = {
|
||||
"deleted_count": deleted_count,
|
||||
"failed_count": len(failed_items),
|
||||
"failed_items": failed_items[:20],
|
||||
"delete_mode": "single_fallback",
|
||||
"batch_error": message or "夸克批量删除失败",
|
||||
"batch_result": data,
|
||||
}
|
||||
if failed_items:
|
||||
return False, result, f"夸克逐项删除后仍有 {len(failed_items)} 项失败"
|
||||
return True, result, ""
|
||||
|
||||
def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]:
|
||||
ok, target_fid, normalized_path = self.ensure_target_dir(path or self.default_target_path)
|
||||
if not ok:
|
||||
return False, {}, target_fid or "定位夸克目录失败"
|
||||
|
||||
ok, children, message = self.list_children(target_fid)
|
||||
if not ok:
|
||||
return False, {}, message or "读取夸克目录失败"
|
||||
|
||||
files = [item for item in children if not bool(item.get("dir"))]
|
||||
folders = [item for item in children if bool(item.get("dir"))]
|
||||
if not children:
|
||||
return True, {
|
||||
"target_path": normalized_path,
|
||||
"target_fid": target_fid,
|
||||
"removed_count": 0,
|
||||
"file_count": 0,
|
||||
"folder_count": 0,
|
||||
"items": [],
|
||||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}, "默认目录当前层为空"
|
||||
|
||||
ok, delete_result, message = self.delete_items(children)
|
||||
removed_count = self.safe_int((delete_result or {}).get("deleted_count"), len(children) if ok else 0)
|
||||
if not ok:
|
||||
return False, {
|
||||
"target_path": normalized_path,
|
||||
"target_fid": target_fid,
|
||||
"file_count": len(files),
|
||||
"folder_count": len(folders),
|
||||
"removed_count": removed_count,
|
||||
"items": [self.clean_text(item.get("name")) for item in children[:20]],
|
||||
"failed_items": (delete_result or {}).get("failed_items") or [],
|
||||
"delete_result": delete_result,
|
||||
}, message or "夸克清空默认目录失败"
|
||||
|
||||
return True, {
|
||||
"target_path": normalized_path,
|
||||
"target_fid": target_fid,
|
||||
"removed_count": removed_count,
|
||||
"file_count": len(files),
|
||||
"folder_count": len(folders),
|
||||
"items": [self.clean_text(item.get("name")) for item in children[:20]],
|
||||
"delete_result": delete_result,
|
||||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}, "success"
|
||||
|
||||
def find_child_dir(self, parent_fid: str, name: str) -> Tuple[bool, str, str]:
|
||||
ok, items, message = self.list_children(parent_fid)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
for item in items:
|
||||
if item.get("dir") and item.get("name") == name:
|
||||
return True, str(item.get("fid") or ""), ""
|
||||
return True, "", ""
|
||||
|
||||
def create_folder(self, parent_fid: str, name: str) -> Tuple[bool, str, str]:
|
||||
ok, data, message = self._request(
|
||||
"POST",
|
||||
"https://pan.quark.cn/1/clouddrive/file/create",
|
||||
json_body={
|
||||
"pdir_fid": parent_fid,
|
||||
"file_name": name,
|
||||
"dir_path": "",
|
||||
"dir_init_lock": False,
|
||||
},
|
||||
)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
|
||||
folder = data.get("data") or {}
|
||||
folder_id = self.clean_text(folder.get("fid") or folder.get("file_id"))
|
||||
if not folder_id:
|
||||
return False, "", "创建目录成功但未返回 fid"
|
||||
return True, folder_id, ""
|
||||
|
||||
def ensure_target_dir(self, path: str) -> Tuple[bool, str, str]:
|
||||
normalized = self.normalize_path(path or self.default_target_path)
|
||||
if normalized == "/":
|
||||
return True, "0", normalized
|
||||
cached = self.path_cache.get(normalized)
|
||||
if cached:
|
||||
return True, cached, normalized
|
||||
|
||||
current_fid = "0"
|
||||
built = ""
|
||||
for part in [segment for segment in normalized.split("/") if segment]:
|
||||
built = f"{built}/{part}" if built else f"/{part}"
|
||||
cached = self.path_cache.get(built)
|
||||
if cached:
|
||||
current_fid = cached
|
||||
continue
|
||||
|
||||
ok, found_fid, message = self.find_child_dir(current_fid, part)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
if not found_fid:
|
||||
ok, found_fid, message = self.create_folder(current_fid, part)
|
||||
if not ok:
|
||||
return False, "", f"创建目录失败 {built}: {message}"
|
||||
self.path_cache[built] = found_fid
|
||||
current_fid = found_fid
|
||||
return True, current_fid, normalized
|
||||
|
||||
def create_save_task(
|
||||
self,
|
||||
pwd_id: str,
|
||||
stoken: str,
|
||||
items: List[Dict[str, Any]],
|
||||
to_pdir_fid: str,
|
||||
) -> Tuple[bool, str, str]:
|
||||
fid_list = [str(item.get("fid") or "") for item in items if item.get("fid")]
|
||||
fid_token_list = [
|
||||
str(item.get("share_fid_token") or "")
|
||||
for item in items
|
||||
if item.get("fid") and item.get("share_fid_token")
|
||||
]
|
||||
if not fid_list or len(fid_list) != len(fid_token_list):
|
||||
return False, "", "分享内容缺少 fid 或 share_fid_token,无法转存"
|
||||
|
||||
params = self._common_params()
|
||||
ok, data, message = self._request(
|
||||
"POST",
|
||||
"https://drive.quark.cn/1/clouddrive/share/sharepage/save",
|
||||
params=params,
|
||||
json_body={
|
||||
"fid_list": fid_list,
|
||||
"fid_token_list": fid_token_list,
|
||||
"to_pdir_fid": to_pdir_fid,
|
||||
"pwd_id": pwd_id,
|
||||
"stoken": stoken,
|
||||
"pdir_fid": "0",
|
||||
"scene": "link",
|
||||
},
|
||||
)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
|
||||
task_id = self.clean_text((data.get("data") or {}).get("task_id"))
|
||||
if not task_id:
|
||||
return False, "", "未获取到转存任务 ID"
|
||||
return True, task_id, ""
|
||||
|
||||
def wait_task(self, task_id: str, retry: int = 20) -> Tuple[bool, Dict[str, Any], str]:
|
||||
for index in range(retry):
|
||||
time.sleep(1.0 if index == 0 else 1.5)
|
||||
params = {
|
||||
"pr": "ucpro",
|
||||
"fr": "pc",
|
||||
"uc_param_str": "",
|
||||
"task_id": task_id,
|
||||
"retry_index": index,
|
||||
"__dt": 21192,
|
||||
"__t": int(time.time() * 1000),
|
||||
}
|
||||
ok, data, message = self._request(
|
||||
"GET",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/task",
|
||||
params=params,
|
||||
)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
|
||||
task = data.get("data") or {}
|
||||
status = self.safe_int(task.get("status"), -1)
|
||||
if status == 2:
|
||||
return True, task, ""
|
||||
if status in {3, 4, 5, 6, 7}:
|
||||
return False, task, self.clean_text(task.get("message")) or "夸克任务执行失败"
|
||||
|
||||
return False, {}, "等待夸克转存任务超时"
|
||||
|
||||
def check_cookie(self) -> Tuple[bool, str]:
|
||||
ok, _, message = self.list_children("0")
|
||||
if ok:
|
||||
return True, ""
|
||||
return False, message or "Cookie 校验失败"
|
||||
|
||||
def transfer_share(
|
||||
self,
|
||||
share_text: str,
|
||||
access_code: str = "",
|
||||
target_path: str = "",
|
||||
*,
|
||||
trigger: str = "Agent影视助手",
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
share_url, pwd_id, final_code = self.extract_share_info(share_text, access_code)
|
||||
ok, message = self.validate_share_url(share_url)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
if not pwd_id:
|
||||
return False, {}, "未识别到有效夸克分享链接"
|
||||
if not self.cookie:
|
||||
self._refresh_cookie()
|
||||
if not self.cookie:
|
||||
return False, {}, "未配置夸克 Cookie"
|
||||
|
||||
ok, stoken, message = self.get_stoken(pwd_id, final_code)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
|
||||
ok, share_items, message = self.get_share_items(pwd_id, stoken)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
|
||||
ok, target_fid, normalized_path = self.ensure_target_dir(target_path or self.default_target_path)
|
||||
if not ok:
|
||||
return False, {}, target_fid
|
||||
|
||||
ok, task_id, message = self.create_save_task(pwd_id, stoken, share_items, target_fid)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
|
||||
ok, task, message = self.wait_task(task_id)
|
||||
if not ok:
|
||||
return False, {"task_id": task_id}, message
|
||||
|
||||
item_names = [str(item.get("file_name") or "") for item in share_items if item.get("file_name")]
|
||||
result = {
|
||||
"share_url": share_url,
|
||||
"pwd_id": pwd_id,
|
||||
"access_code": final_code,
|
||||
"target_path": normalized_path,
|
||||
"target_fid": target_fid,
|
||||
"task_id": task_id,
|
||||
"saved_count": len(share_items),
|
||||
"items": item_names[:20],
|
||||
"task": task,
|
||||
"trigger": trigger,
|
||||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
return True, result, "success"
|
||||
388
plugins/agentresourceofficer/services/streaming_recommend.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
流媒体推荐 — TMDB Discover 直连服务
|
||||
|
||||
直接调用 TMDB discover API,不走 MoviePilot RecommendChain。
|
||||
原因:RecommendChain 不支持 with_watch_providers + 时间窗口组合筛选。
|
||||
|
||||
支持的能力:
|
||||
- 按流媒体平台聚合(Netflix / Disney+ / Apple TV+ / Prime Video)
|
||||
- 严格按时间窗口过滤(本月 / 近N天)
|
||||
- 按热度 + 评分 + 投票人数综合排序
|
||||
- 区分电影 / 剧集 / 全部
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
|
||||
# TMDB Watch Provider ID 映射
|
||||
PROVIDER_MAP: Dict[str, int] = {
|
||||
"netflix": 8,
|
||||
"disney": 337,
|
||||
"disney+": 337,
|
||||
"apple": 384,
|
||||
"apple tv+": 384,
|
||||
"prime": 10,
|
||||
"prime video": 10,
|
||||
"amazon": 10,
|
||||
}
|
||||
|
||||
# 默认聚合平台
|
||||
DEFAULT_PROVIDER_IDS: List[int] = [8, 337, 384, 10]
|
||||
|
||||
# 默认地区(CN / US)
|
||||
DEFAULT_WATCH_REGION = "US"
|
||||
|
||||
# 综合排序权重
|
||||
SCORE_WEIGHTS = {
|
||||
"popularity": 0.4,
|
||||
"vote_average": 0.35,
|
||||
"vote_count_norm": 0.15,
|
||||
"freshness": 0.1,
|
||||
}
|
||||
|
||||
|
||||
class StreamingRecommendService:
|
||||
"""TMDB Discover 直连,返回流媒体推荐列表"""
|
||||
|
||||
def __init__(self, tmdb_api_key: str):
|
||||
self._api_key = tmdb_api_key.strip()
|
||||
|
||||
# ─── 公开入口 ────────────────────────────────────────────────
|
||||
|
||||
async def query(
|
||||
self,
|
||||
*,
|
||||
media_type: str = "all",
|
||||
intent: str = "hot",
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
window_days: int = 90,
|
||||
providers: Optional[List[int]] = None,
|
||||
watch_region: str = DEFAULT_WATCH_REGION,
|
||||
limit: int = 15,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
查询流媒体推荐。
|
||||
|
||||
返回:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str,
|
||||
"items": [ { index, title, year, media_type, release_date,
|
||||
popularity, vote_average, vote_count,
|
||||
providers_str, reason } ],
|
||||
"query_params": { ... },
|
||||
}
|
||||
"""
|
||||
if not self._api_key:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "TMDB API Key 未配置,无法查询流媒体推荐。",
|
||||
"items": [],
|
||||
"query_params": {},
|
||||
}
|
||||
|
||||
provider_ids = providers or DEFAULT_PROVIDER_IDS
|
||||
media_type = (media_type or "all").lower()
|
||||
intent = (intent or "hot").lower()
|
||||
|
||||
# ── 时间窗口 ──
|
||||
final_start, final_end = self._resolve_time_range(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
window_days=window_days,
|
||||
)
|
||||
|
||||
# ── 分别查电影和剧集 ──
|
||||
all_items: List[Dict[str, Any]] = []
|
||||
|
||||
if media_type in ("movie", "all"):
|
||||
movie_items = await self._discover(
|
||||
media_category="movie",
|
||||
intent=intent,
|
||||
start_date=final_start,
|
||||
end_date=final_end,
|
||||
provider_ids=provider_ids,
|
||||
watch_region=watch_region,
|
||||
limit=limit if media_type == "movie" else limit * 2,
|
||||
)
|
||||
all_items.extend(movie_items)
|
||||
|
||||
if media_type in ("tv", "all"):
|
||||
tv_items = await self._discover(
|
||||
media_category="tv",
|
||||
intent=intent,
|
||||
start_date=final_start,
|
||||
end_date=final_end,
|
||||
provider_ids=provider_ids,
|
||||
watch_region=watch_region,
|
||||
limit=limit if media_type == "tv" else limit * 2,
|
||||
)
|
||||
all_items.extend(tv_items)
|
||||
|
||||
# ── 综合排序并截断 ──
|
||||
ranked = self._rank(all_items, intent=intent)
|
||||
trimmed = ranked[:limit]
|
||||
|
||||
# ── 编号 & 推荐理由 ──
|
||||
for idx, item in enumerate(trimmed, start=1):
|
||||
item["index"] = idx
|
||||
item["reason"] = self._generate_reason(item, intent)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "",
|
||||
"items": trimmed,
|
||||
"query_params": {
|
||||
"media_type": media_type,
|
||||
"intent": intent,
|
||||
"start_date": final_start,
|
||||
"end_date": final_end,
|
||||
"provider_ids": provider_ids,
|
||||
"watch_region": watch_region,
|
||||
"count": len(trimmed),
|
||||
},
|
||||
}
|
||||
|
||||
# ─── TMDB Discover 直连 ──────────────────────────────────────
|
||||
|
||||
async def _discover(
|
||||
self,
|
||||
*,
|
||||
media_category: str,
|
||||
intent: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
provider_ids: List[int],
|
||||
watch_region: str,
|
||||
limit: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
调用 TMDB discover/movie 或 discover/tv。
|
||||
返回标准化的条目列表。
|
||||
"""
|
||||
endpoint = "movie" if media_category == "movie" else "tv"
|
||||
date_field = (
|
||||
"primary_release_date.gte" if media_category == "movie"
|
||||
else "first_air_date.gte"
|
||||
)
|
||||
date_field_end = (
|
||||
"primary_release_date.lte" if media_category == "movie"
|
||||
else "first_air_date.lte"
|
||||
)
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"api_key": self._api_key,
|
||||
"language": "zh-CN",
|
||||
"sort_by": "popularity.desc",
|
||||
"watch_region": watch_region,
|
||||
"with_watch_providers": "|".join(str(p) for p in provider_ids),
|
||||
"with_watch_monetization_types": "flatrate",
|
||||
"vote_count.gte": self._min_vote_count(intent),
|
||||
"page": 1,
|
||||
}
|
||||
|
||||
# 严格时间过滤
|
||||
if start_date:
|
||||
params[date_field] = start_date
|
||||
if end_date:
|
||||
params[date_field_end] = end_date
|
||||
|
||||
url = f"https://api.themoviedb.org/3/discover/{endpoint}?" + urlencode(params)
|
||||
|
||||
try:
|
||||
request = UrlRequest(url=url, headers={"Accept": "application/json"})
|
||||
with urlopen(request, timeout=20) as response:
|
||||
payload = json.loads(response.read().decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
return []
|
||||
|
||||
raw_results = payload.get("results") or []
|
||||
if not isinstance(raw_results, list):
|
||||
return []
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for raw in raw_results:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
item = self._normalize_item(raw, media_category)
|
||||
if item:
|
||||
items.append(item)
|
||||
|
||||
return items[:limit * 2]
|
||||
|
||||
def _normalize_item(self, raw: Dict[str, Any], media_category: str) -> Optional[Dict[str, Any]]:
|
||||
"""把 TMDB 原始条目转为标准格式"""
|
||||
title = (
|
||||
raw.get("title")
|
||||
or raw.get("name")
|
||||
or raw.get("original_title")
|
||||
or raw.get("original_name")
|
||||
or ""
|
||||
).strip()
|
||||
if not title:
|
||||
return None
|
||||
|
||||
release_date = raw.get("release_date") or raw.get("primary_release_date") or raw.get("first_air_date") or ""
|
||||
year = str(release_date)[:4] if release_date else ""
|
||||
|
||||
popularity = float(raw.get("popularity") or 0)
|
||||
vote_average = float(raw.get("vote_average") or 0)
|
||||
vote_count = int(raw.get("vote_count") or 0)
|
||||
|
||||
# 处理 media_type
|
||||
raw_type = raw.get("media_type") or ""
|
||||
if media_category == "movie":
|
||||
display_type = "电影"
|
||||
elif media_category == "tv":
|
||||
display_type = "剧集"
|
||||
else:
|
||||
display_type = "电影" if raw_type == "movie" else "剧集"
|
||||
|
||||
# provider_ids 从原数据获取
|
||||
provider_ids_raw = raw.get("origin_country") or []
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"year": year,
|
||||
"media_type": display_type,
|
||||
"release_date": release_date,
|
||||
"popularity": round(popularity, 1),
|
||||
"vote_average": round(vote_average, 1),
|
||||
"vote_count": vote_count,
|
||||
"tmdb_id": raw.get("id"),
|
||||
"provider_ids_raw": provider_ids_raw,
|
||||
}
|
||||
|
||||
# ─── 综合排序 ────────────────────────────────────────────────
|
||||
|
||||
def _rank(self, items: List[Dict[str, Any]], intent: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
按综合分排序。
|
||||
intent 影响权重:big_titles 偏重评分,hot 偏重热度,new 偏重新鲜度。
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
|
||||
weights = dict(SCORE_WEIGHTS)
|
||||
if intent == "big_titles":
|
||||
weights["vote_average"] = 0.45
|
||||
weights["popularity"] = 0.25
|
||||
weights["vote_count_norm"] = 0.2
|
||||
weights["freshness"] = 0.1
|
||||
elif intent == "new":
|
||||
weights["freshness"] = 0.3
|
||||
weights["popularity"] = 0.3
|
||||
weights["vote_average"] = 0.25
|
||||
weights["vote_count_norm"] = 0.15
|
||||
|
||||
# 归一化基准
|
||||
max_pop = max((i.get("popularity") or 0) for i in items) or 1
|
||||
max_votes = max((i.get("vote_count") or 0) for i in items) or 1
|
||||
|
||||
today = date.today()
|
||||
|
||||
def score(item: Dict[str, Any]) -> float:
|
||||
pop = (item.get("popularity") or 0) / max_pop
|
||||
avg = (item.get("vote_average") or 0) / 10.0
|
||||
vc = (item.get("vote_count") or 0) / max_votes
|
||||
# 新鲜度:发布越近分越高(90天内线性衰减)
|
||||
try:
|
||||
rd = item.get("release_date") or ""
|
||||
days_ago = (today - date.fromisoformat(rd[:10])).days if rd and len(rd) >= 10 else 180
|
||||
except Exception:
|
||||
days_ago = 180
|
||||
freshness = max(0.0, 1.0 - days_ago / 180.0)
|
||||
return (
|
||||
weights["popularity"] * pop
|
||||
+ weights["vote_average"] * avg
|
||||
+ weights["vote_count_norm"] * vc
|
||||
+ weights["freshness"] * freshness
|
||||
)
|
||||
|
||||
items.sort(key=score, reverse=True)
|
||||
return items
|
||||
|
||||
# ─── 推荐理由 ────────────────────────────────────────────────
|
||||
|
||||
def _generate_reason(self, item: Dict[str, Any], intent: str) -> str:
|
||||
"""基于数据生成一句话推荐理由,不经过 LLM"""
|
||||
avg = item.get("vote_average") or 0
|
||||
pop = item.get("popularity") or 0
|
||||
votes = item.get("vote_count") or 0
|
||||
|
||||
if intent == "big_titles":
|
||||
if avg >= 8.0 and votes >= 500:
|
||||
return "高口碑大作"
|
||||
if avg >= 7.0 and votes >= 200:
|
||||
return "口碑不错"
|
||||
return "值得关注"
|
||||
|
||||
if intent == "new":
|
||||
if pop >= 500:
|
||||
return "新上线即爆"
|
||||
if avg >= 7.0:
|
||||
return "新上线口碑佳"
|
||||
return "新上线"
|
||||
|
||||
# hot
|
||||
if avg >= 8.0 and pop >= 600:
|
||||
return "口碑热度双高"
|
||||
if pop >= 800:
|
||||
return "热度爆棚"
|
||||
if avg >= 7.5:
|
||||
return "口碑出色"
|
||||
if votes >= 1000:
|
||||
return "大众高关注"
|
||||
return "近期热门"
|
||||
|
||||
# ─── 时间范围 ────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _resolve_time_range(
|
||||
*,
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
window_days: int = 90,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
给定起止日期或窗口天数,返回严格时间范围(YYYY-MM-DD)。
|
||||
- specific_month / this_month:严格按自然月
|
||||
- recent:从今天往前推 window_days 天
|
||||
- last_month:上一个自然月
|
||||
"""
|
||||
today = date.today()
|
||||
|
||||
# 如果都给了就直接用
|
||||
if start_date and end_date:
|
||||
return start_date[:10], end_date[:10]
|
||||
|
||||
# 如果只给了 start_date,end_date 默认今天
|
||||
if start_date and not end_date:
|
||||
return start_date[:10], today.isoformat()
|
||||
|
||||
# 如果只给了 end_date,start_date 往前推 window_days
|
||||
if end_date and not start_date:
|
||||
try:
|
||||
end_d = date.fromisoformat(end_date[:10])
|
||||
except Exception:
|
||||
end_d = today
|
||||
start_d = end_d - timedelta(days=window_days)
|
||||
return start_d.isoformat(), end_d.isoformat()
|
||||
|
||||
# 都没给:默认最近 window_days
|
||||
start_d = today - timedelta(days=window_days)
|
||||
return start_d.isoformat(), today.isoformat()
|
||||
|
||||
@staticmethod
|
||||
def _min_vote_count(intent: str) -> int:
|
||||
"""不同 intent 的最低投票人数门槛"""
|
||||
if intent == "big_titles":
|
||||
return 300
|
||||
if intent == "new":
|
||||
return 30
|
||||
return 100
|
||||
83
plugins/airecognizerenhancer/ARCHITECTURE.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# AI识别增强架构草案
|
||||
|
||||
`AI识别增强` 用来承接 MoviePilot 原生识别失败后的本地 AI 兜底链路。
|
||||
|
||||
## 设计目标
|
||||
|
||||
- 摆脱外部 AI Gateway 的强依赖
|
||||
- 直接使用 MoviePilot 已启用的 LLM 配置
|
||||
- 输出结构化识别结果,而不是只回传一段自由文本
|
||||
|
||||
## 模块分层
|
||||
|
||||
### 1. hooks
|
||||
|
||||
负责接住识别失败事件和后续整理事件。
|
||||
|
||||
### 2. llm
|
||||
|
||||
负责封装对 MP 当前 LLM 的调用:
|
||||
|
||||
- 标准提示词
|
||||
- 结构化返回约束
|
||||
- 超时与错误兜底
|
||||
|
||||
### 3. normalize
|
||||
|
||||
负责把 AI 输出转换成可继续进入 MP 整理链路的数据:
|
||||
|
||||
- 标题
|
||||
- 年份
|
||||
- 类型
|
||||
- 季
|
||||
- 集
|
||||
- 置信度
|
||||
|
||||
### 4. actions
|
||||
|
||||
负责根据结果执行后续动作:
|
||||
|
||||
- 二次识别
|
||||
- 二次整理
|
||||
- 记录失败样本
|
||||
|
||||
## 首期配置模型
|
||||
|
||||
- `enabled`
|
||||
- `notify`
|
||||
- `debug`
|
||||
- `confidence_threshold`
|
||||
- `request_timeout`
|
||||
- `max_retries`
|
||||
- `save_failed_samples`
|
||||
|
||||
## 二期规划
|
||||
|
||||
- 生成自定义识别词建议
|
||||
- 失败样本聚合分析
|
||||
- 提供给 MP Agent / Skill 直接调起
|
||||
|
||||
## 首个里程碑
|
||||
|
||||
第一个可用版本只追求:
|
||||
|
||||
1. 原生识别失败后自动触发本地 LLM 判断
|
||||
2. 拿到结构化结果后自动二次整理
|
||||
3. 能明确记录“成功 / 放弃 / 失败原因”
|
||||
|
||||
## 当前实现状态
|
||||
|
||||
- 已接住 `ChainEventType.NameRecognize`
|
||||
- 已复用 `LLMHelper.get_llm(streaming=False)` 做结构化输出
|
||||
- 已提供手动调试接口用于验证标题识别结果
|
||||
- 已支持查看低置信度样本,并继续生成为 MoviePilot 自定义识别词建议
|
||||
- 已支持直接基于失败样本生成建议并一键写入 `CustomIdentifiers`
|
||||
- 已支持失败样本摘要列表、样本清理、样本去重和保留上限控制
|
||||
- 已支持失败样本洞察汇总,自动挑出重复问题和优先处理样本
|
||||
- 已支持失败样本出队:写入识别词后自动移除,或单独按索引移除
|
||||
- 已支持失败样本复查:按当前识别词和当前识别器重跑,并可自动把已修复样本出队
|
||||
- 已支持失败样本批量复查:可批量重跑并按结果批量出队
|
||||
- 已支持失败样本批量建议与批量写入:可批量生成建议并批量落库
|
||||
- 已支持低 token 精简摘要输出,适合作为智能体批处理入口
|
||||
- 已支持识别词建议模型退化时自动切换到精确规则兜底,优先保证稳定落地
|
||||
- 下一步重点会放在提示词打磨、失败样本回放和识别词建议质量提升
|
||||
101
plugins/airecognizerenhancer/README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# AI识别增强
|
||||
|
||||
`AI识别增强` 用来补强 MoviePilot 原生整理链里的识别阶段。
|
||||
|
||||
它的核心思路很简单:
|
||||
|
||||
- 复用 MoviePilot 当前已经启用的 LLM 配置
|
||||
- 在原生识别失败或置信度不足时,做一次本地结构化识别兜底
|
||||
- 把结果回写给 MoviePilot,继续走原生二次识别和后续整理链
|
||||
|
||||
## 适合什么场景
|
||||
|
||||
- 文件名比较脏,混有压制组、分辨率、语言、站点标记
|
||||
- 同一部剧经常出现英文名、别名、原名、翻译名混用
|
||||
- 网盘挂载、手动整理、历史资源补录时,原生识别偶尔不稳定
|
||||
- 你想把失败样本沉淀下来,后面持续优化 `CustomIdentifiers`
|
||||
|
||||
## 和 MoviePilot 原版智能体的区别
|
||||
|
||||
MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次”的能力。
|
||||
|
||||
这和 `AI识别增强` 有重叠,但定位不同:
|
||||
|
||||
- **MP 原版智能体**
|
||||
- 更偏“一次性补救”
|
||||
- 适合偶发失败、想省事的场景
|
||||
|
||||
- **AI识别增强**
|
||||
- 更偏“识别失败治理层”
|
||||
- 除了补救当前这次,还能:
|
||||
- 保存失败样本
|
||||
- 汇总样本洞察
|
||||
- 生成 `CustomIdentifiers` 建议
|
||||
- 写入识别词
|
||||
- 重放 / 复查 / 批量出队
|
||||
|
||||
一句话区分:
|
||||
|
||||
- 原版智能体:自动接管一次
|
||||
- `AI识别增强`:把失败样本沉淀下来,长期减少同类失败
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 监听 `ChainEventType.NameRecognize`
|
||||
- 用当前 LLM 结构化判断标题、年份、类型、季集
|
||||
- 回写 `name / year / season / episode`
|
||||
- 交回 MoviePilot 原生链路继续二次识别
|
||||
- 保存低置信度失败样本
|
||||
- 提供失败样本工作清单、洞察、重放、删除和清空能力
|
||||
- 生成并应用 `CustomIdentifiers` 建议
|
||||
|
||||
## 主要接口
|
||||
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/health`
|
||||
- 查看插件状态、LLM 提供方、模型、阈值和超时配置
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/recognize`
|
||||
- 对单个标题做一次本地结构化识别测试
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/failed_samples`
|
||||
- 查看最近保存的失败样本
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_worklist`
|
||||
- 返回适合继续处理的失败样本摘要列表
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_insights`
|
||||
- 汇总失败原因、重复问题和优先处理样本
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/replay_failed_sample`
|
||||
- 用当前识别词和当前识别器重放复查某条失败样本
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/suggest_identifiers_from_sample`
|
||||
- 直接基于失败样本生成识别词建议
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/apply_suggested_identifier`
|
||||
- 把建议规则写入系统 `CustomIdentifiers`
|
||||
|
||||
其余批量接口和清理接口可以按需要继续使用,详细路径以插件 `get_api()` 暴露结果为准。
|
||||
|
||||
## 配置建议
|
||||
|
||||
- 先确认 MoviePilot 本身已经配置好可用的 LLM
|
||||
- 建议保持“保存失败样本”开启
|
||||
- 如果你经常处理历史资源或网盘资源,建议定期查看:
|
||||
- `failed_samples`
|
||||
- `sample_worklist`
|
||||
- `sample_insights`
|
||||
|
||||
## 已验证情况
|
||||
|
||||
当前版本:`0.1.12`
|
||||
|
||||
当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68
|
||||
|
||||
这版已经验证过:
|
||||
|
||||
- 最新版 MoviePilot 下可以正常加载
|
||||
- 正常中文标题识别可用
|
||||
- 英文别名、韩文原名、中文别名可识别回标准媒体信息
|
||||
- 低置信度标题会落失败样本
|
||||
- `replay_failed_sample` 复查链可用
|
||||
|
||||
## 说明
|
||||
|
||||
- 这个插件不依赖外部 AI Gateway 回调链
|
||||
- 重点是增强识别,不负责替代 MoviePilot 全部整理流程
|
||||
- 如果你只是偶发整理失败,原版智能体可能已经够用
|
||||
- 如果你长期受命名混乱困扰,这个插件更有价值
|
||||
2043
plugins/airecognizerenhancer/__init__.py
Normal file
623
plugins/alidnsddns/__init__.py
Normal file
@@ -0,0 +1,623 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import random
|
||||
import socket
|
||||
import string
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from base64 import b64encode
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas.types import NotificationType
|
||||
|
||||
_MAX_HISTORY = 100
|
||||
|
||||
|
||||
class AliDnsDDNS(_PluginBase):
|
||||
# ──────────────────────────────────────────────
|
||||
# 插件元数据
|
||||
# ──────────────────────────────────────────────
|
||||
plugin_name = "阿里云 DDNS"
|
||||
plugin_desc = "定时检测公网 IP,自动更新阿里云 DNS 解析记录,支持泛域名(* 记录)及 IPv6(AAAA)。"
|
||||
plugin_icon = "AliDnsDDNS.png"
|
||||
plugin_version = "1.0"
|
||||
plugin_author = "dtzsghnr"
|
||||
author_url = "https://github.com/dtzsghnr"
|
||||
plugin_config_prefix = "alidnsddns_"
|
||||
plugin_order = 30
|
||||
auth_level = 1
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 私有状态
|
||||
# ──────────────────────────────────────────────
|
||||
_enabled: bool = False
|
||||
_access_key_id: str = ""
|
||||
_access_key_secret: str = ""
|
||||
_records: str = ""
|
||||
_interval: int = 5
|
||||
_notify: bool = True
|
||||
_run_once: bool = False
|
||||
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
_last_ipv4: str = ""
|
||||
_last_ipv6: str = ""
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 生命周期
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
if config:
|
||||
self._enabled = config.get("enabled", False)
|
||||
self._access_key_id = config.get("access_key_id", "").strip()
|
||||
self._access_key_secret = config.get("access_key_secret", "").strip()
|
||||
self._records = config.get("records", "").strip()
|
||||
self._interval = max(1, int(config.get("interval", 5) or 5))
|
||||
self._notify = config.get("notify", True)
|
||||
self._run_once = config.get("run_once", False)
|
||||
|
||||
self.stop_service()
|
||||
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
record_count = len(_parse_records(self._records))
|
||||
logger.info(
|
||||
f"[AliDnsDDNS] 插件已启动 | 检测间隔: {self._interval}min | 记录数: {record_count}"
|
||||
)
|
||||
|
||||
# 立即执行一次:用独立调度器触发,不与宿主调度器冲突
|
||||
if self._run_once:
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
self._scheduler.add_job(
|
||||
func=self.__update_dns,
|
||||
trigger="date",
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="阿里云DDNS 立即执行",
|
||||
)
|
||||
self._scheduler.start()
|
||||
self._run_once = False
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"access_key_id": self._access_key_id,
|
||||
"access_key_secret": self._access_key_secret,
|
||||
"records": self._records,
|
||||
"interval": self._interval,
|
||||
"notify": self._notify,
|
||||
"run_once": False,
|
||||
})
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def stop_service(self):
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error(f"[AliDnsDDNS] 停止调度器失败: {e}")
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 服务注册(宿主调度器)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
if self._enabled and self._interval:
|
||||
return [{
|
||||
"id": "AliDnsDDNS",
|
||||
"name": "阿里云 DDNS 更新",
|
||||
"trigger": IntervalTrigger(minutes=self._interval),
|
||||
"func": self.__update_dns,
|
||||
"kwargs": {},
|
||||
}]
|
||||
return []
|
||||
|
||||
def get_command(self) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"path": "/alidnsddns/history",
|
||||
"endpoint": self.__api_history,
|
||||
"methods": ["GET"],
|
||||
"summary": "获取 DDNS 更新历史",
|
||||
},
|
||||
{
|
||||
"path": "/alidnsddns/history/clear",
|
||||
"endpoint": self.__api_clear_history,
|
||||
"methods": ["POST"],
|
||||
"summary": "清空 DDNS 更新历史",
|
||||
},
|
||||
]
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# API
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def __api_history(self) -> List[dict]:
|
||||
return self.get_data("history") or []
|
||||
|
||||
def __api_clear_history(self) -> dict:
|
||||
self.del_data("history")
|
||||
logger.info("[AliDnsDDNS] 更新历史已清空")
|
||||
return {"success": True}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 配置表单
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"component": "VForm",
|
||||
"content": [
|
||||
# ── 开关行 ──
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [{
|
||||
"component": "VSwitch",
|
||||
"props": {"model": "enabled", "label": "启用插件"},
|
||||
}],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [{
|
||||
"component": "VSwitch",
|
||||
"props": {"model": "notify", "label": "IP 变化时发送通知"},
|
||||
}],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [{
|
||||
"component": "VSwitch",
|
||||
"props": {"model": "run_once", "label": "立即运行一次"},
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
# ── 密钥行 ──
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "access_key_id",
|
||||
"label": "AccessKey ID",
|
||||
"placeholder": "LTAI5t...",
|
||||
"hint": "阿里云 RAM 访问密钥 ID",
|
||||
},
|
||||
}],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "access_key_secret",
|
||||
"label": "AccessKey Secret",
|
||||
"placeholder": "xxxx...",
|
||||
"type": "password",
|
||||
"hint": "阿里云 RAM 访问密钥 Secret",
|
||||
},
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
# ── 间隔 ──
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "interval",
|
||||
"label": "检测间隔(分钟)",
|
||||
"type": "number",
|
||||
"placeholder": "5",
|
||||
"hint": "最小 1 分钟",
|
||||
},
|
||||
}],
|
||||
}],
|
||||
},
|
||||
# ── 记录列表 ──
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "records",
|
||||
"label": "DNS 记录列表",
|
||||
"rows": 6,
|
||||
"placeholder": (
|
||||
"格式:顶级域名 主机记录 类型(类型可省略默认A)\n"
|
||||
"example.com @ A # example.com 根域 IPv4\n"
|
||||
"example.com * A # *.example.com 泛域名 IPv4\n"
|
||||
"example.com home A # home.example.com IPv4\n"
|
||||
"example.com home AAAA # home.example.com IPv6"
|
||||
),
|
||||
"hint": "第一列:阿里云注册的顶级域(如 example.com);第二列:主机记录前缀(@ 根域 / * 泛域名 / 子域名前缀)",
|
||||
"persistent-hint": True,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
},
|
||||
# ── 说明 ──
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "info",
|
||||
"variant": "tonal",
|
||||
"text": (
|
||||
"需要在阿里云 RAM 控制台为 AccessKey 授予 AliyunDNSFullAccess 权限。"
|
||||
"第一列必须是阿里云中托管的顶级域(如 example.com),第二列是主机记录前缀。"
|
||||
"更新 home.example.com 填:example.com home A。"
|
||||
"泛域名填 *,根域填 @,IPv6 类型填 AAAA。"
|
||||
),
|
||||
},
|
||||
}],
|
||||
}],
|
||||
},
|
||||
],
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"access_key_id": "",
|
||||
"access_key_secret": "",
|
||||
"records": "",
|
||||
"interval": 5,
|
||||
"notify": True,
|
||||
"run_once": False,
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 详情页
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
history: List[dict] = self.get_data("history") or []
|
||||
|
||||
if not history:
|
||||
return [{
|
||||
"component": "div",
|
||||
"props": {"class": "text-center pa-6 text-medium-emphasis"},
|
||||
"text": "暂无更新记录",
|
||||
}]
|
||||
|
||||
history = sorted(history, key=lambda x: x.get("update_time", ""), reverse=True)
|
||||
|
||||
return [{
|
||||
"component": "VDataTable",
|
||||
"props": {
|
||||
"headers": [
|
||||
{"title": "域名", "key": "fqdn", "sortable": True},
|
||||
{"title": "类型", "key": "type", "sortable": True, "width": "80px"},
|
||||
{"title": "IP 地址", "key": "ip", "sortable": False},
|
||||
{"title": "更新时间", "key": "update_time", "sortable": True},
|
||||
],
|
||||
"items": history,
|
||||
"density": "comfortable",
|
||||
"hover": True,
|
||||
"items-per-page": 20,
|
||||
},
|
||||
}]
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 核心逻辑
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def __update_dns(self):
|
||||
if not self._access_key_id or not self._access_key_secret:
|
||||
logger.warning("[AliDnsDDNS] AccessKey 未配置,跳过本次检测")
|
||||
return
|
||||
|
||||
parsed = _parse_records(self._records)
|
||||
if not parsed:
|
||||
logger.warning("[AliDnsDDNS] 记录列表为空,跳过本次检测")
|
||||
return
|
||||
|
||||
need_v4 = any(r["type"] == "A" for r in parsed)
|
||||
need_v6 = any(r["type"] == "AAAA" for r in parsed)
|
||||
|
||||
ipv4 = _get_public_ip(v6=False) if need_v4 else None
|
||||
ipv6 = _get_public_ip(v6=True) if need_v6 else None
|
||||
|
||||
if need_v4 and not ipv4:
|
||||
logger.error("[AliDnsDDNS] 公网 IPv4 获取失败,跳过本次更新")
|
||||
return
|
||||
if need_v6 and not ipv6:
|
||||
logger.error("[AliDnsDDNS] 公网 IPv6 获取失败,跳过本次更新")
|
||||
return
|
||||
|
||||
client = _AliDnsClient(self._access_key_id, self._access_key_secret)
|
||||
updated: List[dict] = []
|
||||
now_str = datetime.now(tz=pytz.timezone(settings.TZ)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
for rec in parsed:
|
||||
ip = ipv4 if rec["type"] == "A" else ipv6
|
||||
fqdn = _fqdn(rec["rr"], rec["domain"])
|
||||
try:
|
||||
changed = client.upsert(rec["domain"], rec["rr"], rec["type"], ip)
|
||||
if changed:
|
||||
updated.append({"fqdn": fqdn, "type": rec["type"],
|
||||
"ip": ip, "update_time": now_str})
|
||||
logger.info(
|
||||
f"[AliDnsDDNS] 记录已更新 | {fqdn} | {rec['type']} | {ip}"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"[AliDnsDDNS] 记录无变化 | {fqdn} | {rec['type']} | {ip}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[AliDnsDDNS] 记录更新失败 | {fqdn} | {rec['type']} | 原因: {e}"
|
||||
)
|
||||
|
||||
if ipv4:
|
||||
self._last_ipv4 = ipv4
|
||||
if ipv6:
|
||||
self._last_ipv6 = ipv6
|
||||
|
||||
if not updated:
|
||||
return
|
||||
|
||||
self.__save_history(updated)
|
||||
|
||||
if self._notify:
|
||||
self.__send_notify(updated)
|
||||
|
||||
def __save_history(self, new_items: List[dict]):
|
||||
history: List[dict] = self.get_data("history") or []
|
||||
history = (new_items + history)[:_MAX_HISTORY]
|
||||
self.save_data("history", history)
|
||||
|
||||
def __send_notify(self, updated: List[dict]):
|
||||
blocks = []
|
||||
for item in updated:
|
||||
type_label = "IPv4" if item["type"] == "A" else "IPv6"
|
||||
blocks.append(f"{item['fqdn']}({type_label})\n{item['ip']}")
|
||||
|
||||
text = "以下记录已同步新 IP:\n\n" + "\n\n".join(blocks) + "\n\n查看详情"
|
||||
|
||||
self.post_message(
|
||||
mtype=NotificationType.Plugin,
|
||||
title="🌐 阿里云 DDNS 已更新",
|
||||
text=text,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 工具函数
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _fqdn(rr: str, domain: str) -> str:
|
||||
return domain if rr == "@" else f"{rr}.{domain}"
|
||||
|
||||
|
||||
def _parse_records(raw: str) -> List[Dict[str, str]]:
|
||||
"""
|
||||
格式:顶级域名 主机记录 [类型]
|
||||
示例:
|
||||
example.com @ A
|
||||
example.com * A
|
||||
example.com home AAAA
|
||||
空行和 # 注释行会被跳过。
|
||||
"""
|
||||
result = []
|
||||
for line in raw.splitlines():
|
||||
line = line.split("#")[0].strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
logger.warning(f"[AliDnsDDNS] 忽略无效配置行: {line!r}")
|
||||
continue
|
||||
domain = parts[0]
|
||||
rr = parts[1]
|
||||
rec_type = parts[2].upper() if len(parts) >= 3 else "A"
|
||||
if rec_type not in ("A", "AAAA"):
|
||||
logger.warning(
|
||||
f"[AliDnsDDNS] 不支持的记录类型 {rec_type!r},已跳过: {line!r}"
|
||||
)
|
||||
continue
|
||||
result.append({"domain": domain, "rr": rr, "type": rec_type})
|
||||
return result
|
||||
|
||||
|
||||
def _get_public_ip(v6: bool = False) -> Optional[str]:
|
||||
"""从多个公共端点轮询获取公网 IPv4 或 IPv6 地址,首个成功即返回。"""
|
||||
sources_v4 = [
|
||||
"https://api4.ipify.org",
|
||||
"https://ipv4.icanhazip.com",
|
||||
"https://myexternalip.com/raw",
|
||||
"https://ipecho.net/plain",
|
||||
]
|
||||
sources_v6 = [
|
||||
"https://api6.ipify.org",
|
||||
"https://ipv6.icanhazip.com",
|
||||
"https://6.ident.me",
|
||||
]
|
||||
sources = sources_v6 if v6 else sources_v4
|
||||
validate = _is_valid_ipv6 if v6 else _is_valid_ipv4
|
||||
label = "IPv6" if v6 else "IPv4"
|
||||
|
||||
for url in sources:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": "MoviePilot-AliDnsDDNS/1.0"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
ip = resp.read(64).decode().strip()
|
||||
if validate(ip):
|
||||
logger.debug(f"[AliDnsDDNS] 检测到公网 {label}: {ip}(来源: {url})")
|
||||
return ip
|
||||
logger.debug(f"[AliDnsDDNS] {url} 返回无效 {label}: {ip!r}")
|
||||
except Exception as e:
|
||||
logger.debug(f"[AliDnsDDNS] {label} 检测源不可用: {url} — {e}")
|
||||
|
||||
logger.warning(f"[AliDnsDDNS] 所有 {label} 检测源均不可用")
|
||||
return None
|
||||
|
||||
|
||||
def _is_valid_ipv4(ip: str) -> bool:
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET, ip)
|
||||
return True
|
||||
except (OSError, socket.error):
|
||||
return False
|
||||
|
||||
|
||||
def _is_valid_ipv6(ip: str) -> bool:
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET6, ip)
|
||||
return True
|
||||
except (OSError, socket.error):
|
||||
return False
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 阿里云 DNS API 客户端(纯标准库)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_ALIDNS_ENDPOINT = "https://alidns.aliyuncs.com/"
|
||||
|
||||
|
||||
class _AliDnsClient:
|
||||
|
||||
def __init__(self, key_id: str, key_secret: str):
|
||||
self._key_id = key_id
|
||||
self._key_secret = key_secret
|
||||
|
||||
# ── 签名 ─────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _percent_encode(s: str) -> str:
|
||||
e = urllib.parse.quote(s, safe="")
|
||||
return e.replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
|
||||
|
||||
def _sign(self, params: Dict[str, str]) -> str:
|
||||
canonical = "&".join(
|
||||
f"{self._percent_encode(k)}={self._percent_encode(params[k])}"
|
||||
for k in sorted(params)
|
||||
)
|
||||
string_to_sign = "GET&%2F&" + self._percent_encode(canonical)
|
||||
mac = hmac.new(
|
||||
(self._key_secret + "&").encode(),
|
||||
string_to_sign.encode(),
|
||||
hashlib.sha1,
|
||||
)
|
||||
return b64encode(mac.digest()).decode()
|
||||
|
||||
def _base_params(self, action: str) -> Dict[str, str]:
|
||||
nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=16))
|
||||
return {
|
||||
"Action": action,
|
||||
"Version": "2015-01-09",
|
||||
"Format": "JSON",
|
||||
"AccessKeyId": self._key_id,
|
||||
"SignatureMethod": "HMAC-SHA1",
|
||||
"SignatureVersion": "1.0",
|
||||
"SignatureNonce": nonce,
|
||||
"Timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
}
|
||||
|
||||
def _request(self, params: Dict[str, str]) -> dict:
|
||||
import json as _json
|
||||
params["Signature"] = self._sign(params)
|
||||
url = _ALIDNS_ENDPOINT + "?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": "MoviePilot-AliDnsDDNS/1.0"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
body = _json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
# 读取响应体以获取阿里云返回的详细错误信息
|
||||
try:
|
||||
err_body = _json.loads(e.read().decode())
|
||||
code = err_body.get("Code", str(e.code))
|
||||
msg = err_body.get("Message", e.reason)
|
||||
except Exception:
|
||||
code, msg = str(e.code), e.reason
|
||||
raise RuntimeError(f"HTTP {e.code} — {code}: {msg}") from e
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(f"网络请求失败: {e.reason}") from e
|
||||
if body.get("Code"):
|
||||
raise RuntimeError(f"{body['Code']}: {body.get('Message', '')}")
|
||||
return body
|
||||
|
||||
# ── CRUD ─────────────────────────────────────
|
||||
|
||||
def _list_records(self, domain: str, rr: str, rec_type: str) -> List[dict]:
|
||||
p = self._base_params("DescribeDomainRecords")
|
||||
p.update({"DomainName": domain, "RRKeyWord": rr,
|
||||
"TypeKeyWord": rec_type, "PageSize": "20"})
|
||||
return self._request(p).get("DomainRecords", {}).get("Record", [])
|
||||
|
||||
def _add_record(self, domain: str, rr: str, rec_type: str, value: str):
|
||||
p = self._base_params("AddDomainRecord")
|
||||
p.update({"DomainName": domain, "RR": rr,
|
||||
"Type": rec_type, "Value": value, "TTL": "600"})
|
||||
self._request(p)
|
||||
logger.info(f"[AliDnsDDNS] 新建 DNS 记录 | {_fqdn(rr, domain)} | {rec_type} | {value}")
|
||||
|
||||
def _update_record(self, record_id: str, rr: str, rec_type: str,
|
||||
value: str, domain: str = ""):
|
||||
p = self._base_params("UpdateDomainRecord")
|
||||
p.update({"RecordId": record_id, "RR": rr,
|
||||
"Type": rec_type, "Value": value, "TTL": "600"})
|
||||
self._request(p)
|
||||
|
||||
def upsert(self, domain: str, rr: str, rec_type: str, new_ip: str) -> bool:
|
||||
"""
|
||||
创建或更新 DNS 记录,更新所有匹配的记录。
|
||||
返回 True 表示发生了变更,False 表示所有记录均已是最新值。
|
||||
"""
|
||||
records = self._list_records(domain, rr, rec_type)
|
||||
# API RRKeyWord 是模糊匹配,需精确过滤
|
||||
matched = [r for r in records if r.get("RR") == rr and r.get("Type") == rec_type]
|
||||
|
||||
if not matched:
|
||||
self._add_record(domain, rr, rec_type, new_ip)
|
||||
return True
|
||||
|
||||
changed = False
|
||||
for rec in matched:
|
||||
if rec.get("Value") == new_ip:
|
||||
continue # 该条已是最新,跳过
|
||||
self._update_record(rec["RecordId"], rr, rec_type, new_ip, domain)
|
||||
changed = True
|
||||
return changed
|
||||