Compare commits

...

73 Commits

Author SHA1 Message Date
jxxghp
9862c81477 Merge pull request #1015 from wumode/clashruleprovider 2026-05-02 17:57:09 +08:00
wumode
9fb3e09042 fix(ClashRuleProvider): add missing xhttp_opts and correct field types 2026-05-02 17:26:08 +08:00
wumode
1ad19a5b23 feat(ClashRuleProvider): bump version to 2.1.5 and support xhttp protocol 2026-05-02 17:03:31 +08:00
jxxghp
527327c6cb Merge pull request #1014 from Nanako718/feat/alidnsddns 2026-04-26 07:57:29 +08:00
DTZSGHNR
a398dcb0b8 fix(alidnsddns): 根据 Code Review 建议修复四处问题
- 移除 init_plugin 中重复的 interval 调度,定时任务统一由宿主 get_service() 管理
- 将废弃的 datetime.utcnow() 替换为 datetime.now(timezone.utc)
- API 请求增加 HTTPError/URLError 捕获,读取响应体输出详细错误信息
- upsert() 改为更新所有匹配记录,而不只取第一条
2026-04-26 07:05:57 +08:00
DTZSGHNR
f3232dba0a feat(alidnsddns): 新增阿里云 DDNS 插件 v1.0
- 定时检测公网 IPv4/IPv6 地址,自动更新阿里云 DNS 解析记录
- 支持泛域名(* 记录)、根域(@ 记录)及任意子域名
- 支持同时维护多条 A / AAAA 记录
- 详情页展示更新历史(VDataTable,最多 100 条)
- IP 变化时推送通知(兼容所有通知渠道)
- 纯标准库实现阿里云 DNS API(HMAC-SHA1 签名),无额外依赖
2026-04-26 07:05:57 +08:00
jxxghp
e78a371663 docs: split plugin faq into separate pages 2026-04-20 21:43:30 +08:00
jxxghp
068838d013 docs: improve plugin guidance 2026-04-20 21:32:50 +08:00
jxxghp
57f2ad523c fix(wechatclawbot): 状态信息恢复单块VAlert,用white-space:pre-line修复换行 2026-04-10 13:02:32 +08:00
jxxghp
615f85f02b chore(wechatclawbot): 版本升至 0.2.1 2026-04-10 12:57:11 +08:00
jxxghp
74f47c7131 fix(wechatclawbot): 状态信息拆分为独立卡片逐项展示,修复换行问题 2026-04-10 12:56:17 +08:00
jxxghp
87224308d6 feat(wechatclawbot): 优化配置页UI布局,修复回复消息多余类型前缀,升级至v0.2.0
- get_form 改用 VRow/VCol 响应式两列布局,增加使用说明提示
- get_page 所有元素用 VRow/VCol 包裹,二维码居中显示
- 移除 _notification_to_title_lines 中 mtype 前缀拼接,修复回复时出现【智能体】等字样
- package.v2.json 同步版本号至 0.2.0
2026-04-10 12:54:00 +08:00
jxxghp
4b413d93a8 Merge pull request #1013 from mijjjj/main 2026-04-09 21:44:06 +08:00
Jc Fang
34e72a7ae3 Merge branch 'main' into main 2026-04-09 21:35:08 +08:00
Fangjc
944af59468 fix:修复错误 2026-04-09 21:32:08 +08:00
Fangjc
0f898f283e fix:修复插件package写错文件问题 2026-04-09 21:16:08 +08:00
Fangjc
07a4731feb 添加package.json 2026-04-09 21:12:57 +08:00
Fangjc
d3faafe6ee fix:修复依赖问题 2026-04-09 21:05:23 +08:00
Fangjc
8bff87f1c5 添加wechatbot通知支持 2026-04-09 21:05:23 +08:00
Fangjc
889f393d2a fix:修复依赖问题 2026-04-09 21:01:15 +08:00
Fangjc
e008da0c2b 添加wechatbot通知支持 2026-04-09 17:16:02 +08:00
wumode
f3d1aa1ea9 fix(lexiannot): No module named 'langchain.output_parsers' 2026-03-27 15:19:25 +08:00
DDSRem
77f399ffa0 fix python_hosts 2026-03-26 18:01:58 +08:00
DDSRem
e101d5c2bd fix cacheout 2026-03-26 18:01:58 +08:00
jxxghp
a0d25abe25 Merge pull request #1004 from DDSRem-Dev/main 2026-03-25 11:57:14 +08:00
DDSRem
bd3f6fe2e5 fix python-hosts 2026-03-25 11:45:08 +08:00
jxxghp
7f41a8a5f2 fix cacheout 2026-03-24 22:07:29 +08:00
jxxghp
c33e7fe9df fix bencode 2026-03-24 22:05:27 +08:00
jxxghp
20e18117ab Merge pull request #1001 from Raymond38324/main 2026-03-16 06:53:56 +08:00
raymond531
750d5917a2 feat: 修改版本号 2026-03-15 22:26:21 +08:00
raymond531
fc23e3639d fix: 版本号修改 2026-03-15 21:47:29 +08:00
raymond531
8a5b01f58f feat: 修改配置 2026-03-15 21:45:12 +08:00
raymond531
72bb3320ac fix: 修复NoneType错误,增强空值处理 v1.5.1 2026-03-14 23:06:44 +08:00
raymond531
2a4002032d feat: 详情页新增清空历史按钮,可重置空间统计 2026-03-14 22:56:18 +08:00
raymond531
be12618b0f bug 修改 2026-03-14 21:01:21 +08:00
raymond531
4d2bc309ac bug 修改 2026-03-14 21:00:27 +08:00
raymond531
2f78083c7f 添加插件占用空间限制 2026-03-14 20:38:41 +08:00
raymond531
f1355f3400 bug 修改 2026-03-14 18:47:37 +08:00
raymond531
6a03f626be 修改路径 2026-03-14 18:40:39 +08:00
raymond531
5cf62a221a 添加首播试看插件 2026-03-14 18:34:19 +08:00
jxxghp
9662a4c457 Merge pull request #1000 from KoWming/main 2026-03-13 14:55:31 +08:00
KoWming
3ad3de299c Update __init__.py
性能优化
2026-03-13 13:36:11 +08:00
KoWming
e760cd6afa Update 药丸签到2.0.3
增加启用浏览器仿真功能发送请求
2026-03-13 13:13:10 +08:00
jxxghp
8d30ba5c69 Merge pull request #996 from wumode/clashruleprovider 2026-03-08 07:46:10 +08:00
wumode
a9b66c4f43 fix(tobypasstrackers): improve torrents downloading 2026-03-07 22:41:12 +08:00
wumode
cdc062d681 fix(clashruleprovider): unable to delete proxies 2026-03-07 22:33:01 +08:00
jxxghp
437b2b05d4 Merge pull request #993 from honue/main 2026-02-25 15:13:11 +08:00
honue
944919fc34 feat: 共享识别词支持 JSON 格式远程识别词集合订阅 2026-02-25 14:47:56 +08:00
jxxghp
1ae826cf14 Merge pull request #991 from xiaoQQya/develop 2026-02-24 19:57:39 +08:00
xiaoQQya
f438490ca5 perf(AutoSignIn): 优化站点 Rousi Pro 签到失败提示信息 2026-02-23 21:22:13 +08:00
jxxghp
b938ca5bf3 Merge pull request #988 from YuHoYe/feat/dailysummary 2026-02-10 06:56:14 +08:00
YuHoYe
028103b900 fix(DailySummary): 简化配置界面 + 修复通知标题重复
- 移除高级设置 tab(signin_plugin_id / brush_plugin_ids / storage_paths)
  这些内部实现细节不该暴露给用户,改为代码内硬编码默认值
  存储路径改为纯自动检测 MP 的 LIBRARY_PATH / DOWNLOAD_PATH
