mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-06-14 07:26:48 +00:00
Compare commits
73 Commits
AutoSignIn
...
ClashRuleP
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
1ae826cf14 | ||
|
|
f438490ca5 | ||
|
|
b938ca5bf3 | ||
|
|
028103b900 | ||
|
|
bb1f159198 | ||
|
|
6fa42abc17 | ||
|
|
95b952c27f | ||
|
|
6631d06a04 | ||
|
|
1afce8c607 | ||
|
|
82c825e349 | ||
|
|
ff7d7b1fa4 | ||
|
|
328ed9884a | ||
|
|
4d1b90abc8 | ||
|
|
c5afdfc2da | ||
|
|
fdbd5ad501 | ||
|
|
d66605ae99 | ||
|
|
145e9747a9 | ||
|
|
87e4dcd211 | ||
|
|
633c8bad97 | ||
|
|
0927d0388a | ||
|
|
323289aa74 | ||
|
|
1f80e3b078 | ||
|
|
0ac725383e | ||
|
|
659f4f2b0d | ||
|
|
d65979323e |
20
docs/FAQ.md
Normal file
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
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 文档
|
||||
File diff suppressed because it is too large
Load Diff
102
docs/faq/01-extend-notification-channel.md
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
BIN
icons/AliDnsDDNS.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
35
package.json
35
package.json
@@ -26,7 +26,7 @@
|
||||
"name": "AI字幕自动生成(v2)",
|
||||
"description": "使用whisper自动生成视频文件字幕,使用大模型翻译字幕成中文。",
|
||||
"labels": "字幕",
|
||||
"version": "2.3",
|
||||
"version": "2.5.1",
|
||||
"icon": "autosubtitles.jpeg",
|
||||
"author": "TimoYoung",
|
||||
"level": 1,
|
||||
@@ -38,7 +38,9 @@
|
||||
"v2.0": "1.引入任务队列 2.支持监听媒体入库自动生成字幕 3.增加任务状态展示界面",
|
||||
"v2.1": "支持清除历史记录",
|
||||
"v2.2": "fix",
|
||||
"v2.3": "支持独立的大模型调用配置"
|
||||
"v2.3": "支持独立的大模型调用配置",
|
||||
"v2.5": "适配openai api v1",
|
||||
"v2.5.1": "更新依赖"
|
||||
}
|
||||
},
|
||||
"CustomSites": {
|
||||
@@ -190,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"
|
||||
}
|
||||
@@ -218,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": "调整插件开启状态判断条件",
|
||||
@@ -466,13 +470,14 @@
|
||||
"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",
|
||||
@@ -498,11 +503,12 @@
|
||||
"name": "MoviePilot更新推送",
|
||||
"description": "MoviePilot推送release更新通知、自动重启。",
|
||||
"labels": "消息通知,自动更新",
|
||||
"version": "1.5",
|
||||
"version": "1.5.1",
|
||||
"icon": "Moviepilot_A.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.5.1": "修复版本号比较逻辑",
|
||||
"v1.5": "修复版本描述为空时的报错",
|
||||
"v1.4": "兼容更新内容带版本号的情况",
|
||||
"v1.3": "增加前端版本更新检查,需要主程序升级至v1.8.4+版本"
|
||||
@@ -677,12 +683,13 @@
|
||||
"name": "共享识别词",
|
||||
"description": "从Github、Etherpad等远程文件中获取共享识别词并应用。",
|
||||
"labels": "识别",
|
||||
"version": "2.3",
|
||||
"version": "2.4",
|
||||
"icon": "words.png",
|
||||
"author": "honue",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v2.4": "支持 JSON 格式远程识别词集合订阅",
|
||||
"v2.3": "更换默认共享识别词地址"
|
||||
}
|
||||
},
|
||||
@@ -1054,5 +1061,17 @@
|
||||
"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、泛域名、多记录配置、更新历史详情页"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,14 @@
|
||||
"name": "站点自动签到",
|
||||
"description": "自动模拟登录、签到站点。",
|
||||
"labels": "站点",
|
||||
"version": "2.8",
|
||||
"version": "2.8.2",
|
||||
"icon": "signin.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.8.2": "优化站点 Rousi Pro 签到失败提示信息",
|
||||
"v2.8.1": "更新站点 Rousi Pro 签到接口",
|
||||
"v2.8": "适配站点 Rousi Pro",
|
||||
"v2.7": "站点请求使用站点设置的超时时间",
|
||||
"v2.6": "感谢madrays佬提供的UI!",
|
||||
@@ -95,11 +97,12 @@
|
||||
"name": "媒体库服务器通知",
|
||||
"description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。",
|
||||
"labels": "消息通知,媒体库",
|
||||
"version": "1.8.2.1",
|
||||
"version": "1.8.2.2",
|
||||
"icon": "mediaplay.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.8.2.2": "修复emby多条相同新入库消息推送多次的问题",
|
||||
"v1.8.2.1": "修复多集时有概率图片获取失败的问题;修复emby测试通知类型接收失败的问题",
|
||||
"v1.8.1": "修复单集剧情信息有概率获取失败的问题",
|
||||
"v1.8": "当整理路径中没有tmdbid时,会尝试从媒体服务器中获取",
|
||||
@@ -114,11 +117,12 @@
|
||||
"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.6": "支持自定义辅助识别提示词",
|
||||
@@ -136,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)",
|
||||
@@ -283,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 版本"
|
||||
}
|
||||
@@ -383,11 +389,12 @@
|
||||
"name": "MoviePilot更新推送",
|
||||
"description": "MoviePilot推送release更新通知、自动重启。",
|
||||
"labels": "消息通知,自动更新",
|
||||
"version": "2.3",
|
||||
"version": "2.3.1",
|
||||
"icon": "Moviepilot_A.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.3.1": "修复版本号比较逻辑",
|
||||
"v2.3": "修复版本描述为空时的报错",
|
||||
"v2.2": "支持 MoviePilot v2.5.0+",
|
||||
"v2.1": "优化执行周期输入,需要MoviePilot v2.2.1+",
|
||||
@@ -449,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",
|
||||
@@ -471,11 +479,12 @@
|
||||
"name": "IMDb源",
|
||||
"description": "让探索,推荐和媒体识别支持IMDb数据源。",
|
||||
"labels": "探索",
|
||||
"version": "1.6.6",
|
||||
"version": "1.6.7",
|
||||
"icon": "IMDb_IOS-OSX_App.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.6.7": "优化界面显示; 增加榜单排名显示; 添加制作公司过滤项",
|
||||
"v1.6.6": "优化主页组件链接跳转",
|
||||
"v1.6.5": "仪表盘组件支持图片缓存",
|
||||
"v1.6.4": "为元数据增加背景图",
|
||||
@@ -495,7 +504,7 @@
|
||||
"v1.4.3": "为仪表盘组件添加缓存",
|
||||
"v1.4.2": "优化小屏幕组件显示",
|
||||
"v1.4.1": "优化亮色主题显示",
|
||||
"v1.4.0":"添加仪表盘组件: IMDb 编辑精选",
|
||||
"v1.4.0": "添加仪表盘组件: IMDb 编辑精选",
|
||||
"v1.3.3": "修复依赖问题",
|
||||
"v1.3.2": "更新 API query hash",
|
||||
"v1.3.1": "修复按日期排序错误",
|
||||
@@ -509,12 +518,15 @@
|
||||
"name": "Clash Rule Provider",
|
||||
"description": "随时为Clash添加一些额外的规则。",
|
||||
"labels": "工具",
|
||||
"version": "2.1.2",
|
||||
"version": "2.1.5",
|
||||
"icon": "Mihomo_Meta_A.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.1.5": "优化仪表盘连接鉴权;优化订阅更新提示",
|
||||
"v2.1.4": "支持 xhttp 协议",
|
||||
"v2.1.3": "修复代理删除问题",
|
||||
"v2.1.2": "修复规则集序列化错误",
|
||||
"v2.1.1": "增强数据管理功能",
|
||||
"v2.0.10": "适配 MoviePilot 2.8.4",
|
||||
@@ -553,11 +565,13 @@
|
||||
"name": "美剧生词标注",
|
||||
"description": "根据CEFR等级,为英语影视剧标注高级词汇。",
|
||||
"labels": "英语",
|
||||
"version": "1.2.3",
|
||||
"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": "改进字幕样式获取方法",
|
||||
"v1.2.0": "引入大模型候选词决策和词义丰富处理链; 支持读取系统智能体配置; 添加智能体工具; 优化通知样式; 改进 UI",
|
||||
@@ -609,5 +623,44 @@
|
||||
"v1.4.2": "适配MoviePilot v2.8.8+",
|
||||
"v1.4.1": "MoviePilot V2 版本登录壁纸本地化插件"
|
||||
}
|
||||
},
|
||||
"DailySummary": {
|
||||
"name": "活动总结",
|
||||
"description": "定时发送每日/每周/每月活动总结通知,支持自定义报告模块、历史记录查看",
|
||||
"labels": "通知",
|
||||
"version": "2.0.0",
|
||||
"icon": "Bark_A.png",
|
||||
"author": "yuhoye",
|
||||
"level": 1,
|
||||
"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": "初始版本"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class AutoSignIn(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "signin.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.8"
|
||||
plugin_version = "2.8.2"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
|
||||
@@ -57,15 +57,15 @@ class RousiPro(_ISiteSigninHandler):
|
||||
json=body
|
||||
)
|
||||
|
||||
if res is not None and res.status_code == 200 and "签到成功" in res.json().get("message", ""):
|
||||
if res is not None and res.status_code == 200 and res.json().get("code", -1) == 0:
|
||||
logger.info(f"{site} 签到成功")
|
||||
return True, "签到成功"
|
||||
elif res is not None and res.status_code == 400 and res.json().get("error", "") == "今日已签到":
|
||||
elif res is not None and res.status_code == 400 and res.json().get("code", -1) == 1:
|
||||
logger.info(f"{site} 今日已签到")
|
||||
return True, "今日已签到"
|
||||
elif res is not None and res.status_code == 401:
|
||||
logger.error(f"{site} 签到失败,登录状态无效")
|
||||
return False, "签到失败,登录状态无效"
|
||||
logger.error(f"{site} 签到失败,Authorization 已失效")
|
||||
return False, "签到失败,Authorization 已失效"
|
||||
elif res is not None:
|
||||
logger.error(f"{site} 签到失败,状态码:{res.status_code}")
|
||||
return False, f"签到失败,状态码:{res.status_code}"
|
||||
@@ -100,12 +100,12 @@ class RousiPro(_ISiteSigninHandler):
|
||||
url="https://rousi.pro/api/points/attendance/stats"
|
||||
)
|
||||
|
||||
if res is not None and res.status_code == 200 and "attended_dates" in res.json():
|
||||
if res is not None and res.status_code == 200 and res.json().get("code", -1) == 0:
|
||||
logger.info(f"{site} 模拟登录成功")
|
||||
return True, "模拟登录成功"
|
||||
elif res is not None and res.status_code == 401:
|
||||
logger.error(f"{site} 模拟登录失败,登录状态无效")
|
||||
return False, "模拟登录失败,登录状态无效"
|
||||
logger.error(f"{site} 模拟登录失败,Authorization 已失效")
|
||||
return False, "模拟登录失败,Authorization 已失效"
|
||||
elif res is not None:
|
||||
logger.error(f"{site} 模拟登录失败,状态码:{res.status_code}")
|
||||
return False, f"模拟登录失败,状态码:{res.status_code}"
|
||||
|
||||
@@ -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
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.5"
|
||||
# 插件作者
|
||||
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;
|
||||
@@ -6007,13 +6007,28 @@ const _sfc_main$q = /* @__PURE__ */ _defineComponent$q({
|
||||
}, {
|
||||
default: _withCtx$q(() => [
|
||||
_createVNode$q(_component_v_btn_group, {
|
||||
class: "d-sm-none",
|
||||
variant: "outlined",
|
||||
rounded: "",
|
||||
divided: ""
|
||||
}, {
|
||||
default: _withCtx$q(() => [
|
||||
_createVNode$q(_component_v_btn, {
|
||||
icon: "mdi-plus",
|
||||
disabled: loading.value,
|
||||
onClick: openAddRuleDialog
|
||||
}, null, 8, ["disabled"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$q(_component_v_btn_group, {
|
||||
class: "d-none d-sm-flex",
|
||||
variant: "outlined",
|
||||
rounded: "",
|
||||
divided: ""
|
||||
}, {
|
||||
default: _withCtx$q(() => [
|
||||
_createVNode$q(_component_v_btn, {
|
||||
class: "d-none d-sm-flex",
|
||||
icon: group.value ? "mdi-format-list-bulleted" : "mdi-format-list-group",
|
||||
disabled: loading.value,
|
||||
onClick: _cache[2] || (_cache[2] = ($event) => group.value = !group.value)
|
||||
@@ -10403,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) {
|
||||
@@ -10848,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",
|
||||
@@ -10872,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", {
|
||||
@@ -11074,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-DeAFYy3o.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')
|
||||
|
||||
@@ -191,7 +191,7 @@ class ClashRuleProviderService:
|
||||
except ValueError:
|
||||
final_action = action
|
||||
rules = self.state.ruleset_rules_manager.filter_rules_by_action(final_action)
|
||||
return [rule.rule.condition_string() for rule in rules]
|
||||
return [rule.rule.condition_string() for rule in rules if rule.meta.available()]
|
||||
|
||||
def sync_ruleset(self):
|
||||
outbounds = set()
|
||||
@@ -240,12 +240,12 @@ class ClashRuleProviderService:
|
||||
self.state.save_data(DataKey.TOP_RULES, self.state.top_rules_manager.export_rules())
|
||||
|
||||
def clash_outbound(self) -> list[str]:
|
||||
outbound = [pg_data.data.name for pg_data in self.state.proxy_groups_from_subs()]
|
||||
outbound = [pg.data.name for pg in self.state.proxy_groups]
|
||||
if self.state.clash_template:
|
||||
outbound.extend(pg.name for pg in self.state.clash_template.proxy_groups)
|
||||
if self.state.config.group_by_region or self.state.config.group_by_country:
|
||||
outbound.extend(pg.name for pg in self.proxy_groups_by_region())
|
||||
outbound.extend(pg.data.name for pg in self.state.proxy_groups)
|
||||
outbound.extend(pg_data.data.name for pg_data in self.state.proxy_groups_from_subs())
|
||||
outbound.extend(pg.data.name for pg in self.get_proxies())
|
||||
return outbound
|
||||
|
||||
|
||||
@@ -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
1
plugins.v2/crossseed/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
fast-bencode~=1.1.7
|
||||
769
plugins.v2/dailysummary/__init__.py
Normal file
769
plugins.v2/dailysummary/__init__.py
Normal file
@@ -0,0 +1,769 @@
|
||||
"""MoviePilot 活动总结插件 — 定时发送每日/每周/每月活动总结通知"""
|
||||
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import NotificationType
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.schemas.types import EventType
|
||||
from app.core.config import settings
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.db.plugindata_oper import PluginDataOper
|
||||
from app.db.models.siteuserdata import SiteUserData
|
||||
from app.db import ScopedSession
|
||||
|
||||
|
||||
# ─── 模块注册表:key → 中文名,各报告按需选取 ───
|
||||
|
||||
MODULES = OrderedDict([
|
||||
("download", "下载记录"),
|
||||
("transfer", "入库记录"),
|
||||
("signin", "签到状态"),
|
||||
("brush", "刷流统计"),
|
||||
("downloader", "下载器概览"),
|
||||
("site_delta", "站点增量"),
|
||||
("site_current", "站点快照"),
|
||||
("subscribe", "订阅进度"),
|
||||
("storage", "存储空间"),
|
||||
])
|
||||
|
||||
MODULE_OPTIONS = [{"title": name, "value": key} for key, name in MODULES.items()]
|
||||
|
||||
# 各报告类型的默认模块
|
||||
DEFAULT_DAILY_MODULES = ["download", "transfer", "signin", "brush", "downloader", "site_delta"]
|
||||
DEFAULT_WEEKLY_MODULES = ["download", "transfer", "subscribe", "site_delta", "brush"]
|
||||
DEFAULT_MONTHLY_MODULES = [
|
||||
"download", "transfer", "subscribe", "site_current",
|
||||
"site_delta", "storage", "brush", "downloader",
|
||||
]
|
||||
|
||||
WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||
|
||||
# 历史记录上限
|
||||
MAX_HISTORY = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimeRange:
|
||||
"""报告的时间范围"""
|
||||
start: datetime
|
||||
end: datetime
|
||||
start_str: str # "YYYY-MM-DD HH:MM:SS" — 用于数据库查询
|
||||
start_date: str # "YYYY-MM-DD"
|
||||
end_date: str # "YYYY-MM-DD"
|
||||
report_type: str # "daily" / "weekly" / "monthly"
|
||||
prefix: str # "今日" / "本周" / "本月"
|
||||
|
||||
|
||||
class DailySummary(_PluginBase):
|
||||
plugin_name = "活动总结"
|
||||
plugin_desc = "定时发送每日/每周/每月活动总结通知,支持自定义报告模块、历史记录查看"
|
||||
plugin_icon = "Bark_A.png"
|
||||
plugin_version = "2.0.0"
|
||||
plugin_author = "yuhoye"
|
||||
author_url = "https://github.com/yuhoye"
|
||||
plugin_config_prefix = "dailysummary_"
|
||||
plugin_order = 30
|
||||
auth_level = 1
|
||||
|
||||
# ─── 配置字段 ───
|
||||
|
||||
_enabled: bool = False
|
||||
_notify: bool = True
|
||||
_daily_cron: str = "0 23 * * *"
|
||||
_weekly_cron: str = "0 23 * * 1"
|
||||
_monthly_cron: str = "0 23 1 * *"
|
||||
_onlyonce: bool = False
|
||||
_test_type: str = "daily"
|
||||
|
||||
_daily_modules: list = None
|
||||
_weekly_modules: list = None
|
||||
_monthly_modules: list = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
if config:
|
||||
self._enabled = config.get("enabled", False)
|
||||
self._notify = config.get("notify", True)
|
||||
self._daily_cron = config.get("daily_cron", "0 23 * * *")
|
||||
self._weekly_cron = config.get("weekly_cron", "0 23 * * 1")
|
||||
self._monthly_cron = config.get("monthly_cron", "0 23 1 * *")
|
||||
self._onlyonce = config.get("onlyonce", False)
|
||||
self._test_type = config.get("test_type", "daily")
|
||||
self._daily_modules = config.get("daily_modules") or DEFAULT_DAILY_MODULES
|
||||
self._weekly_modules = config.get("weekly_modules") or DEFAULT_WEEKLY_MODULES
|
||||
self._monthly_modules = config.get("monthly_modules") or DEFAULT_MONTHLY_MODULES
|
||||
else:
|
||||
self._daily_modules = DEFAULT_DAILY_MODULES
|
||||
self._weekly_modules = DEFAULT_WEEKLY_MODULES
|
||||
self._monthly_modules = DEFAULT_MONTHLY_MODULES
|
||||
|
||||
if self._onlyonce:
|
||||
self._onlyonce = False
|
||||
self._save_config()
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
test_func = {
|
||||
"daily": self.send_daily,
|
||||
"weekly": self.send_weekly,
|
||||
"monthly": self.send_monthly,
|
||||
}.get(self._test_type, self.send_daily)
|
||||
scheduler.add_job(
|
||||
func=test_func,
|
||||
trigger="date",
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="立即测试",
|
||||
)
|
||||
scheduler.start()
|
||||
|
||||
def _save_config(self):
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"notify": self._notify,
|
||||
"daily_cron": self._daily_cron,
|
||||
"weekly_cron": self._weekly_cron,
|
||||
"monthly_cron": self._monthly_cron,
|
||||
"onlyonce": False,
|
||||
"test_type": self._test_type,
|
||||
"daily_modules": self._daily_modules,
|
||||
"weekly_modules": self._weekly_modules,
|
||||
"monthly_modules": self._monthly_modules,
|
||||
})
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{"cmd": "/daily_summary", "event": EventType.PluginAction, "desc": "发送每日总结", "category": "工具", "data": {"action": "daily_summary"}},
|
||||
{"cmd": "/weekly_summary", "event": EventType.PluginAction, "desc": "发送每周总结", "category": "工具", "data": {"action": "weekly_summary"}},
|
||||
{"cmd": "/monthly_summary", "event": EventType.PluginAction, "desc": "发送每月总结", "category": "工具", "data": {"action": "monthly_summary"}},
|
||||
]
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
return [{
|
||||
"path": "/clear_history",
|
||||
"endpoint": self._api_clear_history,
|
||||
"methods": ["POST"],
|
||||
"summary": "清除历史记录",
|
||||
}]
|
||||
|
||||
def _api_clear_history(self) -> dict:
|
||||
self.save_data("history", [])
|
||||
logger.info("[DailySummary] 历史记录已清除")
|
||||
return {"success": True}
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
if not self._enabled:
|
||||
return []
|
||||
services = []
|
||||
if self._daily_cron:
|
||||
services.append({
|
||||
"id": "DailySummary_daily",
|
||||
"name": "每日总结",
|
||||
"trigger": CronTrigger.from_crontab(self._daily_cron),
|
||||
"func": self.send_daily,
|
||||
"kwargs": {},
|
||||
})
|
||||
if self._weekly_cron:
|
||||
services.append({
|
||||
"id": "DailySummary_weekly",
|
||||
"name": "每周总结",
|
||||
"trigger": CronTrigger.from_crontab(self._weekly_cron),
|
||||
"func": self.send_weekly,
|
||||
"kwargs": {},
|
||||
})
|
||||
if self._monthly_cron:
|
||||
services.append({
|
||||
"id": "DailySummary_monthly",
|
||||
"name": "每月总结",
|
||||
"trigger": CronTrigger.from_crontab(self._monthly_cron),
|
||||
"func": self.send_monthly,
|
||||
"kwargs": {},
|
||||
})
|
||||
return services
|
||||
|
||||
# ─── 配置表单:三 Tab 布局 ───
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
test_options = [
|
||||
{"title": "每日总结", "value": "daily"},
|
||||
{"title": "每周总结", "value": "weekly"},
|
||||
{"title": "每月总结", "value": "monthly"},
|
||||
]
|
||||
return [
|
||||
{
|
||||
"component": "VForm",
|
||||
"content": [
|
||||
# ── 基本设置(VTabs 外面,避免弹出菜单被裁剪) ──
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 3},
|
||||
'content': [{'component': 'VSwitch', 'props': {'model': 'enabled', 'label': '启用插件'}}]},
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 3},
|
||||
'content': [{'component': 'VSwitch', 'props': {'model': 'notify', 'label': '发送通知'}}]},
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 3},
|
||||
'content': [{'component': 'VSwitch', 'props': {'model': 'onlyonce', 'label': '立即测试一次'}}]},
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 3},
|
||||
'content': [{'component': 'VSelect', 'props': {'model': 'test_type', 'label': '测试类型', 'items': test_options}}]},
|
||||
],
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 4},
|
||||
'content': [{'component': 'VCronField', 'props': {'model': 'daily_cron', 'label': '每日周期', 'placeholder': '5位cron表达式'}}]},
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 4},
|
||||
'content': [{'component': 'VCronField', 'props': {'model': 'weekly_cron', 'label': '每周周期', 'placeholder': '5位cron表达式'}}]},
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 4},
|
||||
'content': [{'component': 'VCronField', 'props': {'model': 'monthly_cron', 'label': '每月周期', 'placeholder': '5位cron表达式'}}]},
|
||||
],
|
||||
},
|
||||
# ── 报告内容 ──
|
||||
{
|
||||
"component": "VRow",
|
||||
"props": {"style": "margin-top: 8px;"},
|
||||
"content": [
|
||||
{"component": "VCol", "props": {"cols": 12},
|
||||
"content": [{"component": "VAlert", "props": {"type": "info", "variant": "tonal", "text": "选择各报告中包含的信息模块,模块按选择顺序显示在报告中"}}]},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{"component": "VCol", "props": {"cols": 12, "md": 4},
|
||||
"content": [{"component": "VSelect", "props": {
|
||||
"model": "daily_modules", "label": "日报模块",
|
||||
"items": MODULE_OPTIONS, "multiple": True, "chips": True, "closable-chips": True,
|
||||
}}]},
|
||||
{"component": "VCol", "props": {"cols": 12, "md": 4},
|
||||
"content": [{"component": "VSelect", "props": {
|
||||
"model": "weekly_modules", "label": "周报模块",
|
||||
"items": MODULE_OPTIONS, "multiple": True, "chips": True, "closable-chips": True,
|
||||
}}]},
|
||||
{"component": "VCol", "props": {"cols": 12, "md": 4},
|
||||
"content": [{"component": "VSelect", "props": {
|
||||
"model": "monthly_modules", "label": "月报模块",
|
||||
"items": MODULE_OPTIONS, "multiple": True, "chips": True, "closable-chips": True,
|
||||
}}]},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": True,
|
||||
"daily_cron": "0 23 * * *",
|
||||
"weekly_cron": "0 23 * * 1",
|
||||
"monthly_cron": "0 23 1 * *",
|
||||
"onlyonce": False,
|
||||
"test_type": "daily",
|
||||
"daily_modules": DEFAULT_DAILY_MODULES,
|
||||
"weekly_modules": DEFAULT_WEEKLY_MODULES,
|
||||
"monthly_modules": DEFAULT_MONTHLY_MODULES,
|
||||
}
|
||||
|
||||
# ─── 历史记录页面 ───
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
history = self.get_data("history") or []
|
||||
|
||||
# 模块配置摘要
|
||||
def _module_names(modules):
|
||||
return "、".join(MODULES.get(m, m) for m in (modules or []))
|
||||
|
||||
config_cards = [
|
||||
self._config_card('📊 日报模块', _module_names(self._daily_modules), self._daily_cron),
|
||||
self._config_card('📈 周报模块', _module_names(self._weekly_modules), self._weekly_cron),
|
||||
self._config_card('📅 月报模块', _module_names(self._monthly_modules), self._monthly_cron),
|
||||
]
|
||||
|
||||
if not history:
|
||||
return [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': config_cards,
|
||||
},
|
||||
{
|
||||
'component': 'div',
|
||||
'text': '暂无发送记录',
|
||||
'props': {'class': 'text-center mt-4'},
|
||||
},
|
||||
]
|
||||
|
||||
daily_count = sum(1 for r in history if r.get("type") == "daily")
|
||||
weekly_count = sum(1 for r in history if r.get("type") == "weekly")
|
||||
monthly_count = sum(1 for r in history if r.get("type") == "monthly")
|
||||
|
||||
items = [
|
||||
{
|
||||
'time': r.get('time', ''),
|
||||
'type_label': {'daily': '日报', 'weekly': '周报', 'monthly': '月报'}.get(r.get('type'), ''),
|
||||
'title': r.get('title', ''),
|
||||
'preview': (r.get('text', '')[:80] + '...') if len(r.get('text', '')) > 80 else r.get('text', ''),
|
||||
}
|
||||
for r in reversed(history)
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': config_cards + [
|
||||
# 发送统计
|
||||
self._stat_card('日报', f'{daily_count} 份'),
|
||||
self._stat_card('周报', f'{weekly_count} 份'),
|
||||
self._stat_card('月报', f'{monthly_count} 份'),
|
||||
# 历史记录表格
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {'cols': 12, 'class': 'd-none d-sm-block'},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VDataTableVirtual',
|
||||
'props': {
|
||||
'class': 'text-sm',
|
||||
'headers': [
|
||||
{'title': '时间', 'key': 'time', 'sortable': True},
|
||||
{'title': '类型', 'key': 'type_label', 'sortable': True},
|
||||
{'title': '标题', 'key': 'title', 'sortable': False},
|
||||
{'title': '预览', 'key': 'preview', 'sortable': False},
|
||||
],
|
||||
'items': items,
|
||||
'height': '30rem',
|
||||
'density': 'compact',
|
||||
'fixed-header': True,
|
||||
'hide-no-data': True,
|
||||
'hover': True,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _config_card(title: str, modules_text: str, cron: str) -> dict:
|
||||
return {
|
||||
'component': 'VCol',
|
||||
'props': {'cols': 12, 'md': 4},
|
||||
'content': [{
|
||||
'component': 'VCard',
|
||||
'props': {'variant': 'tonal'},
|
||||
'content': [{
|
||||
'component': 'VCardText',
|
||||
'content': [
|
||||
{'component': 'div', 'props': {'class': 'text-subtitle-2 mb-1'}, 'text': f'{title} ⏰ {cron}'},
|
||||
{'component': 'span', 'props': {'class': 'text-caption'}, 'text': modules_text},
|
||||
],
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _stat_card(title: str, value: str) -> dict:
|
||||
return {
|
||||
'component': 'VCol',
|
||||
'props': {'cols': 4, 'md': 4},
|
||||
'content': [{
|
||||
'component': 'VCard',
|
||||
'props': {'variant': 'tonal'},
|
||||
'content': [{
|
||||
'component': 'VCardText',
|
||||
'props': {'class': 'text-center pa-2'},
|
||||
'content': [
|
||||
{'component': 'div', 'props': {'class': 'text-caption'}, 'text': title},
|
||||
{'component': 'div', 'props': {'class': 'text-h6'}, 'text': value},
|
||||
],
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
def stop_service(self):
|
||||
pass
|
||||
|
||||
# ─── 命令处理 ───
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def handle_command(self, event: Event = None):
|
||||
if not event:
|
||||
return
|
||||
action = (event.event_data or {}).get("action", "")
|
||||
handler = {
|
||||
"daily_summary": self.send_daily,
|
||||
"weekly_summary": self.send_weekly,
|
||||
"monthly_summary": self.send_monthly,
|
||||
}.get(action)
|
||||
if handler:
|
||||
handler()
|
||||
|
||||
# ─── 统一报告引擎 ───
|
||||
|
||||
def send_daily(self):
|
||||
header, text = self._build_report("daily")
|
||||
self._send(report_type="daily", title=header, text=text)
|
||||
|
||||
def send_weekly(self):
|
||||
header, text = self._build_report("weekly")
|
||||
self._send(report_type="weekly", title=header, text=text)
|
||||
|
||||
def send_monthly(self):
|
||||
header, text = self._build_report("monthly")
|
||||
self._send(report_type="monthly", title=header, text=text)
|
||||
|
||||
def _build_report(self, report_type: str) -> Tuple[str, str]:
|
||||
logger.info(f"[DailySummary] 开始生成 {report_type} 总结")
|
||||
tr = self._calc_time_range(report_type)
|
||||
modules = {
|
||||
"daily": self._daily_modules,
|
||||
"weekly": self._weekly_modules,
|
||||
"monthly": self._monthly_modules,
|
||||
}.get(report_type, self._daily_modules)
|
||||
|
||||
sections = []
|
||||
for mod in modules:
|
||||
result = self._run_section(mod, tr)
|
||||
if result:
|
||||
sections.append(result)
|
||||
|
||||
header = self._make_header(report_type, tr)
|
||||
text = "\n\n".join(sections) if sections else "无数据"
|
||||
return header, text
|
||||
|
||||
def _calc_time_range(self, report_type: str) -> TimeRange:
|
||||
tz = pytz.timezone(settings.TZ)
|
||||
now = datetime.now(tz)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
if report_type == "daily":
|
||||
start = today_start
|
||||
prefix = "今日"
|
||||
elif report_type == "weekly":
|
||||
start = today_start - timedelta(days=now.weekday())
|
||||
prefix = "本周"
|
||||
else:
|
||||
start = today_start.replace(day=1)
|
||||
prefix = "本月"
|
||||
|
||||
return TimeRange(
|
||||
start=start,
|
||||
end=now,
|
||||
start_str=start.strftime("%Y-%m-%d 00:00:00"),
|
||||
start_date=start.strftime("%Y-%m-%d"),
|
||||
end_date=today_start.strftime("%Y-%m-%d"),
|
||||
report_type=report_type,
|
||||
prefix=prefix,
|
||||
)
|
||||
|
||||
def _make_header(self, report_type: str, tr: TimeRange) -> str:
|
||||
now = tr.end
|
||||
if report_type == "daily":
|
||||
return f"📊 每日总结 ({now.strftime('%m-%d')} {WEEKDAY_NAMES[now.weekday()]})"
|
||||
elif report_type == "weekly":
|
||||
return f"📈 周报 ({tr.start.strftime('%m-%d')} ~ {now.strftime('%m-%d')})"
|
||||
else:
|
||||
return f"📅 月报 ({now.strftime('%Y年%m月')})"
|
||||
|
||||
def _run_section(self, module: str, tr: TimeRange) -> Optional[str]:
|
||||
handler = {
|
||||
"download": self._section_download,
|
||||
"transfer": self._section_transfer,
|
||||
"signin": self._section_signin,
|
||||
"brush": self._section_brush,
|
||||
"downloader": self._section_downloader,
|
||||
"site_delta": self._section_site_delta,
|
||||
"site_current": self._section_site_current,
|
||||
"subscribe": self._section_subscribe,
|
||||
"storage": self._section_storage,
|
||||
}.get(module)
|
||||
if not handler:
|
||||
return None
|
||||
try:
|
||||
return handler(tr)
|
||||
except Exception as e:
|
||||
logger.error(f"[DailySummary] 模块 {module} 执行失败: {e}")
|
||||
return f"【{MODULES.get(module, module)}】数据读取失败"
|
||||
|
||||
# ─── 各模块实现 ───
|
||||
|
||||
def _section_download(self, tr: TimeRange) -> Optional[str]:
|
||||
downloads = self._get_downloads(tr.start_str)
|
||||
if not downloads:
|
||||
return f"【{tr.prefix}下载】无"
|
||||
|
||||
# 日报:详细列表;周报/月报:分类汇总
|
||||
if tr.report_type == "daily":
|
||||
lines = [f"【{tr.prefix}下载 {len(downloads)} 部】"]
|
||||
for d in downloads:
|
||||
ep = f" {d.seasons}{d.episodes}" if d.episodes else (f" {d.seasons}" if d.seasons else "")
|
||||
site = f" - {d.torrent_site}" if d.torrent_site else ""
|
||||
lines.append(f" • {d.title}{ep}{site}")
|
||||
return "\n".join(lines)
|
||||
|
||||
type_count = {}
|
||||
for d in downloads:
|
||||
cat = d.media_category or d.type or "其他"
|
||||
type_count[cat] = type_count.get(cat, 0) + 1
|
||||
type_summary = " | ".join(f"{k} {v}" for k, v in sorted(type_count.items(), key=lambda x: -x[1]))
|
||||
return f"【{tr.prefix}下载】共 {len(downloads)} 部\n {type_summary}"
|
||||
|
||||
def _section_transfer(self, tr: TimeRange) -> Optional[str]:
|
||||
transfers = self._get_transfers(tr.start_str)
|
||||
success = [t for t in transfers if t.status]
|
||||
if not success:
|
||||
return f"【{tr.prefix}入库】无"
|
||||
|
||||
# 日报:详细列表;周报/月报:分类汇总
|
||||
if tr.report_type == "daily":
|
||||
lines = [f"【{tr.prefix}入库 {len(success)} 部】"]
|
||||
for t in success:
|
||||
ep = f" {t.seasons}{t.episodes}" if t.episodes else (f" {t.seasons}" if t.seasons else "")
|
||||
cat = f" → {t.category}" if t.category else ""
|
||||
lines.append(f" • {t.title}{ep}{cat}")
|
||||
return "\n".join(lines)
|
||||
|
||||
cat_count = {}
|
||||
for t in success:
|
||||
cat = t.category or t.type or "其他"
|
||||
cat_count[cat] = cat_count.get(cat, 0) + 1
|
||||
cat_summary = " | ".join(f"{k} {v}" for k, v in sorted(cat_count.items(), key=lambda x: -x[1]))
|
||||
return f"【{tr.prefix}入库】共 {len(success)} 部\n {cat_summary}"
|
||||
|
||||
def _section_signin(self, tr: TimeRange) -> str:
|
||||
pdo = PluginDataOper()
|
||||
plugin_id = "AutoSignIn"
|
||||
now = tr.end
|
||||
key = f"{now.month}月{now.day}日"
|
||||
data = pdo.get_data(plugin_id, key)
|
||||
if not data:
|
||||
return "【签到】今日无签到记录"
|
||||
|
||||
signin_records = [r for r in data if "模拟登录" not in r.get("status", "")]
|
||||
total = len(signin_records)
|
||||
success = sum(1 for r in signin_records if "成功" in r.get("status", ""))
|
||||
failed = [r for r in signin_records if "成功" not in r.get("status", "")]
|
||||
|
||||
if success == total:
|
||||
return f"【签到】✅ 全部成功 ({success}/{total})"
|
||||
|
||||
fail_sites = ", ".join(r["site"] for r in failed)
|
||||
return f"【签到】⚠️ {success}/{total} 成功\n 失败: {fail_sites}"
|
||||
|
||||
def _section_brush(self, tr: TimeRange) -> str:
|
||||
pdo = PluginDataOper()
|
||||
plugin_ids = ["BrushFlow"]
|
||||
|
||||
total_uploaded = 0
|
||||
total_downloaded = 0
|
||||
total_active = 0
|
||||
total_deleted = 0
|
||||
total_count = 0
|
||||
|
||||
for pid in plugin_ids:
|
||||
stat = pdo.get_data(pid, "statistic")
|
||||
if not stat:
|
||||
continue
|
||||
total_uploaded += stat.get("uploaded", 0) + stat.get("active_uploaded", 0)
|
||||
total_downloaded += stat.get("downloaded", 0)
|
||||
total_active += stat.get("active", 0)
|
||||
total_deleted += stat.get("deleted", 0)
|
||||
total_count += stat.get("count", 0)
|
||||
|
||||
return (
|
||||
f"【刷流】总种: {total_count} | 活跃: {total_active} | 已删: {total_deleted}\n"
|
||||
f" 总↑ {_human_size(total_uploaded)} | 总↓ {_human_size(total_downloaded)}"
|
||||
)
|
||||
|
||||
def _section_downloader(self, tr: TimeRange) -> Optional[str]:
|
||||
"""通过 DownloaderHelper 获取所有已配置下载器的概览"""
|
||||
try:
|
||||
from app.helper.downloader import DownloaderHelper
|
||||
except ImportError:
|
||||
logger.warning("[DailySummary] DownloaderHelper 不可用")
|
||||
return None
|
||||
|
||||
services = DownloaderHelper().get_services()
|
||||
if not services:
|
||||
return None
|
||||
|
||||
lines = ["【下载器概览】"]
|
||||
for name, svc in services.items():
|
||||
if not svc or not svc.instance:
|
||||
continue
|
||||
inst = svc.instance
|
||||
completed = inst.get_completed_torrents() or []
|
||||
downloading = inst.get_downloading_torrents() or []
|
||||
total = len(completed) + len(downloading)
|
||||
ti = inst.transfer_info()
|
||||
up_speed = ti.get("up_info_speed", 0) if ti else 0
|
||||
dl_speed = ti.get("dl_info_speed", 0) if ti else 0
|
||||
lines.append(f" {name}: 种子 {total} | ↑{_human_size(up_speed)}/s | ↓{_human_size(dl_speed)}/s")
|
||||
|
||||
return "\n".join(lines) if len(lines) > 1 else None
|
||||
|
||||
def _section_site_delta(self, tr: TimeRange) -> Optional[str]:
|
||||
with ScopedSession() as db:
|
||||
start_data = SiteUserData.get_by_date(db, tr.start_date)
|
||||
end_data = SiteUserData.get_by_date(db, tr.end_date)
|
||||
|
||||
if not start_data or not end_data:
|
||||
return None
|
||||
|
||||
start_map = {d.domain: d for d in start_data}
|
||||
end_map = {d.domain: d for d in end_data}
|
||||
|
||||
label = {"daily": "站点增量", "weekly": "站点周增量", "monthly": "站点月增量"}.get(tr.report_type, "站点增量")
|
||||
lines = [f"【{label}】", " 站点 ↑上传 ↓下载 魔力变化"]
|
||||
|
||||
has_data = False
|
||||
for domain, end in sorted(end_map.items(), key=lambda x: (x[1].upload or 0), reverse=True):
|
||||
start = start_map.get(domain)
|
||||
if not start:
|
||||
continue
|
||||
up_delta = (end.upload or 0) - (start.upload or 0)
|
||||
down_delta = (end.download or 0) - (start.download or 0)
|
||||
bonus_delta = (end.bonus or 0) - (start.bonus or 0)
|
||||
|
||||
no_change = up_delta == 0 and down_delta == 0 and bonus_delta == 0
|
||||
data_anomaly = up_delta < 0 or down_delta < 0
|
||||
if no_change or data_anomaly:
|
||||
continue
|
||||
|
||||
has_data = True
|
||||
name = (end.name or domain)[:6].ljust(6)
|
||||
bonus_str = f"+{bonus_delta:.0f}" if bonus_delta >= 0 else f"{bonus_delta:.0f}"
|
||||
lines.append(f" {name} {_human_size(up_delta):>10} {_human_size(down_delta):>10} {bonus_str:>8}")
|
||||
|
||||
return "\n".join(lines) if has_data else None
|
||||
|
||||
def _section_site_current(self, tr: TimeRange) -> Optional[str]:
|
||||
with ScopedSession() as db:
|
||||
data = SiteUserData.get_latest(db)
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
lines = ["【站点数据】", " 站点 总↑ 总↓ 分享率 魔力"]
|
||||
|
||||
for d in sorted(data, key=lambda x: (x.upload or 0), reverse=True):
|
||||
if not d.upload and not d.download:
|
||||
continue
|
||||
name = (d.name or d.domain)[:6].ljust(6)
|
||||
ratio = f"{d.ratio:.2f}" if d.ratio else "∞"
|
||||
bonus = f"{d.bonus:.0f}" if d.bonus else "0"
|
||||
lines.append(f" {name} {_human_size(d.upload or 0):>10} {_human_size(d.download or 0):>10} {ratio:>6} {bonus:>8}")
|
||||
|
||||
return "\n".join(lines) if len(lines) > 2 else None
|
||||
|
||||
def _section_subscribe(self, tr: TimeRange) -> str:
|
||||
subs = SubscribeOper().list(state="R") or []
|
||||
if not subs:
|
||||
return "【订阅进度】无活跃订阅"
|
||||
|
||||
lines = [f"【订阅进度】{len(subs)} 部进行中"]
|
||||
for s in subs:
|
||||
total = s.total_episode or 0
|
||||
lack = s.lack_episode or 0
|
||||
done = total - lack
|
||||
season = f" S{s.season}" if s.season else ""
|
||||
progress = f" {done}/{total}" if total > 0 else ""
|
||||
lines.append(f" • {s.name}{season}{progress}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _section_storage(self, tr: TimeRange) -> Optional[str]:
|
||||
volumes = self._parse_storage_paths()
|
||||
if not volumes:
|
||||
return None
|
||||
|
||||
lines = ["【存储空间】"]
|
||||
has_data = False
|
||||
for path, label in volumes:
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
stat = os.statvfs(path)
|
||||
total = stat.f_blocks * stat.f_frsize
|
||||
used = (stat.f_blocks - stat.f_bfree) * stat.f_frsize
|
||||
if total == 0:
|
||||
continue
|
||||
has_data = True
|
||||
pct = used / total * 100
|
||||
lines.append(f" {label}: 已用 {_human_size(used)} / {_human_size(total)} ({pct:.0f}%)")
|
||||
return "\n".join(lines) if has_data else None
|
||||
|
||||
def _parse_storage_paths(self) -> List[Tuple[str, str]]:
|
||||
"""自动检测 MP 的 LIBRARY_PATH / DOWNLOAD_PATH"""
|
||||
paths = []
|
||||
if hasattr(settings, "LIBRARY_PATH") and settings.LIBRARY_PATH:
|
||||
paths.append((settings.LIBRARY_PATH, "媒体库"))
|
||||
if hasattr(settings, "DOWNLOAD_PATH") and settings.DOWNLOAD_PATH:
|
||||
paths.append((settings.DOWNLOAD_PATH, "下载目录"))
|
||||
return paths
|
||||
|
||||
# ─── 数据查询 ───
|
||||
|
||||
def _get_downloads(self, since: str) -> list:
|
||||
try:
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
with ScopedSession() as db:
|
||||
return db.query(DownloadHistory).filter(
|
||||
DownloadHistory.date > since
|
||||
).order_by(DownloadHistory.date.desc()).all()
|
||||
except Exception as e:
|
||||
logger.error(f"[DailySummary] 查询下载记录失败: {e}")
|
||||
return []
|
||||
|
||||
def _get_transfers(self, since: str) -> list:
|
||||
try:
|
||||
return TransferHistoryOper().list_by_date(since) or []
|
||||
except Exception as e:
|
||||
logger.error(f"[DailySummary] 查询入库记录失败: {e}")
|
||||
return []
|
||||
|
||||
# ─── 发送通知 + 保存历史 ───
|
||||
|
||||
def _send(self, report_type: str, title: str, text: str):
|
||||
logger.info(f"[DailySummary] {title}\n{text}")
|
||||
|
||||
if self._notify:
|
||||
self.post_message(mtype=NotificationType.Plugin, title=title, text=text)
|
||||
|
||||
# 保存历史记录
|
||||
tz = pytz.timezone(settings.TZ)
|
||||
now = datetime.now(tz)
|
||||
record = {
|
||||
"time": now.strftime("%Y-%m-%d %H:%M"),
|
||||
"type": report_type,
|
||||
"title": title,
|
||||
"text": text,
|
||||
}
|
||||
history = self.get_data("history") or []
|
||||
history.append(record)
|
||||
# 保留最近 MAX_HISTORY 条
|
||||
if len(history) > MAX_HISTORY:
|
||||
history = history[-MAX_HISTORY:]
|
||||
self.save_data("history", history)
|
||||
|
||||
|
||||
# ─── 工具函数 ───
|
||||
|
||||
def _human_size(size_bytes: float) -> str:
|
||||
if size_bytes is None or size_bytes == 0:
|
||||
return "0 B"
|
||||
negative = size_bytes < 0
|
||||
size_bytes = abs(size_bytes)
|
||||
for unit in ["B", "KB", "MB", "GB", "TB", "PB"]:
|
||||
if size_bytes < 1024:
|
||||
formatted = f"{size_bytes:.1f} {unit}" if size_bytes != int(size_bytes) else f"{int(size_bytes)} {unit}"
|
||||
return f"-{formatted}" if negative else formatted
|
||||
size_bytes /= 1024
|
||||
return f"{size_bytes:.1f} EB"
|
||||
@@ -34,7 +34,7 @@ class ImdbSource(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "IMDb_IOS-OSX_App.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.6.6"
|
||||
plugin_version = "1.6.7"
|
||||
# 插件作者
|
||||
plugin_author = "wumode"
|
||||
# 作者主页
|
||||
@@ -223,7 +223,7 @@ class ImdbSource(_PluginBase):
|
||||
height = 335
|
||||
is_mobile = ImdbSource.is_mobile(kwargs.get('user_agent'))
|
||||
if is_mobile:
|
||||
height *= 1.75
|
||||
height *= 1.80
|
||||
# 全局配置
|
||||
attrs = {
|
||||
"border": False
|
||||
@@ -320,7 +320,7 @@ class ImdbSource(_PluginBase):
|
||||
'href': f"https://www.imdb.com/name/{cs.id}",
|
||||
'target': '_blank',
|
||||
'rel': 'noopener noreferrer',
|
||||
'class': 'text-h4 font-weight-bold mb-2 d-flex align-center',
|
||||
'class': 'text-h4 font-weight-bold mb-1 d-flex align-center',
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -344,7 +344,6 @@ class ImdbSource(_PluginBase):
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
'component': 'span',
|
||||
'props': {
|
||||
@@ -361,6 +360,7 @@ class ImdbSource(_PluginBase):
|
||||
poster_url = next((f"{title.primary_image.url if title.primary_image else ''}" for title in titles if
|
||||
title.id == entry.ttconst), None)
|
||||
poster_url = f"{self._img_proxy_prefix}{quote(poster_url or '')}"
|
||||
meter_ranking_url = imdb_title.meter_ranking.url if imdb_title.meter_ranking else None
|
||||
poster_com = {
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
@@ -375,9 +375,9 @@ class ImdbSource(_PluginBase):
|
||||
}
|
||||
|
||||
poster_ui = {
|
||||
'component': 'div',
|
||||
'component': 'VRow',
|
||||
'props': {
|
||||
'class': 'd-flex justify-center mt-2'
|
||||
'align': 'center'
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -394,11 +394,39 @@ class ImdbSource(_PluginBase):
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
meta_chips = [
|
||||
{
|
||||
"component": "VChip",
|
||||
"props": {
|
||||
"append-icon": "mdi-trending-up",
|
||||
"size": "small",
|
||||
"href": meter_ranking_url,
|
||||
"target": "_blank"
|
||||
},
|
||||
"text": imdb_title.meter_ranking_text
|
||||
},
|
||||
{
|
||||
"component": "VChip",
|
||||
"props": {
|
||||
"size": "small",
|
||||
},
|
||||
"text": imdb_title.title_type.text
|
||||
},
|
||||
]
|
||||
if imdb_title.certificate_text:
|
||||
meta_chips.append(
|
||||
{
|
||||
"component": "VChip",
|
||||
"props": {
|
||||
"size": "small"
|
||||
},
|
||||
"text": imdb_title.certificate_text
|
||||
}
|
||||
)
|
||||
rating_ui = {
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'mb-2 d-flex align-center',
|
||||
'class': 'd-flex align-center mb-1',
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -418,21 +446,21 @@ class ImdbSource(_PluginBase):
|
||||
{
|
||||
'component': 'span',
|
||||
'props': {
|
||||
'class': 'text-body-2 ml-1',
|
||||
'class': 'text-truncate text-body-2 ml-1',
|
||||
'style': 'color: rgba(231, 227, 252, 0.8);'
|
||||
},
|
||||
'text': f"{imdb_title.rating_text}/10",
|
||||
'text': f"{imdb_title.rating_text}",
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'span',
|
||||
'props': {
|
||||
'class': 'text-warning font-weight-bold ml-4',
|
||||
'class': 'text-truncate text-warning font-weight-bold ml-4',
|
||||
'color': 'warning'
|
||||
},
|
||||
'text': entry.detail,
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -455,7 +483,7 @@ class ImdbSource(_PluginBase):
|
||||
'component': 'span',
|
||||
'html': f"{entry.name}",
|
||||
'props': {
|
||||
'class': 'line-clamp-2 overflow-hidden',
|
||||
'class': 'text-truncate overflow-hidden',
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -468,6 +496,13 @@ class ImdbSource(_PluginBase):
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": 'div',
|
||||
"props": {
|
||||
"class": "d-flex align-center gap-1 mb-2",
|
||||
},
|
||||
"content": meta_chips
|
||||
},
|
||||
rating_ui,
|
||||
{
|
||||
'component': 'span',
|
||||
@@ -499,14 +534,16 @@ class ImdbSource(_PluginBase):
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'd-flex flex-row absolute pa-4 text-white',
|
||||
'class': 'd-flex flex-row absolute pa-4 text-white h-100',
|
||||
'style': 'z-index: 2; bottom: 0; max-width: 100%;',
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'props': {
|
||||
'class': 'w-100'
|
||||
'class': 'w-100',
|
||||
'align': "end",
|
||||
'align-md': "center"
|
||||
},
|
||||
'content': [
|
||||
# 左图:海报
|
||||
@@ -514,7 +551,8 @@ class ImdbSource(_PluginBase):
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
'md': 3,
|
||||
'class': 'd-flex justify-center align-center'
|
||||
},
|
||||
'content': [
|
||||
poster_ui
|
||||
@@ -1057,16 +1095,17 @@ class ImdbSource(_PluginBase):
|
||||
return res
|
||||
|
||||
async def imdb_discover(self, mtype: str = "series",
|
||||
country: str = None,
|
||||
lang: str = None,
|
||||
genre: str = None,
|
||||
country: str | None = None,
|
||||
lang: str | None = None,
|
||||
genre: str | None = None,
|
||||
company: str | None = None,
|
||||
sort_by: str = 'POPULARITY',
|
||||
sort_order: str = 'DESC',
|
||||
using_rating: bool = False,
|
||||
user_rating: list[int] = Query(None, alias="user_rating[]"),
|
||||
year: str = None,
|
||||
award: str = None,
|
||||
ranked_list: str = None,
|
||||
year: str | None = None,
|
||||
award: str | None = None,
|
||||
ranked_list: str | None = None,
|
||||
page: int = 1) -> List[schemas.MediaInfo]:
|
||||
|
||||
if not self._imdb_helper:
|
||||
@@ -1086,41 +1125,16 @@ class ImdbSource(_PluginBase):
|
||||
release_date_start = None
|
||||
release_date_end = None
|
||||
if year:
|
||||
if year == "2025":
|
||||
release_date_start = "2025-01-01"
|
||||
elif year == "2024":
|
||||
release_date_start = "2024-01-01"
|
||||
release_date_end = "2024-12-31"
|
||||
elif year == "2023":
|
||||
release_date_start = "2023-01-01"
|
||||
release_date_end = "2023-12-31"
|
||||
elif year == "2022":
|
||||
release_date_start = "2022-01-01"
|
||||
release_date_end = "2022-12-31"
|
||||
elif year == "2021":
|
||||
release_date_start = "2021-01-01"
|
||||
release_date_end = "2021-12-31"
|
||||
elif year == "2020":
|
||||
release_date_start = "2020-01-01"
|
||||
release_date_end = "2020-12-31"
|
||||
elif year == "2020s":
|
||||
release_date_start = "2020-01-01"
|
||||
release_date_end = "2029-12-31"
|
||||
elif year == "2010s":
|
||||
release_date_start = "2010-01-01"
|
||||
release_date_end = "2019-12-31"
|
||||
elif year == "2000s":
|
||||
release_date_start = "2000-01-01"
|
||||
release_date_end = "2009-12-31"
|
||||
elif year == "1990s":
|
||||
release_date_start = "1990-01-01"
|
||||
release_date_end = "1999-12-31"
|
||||
elif year == "1980s":
|
||||
release_date_start = "1980-01-01"
|
||||
release_date_end = "1989-12-31"
|
||||
elif year == "1970s":
|
||||
release_date_start = "1970-01-01"
|
||||
release_date_end = "1979-12-31"
|
||||
if year == f"{datetime.now().year}":
|
||||
release_date_start = f"{datetime.now().year}-01-01"
|
||||
elif year.endswith("s"):
|
||||
decade = int(year[:-2])
|
||||
release_date_start = f"{decade}0-01-01"
|
||||
release_date_end = f"{decade}9-12-31"
|
||||
else:
|
||||
release_date_start = f"{year}-01-01"
|
||||
release_date_end = f"{year}-12-31"
|
||||
|
||||
if not release_date_end:
|
||||
release_date_end = datetime.now().date().strftime("%Y-%m-%d")
|
||||
if sort_by == 'POPULARITY':
|
||||
@@ -1142,7 +1156,8 @@ class ImdbSource(_PluginBase):
|
||||
release_date_end=release_date_end,
|
||||
release_date_start=release_date_start,
|
||||
award_constraint=awards,
|
||||
ranked=ranked_lists
|
||||
ranked=ranked_lists,
|
||||
company=company
|
||||
)
|
||||
results = await self._imdb_helper.async_advanced_title_search(search_params, first_page=first_page)
|
||||
res: List[schemas.MediaInfo] = []
|
||||
@@ -1339,21 +1354,15 @@ class ImdbSource(_PluginBase):
|
||||
} for key, value in sort_order_dict.items()
|
||||
]
|
||||
|
||||
year_dict = {
|
||||
"2025": "2025",
|
||||
"2024": "2024",
|
||||
"2023": "2023",
|
||||
"2022": "2022",
|
||||
"2021": "2021",
|
||||
"2020": "2020",
|
||||
year_dict = {str(year): str(year) for year in range(datetime.now().year, 2019, -1)}
|
||||
year_dict.update({
|
||||
"2020s": "2020s",
|
||||
"2010s": "2010s",
|
||||
"2000s": "2000s",
|
||||
"1990s": "1990s",
|
||||
"1980s": "1980s",
|
||||
"1970s": "1970s",
|
||||
}
|
||||
|
||||
})
|
||||
year_ui = [
|
||||
{
|
||||
"component": "VChip",
|
||||
@@ -1394,12 +1403,12 @@ class ImdbSource(_PluginBase):
|
||||
]
|
||||
|
||||
ranked_list_dict = {
|
||||
"TOP_RATED_MOVIES-100": "IMDb Top 100",
|
||||
"TOP_RATED_MOVIES-250": "IMDb Top 250",
|
||||
"TOP_RATED_MOVIES-1000": "IMDb Top 1000",
|
||||
"LOWEST_RATED_MOVIES-100": "IMDb Bottom 100",
|
||||
"LOWEST_RATED_MOVIES-250": "IMDb Bottom 250",
|
||||
"LOWEST_RATED_MOVIES-1000": "IMDb Bottom 1000",
|
||||
"TOP_RATED_MOVIES-100": "Top 100",
|
||||
"TOP_RATED_MOVIES-250": "Top 250",
|
||||
"TOP_RATED_MOVIES-1000": "Top 1000",
|
||||
"LOWEST_RATED_MOVIES-100": "Bottom 100",
|
||||
"LOWEST_RATED_MOVIES-250": "Bottom 250",
|
||||
"LOWEST_RATED_MOVIES-1000": "Bottom 1000",
|
||||
}
|
||||
|
||||
ranked_list_ui = [
|
||||
@@ -1414,6 +1423,41 @@ class ImdbSource(_PluginBase):
|
||||
} for key, value in ranked_list_dict.items()
|
||||
]
|
||||
|
||||
companies = {
|
||||
"20th Century Fox": "20世纪福克斯",
|
||||
"DreamWorks": "梦工厂",
|
||||
"MGM": "米高梅",
|
||||
"Paramount": "派拉蒙",
|
||||
"Sony": "索尼",
|
||||
"Universal": "环球",
|
||||
"Walt Disney": "迪士尼",
|
||||
"Warner Bros.": "华纳兄弟",
|
||||
"HBO": "HBO",
|
||||
"Netflix": "Netflix",
|
||||
"Hulu": "Hulu",
|
||||
"Amazon Prime Video": "Amazon Prime",
|
||||
"Apple TV": "Apple TV",
|
||||
"British Broadcasting Corporation (BBC)": "BBC",
|
||||
"Tencent Video": "腾讯视频",
|
||||
"Youku": "优酷",
|
||||
"iQIYI": "爱奇艺",
|
||||
"China Central Television (CCTV)": "CCTV",
|
||||
"Huayi Brothers Media": "华谊兄弟",
|
||||
"Beijing Enlight Pictures": "光线传媒",
|
||||
"Bona Film Group": "博纳影业",
|
||||
}
|
||||
companies_ui = [
|
||||
{
|
||||
"component": "VChip",
|
||||
"props": {
|
||||
"filter": True,
|
||||
"tile": True,
|
||||
"value": key
|
||||
},
|
||||
"text": value
|
||||
} for key, value in companies.items()
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
"component": "div",
|
||||
@@ -1596,6 +1640,33 @@ class ImdbSource(_PluginBase):
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "div",
|
||||
"props": {
|
||||
"class": "flex justify-start items-center",
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "div",
|
||||
"props": {
|
||||
"class": "mr-5"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VLabel",
|
||||
"text": "出品方"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VChipGroup",
|
||||
"props": {
|
||||
"model": "company"
|
||||
},
|
||||
"content": companies_ui
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "div",
|
||||
"props": {
|
||||
@@ -1750,7 +1821,7 @@ class ImdbSource(_PluginBase):
|
||||
"user_rating": [1, 10],
|
||||
"using_rating": False,
|
||||
"award": None,
|
||||
"ranked_list": None
|
||||
"ranked_list": None,
|
||||
},
|
||||
depends={
|
||||
"ranked_list": ["mtype"]
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.utils.http import RequestUtils, AsyncRequestUtils
|
||||
from .schema.imdbapi import ImdbApiTitle, ImdbApiEpisode, ImdbApiCredit, ImdbapiImage
|
||||
from .schema.imdbapi import (ImdbApiSearchTitlesResponse, ImdbApiListTitlesResponse, ImdbApiListTitleEpisodesResponse,
|
||||
ImdbApiListTitleSeasonsResponse, ImdbApiListTitleCreditsResponse,
|
||||
ImdbapiListTitleAKAsResponse, ImdbApiTitleImagesResponse)
|
||||
ImdbapiListTitleAKAsResponse, ImdbApiTitleImagesResponse, ImdbapiCompanyCreditResponse)
|
||||
from .schema.imdbtypes import ImdbType
|
||||
|
||||
|
||||
@@ -769,3 +769,24 @@ class ImdbApiClient:
|
||||
page_token = response.next_page_token
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
async def company_credits(self, title_id: str, categories: list[str] | None = None
|
||||
) -> Optional[ImdbapiCompanyCreditResponse]:
|
||||
"""
|
||||
Retrieve the company credits associated with a specific title.
|
||||
|
||||
:param title_id: Required. IMDb title ID in the format "tt1234567".
|
||||
:param categories: Optional. The categories of company credit to filter by.
|
||||
:return: Company Credits.
|
||||
"""
|
||||
path = "/titles/%s/companyCredits"
|
||||
param: dict[str, Any] = {}
|
||||
if categories:
|
||||
param['categories'] = categories
|
||||
try:
|
||||
r = await self._async_free_imdb_api(path=path % title_id, params=param)
|
||||
ret = ImdbapiCompanyCreditResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving company credits: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
@@ -113,7 +113,7 @@ class ImdbHelper:
|
||||
logger.error("Error getting staff picks")
|
||||
return None
|
||||
try:
|
||||
data = StaffPickApiResponse.model_validate_json(res)
|
||||
data = StaffPickApiResponse.model_validate_json(res, by_name=True)
|
||||
except (JSONDecodeError, ValidationError):
|
||||
return None
|
||||
return data
|
||||
@@ -210,7 +210,8 @@ class ImdbHelper:
|
||||
return key
|
||||
return ""
|
||||
|
||||
async def advanced_title_search_generator(self, params: SearchParams, first_page: bool = True) -> AsyncGenerator[TitleEdge, None]:
|
||||
async def advanced_title_search_generator(self, params: SearchParams, first_page: bool = True
|
||||
) -> AsyncGenerator[TitleEdge, None]:
|
||||
await self._async_update_hash()
|
||||
sha256 = self._imdb_api_hash.advanced_title_search
|
||||
if not first_page and params in self._title_generators:
|
||||
@@ -253,7 +254,7 @@ class ImdbHelper:
|
||||
seasons_dict[s] = episode.release_date
|
||||
return seasons_dict
|
||||
|
||||
def match_by(self, name: str, mtype: Optional[MediaType] = None, year: Optional[str] = None) -> Optional[ImdbMediaInfo]:
|
||||
def match_by(self, name: str, mtype: MediaType | None = None, year: str | None = None) -> ImdbMediaInfo | None:
|
||||
"""
|
||||
根据名称同时查询电影和电视剧,没有类型也没有年份时使用
|
||||
|
||||
|
||||
@@ -275,10 +275,35 @@ INTERESTS_ID: Final[Dict[str, Dict[str, str]]] = {
|
||||
"Western Epic": "in0000189"
|
||||
}
|
||||
}
|
||||
|
||||
COMPANY_ID = {
|
||||
"20th Century Fox": ["co0000756", "co0176225", "co0201557", "co0017497"],
|
||||
"DreamWorks": ["co0067641", "co0040938", "co0252576", "co0003158"],
|
||||
"MGM": ["co0007143", "co0026841"],
|
||||
"Paramount": ["co0023400"],
|
||||
"Sony": ["co0050868", "co0026545", "co0121181"],
|
||||
"Universal": ["co0005073", "co0055277", "co0042399"],
|
||||
"Walt Disney": ["co0008970", "co0017902", "co0098836", "co0059516", "co0092035", "co0049348"],
|
||||
"Warner Bros.": ["co0002663", "co0005035", "co0863266", "co0072876", "co0080422", "co0046718"],
|
||||
"HBO": ["co0008693", "co0754095", "co0306346", "co0148466", "co0909975", "co0638197", "co0391378"],
|
||||
"Netflix": ["co0144901", "co0805756"],
|
||||
"Hulu": ["co0218858", "co0381648"],
|
||||
"Amazon Prime Video": ["co0476953", "co1160313", "co0939864", "co0931938"],
|
||||
"Apple TV": ["co0931939", "co0546168"],
|
||||
"British Broadcasting Corporation (BBC)": ['co0043107'],
|
||||
"Tencent Video": ["co0487058"],
|
||||
"Youku": ["co0264223"],
|
||||
"iQIYI": ["co0493506", "co0691262"],
|
||||
"China Central Television (CCTV)": ['co0001524'],
|
||||
"Huayi Brothers Media": ["co0099734"],
|
||||
"Beijing Enlight Pictures": ["co0208796"],
|
||||
"Bona Film Group": ["co0452101"],
|
||||
}
|
||||
|
||||
CACHE_LIFETIME: Final[int] = 86400
|
||||
IMDB_GRAPHQL_QUERY: Final[str] = dedent("""
|
||||
query VerticalListPageItems( $titles: [ID!]! $names: [ID!]! $images: [ID!]! $videos: [ID!]!) {
|
||||
titles(ids: $titles) { ...TitleParts meterRanking { currentRank meterType rankChange {changeDirection difference} } ratingsSummary { aggregateRating } }
|
||||
titles(ids: $titles) { ...TitleParts meterRanking { currentRank meterType rankChange {changeDirection difference} } ratingsSummary { aggregateRating voteCount} }
|
||||
names(ids: $names) { ...NameParts }
|
||||
videos(ids: $videos) { ...VideoParts }
|
||||
images(ids: $images) { ...ImageParts }
|
||||
@@ -500,6 +525,12 @@ class OfficialApiClient:
|
||||
if in_id:
|
||||
constraints.append(in_id)
|
||||
variables["interestConstraint"] = {"allInterestIds": constraints, "excludeInterestIds": []}
|
||||
|
||||
if params.company:
|
||||
company_ids = COMPANY_ID.get(params.company)
|
||||
if company_ids:
|
||||
variables["creditedCompanyConstraint"] = {"anyCompanyIds": company_ids, "excludeCompanyIds": []}
|
||||
|
||||
if last_cursor:
|
||||
variables["after"] = last_cursor
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ class ErrorType(Enum):
|
||||
|
||||
class StaffPickEntry(BaseModel):
|
||||
name: str
|
||||
ttconst: str
|
||||
ttconst: str = Field(..., alias='id')
|
||||
rmconst: str
|
||||
detail: Optional[str] = ""
|
||||
description: Optional[str] = ""
|
||||
relatedconst: List[str] = Field(default_factory=list)
|
||||
relatedconst: List[str] = Field(default_factory=list, alias='relatedConst')
|
||||
viconst: Optional[str] = None
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ class SearchParams(BaseModel):
|
||||
award_constraint: Optional[Tuple[str, ...]] = None
|
||||
ranked: Optional[Tuple[str, ...]] = None
|
||||
interests: Optional[Tuple[str, ...]] = None
|
||||
company: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=True
|
||||
|
||||
@@ -154,3 +154,20 @@ class ImdbapiListTitleAKAsResponse(BaseModel):
|
||||
|
||||
class ImdbApiTitleImagesResponse(PagedResponse):
|
||||
images: List[ImdbapiImage] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ImdbapiCompany(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class ImdbapiCompanyCredit(BaseModel):
|
||||
company: ImdbapiCompany
|
||||
category: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Category of the company credit, such as production, sales, distribution, etc."
|
||||
)
|
||||
|
||||
|
||||
class ImdbapiCompanyCreditResponse(PagedResponse):
|
||||
company_credits: List[ImdbapiCompanyCredit] = Field(default_factory=list, alias='companyCredits')
|
||||
|
||||
@@ -4,6 +4,15 @@ from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
def format_number(n: int) -> str:
|
||||
units = ["", "K", "M", "B", "T"]
|
||||
idx = 0
|
||||
while n >= 1000 and idx < len(units) - 1:
|
||||
n //= 1000
|
||||
idx += 1
|
||||
return f"{n}{units[idx]}"
|
||||
|
||||
|
||||
class ImdbType(Enum):
|
||||
TV_SERIES = "tvSeries"
|
||||
TV_MINI_SERIES = "tvMiniSeries"
|
||||
@@ -23,6 +32,25 @@ class ImdbType(Enum):
|
||||
class TitleType(BaseModel):
|
||||
id: ImdbType
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
type_mapping = {
|
||||
ImdbType.TV_SERIES: "TV Series",
|
||||
ImdbType.TV_MINI_SERIES: "TV Mini Series",
|
||||
ImdbType.MOVIE: "Movie",
|
||||
ImdbType.TV_MOVIE: "TV Movie",
|
||||
ImdbType.MUSIC_VIDEO: "Music Video",
|
||||
ImdbType.TV_SHORT: "TV Short",
|
||||
ImdbType.SHORT: "Short",
|
||||
ImdbType.TV_EPISODE: "TV Episode",
|
||||
ImdbType.TV_SPECIAL: "TV Special",
|
||||
ImdbType.VIDEO_GAME: "Video Game",
|
||||
ImdbType.VIDEO: "Video",
|
||||
ImdbType.PODCAST_SERIES: "Podcast Series",
|
||||
ImdbType.PODCAST_EPISODE: "Podcast Episode",
|
||||
}
|
||||
return type_mapping.get(self.id, "Unknown")
|
||||
|
||||
|
||||
class ReleaseYear(BaseModel):
|
||||
year: Optional[int] = None
|
||||
@@ -89,6 +117,23 @@ class MeterRanking(BaseModel):
|
||||
meter_type: Optional[str] = Field(default=None, alias='meterType')
|
||||
rank_change: Optional[RankChange] = Field(default=None, alias='rankChange')
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
if self.current_rank:
|
||||
rank = self.current_rank
|
||||
meter_rank = ""
|
||||
if self.meter_type:
|
||||
meter_rank = self.meter_type.replace("_", "").replace("METER", "Meter")
|
||||
meter_rank = f" {meter_rank}"
|
||||
return f"#{rank}{meter_rank}"
|
||||
return ""
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
if self.current_rank and self.meter_type:
|
||||
return f"https://www.imdb.com/chart/{self.meter_type.replace("_", "").lower()}/"
|
||||
return ""
|
||||
|
||||
|
||||
class RatingsSummary(BaseModel):
|
||||
aggregate_rating: Optional[float] = Field(default=None, alias='aggregateRating')
|
||||
@@ -154,8 +199,24 @@ class ImdbTitle(BaseModel):
|
||||
@property
|
||||
def rating_text(self) -> str:
|
||||
if self.ratings_summary and self.ratings_summary.aggregate_rating:
|
||||
return f"{self.ratings_summary.aggregate_rating:.1f}"
|
||||
return "-"
|
||||
votes = ""
|
||||
if self.ratings_summary.vote_count:
|
||||
votes = f" ({format_number(self.ratings_summary.vote_count)})"
|
||||
return f"{self.ratings_summary.aggregate_rating:.1f}{votes}"
|
||||
return "-/10"
|
||||
|
||||
@property
|
||||
def meter_ranking_text(self) -> str:
|
||||
if self.meter_ranking and self.meter_ranking.current_rank:
|
||||
return self.meter_ranking.text
|
||||
return ""
|
||||
|
||||
@property
|
||||
def certificate_text(self) -> str:
|
||||
if self.certificate and self.certificate.rating:
|
||||
return self.certificate.rating
|
||||
return ""
|
||||
|
||||
|
||||
class Thumbnail(BaseModel):
|
||||
url: str
|
||||
|
||||
@@ -60,7 +60,7 @@ class LexiAnnot(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "LexiAnnot.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.2.3"
|
||||
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.",
|
||||
@@ -509,10 +509,6 @@ Your goal is two-fold:
|
||||
* **Do NOT include** simple high-frequency words, common fillers ('gonna', 'gotta'), onomatopoeia, or basic swear words.
|
||||
|
||||
-------------------------
|
||||
You MUST return output strictly matching the provided Pydantic schema.
|
||||
Return ONLY valid JSON.
|
||||
|
||||
**Here are the output format instructions you MUST follow strictly:**
|
||||
{format_instructions}
|
||||
""",
|
||||
),
|
||||
@@ -556,10 +552,6 @@ For each word (identified by `WORD_ID`), provide:
|
||||
**Your judgment should be based strictly on the provided subtitle context. DO NOT fabricate context or forced explanation.**
|
||||
|
||||
-------------------------
|
||||
You MUST return output strictly matching the provided Pydantic schema.
|
||||
Return ONLY valid JSON.
|
||||
|
||||
**Here are the output format instructions you MUST follow strictly:**
|
||||
{format_instructions}
|
||||
""",
|
||||
),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import re
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from enum import Enum
|
||||
from enum import Enum, StrEnum
|
||||
from typing import Literal, Generator, Iterator
|
||||
|
||||
from pydantic import BaseModel, Field, RootModel, model_validator
|
||||
from pydantic import BaseModel, Field, RootModel, model_validator, field_validator
|
||||
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
@@ -12,9 +12,8 @@ from app.utils.singleton import Singleton
|
||||
Cefr = Literal["C2", "C1", "B2", "B1", "A2", "A1"]
|
||||
|
||||
|
||||
class UniversalPos(str, Enum):
|
||||
class UniversalPos(StrEnum):
|
||||
"""Universal Part-of-Speech tags"""
|
||||
|
||||
ADJ = "ADJ" # Adjective
|
||||
ADV = "ADV" # Adverb
|
||||
INTJ = "INTJ" # Interjection
|
||||
@@ -34,9 +33,8 @@ class UniversalPos(str, Enum):
|
||||
X = "X" # Other/unknown
|
||||
|
||||
|
||||
class LexicalFeatures(str, Enum):
|
||||
class LexicalFeatures(StrEnum):
|
||||
"""Lexical features for words."""
|
||||
|
||||
FORMAL = "formal"
|
||||
INFORMAL = "informal"
|
||||
SLANG = "slang"
|
||||
@@ -333,6 +331,14 @@ class LlmWordEnrichment(BaseModel):
|
||||
usage_context: str | None = Field(default=None, description="Usage or Cultural Context")
|
||||
lexical_features: list[LexicalFeatures] = Field(default_factory=list, description="Lexical features")
|
||||
|
||||
@field_validator("lexical_features", mode="before")
|
||||
@classmethod
|
||||
def filter_invalid_lexical_features(cls, v):
|
||||
if isinstance(v, list):
|
||||
valid_values = {f.value for f in LexicalFeatures}
|
||||
return [item for item in v if item in valid_values]
|
||||
return v
|
||||
|
||||
|
||||
class LlmEnrichmentResult(BaseModel):
|
||||
enriched_words: list[LlmWordEnrichment] = Field(default_factory=list, description="List of enriched word data")
|
||||
|
||||
@@ -29,6 +29,7 @@ class MediaServerMsg(_PluginBase):
|
||||
# 常量定义
|
||||
DEFAULT_EXPIRATION_TIME = 600 # 默认过期时间(秒)
|
||||
DEFAULT_AGGREGATE_TIME = 15 # 默认聚合时间(秒)
|
||||
DEDUPE_EXPIRATION_TIME = 30 # 去重缓存过期时间(秒)
|
||||
|
||||
# 插件基本信息
|
||||
plugin_name = "媒体库服务器通知"
|
||||
@@ -37,7 +38,7 @@ class MediaServerMsg(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "mediaplay.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.8.2.1"
|
||||
plugin_version = "1.8.2.2"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
@@ -59,7 +60,8 @@ class MediaServerMsg(_PluginBase):
|
||||
|
||||
# TV剧集消息聚合配置
|
||||
_aggregate_time = DEFAULT_AGGREGATE_TIME # 聚合时间窗口(秒)
|
||||
_pending_messages = {} # 待聚合的消息 {series_key: [event_info, ...]}
|
||||
# 待聚合的消息 {series_key: [event_info, ...]}
|
||||
_pending_messages = {}
|
||||
_aggregate_timers = {} # 聚合定时器 {series_key: timer}
|
||||
|
||||
# Webhook事件映射配置
|
||||
@@ -102,8 +104,8 @@ class MediaServerMsg(_PluginBase):
|
||||
self._mediaservers = config.get("mediaservers") or []
|
||||
self._add_play_link = config.get("add_play_link", False)
|
||||
self._aggregate_enabled = config.get("aggregate_enabled", False)
|
||||
self._aggregate_time = int(config.get("aggregate_time", self.DEFAULT_AGGREGATE_TIME))
|
||||
|
||||
self._aggregate_time = int(config.get(
|
||||
"aggregate_time", self.DEFAULT_AGGREGATE_TIME))
|
||||
|
||||
def service_infos(self, type_filter: Optional[str] = None) -> Optional[Dict[str, ServiceInfo]]:
|
||||
"""
|
||||
@@ -119,7 +121,8 @@ class MediaServerMsg(_PluginBase):
|
||||
logger.warning("尚未配置媒体服务器,请检查配置")
|
||||
return None
|
||||
|
||||
services = MediaServerHelper().get_services(type_filter=type_filter, name_filters=self._mediaservers)
|
||||
services = MediaServerHelper().get_services(
|
||||
type_filter=type_filter, name_filters=self._mediaservers)
|
||||
if not services:
|
||||
logger.warning("获取媒体服务器实例失败,请检查配置")
|
||||
return None
|
||||
@@ -454,6 +457,18 @@ class MediaServerMsg(_PluginBase):
|
||||
logger.info(f"未开启媒体服务器类型 {channel} 的消息通知")
|
||||
return
|
||||
|
||||
# 通用去重:构造去重键
|
||||
item_id = getattr(event_info, 'item_id', '')
|
||||
if item_id:
|
||||
# 使用 server_name + event_type + item_id 作为唯一标识
|
||||
dedupe_key = f"{server_name}-{event_type}-{item_id}" if server_name else f"{event_type}-{item_id}"
|
||||
# 检查是否已处理过该事件
|
||||
if dedupe_key in self.__get_elements():
|
||||
logger.debug(f"检测到重复Webhook事件,已处理过: {dedupe_key}")
|
||||
return
|
||||
# 添加到去重缓存(30秒过期)
|
||||
self.__add_element(dedupe_key, duration=self.DEDUPE_EXPIRATION_TIME)
|
||||
|
||||
# TV剧集结入库聚合处理
|
||||
logger.debug("检查是否需要进行TV剧集聚合处理")
|
||||
|
||||
@@ -549,7 +564,8 @@ class MediaServerMsg(_PluginBase):
|
||||
if overview:
|
||||
message_texts.append(f"剧情:{overview}")
|
||||
|
||||
message_texts.append(f"时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}")
|
||||
message_texts.append(
|
||||
f"时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}")
|
||||
|
||||
# 消息内容
|
||||
message_content = "\n".join(message_texts)
|
||||
@@ -684,7 +700,8 @@ class MediaServerMsg(_PluginBase):
|
||||
# 设置新的定时器
|
||||
logger.debug(f"设置新的定时器,将在 {self._aggregate_time} 秒后触发")
|
||||
try:
|
||||
timer = threading.Timer(self._aggregate_time, self._send_aggregated_message, [series_id])
|
||||
timer = threading.Timer(
|
||||
self._aggregate_time, self._send_aggregated_message, [series_id])
|
||||
self._aggregate_timers[series_id] = timer
|
||||
timer.start()
|
||||
except Exception as e:
|
||||
@@ -692,7 +709,8 @@ class MediaServerMsg(_PluginBase):
|
||||
# 如果定时器设置失败,直接发送消息
|
||||
self._send_aggregated_message(series_id)
|
||||
|
||||
logger.debug(f"已添加剧集 {series_id} 的消息到聚合队列,当前队列长度: {len(self._pending_messages.get(series_id, []))},定时器将在 {self._aggregate_time} 秒后触发")
|
||||
logger.debug(
|
||||
f"已添加剧集 {series_id} 的消息到聚合队列,当前队列长度: {len(self._pending_messages.get(series_id, []))},定时器将在 {self._aggregate_time} 秒后触发")
|
||||
logger.debug(f"完成聚合处理: series_id={series_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"聚合处理过程中出现异常: {str(e)}", exc_info=True)
|
||||
@@ -804,7 +822,8 @@ class MediaServerMsg(_PluginBase):
|
||||
# 确保索引在有效范围内
|
||||
if 0 <= ep_index < len(episodes):
|
||||
episode_info = episodes[ep_index]
|
||||
episode_overview = episode_info.get('overview', '')
|
||||
episode_overview = episode_info.get(
|
||||
'overview', '')
|
||||
|
||||
# 如果该集的概述存在且非空,则返回该集概述
|
||||
if episode_overview:
|
||||
@@ -824,7 +843,8 @@ class MediaServerMsg(_PluginBase):
|
||||
# 使用原有逻辑构造消息
|
||||
message_title = f"📺 {self._webhook_actions.get(first_event.event)}剧集:{first_event.item_name}"
|
||||
message_texts = []
|
||||
message_texts.append(f"⏰ 时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}")
|
||||
message_texts.append(
|
||||
f"⏰ 时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}")
|
||||
|
||||
# 收集集数信息
|
||||
episode_details = []
|
||||
@@ -832,17 +852,20 @@ class MediaServerMsg(_PluginBase):
|
||||
if (hasattr(event, 'season_id') and event.season_id is not None and
|
||||
hasattr(event, 'episode_id') and event.episode_id is not None):
|
||||
try:
|
||||
episode_details.append(f"S{int(event.season_id):02d}E{int(event.episode_id):02d}")
|
||||
episode_details.append(
|
||||
f"S{int(event.season_id):02d}E{int(event.episode_id):02d}")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if episode_details:
|
||||
message_texts.append(f"📺 季集:{', '.join(episode_details)}")
|
||||
message_texts.append(
|
||||
f"📺 季集:{', '.join(episode_details)}")
|
||||
|
||||
message_content = "\n".join(message_texts)
|
||||
|
||||
# 使用默认图片
|
||||
image_url = getattr(first_event, 'image_url', None) or self._webhook_images.get(getattr(first_event, 'channel', ''))
|
||||
image_url = getattr(first_event, 'image_url', None) or self._webhook_images.get(
|
||||
getattr(first_event, 'channel', ''))
|
||||
|
||||
# 处理播放链接
|
||||
play_link = None
|
||||
@@ -868,7 +891,8 @@ class MediaServerMsg(_PluginBase):
|
||||
except Exception as e:
|
||||
logger.error(f"获取TMDB信息时出错: {str(e)}")
|
||||
|
||||
overview = safe_get_overview(tmdb_info, first_event, is_multiple_episodes)
|
||||
overview = safe_get_overview(
|
||||
tmdb_info, first_event, is_multiple_episodes)
|
||||
|
||||
# 消息标题
|
||||
show_name = first_event.item_name
|
||||
@@ -894,7 +918,8 @@ class MediaServerMsg(_PluginBase):
|
||||
# 消息内容
|
||||
message_texts = []
|
||||
# 时间信息放在最前面
|
||||
message_texts.append(f"⏰ 时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}")
|
||||
message_texts.append(
|
||||
f"⏰ 时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}")
|
||||
# 添加每个集数的信息并合并连续集数
|
||||
episodes_detail = self._merge_continuous_episodes(events)
|
||||
message_texts.append(f"📺 季集:{episodes_detail}")
|
||||
@@ -993,7 +1018,8 @@ class MediaServerMsg(_PluginBase):
|
||||
play_link = self._get_play_link(first_event)
|
||||
|
||||
# 发送聚合消息
|
||||
logger.debug(f"准备发送消息 - 标题: {message_title}, 内容: {message_content}, 图片: {image_url}")
|
||||
logger.debug(
|
||||
f"准备发送消息 - 标题: {message_title}, 内容: {message_content}, 图片: {image_url}")
|
||||
self.post_message(mtype=NotificationType.MediaServer,
|
||||
title=message_title, text=message_content, image=image_url, link=play_link)
|
||||
|
||||
@@ -1105,26 +1131,31 @@ class MediaServerMsg(_PluginBase):
|
||||
else:
|
||||
# 保存当前区间
|
||||
if start == end:
|
||||
merged_details.append(f"S{season:02d}E{start:02d} {episode_names[0]}")
|
||||
merged_details.append(
|
||||
f"S{season:02d}E{start:02d} {episode_names[0]}")
|
||||
else:
|
||||
# 合并区间
|
||||
merged_details.append(f"S{season:02d}E{start:02d}-E{end:02d}")
|
||||
merged_details.append(
|
||||
f"S{season:02d}E{start:02d}-E{end:02d}")
|
||||
# 开始新区间
|
||||
start = end = current
|
||||
episode_names = [episodes[i]["name"]]
|
||||
|
||||
# 添加最后一个区间
|
||||
if start == end:
|
||||
merged_details.append(f"S{season:02d}E{start:02d} {episode_names[-1] if episode_names else ''}")
|
||||
merged_details.append(
|
||||
f"S{season:02d}E{start:02d} {episode_names[-1] if episode_names else ''}")
|
||||
else:
|
||||
merged_details.append(f"S{season:02d}E{start:02d}-E{end:02d}")
|
||||
merged_details.append(
|
||||
f"S{season:02d}E{start:02d}-E{end:02d}")
|
||||
except Exception as e:
|
||||
logger.error(f"合并集数信息时出错: {str(e)}")
|
||||
# 出错时返回简单的集数列表
|
||||
simple_details = []
|
||||
for season in sorted(season_episodes.keys()):
|
||||
for episode_info in season_episodes[season]:
|
||||
simple_details.append(f"S{season:02d}E{episode_info['episode']:02d}")
|
||||
simple_details.append(
|
||||
f"S{season:02d}E{episode_info['episode']:02d}")
|
||||
return ", ".join(simple_details)
|
||||
|
||||
return ", ".join(merged_details)
|
||||
@@ -1148,7 +1179,8 @@ class MediaServerMsg(_PluginBase):
|
||||
Args:
|
||||
key (str): 要移除的元素键值
|
||||
"""
|
||||
self._webhook_msg_keys = {k: v for k, v in self._webhook_msg_keys.items() if k != key}
|
||||
self._webhook_msg_keys = {
|
||||
k: v for k, v in self._webhook_msg_keys.items() if k != key}
|
||||
|
||||
def __get_elements(self):
|
||||
"""
|
||||
@@ -1250,11 +1282,11 @@ class MediaServerMsg(_PluginBase):
|
||||
if mtype == MediaType.MOVIE:
|
||||
return self.chain.tmdb_info(tmdbid=tmdb_id, mtype=mtype)
|
||||
else: # TV类型
|
||||
tmdb_info = self.chain.tmdb_info(tmdbid=tmdb_id, mtype=mtype, season=season)
|
||||
tmdb_info = self.chain.tmdb_info(
|
||||
tmdbid=tmdb_id, mtype=mtype, season=season)
|
||||
tmdb_info2 = self.chain.tmdb_info(tmdbid=tmdb_id, mtype=mtype)
|
||||
return tmdb_info | tmdb_info2
|
||||
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件时的清理工作
|
||||
|
||||
@@ -23,7 +23,7 @@ class MoviePilotUpdateNotify(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "Moviepilot_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.3"
|
||||
plugin_version = "2.3.1"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
@@ -83,7 +83,7 @@ class MoviePilotUpdateNotify(_PluginBase):
|
||||
|
||||
# 本地版本
|
||||
local_version = SystemChain().get_server_local_version()
|
||||
if local_version and release_version <= local_version:
|
||||
if local_version and list(map(int, re.findall(r'\d+', release_version))) <= list(map(int, re.findall(r'\d+', local_version))):
|
||||
logger.info(f"当前后端版本:{local_version} 远程版本:{release_version} 停止运行")
|
||||
return False
|
||||
|
||||
@@ -108,7 +108,7 @@ class MoviePilotUpdateNotify(_PluginBase):
|
||||
|
||||
# 本地版本
|
||||
local_version = SystemChain().get_frontend_version()
|
||||
if local_version and release_version <= local_version:
|
||||
if local_version and list(map(int, re.findall(r'\d+', release_version))) <= list(map(int, re.findall(r'\d+', local_version))):
|
||||
logger.info(f"当前前端版本:{local_version} 远程版本:{release_version} 停止运行")
|
||||
return False
|
||||
|
||||
|
||||
@@ -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
1
plugins.v2/torrenttransfer/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
fast-bencode~=1.1.7
|
||||
844
plugins.v2/tvfirstwatch/__init__.py
Normal file
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
1
plugins.v2/tvfirstwatch/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
feedparser>=6.0.0
|
||||
2119
plugins.v2/wechatclawbot/__init__.py
Normal file
2119
plugins.v2/wechatclawbot/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
3
plugins.v2/wechatclawbot/ilink/__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
1158
plugins.v2/wechatclawbot/ilink/client.py
Normal file
File diff suppressed because it is too large
Load Diff
623
plugins/alidnsddns/__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
|
||||
@@ -66,7 +66,7 @@ class AutoSubv2(_PluginBase):
|
||||
# 主题色
|
||||
plugin_color = "#2C4F7E"
|
||||
# 插件版本
|
||||
plugin_version = "2.3"
|
||||
plugin_version = "2.5.1"
|
||||
# 插件作者
|
||||
plugin_author = "TimoYoung"
|
||||
# 作者主页
|
||||
|
||||
@@ -2,3 +2,5 @@ iso639~=0.1.4
|
||||
srt~=3.5.3
|
||||
python-dotenv~=1.0.1
|
||||
faster-whisper~=1.0.1
|
||||
cacheout~=0.16.0
|
||||
|
||||
|
||||
@@ -17,10 +17,16 @@ class OpenAi:
|
||||
compatible: bool = False):
|
||||
self._api_key = api_key
|
||||
self._api_url = api_url
|
||||
openai.api_base = self._api_url if compatible else self._api_url + "/v1"
|
||||
openai.api_key = self._api_key
|
||||
base_url = self._api_url if compatible else self._api_url + "/v1"
|
||||
|
||||
# 创建 OpenAI 客户端实例
|
||||
if proxy and proxy.get("https"):
|
||||
openai.proxy = proxy.get("https")
|
||||
import httpx
|
||||
http_client = httpx.Client(proxies=proxy.get("https"))
|
||||
self.client = openai.OpenAI(api_key=self._api_key, base_url=base_url, http_client=http_client)
|
||||
else:
|
||||
self.client = openai.OpenAI(api_key=self._api_key, base_url=base_url)
|
||||
|
||||
if model:
|
||||
self._model = model
|
||||
|
||||
@@ -92,7 +98,7 @@ class OpenAi:
|
||||
"content": message
|
||||
}
|
||||
]
|
||||
return openai.ChatCompletion.create(
|
||||
return self.client.chat.completions.create(
|
||||
model=self._model,
|
||||
user=user,
|
||||
messages=message,
|
||||
|
||||
@@ -32,7 +32,7 @@ class CloudflareSpeedTest(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "cloudflare.jpg"
|
||||
# 插件版本
|
||||
plugin_version = "1.5"
|
||||
plugin_version = "1.5.1"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
|
||||
1
plugins/cloudflarespeedtest/requirements.txt
Normal file
1
plugins/cloudflarespeedtest/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
python-hosts~=1.1.2
|
||||
@@ -18,7 +18,7 @@ class CustomHosts(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "hosts.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.2"
|
||||
plugin_version = "1.2.1"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
|
||||
1
plugins/customhosts/requirements.txt
Normal file
1
plugins/customhosts/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
python-hosts~=1.1.2
|
||||
@@ -13,6 +13,7 @@ from typing import Any, List, Dict, Tuple, Optional
|
||||
from app.log import logger
|
||||
from app.schemas import NotificationType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.helper.browser import PlaywrightHelper
|
||||
|
||||
|
||||
class InvitesSignin(_PluginBase):
|
||||
@@ -23,7 +24,7 @@ class InvitesSignin(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "invites.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.0.2"
|
||||
plugin_version = "2.0.3"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
@@ -42,8 +43,9 @@ class InvitesSignin(_PluginBase):
|
||||
_cookie = None
|
||||
_onlyonce = False
|
||||
_notify = False
|
||||
# 代理相关
|
||||
# 代理 / 浏览器仿真
|
||||
_use_proxy = True # 是否使用代理,默认启用
|
||||
_use_browser_emulation = False # 是否启用浏览器仿真(绕过CF防护)
|
||||
_history_days = None
|
||||
_username = None
|
||||
_user_password = None
|
||||
@@ -54,6 +56,8 @@ class InvitesSignin(_PluginBase):
|
||||
|
||||
# 定时器
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
# 浏览器仿真实例缓存
|
||||
_playwright: Optional[PlaywrightHelper] = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
@@ -66,11 +70,19 @@ class InvitesSignin(_PluginBase):
|
||||
self._notify = config.get("notify")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._use_proxy = config.get("use_proxy", True)
|
||||
self._use_browser_emulation = config.get("use_browser_emulation", False)
|
||||
self._history_days = int(config.get("history_days") or 30)
|
||||
self._username = config.get("username")
|
||||
self._user_password = config.get("user_password")
|
||||
self._retry_count = int(config.get("retry_count") or 2)
|
||||
self._retry_interval = int(config.get("retry_interval") or 5)
|
||||
|
||||
if self._use_browser_emulation:
|
||||
if not self._playwright:
|
||||
self._playwright = PlaywrightHelper()
|
||||
else:
|
||||
self._playwright = None
|
||||
|
||||
if self._onlyonce:
|
||||
# 定时服务
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
@@ -87,6 +99,7 @@ class InvitesSignin(_PluginBase):
|
||||
"cookie": self._cookie,
|
||||
"notify": self._notify,
|
||||
"use_proxy": self._use_proxy,
|
||||
"use_browser_emulation": self._use_browser_emulation,
|
||||
"history_days": self._history_days,
|
||||
"username": self._username,
|
||||
"user_password": self._user_password,
|
||||
@@ -99,6 +112,32 @@ class InvitesSignin(_PluginBase):
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def _get_page_source(self, url: str, cookies=None) -> Optional[str]:
|
||||
"""
|
||||
获取页面HTML源码。
|
||||
若启用浏览器仿真,调用PlaywrightHelper(内部按 settings.BROWSER_EMULATION 使用
|
||||
playwright 或 flaresolverr 引擎);否则走普通HTTP请求。
|
||||
"""
|
||||
proxies = self.__get_proxies()
|
||||
proxy_server = settings.PROXY_SERVER if self._use_proxy else None
|
||||
|
||||
if self._use_browser_emulation:
|
||||
logger.info(f"[浏览器仿真] 使用 {settings.BROWSER_EMULATION} 引擎请求: {url}")
|
||||
if not self._playwright:
|
||||
self._playwright = PlaywrightHelper()
|
||||
return self._playwright.get_page_source(
|
||||
url=url,
|
||||
cookies=cookies,
|
||||
proxies=proxy_server,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
res = RequestUtils(cookies=cookies, proxies=proxies).get_res(url=url)
|
||||
if res and res.status_code == 200:
|
||||
return res.text
|
||||
logger.error(f"普通请求失败: {url}, 状态码: {res.status_code if res else '无响应'}")
|
||||
return None
|
||||
|
||||
def __get_proxies(self):
|
||||
"""
|
||||
获取代理设置
|
||||
@@ -313,6 +352,7 @@ class InvitesSignin(_PluginBase):
|
||||
"cookie": self._cookie,
|
||||
"notify": self._notify,
|
||||
"use_proxy": self._use_proxy,
|
||||
"use_browser_emulation": self._use_browser_emulation,
|
||||
"history_days": self._history_days,
|
||||
"username": self._username,
|
||||
"user_password": self._user_password,
|
||||
@@ -411,14 +451,14 @@ class InvitesSignin(_PluginBase):
|
||||
proxies = self.__get_proxies()
|
||||
|
||||
# 4. 使用新cookie获取csrfToken和userId
|
||||
res = RequestUtils(cookies=new_cookie, proxies=proxies).get_res(url="https://invites.fun")
|
||||
if not res or res.status_code != 200:
|
||||
page_html = self._get_page_source("https://invites.fun", cookies=new_cookie)
|
||||
if not page_html:
|
||||
logger.error("请求药丸错误")
|
||||
return False
|
||||
|
||||
# 获取csrfToken
|
||||
pattern = r'"csrfToken":"(.*?)"'
|
||||
csrfToken = re.findall(pattern, res.text)
|
||||
csrfToken = re.findall(pattern, page_html)
|
||||
if not csrfToken:
|
||||
logger.error("请求csrfToken失败")
|
||||
return False
|
||||
@@ -428,7 +468,7 @@ class InvitesSignin(_PluginBase):
|
||||
|
||||
# 获取userid
|
||||
pattern = r'"userId":(\d+)'
|
||||
match = re.search(pattern, res.text)
|
||||
match = re.search(pattern, page_html)
|
||||
|
||||
if match:
|
||||
userId = match.group(1)
|
||||
@@ -641,6 +681,9 @@ class InvitesSignin(_PluginBase):
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 3}, 'content': [{'component': 'VSwitch', 'props': {'model': 'notify', 'label': '开启通知', 'color': 'info'}}]},
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 3}, 'content': [{'component': 'VSwitch', 'props': {'model': 'onlyonce', 'label': '立即运行一次', 'color': 'success'}}]},
|
||||
]},
|
||||
{'component': 'VRow', 'content': [
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 3}, 'content': [{'component': 'VSwitch', 'props': {'model': 'use_browser_emulation', 'label': '启用浏览器仿真', 'color': '#009688'}}]},
|
||||
]},
|
||||
]}
|
||||
]
|
||||
},
|
||||
@@ -781,7 +824,18 @@ class InvitesSignin(_PluginBase):
|
||||
{'component': 'VIcon', 'props': {'color': 'success', 'class': 'mt-1 mr-2'}, 'text': 'mdi-check-circle'},
|
||||
{'component': 'div', 'props': {'class': 'text-subtitle-1 font-weight-regular mb-1', 'style': 'color: #444;'}, 'text': '功能特点'}
|
||||
]},
|
||||
{'component': 'div', 'props': {'class': 'text-body-2 ml-8'}, 'text': '优先使用填写Cookie进行签到,自动刷新session,如果Cookie签到失败或未设置则尝试进行登陆签到,支持签到历史记录查看。'}
|
||||
{'component': 'div', 'props': {'class': 'text-body-2 ml-8'}, 'text': '优先使用填写Cookie进行签到,自动刷新session,如果Cookie签到失败或未设置则尝试进行登陆签到,支持签到历史记录查看。'}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VListItem',
|
||||
'props': {'lines': 'two'},
|
||||
'content': [
|
||||
{'component': 'div', 'props': {'class': 'd-flex align-items-start'}, 'content': [
|
||||
{'component': 'VIcon', 'props': {'color': '#009688', 'class': 'mt-1 mr-2'}, 'text': 'mdi-robot'},
|
||||
{'component': 'div', 'props': {'class': 'text-subtitle-1 font-weight-regular mb-1', 'style': 'color: #444;'}, 'text': '浏览器仿真说明'}
|
||||
]},
|
||||
{'component': 'div', 'props': {'class': 'text-body-2 ml-8'}, 'text': '开启后将使用系统配置的 Playwright 或 FlareSolverr 引擎获取页面,可绕过 Cloudflare 防护。引擎须在 MoviePilot 系统设置中提前配置。'}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -796,6 +850,7 @@ class InvitesSignin(_PluginBase):
|
||||
"onlyonce": False,
|
||||
"notify": False,
|
||||
"use_proxy": True,
|
||||
"use_browser_emulation": False,
|
||||
"cookie": "",
|
||||
"history_days": 30,
|
||||
"cron": "0 9 * * *",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
import re
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
@@ -22,7 +23,7 @@ class MoviePilotUpdateNotify(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "Moviepilot_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.5"
|
||||
plugin_version = "1.5.1"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
@@ -82,7 +83,7 @@ class MoviePilotUpdateNotify(_PluginBase):
|
||||
|
||||
# 本地版本
|
||||
local_version = SystemChain().get_server_local_version()
|
||||
if local_version and release_version <= local_version:
|
||||
if local_version and list(map(int, re.findall(r'\d+', release_version))) <= list(map(int, re.findall(r'\d+', local_version))):
|
||||
logger.info(f"当前后端版本:{local_version} 远程版本:{release_version} 停止运行")
|
||||
return False
|
||||
|
||||
@@ -107,7 +108,7 @@ class MoviePilotUpdateNotify(_PluginBase):
|
||||
|
||||
# 本地版本
|
||||
local_version = SystemChain().get_frontend_version()
|
||||
if local_version and release_version <= local_version:
|
||||
if local_version and list(map(int, re.findall(r'\d+', release_version))) <= list(map(int, re.findall(r'\d+', local_version))):
|
||||
logger.info(f"当前前端版本:{local_version} 远程版本:{release_version} 停止运行")
|
||||
return False
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from typing import List, Tuple, Dict, Any
|
||||
|
||||
import datetime
|
||||
@@ -23,7 +24,7 @@ class RemoteIdentifiers(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "words.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.3"
|
||||
plugin_version = "2.4"
|
||||
# 插件作者
|
||||
plugin_author = "honue"
|
||||
# 作者主页
|
||||
@@ -74,25 +75,17 @@ class RemoteIdentifiers(_PluginBase):
|
||||
def get_file_content(self, file_urls: list) -> List[str]:
|
||||
ret: List[str] = ['#========以下识别词由 RemoteIdentifiers 插件添加========#']
|
||||
for file_url in file_urls:
|
||||
# https://movie-pilot.org/etherpad/p/MoviePilot_TV_Words
|
||||
if file_url.count("etherpad") != 0 and file_url.count("export") == 0:
|
||||
real_url = file_url + "/export/txt"
|
||||
file_url = file_url.strip()
|
||||
if not file_url:
|
||||
continue
|
||||
if file_url.lower().endswith(".json"):
|
||||
mapping = self.__get_remote_mapping(file_url=file_url)
|
||||
for words_name, words_url in mapping.items():
|
||||
identifiers = self.__get_remote_identifiers(words_url=words_url, words_name=words_name)
|
||||
ret += identifiers
|
||||
else:
|
||||
real_url = file_url
|
||||
response = RequestUtils(proxies=settings.PROXY,
|
||||
headers=settings.GITHUB_HEADERS if real_url.count("github") else None,
|
||||
timeout=15).get_res(real_url)
|
||||
if not response:
|
||||
raise Exception(f"文件 {real_url} 下载失败!")
|
||||
elif response.status_code != 200:
|
||||
raise Exception(f"下载文件 {real_url} 失败:{response.status_code} - {response.reason}")
|
||||
text = response.content.decode('utf-8')
|
||||
if text.find("doctype html") > 0:
|
||||
raise Exception(f"下载文件 {real_url} 失败:{response.status_code} - {response.reason}")
|
||||
if "try again later" in text:
|
||||
raise Exception(f"下载文件 {real_url} 失败:{text}")
|
||||
identifiers: List[str] = text.split('\n')
|
||||
ret += identifiers
|
||||
identifiers = self.__get_remote_identifiers(words_url=file_url)
|
||||
ret += identifiers
|
||||
# flitter 过滤空行
|
||||
if self._flitter:
|
||||
filtered_ret = []
|
||||
@@ -103,6 +96,56 @@ class RemoteIdentifiers(_PluginBase):
|
||||
logger.info(f"获取到远端识别词{len(ret) - 1}条: {ret[1:]}")
|
||||
return ret
|
||||
|
||||
def __get_real_url(self, words_url: str) -> str:
|
||||
# https://movie-pilot.org/etherpad/p/MoviePilot_TV_Words
|
||||
if words_url.count("etherpad") != 0 and words_url.count("export") == 0:
|
||||
return words_url + "/export/txt"
|
||||
return words_url
|
||||
|
||||
def __get_response_text(self, url: str) -> str:
|
||||
response = RequestUtils(
|
||||
proxies=settings.PROXY,
|
||||
headers=settings.GITHUB_HEADERS if url.count("github") else None,
|
||||
timeout=15
|
||||
).get_res(url)
|
||||
if not response:
|
||||
raise Exception(f"文件 {url} 下载失败!")
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"下载文件 {url} 失败:{response.status_code} - {response.reason}")
|
||||
text = response.content.decode('utf-8')
|
||||
if text.find("doctype html") > 0:
|
||||
raise Exception(f"下载文件 {url} 失败:{response.status_code} - {response.reason}")
|
||||
if "try again later" in text:
|
||||
raise Exception(f"下载文件 {url} 失败:{text}")
|
||||
return text
|
||||
|
||||
def __get_remote_identifiers(self, words_url: str, words_name: str = None) -> List[str]:
|
||||
real_url = self.__get_real_url(words_url=words_url)
|
||||
text = self.__get_response_text(url=real_url)
|
||||
identifiers = text.split('\n')
|
||||
if words_name:
|
||||
logger.info(f"词表[{words_name}]获取成功,地址:{real_url},识别词数量:{len(identifiers)}")
|
||||
return identifiers
|
||||
|
||||
def __get_remote_mapping(self, file_url: str) -> Dict[str, str]:
|
||||
real_url = self.__get_real_url(words_url=file_url)
|
||||
text = self.__get_response_text(url=real_url)
|
||||
try:
|
||||
mapping = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
raise Exception(f"订阅文件 {real_url} 不是合法JSON:{str(e)}")
|
||||
if not isinstance(mapping, dict):
|
||||
raise Exception(f"订阅文件 {real_url} 格式错误:必须为对象,格式为 词表名 -> 词表地址")
|
||||
normalized_mapping: Dict[str, str] = {}
|
||||
for words_name, words_url in mapping.items():
|
||||
if not isinstance(words_name, str):
|
||||
raise Exception(f"订阅文件 {real_url} 格式错误:词表名必须是字符串")
|
||||
if not isinstance(words_url, str) or not words_url.strip():
|
||||
raise Exception(f"订阅文件 {real_url} 格式错误:词表[{words_name}]地址必须是非空字符串")
|
||||
normalized_mapping[words_name] = words_url.strip()
|
||||
logger.info(f"订阅文件[{real_url}]解析成功,共 {len(normalized_mapping)} 个词表")
|
||||
return normalized_mapping
|
||||
|
||||
def __task(self):
|
||||
words: List[str] = self.systemconfig.get(SystemConfigKey.CustomIdentifiers) or []
|
||||
file_urls: list = self._file_urls.split('\n') if self._file_urls else []
|
||||
|
||||
Reference in New Issue
Block a user