- 去掉 VTabs,报告模块选择器直接平铺
- Cron 字段和开关移到 VTabs 外面,避免弹出菜单被裁剪
- 修复通知标题重复:text 中不再拼接 header,由 post_message 的 title 参数单独传递
2026-02-09 23:25:51 +08:00
YuHoYe
bb1f159198 feat(DailySummary): Cron 字段改用 VCronField GUI 选择器 2026-02-09 23:23:10 +08:00
YuHoYe
6fa42abc17 fix(DailySummary): 旧配置升级时回写默认模块选择 2026-02-09 23:23:10 +08:00
YuHoYe
95b952c27f feat(DailySummary): 详情页展示模块配置和发送统计 2026-02-09 23:23:10 +08:00
jxxghp
6631d06a04 Merge pull request #987 from YuHoYe/feat/dailysummary 2026-02-09 06:24:53 +08:00
jxxghp
1afce8c607 Merge pull request #986 from BlueflameLi/main 2026-02-09 06:23:50 +08:00
YuHoYe
82c825e349 fix(DailySummary): 修复详情页面不显示问题
- 参考 BrushFlow 的 get_page 结构,将所有内容放在单个 VRow 内
- 无数据时返回「暂无数据」提示
- 表格 height 改为 '30rem'(字符串)
- 统计卡片对齐官方组件风格
2026-02-09 00:41:43 +08:00
YuHoYe
ff7d7b1fa4 feat(DailySummary): 新增活动总结插件
定时发送每日/每周/每月活动总结通知,支持自定义报告模块和历史记录查看。
2026-02-08 23:06:18 +08:00
BlueflameLi
328ed9884a 🐞 fix(MoviePilotUpdateNotify): 修复版本号比较逻辑 2026-02-08 22:00:03 +08:00
jxxghp
4d1b90abc8 Merge pull request #980 from xiaoQQya/develop 2026-01-26 18:42:27 +08:00
xiaoQQya
c5afdfc2da fix(AutoSignIn): 更新站点 Rousi Pro 签到接口 2026-01-25 14:27:32 +08:00
jxxghp
fdbd5ad501 Merge pull request #979 from TimoYoung/main
fix(autosubv2): v2.5 fix openai client init problem
2026-01-23 22:54:49 +08:00
TimoYoung
d66605ae99 fix(autosubv2): v2.5 fix openai client init problem 2026-01-23 22:07:07 +08:00
jxxghp
145e9747a9 Merge pull request #977 from wumode/imdbsource 2026-01-21 21:35:14 +08:00
jxxghp
87e4dcd211 Merge pull request #976 from TimoYoung/main 2026-01-21 17:38:19 +08:00
TimoYoung
633c8bad97 autosubv2: v2.4 适配openai api v1 2026-01-21 17:30:59 +08:00
wumode
0927d0388a feat(imdbsource): add production company filter and optimize year selection 2026-01-21 14:48:28 +08:00
wumode
323289aa74 fix(ClashRuleProvider): 规则集禁用失效 2026-01-21 14:48:28 +08:00
wumode
1f80e3b078 fix(LexiAnnot): 避免潜在的数据校验错误 2026-01-21 14:48:28 +08:00
jxxghp
0ac725383e Merge pull request #975 from AkaiShuichi7/main 2026-01-21 06:44:27 +08:00
AkaiShuichi7
659f4f2b0d fix(MediaServerMsg): 优化去重逻辑并修复潜在内存泄漏 (PR review) 2026-01-20 23:43:51 +08:00
AkaiShuichi7
d65979323e feat(MediaServerMsg): 修复emby多条相同新入库消息推送多次的问题 2026-01-20 23:22:47 +08:00
68 changed files with 8734 additions and 2146 deletions

1569
README.md

File diff suppressed because it is too large Load Diff

20
docs/FAQ.md Normal file
View 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
View 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

View 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"
```

View 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
```

View 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文档和调试

View 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
}]
```

View 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 # 结果只含原标题,代表空识别结果事件
}
)
```

View 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"
}
}
}
}
```
- 需要注意的是,如果你没有完成用户认证,通过插件配置进去的索引站点也是无法正常使用的。
- **请不要添加对黄赌毒站点的支持,否则随时封闭接口。**

View 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调用完成后均会自动刷新一次插件数据页。

View 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
```

View 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请求。

View 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="额外媒体数据源")
```

View 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`,将继续执行下一个插件或者系统模块的相同声明方法;如果对应的方法需要返回是的列表对象,则会执行所有插件和系统模块的方法后将结果组合返回。

View 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": "复制"
}
```

View 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
```
- 编辑工作流流程,添加`调用插件`组件,选择该插件的对应动作,将插件的功能串接到工作流程中

View 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才能使用此功能
- 建议在交互中保存用户状态数据,以支持复杂的多步骤操作
- 可以结合插件数据存储功能保存用户的交互历史和偏好设置

View 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值
- 大文件或二进制数据建议使用文件缓存后端
- 在插件卸载时清理相关缓存,避免内存泄漏

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -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 解析记录,支持泛域名(* 记录)及 IPv6AAAA。",
"labels": "网络",
"version": "1.0",
"icon": "AliDnsDDNS.png",
"author": "dtzsghnr",
"level": 1,
"history": {
"v1.0": "初始版本,支持 IPv4/IPv6、泛域名、多记录配置、更新历史详情页"
}
}
}
}

View File

@@ -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": "初始版本"
}
}
}

View File

@@ -35,7 +35,7 @@ class AutoSignIn(_PluginBase):
# 插件图标
plugin_icon = "signin.png"
# 插件版本
plugin_version = "2.8"
plugin_version = "2.8.2"
# 插件作者
plugin_author = "thsrite"
# 作者主页

View File

@@ -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}"

View File

@@ -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"
# 作者主页

View File

@@ -0,0 +1 @@
cacheout~=0.16.0

View File

@@ -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"
# 作者主页

View File

@@ -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);

View File

@@ -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;

View File

@@ -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');

View File

@@ -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;

View File

@@ -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')

View File

@@ -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

View File

@@ -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"
# 作者主页

View File

@@ -0,0 +1 @@
fast-bencode~=1.1.7

View 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"

View File

@@ -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"]

View File

@@ -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

View File

@@ -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:
"""
根据名称同时查询电影和电视剧,没有类型也没有年份时使用

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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

View File

@@ -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,

View File

@@ -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}
""",
),

View File

@@ -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")

View File

@@ -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):
"""
退出插件时的清理工作

View File

@@ -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

View File

@@ -2,6 +2,7 @@ import asyncio
import base64
import ipaddress
import json
import re
import socket
import time
from datetime import datetime, timedelta
@@ -62,7 +63,7 @@ class ToBypassTrackers(_PluginBase):
# 插件图标
plugin_icon = "Clash_A.png"
# 插件版本
plugin_version = "1.5.2"
plugin_version = "1.5.3"
# 插件作者
plugin_author = "wumode"
# 作者主页
@@ -715,7 +716,7 @@ class ToBypassTrackers(_PluginBase):
tracker_file = Path(self.get_data_path() / "trackers.json")
try:
if tracker_file.exists():
trackers: dict[str, list[str]] = json.loads(tracker_file.read_text())
trackers = json.loads(tracker_file.read_text(encoding="utf-8"))
else:
file = settings.ROOT_PATH / 'app' / 'plugins' / self.__class__.__name__.lower() / 'sites' / 'trackers'
with open(file, "r", encoding="utf-8") as f:
@@ -725,6 +726,63 @@ class ToBypassTrackers(_PluginBase):
logger.error(f"trackers 加载错误:{e}")
return trackers
@staticmethod
def _get_redict_url(url: str, ua: str | None = None, cookie: str | None = None) -> str | None:
"""
获取下载链接, url格式[base64]url
"""
# 获取[]中的内容
m = re.search(r"\[(.*)](.*)", url)
if m:
# 参数
base64_str = m.group(1)
# URL
url = m.group(2)
if not base64_str:
return url
# 解码参数
req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8')
req_params: Dict[str, dict] = json.loads(req_str)
# 是否使用cookie
if not req_params.get('cookie'):
cookie = None
# 代理
proxy = req_params.get('proxy')
# 请求头
if req_params.get('header'):
headers = req_params.get('header')
else:
headers = None
if req_params.get('method') == 'get':
# GET请求
res = RequestUtils(
ua=ua,
cookies=cookie,
headers=headers,
proxies=settings.PROXY if proxy else None
).get_res(url, params=req_params.get('params'))
else:
# POST请求
res = RequestUtils(
ua=ua,
cookies=cookie,
headers=headers,
proxies=settings.PROXY if proxy else None
).post_res(url, params=req_params.get('params'))
if not res:
return None
if not req_params.get('result'):
return res.text
else:
data = res.json()
for key in str(req_params.get('result')).split("."):
data = data.get(key)
if not data:
return None
logger.info(f"获取到下载地址:{data}")
return data
return None
def refresh_trackers(self):
"""更新 Tracker 服务器列表"""
logger.info("开始从站点获取最新 Tracker 服务器 ...")
@@ -735,7 +793,14 @@ class ToBypassTrackers(_PluginBase):
torrents = torrents_chain.browse(domain=site.domain)
if not torrents:
continue
torrent_url = torrents[0].enclosure
torrent_info = torrents[0]
torrent_url = torrent_info.enclosure
if torrent_url.startswith('['):
torrent_url = ToBypassTrackers._get_redict_url(
url=torrent_url, ua=torrent_info.site_ua, cookie=torrent_info.site_cookie
)
if torrent_url is None:
continue
_, content, _, _, error_msg = TorrentHelper().download_torrent(
url=torrent_url,
cookie=site.cookie,
@@ -748,16 +813,17 @@ class ToBypassTrackers(_PluginBase):
except BencodeDecodingError as e:
logger.error(f"解析 {site.name} 种子文件失败: {e}")
continue
servers: list[str] = []
for urls in torrent.announce_urls:
servers: set[str] = set()
for urls in torrent.announce_urls or []:
for url in urls:
parsed = urlparse(url)
if parsed.hostname:
servers.append(parsed.hostname)
servers.add(parsed.hostname)
if servers:
trackers[site.domain] = servers
trackers[site.domain] = list(servers)
logger.info(f"更新 {site.name} trackers -> {trackers[site.domain]}")
tracker_file = Path(self.get_data_path() / "trackers.json")
tracker_file.write_text(json.dumps(trackers, indent=4))
tracker_file.write_text(json.dumps(trackers, indent=4), encoding="utf-8")
logger.info("已更新 Tracker 服务器列表")
def bypassed_ips(self, protocol: Literal['4', '6']) -> Response:
@@ -941,7 +1007,7 @@ class ToBypassTrackers(_PluginBase):
for domain in site_domains:
domain_name_map[domain] = do_sites[site]
else:
logger.warn(f"不支持的站点: {do_sites[site]}({site})")
logger.warn(f"不支持的站点: {do_sites[site]}({site})请执行一次trackers更新。")
unsupported_msg.append(f'{do_sites[site]}】不支持的站点')
for custom_tracker in self._custom_trackers.split('\n'):
if custom_tracker:

View File

@@ -28,7 +28,7 @@ class TorrentTransfer(_PluginBase):
# 插件图标
plugin_icon = "seed.png"
# 插件版本
plugin_version = "1.10.2"
plugin_version = "1.10.3"
# 插件作者
plugin_author = "jxxghp"
# 作者主页

View File

@@ -0,0 +1 @@
fast-bencode~=1.1.7

View 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"
"# 需要Cookiehttps://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}

View File

@@ -0,0 +1 @@
feedparser>=6.0.0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
from .client import ILinkClient, ILinkIncomingMessage
__all__ = ["ILinkClient", "ILinkIncomingMessage"]

File diff suppressed because it is too large Load Diff

View 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 解析记录,支持泛域名(* 记录)及 IPv6AAAA"
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

View File

@@ -66,7 +66,7 @@ class AutoSubv2(_PluginBase):
# 主题色
plugin_color = "#2C4F7E"
# 插件版本
plugin_version = "2.3"
plugin_version = "2.5.1"
# 插件作者
plugin_author = "TimoYoung"
# 作者主页

View File

@@ -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

View File

@@ -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,

View File

@@ -32,7 +32,7 @@ class CloudflareSpeedTest(_PluginBase):
# 插件图标
plugin_icon = "cloudflare.jpg"
# 插件版本
plugin_version = "1.5"
plugin_version = "1.5.1"
# 插件作者
plugin_author = "thsrite"
# 作者主页

View File

@@ -0,0 +1 @@
python-hosts~=1.1.2

View File

@@ -18,7 +18,7 @@ class CustomHosts(_PluginBase):
# 插件图标
plugin_icon = "hosts.png"
# 插件版本
plugin_version = "1.2"
plugin_version = "1.2.1"
# 插件作者
plugin_author = "thsrite"
# 作者主页

View File

@@ -0,0 +1 @@
python-hosts~=1.1.2

View File

@@ -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 * * *",

View File

@@ -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

View File

@@ -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 []