Compare commits

...

130 Commits

Author SHA1 Message Date
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
jxxghp
d2503648a9 Merge pull request #974 from xiaoQQya/develop 2026-01-14 20:00:43 +08:00
xiaoQQya
fffad33cc5 feat(AutoSignIn): 适配站点 Rousi Pro 2026-01-14 19:23:57 +08:00
jxxghp
ae99671190 Merge pull request #971 from wumode/clashruleprovider 2026-01-13 07:05:42 +08:00
wumode
528b938f0f fix(ClashRuleProvider): fix rule-providers serialization error 2026-01-12 23:09:32 +08:00
jxxghp
722f8da96d Merge pull request #970 from wumode/lexiannot 2026-01-12 15:01:58 +08:00
wumode
c53a3dc152 fix(LexiAnnot): typo 2026-01-12 13:41:03 +08:00
jxxghp
e29f59c28c Merge pull request #969 from wumode/clashruleprovider 2026-01-10 21:00:44 +08:00
wumode
c2c1320b18 fix(ClashRuleProvider): remove proxiesmanager.py 2026-01-10 20:05:20 +08:00
wumode
e15733b7de refactor(ClashRuleProvider): 重构后端核心逻辑与数据模型
- 数据模型重构: 全面引入 Pydantic 模型(ClashConfig, Proxy, ProxyGroup 等)替代原有字典结构,提供更严格的数据验证与类型安全。
- 数据迁移机制: 新增 v2.1.0 数据升级脚本,支持将旧版代理、策略组及规则数据自动迁移至新架构。
- 配置补丁系统: 实现基于 JSON Patch 的细粒度配置修补机制,替代旧版覆盖逻辑,提升配置修改的灵活性。
- 服务层优化: 重写 ClashRuleProviderService 以适配新对象模型,增强代码可维护性与扩展性。
- API模型同步: 更新相关 API 数据模型以保持与内部数据结构的一致性。
- 用户界面: 批量规则管理和数据项隐藏支持
2026-01-10 19:23:32 +08:00
jxxghp
02a2518fce Merge pull request #966 from wumode/lexiannot 2026-01-07 16:40:08 +08:00
wumode
861f416aad fix(LexiAnnot): typo 2026-01-07 13:16:14 +08:00
wumode
17cf85c1c1 Update ImdbSource to v1.6.6 2026-01-07 12:55:52 +08:00
wumode
6dbf539d88 feat(lexiannot): optimize prompts and subtitle extraction 2026-01-07 12:54:09 +08:00
jxxghp
24b9c2ec29 Merge pull request #965 from BlueflameLi/main 2026-01-07 06:50:04 +08:00
BlueflameLi
9a8e939414 fix(MoviePilotUpdateNotify): 更新版本号和历史 2026-01-06 23:21:27 +08:00
jxxghp
a6b5286bf9 Merge pull request #963 from wumode/tobypasstrackers 2026-01-06 15:19:45 +08:00
wumode
490c740c54 feat(tobypasstrackers): 支持从站点首页获取最新 Trackers 2026-01-06 15:12:08 +08:00
jxxghp
39d64a1cf4 Merge pull request #962 from BlueflameLi/main 2026-01-05 22:32:27 +08:00
BlueflameLi
a0272dfcaf fix(MoviePilotUpdateNotify): 修复版本描述为空时的报错 2026-01-05 21:31:41 +08:00
jxxghp
44d3db72b4 Merge pull request #961 from cddjr/fix_947 2026-01-04 13:15:07 +08:00
景大侠
48b5d1018e 更新登录壁纸本地化插件 适配新版MP 2026-01-04 12:05:30 +08:00
jxxghp
738e224ba3 Merge pull request #960 from Seed680/main 2025-12-30 07:02:30 +08:00
noone
6f2a0b2213 fix(mediaservermsg): 修复媒体服务器通知插件的多个问题
- 修复多集时有概率图片获取失败的问题
- 修复emby测试通知类型接收失败的问题
- 修复单集剧情信息有概率获取失败的问题
- 更新插件版本号至1.8.2.1
- 修正webhook测试事件类型从system.webhooktest改为system.notificationtest
- 添加事件类型过滤调试日志
- 优化图片URL处理逻辑,改进单集和多集的图片获取策略
- 完善剧集概述信息获取的安全性处理
2025-12-29 22:15:24 +08:00
jxxghp
c2ccdf2b8e Merge pull request #959 from Seed680/main 2025-12-29 19:18:23 +08:00
noone
adb6230eea Merge branch 'main' of https://github.com/Seed680/MoviePilot-Plugins-main 2025-12-29 17:35:16 +08:00
noone
aa89750d1f fix(brushflow): 提升匹配规则时的健壮性
- 在包含规则和排除规则中添加正则表达式错误处理
- 防止因正则表达式错误导致的匹配失败
- 添加对torrent标题和描述的空值检查
- 修复RSS支持配置选项的处理逻辑
- 更新插件版本到4.3.5
2025-12-29 17:35:12 +08:00
Seed680
4ca2d14076 Merge branch 'jxxghp:main' into main 2025-12-29 17:19:21 +08:00
noone
8bd590e1ea fix(mediaservermsg): 修复多集图片获取失败及emby测试通知问题
- 修复多集时有概率图片获取失败的问题
- 修复emby测试通知类型接收失败的问题
- 更新版本号至1.8.2
- 将webhook事件映射中的system.notificationtest改为system.webhooktest
2025-12-29 17:18:44 +08:00
jxxghp
d7effcd625 Merge pull request #955 from Seed680/main 2025-12-23 08:43:19 +08:00
noone
a7b830e4fd fix(mediaservermsg): 修复单集剧情信息有概率获取失败的问题
- 重构safe_get_overview函数,增加详细的文档说明
- 优化单集剧情获取逻辑,优先使用webhook事件的overview
2025-12-23 07:54:42 +08:00
jxxghp
5b8f5b406f Merge pull request #954 from wumode/imdbsource 2025-12-22 19:05:12 +08:00
jxxghp
69b430bdc3 Merge pull request #953 from wumode/lexiannot 2025-12-22 19:04:48 +08:00
wumode
00d3346dfc fix(lexiannot): ValueError 2025-12-22 18:01:19 +08:00
wumode
7452540a93 fix(imdbsource): TypeError 2025-12-22 17:56:21 +08:00
wumode
d98902e536 feat(lexiannot): 改进字幕样式获取方法 2025-12-22 13:36:09 +08:00
wumode
5ecefb4a41 feat(ImdbSource): 仪表盘组件支持图片缓存 2025-12-22 13:23:37 +08:00
jxxghp
814149e0f3 删除 requirements.txt 2025-12-22 11:27:04 +08:00
jxxghp
d306145a14 更新 package.v2.json 2025-12-22 11:26:49 +08:00
jxxghp
da72e1b252 删除 package.json 2025-12-22 11:24:47 +08:00
jxxghp
b6fc76cdb7 Merge pull request #952 from jxxghp/copilot/add-plugin-definition-tmdbwallpaper 2025-12-22 11:22:39 +08:00
copilot-swe-agent[bot]
7842375d11 Remove trailing newlines from package.json and requirements.txt
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2025-12-22 03:21:01 +00:00
copilot-swe-agent[bot]
f6d83a5d31 Add package.json and requirements.txt for tmdbwallpaper plugin
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2025-12-22 03:19:36 +00:00
jxxghp
97b8e7028a Merge pull request #950 from jxxghp/copilot/remove-v2-compatibility-update-references 2025-12-22 11:17:10 +08:00
copilot-swe-agent[bot]
cc6cc55ad0 Initial plan 2025-12-22 03:15:48 +00:00
copilot-swe-agent[bot]
52063367f8 Remove v2 compatibility marker from TmdbWallpaper plugin
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2025-12-22 03:14:58 +00:00
jxxghp
0003e4382b Merge pull request #949 from jxxghp/copilot/copy-wallpaper-plugin-to-v2 2025-12-22 11:12:41 +08:00
copilot-swe-agent[bot]
e2cbe22e8d Initial plan 2025-12-22 03:11:19 +00:00
copilot-swe-agent[bot]
436983e49e Copy tmdbwallpaper plugin to plugins.v2 directory
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2025-12-22 03:05:34 +00:00
copilot-swe-agent[bot]
8829414a47 Initial plan 2025-12-22 03:02:42 +00:00
jxxghp
9f46c829db Merge pull request #948 from ArvinChen9539/remove-playletfortunewheel 2025-12-22 10:08:55 +08:00
ArvinChen9539
0de6531aed PlayLet移除自动抽奖插件 2025-12-22 09:46:28 +08:00
jxxghp
a5a96b74e3 Merge pull request #946 from 13bit-X/main 2025-12-21 22:28:03 +08:00
jofyxu
f7b1a027f5 fix: 升级插件版本至 1.3
升级插件版本至 1.3
同时更新了 package.json 中的版本号和历史记录。
2025-12-21 21:42:04 +08:00
jofyxu
bde04fd7e1 fix(ntfymsg): 修复标题和文本为空时的日志警告问题
当标题和文本都为空时,使用空字符代替空字符串,以避免自动生成 “triggered” 文本。

- 将 `title = msg_body.get("title") or ""` 修改为 `title = msg_body.get("title") or "\u200b"`
- 将 `text = msg_body.get("text") or ""` 修改为 `text = msg_body.get("text") or "\u200b"`
2025-12-21 21:30:46 +08:00
jofyxu
af38909f58 fix: 修复消息标题和内容为空时日志错误
修复了当消息的标题和内容都为空时,日志记录的错误信息不完整的问题。现在,如果消息的标题或内容为空,将返回空字符串而不是None,从而避免日志记录的警告信息中出现None。

- 修改了消息标题 (`title = msg_body.get("title") or ""`) 的获取方式,当标题为空时返回空字符串。
- 修改了消息文本 (`text = msg_body.get("text") or ""`) 的获取方式,当文本为空时返回空字符串。
2025-12-21 20:57:40 +08:00
jxxghp
5ccd80c4f1 Merge pull request #945 from 13bit-X/main 2025-12-21 17:35:25 +08:00
13bit
ebf407b8b2 Update package.json
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-12-21 16:24:00 +08:00
13bit
d0be1feec5 将插件版本从 1.1 升级到 1.2 2025-12-21 16:11:39 +08:00
13bit
02fbbc87b4 将 NtfyMsg 版本升级到 1.2
将 NtfyMsg 版本更新至 1.2,并为版本 1.2 添加了更新记录。
2025-12-21 16:09:45 +08:00
13bit
ce1804cd0f 修复 ntfy 通知图标链接失效的问题 2025-12-21 16:06:57 +08:00
jxxghp
53da73f11e Merge pull request #944 from ArvinChen9539/feature-playlet-fortune-wheel 2025-12-21 10:13:08 +08:00
陈敬
fb3d8e9c0d PlayLet幸运大转盘 2025-12-21 02:01:07 +08:00
jxxghp
5039a94bbf Merge pull request #943 from EkkoG/main 2025-12-18 21:00:36 +08:00
Ekko
3ae993050b 豆瓣榜单-支持设置 rsshub 实例地址 2025-12-18 17:51:15 +08:00
jxxghp
0dddb4675f Merge pull request #942 from EkkoG/main 2025-12-18 14:15:26 +08:00
Ekko
56abaaf31c 豆瓣榜单-支持设置 rsshub 示例地址 2025-12-18 13:01:19 +08:00
jxxghp
900f4fec95 Merge pull request #941 from Seed680/main 2025-12-18 12:43:05 +08:00
jxxghp
88688672db Merge pull request #939 from KoWming/main 2025-12-18 12:42:40 +08:00
noone
cc6b95e5a1 fix(mediaservermsg): 优化剧集名称提取逻辑并改进错误日志
- 将 TMDB 信息获取错误的日志级别从 debug 提升为 error
- 从 json_object 中提取 SeriesName 作为剧集名称,提高准确性
- 优化消息标题格式,去除冗余文字,提升可读性
- 添加异常处理以确保 SeriesName 提取过程的稳定性
2025-12-18 09:30:13 +08:00
noone
377808f3da refactor(mediaservermsg): 优化TV剧集消息聚合处理逻辑
- 移除未使用的Emby和RequestUtils导入模块
2025-12-18 09:23:00 +08:00
noone
1d5e44e02c feat(mediaservermsg): 增强媒体服务器通知插件功能
- 当整理路径中没有tmdbid时,会尝试从媒体服务器中获取
- 增强错误处理和异常捕获机制
- 改进消息发送流程的安全性与稳定性
- 更新插件版本至1.8
2025-12-18 09:13:45 +08:00
KoWming
ff9c35041e perf: 增加签到检测机制防止重复签到,增强代码健壮性。 2025-12-17 10:13:21 +08:00
KoWming
d9afb64d00 perf: 尝试修复签到失败问题,新增使用代理、Cookie自动更新功能
尝试修复签到失败问题,新增使用代理、Cookie自动更新功能
2025-12-16 11:24:17 +08:00
jxxghp
6d60123272 Merge pull request #938 from wumode/lexiannot 2025-12-13 12:41:26 +08:00
wumode
84fcc3762f fix(LexiAnnot): Array index error 2025-12-12 19:15:21 +08:00
wumode
77b34dba5c feat(lexiannot): Add QueryAnnotationTasksTool 2025-12-12 15:01:56 +08:00
wumode
4d8f36f674 feat(lexiannot): Add exam_distribution to SegmentList schema 2025-12-12 11:19:40 +08:00
wumode
5ccbb412eb feat(tobypasstrackers): Add new trackers and refine IP exclusion logic 2025-12-11 11:34:36 +08:00
wumode
4a0c700e6b feat(imdbsource): Add background image support to metadata and update version to 1.6.4 2025-12-11 11:23:12 +08:00
wumode
00c65a0983 feat(lexiannot): Integrate LLM for advanced vocabulary processing 2025-12-11 11:23:12 +08:00
jxxghp
b961a52440 Merge pull request #937 from Seed680/main 2025-12-10 12:10:57 +08:00
noone
707feedda2 fix(mediaservermsg): 优化剧集图片查询逻辑
- 修改查询条件,仅当项目类型为TV或SHOW时执行剧集图片查询
2025-12-10 10:41:55 +08:00
noone
07c6ee1341 fix(mediaservermsg): 修复媒体服务器消息图片查询逻辑
- 修正了电影图片查询条件,确保仅在有 tmdb_id 时执行
2025-12-10 10:40:55 +08:00
noone
fd360cf21d bugfix:MediaServerMsg1.7.1 2025-12-10 10:36:56 +08:00
noone
a267df9e5d Merge branch 'main' of https://github.com/Seed680/MoviePilot-Plugins-main 2025-12-10 18:18:40 +08:00
noone
8feecbcb42 fix(mediaservermsg): 修复未获取到tmdb信息时的消息发送逻辑
- 当tmdb_id为空时,回退到原有逻辑发送通知消息
- 为电影类型添加海报图片查询支持
2025-12-10 18:18:25 +08:00
jxxghp
4224939f30 Merge pull request #936 from Seed680/main 2025-12-09 11:51:43 +08:00
Seed680
234ceba60c Merge branch 'jxxghp:main' into main 2025-12-09 10:23:50 +08:00
noone
5c8a6647e2 feat(mediaservermsg): 新增TV剧集结入库聚合功能
- 实现TV剧集入库事件的智能聚合,避免消息轰炸
- 添加聚合时间窗口配置,默认15秒
- 支持通过TMDB ID获取剧集详细信息用于消息展示
- 自动合并连续集数信息,优化消息可读性
- 增加聚合功能开关和时间配置项到插件表单
- 完善消息构造逻辑,支持剧集封面和背景图展示
- 优化消息缓存机制,提升重复消息过滤效果
- 补充详细的日志记录便于问题排查
- 更新插件版本至1.7并添加更新说明提示
2025-12-09 10:22:52 +08:00
jxxghp
5b763dff42 Merge pull request #933 from cikezhu/main 2025-12-06 03:52:20 +08:00
cikezhu
ee453841df fix 更新标签而传递旧的种子列表 2025-12-05 22:51:50 +08:00
cikezhu
6768d2c244 add 站点/剧名前缀功能 2025-12-05 22:44:49 +08:00
jxxghp
cb14efcc68 Merge pull request #932 from cikezhu/main 2025-12-05 17:54:08 +08:00
cikezhu
7871dfd0b8 fix gemini-code-assist suggestion‌ 2025-12-05 17:01:33 +08:00
cikezhu
99d1bfe37e fix 2025-12-05 16:24:15 +08:00
cikezhu
b65c1b8bf7 首次运行时初始化rid映射 2025-12-05 16:18:09 +08:00
cikezhu
517a16f0a3 优化采用公共服务自动清理未使用标签 2025-12-05 16:13:38 +08:00
jxxghp
89bfb9750d Merge pull request #930 from cikezhu/main 2025-12-04 22:28:59 +08:00
cikezhu
01eac66a6a fix _get_label 2025-12-04 22:11:57 +08:00
cikezhu
cd53b8d454 add 自动清除未使用标签 2025-12-04 21:56:48 +08:00
jxxghp
d986f45634 Merge pull request #928 from cikezhu/main 2025-12-03 14:40:56 +08:00
cikezhu
0ceb633d96 通过直接解析而不是硬编码解决代码冗余问题 2025-12-03 10:54:12 +08:00
cikezhu
2965743cfe fix 首次更新时页面配置内容 2025-12-03 09:55:16 +08:00
cikezhu
9fa02d62e2 调整界面显示效果 2025-12-03 09:34:18 +08:00
cikezhu
b2bd0f3701 增加tracker映射配置 2025-12-03 09:26:01 +08:00
87 changed files with 25665 additions and 17459 deletions

View File

@@ -26,7 +26,7 @@
"name": "AI字幕自动生成(v2)",
"description": "使用whisper自动生成视频文件字幕,使用大模型翻译字幕成中文。",
"labels": "字幕",
"version": "2.3",
"version": "2.5",
"icon": "autosubtitles.jpeg",
"author": "TimoYoung",
"level": 1,
@@ -38,7 +38,8 @@
"v2.0": "1.引入任务队列 2.支持监听媒体入库自动生成字幕 3.增加任务状态展示界面",
"v2.1": "支持清除历史记录",
"v2.2": "fix",
"v2.3": "支持独立的大模型调用配置"
"v2.3": "支持独立的大模型调用配置",
"v2.5": "适配openai api v1"
}
},
"CustomSites": {
@@ -466,13 +467,15 @@
"name": "药丸签到",
"description": "药丸论坛签到。",
"labels": "站点",
"version": "2.0.0",
"version": "2.0.2",
"icon": "invites.png",
"author": "thsrite",
"level": 2,
"v2": true,
"release": true,
"history": {
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
"v2.0.1": "尝试修复签到失败问题新增使用代理、Cookie自动更新功能",
"v2.0.0": "修复签到失败问题新增账户登录签到功能、新增签到失败重试机制美化界面UI",
"v1.4.1": "更新签到域名前缀",
"v1.4": "自定义保留消息天数"
@@ -496,11 +499,13 @@
"name": "MoviePilot更新推送",
"description": "MoviePilot推送release更新通知、自动重启。",
"labels": "消息通知,自动更新",
"version": "1.4",
"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+版本"
}
@@ -808,13 +813,15 @@
"name": "ntfy消息推送",
"description": "支持使用ntfy发送消息通知。",
"labels": "消息通知",
"version": "1.1",
"version": "1.3",
"icon": "Ntfy_A.png",
"author": "lethargicScribe",
"level": 1,
"v2": true,
"history": {
"v1.1": "添加Token认证和用户动作"
"v1.1": "添加Token认证和用户动作",
"v1.2": "修复 ntfy 通知图标链接失效的问题",
"v1.3": "修复标题或文本为空时,通知发送失败的问题"
}
},
"GotifyMsg": {
@@ -845,7 +852,6 @@
"icon": "Macos_Sierra.png",
"author": "jxxghp",
"level": 1,
"v2": true,
"history": {
"v1.4.1": "修复Bing壁纸命名问题",
"v1.3": "适配MoviePilot v2.5.3+版本",

View File

@@ -24,11 +24,12 @@
"name": "站点刷流",
"description": "自动托管刷流,将会提高对应站点的访问频率。",
"labels": "刷流,仪表板",
"version": "4.3.4",
"version": "4.3.5",
"icon": "brush.jpg",
"author": "jxxghp,InfinityPacer,Seed680",
"level": 2,
"history": {
"v4.3.5": "提升匹配规则时的健壮性",
"v4.3.4": "添加RSS支持配置选项",
"v4.3.2": "增加'删除促销结束的未完成下载'功能",
"v4.3.1": "修复了一些细节问题",
@@ -43,12 +44,15 @@
"name": "站点自动签到",
"description": "自动模拟登录、签到站点。",
"labels": "站点",
"version": "2.7",
"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!",
"v2.5.4": "增加保号风险提示",
@@ -62,11 +66,15 @@
"name": "下载任务分类与标签",
"description": "自动给下载任务分类与打站点标签、剧集名称标签",
"labels": "下载管理",
"version": "2.2",
"version": "2.6",
"icon": "Youtube-dl_B.png",
"author": "叮叮当",
"level": 1,
"history": {
"v2.6": "增加站点/剧名前缀功能",
"v2.5": "优化采用公共服务自动清理未使用标签",
"v2.4": "增加自动清理未使用标签",
"v2.3": "增加tracker映射配置",
"v2.2": "MoviePilot V2 版本下载任务分类与标签插件"
}
},
@@ -89,11 +97,17 @@
"name": "媒体库服务器通知",
"description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。",
"labels": "消息通知,媒体库",
"version": "1.6",
"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时会尝试从媒体服务器中获取",
"v1.7.1": "未获取到tmdb信息则按原有逻辑发送电影显示海报",
"v1.7": "对TV剧集入库事件进行聚合避免消息轰炸。更新后如果打不开插件请重置插件",
"v1.6": "查询剧集图片兼容没有季集信息的情况",
"v1.5": "支持独立控制媒体服务器通知",
"v1.4": "MoviePilot V2 版本媒体库服务器通知插件"
@@ -372,11 +386,13 @@
"name": "MoviePilot更新推送",
"description": "MoviePilot推送release更新通知、自动重启。",
"labels": "消息通知,自动更新",
"version": "2.2",
"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+",
"v2.0": "兼容MoviePilot V2"
@@ -437,11 +453,13 @@
"name": "绕过Trackers",
"description": "提供tracker服务器IP地址列表帮助IPv6连接绕过OpenClash。",
"labels": "工具",
"version": "1.5.0",
"version": "1.5.2",
"icon": "Clash_A.png",
"author": "wumode",
"level": 2,
"history": {
"v1.5.2": "支持从站点首页获取最新 Trackers",
"v1.5.1": "新增 Tracker",
"v1.5.0": "新增 Page 界面; 支持通过`/check_ip` 命令检查IP; 改进 UI",
"v1.4.3": "修复 bug",
"v1.4.2": "修复插件动作",
@@ -457,11 +475,15 @@
"name": "IMDb源",
"description": "让探索推荐和媒体识别支持IMDb数据源。",
"labels": "探索",
"version": "1.6.3",
"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": "为元数据增加背景图",
"v1.6.3": "优化媒体识别速度; 适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
"v1.6.2": "修复 API 查询错误重试问题",
"v1.6.1": "添加中文主屏幕组件; 修复 bug",
@@ -478,7 +500,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": "修复按日期排序错误",
@@ -492,12 +514,14 @@
"name": "Clash Rule Provider",
"description": "随时为Clash添加一些额外的规则。",
"labels": "工具",
"version": "2.0.10",
"version": "2.1.2",
"icon": "Mihomo_Meta_A.png",
"author": "wumode",
"level": 1,
"release": true,
"history": {
"v2.1.2": "修复规则集序列化错误",
"v2.1.1": "增强数据管理功能",
"v2.0.10": "适配 MoviePilot 2.8.4",
"v2.0.9": "适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
"v2.0.8": "修复已知问题",
@@ -534,11 +558,15 @@
"name": "美剧生词标注",
"description": "根据CEFR等级为英语影视剧标注高级词汇。",
"labels": "英语",
"version": "1.1.4",
"version": "1.2.4",
"icon": "LexiAnnot.png",
"author": "wumode",
"level": 1,
"history": {
"v1.2.4": "增强数据校验",
"v1.2.3": "优化提示词",
"v1.2.1": "改进字幕样式获取方法",
"v1.2.0": "引入大模型候选词决策和词义丰富处理链; 支持读取系统智能体配置; 添加智能体工具; 优化通知样式; 改进 UI",
"v1.1.4": "优化字幕选择决策",
"v1.1.3": "适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
"v1.1.2": "使用子进程避免 spaCy 模型常驻内存",
@@ -574,5 +602,30 @@
"v1.2": "优化上报信息量",
"v1.1": "加强脱敏处理"
}
},
"TmdbWallpaper": {
"name": "登录壁纸本地化",
"description": "将MoviePilot的登录壁纸下载到本地。",
"labels": "壁纸,本地化",
"version": "1.4.2",
"icon": "Macos_Sierra.png",
"author": "jxxghp",
"level": 1,
"history": {
"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": "首次发布:可配置报告模块、历史记录页面、下载器通用化、存储路径可配置"
}
}
}

View File

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

View File

@@ -0,0 +1,114 @@
from typing import Tuple
from ruamel.yaml import CommentedMap
from app.log import logger
from app.core.config import settings
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
from app.plugins.autosignin.sites import _ISiteSigninHandler
class RousiPro(_ISiteSigninHandler):
"""
rousi pro 签到
"""
# 匹配的站点Url每一个实现类都需要设置为自己的站点Url
site_url = "rousi.pro"
@classmethod
def match(cls, url: str) -> bool:
"""
根据站点Url判断是否匹配当前站点签到类大部分情况使用默认实现即可
:param url: 站点Url
:return: 是否匹配如匹配则会调用该类的signin方法
"""
return True if StringUtils.url_equal(url, cls.site_url) else False
def signin(self, site_info: CommentedMap) -> Tuple[bool, str]:
"""
执行签到操作,固定签到
:param site_info: 站点信息含有站点Url、站点Cookie、UA等信息
:return: 签到结果信息
"""
site = site_info.get("name")
ua = site_info.get("ua")
token = site_info.get("token")
timeout = site_info.get("timeout")
if not token or token.strip() == "":
logger.error(f"{site} 签到失败,缺少 Authorization 信息")
return False, "签到失败,缺少 Authorization 信息"
headers = {
"Content-Type": "application/json",
"User-Agent": ua,
"Accept": "application/json, text/plain, */*",
"Authorization": token if token.startswith("Bearer ") else f"Bearer {token}"
}
body = {
"mode": "fixed"
}
res = RequestUtils(
headers=headers,
timeout=timeout,
proxies=settings.PROXY if site_info.get("proxy") else None,
).post_res(
url="https://rousi.pro/api/points/attendance",
json=body
)
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("code", -1) == 1:
logger.info(f"{site} 今日已签到")
return True, "今日已签到"
elif res is not None and res.status_code == 401:
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}"
else:
logger.error(f"{site} 签到失败,无法访问网站")
return False, "签到失败,无法访问网站"
def login(self, site_info: CommentedMap) -> Tuple[bool, str]:
"""
执行登录操作,访问签到统计接口更新站点最后活跃时间
:param site_info: 站点信息含有站点Url、站点Cookie、UA等信息
:return: 登录结果信息
"""
site = site_info.get("name")
ua = site_info.get("ua")
token = site_info.get("token")
timeout = site_info.get("timeout")
if not token or token.strip() == "":
logger.error(f"{site} 模拟登录失败,缺少 Authorization 信息")
return False, "模拟登录失败,缺少 Authorization 信息"
headers = {
"User-Agent": ua,
"Accept": "application/json, text/plain, */*",
"Authorization": token if token.startswith("Bearer ") else f"Bearer {token}"
}
res = RequestUtils(
headers=headers,
timeout=timeout,
proxies=settings.PROXY if site_info.get("proxy") else None,
).get_res(
url="https://rousi.pro/api/points/attendance/stats"
)
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} 模拟登录失败Authorization 已失效")
return False, "模拟登录失败Authorization 已失效"
elif res is not None:
logger.error(f"{site} 模拟登录失败,状态码:{res.status_code}")
return False, f"模拟登录失败,状态码:{res.status_code}"
else:
logger.error(f"{site} 模拟登录失败,无法访问网站")
return False, "模拟登录失败,无法访问网站"

View File

@@ -197,7 +197,6 @@ class BrushConfig:
"site_hr_active": true,
"site_skip_tips": true,
"rss_support": true
"
}]"""
return desc + config
@@ -263,7 +262,7 @@ class BrushFlow(_PluginBase):
# 插件图标
plugin_icon = "brush.jpg"
# 插件版本
plugin_version = "4.3.4"
plugin_version = "4.3.5"
# 插件作者
plugin_author = "jxxghp,InfinityPacer,Seed680"
# 作者主页
@@ -2247,16 +2246,34 @@ class BrushFlow(_PluginBase):
return False, "存在H&R"
# 包含规则
if brush_config.include and not (
re.search(brush_config.include, torrent.title, re.I) or re.search(brush_config.include,
torrent.description, re.I)):
return False, "不符合包含规则"
if brush_config.include:
try:
include_match = False
if torrent.title and re.search(brush_config.include, torrent.title, re.I):
include_match = True
elif torrent.description and re.search(brush_config.include, torrent.description, re.I):
include_match = True
if not include_match:
return False, "不符合包含规则"
except re.error:
logger.warning(f"包含规则正则表达式错误: {brush_config.include}")
return False, "包含规则正则表达式错误"
# 排除规则
if brush_config.exclude and (
re.search(brush_config.exclude, torrent.title, re.I) or re.search(brush_config.exclude,
torrent.description, re.I)):
return False, "符合排除规则"
if brush_config.exclude:
try:
exclude_match = False
if torrent.title and re.search(brush_config.exclude, torrent.title, re.I):
exclude_match = True
elif torrent.description and re.search(brush_config.exclude, torrent.description, re.I):
exclude_match = True
if exclude_match:
return False, "符合排除规则"
except re.error:
logger.warning(f"排除规则正则表达式错误: {brush_config.exclude}")
return False, "排除规则正则表达式错误"
# 种子大小GB
if brush_config.size:

View File

@@ -1,9 +1,8 @@
# Clash Rule Provider
**Clash Rule Provider** 生成适用于 [Meta Kernel](https://github.com/MetaCubeX/mihomo/tree/Meta) 定制配置,便于增加、修改和删除规则。
**Clash Rule Provider** 是一个[MoviePilot](https://github.com/jxxghp/MoviePilot)插件,用于生成适用于 [Meta Kernel](https://github.com/MetaCubeX/mihomo/tree/Meta) 定制配置,便于增加、修改和删除规则,基于 Meta 内核丰富的代理组配置,提供灵活的路由功能
- 即时通知 Clash 刷新规则集合
- 基于 Meta 内核丰富的代理组配置,提供灵活的路由功能
- 支持按大洲和国家分组节点
- 支持覆写出站代理
- GEO 规则输入提示
@@ -13,7 +12,7 @@
### 规则集规则
用于添加能够在 Clash 中即时生效的规则Clash Rule Provider 会根据每条规则的**出站**生成相应的**规则集合** `📂<-` + `出站`
用于添加能够在 Clash 中即时生效的规则Clash Rule Provider 会根据每条规则的**出站**生成相应的**规则集合**。
### 置顶规则
@@ -41,4 +40,28 @@
### Hosts
如果需要自动更新此处使用的 Cloudflare IP, 可以通过其它[插件](https://github.com/wumode/MoviePilot-Addons)实现。
如果需要自动更新此处使用的 Cloudflare IP, 可以通过其它[插件](https://github.com/wumode/MoviePilot-Addons)实现。
### 配置隐藏
如果希望某些代理组、规则或是代理节点仅在特定条件下可见,可以使用可见性限制功能。例如,可以设置某些规则集仅在特定网络环境下可见。
自定义表达式是个返回`bool`值的Python表达式可以使用以下变量:
```python
# 请求 URL
url: str
# 客户端的IP地址
client_host: str
# 请求的标识符
identifier: str | None = None
# User-Agent
user_agent : str | None = None
```
表达式示例:
- `client_host == '192.168.1.1'`
- `identifier == 'office-laptop' and 'Mobile' in user_agent`
## 远程组件
[ClashRuleProvider-Remote](https://github.com/wumode/ClashRuleProvider-Remote)

View File

@@ -1,5 +1,3 @@
import asyncio
import copy
import pytz
from datetime import datetime, timedelta
from typing import Any, Optional, List, Dict, Tuple
@@ -9,21 +7,28 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from pydantic import ValidationError
from app.api.endpoints.plugin import register_plugin_api
from app.core.config import settings, global_vars
from app.core.event import eventmanager, Event
from app.log import logger
from app.plugins import _PluginBase
from app.schemas.types import EventType, NotificationType
from app.utils.string import StringUtils
from .api import ClashRuleProviderApi, apis
from .base import _ClashRuleProviderBase
from .base import Constant
from .config import PluginConfig
from .helper.utilsprovider import UtilsProvider
from .state import PluginState
from .models import ProxyGroup, ProxyGroups, RuleProviders, Hosts
from .models.api import SubscriptionsInfo
from .models.configuration import ClashConfig
from .models.datapatch import DataPatch
from .models.types import DataKey, DataSource
from .state import PluginState, GeoRules
from .services import ClashRuleProviderService
from .store import PluginStore
class ClashRuleProvider(_ClashRuleProviderBase):
class ClashRuleProvider(_PluginBase):
# 插件名称
plugin_name = "Clash Rule Provider"
# 插件描述
@@ -31,7 +36,7 @@ class ClashRuleProvider(_ClashRuleProviderBase):
# 插件图标
plugin_icon = "Mihomo_Meta_A.png"
# 插件版本
plugin_version = "2.0.10"
plugin_version = "2.1.2"
# 插件作者
plugin_author = "wumode"
# 作者主页
@@ -42,110 +47,84 @@ class ClashRuleProvider(_ClashRuleProviderBase):
plugin_order = 99
# 可使用的用户级别
auth_level = 1
# 主线程事件循环
event_loop: Optional[asyncio.AbstractEventLoop] = None
# Runtime variables
services: ClashRuleProviderService
api: ClashRuleProviderApi
def __init__(self):
# Configuration attributes
super().__init__()
state: PluginState
scheduler: AsyncIOScheduler | None = None
def init_plugin(self, conf: dict = None):
self.stop_service()
self.state = PluginState()
self.config = PluginConfig()
self.store = PluginStore(self.__class__.__name__)
# Load persistent data into state
self.state.proxy_groups = self.get_data("proxy_groups") or []
self.state.extra_proxies = self.get_data("extra_proxies") or []
self.state.subscription_info = self.get_data("subscription_info") or {}
self.state.rule_provider = self.get_data("rule_provider") or {}
self.state.rule_providers = self.get_data("extra_rule_providers") or {}
self.state.ruleset_names = self.get_data("ruleset_names") or {}
self.state.acl4ssr_providers = self.get_data("acl4ssr_providers") or {}
self.state.clash_configs = self.get_data("clash_configs") or {}
self.state.hosts = self.get_data("hosts") or []
self.state.overwritten_region_groups = self.get_data("overwritten_region_groups") or {}
self.state.overwritten_proxies = self.get_data("overwritten_proxies") or {}
self.state.geo_rules = self.get_data("geo_rules") or {'geoip': [], 'geosite': []}
self.state = PluginState(self.__class__.__name__)
self.upgrade_data()
if conf:
try:
raw_conf = PluginConfig.upgrade_conf(conf)
self.config = PluginConfig.model_validate(raw_conf)
self.state.config = PluginConfig.model_validate(conf)
except ValidationError as e:
logger.error(f"解析配置出错: {e}")
return
self._update_config()
if self.config.enabled:
if self.state.config.enabled:
self._initialize_plugin()
def upgrade_data(self):
data_version = self.get_data(DataKey.DATA_VERSION) or "2.0.10"
if StringUtils.compare_version(data_version, '<', "2.1.0"):
from .helper.dataupgrader import v_2_1_0
v_2_1_0.upgrade(self.__class__.__name__)
def _initialize_plugin(self):
self.state.proxies_manager.clear()
self.state.top_rules_manager.clear()
self.state.ruleset_rules_manager.clear()
if ClashRuleProvider.event_loop is None:
ClashRuleProvider.event_loop = global_vars.loop
self.scheduler = AsyncIOScheduler(timezone=settings.TZ, event_loop=ClashRuleProvider.event_loop)
self.services = ClashRuleProviderService(self.__class__.__name__, self.config, self.state, self.store,
self.scheduler)
self.api = ClashRuleProviderApi(self.services, self.config)
self.scheduler = AsyncIOScheduler(timezone=settings.TZ, event_loop=global_vars.loop)
self.services = ClashRuleProviderService(self.__class__.__name__, self.state, self.scheduler)
self.api = ClashRuleProviderApi(self.services, self.state.config)
try:
self.state.clash_template_dict = yaml.load(self.config.clash_template, Loader=yaml.SafeLoader) or {}
if not isinstance(self.state.clash_template_dict, dict):
self.state.clash_template_dict = {}
clash_template_dict = yaml.load(self.state.config.clash_template, Loader=yaml.SafeLoader) or {}
if isinstance(clash_template_dict, dict):
self.state.clash_template = ClashConfig.model_validate(clash_template_dict)
else:
logger.error("Invalid clash template yaml")
except yaml.YAMLError as exc:
logger.error(f"Error loading clash template yaml: {exc}")
self.state.clash_template_dict = {}
# Normalize template
for key, default in self.DEFAULT_CLASH_CONF.items():
self.state.clash_template_dict.setdefault(key, copy.deepcopy(default))
except Exception as ve:
logger.error(f"Error validating clash template config: {ve}")
self.services.load_rules()
self.services.load_proxies()
self.state.subscription_info = {url: self.state.subscription_info.get(url) or {}
for url in self.config.sub_links}
for _, sub_info in self.state.subscription_info.items():
sub_info.setdefault('enabled', True)
self.state.clash_configs = {url: self.state.clash_configs[url] for url in self.config.sub_links if
self.state.clash_configs.get(url)}
# Accessing subscription_info property triggers load from DB.
sub_info_map = self.state.subscription_info
sub_info_map.update(self.state.config.sub_links)
self.state.subscription_info = sub_info_map
for url, conf in self.state.clash_configs.items():
self.services.add_proxies_to_manager(conf.get('proxies', []),
f"Sub:{UtilsProvider.get_url_domain(url)}-{abs(hash(url))}")
self.services.add_proxies_to_manager(self.state.clash_template_dict.get('proxies', []), 'Template')
# sub_configs loaded from DB. Filter by current sub_links.
sub_configs_map = self.state.sub_configs
sub_configs_map = {url: sub_configs_map[url] for url in self.state.config.sub_links if sub_configs_map.get(url)}
self.state.sub_configs = sub_configs_map
self.services.check_proxies_lifetime()
self.services.check_patch_lifetime()
self._start_scheduler()
def _start_scheduler(self):
self.scheduler.start()
now = datetime.now(tz=pytz.timezone(settings.TZ))
self.scheduler.add_job(self.services.async_refresh_subscriptions, "date",
run_date=now + timedelta(seconds=2), misfire_grace_time=self.MISFIRE_GRACE_TIME)
if self.config.hint_geo_dat:
run_date=now + timedelta(seconds=2), misfire_grace_time=Constant.MISFIRE_GRACE_TIME)
if self.state.config.hint_geo_dat:
self.scheduler.add_job(self.services.async_refresh_geo_dat, "date",
run_date=now + timedelta(seconds=3), misfire_grace_time=self.MISFIRE_GRACE_TIME)
else:
self.state.geo_rules = {'geoip': [], 'geosite': []}
if self.config.enable_acl4ssr:
run_date=now + timedelta(seconds=3), misfire_grace_time=Constant.MISFIRE_GRACE_TIME)
if self.state.config.enable_acl4ssr:
self.scheduler.add_job(self.services.async_refresh_acl4ssr, "date",
run_date=now + timedelta(seconds=4), misfire_grace_time=self.MISFIRE_GRACE_TIME)
else:
self.state.acl4ssr_providers = {}
run_date=now + timedelta(seconds=4), misfire_grace_time=Constant.MISFIRE_GRACE_TIME)
def get_state(self) -> bool:
return self.config.enabled
return self.state.config.enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
@@ -165,10 +144,10 @@ class ClashRuleProvider(_ClashRuleProviderBase):
{"key": "clash_info", "name": "Clash Info"},
{"key": "traffic_stats", "name": "Traffic Stats"}
]
return [c for c in components if c.get("name") in self.config.dashboard_components]
return [c for c in components if c.get("name") in self.state.config.dashboard_components]
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
clash_available = bool(self.config.dashboard_url and self.config.dashboard_secret)
clash_available = bool(self.state.config.dashboard_url and self.state.config.dashboard_secret)
components = {'clash_info': {'title': 'Clash Info', 'md': 4},
'traffic_stats': {'title': 'Traffic Stats', 'md': 8}}
col_config = {'cols': 12, 'md': components.get(key, {}).get('md', 4)}
@@ -176,7 +155,7 @@ class ClashRuleProvider(_ClashRuleProviderBase):
'title': components.get(key, {}).get('title', 'Clash Info'),
'border': True,
'clash_available': clash_available,
'secret': self.config.dashboard_secret,
'secret': self.state.config.dashboard_secret,
}
return col_config, global_config, []
@@ -193,18 +172,18 @@ class ClashRuleProvider(_ClashRuleProviderBase):
logger.error(f"退出插件失败:{e}")
def get_service(self) -> List[Dict[str, Any]]:
if self.get_state() and self.config.auto_update_subscriptions and self.config.sub_links:
if self.get_state() and self.state.config.auto_update_subscriptions and self.state.config.sub_links:
return [{
"id": "ClashRuleProvider",
"name": "定时更新订阅",
"trigger": CronTrigger.from_crontab(self.config.cron_string),
"trigger": CronTrigger.from_crontab(self.state.config.cron_string),
"func": self.refresh_subscription_service,
"kwargs": {}
}]
return []
async def refresh_subscription_service(self):
if not self.config.sub_links:
if not self.state.config.sub_links:
return
res = await self.services.async_refresh_subscriptions()
messages = []
@@ -214,37 +193,36 @@ class ClashRuleProvider(_ClashRuleProviderBase):
message = f"{index}. 「 {host_name}\n"
index += 1
if result:
sub_info = self.state.subscription_info.get(url, {})
if sub_info.get('total') is not None:
used = sub_info.get('download', 0) + sub_info.get('upload', 0)
remaining = sub_info.get('total', 0) - used
sub_info = self.state.subscription_info.get(url)
if sub_info.total:
used = sub_info.download + sub_info.upload
remaining = sub_info.total- used
info = (
f"节点数量: {sub_info.get('proxy_num', 0)}\n"
f"节点数量: {sub_info.proxy_num}\n"
f"已用流量: {UtilsProvider.format_bytes(used)}\n"
f"剩余流量: {UtilsProvider.format_bytes(remaining)}\n"
f"总量: {UtilsProvider.format_bytes(sub_info.get('total', 0))}\n"
f"过期时间: {UtilsProvider.format_expire_time(sub_info.get('expire', 0))}"
f"总量: {UtilsProvider.format_bytes(sub_info.total)}\n"
f"过期时间: {UtilsProvider.format_expire_time(sub_info.expire)}"
)
else:
info = f"节点数量: {sub_info.get('proxy_num', 0)}\n"
info = f"节点数量: {sub_info.proxy_num}\n"
message += f"订阅更新成功\n{info}"
else:
message += '订阅更新失败'
messages.append(message)
if self.config.notify:
self.post_message(title=f"{self.plugin_name}",
mtype=NotificationType.Plugin,
text='\n'.join(messages)
)
if self.state.config.notify:
self.post_message(
title=f"{self.plugin_name}", mtype=NotificationType.Plugin, text='\n'.join(messages)
)
def _update_config(self):
conf = self.config.model_dump(by_alias=True)
conf = self.state.config.model_dump(by_alias=True)
self.update_config(conf)
def update_best_cf_ip(self, ips: List[str]):
self.config.best_cf_ip = [*ips]
self.state.config.best_cf_ip = [*ips]
conf = self.get_config()
conf['best_cf_ip'] = self.config.best_cf_ip
conf['best_cf_ip'] = self.state.config.best_cf_ip
self.update_config(conf)
@eventmanager.register(EventType.PluginAction)
@@ -258,3 +236,13 @@ class ClashRuleProvider(_ClashRuleProviderBase):
if isinstance(ips, list):
logger.info("更新 Cloudflare 优选 IP ...")
self.update_best_cf_ip(ips)
@eventmanager.register(EventType.PluginReload)
def reload(self, event):
"""
响应插件重载事件
"""
plugin_id = event.event_data.get("plugin_id")
if plugin_id == self.__class__.__name__:
logger.info("正在注册 API ...")
register_plugin_api(plugin_id=plugin_id)

View File

@@ -5,7 +5,7 @@ from typing import Any, Dict, List, Callable, Optional, Literal
import websockets
import yaml
from fastapi import HTTPException, Request, status, Response
from fastapi import HTTPException, Request, status, Response, Body
from fastapi.responses import PlainTextResponse
from sse_starlette.sse import EventSourceResponse
@@ -14,8 +14,10 @@ from app.core.config import settings
from app.log import logger
from .config import PluginConfig
from .models import ProxyGroup
from .models.api import RuleData, Connectivity, Subscription, RuleProviderData, SubscriptionInfo, HostData
from .models import ProxyGroup, Proxy, HostData, RuleData, RuleProvider, RuleProviderData
from .models.api import Connectivity, SubscriptionSetting, ConfigRequest
from .models.metadata import Metadata
from .models.types import RuleSet, DataSource
from .services import ClashRuleProviderService
@@ -27,14 +29,16 @@ class ApiCollection:
methods: List[Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD', 'TRACE']],
allow_anonymous: Optional[bool] = None,
auth: Optional[str] = None,
summary: Optional[str] = ''):
summary: Optional[str] = '',
**kwargs):
def decorator(func: Callable):
route_meta: Dict[str, Any] = {
'path': path,
'methods': methods,
'summary': summary,
'endpoint': func
'endpoint': func,
**kwargs
}
if allow_anonymous is not None:
route_meta['allow_anonymous'] = allow_anonymous
@@ -63,7 +67,7 @@ class ClashRuleProviderApi:
self.services: ClashRuleProviderService = services
self.config = config
@apis.register(path='/connectivity', methods=['POST'], auth='bear', summary='测试连接')
@apis.register(path="/connectivity", methods=["POST"], auth="bear", summary="测试连接")
async def test_connectivity(self, item: Connectivity) -> schemas.Response:
success, message = await self.services.test_connectivity(item.clash_apis, item.sub_links)
return schemas.Response(success=success, message=message)
@@ -71,7 +75,7 @@ class ClashRuleProviderApi:
@apis.register(path="/clash-outbound", methods=["GET"], auth="bear", summary="获取所有出站")
def get_clash_outbound(self) -> schemas.Response:
outbound = self.services.clash_outbound()
return schemas.Response(success=True, data={"outbound": outbound})
return schemas.Response(success=True, data=outbound)
@apis.register(path="/status", methods=["GET"], auth="bear", summary="插件状态")
def get_status(self) -> schemas.Response:
@@ -79,56 +83,80 @@ class ClashRuleProviderApi:
return schemas.Response(success=True, data=data)
@apis.register(path="/rules/{ruleset}", methods=["GET"], auth="bear", summary="获取指定集合中的规则")
def get_rules(self, ruleset: Literal['ruleset', 'top']) -> schemas.Response:
def get_rules(self, ruleset: RuleSet) -> schemas.Response:
data = self.services.get_rules(ruleset)
return schemas.Response(success=True, data={'rules': data})
return schemas.Response(success=True, data=data)
@apis.register(path="/reorder-rules/{ruleset}/{target_priority}", methods=["PUT"], auth="bear",
summary="重新排序规则")
def reorder_rules(self, ruleset: Literal['ruleset', 'top'], target_priority: int,
rule_data: RuleData) -> schemas.Response:
moved_priority = rule_data.priority
success, message = self.services.reorder_rules(ruleset, moved_priority, target_priority)
@apis.register(path="/reorder-rules/{ruleset}/{target}", methods=["PUT"], auth="bear", summary="重新排序规则")
def reorder_rules(self, ruleset: RuleSet, target: int,
moved_priority: int = Body(..., embed=True)) -> schemas.Response:
success, message = self.services.reorder_rules(ruleset, moved_priority, target)
return schemas.Response(success=success, message=message)
@apis.register(path="/rules/{ruleset}/{priority}", methods=["PATCH"], auth="bear", summary="更新规则")
def update_rule(self, ruleset: Literal['ruleset', 'top'], priority: int, rule_data: RuleData) -> schemas.Response:
def update_rule(self, ruleset: RuleSet, priority: int, rule_data: RuleData) -> schemas.Response:
success, message = self.services.update_rule(ruleset, priority, rule_data)
return schemas.Response(success=success, message=message)
@apis.register(path="/rules/{ruleset}", methods=["POST"], auth="bear", summary="添加规则")
def add_rule(self, ruleset: Literal['ruleset', 'top'], rule_data: RuleData) -> schemas.Response:
def add_rule(self, ruleset: RuleSet, rule_data: RuleData = Body(...)) -> schemas.Response:
success, message = self.services.add_rule(ruleset, rule_data)
return schemas.Response(success=success, message=message)
@apis.register(path="/rules/{ruleset}/{priority}/meta", methods=["PATCH"], auth="bear", summary="更新规则元数据")
def update_rule_meta(self, ruleset: RuleSet, priority: int, meta: Metadata = Body(...)) -> schemas.Response:
success, message = self.services.update_rule_meta(ruleset, priority, meta)
return schemas.Response(success=success, message=message)
@apis.register(path="/rules/{ruleset}/metadata/disabled", methods=["POST"], auth="bear", summary="设置规则状态")
def set_rules_status(self, ruleset: RuleSet, priorities: dict[int, bool] = Body(...)):
self.services.set_rules_status(ruleset, priorities)
@apis.register(path="/rules/{ruleset}/{priority}", methods=["DELETE"], auth="bear", summary="删除规则")
def delete_rule(self, ruleset: Literal['ruleset', 'top'], priority: int) -> schemas.Response:
def delete_rule(self, ruleset: RuleSet, priority: int) -> schemas.Response:
self.services.delete_rule(ruleset, priority)
return schemas.Response(success=True)
@apis.register(path="/rules/{ruleset}", methods=["DELETE"], auth="bear", summary="批量删除规则")
def delete_rules(self, ruleset: RuleSet, priority: list[int] = Body(...)) -> schemas.Response:
self.services.delete_rules(ruleset, priority)
return schemas.Response(success=True)
@apis.register(path="/refresh", methods=["PUT"], auth="bear", summary="更新订阅")
async def refresh_subscription(self, subscription: Subscription) -> schemas.Response:
success, message = await self.services.refresh_subscription(subscription.url)
async def refresh_subscription(self, url: str = Body(..., embed=True)) -> schemas.Response:
success, message = await self.services.refresh_subscription(url)
return schemas.Response(success=success, message=message)
@apis.register(path="/rule-providers", methods=["GET"], auth="bear", summary="获取规则集合")
@apis.register(path="/rule-providers", methods=["GET"], auth="bear", summary="获取规则集合",
response_model=schemas.Response, response_model_exclude_none=True)
def get_rule_providers(self) -> schemas.Response:
return schemas.Response(success=True, data=self.services.rule_providers())
return schemas.Response(success=True, data=self.services.state.all_rule_providers)
@apis.register(path="/rule-providers/{name}", methods=["POST"], auth="bear", summary="更新规则集合")
@apis.register(path="/rule-providers/{name}", methods=["POST"], auth="bear", summary="添加规则集合")
def add_rule_provider(self, name: str, item: RuleProvider):
success, message = self.services.add_rule_provider(name, item)
return schemas.Response(success=success, message=message)
@apis.register(path="/rule-providers/{name}", methods=["PATCH"], auth="bear", summary="更新规则集合")
def update_rule_provider(self, name: str, item: RuleProviderData):
success, message = self.services.update_rule_provider(name, item)
return schemas.Response(success=success, message=message)
@apis.register(path="/rule-providers/{name}/meta", methods=["PATCH"], auth="bear", summary="更新规则集元数据")
def update_rule_providers_meta(self, name: str, meta: Metadata):
success, message = self.services.update_rule_providers_meta(name, meta)
return schemas.Response(success=success, message=message)
@apis.register(path="/rule-providers/{name}", methods=["DELETE"], auth="bear", summary="删除规则集合")
def delete_rule_provider(self, name: str):
self.services.delete_rule_provider(name)
return schemas.Response(success=True)
@apis.register(path="/proxies", methods=["GET"], auth="bear", summary="获取出站代理")
@apis.register(path="/proxies", methods=["GET"], auth="bear", summary="获取代理",
response_model=schemas.Response, response_model_exclude_none=True)
def get_proxies(self):
proxies = self.services.get_all_proxies_with_details()
return schemas.Response(success=True, data={'proxies': proxies})
proxies = self.services.get_proxies()
return schemas.Response(success=True, data=proxies)
@apis.register(path="/proxies/{name}", methods=["DELETE"], auth="bear", summary="删除出站代理")
def delete_proxy(self, name: str):
@@ -136,39 +164,61 @@ class ClashRuleProviderApi:
return schemas.Response(success=True)
@apis.register(path="/proxies", methods=["PUT"], auth="bear", summary="添加出站代理")
def import_proxies(self, params: Dict[str, Any]):
success, message = self.services.import_proxies(params)
def import_proxies(self, vehicle: Literal["YAML", "LINK"] = Body(...), payload: str = Body(...)):
success, message = self.services.import_proxies(vehicle, payload)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxies/{name}", methods=["PATCH"], auth="bear", summary="更新出站代理")
def update_proxy(self, name: str, param: Dict[str, Any]) -> schemas.Response:
success, message = self.services.update_proxy(name, param)
def update_proxy(self, name: str, source: DataSource = Body(...), proxy: Proxy = Body(...)) -> schemas.Response:
success, message = self.services.update_proxy(name, source, proxy)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxy-groups", methods=["GET"], auth="bear", summary="获取代理组")
@apis.register(path="/proxies/{name}/meta", methods=["PATCH"], auth="bear", summary="更新代理组元数据")
def update_proxy_meta(self, name: str, meta: Metadata):
success, message = self.services.update_proxy_meta(name, meta)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxies/{name}/patch", methods=["DELETE"], auth="bear", summary="删除代理补丁")
def delete_proxy_patch(self, name: str):
success, message = self.services.delete_proxy_patch(name)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxy-groups", methods=["GET"], auth="bear", summary="获取代理组",
response_model=schemas.Response, response_model_exclude_none=True)
def get_proxy_groups(self):
proxy_groups = self.services.get_all_proxy_groups_with_source()
return schemas.Response(success=True, data={'proxy_groups': proxy_groups})
proxy_groups = self.services.get_proxy_groups()
return schemas.Response(success=True, data=proxy_groups)
@apis.register(path="/proxy-groups/{name}", methods=["DELETE"], auth="bear", summary="删除代理组")
def delete_proxy_group(self, name: str):
success, message = self.services.delete_proxy_group(name)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxy-groups/{name}/meta", methods=["PATCH"], auth="bear", summary="更新代理组元数据")
def update_proxy_group_meta(self, name: str, meta: Metadata):
success, message = self.services.update_proxy_group_meta(name, meta)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxy-groups/{name}/patch", methods=["DELETE"], auth="bear", summary="删除代理组补丁")
def delete_proxy_group_patch(self, name: str):
success, message = self.services.delete_proxy_group_patch(name)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxy-groups", methods=["POST"], auth="bear", summary="添加代理组")
def add_proxy_group(self, item: ProxyGroup):
success, message = self.services.add_proxy_group(item)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxy-groups/{previous_name}", methods=["PATCH"], auth="bear", summary="更新代理组")
def update_proxy_group(self, previous_name: str, item: ProxyGroup):
success, message = self.services.update_proxy_group(previous_name, item)
@apis.register(path="/proxy-groups/{name}", methods=["PATCH"], auth="bear", summary="更新代理组")
def update_proxy_group(self, name: str, source: DataSource = Body(...), proxy_group: ProxyGroup = Body(...)):
success, message = self.services.update_proxy_group(name, source, proxy_group)
return schemas.Response(success=success, message=message)
@apis.register(path="/proxy-providers", methods=["GET"], auth="bear", summary="获取代理集合")
@apis.register(path="/proxy-providers", methods=["GET"], auth="bear", summary="获取代理集合",
response_model=schemas.Response, response_model_exclude_none=True)
def get_proxy_providers(self):
proxy_providers = self.services.all_proxy_providers()
return schemas.Response(success=True, data={'proxy_providers': proxy_providers})
proxy_providers = self.services.state.all_proxy_providers
return schemas.Response(success=True, data=proxy_providers)
@apis.register(path="/ruleset", methods=["GET"], allow_anonymous=True, summary="获取规则集规则")
def get_ruleset(self, name: str, apikey: str) -> PlainTextResponse:
@@ -181,43 +231,49 @@ class ClashRuleProviderApi:
return PlainTextResponse(content=res, media_type="application/x-yaml")
@apis.register(path="/import", methods=["POST"], auth="bear", summary="导入规则")
def import_rules(self, params: Dict[str, Any]):
self.services.import_rules(params)
def import_rules(self, vehicle: Literal["YAML"] = Body(...), payload: str = Body(...)):
self.services.import_rules(vehicle, payload)
return schemas.Response(success=True)
@apis.register(path="/hosts", methods=["GET"], auth="bear", summary="获取 Hosts")
def get_hosts(self):
return schemas.Response(success=True, data={'hosts': self.services.get_hosts()})
return schemas.Response(success=True, data=self.services.state.hosts.model_dump(mode='json'))
@apis.register(path="/hosts", methods=["POST"], auth="bear", summary="更新 Hosts")
def update_hosts(self, host: HostData):
success, message = self.services.update_hosts(host)
def update_hosts(self, domain: str = Body(..., embed=True), host: HostData = Body(...)):
success, message = self.services.update_hosts(domain, host)
return schemas.Response(success=success, message=message)
@apis.register(path="/hosts", methods=["DELETE"], auth="bear", summary="删除 Hosts")
def delete_host(self, host: HostData):
success, message = self.services.delete_host(host)
@apis.register(path="/hosts/{domain}", methods=["DELETE"], auth="bear", summary="删除 Hosts")
def delete_host(self, domain: str):
success, message = self.services.delete_host(domain)
return schemas.Response(success=success, message=message)
@apis.register(path="/subscription-info", methods=["POST"], auth="bear", summary="更新订阅信息")
def update_subscription_info(self, sub_info: SubscriptionInfo):
def update_subscription_info(self, sub_info: SubscriptionSetting):
self.services.update_subscription_info(sub_info)
return schemas.Response(success=True)
@apis.register(path="/config", methods=["GET"], allow_anonymous=bool(True), summary="获取 Clash 配置")
def get_clash_config(self, apikey: str, request: Request):
def get_clash_config(self, apikey: str, request: Request, identifier: str | None = None):
_apikey = self.config.apikey or settings.API_TOKEN
param = ConfigRequest(
url=str(request.url),
client_host=request.client.host,
identifier=identifier,
user_agent=request.headers.get("user-agent")
)
if not secrets.compare_digest(apikey, _apikey):
raise HTTPException(status_code=403, detail="Invalid API Key")
logger.info(f"{request.client.host} 正在获取配置")
config = self.services.clash_config()
config = self.services.build_clash_config(param=param)
if not config:
raise HTTPException(status_code=500, detail="配置不可用")
res = yaml.dump(config, allow_unicode=True, sort_keys=False)
config_dict = config.model_dump(mode="json", by_alias=True, exclude_none=True)
res = yaml.dump(config_dict, allow_unicode=True, sort_keys=False)
sub_info = self.services.get_subscription_user_info()
headers = {'Subscription-Userinfo': f'upload={sub_info["upload"]}; download={sub_info["download"]}; '
f'total={sub_info["total"]}; expire={sub_info["expire"]}'}
headers = {'Subscription-Userinfo': sub_info.header}
return Response(headers=headers, content=res, media_type="text/yaml")
@apis.register(path="/clash/proxy/{path:path}", methods=["GET"], auth="bear", summary="转发 Clash API 请求")

View File

@@ -1,34 +1,8 @@
from abc import ABC
from typing import Final, Literal, Dict
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.plugins import _PluginBase
from .config import PluginConfig
from .state import PluginState
from .store import PluginStore
from typing import Final
class _ClashRuleProviderBase(_PluginBase, ABC):
# Constants
DEFAULT_CLASH_CONF: Final[
Dict[Literal['rules', 'rule-providers', 'proxies', 'proxy-groups', 'proxy-providers'], dict | list]] = {
'rules': [], 'rule-providers': {},
'proxies': [], 'proxy-groups': [], 'proxy-providers': {}
}
OVERWRITTEN_PROXIES_LIFETIME: Final[int] = 10
class Constant:
PATCH_LIFESPAN: Final[int] = 10
ACL4SSR_API: Final[str] = "https://api.github.com/repos/ACL4SSR/ACL4SSR"
METACUBEX_RULE_DAT_API: Final[str] = "https://api.github.com/repos/MetaCubeX/meta-rules-dat"
MISFIRE_GRACE_TIME: Final[int] = 120
KEY_TOP_RULES: Final[str] = "top_rules"
KEY_RULESET_RULES: Final[str] = "ruleset_rules"
KEY_PROXIES: Final[str] = "proxies"
KEY_PROXY_GROUPS: Final[str] = "proxy-groups"
KEY_NAME: Final[str] = "name"
# Runtime variables
state: PluginState
config: PluginConfig
store: PluginStore
scheduler: AsyncIOScheduler = None

View File

@@ -1,6 +1,6 @@
from typing import Any, Dict, List, Optional
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
from .models.api import ClashApi
@@ -8,10 +8,10 @@ from .models.api import ClashApi
class SubscriptionConfig(BaseModel):
url: str
rules: Optional[bool] = True
rule_providers: Optional[bool] = Field(True, alias='rule-providers')
rule_providers: Optional[bool] = Field(default=True, alias='rule-providers')
proxies: Optional[bool] = True
proxy_groups: Optional[bool] = Field(True, alias='proxy-groups')
proxy_providers: Optional[bool] = Field(True, alias='proxy-providers')
proxy_groups: Optional[bool] = Field(default=True, alias='proxy-groups')
proxy_providers: Optional[bool] = Field(default=True, alias='proxy-providers')
@field_validator('url')
@classmethod
@@ -23,10 +23,14 @@ class PluginConfig(BaseModel):
"""
A dataclass to hold all the configuration of the ClashRuleProvider plugin.
"""
model_config = ConfigDict(
str_strip_whitespace=True,
)
enabled: bool = False
proxy: bool = False
notify: bool = False
subscriptions_config: List[SubscriptionConfig] = Field(default_factory=list)
subscriptions_config: list[SubscriptionConfig] = Field(default_factory=list)
movie_pilot_url: str = ''
cron_string: str = '30 12 * * *'
timeout: int = 10
@@ -46,6 +50,8 @@ class PluginConfig(BaseModel):
apikey: Optional[str] = None
clash_dashboards: List[ClashApi] = Field(default_factory=list)
active_dashboard: Optional[int] = None
identifiers: list[str] = Field(default_factory=list)
cache_ttl: int = 3600
@field_validator('clash_dashboards')
@classmethod
@@ -62,32 +68,6 @@ class PluginConfig(BaseModel):
def validate_movie_pilot_url(cls, v: str):
return v.rstrip('/')
@field_validator('ruleset_prefix')
@classmethod
def validate_ruleset_prefix(cls, v: str):
return v.strip()
@field_validator('acl4ssr_prefix')
@classmethod
def validate_acl4ssr_prefix(cls, v: str):
return v.strip()
@staticmethod
def upgrade_conf(conf: Dict[str, Any]) -> Dict[str, Any]:
if conf.get('sub_links'):
subscriptions_config = conf.get('subscriptions_config') or []
subscriptions_config.extend(
[{'url': url, 'rules': True, 'rule-providers': True, 'proxies': True, 'proxy-groups': True,
'proxy-providers': True}
for url in conf['sub_links']]
)
conf['subscriptions_config'] = subscriptions_config
if conf.get('clash_dashboard_url') and conf.get('clash_dashboard_secret'):
clash_dashboards = conf.get('clash_dashboards') or []
clash_dashboards.append({'url': conf.get('clash_dashboard_url'), 'secret': conf.get('clash_dashboard_secret')})
conf['clash_dashboards'] = clash_dashboards
return conf
@property
def sub_links(self) -> List[str]:
return [sub.url for sub in self.subscriptions_config]
@@ -105,3 +85,6 @@ class PluginConfig(BaseModel):
if self.active_dashboard is not None and self.active_dashboard in range(len(self.clash_dashboards)):
dashboard_secret = self.clash_dashboards[self.active_dashboard].secret
return dashboard_secret
def get_sub_conf(self, url: str) -> SubscriptionConfig:
return next((conf for conf in self.subscriptions_config if conf.url == url), SubscriptionConfig(url=url))

View File

@@ -0,0 +1,3 @@
const MetaLogo = "/assets/Meta-uqWbsmWL.png";
export { MetaLogo as M };

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
.plugin-config[data-v-3fef8398] {
margin: 0 auto;
}

View File

@@ -1,4 +0,0 @@
.plugin-config[data-v-5f383f33] {
margin: 0 auto;
}

View File

@@ -1,4 +1,4 @@
.dashboard-widget[data-v-de7a088e] {
.dashboard-widget[data-v-318a5020] {
font-family: 'Inter', sans-serif;
}

View File

@@ -0,0 +1,84 @@
.rule-card[data-v-5bf9d562]:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
}
.proxy-group-card[data-v-88bfc397]:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
}
.proxy-card[data-v-e80a10d3]:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
}
.subscription-card[data-v-97c0f367] {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.subscription-card[data-v-97c0f367]: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] {
background: rgba(var(--v-theme-surface-variant), 0.05);
}
.bg-surface-variant-lighten[data-v-97c0f367] {
background: rgba(var(--v-theme-surface-variant), 0.02);
}
.stats-grid[data-v-97c0f367] {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.bounce[data-v-6a1d5a83] {
animation: bounce-6a1d5a83 2s infinite;
}
@keyframes bounce-6a1d5a83 {
0%,
20%,
50%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-20px);
}
60% {
transform: translateY(-10px);
}
}
.rule-provider-card[data-v-01e2e8ef]:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
}
.host-card[data-v-a5d6e0e6]:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
}
.search-field {
max-width: 25rem;
}
.clash-data-table {
max-height: 40rem;
overflow-y: auto;
}
.drag-handle {
cursor: move;
}
.dragging-item {
opacity: 0.5;
background-color: rgb(var(--v-theme-grey-200));
}
.drop-over {
background-color: rgba(var(--v-theme-primary), 0.1) !important;
}
.plugin-page[data-v-ab912b83] {
margin: 0 auto;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +0,0 @@
.plugin-page[data-v-67d1defe] {
margin: 0 auto;
}
/* 使卡片等宽并适应移动端 */
.d-flex.flex-wrap[data-v-67d1defe] {
gap: 16px;
}
/* 移动端堆叠布局 */
@media (max-width: 768px) {
.d-flex.flex-wrap[data-v-67d1defe] {
flex-direction: column;
}
}
.drag-handle[data-v-67d1defe] {
cursor: move;
}
.toggle-container[data-v-67d1defe] {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem;
margin-left: 0.75rem;
margin-right: 0.75rem;
}
.subscription-card[data-v-67d1defe] {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transition: transform 0.3s, box-shadow 0.3s;
background: white;
}
.subscription-card[data-v-67d1defe]:hover {
transform: translateY(-5px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.card-title[data-v-67d1defe] {
color: whitesmoke;
}
.card-header[data-v-67d1defe] {
padding: 0.625rem;
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 1) 0%, rgba(var(--v-theme-primary), 0.7) 100%);
color: white;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-refresh-button[data-v-67d1defe] {
background-color: rgba(var(--v-theme-primary), 0.9);
color: whitesmoke;
border: none;
border-radius: 6px;
padding: 0.625rem;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
transition: background-color 0.3s;
}
.search-field[data-v-67d1defe] {
max-width: 25rem;
}
.clash-data-table[data-v-67d1defe] {
max-height: 40rem;
overflow-y: auto;
}

View File

@@ -0,0 +1,208 @@
const isValidUrl = (urlString) => {
if (!urlString) return false;
try {
const url = new URL(urlString);
return url.protocol === "http:" || url.protocol === "https:";
} catch (e) {
return false;
}
};
function isValidIP(ip) {
const ipv4Regex = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(([0-9a-fA-F]{1,4}:){1,7}|:):([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})$/;
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
}
function validateIPs(ips) {
if (ips.length === 0) {
return `至少需要一个 IP 地址`;
}
for (const ip of ips) {
if (!isValidIP(ip)) {
return `无效的 IP 地址: ${ip}`;
}
}
return true;
}
function getUsageColor(percentage) {
return percentage > 90 ? "error" : percentage > 70 ? "warning" : "success";
}
function getBehaviorColor(action) {
const colors = {
classical: "success",
domain: "error",
ipcidr: "error"
};
return colors[action] || "primary";
}
function getFormatColor(action) {
const colors = {
yaml: "success",
text: "warning",
mrs: "info"
};
return colors[action] || "secondary";
}
function getRuleTypeColor(type) {
const colors = {
DOMAIN: "primary",
"DOMAIN-SUFFIX": "primary",
"DOMAIN-KEYWORD": "primary",
"DOMAIN-REGEX": "primary",
"DOMAIN-WILDCARD": "primary",
GEOSITE: "info",
GEOIP: "info",
"IP-CIDR": "warning",
"IP-CIDR6": "warning",
"IP-SUFFIX": "warning",
"IP-ASN": "warning",
"SRC-GEOIP": "info",
"SRC-IP-ASN": "warning",
"SRC-IP-CIDR": "warning",
"SRC-IP-SUFFIX": "warning",
"DST-PORT": "success",
"SRC-PORT": "success",
"IN-PORT": "success",
"IN-TYPE": "success",
"IN-USER": "success",
"IN-NAME": "success",
"PROCESS-PATH": "error",
"PROCESS-PATH-REGEX": "error",
"PROCESS-NAME": "error",
"PROCESS-NAME-REGEX": "error",
UID: "secondary",
NETWORK: "secondary",
DSCP: "secondary",
"RULE-SET": "deep-purple",
AND: "deep-orange",
OR: "deep-orange",
NOT: "deep-orange",
"SUB-RULE": "deep-orange",
MATCH: "teal"
};
return colors[type] || "grey";
}
function getSourceColor(source) {
const colors = {
Auto: "success",
Manual: "info"
};
return colors[source] || "primary";
}
function getActionColor(action) {
const colors = {
DIRECT: "success",
REJECT: "error",
"REJECT-DROP": "error",
PASS: "warning",
COMPATIBLE: "info"
};
return colors[action] || "primary";
}
function getProxyGroupTypeColor(action) {
const colors = {
"url-test": "success",
fallback: "error",
"load-balance": "primary",
select: "info"
};
return colors[action] || "warning";
}
function getProxyColor(action) {
const colors = {
ss: "success",
ssr: "success",
trojan: "error",
vmess: "primary",
vless: "primary",
hysteria: "info",
hysteria2: "info",
anytls: "warning"
};
return colors[action] || "secondary";
}
function getBoolColor(value) {
if (value) {
return "primary";
}
return "success";
}
function isSystemRule(rule) {
return rule.meta.source?.startsWith("Auto");
}
function isManual(source) {
return source === "Manual";
}
function isInvalid(source) {
return source === "Invalid";
}
function isRegion(source) {
return source === "Auto";
}
function pageTitle(itemPerPageValue) {
if (itemPerPageValue < 0) {
return "♾️";
}
return `${itemPerPageValue}`;
}
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function formatTimestamp(timestamp) {
if (!timestamp) return "N/A";
const date = new Date(timestamp * 1e3);
return date.toLocaleDateString("zh-CN");
}
function timestampToDate(timestamp) {
if (!timestamp) return "N/A";
const date = new Date(timestamp * 1e3);
return date.toLocaleString("zh-CN", {
// 'en-GB' 表示使用英国格式YYYY-MM-DD HH:mm:ss
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
// 24小时制
});
}
function getExpireColor(timestamp) {
if (!timestamp) return "grey";
const secondsLeft = timestamp - Math.floor(Date.now() / 1e3);
const daysLeft = secondsLeft / 86400;
return daysLeft < 7 ? "error" : daysLeft < 30 ? "warning" : "success";
}
function extractDomain(url) {
try {
const hostname = new URL(url).hostname;
const parts = hostname.split(".");
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(":")) {
return hostname;
}
if (parts.length <= 2) {
return hostname;
}
return parts.slice(-2).join(".");
} catch {
return url;
}
}
function getUsedPercentageFloor(data) {
const used = data.upload + data.download;
return data.total > 0 ? Math.floor(used / data.total * 100) : 0;
}
const _export_sfc = (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) {
target[key] = val;
}
return target;
};
export { _export_sfc as _, getActionColor as a, isManual as b, isRegion as c, getSourceColor as d, getProxyGroupTypeColor as e, isValidUrl as f, getRuleTypeColor as g, isInvalid as h, isSystemRule as i, getProxyColor as j, extractDomain as k, formatTimestamp as l, getExpireColor as m, formatBytes as n, getUsageColor as o, pageTitle as p, getUsedPercentageFloor as q, getFormatColor as r, getBehaviorColor as s, timestampToDate as t, getBoolColor as u, validateIPs as v };

View File

@@ -1,9 +0,0 @@
const _export_sfc = (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) {
target[key] = val;
}
return target;
};
export { _export_sfc as _ };

View File

@@ -2,14 +2,14 @@ const currentImports = {};
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
let moduleMap = {
"./Page":()=>{
dynamicLoadingCss(["__federation_expose_Page-Dx-0nC8K.css"], false, './Page');
return __federation_import('./__federation_expose_Page-CUYOswsP.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
dynamicLoadingCss(["__federation_expose_Page-CJILOVp4.css"], false, './Page');
return __federation_import('./__federation_expose_Page-DhQfGEOD.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./Config":()=>{
dynamicLoadingCss(["__federation_expose_Config-D7x82s8Y.css"], false, './Config');
return __federation_import('./__federation_expose_Config-C8YPPEsk.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
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-vS9Qm2ZB.css"], false, './Dashboard');
return __federation_import('./__federation_expose_Dashboard-BDSt5WaH.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
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)},};
const seen = {};
const dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => {
const metaUrl = import.meta.url;

View File

@@ -713,6 +713,37 @@ var ace$2 = {exports: {}};
exports.importCssStylsheet = function (uri, doc) {
exports.buildDom(["link", { rel: "stylesheet", href: uri }], exports.getDocumentHead(doc));
};
exports.$fixPositionBug = function (el) {
var rect = el.getBoundingClientRect();
if (el.style.left) {
var target = parseFloat(el.style.left);
var result = +rect.left;
if (Math.abs(target - result) > 1) {
el.style.left = 2 * target - result + "px";
}
}
if (el.style.right) {
var target = parseFloat(el.style.right);
var result = window.innerWidth - rect.right;
if (Math.abs(target - result) > 1) {
el.style.right = 2 * target - result + "px";
}
}
if (el.style.top) {
var target = parseFloat(el.style.top);
var result = +rect.top;
if (Math.abs(target - result) > 1) {
el.style.top = 2 * target - result + "px";
}
}
if (el.style.bottom) {
var target = parseFloat(el.style.bottom);
var result = window.innerHeight - rect.bottom;
if (Math.abs(target - result) > 1) {
el.style.bottom = 2 * target - result + "px";
}
}
};
exports.scrollbarWidth = function (doc) {
var inner = exports.createElement("ace_inner");
inner.style.width = "100%";
@@ -1319,7 +1350,7 @@ var ace$2 = {exports: {}};
reportErrorIfPathIsNotConfigured = function () { };
}
};
exports.version = "1.43.2";
exports.version = "1.43.5";
});
@@ -2072,6 +2103,7 @@ var ace$2 = {exports: {}};
this.text = dom.createElement("textarea");
this.text.className = "ace_text-input";
this.text.setAttribute("wrap", "off");
this.text.setAttribute("autocomplete", "off");
this.text.setAttribute("autocorrect", "off");
this.text.setAttribute("autocapitalize", "off");
this.text.setAttribute("spellcheck", "false");
@@ -2858,7 +2890,7 @@ var ace$2 = {exports: {}};
anchor = this.$clickSelection.start;
}
else {
var orientedRange = calcRangeOrientation(this.$clickSelection, cursor);
var orientedRange = calcRangeOrientation(this.$clickSelection, cursor, editor.session);
cursor = orientedRange.cursor;
anchor = orientedRange.anchor;
}
@@ -2889,7 +2921,7 @@ var ace$2 = {exports: {}};
anchor = range.start;
}
else {
var orientedRange = calcRangeOrientation(this.$clickSelection, cursor);
var orientedRange = calcRangeOrientation(this.$clickSelection, cursor, editor.session);
cursor = orientedRange.cursor;
anchor = orientedRange.anchor;
}
@@ -3003,11 +3035,11 @@ var ace$2 = {exports: {}};
function calcDistance(ax, ay, bx, by) {
return Math.sqrt(Math.pow(bx - ax, 2) + Math.pow(by - ay, 2));
}
function calcRangeOrientation(range, cursor) {
function calcRangeOrientation(range, cursor, session) {
if (range.start.row == range.end.row)
var cmp = 2 * cursor.column - range.start.column - range.end.column;
else if (range.start.row == range.end.row - 1 && !range.start.column && !range.end.column)
var cmp = cursor.column - 4;
var cmp = 3 * cursor.column - 2 * session.getLine(range.start.row).length;
else
var cmp = 2 * cursor.row - range.start.row - range.end.row;
if (cmp < 0)
@@ -3018,6 +3050,71 @@ var ace$2 = {exports: {}};
});
ace.define("ace/mouse/mouse_event",["require","exports","module","ace/lib/event","ace/lib/useragent"], function(require, exports, module){ var event = require("../lib/event");
var useragent = require("../lib/useragent");
var MouseEvent = /** @class */ (function () {
function MouseEvent(domEvent, editor) { this.speed; this.wheelX; this.wheelY;
this.domEvent = domEvent;
this.editor = editor;
this.x = this.clientX = domEvent.clientX;
this.y = this.clientY = domEvent.clientY;
this.$pos = null;
this.$inSelection = null;
this.propagationStopped = false;
this.defaultPrevented = false;
}
MouseEvent.prototype.stopPropagation = function () {
event.stopPropagation(this.domEvent);
this.propagationStopped = true;
};
MouseEvent.prototype.preventDefault = function () {
event.preventDefault(this.domEvent);
this.defaultPrevented = true;
};
MouseEvent.prototype.stop = function () {
this.stopPropagation();
this.preventDefault();
};
MouseEvent.prototype.getDocumentPosition = function () {
if (this.$pos)
return this.$pos;
this.$pos = this.editor.renderer.screenToTextCoordinates(this.clientX, this.clientY);
return this.$pos;
};
MouseEvent.prototype.getGutterRow = function () {
var documentRow = this.getDocumentPosition().row;
var screenRow = this.editor.session.documentToScreenRow(documentRow, 0);
var screenTopRow = this.editor.session.documentToScreenRow(this.editor.renderer.$gutterLayer.$lines.get(0).row, 0);
return screenRow - screenTopRow;
};
MouseEvent.prototype.inSelection = function () {
if (this.$inSelection !== null)
return this.$inSelection;
var editor = this.editor;
var selectionRange = editor.getSelectionRange();
if (selectionRange.isEmpty())
this.$inSelection = false;
else {
var pos = this.getDocumentPosition();
this.$inSelection = selectionRange.contains(pos.row, pos.column);
}
return this.$inSelection;
};
MouseEvent.prototype.getButton = function () {
return event.getButton(this.domEvent);
};
MouseEvent.prototype.getShiftKey = function () {
return this.domEvent.shiftKey;
};
MouseEvent.prototype.getAccelKey = function () {
return useragent.isMac ? this.domEvent.metaKey : this.domEvent.ctrlKey;
};
return MouseEvent;
}());
exports.MouseEvent = MouseEvent;
});
ace.define("ace/lib/scroll",["require","exports","module"], function(require, exports, module){exports.preventParentScroll = function preventParentScroll(event) {
event.stopPropagation();
var target = event.currentTarget;
@@ -3090,8 +3187,20 @@ var ace$2 = {exports: {}};
dom.addCssClass(this.getElement(), className);
};
Tooltip.prototype.setTheme = function (theme) {
this.$element.className = CLASSNAME + " " +
(theme.isDark ? "ace_dark " : "") + (theme.cssClass || "");
if (this.theme) {
this.theme.isDark && dom.removeCssClass(this.getElement(), "ace_dark");
this.theme.cssClass && dom.removeCssClass(this.getElement(), this.theme.cssClass);
}
if (theme.isDark) {
dom.addCssClass(this.getElement(), "ace_dark");
}
if (theme.cssClass) {
dom.addCssClass(this.getElement(), theme.cssClass);
}
this.theme = {
isDark: theme.isDark,
cssClass: theme.cssClass
};
};
Tooltip.prototype.show = function (text, x, y) {
if (text != null)
@@ -3218,12 +3327,18 @@ var ace$2 = {exports: {}};
HoverTooltip.prototype.addToEditor = function (editor) {
editor.on("mousemove", this.onMouseMove);
editor.on("mousedown", this.hide);
editor.renderer.getMouseEventTarget().addEventListener("mouseout", this.onMouseOut, true);
var target = editor.renderer.getMouseEventTarget();
if (target && typeof target.removeEventListener === "function") {
target.addEventListener("mouseout", this.onMouseOut, true);
}
};
HoverTooltip.prototype.removeFromEditor = function (editor) {
editor.off("mousemove", this.onMouseMove);
editor.off("mousedown", this.hide);
editor.renderer.getMouseEventTarget().removeEventListener("mouseout", this.onMouseOut, true);
var target = editor.renderer.getMouseEventTarget();
if (target && typeof target.removeEventListener === "function") {
target.removeEventListener("mouseout", this.onMouseOut, true);
}
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
@@ -3278,7 +3393,6 @@ var ace$2 = {exports: {}};
this.$gatherData = value;
};
HoverTooltip.prototype.showForRange = function (editor, range, domNode, startingEvent) {
var MARGIN = 10;
if (startingEvent && startingEvent != this.lastEvent)
return;
if (this.isOpen && document.activeElement == this.getElement())
@@ -3290,7 +3404,6 @@ var ace$2 = {exports: {}};
this.setTheme(renderer.theme);
}
this.isOpen = true;
this.addMarker(range, editor.session);
this.range = Range.fromPoints(range.start, range.end);
var position = renderer.textToScreenCoordinates(range.start.row, range.start.column);
var rect = renderer.scroller.getBoundingClientRect();
@@ -3301,17 +3414,27 @@ var ace$2 = {exports: {}};
element.appendChild(domNode);
element.style.maxHeight = "";
element.style.display = "block";
var labelHeight = element.clientHeight;
var labelWidth = element.clientWidth;
var spaceBelow = window.innerHeight - position.pageY - renderer.lineHeight;
var isAbove = true;
if (position.pageY - labelHeight < 0 && position.pageY < spaceBelow) {
isAbove = false;
}
element.style.maxHeight = (isAbove ? position.pageY : spaceBelow) - MARGIN + "px";
element.style.top = isAbove ? "" : position.pageY + renderer.lineHeight + "px";
element.style.bottom = isAbove ? window.innerHeight - position.pageY + "px" : "";
element.style.left = Math.min(position.pageX, window.innerWidth - labelWidth - MARGIN) + "px";
this.$setPosition(editor, position, true, range);
dom.$fixPositionBug(element);
};
HoverTooltip.prototype.$setPosition = function (editor, position, withMarker, range) {
var MARGIN = 10;
withMarker && this.addMarker(range, editor.session);
var renderer = editor.renderer;
var element = this.getElement();
var labelHeight = element.offsetHeight;
var labelWidth = element.offsetWidth;
var anchorTop = position.pageY;
var anchorLeft = position.pageX;
var spaceBelow = window.innerHeight - anchorTop - renderer.lineHeight;
var isAbove = this.$shouldPlaceAbove(labelHeight, anchorTop, spaceBelow - MARGIN);
element.style.maxHeight = (isAbove ? anchorTop : spaceBelow) - MARGIN + "px";
element.style.top = isAbove ? "" : anchorTop + renderer.lineHeight + "px";
element.style.bottom = isAbove ? window.innerHeight - anchorTop + "px" : "";
element.style.left = Math.min(anchorLeft, window.innerWidth - labelWidth - MARGIN) + "px";
};
HoverTooltip.prototype.$shouldPlaceAbove = function (labelHeight, anchorTop, spaceBelow) {
return !(anchorTop - labelHeight < 0 && anchorTop < spaceBelow);
};
HoverTooltip.prototype.addMarker = function (range, session) {
if (this.marker) {
@@ -3321,6 +3444,11 @@ var ace$2 = {exports: {}};
this.marker = session && session.addMarker(range, "ace_highlight-marker", "text");
};
HoverTooltip.prototype.hide = function (e) {
if (e && this.$fromKeyboard && e.type == "keydown") {
if (e.code == "Escape") {
return;
}
}
if (!e && document.activeElement == this.getElement())
return;
if (e && e.target && (e.type != "keydown" || e.ctrlKey || e.metaKey) && this.$element.contains(e.target))
@@ -3331,6 +3459,7 @@ var ace$2 = {exports: {}};
this.timeout = null;
this.addMarker(null);
if (this.isOpen) {
this.$fromKeyboard = false;
this.$removeCloseEvents();
this.getElement().style.display = "none";
this.isOpen = false;
@@ -3368,7 +3497,7 @@ var ace$2 = {exports: {}};
});
ace.define("ace/mouse/default_gutter_handler",["require","exports","module","ace/lib/dom","ace/lib/event","ace/tooltip","ace/config"], function(require, exports, module){ var __extends = (this && this.__extends) || (function () {
ace.define("ace/mouse/default_gutter_handler",["require","exports","module","ace/lib/dom","ace/mouse/mouse_event","ace/tooltip","ace/config","ace/range"], function(require, exports, module){ var __extends = (this && this.__extends) || (function () {
var extendStatics = function (d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
@@ -3395,17 +3524,19 @@ var ace$2 = {exports: {}};
throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
};
var dom = require("../lib/dom");
var event = require("../lib/event");
var Tooltip = require("../tooltip").Tooltip;
var MouseEvent = require("./mouse_event").MouseEvent;
var HoverTooltip = require("../tooltip").HoverTooltip;
var nls = require("../config").nls;
var GUTTER_TOOLTIP_LEFT_OFFSET = 5;
var GUTTER_TOOLTIP_TOP_OFFSET = 3;
exports.GUTTER_TOOLTIP_LEFT_OFFSET = GUTTER_TOOLTIP_LEFT_OFFSET;
exports.GUTTER_TOOLTIP_TOP_OFFSET = GUTTER_TOOLTIP_TOP_OFFSET;
var Range = require("../range").Range;
function GutterHandler(mouseHandler) {
var editor = mouseHandler.editor;
var gutter = editor.renderer.$gutterLayer;
var tooltip = new GutterTooltip(editor, true);
mouseHandler.$tooltip = new GutterTooltip(editor);
mouseHandler.$tooltip.addToEditor(editor);
mouseHandler.$tooltip.setDataProvider(function (e, editor) {
var row = e.getDocumentPosition().row;
mouseHandler.$tooltip.showTooltip(row);
});
mouseHandler.editor.setDefaultHandler("guttermousedown", function (e) {
if (!editor.isFocused() || e.getButton() != 0)
return;
@@ -3427,87 +3558,11 @@ var ace$2 = {exports: {}};
mouseHandler.captureMouse(e);
return e.preventDefault();
});
var tooltipTimeout, mouseEvent;
function showTooltip() {
var row = mouseEvent.getDocumentPosition().row;
var maxRow = editor.session.getLength();
if (row == maxRow) {
var screenRow = editor.renderer.pixelToScreenCoordinates(0, mouseEvent.y).row;
var pos = mouseEvent.$pos;
if (screenRow > editor.session.documentToScreenRow(pos.row, pos.column))
return hideTooltip();
}
tooltip.showTooltip(row);
if (!tooltip.isOpen)
return;
editor.on("mousewheel", hideTooltip);
editor.on("changeSession", hideTooltip);
window.addEventListener("keydown", hideTooltip, true);
if (mouseHandler.$tooltipFollowsMouse) {
moveTooltip(mouseEvent);
}
else {
var gutterRow = mouseEvent.getGutterRow();
var gutterCell = gutter.$lines.get(gutterRow);
if (gutterCell) {
var gutterElement = gutterCell.element.querySelector(".ace_gutter_annotation");
var rect = gutterElement.getBoundingClientRect();
var style = tooltip.getElement().style;
style.left = (rect.right - GUTTER_TOOLTIP_LEFT_OFFSET) + "px";
style.top = (rect.bottom - GUTTER_TOOLTIP_TOP_OFFSET) + "px";
}
else {
moveTooltip(mouseEvent);
}
}
}
function hideTooltip(e) {
if (e && e.type === "keydown" && (e.ctrlKey || e.metaKey))
return;
if (e && e.type === "mouseout" && (!e.relatedTarget || tooltip.getElement().contains(e.relatedTarget)))
return;
if (tooltipTimeout)
tooltipTimeout = clearTimeout(tooltipTimeout);
if (tooltip.isOpen) {
tooltip.hideTooltip();
editor.off("mousewheel", hideTooltip);
editor.off("changeSession", hideTooltip);
window.removeEventListener("keydown", hideTooltip, true);
}
}
function moveTooltip(e) {
tooltip.setPosition(e.x, e.y);
}
mouseHandler.editor.setDefaultHandler("guttermousemove", function (e) {
var target = e.domEvent.target || e.domEvent.srcElement;
if (dom.hasCssClass(target, "ace_fold-widget") || dom.hasCssClass(target, "ace_custom-widget"))
return hideTooltip();
if (tooltip.isOpen && mouseHandler.$tooltipFollowsMouse)
moveTooltip(e);
mouseEvent = e;
if (tooltipTimeout)
return;
tooltipTimeout = setTimeout(function () {
tooltipTimeout = null;
if (mouseEvent && !mouseHandler.isMousePressed)
showTooltip();
}, 50);
});
event.addListener(editor.renderer.$gutter, "mouseout", function (e) {
mouseEvent = null;
if (!tooltip.isOpen)
return;
tooltipTimeout = setTimeout(function () {
tooltipTimeout = null;
hideTooltip(e);
}, 50);
}, editor);
}
exports.GutterHandler = GutterHandler;
var GutterTooltip = /** @class */ (function (_super) {
__extends(GutterTooltip, _super);
function GutterTooltip(editor, isHover) {
if (isHover === void 0) { isHover = false; }
function GutterTooltip(editor) {
var _this = _super.call(this, editor.container) || this;
_this.id = "gt" + (++GutterTooltip.$uid);
_this.editor = editor;
@@ -3516,35 +3571,37 @@ var ace$2 = {exports: {}};
el.setAttribute("role", "tooltip");
el.setAttribute("id", _this.id);
el.style.pointerEvents = "auto";
if (isHover) {
_this.onMouseOut = _this.onMouseOut.bind(_this);
el.addEventListener("mouseout", _this.onMouseOut);
}
_this.idleTime = 50;
_this.onDomMouseMove = _this.onDomMouseMove.bind(_this);
_this.onDomMouseOut = _this.onDomMouseOut.bind(_this);
_this.setClassName("ace_gutter-tooltip");
return _this;
}
GutterTooltip.prototype.onMouseOut = function (e) {
if (!this.isOpen)
return;
if (!e.relatedTarget || this.getElement().contains(e.relatedTarget))
return;
if (e && e.currentTarget.contains(e.relatedTarget))
return;
this.hideTooltip();
GutterTooltip.prototype.onDomMouseMove = function (domEvent) {
var aceEvent = new MouseEvent(domEvent, this.editor);
this.onMouseMove(aceEvent, this.editor);
};
GutterTooltip.prototype.setPosition = function (x, y) {
var windowWidth = window.innerWidth || document.documentElement.clientWidth;
var windowHeight = window.innerHeight || document.documentElement.clientHeight;
var width = this.getWidth();
var height = this.getHeight();
x += 15;
y += 15;
if (x + width > windowWidth) {
x -= (x + width) - windowWidth;
GutterTooltip.prototype.onDomMouseOut = function (domEvent) {
var aceEvent = new MouseEvent(domEvent, this.editor);
this.onMouseOut(aceEvent);
};
GutterTooltip.prototype.addToEditor = function (editor) {
var gutter = editor.renderer.$gutter;
gutter.addEventListener("mousemove", this.onDomMouseMove);
gutter.addEventListener("mouseout", this.onDomMouseOut);
_super.prototype.addToEditor.call(this, editor);
};
GutterTooltip.prototype.removeFromEditor = function (editor) {
var gutter = editor.renderer.$gutter;
gutter.removeEventListener("mousemove", this.onDomMouseMove);
gutter.removeEventListener("mouseout", this.onDomMouseOut);
_super.prototype.removeFromEditor.call(this, editor);
};
GutterTooltip.prototype.destroy = function () {
if (this.editor) {
this.removeFromEditor(this.editor);
}
if (y + height > windowHeight) {
y -= 20 + height;
}
Tooltip.prototype.setPosition.call(this, x, y);
_super.prototype.destroy.call(this);
};
Object.defineProperty(GutterTooltip, "annotationLabels", {
get: function () {
@@ -3610,7 +3667,7 @@ var ace$2 = {exports: {}};
}
}
if (annotation.displayText.length === 0)
return this.hideTooltip();
return this.hide();
var annotationMessages = { error: [], security: [], warning: [], info: [], hint: [] };
var iconClassName = gutter.$useSvgGutterIcons ? "ace_icon_svg" : "ace_icon";
for (var i = 0; i < annotation.displayText.length; i++) {
@@ -3625,26 +3682,42 @@ var ace$2 = {exports: {}};
lineElement.appendChild(dom.createElement("br"));
annotationMessages[annotation.type[i].replace("_fold", "")].push(lineElement);
}
var tooltipElement = this.getElement();
dom.removeChildren(tooltipElement);
var tooltipElement = dom.createElement("span");
annotationMessages.error.forEach(function (el) { return tooltipElement.appendChild(el); });
annotationMessages.security.forEach(function (el) { return tooltipElement.appendChild(el); });
annotationMessages.warning.forEach(function (el) { return tooltipElement.appendChild(el); });
annotationMessages.info.forEach(function (el) { return tooltipElement.appendChild(el); });
annotationMessages.hint.forEach(function (el) { return tooltipElement.appendChild(el); });
tooltipElement.setAttribute("aria-live", "polite");
if (!this.isOpen) {
this.setTheme(this.editor.renderer.theme);
this.setClassName("ace_gutter-tooltip");
}
var annotationNode = this.$findLinkedAnnotationNode(row);
if (annotationNode) {
annotationNode.setAttribute("aria-describedby", this.id);
}
this.show();
var range = Range.fromPoints({ row: row, column: 0 }, { row: row, column: 0 });
this.showForRange(this.editor, range, tooltipElement);
this.visibleTooltipRow = row;
this.editor._signal("showGutterTooltip", this);
};
GutterTooltip.prototype.$setPosition = function (editor, _ignoredPosition, _withMarker, range) {
var gutterCell = this.$findCellByRow(range.start.row);
if (!gutterCell)
return;
var el = gutterCell && gutterCell.element;
var anchorEl = el && (el.querySelector(".ace_gutter_annotation"));
if (!anchorEl)
return;
var r = anchorEl.getBoundingClientRect();
if (!r)
return;
var position = {
pageX: r.right,
pageY: r.top
};
return _super.prototype.$setPosition.call(this, editor, position, false, range);
};
GutterTooltip.prototype.$shouldPlaceAbove = function (labelHeight, anchorTop, spaceBelow) {
return spaceBelow < labelHeight;
};
GutterTooltip.prototype.$findLinkedAnnotationNode = function (row) {
var cell = this.$findCellByRow(row);
if (cell) {
@@ -3657,12 +3730,11 @@ var ace$2 = {exports: {}};
GutterTooltip.prototype.$findCellByRow = function (row) {
return this.editor.renderer.$gutterLayer.$lines.cells.find(function (el) { return el.row === row; });
};
GutterTooltip.prototype.hideTooltip = function () {
GutterTooltip.prototype.hide = function (e) {
if (!this.isOpen) {
return;
}
this.$element.removeAttribute("aria-live");
this.hide();
if (this.visibleTooltipRow != undefined) {
var annotationNode = this.$findLinkedAnnotationNode(this.visibleTooltipRow);
if (annotationNode) {
@@ -3671,6 +3743,7 @@ var ace$2 = {exports: {}};
}
this.visibleTooltipRow = undefined;
this.editor._signal("hideGutterTooltip", this);
_super.prototype.hide.call(this, e);
};
GutterTooltip.annotationsToSummaryString = function (annotations) {
var e_1, _a;
@@ -3694,78 +3767,19 @@ var ace$2 = {exports: {}};
}
return summary.join(", ");
};
GutterTooltip.prototype.isOutsideOfText = function (e) {
var editor = e.editor;
var rect = editor.renderer.$gutter.getBoundingClientRect();
return !(e.clientX >= rect.left && e.clientX <= rect.right &&
e.clientY >= rect.top && e.clientY <= rect.bottom);
};
return GutterTooltip;
}(Tooltip));
}(HoverTooltip));
GutterTooltip.$uid = 0;
exports.GutterTooltip = GutterTooltip;
});
ace.define("ace/mouse/mouse_event",["require","exports","module","ace/lib/event","ace/lib/useragent"], function(require, exports, module){ var event = require("../lib/event");
var useragent = require("../lib/useragent");
var MouseEvent = /** @class */ (function () {
function MouseEvent(domEvent, editor) { this.speed; this.wheelX; this.wheelY;
this.domEvent = domEvent;
this.editor = editor;
this.x = this.clientX = domEvent.clientX;
this.y = this.clientY = domEvent.clientY;
this.$pos = null;
this.$inSelection = null;
this.propagationStopped = false;
this.defaultPrevented = false;
}
MouseEvent.prototype.stopPropagation = function () {
event.stopPropagation(this.domEvent);
this.propagationStopped = true;
};
MouseEvent.prototype.preventDefault = function () {
event.preventDefault(this.domEvent);
this.defaultPrevented = true;
};
MouseEvent.prototype.stop = function () {
this.stopPropagation();
this.preventDefault();
};
MouseEvent.prototype.getDocumentPosition = function () {
if (this.$pos)
return this.$pos;
this.$pos = this.editor.renderer.screenToTextCoordinates(this.clientX, this.clientY);
return this.$pos;
};
MouseEvent.prototype.getGutterRow = function () {
var documentRow = this.getDocumentPosition().row;
var screenRow = this.editor.session.documentToScreenRow(documentRow, 0);
var screenTopRow = this.editor.session.documentToScreenRow(this.editor.renderer.$gutterLayer.$lines.get(0).row, 0);
return screenRow - screenTopRow;
};
MouseEvent.prototype.inSelection = function () {
if (this.$inSelection !== null)
return this.$inSelection;
var editor = this.editor;
var selectionRange = editor.getSelectionRange();
if (selectionRange.isEmpty())
this.$inSelection = false;
else {
var pos = this.getDocumentPosition();
this.$inSelection = selectionRange.contains(pos.row, pos.column);
}
return this.$inSelection;
};
MouseEvent.prototype.getButton = function () {
return event.getButton(this.domEvent);
};
MouseEvent.prototype.getShiftKey = function () {
return this.domEvent.shiftKey;
};
MouseEvent.prototype.getAccelKey = function () {
return useragent.isMac ? this.domEvent.metaKey : this.domEvent.ctrlKey;
};
return MouseEvent;
}());
exports.MouseEvent = MouseEvent;
});
ace.define("ace/mouse/dragdrop_handler",["require","exports","module","ace/lib/dom","ace/lib/event","ace/lib/useragent"], function(require, exports, module){ var dom = require("../lib/dom");
var event = require("../lib/event");
var useragent = require("../lib/useragent");
@@ -4574,6 +4588,8 @@ var ace$2 = {exports: {}};
MouseHandler.prototype.destroy = function () {
if (this.releaseMouse)
this.releaseMouse();
if (this.$tooltip)
this.$tooltip.destroy();
};
return MouseHandler;
}());
@@ -4583,7 +4599,6 @@ var ace$2 = {exports: {}};
dragDelay: { initialValue: (useragent.isMac ? 150 : 0) },
dragEnabled: { initialValue: true },
focusTimeout: { initialValue: 0 },
tooltipFollowsMouse: { initialValue: true }
});
exports.MouseHandler = MouseHandler;
@@ -13724,8 +13739,7 @@ var ace$2 = {exports: {}};
});
ace.define("ace/keyboard/gutter_handler",["require","exports","module","ace/lib/keys","ace/mouse/default_gutter_handler"], function(require, exports, module){ var keys = require('../lib/keys');
var GutterTooltip = require("../mouse/default_gutter_handler").GutterTooltip;
ace.define("ace/keyboard/gutter_handler",["require","exports","module","ace/lib/keys"], function(require, exports, module){ var keys = require('../lib/keys');
var GutterKeyboardHandler = /** @class */ (function () {
function GutterKeyboardHandler(editor) {
this.editor = editor;
@@ -13734,7 +13748,7 @@ var ace$2 = {exports: {}};
this.lines = editor.renderer.$gutterLayer.$lines;
this.activeRowIndex = null;
this.activeLane = null;
this.annotationTooltip = new GutterTooltip(this.editor);
this.annotationTooltip = this.editor.$mouseHandler.$tooltip;
}
GutterKeyboardHandler.prototype.addListener = function () {
this.element.addEventListener("keydown", this.$onGutterKeyDown.bind(this));
@@ -13750,7 +13764,7 @@ var ace$2 = {exports: {}};
if (this.annotationTooltip.isOpen) {
e.preventDefault();
if (e.keyCode === keys["escape"])
this.annotationTooltip.hideTooltip();
this.annotationTooltip.hide();
return;
}
if (e.target === this.element) {
@@ -13869,12 +13883,8 @@ var ace$2 = {exports: {}};
}
return;
case "annotation":
var gutterElement = this.lines.cells[this.activeRowIndex].element.childNodes[2];
var rect = gutterElement.getBoundingClientRect();
var style = this.annotationTooltip.getElement().style;
style.left = rect.right + "px";
style.top = rect.bottom + "px";
this.annotationTooltip.showTooltip(this.$rowIndexToRow(this.activeRowIndex));
this.annotationTooltip.$fromKeyboard = true;
break;
}
return;
@@ -13893,7 +13903,7 @@ var ace$2 = {exports: {}};
}
}
if (this.annotationTooltip.isOpen)
this.annotationTooltip.hideTooltip();
this.annotationTooltip.hide();
return;
};
GutterKeyboardHandler.prototype.$isFoldWidgetVisible = function (index) {
@@ -16178,7 +16188,6 @@ var ace$2 = {exports: {}};
dragDelay: "$mouseHandler",
dragEnabled: "$mouseHandler",
focusTimeout: "$mouseHandler",
tooltipFollowsMouse: "$mouseHandler",
firstLineNumber: "session",
overwrite: "session",
newLineMode: "session",
@@ -16328,6 +16337,7 @@ var ace$2 = {exports: {}};
var nls = require("../config").nls;
var Gutter = /** @class */ (function () {
function Gutter(parentEl) {
this.$showCursorMarker = null;
this.element = dom.createElement("div");
this.element.className = "ace_layer ace_gutter-layer";
parentEl.appendChild(this.element);
@@ -16448,6 +16458,8 @@ var ace$2 = {exports: {}};
}
this._signal("afterRender");
this.$updateGutterWidth(config);
if (this.$showCursorMarker && this.$highlightGutterLine)
this.$updateCursorMarker();
};
Gutter.prototype.$updateGutterWidth = function (config) {
var session = this.session;
@@ -16476,6 +16488,8 @@ var ace$2 = {exports: {}};
this.$cursorRow = position.row;
};
Gutter.prototype.updateLineHighlight = function () {
if (this.$showCursorMarker)
this.$updateCursorMarker();
if (!this.$highlightGutterLine)
return;
var row = this.session.selection.cursor.row;
@@ -16502,6 +16516,26 @@ var ace$2 = {exports: {}};
}
}
};
Gutter.prototype.$updateCursorMarker = function () {
if (!this.session)
return;
var session = this.session;
if (!this.$highlightElement) {
this.$highlightElement = dom.createElement("div");
this.$highlightElement.className = "ace_gutter-cursor";
this.$highlightElement.style.pointerEvents = "none";
this.element.appendChild(this.$highlightElement);
}
var pos = session.selection.cursor;
var config = this.config;
var lines = this.$lines;
var screenTop = config.firstRowScreen * config.lineHeight;
var screenPage = Math.floor(screenTop / lines.canvasHeight);
var lineTop = session.documentToScreenRow(pos) * config.lineHeight;
var top = lineTop - (screenPage * lines.canvasHeight);
dom.setStyle(this.$highlightElement.style, "height", config.lineHeight + "px");
dom.setStyle(this.$highlightElement.style, "top", top + "px");
};
Gutter.prototype.scrollLines = function (config) {
var oldConfig = this.config;
this.config = config;
@@ -16745,6 +16779,10 @@ var ace$2 = {exports: {}};
};
Gutter.prototype.setHighlightGutterLine = function (highlightGutterLine) {
this.$highlightGutterLine = highlightGutterLine;
if (!highlightGutterLine && this.$highlightElement) {
this.$highlightElement.remove();
this.$highlightElement = null;
}
};
Gutter.prototype.setShowLineNumbers = function (show) {
this.$renderer = !show && {
@@ -16786,8 +16824,24 @@ var ace$2 = {exports: {}};
};
Gutter.prototype.$getGutterCell = function (row) {
var cells = this.$lines.cells;
var visibileRow = this.session.documentToScreenRow(row, 0);
return cells[row - this.config.firstRowScreen - (row - visibileRow)];
var min = 0;
var max = cells.length - 1;
if (row < cells[0].row || row > cells[max].row)
return;
while (min <= max) {
var mid = Math.floor((min + max) / 2);
var cell = cells[mid];
if (cell.row > row) {
max = mid - 1;
}
else if (cell.row < row) {
min = mid + 1;
}
else {
return cell;
}
}
return cell;
};
Gutter.prototype.$addCustomWidget = function (row, _a, cell) {
var className = _a.className, label = _a.label, title = _a.title, callbacks = _a.callbacks;
@@ -16850,7 +16904,7 @@ var ace$2 = {exports: {}};
}());
Gutter.prototype.$fixedWidth = false;
Gutter.prototype.$highlightGutterLine = true;
Gutter.prototype.$renderer = "";
Gutter.prototype.$renderer = undefined;
Gutter.prototype.$showLineNumbers = true;
Gutter.prototype.$showFoldWidgets = true;
oop.implement(Gutter.prototype, EventEmitter);
@@ -19856,6 +19910,15 @@ var ace$2 = {exports: {}};
: "padding" in (_self.theme || {}) ? 4 : _self.$padding;
if (_self.$padding && padding != _self.$padding)
_self.setPadding(padding);
if (_self.$gutterLayer) {
var showGutterCursor = module["$showGutterCursorMarker"];
if (showGutterCursor && !_self.$gutterLayer.$showCursorMarker) {
_self.$gutterLayer.$showCursorMarker = "theme";
}
else if (!showGutterCursor && _self.$gutterLayer.$showCursorMarker == "theme") {
_self.$gutterLayer.$showCursorMarker = null;
}
}
_self.$theme = module.cssClass;
_self.theme = module;
dom.addCssClass(_self.container, module.cssClass);

View File

@@ -1,18 +1,10 @@
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Union, Iterator
from .clashruleparser import ClashRuleParser
from ..models.rule import Action, RoutingRuleType, MatchRule, ClashRule, LogicRule, SubRule
from pydantic import TypeAdapter, ValidationError
@dataclass
class RuleItem:
"""Clash rule item"""
rule: Union[ClashRule, LogicRule, MatchRule, SubRule]
remark: str = field(default="")
time_modified: float = field(default=0)
from ..models.metadata import Metadata
from ..models.rule import Action, RoutingRuleType, MatchRule, ClashRule, LogicRule
from ..models.ruleitem import RuleItem, RuleData
class ClashRuleManager:
@@ -21,20 +13,17 @@ class ClashRuleManager:
self.rules: List[RuleItem] = []
def import_rules(self, rules_list: List[Dict[str, Any]]):
self.rules = []
self.rules.clear()
for r in rules_list:
rule = ClashRuleParser.parse_rule_line(r['rule'])
if rule is None:
try:
rule = RuleItem.model_validate(r)
except ValidationError:
continue
remark = r.get('remark', '')
time_modified = r.get('time_modified', time.time())
self.rules.append(RuleItem(rule=rule, remark=remark, time_modified=time_modified))
self.rules.append(rule)
def export_rules(self) -> List[Dict[str, str]]:
rules_list = []
for rule in self.rules:
rules_list.append({'rule': str(rule.rule), 'remark': rule.remark, 'time_modified': rule.time_modified})
return rules_list
adapter = TypeAdapter(list[RuleItem])
return adapter.dump_python(self.rules, mode='json')
def append_rules(self, clash_rules: List[RuleItem]):
self.rules.extend(clash_rules)
@@ -64,6 +53,15 @@ class ClashRuleManager:
return self.rules.pop(priority)
return None
def remove_rules_at_priorities(self, priorities: list[int]) -> list[RuleItem]:
"""Remove rules at specific priorities"""
removed = []
# Sort priorities in descending order to avoid index shift issues during removal
for priority in sorted(priorities, reverse=True):
if 0 <= priority < len(self.rules):
removed.append(self.rules.pop(priority))
return removed
def remove_rules_by_lambda(self, condition: Callable[[RuleItem], bool]):
"""Remove rules by lambda"""
initial_count = len(self.rules)
@@ -101,7 +99,7 @@ class ClashRuleManager:
return any(r.rule == clash_rule for r in self.rules)
def has_rule_item(self, clash_rule: RuleItem) -> bool:
return any(clash_rule.remark == r.remark and r.rule == clash_rule.rule for r in self.rules)
return any(clash_rule.meta.source == r.meta.source and r.rule == clash_rule.rule for r in self.rules)
def reorder_rules(self, moved_priority: int, target_priority: int) -> RuleItem:
"""Reorder the rules"""
@@ -113,13 +111,27 @@ class ClashRuleManager:
self.rules.insert(target_priority, rule)
return rule
def to_list(self) -> List[Dict[str, Any]]:
def update_rules_at_priorities(self, priorities: dict[int, bool]) -> list[RuleItem]:
"""Disable rules"""
updated = []
for priority, disabled in priorities.items():
if 0 <= priority < len(self.rules):
self.rules[priority].meta.disabled = disabled
updated.append(self.rules[priority])
return updated
def update_rule_meta_at_priority(self, priority: int, meta: Metadata) -> bool:
"""Update rule metadata at priority"""
if 0 <= priority < len(self.rules):
self.rules[priority].meta = meta
return True
return False
def to_list(self) -> list[RuleData]:
"""Convert parsed rules to a list"""
result = []
result: list[RuleData] = []
for priority, rule_item in enumerate(self.rules):
rule_dict = {'remark': rule_item.remark, 'time_modified': rule_item.time_modified,'priority': priority,
**rule_item.rule.to_dict()}
result.append(rule_dict)
result.append(RuleData.from_rule_item(rule_item, priority))
return result
def clear(self):

View File

@@ -9,21 +9,25 @@ from ..models.rule import RuleType, Action, RoutingRuleType, MatchRule, ClashRul
class ClashRuleParser:
"""Parser for Clash routing rules"""
@staticmethod
def parse(line: str) -> RuleType:
"""Parse a single rule line"""
# Handle logic rules (AND, OR, NOT)
if line.startswith(('AND,', 'OR,', 'NOT,')):
return ClashRuleParser._parse_logic_rule(line)
elif line.startswith('MATCH'):
return ClashRuleParser._parse_match_rule(line)
elif line.startswith('SUB-RULE'):
return ClashRuleParser._parse_sub_rule(line)
# Handle regular rules
return ClashRuleParser._parse_regular_rule(line)
@staticmethod
def parse_rule_line(line: str) -> Optional[RuleType]:
"""Parse a single rule line"""
line = line.strip()
try:
# Handle logic rules (AND, OR, NOT)
if line.startswith(('AND,', 'OR,', 'NOT,')):
return ClashRuleParser._parse_logic_rule(line)
elif line.startswith('MATCH'):
return ClashRuleParser._parse_match_rule(line)
elif line.startswith('SUB-RULE'):
return ClashRuleParser._parse_sub_rule(line)
# Handle regular rules
return ClashRuleParser._parse_regular_rule(line)
return ClashRuleParser.parse(line)
except (ValidationError, TypeError, ValueError, RecursionError):
return None
@@ -221,7 +225,7 @@ class ClashRuleParser:
"""
Parse conditions within logic rules, supporting nested logic.
The examples of conditions_str:
- (DOMAIN,baidu.com)`
- (DOMAIN,baidu.com)
- (AND,(DOMAIN,baidu.com),(NETWORK,TCP))
"""
@@ -288,11 +292,6 @@ class ClashRuleParser:
raise ValueError(f"Invalid rule format: {content}")
return conditions
@staticmethod
def action_string(action: Union[Action, str]) -> str:
return action.value if isinstance(action, Action) else action
@staticmethod
def parse_rules(rules_text: str) -> List[Union[ClashRule, LogicRule, MatchRule]]:
"""Parse multiple rules from text, preserving order and priority"""

View File

@@ -2,7 +2,7 @@ import base64
import importlib
import json
import os
from typing import List, Dict, Any, Optional, Union
from typing import Dict, Any, Optional, Union
from urllib.parse import quote
from .converters import BaseConverter
@@ -54,8 +54,8 @@ class Converter:
print(f"Could not load converter for {module_name}: {e}")
return converters
def convert_line(self, line: str, names: Optional[Dict[str, int]] = None, skip_exception: bool = True
) -> Optional[Dict[str, Any]]:
def convert_line(self, line: str, names: dict[str, int] | None = None, skip_exception: bool = True,
logger: Any = None) -> dict[str, Any] | None:
"""
Parses a single subscription link and converts it to a proxy dictionary.
"""
@@ -73,12 +73,15 @@ class Converter:
try:
return converter.convert(line, names)
except Exception as e:
if logger:
logger.error(f"Error converting line {line}: {e}")
if not skip_exception:
raise ValueError(f"{scheme.upper()} parse error: {e}") from e
return None
return None
def convert_v2ray(self, v2ray_link: Union[list, bytes], skip_exception: bool = True) -> List[Dict[str, Any]]:
def convert_v2ray(self, v2ray_link: Union[list, bytes], skip_exception: bool = True,
logger: Any = None) -> dict[str, dict[str, Any]]:
"""
Converts a base64 encoded V2Ray subscription content or a list of links
into a list of proxy dictionaries.
@@ -89,15 +92,15 @@ class Converter:
else:
lines = v2ray_link
proxies = []
proxies: dict[str, dict[str, Any]] = {}
names = {}
for line in lines:
line = line.strip()
if not line:
continue
proxy = self.convert_line(line, names, skip_exception=skip_exception)
proxy = self.convert_line(line, names, skip_exception=skip_exception, logger=logger)
if proxy:
proxies.append(proxy)
proxies[line] = proxy
elif not skip_exception:
raise ValueError("Failed to convert one of the links in the subscription.")
return proxies

View File

@@ -17,6 +17,7 @@ class HysteriaConverter(BaseConverter):
"type": "hysteria",
"server": parsed.hostname,
"port": parsed.port,
"udp": True
}
auth_str = query.get("auth")

View File

@@ -34,6 +34,7 @@ class Hysteria2Converter(BaseConverter):
"skip-cert-verify": StringUtils.to_bool(query.get("insecure", "false")),
"down": query.get("down"),
"up": query.get("up"),
"udp": True
}
if "pinSHA256" in query:
proxy["fingerprint"] = query.get("pinSHA256")

View File

@@ -0,0 +1,118 @@
import copy
import time
from typing import Any
import jsonpatch
from pydantic import ValidationError
from app.db.plugindata_oper import PluginDataOper
from app.log import logger
from ..configconverter import Converter
from ..utilsprovider import UtilsProvider
from ...models.proxygroups import ProxyGroupData
from ...models.proxy import Proxy, ProxyData
from ...models.ruleproviders import RuleProviderData
from ...models.types import DataSource, DataKey
from ...models.datapatch import PatchItem
from ...models.metadata import Metadata
def _overwrite_proxy(proxy: dict[str, Any], overwritten_proxies: dict[str, Any]) -> dict[str, Any]:
if proxy["name"] in overwritten_proxies:
for key in ['base', 'tls', 'network']:
if overlay := overwritten_proxies[proxy["name"]].get(key):
proxy.update(copy.deepcopy(overlay))
return proxy
def upgrade(plugin_id: str):
data_oper = PluginDataOper()
# Upgrade proxy groups
proxy_groups = data_oper.get_data(plugin_id, "proxy_groups") or []
new_pg, invalid_pg, names = [], [], set()
for pg in proxy_groups:
try:
obj = ProxyGroupData(meta=Metadata(source=DataSource.MANUAL), data=pg, name=pg["name"])
if obj.name not in names:
new_pg.append(obj.model_dump(by_alias=True, exclude_none=True))
names.add(obj.name)
except ValidationError:
logger.error(f"升级代理组失败: {pg}")
invalid_pg.append(pg)
data_oper.save(plugin_id, DataKey.PROXY_GROUPS, new_pg)
data_oper.save(plugin_id, "proxy_groups", invalid_pg)
# Upgrade rule providers
rule_providers = data_oper.get_data(plugin_id, "extra_rule_providers") or {}
new_rp, invalid_rp = [], []
for name, rp in rule_providers.items():
try:
obj = RuleProviderData(meta=Metadata(source=DataSource.MANUAL), name=name, data=rp)
new_rp.append(obj.model_dump(by_alias=True, exclude_none=True))
except ValidationError:
logger.error(f"升级规则集失败: {rp}")
invalid_rp.append(rp)
data_oper.save(plugin_id, DataKey.RULE_PROVIDERS, new_rp)
data_oper.save(plugin_id, "extra_rule_providers", invalid_rp)
# Upgrade proxies
proxies = data_oper.get_data(plugin_id, DataKey.PROXIES) or []
new_proxies, invalid_proxies = [], []
all_proxies = []
names = set()
converter = Converter()
for proxy in proxies:
try:
raw = None
if isinstance(proxy, str):
proxy_dict, raw = converter.convert_line(proxy), proxy
elif isinstance(proxy, dict):
proxy_dict = UtilsProvider.filter_empty(proxy, empty=['', None])
else:
continue
obj = Proxy.model_validate(proxy_dict)
if obj.name in names: continue
p_data = ProxyData(data=obj, name=obj.name, meta=Metadata(source=DataSource.MANUAL), raw=raw)
new_proxies.append(p_data.model_dump(by_alias=True, exclude_none=True))
all_proxies.append(p_data.data)
names.add(p_data.name)
except Exception:
logger.error(f"升级代理失败: {proxy}")
invalid_proxies.append(proxy)
data_oper.save(plugin_id, DataKey.PROXIES, new_proxies)
data_oper.save(plugin_id, "extra_proxies", invalid_proxies)
# Create proxy patches
data_patch = {}
overwritten = data_oper.get_data(plugin_id, "overwritten_proxies") or {}
for name in overwritten:
if proxy := next((p for p in all_proxies if p.name == name), None):
src = proxy.model_dump(by_alias=True)
# Create a deep copy for dst to avoid modifying src in place if _overwrite_proxy mutates
dst = _overwrite_proxy(copy.deepcopy(src), overwritten)
if patch := jsonpatch.make_patch(src, dst).to_string():
data_patch[name] = PatchItem(patch=patch).model_dump(by_alias=True, exclude_none=True)
data_oper.save(plugin_id, DataKey.PROXY_PATCH, data_patch)
data_oper.save(plugin_id, DataKey.ACL4SSR, [])
# Upgrade rules
for key in [DataKey.TOP_RULES, DataKey.RULESET_RULES]:
if rules := data_oper.get_data(plugin_id, key):
for rule in rules:
rule["meta"] = Metadata(
source=rule.get("remark") or DataSource.MANUAL,
time_modified=rule.get("time_modified") or time.time()
).model_dump()
data_oper.save(plugin_id, key, rules)
data_oper.save(plugin_id, DataKey.DATA_VERSION, "2.1.0")

View File

@@ -1,105 +0,0 @@
import copy
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional, Union, Any, Iterator
from ..models.proxy import Proxy, ProxyType
@dataclass
class ProxyItem:
proxy: ProxyType
remark: str = ""
raw: Optional[Union[str, Dict[str, Any]]] = None
class ProxyManager:
"""Proxy Manager"""
def __init__(self):
self.proxies: Dict[str,ProxyItem] = {}
def add(self, proxy: ProxyType, remark: str = "", raw: Optional[str|Dict[str, Any]] = None):
"""Add a proxy to the proxy manager. """
if proxy.name not in self.proxies:
self.proxies[proxy.name] = ProxyItem(proxy, remark, raw=copy.deepcopy(raw))
else:
raise ValueError(f"Proxy with name {proxy.name!r} already exists.")
def add_proxy_dict(self, proxy_dict: Dict[str, Any], remark: str = "", raw: Optional[str] = None):
"""
Add a proxy to the proxies list.
:param proxy_dict: Proxy dict with proxy name as key
:param remark: Proxy remark
:param raw: Proxy raw
:raises: ValueError if proxy name already exists
"""
proxy = Proxy.model_validate(proxy_dict)
raw = raw or proxy_dict
self.add(proxy.root, remark=remark, raw=raw)
def add_from_list(self, proxies: List[Dict[str, Any]], remark: str = "", skip_existing: bool = False):
"""Add proxies from the proxies list. """
proxies_list = []
for proxy in proxies:
p = Proxy.model_validate(proxy)
proxies_list.append(ProxyItem(p.root, remark, raw=proxy))
for proxy_item in proxies_list:
try:
self.add(proxy_item.proxy, remark=remark, raw=proxy_item.raw)
except ValueError:
if skip_existing:
continue
raise
def get_all_proxies(self) -> List[Dict[str, Any]]:
proxies = []
for proxy_item in self.proxies.values():
proxy_dict = proxy_item.proxy.model_dump(by_alias=True, exclude_none=True)
proxies.append(proxy_dict)
return proxies
def remove_proxy(self, name):
if name in self.proxies:
del self.proxies[name]
def remove_proxies_by_condition(self, condition: Callable[[ProxyItem], bool]) -> int:
"""
Removes proxies from the manager based on a given condition.
:param condition: A callable that takes a ProxyItem and returns True if the proxy should be removed.
:return: The number of proxies removed.
"""
initial_count = len(self.proxies)
self.proxies = {
name: item
for name, item in self.proxies.items()
if not condition(item)
}
return initial_count - len(self.proxies)
def filter_proxies_by_condition(self, condition: Callable[[ProxyItem], bool]) -> List[ProxyItem]:
return [proxy for proxy in self.proxies.values() if condition(proxy)]
def clear(self):
self.proxies.clear()
def export_raw(self, condition: Optional[Callable[[ProxyItem], bool]] = None) -> List[str|Dict[str, Any]]:
proxies = []
for proxy in self.proxies.values():
if condition and not condition(proxy):
continue
if proxy.raw:
proxies.append(copy.deepcopy(proxy.raw))
else:
proxies.append(proxy.proxy.model_dump(by_alias=True, exclude_none=True))
return proxies
def proxy_names(self) -> Iterator[str]:
return iter(self.proxies)
def __len__(self) -> int:
return len(self.proxies)
def __iter__(self) -> Iterator[ProxyItem]:
return iter(self.proxies.values())
def __contains__(self, name: str) -> bool:
return name in self.proxies

View File

@@ -1,3 +1,6 @@
from .proxy import *
from .hosts import *
from .ruleitem import *
from .ruleproviders import *
from .proxygroups import *
from .proxyproviders import *

View File

@@ -1,48 +1,71 @@
from typing import List, Optional, Union, Literal
from typing import List
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, Field, RootModel
from simpleeval import simple_eval
from .rule import RoutingRuleType, Action, AdditionalParam
from .ruleproviders import RuleProvider
class RuleData(BaseModel):
priority: int
type: RoutingRuleType
payload: Optional[str] = None
action: Union['Action', str]
additional_params: Optional[AdditionalParam] = None
conditions: Optional[List[str]] = None
condition: Optional[str] = None
model_config = ConfigDict(
use_enum_values=True
)
class ClashApi(BaseModel):
url: str
secret: str
class Connectivity(BaseModel):
clash_apis: List[ClashApi] = Field(default_factory=list)
sub_links: List[str] = Field(default_factory=list)
class Subscription(BaseModel):
class SubscriptionSetting(BaseModel):
url: str
enabled: bool
class RuleProviderData(BaseModel):
name: str
rule_provider: RuleProvider
class SubscriptionInfo(BaseModel):
class DataUsage(BaseModel):
upload: int = 0
download: int = 0
total: int = 0
expire: int = 0
@property
def header(self) -> str:
return f'upload={self.upload}; download={self.download}; total={self.total}; expire={self.expire};'
class SubscriptionInfo(DataUsage):
last_update: int = Field(default=0)
proxy_num: int = Field(default=0)
enabled: bool = True
def update(self, setting: SubscriptionSetting):
self.enabled = setting.enabled
class SubscriptionsInfo(RootModel[dict[str, SubscriptionInfo]]):
root: dict[str, SubscriptionInfo] = Field(default_factory=dict)
def update(self, urls: list[str]):
if not urls:
return
self.root.clear()
for url in urls:
self.root[url] = self.root.get(url, SubscriptionInfo())
def get(self, url: str) -> SubscriptionInfo:
return self.root.get(url, SubscriptionInfo())
def __setitem__(self, key: str, value: SubscriptionInfo):
self.root[key] = value
def set(self, setting: SubscriptionSetting):
if setting.url in self.root:
self.root[setting.url].update(setting)
class ConfigRequest(BaseModel):
url: str
field: Literal['name', 'enabled']
value: Union[bool, str]
client_host: str
identifier: str | None = None
user_agent : str | None = None
class Host(BaseModel):
domain: str
value: List[str]
using_cloudflare: bool
class HostData(BaseModel):
domain: str
value: Optional[Host] = None
def resolve(self, expr) -> bool:
return bool(simple_eval(expr=expr, names=self.model_dump()))

View File

@@ -0,0 +1,233 @@
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator, field_validator, field_serializer, PrivateAttr
from app.log import logger
from .proxy import Proxy
from .proxygroups import ProxyGroup
from .proxyproviders import ProxyProvider
from .proxy.tlsmixin import ClientFingerprint
from .ruleproviders import RuleProvider
from .rule import RuleType, Action, RoutingRuleType
from ..helper.clashruleparser import ClashRuleParser
class ExternalControllerCors(BaseModel):
allow_origins: list[str] = Field(default_factory=lambda: ["*"], alias="allow-origins")
allow_credentials: bool = Field(default=True, alias="allow-credentials")
class Profile(BaseModel):
store_selected: bool = Field(default=False, alias="store-selected")
store_fake_ip: bool = Field(default=False, alias="store-fake-ip")
class NTP(BaseModel):
enable: bool = Field(default=False)
Server: str = Field(default="time.apple.com")
port: int = Field(default=123)
write_to_system: bool = Field(default=False, alias="write-to-system")
class Experimental(BaseModel):
quic_go_disable_gso: bool = Field(default=False, alias="quic-go-disable-gso")
quic_go_disable_ecn: bool = Field(default=True, alias="quic-go-disable-ecn")
dialer_ip4p_convert: bool = Field(default=False, alias="dialer-ip4p-convert")
class ClashConfig(BaseModel):
_raw_proxies: dict[str, str] = PrivateAttr(default_factory=dict)
dns: dict[str, Any] | None = Field(default=None)
hosts: dict[str, list[str] | str] | None = Field(default=None)
allow_lan: bool | None = Field(default=None, alias="allow-lan")
bind_address: str = Field(default="*", alias="bind-address")
lan_allowed_ips: list[str] = Field(default_factory=lambda: ["0.0.0.0/0", "::/0"], alias="lan-allowed-ips")
lan_disallowed_ips: list[str] = Field(default_factory=list, alias="lan-disallowed-ips")
authentication: list[str] = Field(default_factory=list)
skip_auth_prefixes: list[str] = Field(default_factory=list, alias="skip-auth-prefixes")
mode: Literal["rule", "global", "direct"] = Field(default="rule")
log_level: Literal["silent", "error", "warning", "info", "debug"] = Field(default="info", alias="log-level")
ipv6: bool = Field(default=True)
keep_alive_interval: int = Field(default=0, alias="keep-alive-interval")
keep_alive_idle: int = Field(default=0, alias="keep-alive-idle")
disable_keep_alive: bool = Field(default=False, alias="disable-keep-alive")
find_process_mode: Literal["strict", "always", "off"] = Field(default="strict", alias="find-process-mode")
external_controller: str | None = Field(default=None, alias="external-controller")
external_controller_cors: ExternalControllerCors = Field(default_factory=ExternalControllerCors,
alias="external-controller-cors")
external_controller_unix: str | None = Field(default=None, alias="external-controller-unix")
external_controller_pipe: str | None = Field(default=None, alias="external-controller-pipe")
external_controller_tls: str | None = Field(default=None, alias="external-controller-tls")
secret: str | None = Field(default=None)
external_ui: str | None = Field(default=None, alias="external-ui")
external_ui_name: str | None = Field(default=None, alias="external-ui-name")
external_ui_url: str | None = Field(default=None, alias="external-ui-url")
profile: Profile = Field(default_factory=Profile)
unified_delay: bool = Field(default=True, alias="unified-delay")
tcp_concurrent: bool = Field(default=True, alias="tcp-concurrent")
interface_name: str | None = Field(default=None, alias="interface-name")
routing_mark: int | None = Field(default=None, alias="routing-mark")
tls: dict[str, Any] | None = Field(default=None, alias="tls")
global_client_fingerprint: ClientFingerprint | None = Field(default=ClientFingerprint.chrome,
alias="global-client-fingerprint")
geodata_mode: bool | None = Field(default=None, alias="geodata-mode")
geodata_loader: Literal["memconservative", "standard"] = Field(default="memconservative", alias="geodata-loader")
geo_auto_update: bool = Field(default=False, alias="geo-auto-update")
geo_update_interval: int = Field(default=24, alias="geo-update-interval")
global_ua: str = Field(default="clash.meta", alias="global-ua")
etag_support: bool = Field(default=True, alias="etag-support")
sniffer: dict[str, Any] | None = None
listeners: list[dict[str, Any]] | None = Field(default=None)
port: int = Field(default=0, description="HTTP(S) proxy port")
socks_port: int = Field(default=0, alias="socks-port")
mixed_port: int = Field(default=0, alias="mixed-port")
redir_port: int = Field(default=0, alias="redir-port")
tproxy_port: int = Field(default=0, alias="tproxy-port")
tun: dict[str, Any] | None = Field(default=None)
sub_rules: dict[str, Any] | None = Field(default=None, alias="sub-rules")
tunnels: list[dict[str, Any] | str] | None = Field(default=None)
ntp: NTP | None = Field(default=None)
experimental: Experimental | None = Field(default=None)
proxies: list[Proxy] = Field(default_factory=list)
proxy_providers: dict[str, ProxyProvider] = Field(default_factory=dict, alias="proxy-providers")
proxy_groups: list[ProxyGroup] = Field(default_factory=list, alias="proxy-groups")
rules: list[RuleType] = Field(default_factory=list)
rule_providers: dict[str, RuleProvider] = Field(default_factory=dict, alias="rule-providers")
@model_validator(mode="before")
@classmethod
def fill_none_with_default(cls, values: dict):
fill_none_fields = {"proxies", "proxy_providers", "proxy_groups", "rules", "rule_providers"}
for field_name in fill_none_fields:
field = cls.model_fields[field_name]
factory = field.default_factory
if not factory:
continue
keys = {field_name}
if field.alias:
keys.add(field.alias)
for key in keys:
if key in values and values[key] is None:
values[key] = factory()
return values
@field_serializer("proxies")
def serialize_proxies(self, v: list[Proxy], _info):
serialized_proxies = []
seen_names = set()
for proxy in v:
if proxy.name in seen_names:
logger.warning(f"Skipping duplicate proxy: {proxy.name}")
continue
seen_names.add(proxy.name)
serialized_proxies.append(proxy.model_dump(by_alias=True, exclude_none=True, mode="json"))
return serialized_proxies
@field_serializer("proxy_groups")
def serialize_proxy_groups(self, v: list[ProxyGroup], _info):
valid_outbounds = {a.value for a in Action}
valid_outbounds.add("GLOBAL")
if self.proxies:
valid_outbounds.update(p.name for p in self.proxies)
if v:
valid_outbounds.update(pg.name for pg in v)
serialized_groups = []
seen_names = set()
for group in v:
if group.name in seen_names:
logger.warning(f"Skipping duplicate proxy group: {group.name}")
continue
seen_names.add(group.name)
group_data = group.model_dump(by_alias=True, exclude_none=True, mode="json")
if "proxies" in group_data and group_data["proxies"]:
original_proxies = group_data["proxies"]
group_data["proxies"] = [
p for p in original_proxies if p in valid_outbounds
]
removed = set(original_proxies) - set(group_data["proxies"])
if removed:
logger.warning(f"Proxy group {group.name} removed missing proxies: {removed}")
serialized_groups.append(group_data)
return serialized_groups
@field_validator("mode", mode="before")
@classmethod
def validate_mode(cls, v):
if isinstance(v, str):
return v.lower()
return v
@field_validator("rules", mode="before")
@classmethod
def validate_rules(cls, v):
if isinstance(v, list):
rules = []
for item in v:
if isinstance(item, str):
rules.append(ClashRuleParser.parse(item))
else:
rules.append(item)
return rules
return v
@field_serializer("rules")
def serialize_rules(self, v: list[RuleType], _info):
valid_rules = []
valid_outbounds = set(self.outbounds)
valid_actions = {a.value for a in Action}
for rule in v:
if rule.rule_type == RoutingRuleType.SUB_RULE:
if self.sub_rules and rule.action in self.sub_rules:
valid_rules.append(rule)
else:
logger.warning(f"Skipping rule with missing sub-rule action: {rule}")
continue
if rule.rule_type == RoutingRuleType.RULE_SET:
if rule.payload not in self.rule_providers:
logger.warning(f"Skipping rule with missing rule-provider: {rule}")
continue
action_str = str(rule.action)
if action_str in valid_actions or action_str in valid_outbounds:
valid_rules.append(rule)
else:
logger.warning(f"Skipping rule with invalid outbound: {rule}")
return [str(rule) for rule in valid_rules]
@property
def outbounds(self) -> list[str]:
outbounds = []
if self.proxies:
outbounds.extend(p.name for p in self.proxies)
if self.proxy_groups:
outbounds.extend(pg.name for pg in self.proxy_groups)
return outbounds
@property
def node_num(self) -> int:
return len(self.proxies)
@property
def raw_proxies(self) -> dict[str, str]:
return self._raw_proxies
@raw_proxies.setter
def raw_proxies(self, value: dict[str, str]):
self._raw_proxies = value
def merge(self, other: 'ClashConfig') -> 'ClashConfig':
self.proxies += other.proxies
self.proxy_groups += other.proxy_groups
self.rules += other.rules
self.rule_providers |= other.rule_providers
self.proxy_providers |= other.proxy_providers
return self

View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel, Field
from .api import SubscriptionsInfo
from .configuration import ClashConfig
from .datapatch import DataPatch
from .hosts import Hosts
from .proxy import Proxies
from .proxygroups import ProxyGroups
from .ruleproviders import RuleProviders, RuleProvider
from .types import DataKey
class GeoRules(BaseModel):
geoip: list[str] = Field(default_factory=list)
geosite: list[str] = Field(default_factory=list)
class PersistState(BaseModel):
proxies: Proxies = Field(alias=DataKey.PROXIES, default_factory=Proxies)
proxy_groups: ProxyGroups = Field(alias=DataKey.PROXY_GROUPS, default_factory=ProxyGroups)
subscription_info: SubscriptionsInfo = Field(alias=DataKey.SUB_INFO, default_factory=SubscriptionsInfo)
rule_provider: dict[str, RuleProvider] = Field(alias=DataKey.AUTO_RULE_PROVIDERS, default_factory=dict)
rule_providers: RuleProviders = Field(alias=DataKey.RULE_PROVIDERS, default_factory=RuleProviders)
ruleset_names: dict[str, str] = Field(alias=DataKey.RULESET_NAMES, default_factory=dict)
acl4ssr_providers: RuleProviders = Field(alias=DataKey.ACL4SSR, default_factory=RuleProviders)
sub_configs: dict[str, ClashConfig] = Field(alias=DataKey.SUB_CONFIGS, default_factory=dict)
hosts: Hosts = Field(alias=DataKey.HOSTS, default_factory=Hosts)
proxy_group_patch: DataPatch = Field(alias=DataKey.PROXY_GROUP_PATCH, default_factory=DataPatch)
proxy_patch: DataPatch = Field(alias=DataKey.PROXY_PATCH, default_factory=DataPatch)
geo_rules: GeoRules = Field(alias=DataKey.GEO_RULES, default_factory=GeoRules)
rule_provider_patch: DataPatch = Field(alias=DataKey.RULE_PROVIDER_PATCH, default_factory=DataPatch)

View File

@@ -0,0 +1,32 @@
from pydantic import BaseModel, Field, RootModel
class PatchItem(BaseModel):
lifecycle: int = Field(default=3)
patch: str
class DataPatch(RootModel[dict[str, PatchItem]]):
"""DataPatch model for storing patch items."""
root: dict[str, PatchItem] = Field(default_factory=dict, description="Dictionary of patch items.")
def update_patch(self, alive_keys: list[str] | set[str], lifespan: int = 3):
outdated_keys = []
for key in list(self.root.keys()):
if key not in alive_keys:
self.root[key].lifecycle -= 1
if self.root[key].lifecycle == 0:
outdated_keys.append(key)
else:
self.root[key].lifecycle = lifespan
for key in outdated_keys:
del self.root[key]
def __setitem__(self, key: str, value: PatchItem):
self.root[key] = value
def __contains__(self, key: str) -> bool:
return key in self.root
def __getitem__(self, key: str) -> PatchItem:
return self.root[key]

View File

@@ -0,0 +1,93 @@
from typing import TypeVar, Generic, Iterator, Any
from pydantic import BaseModel, RootModel, Field, model_validator
from .metadata import Metadata
# Specific data payload model
T = TypeVar("T")
class ResourceItem(BaseModel, Generic[T]):
"""Generic resource item model"""
name: str = Field(..., description="Resource name")
data: T = Field(..., description="Resource data payload")
meta: Metadata = Field(default_factory=Metadata, description="Resource metadata")
# Subclasses of ResourceItem
R = TypeVar("R", bound=ResourceItem)
class ResourceList(RootModel[list[R]], Generic[R]):
"""
Generic configuration list base class
"""
root: list[R] = Field(default_factory=list)
@model_validator(mode='after')
def validate_unique_names(self) -> 'ResourceList[R]':
names = [item.name for item in self.root]
if len(names) != len(set(names)):
raise ValueError("names must be unique")
return self
def __iter__(self) -> Iterator[R]:
return iter(self.root)
def __len__(self) -> int:
return len(self.root)
def __contains__(self, name: str) -> bool:
"""Check if a configuration with the specified name exists"""
return any(item.name == name for item in self.root)
def get(self, name: str) -> R | None:
"""Get the configuration item by name"""
for item in self.root:
if item.name == name:
return item
return None
def add(self, item: R):
"""Add a configuration item, raise an exception if the name is duplicated"""
if item.name in self:
raise ValueError(f"name {item.name!r} already exists")
self.root.insert(0, item)
def remove(self, name: str):
"""Remove the configuration item by name"""
self.root = [item for item in self.root if item.name != name]
def pop(self, name: str) -> R | None:
"""Remove and return the configuration item with the specified name"""
for i, item in enumerate(self.root) :
if item.name == name:
return self.root.pop(i)
return None
def update(self, name: str, item: R):
"""Update the configuration item with the specified name"""
for i, existing_item in enumerate(self.root):
if existing_item.name == name:
item.meta = self.root[i].meta
self.root[i] = item
return
def update_data(self, name: str, data: Any) -> bool:
"""Update only the data payload of the configuration item with the specified name"""
item = self.get(name)
if item:
item.data = data
return True
return False
def set_meta(self, name: str, meta: Metadata) -> bool:
"""Set metadata for the specified configuration item"""
item = self.get(name)
if item:
item.meta = meta
return True
return False
@property
def names(self) -> list[str]:
"""Return a list of names for all configuration items"""
return [item.name for item in self.root]

View File

@@ -0,0 +1,33 @@
from pydantic import Field, RootModel, BaseModel
from .metadata import Metadata
class HostData(BaseModel):
domain: str
value: list[str]
using_cloudflare: bool
meta: Metadata = Field(default_factory=Metadata)
class Hosts(RootModel[list[HostData]]):
root: list[HostData] = Field(default_factory=list)
def __len__(self) -> int:
return len(self.root)
def update(self, domain: str, data: HostData):
self.root = [host for host in self.root if host.domain != domain]
self.root.append(data)
def delete(self, domain: str):
self.root = [host for host in self.root if host.domain != domain]
def to_dict(self, cloudflare: list[str]) -> dict[str, list[str]]:
hosts = {}
for host in self.root:
if host.using_cloudflare:
hosts[host.domain] = cloudflare
else:
hosts[host.domain] = host.value
return hosts

View File

@@ -0,0 +1,25 @@
import time
from pydantic import BaseModel, Field
from .api import ConfigRequest
from .types import DataSource
class Metadata(BaseModel):
"""Metadata model for Clash items"""
# source of the item
source: DataSource = Field(default=DataSource.MANUAL)
# whether the item is disabled
disabled: bool = Field(default=False)
# roles that cannot see the item
invisible_to: list[str] = Field(default_factory=list)
# additional remarks
remark: str = Field(default="")
# last modified time
time_modified: float = Field(default_factory=lambda: time.time())
# whether the item has been patched
patched: bool = Field(default=False)
def available(self, param: ConfigRequest | None = None) -> bool:
return not self.disabled and (param is None or not any(param.resolve(expr) for expr in self.invisible_to))

View File

@@ -1,6 +1,7 @@
from typing import Union
import jsonpatch
from typing import Union, Any
from pydantic import Field, RootModel
from pydantic import Field, RootModel, model_validator
from .anytlsproxy import AnyTLSProxy
from .directproxy import DirectProxy
@@ -22,6 +23,7 @@ from .tuicproxy import TuicProxy
from .vlessproxy import VlessProxy
from .vmessproxy import VmessProxy
from .wireguardproxy import WireGuardProxy
from ..generics import ResourceItem, ResourceList
ProxyType = Union[
AnyTLSProxy,
@@ -46,3 +48,31 @@ ProxyType = Union[
class Proxy(RootModel[ProxyType]):
root: ProxyType = Field(..., discriminator="type")
@property
def name(self) -> str:
return self.root.name
def __getattr__(self, item):
return getattr(self.root, item)
def patch(self, patch: str) -> 'Proxy':
src = self.model_dump(mode='json', by_alias=True)
patched = jsonpatch.apply_patch(src, patch=patch, in_place=True)
return Proxy.model_validate(patched)
class ProxyData(ResourceItem[Proxy]):
raw: Union[str, dict[str, Any], None] = None
v2ray_link: str | None = None
@model_validator(mode="after")
def validate_name_consistency(self):
if self.name != self.data.name:
raise ValueError(f"name ({self.name}) must equal data.name ({self.data.name})")
return self
class Proxies(ResourceList[ProxyData]):
"""Proxies Collection"""
pass

View File

@@ -10,8 +10,8 @@ class Hysteria2Proxy(ProxyBase):
password: Optional[str] = None
obfs: Optional[Literal['salamander']] = None
obfs_password: Optional[str] = Field(None, alias='obfs-password')
up: Optional[str] = None
down: Optional[str] = None
up: Optional[int | str] = None
down: Optional[int | str] = None
hop_interval: Optional[int] = Field(None, alias='hop-interval')
ca: Optional[str] = None
ca_str: Optional[str] = Field(None, alias='ca-str')

View File

@@ -10,10 +10,8 @@ class HysteriaProxy(ProxyBase):
auth_str: Optional[str] = Field(None, alias='auth-str')
auth: Optional[str] = None
protocol: Optional[Literal['udp','wechat-video', 'faketcp']] = None
up: Optional[str] = None
down: Optional[str] = None
up_speed: Optional[int] = Field(None, alias='up-speed')
down_speed: Optional[int] = Field(None, alias='down-speed')
up: Optional[int | str] = None
down: Optional[int | str] = None
obfs: Optional[str] = None
obfs_protocol: Optional[str] = Field(None, alias='obfs-protocol')
recv_window_conn: Optional[int] = Field(None, alias='recv-window-conn')

View File

@@ -1,8 +1,21 @@
from typing import List, Optional, Literal
from enum import StrEnum
from typing import List, Optional
from pydantic import BaseModel, Field
class ClientFingerprint(StrEnum):
chrome = 'chrome'
firefox = 'firefox'
safari = 'safari'
ios = 'ios'
android = 'android'
edge = 'edge'
n360 = '360'
qq = 'qq'
random = 'random'
class RealityOpts(BaseModel):
public_key: str = Field(..., alias='public-key')
short_id: Optional[str] = Field(None, alias='short-id')
@@ -23,6 +36,6 @@ class TLSMixin(BaseModel):
fingerprint: Optional[str] = None
alpn: Optional[List[str]] = None
skip_cert_verify: Optional[bool] = Field(None, alias='skip-cert-verify')
client_fingerprint: Optional[Literal['chrome', 'firefox', 'safari', 'ios', 'android', 'edge', '360', 'qq', 'random']] = Field(None, alias='client-fingerprint')
client_fingerprint: Optional[ClientFingerprint] = Field(None, alias='client-fingerprint')
reality_opts: Optional[RealityOpts] = Field(None, alias='reality-opts')
ech_opts: Optional[EchOpts] = Field(None, alias='ech-opts')

View File

@@ -1,7 +1,10 @@
import jsonpatch
import re
from typing import List, Optional, Union, Literal
from pydantic import BaseModel, Field, field_validator, RootModel
from pydantic import BaseModel, Field, field_validator, RootModel, model_validator
from .generics import ResourceItem, ResourceList
class ProxyGroupBase(BaseModel):
@@ -12,44 +15,45 @@ class ProxyGroupBase(BaseModel):
name: str = Field(..., description="The name of the proxy group.")
# Proxy and provider references
proxies: Optional[List[str]] = Field(None, description="References to outbound proxies or other proxy groups.")
use: Optional[List[str]] = Field(None, description="References to proxy provider sets.")
proxies: Optional[List[str]] = Field(default=None,
description="References to outbound proxies or other proxy groups.")
use: Optional[List[str]] = Field(default=None, description="References to proxy provider sets.")
# Health check fields
url: Optional[str] = Field(None, description="Health check test address.")
interval: Optional[int] = Field(None, description="Health check interval in seconds.")
lazy: Optional[bool] = Field(True, description="If not selected, no health checks are performed.")
timeout: Optional[int] = Field(None, description="Health check timeout in milliseconds.")
max_failed_times: Optional[int] = Field(5, description="Maximum number of failures before a forced health check.",
alias="max-failed-times")
expected_status: Optional[str] = Field('*',
description="Expected HTTP response status code for health checks.",
alias="expected-status")
url: Optional[str] = Field(default="https://www.gstatic.com/generate_204", description="Health check test address.")
interval: Optional[int] = Field(default=300, description="Health check interval in seconds.")
lazy: Optional[bool] = Field(default=True, description="If not selected, no health checks are performed.")
timeout: Optional[int] = Field(default=5000, description="Health check timeout in milliseconds.")
max_failed_times: Optional[int] = Field(default=5, alias="max-failed-times",
description="Maximum number of failures before a forced health check.")
expected_status: Optional[str] = Field(default='*', alias="expected-status",
description="Expected HTTP response status code for health checks.")
# Network and routing fields
disable_udp: Optional[bool] = Field(False, description="Disables UDP for this proxy group.", alias="disable-udp")
interface_name: Optional[str] = Field(None, description="DEPRECATED. Specifies the outbound interface.",
disable_udp: Optional[bool] = Field(default=False, description="Disables UDP for this proxy group.",
alias="disable-udp")
interface_name: Optional[str] = Field(default=None, description="DEPRECATED. Specifies the outbound interface.",
alias="interface-name")
routing_mark: Optional[int] = Field(None, description="DEPRECATED. The routing mark for outbound connections.",
alias="routing-mark")
routing_mark: Optional[int] = Field(default=None, alias="routing-mark",
description="DEPRECATED. The routing mark for outbound connections.")
# Dynamic proxy inclusion
include_all: Optional[bool] = Field(False, description="Includes all outbound proxies and proxy sets.",
include_all: Optional[bool] = Field(default=False, description="Includes all outbound proxies and proxy sets.",
alias="include-all")
include_all_proxies: Optional[bool] = Field(False, description="Includes all outbound proxies.",
include_all_proxies: Optional[bool] = Field(default=False, description="Includes all outbound proxies.",
alias="include-all-proxies")
include_all_providers: Optional[bool] = Field(False, description="Includes all proxy provider sets.",
include_all_providers: Optional[bool] = Field(default=False, description="Includes all proxy provider sets.",
alias="include-all-providers")
# Filtering
filter: Optional[str] = Field(None, description="Regex to filter nodes from providers.")
exclude_filter: Optional[str] = Field(None, description="Regex to exclude nodes.", alias="exclude-filter")
exclude_type: Optional[str] = Field(None, description="Exclude nodes by adapter type, separated by '|'.",
filter: Optional[str] = Field(default=None, description="Regex to filter nodes from providers.")
exclude_filter: Optional[str] = Field(default=None, description="Regex to exclude nodes.", alias="exclude-filter")
exclude_type: Optional[str] = Field(default=None, description="Exclude nodes by adapter type, separated by '|'.",
alias="exclude-type")
# UI fields
hidden: Optional[bool] = Field(False, description="Hides the proxy group in the API.")
icon: Optional[str] = Field(None, description="Icon string for the proxy group, for UI use.")
hidden: Optional[bool] = Field(default=False, description="Hides the proxy group in the API.")
icon: Optional[str] = Field(default=None, description="Icon string for the proxy group, for UI use.")
@field_validator('expected_status')
@classmethod
@@ -72,44 +76,50 @@ class ProxyGroupBase(BaseModel):
class SelectGroup(ProxyGroupBase):
type: Literal['select']
type: Literal['select'] = "select"
class RelayGroup(ProxyGroupBase):
type: Literal['relay']
type: Literal['relay'] = "relay"
class FallbackGroup(ProxyGroupBase):
type: Literal['fallback']
type: Literal['fallback'] = "fallback"
class UrlTestGroup(ProxyGroupBase):
type: Literal['url-test']
tolerance: Optional[int] = Field(None, description="proxies switch tolerance, measured in milliseconds (ms).")
type: Literal['url-test'] = "url-test"
tolerance: Optional[int] = Field(default=None, description="proxies switch tolerance, measured in milliseconds (ms).")
class LoadBalanceGroup(ProxyGroupBase):
type: Literal['load-balance']
type: Literal['load-balance'] = "load-balance"
strategy: Optional[Literal['round-robin', 'consistent-hashing', 'sticky-sessions']] = Field(
'round-robin',
description="Load balancing strategy."
default='round-robin', description="Load balancing strategy."
)
class SmartGroup(ProxyGroupBase):
type: Literal['smart']
uselightgbm: bool = Field(..., description="Use LightGBM model predict weight.")
collectdata: bool = Field(..., description="Collect datas for model training.")
policy_priority: Optional[str] = Field("1",
type: Literal['smart'] = "smart"
uselightgbm: bool = Field(default=False, description="Use LightGBM model predict weight.")
collectdata: bool = Field(default=False, description="Collect datas for model training.")
policy_priority: Optional[str] = Field(default=None,
description="<1 means lower priority, >1 means higher priority, "
"the default is 1, pattern support regex and string.",
alias="policy-priority")
strategy: Optional[Literal['round-robin', 'sticky-sessions']] = Field(
'sticky-sessions',
description="Load balancing strategy."
default='sticky-sessions', description="Load balancing strategy."
)
sample_rate: Optional[int] = Field(1, description="Data acquisition rate.", alias="sample-rate")
sample_rate: Optional[int] = Field(default=1, description="Data acquisition rate.", alias="sample-rate")
@field_validator('policy_priority', mode='before')
@classmethod
def validate_policy_priority(cls, v):
if v is None or v == "":
return None
if not isinstance(v, str):
raise ValueError('policy_priority must be a string')
return v
# Discriminated Union
ProxyGroupType = Union[SelectGroup, RelayGroup, FallbackGroup, UrlTestGroup, LoadBalanceGroup, SmartGroup]
@@ -117,3 +127,37 @@ ProxyGroupType = Union[SelectGroup, RelayGroup, FallbackGroup, UrlTestGroup, Loa
class ProxyGroup(RootModel[ProxyGroupType]):
root: ProxyGroupType = Field(..., discriminator='type')
@property
def name(self) -> str:
return self.root.name
@property
def proxies(self) -> list[str]:
if self.root.proxies:
return self.root.proxies
return []
def __getattr__(self, item):
return getattr(self.root, item)
def patch(self, patch: str) -> 'ProxyGroup':
src = self.model_dump(mode="json", by_alias=True)
patched = jsonpatch.apply_patch(src, patch=patch, in_place=True)
return ProxyGroup.model_validate(patched)
class ProxyGroupData(ResourceItem[ProxyGroup]):
"""Proxy Group Data"""
@model_validator(mode="after")
def validate_name_consistency(self):
data_name = self.data.name
if self.name != data_name:
raise ValueError(f"name ({self.name}) must equal data.name ({data_name})")
return self
class ProxyGroups(ResourceList[ProxyGroupData]):
"""Proxy Groups Collection"""
pass

View File

@@ -0,0 +1,130 @@
from typing import Any, Optional
from pydantic import BaseModel, Field, field_validator, ConfigDict
from .generics import ResourceItem, ResourceList
from .types import VehicleType
class OverrideProxyName(BaseModel):
"""代理名称覆盖配置"""
pattern: str | None = Field(None, description="正则表达式模式")
target: str = Field(..., description="替换目标")
class Override(BaseModel):
"""代理配置覆盖"""
tfo: bool | None = Field(None, description="TCP Fast Open")
mptcp: bool | None = Field(None, description="Multipath TCP")
udp: bool | None = Field(None, description="UDP支持")
udp_over_tcp: bool | None = Field(None, alias="udp-over-tcp", description="UDP over TCP")
up: str | None = Field(None, description="上传速度限制")
dialer_proxy: str | None = Field(None, alias="dialer-proxy", description="拨号代理")
skip_cert_verify: bool | None = Field(None, alias="skip-cert-verify", description="跳过证书验证")
interface_name: Optional[str] = Field(None, alias="interface-name", description="网络接口名称")
routing_mark: int | None = Field(None, alias="routing-mark", description="路由标记")
ip_version: str | None = Field(None, alias="ip-version", description="IP版本偏好")
additional_prefix: str | None = Field(None, alias="additional-prefix", description="名称前缀")
additional_suffix: str | None = Field(None, alias="additional-suffix", description="名称后缀")
proxy_name: list[OverrideProxyName] | None = Field(None, alias="proxy-name", description="代理名称替换规则")
class HealthCheck(BaseModel):
"""健康检查配置"""
enable: bool = Field(..., description="启用健康检查")
url: str = Field(..., description="健康检查URL")
interval: int = Field(300, description="检查间隔(秒)")
timeout: int | None = Field(None, description="超时时间(毫秒)")
lazy: bool = Field(True, description="懒加载模式")
expected_status: str | None = Field(None, alias="expected-status", description="期望的HTTP状态码")
@field_validator('interval')
@classmethod
def validate_interval(cls, v):
if v <= 0:
raise ValueError("间隔时间必须大于0")
return v
@field_validator('timeout')
@classmethod
def validate_timeout(cls, v):
if v is not None and v <= 0:
raise ValueError("超时时间必须大于0")
return v
class ProxyProvider(BaseModel):
"""Proxy Provider"""
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
type: VehicleType = Field(..., description="Provider类型")
path: str | None = Field(default=None, description="本地文件路径")
url: str | None = Field(default=None, description="远程URL")
proxy: str | None = Field(default=None, description="使用的代理")
interval: int | None = Field(default=None, description="更新间隔(秒)")
filter: str | None = Field(default=None, description="过滤正则表达式")
exclude_filter: str | None = Field(default=None, alias="exclude-filter", description="排除过滤正则表达式")
exclude_type: str | None = Field(default=None, alias="exclude-type", description="排除的代理类型")
dialer_proxy: str | None = Field(default=None, alias="dialer-proxy", description="拨号代理")
size_limit: int | None = Field(default=None, alias="size-limit", description="文件大小限制(字节)")
payload: list[dict[str, Any]] | None = Field(default=None, description="内联代理配置")
health_check: HealthCheck | None = Field(default=None, alias="health-check", description="健康检查配置")
override: Override | None = Field(default=None, description="配置覆盖")
header: dict[str, list[str]] | None = Field(default=None, description="HTTP请求头")
@field_validator('interval')
@classmethod
def validate_interval(cls, v):
if v is not None and v <= 0:
raise ValueError("间隔时间必须大于0")
return v
@field_validator('size_limit')
@classmethod
def validate_size_limit(cls, v):
if v is not None and v < 0:
raise ValueError("文件大小限制不能为负数")
return v
@field_validator('exclude_type')
@classmethod
def validate_exclude_type(cls, v):
if v is not None:
types = [t.strip() for t in v.split('|')]
if not all(types):
raise ValueError("排除类型不能为空")
return v
@field_validator('url')
@classmethod
def validate_url_dependency(cls, v, info):
if info.data.get('type') == VehicleType.HTTP and not v:
raise ValueError("HTTP类型的provider必须提供URL")
return v
@field_validator('path')
@classmethod
def validate_path_dependency(cls, v, info):
if info.data.get('type') == VehicleType.FILE and not v:
raise ValueError("FILE类型的provider必须提供路径")
return v
@field_validator('payload')
@classmethod
def validate_payload_dependency(cls, v, info):
if info.data.get('type') == VehicleType.INLINE and not v:
raise ValueError("INLINE类型的provider必须提供payload")
return v
class ProxyProviderData(ResourceItem[ProxyProvider]):
"""Proxy Provider Data"""
pass
class ProxyProviders(ResourceList[ProxyProviderData]):
"""Proxy Provider Collection"""
pass

View File

@@ -1,4 +1,4 @@
from enum import Enum
from enum import Enum, StrEnum
from typing import Any, List, Optional, Union, Dict, Literal
from pydantic import BaseModel, field_validator, ValidationInfo
@@ -57,7 +57,7 @@ class RoutingRuleType(Enum):
MATCH = "MATCH"
class Action(Enum):
class Action(StrEnum):
"""Enumeration of rule actions"""
DIRECT = "DIRECT"
REJECT = "REJECT"
@@ -65,9 +65,6 @@ class Action(Enum):
PASS = "PASS"
COMPATIBLE = "COMPATIBLE"
def __str__(self) -> str:
return self.value
class RuleBase(BaseModel):
rule_type: RoutingRuleType
@@ -101,7 +98,7 @@ class ClashRule(RuleBase):
'payload': self.payload,
'action': self.action.value if isinstance(self.action, Action) else self.action,
'additional_params': self.additional_params.value if self.additional_params else None,
'raw': self.raw_rule
'rule_string': str(self)
}
def __str__(self) -> str:
@@ -131,7 +128,7 @@ class LogicRule(RuleBase):
return f"{self.rule_type.value},({conditions_str})"
def to_dict(self) -> Dict[str, Any]:
conditions = []
conditions: list[str] = []
for condition in self.conditions:
conditions.append(condition.condition_string())
@@ -139,7 +136,7 @@ class LogicRule(RuleBase):
'type': self.rule_type.value,
'conditions': conditions,
'action': self.action.value if isinstance(self.action, Action) else self.action,
'raw': self.raw_rule
'rule_string': str(self)
}
@field_validator('conditions', mode='after')
@@ -166,7 +163,7 @@ class SubRule(RuleBase):
'type': self.rule_type.value,
'condition': f"({self.condition.condition_string()})",
'action': self.action,
'raw': self.raw_rule
'rule_string': str(self)
}
def __str__(self) -> str:
@@ -185,7 +182,7 @@ class MatchRule(RuleBase):
return {
'type': 'MATCH',
'action': self.action.value if isinstance(self.action, Action) else self.action,
'raw': self.raw_rule
'rule_string': str(self)
}
def __str__(self) -> str:

View File

@@ -0,0 +1,40 @@
from pydantic import BaseModel, Field, field_validator, field_serializer
from .metadata import Metadata
from .rule import RuleType
from .rule import RoutingRuleType, Action, AdditionalParam
from ..helper.clashruleparser import ClashRuleParser
class RuleItem(BaseModel):
"""Clash rule item"""
rule: RuleType
meta: Metadata = Field(default_factory=Metadata)
@field_serializer("rule")
def serialize_rule(self, v: RuleType, _info):
return str(v)
@field_validator("rule", mode="before")
@classmethod
def validate_rule(cls, v):
if isinstance(v, str):
return ClashRuleParser.parse(v)
return v
class RuleData(BaseModel):
priority: int
rule_string: str
type: RoutingRuleType
payload: str | None = None
action: Action | str
additional_params: AdditionalParam | None = None
conditions: list[str] | None = None
condition: str | None = None
meta: Metadata = Field(default_factory=Metadata)
@classmethod
def from_rule_item(cls, item: RuleItem, priority: int) -> 'RuleData':
fields = item.rule.to_dict()
return cls(priority=priority, meta=item.meta, **fields)

View File

@@ -1,20 +1,31 @@
from typing import List, Optional, Literal, Dict
from typing import Annotated, List, Optional, Literal
from pydantic import BaseModel, Field, model_validator, HttpUrl, RootModel
from pydantic import BaseModel, ConfigDict, Field, model_validator
from .generics import ResourceItem, ResourceList
from .types import VehicleType
class RuleProvider(BaseModel):
type: Literal["http", "file", "inline"] = Field(..., description="Provider type")
url: Optional[HttpUrl] = Field(None, description="Must be configured if the type is http")
path: Optional[str] = Field(None, description="Optional, file path, must be unique.")
interval: Optional[int] = Field(None, ge=0, description="The update interval for the provider, in seconds.")
proxy: Optional[str] = Field(None, description="Download/update through the specified proxy.")
"""Rule Provider"""
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
type: VehicleType = Field(..., description="Provider type")
url: Optional[str] = Field(default=None, description="Must be configured if the type is http")
path: Optional[str] = Field(default=None, description="Optional, file path, must be unique.")
interval: Optional[int] = Field(default=None, ge=0, description="The update interval for the provider, in seconds.")
proxy: Optional[str] = Field(default=None, description="Download/update through the specified proxy.")
behavior: Optional[Literal["domain", "ipcidr", "classical"]] = Field(None,
description="Behavior of the rule provider")
format: Literal["yaml", "text", "mrs"] = Field("yaml", description="Format of the rule provider file")
size_limit: int = Field(0, ge=0, description="The maximum size of downloadable files in bytes (0 for no limit)",
alias="size-limit")
payload: Optional[List[str]] = Field(None, description="Content, only effective when type is inline")
size_limit: Annotated[int, Field(
default=0, ge=0, validation_alias="size-limit", serialization_alias="size-limit",
description="The maximum size of downloadable files in bytes (0 for no limit)")
] = 0
payload: Optional[List[str]] = Field(default=None, description="Content, only effective when type is inline")
@model_validator(mode="before")
@classmethod
@@ -55,5 +66,11 @@ class RuleProvider(BaseModel):
return values
class RuleProviders(RootModel[Dict[str, RuleProvider]]):
root: Dict[str, RuleProvider]
class RuleProviderData(ResourceItem[RuleProvider]):
"""Rule Provider Data"""
pass
class RuleProviders(ResourceList[RuleProviderData]):
"""Rule Providers Collection"""
pass

View File

@@ -0,0 +1,57 @@
from enum import StrEnum
from typing import TypeVar, Protocol
class DataSource(StrEnum):
MANUAL = "Manual"
ACL4SSR = "Acl4SSR"
TEMPLATE = "Template"
SUB = "Subscription"
AUTO = "Auto"
class VehicleType(StrEnum):
FILE = "file"
HTTP = "http"
INLINE = "inline"
class DataKey(StrEnum):
"""Plugin data key"""
PROXY_PATCH = "proxy_patch"
PROXY_GROUPS = "proxy-groups"
PROXIES = "proxies"
INVALID_PROXIES = "extra_proxies"
SUB_INFO = "subscription_info"
HOSTS = "hosts"
ACL4SSR = "acl4ssr_providers"
RULE_PROVIDERS = "rule-providers"
DATA_VERSION = "data_version"
SUB_CONFIGS = "clash_configs"
PROXY_GROUP_PATCH = "proxy_group_patch"
RULESET_NAMES = "ruleset_names"
AUTO_RULE_PROVIDERS = "rule_provider"
GEO_RULES = "geo_rules"
TOP_RULES = "top_rules"
RULESET_RULES = "ruleset_rules"
RULE_PROVIDER_PATCH = "rule_provider_patch"
RAW_PROXIES = "raw_proxies"
class RuleSet(StrEnum):
TOP = "top"
RULESET = "ruleset"
class ClashKey(StrEnum):
PROXIES = "proxies"
PROXY_GROUPS = "proxy-groups"
NAME = "name"
RULES = "rules"
T = TypeVar("T")
class SupportsPatch(Protocol[T]):
def patch(self, patch: str) -> T:
...

View File

@@ -1,3 +1,5 @@
websockets
sse_starlette~=2.3.6
PyYAML~=6.0.2
sse_starlette~=3.1.1
PyYAML~=6.0.2
jsonpatch~=1.33
simpleeval~=1.0.3

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,283 @@
from dataclasses import dataclass, field
from typing import Any, Dict, List
from itertools import chain
from typing import Any, Generator, Callable
from pydantic import TypeAdapter
from app.core.cache import Cache
from app.db.plugindata_oper import PluginDataOper
from .config import PluginConfig
from .helper.clashrulemanager import ClashRuleManager
from .helper.proxiesmanager import ProxyManager
from .helper.utilsprovider import UtilsProvider
from .models import RuleProviderData, ProxyProviderData, ProxyGroupData, Hosts, ProxyGroups, RuleProviders, \
RuleProvider, Metadata, Proxies, ProxyData
from .models.configuration import ClashConfig
from .models.types import DataSource, RuleSet, DataKey
from .models.datapatch import DataPatch
from .models.api import SubscriptionsInfo
from .models.datamodel import GeoRules, PersistState
@dataclass
class PluginState:
"""
A dataclass to hold all the runtime state of the ClashRuleProvider plugin.
A DAL to manage the runtime state of ClashRuleProvider.
"""
# Rule and Proxy Managers
top_rules_manager: ClashRuleManager = field(default_factory=ClashRuleManager)
ruleset_rules_manager: ClashRuleManager = field(default_factory=ClashRuleManager)
proxies_manager: ProxyManager = field(default_factory=ProxyManager)
def __init__(self, plugin_id: str, config: PluginConfig = None):
self.plugin_id = plugin_id
self.config = config or PluginConfig()
self.plugin_data = PluginDataOper()
self.cache = Cache(maxsize=256, ttl=self.config.cache_ttl)
self.cache_region = f"app.plugins.{self.plugin_id.lower()}"
# Loaded from saved data
proxy_groups: List[Dict[str, Any]] = field(default_factory=list)
extra_proxies: List[Dict[str, Any]] = field(default_factory=list)
subscription_info: Dict[str, Any] = field(default_factory=dict)
rule_provider: Dict[str, Any] = field(default_factory=dict)
rule_providers: Dict[str, Any] = field(default_factory=dict)
ruleset_names: Dict[str, str] = field(default_factory=dict)
acl4ssr_providers: Dict[str, Any] = field(default_factory=dict)
clash_configs: Dict[str, Any] = field(default_factory=dict)
hosts: List[Dict[str, Any]] = field(default_factory=list)
overwritten_region_groups: Dict[str, Any] = field(default_factory=dict)
overwritten_proxies: Dict[str, Any] = field(default_factory=dict)
clash_template_dict: Dict[str, Any] = field(default_factory=dict)
# Build schemas from PersistState model
self._schemas: dict[str, tuple[TypeAdapter, Callable[[], Any]]] = {}
for _, field in PersistState.model_fields.items():
alias = field.alias
if alias:
self._schemas[alias] = (TypeAdapter(field.annotation), field.default_factory)
# Volatile state (generated at runtime)
geo_rules: Dict[str, List[str]] = field(default_factory=lambda: {'geoip': [], 'geosite': []})
# Rule and Proxy Managers (Runtime)
self.top_rules_manager: ClashRuleManager = ClashRuleManager()
self.ruleset_rules_manager: ClashRuleManager = ClashRuleManager()
# Runtime variables (not persisted directly or persisted via config)
self.clash_template: ClashConfig = ClashConfig()
def _get_val(self, key: str) -> Any:
# Check cache
if self.cache.exists(key, region=self.cache_region):
return self.cache.get(key, region=self.cache_region)
data = self.plugin_data.get_data(self.plugin_id, key)
adapter, default_factory = self._schemas.get(key, (None, None))
if data is None:
if default_factory:
val = default_factory()
self.cache.set(key, val, region=self.cache_region)
return val
return None
if adapter:
val = adapter.validate_python(data)
else:
val = data
self.cache.set(key, val, region=self.cache_region)
return val
def _set_val(self, key: str, value: Any):
adapter, _ = self._schemas.get(key, (None, None))
if adapter:
data = adapter.dump_python(value, mode="json", by_alias=True, exclude_none=True)
else:
data = value
self.plugin_data.save(self.plugin_id, key, data)
self.cache.set(key, value, region=self.cache_region)
@property
def proxies(self) -> Proxies:
return self._get_val(DataKey.PROXIES)
@proxies.setter
def proxies(self, value: Proxies):
self._set_val(DataKey.PROXIES, value)
@property
def proxy_groups(self) -> ProxyGroups:
return self._get_val(DataKey.PROXY_GROUPS)
@proxy_groups.setter
def proxy_groups(self, value: ProxyGroups):
self._set_val(DataKey.PROXY_GROUPS, value)
@property
def subscription_info(self) -> SubscriptionsInfo:
return self._get_val(DataKey.SUB_INFO)
@subscription_info.setter
def subscription_info(self, value: SubscriptionsInfo):
self._set_val(DataKey.SUB_INFO, value)
@property
def rule_provider(self) -> dict[str, RuleProvider]:
return self._get_val(DataKey.AUTO_RULE_PROVIDERS)
@rule_provider.setter
def rule_provider(self, value: dict[str, RuleProvider]):
self._set_val(DataKey.AUTO_RULE_PROVIDERS, value)
@property
def rule_providers(self) -> RuleProviders:
return self._get_val(DataKey.RULE_PROVIDERS)
@rule_providers.setter
def rule_providers(self, value: RuleProviders):
self._set_val(DataKey.RULE_PROVIDERS, value)
@property
def ruleset_names(self) -> dict[str, str]:
return self._get_val(DataKey.RULESET_NAMES)
@ruleset_names.setter
def ruleset_names(self, value: dict[str, str]):
self._set_val(DataKey.RULESET_NAMES, value)
@property
def acl4ssr_providers(self) -> RuleProviders:
return self._get_val(DataKey.ACL4SSR)
@acl4ssr_providers.setter
def acl4ssr_providers(self, value: RuleProviders):
self._set_val(DataKey.ACL4SSR, value)
@property
def sub_configs(self) -> dict[str, ClashConfig]:
sub_conf = self._get_val(DataKey.SUB_CONFIGS)
return sub_conf
@sub_configs.setter
def sub_configs(self, value: dict[str, ClashConfig]):
self._set_val(DataKey.SUB_CONFIGS, value)
@property
def hosts(self) -> Hosts:
return self._get_val(DataKey.HOSTS)
@hosts.setter
def hosts(self, value: Hosts):
self._set_val(DataKey.HOSTS, value)
@property
def proxy_group_patch(self) -> DataPatch:
return self._get_val(DataKey.PROXY_GROUP_PATCH)
@proxy_group_patch.setter
def proxy_group_patch(self, value: DataPatch):
self._set_val(DataKey.PROXY_GROUP_PATCH, value)
@property
def proxy_patch(self) -> DataPatch:
return self._get_val(DataKey.PROXY_PATCH)
@proxy_patch.setter
def proxy_patch(self, value: DataPatch):
self._set_val(DataKey.PROXY_PATCH, value)
@property
def rule_provider_patch(self) -> DataPatch:
return self._get_val(DataKey.RULE_PROVIDER_PATCH)
@rule_provider_patch.setter
def rule_provider_patch(self, value: DataPatch):
self._set_val(DataKey.RULE_PROVIDER_PATCH, value)
@property
def geo_rules(self) -> GeoRules:
return self._get_val(DataKey.GEO_RULES)
@geo_rules.setter
def geo_rules(self, value: GeoRules):
self._set_val(DataKey.GEO_RULES, value)
def get_data(self, key: str) -> Any:
return self.plugin_data.get_data(self.plugin_id, key)
def save_data(self, key: str, value: Any):
self.plugin_data.save(self.plugin_id, key, value)
def get_rule_manager(self, ruleset: RuleSet) -> ClashRuleManager:
if ruleset == RuleSet.RULESET:
return self.ruleset_rules_manager
return self.top_rules_manager
def get_sub_config(self, url: str) -> ClashConfig:
conf = self.sub_configs.get(url)
if conf is None:
return ClashConfig()
ret = ClashConfig()
sub_options = self.config.get_sub_conf(url)
for field_name in sub_options.model_fields.keys():
if getattr(sub_options, field_name) is True and field_name in ret.model_fields:
setattr(ret, field_name, getattr(conf, field_name))
return ret
def set_rule_providers(self, rule_providers: dict[str, dict[str, Any]]):
self.rule_provider.clear()
for name, rp in rule_providers.items():
self.rule_providers[name] = RuleProvider(**rp)
def rule_providers_from_subs(self) -> Generator[RuleProviderData, None, None]:
for url, conf in self.sub_configs.items():
if self.config.get_sub_conf(url).rule_providers:
for name, rp in conf.rule_providers.items():
meta = Metadata(source=DataSource.SUB, remark=UtilsProvider.get_url_domain(url))
yield RuleProviderData(name=name, data=rp, meta=meta)
def rule_providers_from_template(self) -> Generator[RuleProviderData, None, None]:
for name, rp in self.clash_template.rule_providers.items():
yield RuleProviderData(meta=Metadata(source=DataSource.TEMPLATE), name=name, data=rp)
def proxy_providers_from_subs(self) -> Generator[ProxyProviderData, None, None]:
for url, conf in self.sub_configs.items():
if self.config.get_sub_conf(url).proxy_providers:
for name, pp in conf.proxy_providers.items():
meta = Metadata(source=DataSource.SUB, remark=UtilsProvider.get_url_domain(url))
yield ProxyProviderData(meta=meta, name=name, data=pp)
def proxy_providers_from_template(self) -> Generator[ProxyProviderData, None, None]:
for name, pp in self.clash_template.proxy_providers.items():
yield ProxyProviderData(meta=Metadata(source=DataSource.TEMPLATE), name=name, data=pp)
def proxy_groups_from_subs(self) -> Generator[ProxyGroupData, None, None]:
for url, conf in self.sub_configs.items():
if self.config.get_sub_conf(url).proxy_groups:
for pg in conf.proxy_groups:
meta = Metadata(source=DataSource.SUB, remark=UtilsProvider.get_url_domain(url))
yield ProxyGroupData(meta=meta, data=pg, name=pg.name)
def proxy_groups_from_template(self) -> Generator[ProxyGroupData, None, None]:
for pg in self.clash_template.proxy_groups:
yield ProxyGroupData(meta=Metadata(source=DataSource.TEMPLATE), data=pg, name=pg.name)
def proxies_from_subs(self) -> Generator[ProxyData, None, None]:
for url, conf in self.sub_configs.items():
for p in conf.proxies:
meta = Metadata(source=DataSource.SUB, remark=UtilsProvider.get_url_domain(url))
yield ProxyData(meta=meta, data=p, name=p.name, v2ray_link=conf.raw_proxies.get(p.name))
def proxies_from_template(self) -> Generator[ProxyData, None, None]:
for p in self.clash_template.proxies:
yield ProxyData(meta=Metadata(source=DataSource.TEMPLATE), data=p, name=p.name)
@property
def all_rule_providers(self) -> list[RuleProviderData]:
return list(chain(
self.rule_providers,
self.rule_providers_from_template(),
self.rule_providers_from_subs(),
self.acl4ssr_providers
))
@property
def all_proxy_providers(self) -> list[ProxyProviderData]:
return list(chain(
self.proxy_providers_from_subs(),
self.proxy_providers_from_template()
))
@property
def all_proxy_groups(self) -> list[ProxyGroupData]:
return list(chain(
self.proxy_groups,
self.proxy_groups_from_subs(),
self.proxy_groups_from_template()
))
@property
def all_proxies(self) -> list[ProxyData]:
return list(chain(
self.proxies,
self.proxies_from_subs(),
self.proxies_from_template()
))

View File

@@ -1,19 +0,0 @@
from typing import Any, Optional
from app.db.plugindata_oper import PluginDataOper
class PluginStore:
"""数据持久化"""
def __init__(self, plugin_id: str):
self.plugin_id = plugin_id
self.plugin_data = PluginDataOper()
def get_data(self, key: Optional[str] = None) -> Any:
return self.plugin_data.get_data(self.plugin_id, key)
def save_data(self, key: str, value: Any):
self.plugin_data.save(self.plugin_id, key, value)
def del_data(self, key: str) -> Any:
self.plugin_data.del_data(self.plugin_id, key)

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

@@ -47,14 +47,14 @@ class DoubanRank(_PluginBase):
# 私有属性
_scheduler = None
_douban_address = {
'movie-ustop': 'https://rsshub.app/douban/movie/ustop',
'movie-weekly': 'https://rsshub.app/douban/movie/weekly',
'movie-real-time': 'https://rsshub.app/douban/movie/weekly/movie_real_time_hotest',
'show-domestic': 'https://rsshub.app/douban/movie/weekly/show_domestic',
'movie-hot-gaia': 'https://rsshub.app/douban/movie/weekly/movie_hot_gaia',
'tv-hot': 'https://rsshub.app/douban/movie/weekly/tv_hot',
'movie-top250': 'https://rsshub.app/douban/movie/weekly/movie_top250',
'movie-top250-full': 'https://rsshub.app/douban/list/movie_top250',
'movie-ustop': '/douban/movie/ustop',
'movie-weekly': '/douban/movie/weekly',
'movie-real-time': '/douban/movie/weekly/movie_real_time_hotest',
'show-domestic': '/douban/movie/weekly/show_domestic',
'movie-hot-gaia': '/douban/movie/weekly/movie_hot_gaia',
'tv-hot': '/douban/movie/weekly/tv_hot',
'movie-top250': '/douban/movie/weekly/movie_top250',
'movie-top250-full': '/douban/list/movie_top250',
}
_enabled = False
_cron = ""
@@ -65,6 +65,7 @@ class DoubanRank(_PluginBase):
_clear = False
_clearflag = False
_proxy = False
_rsshub = "https://rsshub.app"
def init_plugin(self, config: dict = None):
@@ -74,6 +75,7 @@ class DoubanRank(_PluginBase):
self._proxy = config.get("proxy")
self._onlyonce = config.get("onlyonce")
self._vote = float(config.get("vote")) if config.get("vote") else 0
self._rsshub = config.get("rsshub") or "https://rsshub.app"
rss_addrs = config.get("rss_addrs")
if rss_addrs:
if isinstance(rss_addrs, str):
@@ -237,7 +239,7 @@ class DoubanRank(_PluginBase):
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
'md': 4
},
'content': [
{
@@ -254,7 +256,7 @@ class DoubanRank(_PluginBase):
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
'md': 4
},
'content': [
{
@@ -266,6 +268,23 @@ class DoubanRank(_PluginBase):
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'rsshub',
'label': 'RSSHub地址',
'placeholder': 'https://rsshub.app'
}
}
]
}
]
},
@@ -345,6 +364,7 @@ class DoubanRank(_PluginBase):
"proxy": False,
"onlyonce": False,
"vote": "",
"rsshub": "https://rsshub.app",
"ranks": [],
"rss_addrs": "",
"clear": False
@@ -508,6 +528,7 @@ class DoubanRank(_PluginBase):
"cron": self._cron,
"onlyonce": self._onlyonce,
"vote": self._vote,
"rsshub": self._rsshub,
"ranks": self._ranks,
"rss_addrs": '\n'.join(map(str, self._rss_addrs)),
"clear": self._clear
@@ -518,7 +539,10 @@ class DoubanRank(_PluginBase):
刷新RSS
"""
logger.info(f"开始刷新豆瓣榜单 ...")
addr_list = self._rss_addrs + [self._douban_address.get(rank) for rank in self._ranks]
# 构建完整的RSS地址
rsshub_base = self._rsshub.rstrip('/')
rank_addrs = [f"{rsshub_base}{self._douban_address.get(rank)}" for rank in self._ranks if self._douban_address.get(rank)]
addr_list = self._rss_addrs + rank_addrs
if not addr_list:
logger.info(f"未设置榜单RSS地址")
return

View File

@@ -28,7 +28,7 @@ class DownloadSiteTag(_PluginBase):
# 插件图标
plugin_icon = "Youtube-dl_B.png"
# 插件版本
plugin_version = "2.2"
plugin_version = "2.6"
# 插件作者
plugin_author = "叮叮当"
# 作者主页
@@ -55,12 +55,33 @@ class DownloadSiteTag(_PluginBase):
_enabled_media_tag = False
_enabled_tag = True
_enabled_category = False
_enabled_del_tags = False
_category_movie = None
_category_tv = None
_category_anime = None
_downloaders = None
# 默认的tracker映射字符串用于显示在界面上
_tracker_mappings_default = "\n".join([
"chdbits.xyz -> ptchdbits.co",
"agsvpt.trackers.work -> agsvpt.com",
"tracker.cinefiles.info -> audiences.me",
"# 格式说明tracker域名 -> 映射域名",
"# 使用 -> 作为分隔符",
"# 每行一个映射规则,空行和以#开头的行会被忽略",
"# 站点管理中必须存在对应的域名才能生效"
])
_tracker_mappings_str = ""
_tracker_mappings = {}
_del_tags_task_rid = {}
# 前缀配置
_site_prefix = ""
_media_prefix = ""
def init_plugin(self, config: dict = None):
# 初始化删除标签任务rid映射
self._del_tags_task_rid = {}
# 初始化默认的tracker映射
self._tracker_mappings = self._parse_tracker_mappings(self._tracker_mappings_default)
# 读取配置
if config:
self._enabled = config.get("enabled")
@@ -72,10 +93,29 @@ class DownloadSiteTag(_PluginBase):
self._enabled_media_tag = config.get("enabled_media_tag")
self._enabled_tag = config.get("enabled_tag")
self._enabled_category = config.get("enabled_category")
self._enabled_del_tags = config.get("enabled_del_tags")
self._category_movie = config.get("category_movie") or "电影"
self._category_tv = config.get("category_tv") or "电视"
self._category_anime = config.get("category_anime") or "动漫"
self._downloaders = config.get("downloaders")
self._tracker_mappings_str = config.get("tracker_mappings_str", "")
# 读取前缀配置
self._site_prefix = config.get("site_prefix", "")
self._media_prefix = config.get("media_prefix", "")
# 此设置对于老用户来说缺乏具体说明,因此如果为空,表示用户首次更新,则使用默认配置起到提示作用
if not ("tracker_mappings_str" in config):
config["tracker_mappings_str"] = self._tracker_mappings_default
self.update_config(config)
# 如果用户有配置,解析并合并到默认映射中
elif self._tracker_mappings_str:
user_mappings = self._parse_tracker_mappings(self._tracker_mappings_str)
# 将用户映射合并到默认映射中用户映射会覆盖默认映射中相同的key
self._tracker_mappings.update(user_mappings)
# 首次运行时从下载器初始化rid映射
if self._enabled_del_tags:
self._task_del_unused_tags()
# 停止现有任务
self.stop_service()
@@ -146,7 +186,20 @@ class DownloadSiteTag(_PluginBase):
"kwargs": {} # 定时器参数
}]
"""
# 初始化公共服务列表
tasks = []
if self._enabled:
if self._enabled_del_tags:
# 添加 删除所有未被任何种子使用的标签 任务 每5分钟执行一次
tasks.append({
"id": "DeleteUnusedTags",
"name": "删除下载器中未被使用的标签",
"trigger": "interval",
"func": self._task_del_unused_tags,
"kwargs": {
"minutes": 5
}
})
if self._interval == "计划任务" or self._interval == "固定间隔":
if self._interval == "固定间隔":
if self._interval_unit == "小时":
@@ -163,7 +216,7 @@ class DownloadSiteTag(_PluginBase):
if self._interval_time < 5:
self._interval_time = 5
logger.info(f"{self.LOG_TAG}启动定时服务: 最小不少于5分钟, 防止执行间隔太短任务冲突")
return [{
tasks.append({
"id": "DownloadSiteTag",
"name": "补全下载历史的标签与分类",
"trigger": "interval",
@@ -171,16 +224,16 @@ class DownloadSiteTag(_PluginBase):
"kwargs": {
"minutes": self._interval_time
}
}]
})
else:
return [{
tasks.append({
"id": "DownloadSiteTag",
"name": "补全下载历史的标签与分类",
"trigger": CronTrigger.from_crontab(self._interval_cron),
"func": self._complemented_history,
"kwargs": {}
}]
return []
})
return tasks
@staticmethod
def str_to_number(s: str, i: int) -> int:
@@ -188,10 +241,21 @@ class DownloadSiteTag(_PluginBase):
return int(s)
except ValueError:
return i
@staticmethod
def custom_intersection(indexers, tags):
"""
自定义交集算法, 用于处理标签与分类的交集(后缀匹配模式)
"""
return {
idx for idx in set(indexers)
for tag in set(tags)
if idx == tag or tag.endswith(idx)
}
def _complemented_history(self):
"""
补全下载历史的标签与分类
补全下载历史的标签与分类,且执行清理未使用的标签
"""
if not self.service_infos:
return
@@ -199,15 +263,10 @@ class DownloadSiteTag(_PluginBase):
# 记录处理的种子, 供辅种(无下载历史)使用
dispose_history = {}
# 所有站点索引
indexers = [indexer.get("name") for indexer in SitesHelper().get_indexers()]
indexers = [self._generate_site_tag(i.get("name")) if self._site_prefix else i.get("name") for i in SitesHelper().get_indexers()]
# JackettIndexers索引器支持多个站点, 如果不存在历史记录, 则通过tracker会再次附加其他站点名称
indexers.append("JackettIndexers")
indexers.append(self._generate_site_tag("JackettIndexers"))
indexers = set(indexers)
tracker_mappings = {
"chdbits.xyz": "ptchdbits.co",
"agsvpt.trackers.work": "agsvpt.com",
"tracker.cinefiles.info": "audiences.me",
}
for service in self.service_infos.values():
downloader = service.name
downloader_obj = service.instance
@@ -263,7 +322,7 @@ class DownloadSiteTag(_PluginBase):
trackers = self._get_trackers(torrent=torrent, dl_type=service.type)
for tracker in trackers:
# 检查tracker是否包含特定的关键字并进行相应的映射
for key, mapped_domain in tracker_mappings.items():
for key, mapped_domain in self._tracker_mappings.items():
if key in tracker:
domain = mapped_domain
break
@@ -279,12 +338,17 @@ class DownloadSiteTag(_PluginBase):
# 按设置生成需要写入的标签与分类
_tags = []
_cat = None
# 站点标签, 如果勾选开关的话 因允许torrent_site为空时运行到此, 因此需要判断torrent_site不为空
if self._enabled_tag and history.torrent_site:
_tags.append(history.torrent_site)
site_tag = self._generate_site_tag(history.torrent_site)
_tags.append(site_tag)
# 媒体标题标签, 如果勾选开关的话 因允许title为空时运行到此, 因此需要判断title不为空
if self._enabled_media_tag and history.title:
_tags.append(history.title)
media_tag = self._generate_media_tag(history.title)
_tags.append(media_tag)
# 分类, 如果勾选开关的话 <tr暂不支持> 因允许mtype为空时运行到此, 因此需要判断mtype不为空。为防止不必要的识别, 种子已经存在分类torrent_cat时 也不执行
if service.type == "qbittorrent" and self._enabled_category and not torrent_cat and history.type:
# 如果是电视剧 需要区分是否动漫
@@ -298,24 +362,92 @@ class DownloadSiteTag(_PluginBase):
genre_ids = tmdb_info.get("genre_ids")
_cat = self._genre_ids_get_cat(history.type, genre_ids)
# 识别并清理历史标签
tags_to_remove = []
if torrent_tags:
# 站点标签处理
if history.torrent_site:
# 如果配置了站点前缀
if self._site_prefix:
# 清理无前缀的站点标签
if history.torrent_site in torrent_tags:
tags_to_remove.append(history.torrent_site)
# 清理带旧前缀的站点标签(除了当前前缀)
for tag in torrent_tags:
if tag.endswith(history.torrent_site) and tag != f"{self._site_prefix}{history.torrent_site}":
tags_to_remove.append(tag)
# 如果没有配置站点前缀
else:
# 清理所有带前缀的站点标签
for tag in torrent_tags:
if tag.endswith(history.torrent_site) and tag != history.torrent_site:
tags_to_remove.append(tag)
# 剧名标签处理
if history.title:
# 如果配置了剧名前缀
if self._media_prefix:
# 清理无前缀的剧名标签
if history.title in torrent_tags:
tags_to_remove.append(history.title)
# 清理带旧前缀的剧名标签(除了当前前缀)
for tag in torrent_tags:
if tag.endswith(history.title) and tag != f"{self._media_prefix}{history.title}":
tags_to_remove.append(tag)
# 如果没有配置剧名前缀
else:
# 清理所有带前缀的剧名标签
for tag in torrent_tags:
if tag.endswith(history.title) and tag != history.title:
tags_to_remove.append(tag)
# 去除种子已经存在的标签
if _tags and torrent_tags:
_tags = list(set(_tags) - set(torrent_tags))
# 如果分类一样, 那么不需要修改
if _cat == torrent_cat:
_cat = None
# 判断当前种子是否不需要修改
if not _cat and not _tags:
if not _cat and not _tags and not tags_to_remove:
continue
# 执行通用方法, 设置种子标签与分类
self._set_torrent_info(service=service, _hash=_hash, _torrent=torrent, _tags=_tags, _cat=_cat,
_original_tags=torrent_tags)
_original_tags=torrent_tags, _tags_to_remove=tags_to_remove)
except Exception as e:
logger.error(
f"{self.LOG_TAG}分析种子信息时发生了错误: {str(e)}")
# 执行清理未使用标签
if self._enabled_del_tags:
self._del_unused_tags(service=service)
logger.info(f"{self.LOG_TAG}执行完成")
def _generate_site_tag(self, site_name):
"""
生成带前缀的站点标签
"""
if not site_name:
return ""
if self._site_prefix:
return f"{self._site_prefix}{site_name}"
else:
return site_name
def _generate_media_tag(self, media_title):
"""
生成带前缀的剧名标签
"""
if not media_title:
return ""
if self._media_prefix:
return f"{self._media_prefix}{media_title}"
else:
return media_title
def _genre_ids_get_cat(self, mtype, genre_ids=None):
"""
根据genre_ids判断是否<动漫>分类
@@ -335,6 +467,47 @@ class DownloadSiteTag(_PluginBase):
_cat = self._category_tv
return _cat
@staticmethod
def _parse_tracker_mappings(mapping_str: str) -> dict:
"""
解析tracker映射规则字符串为字典
格式tracker域名 -> 映射域名
例如chdbits.xyz -> ptchdbits.co
使用"->"作为分隔符
"""
tracker_mappings = {}
if not mapping_str:
return tracker_mappings
lines = mapping_str.strip().split('\n')
for line in lines:
line = line.strip()
if not line or line.startswith('#'):
continue # 跳过空行和注释行
# 支持多种分隔符
separators = ['->', '', ':', '']
separator = None
for sep in separators:
if sep in line:
separator = sep
break
if separator:
parts = line.split(separator, 1)
if len(parts) == 2:
key = parts[0].strip()
value = parts[1].strip()
if key and value:
tracker_mappings[key] = value
else:
# 如果没有找到分隔符,尝试按空格分割
parts = line.split()
if len(parts) >= 2:
tracker_mappings[parts[0].strip()] = parts[1].strip()
return tracker_mappings
@staticmethod
def _torrent_key(torrent: Any, dl_type: str) -> Optional[Tuple[int, str]]:
"""
@@ -423,8 +596,12 @@ class DownloadSiteTag(_PluginBase):
获取种子标签
"""
try:
return [str(tag).strip() for tag in torrent.get("tags", "").split(',')] \
if dl_type == "qbittorrent" else torrent.labels or []
if dl_type == "qbittorrent":
tags_str = torrent.get("tags", "")
# 处理空字符串情况,并过滤掉空白标签
return [tag.strip() for tag in tags_str.split(',') if tag.strip()] if tags_str else []
else:
return torrent.labels or []
except Exception as e:
print(str(e))
return []
@@ -441,7 +618,7 @@ class DownloadSiteTag(_PluginBase):
return None
def _set_torrent_info(self, service: ServiceInfo, _hash: str, _torrent: Any = None, _tags=None, _cat: str = None,
_original_tags: list = None):
_original_tags: list = None, _tags_to_remove: list = []):
"""
设置种子标签与分类
"""
@@ -463,7 +640,10 @@ class DownloadSiteTag(_PluginBase):
if _hash and _torrent:
# 下载器api不通用, 因此需分开处理
if service.type == "qbittorrent":
# 设置标签
# 先移除需要删除的标签
if _tags_to_remove:
downloader_obj.remove_torrents_tag(ids=_hash, tag=_tags_to_remove)
# 再添加新标签
if _tags:
downloader_obj.set_torrents_tag(ids=_hash, tags=_tags)
# 设置分类 <tr暂不支持>
@@ -478,16 +658,102 @@ class DownloadSiteTag(_PluginBase):
_torrent.setCategory(category=_cat)
else:
# 设置标签
if _tags:
if _tags or _tags_to_remove:
# _original_tags = None表示未指定, 因此需要获取原始标签
if _original_tags is None:
_original_tags = self._get_label(torrent=_torrent, dl_type=service.type)
# 如果原始标签不是空的, 那么合并原始标签
if _original_tags:
_tags = list(set(_original_tags).union(set(_tags)))
# 移除需要删除的标签
if _tags_to_remove:
_tags = list(set(_tags) - set(_tags_to_remove))
downloader_obj.set_torrent_tag(ids=_hash, tags=_tags)
logger.warn(
f"{self.LOG_TAG}下载器: {service.name} 种子id: {_hash} {(' 标签: ' + ','.join(_tags)) if _tags else ''} {(' 分类: ' + _cat) if _cat else ''}")
def _task_del_unused_tags(self):
"""
公共服务:删除所有未被任何种子使用的标签,遍历全部下载器
"""
if not self.service_infos:
return
for service in self.service_infos.values():
# 仅qb支持删除未使用标签
if service.type != "qbittorrent":
continue
downloader = service.name
downloader_obj = service.instance
if not downloader_obj:
logger.error(f"{self.LOG_TAG} 删除未使用标签公共服务,获取下载器失败 {downloader}")
continue
try:
# 初始化下载器 获取全量数据
if downloader not in self._del_tags_task_rid:
data = downloader_obj.qbc.sync_maindata(rid=0)
logger.info(f"{self.LOG_TAG}初始化删除未使用标签任务 RID for {downloader} full_update: {data.get('full_update', False)}")
self._del_tags_task_rid[downloader] = data.get("rid", 0)
else:
# 提取上次返回的 rid
last_rid = self._del_tags_task_rid[downloader]
data = downloader_obj.qbc.sync_maindata(rid=last_rid)
# 更新 rid 用于下次访问
self._del_tags_task_rid[downloader] = data.get("rid", last_rid)
# 可能服务器重启,或其他原因导致 rid 状态已被重置
if data.get("full_update", False):
logger.info(f"{self.LOG_TAG}重置删除未使用标签任务 RID for {downloader} full_update: {data.get('full_update', False)}")
continue
if data.get('torrents_removed', []):
logger.info(f"{self.LOG_TAG}删除未使用标签任务 RID for {downloader} 发现删除种子,即将执行清理未使用标签操作!")
# 指定下载器服务,执行删除未使用标签
self._del_unused_tags(service=service)
except Exception as e:
logger.error(
f"{self.LOG_TAG}删除未使用标签公共服务,下载器:{downloader} 发生了错误: {str(e)}")
def _del_unused_tags(self, service: ServiceInfo, torrents: Any = None):
"""
删除所有未被任何种子使用的标签, 可指定下载器与种子列表
"""
# 只有qb下载器才需要删除未使用的标签TR下载器未使用标签会自动移除
if not service or not service.instance or service.type != "qbittorrent":
return
downloader_obj = service.instance
try:
# 获取所有现有的标签 调用内部qbc的API
all_tags = downloader_obj.qbc.torrents_tags()
if not all_tags:
logger.info(
f"{self.LOG_TAG}下载器: {service.name} 当前没有任何标签,跳过删除未使用标签操作")
return
# 获取下载器中的种子
if not torrents:
torrents, error = downloader_obj.get_torrents()
# 如果下载器获取种子发生错误 或 没有种子 则跳过
if error or not torrents:
logger.warn(
f"{self.LOG_TAG}删除所有未被任何种子使用的标签时发生了错误或查询不到任何种子!")
return
logger.info(
f"{self.LOG_TAG}删除所有未被任何种子使用的标签: {service.name} 查询到 {len(torrents)} 个种子")
# 收集所有正在被使用的标签
used_tags_set = set()
for torrent in torrents:
tag = self._get_label(torrent=torrent, dl_type=service.type)
if tag: # 确保种子有标签
used_tags_set.update(tag)
# 计算未使用的标签(在全部标签中但不在使用集合中)
unused_tags = [tag for tag in all_tags if tag not in used_tags_set]
# 删除未使用的标签
if unused_tags:
downloader_obj.delete_torrents_tag(ids=None, tag=unused_tags)
logger.info(
f"{self.LOG_TAG}删除所有未被任何种子使用的标签: {",".join(unused_tags)}")
except Exception as e:
logger.error(
f"{self.LOG_TAG}删除所有未被任何种子使用的标签时发生了错误: {str(e)}")
@eventmanager.register(EventType.DownloadAdded)
def download_added(self, event: Event):
@@ -510,7 +776,7 @@ class DownloadSiteTag(_PluginBase):
if not service:
logger.info(f"触发添加下载事件,但没有监听下载器 {downloader},跳过后续处理")
return
context: Context = event.event_data.get("context")
_hash = event.event_data.get("hash")
_torrent = context.torrent_info
@@ -519,10 +785,12 @@ class DownloadSiteTag(_PluginBase):
_cat = None
# 站点标签, 如果勾选开关的话
if self._enabled_tag and _torrent.site_name:
_tags.append(_torrent.site_name)
site_tag = self._generate_site_tag(_torrent.site_name)
_tags.append(site_tag)
# 媒体标题标签, 如果勾选开关的话
if self._enabled_media_tag and _media.title:
_tags.append(_media.title)
media_tag = self._generate_media_tag(_media.title)
_tags.append(media_tag)
# 分类, 如果勾选开关的话 <tr暂不支持>
if self._enabled_category and _media.type:
_cat = self._genre_ids_get_cat(_media.type, _media.genre_ids)
@@ -531,12 +799,13 @@ class DownloadSiteTag(_PluginBase):
self._set_torrent_info(service=service, _hash=_hash, _tags=_tags, _cat=_cat)
except Exception as e:
logger.error(
f"{self.LOG_TAG}分析下载事件时发生了错误: {str(e)}")
f"{self.LOG_TAG}分析添加下载事件时发生了错误: {str(e)}")
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
return [
{
'component': 'VForm',
@@ -616,7 +885,8 @@ class DownloadSiteTag(_PluginBase):
{
'component': 'VCol',
'props': {
'cols': 12
'cols': 12,
'md': 6
},
'content': [
{
@@ -627,6 +897,61 @@ class DownloadSiteTag(_PluginBase):
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VCheckboxBtn',
'props': {
'model': 'enabled_del_tags',
'label': '自动删除未使用标签',
}
}
]
},
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'site_prefix',
'label': '站点标签前缀',
'placeholder': '留空表示不使用前缀,自动识别历史标签并更新',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'media_prefix',
'label': '剧名标签前缀',
'placeholder': '留空表示不使用前缀,自动识别历史标签并更新',
}
}
]
}
]
},
@@ -735,6 +1060,27 @@ class DownloadSiteTag(_PluginBase):
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '定时任务:支持两种定时方式,主要针对辅种刷流等种子补全站点信息。如没有对应的需求建议切换为禁用。'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
@@ -749,7 +1095,8 @@ class DownloadSiteTag(_PluginBase):
'props': {
'model': 'category_movie',
'label': '电影分类名称(默认: 电影)',
'placeholder': '电影'
'placeholder': '电影',
'hint': '请填写下载器里已创建的电影分类名称'
}
}
]
@@ -770,7 +1117,8 @@ class DownloadSiteTag(_PluginBase):
'props': {
'model': 'category_tv',
'label': '电视分类名称(默认: 电视)',
'placeholder': '电视'
'placeholder': '电视',
'hint': '请填写下载器里已创建的电视分类名称'
}
}
]
@@ -791,7 +1139,8 @@ class DownloadSiteTag(_PluginBase):
'props': {
'model': 'category_anime',
'label': '动漫分类名称(默认: 动漫)',
'placeholder': '动漫'
'placeholder': '动漫',
'hint': '请填写下载器里已创建的动漫分类名称'
}
}
]
@@ -804,7 +1153,7 @@ class DownloadSiteTag(_PluginBase):
{
'component': 'VCol',
'props': {
'cols': 12,
'cols': 12
},
'content': [
{
@@ -812,7 +1161,30 @@ class DownloadSiteTag(_PluginBase):
'props': {
'type': 'info',
'variant': 'tonal',
'text': '定时任务:支持两种定时方式,主要针对辅种刷流等种子补全站点信息。如没有对应的需求建议切换为禁用'
'text': '以下为tracker映射规则您可以根据需要修改或添加新的规则'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'tracker_mappings_str',
'label': 'Tracker域名映射规则',
'rows': 8,
'placeholder': '每行一个映射格式tracker域名 -> 映射域名\n例如chdbits.xyz -> ptchdbits.co',
'hint': '支持的分隔符:->, →, :, :,空格'
}
}
]
@@ -827,13 +1199,17 @@ class DownloadSiteTag(_PluginBase):
"enabled_tag": True,
"enabled_media_tag": False,
"enabled_category": False,
"enabled_del_tags": False,
"category_movie": "电影",
"category_tv": "电视",
"category_anime": "动漫",
"interval": "计划任务",
"interval_cron": "5 4 * * *",
"interval_time": "6",
"interval_unit": "小时"
"interval_unit": "小时",
"tracker_mappings_str": self._tracker_mappings_default, # 添加默认的映射规则字符串
"site_prefix": "",
"media_prefix": ""
}
def get_page(self) -> List[dict]:
@@ -852,4 +1228,4 @@ class DownloadSiteTag(_PluginBase):
self._event.clear()
self._scheduler = None
except Exception as e:
print(str(e))
print(str(e))

View File

@@ -2,6 +2,7 @@ import re
import urllib.parse
from datetime import datetime
from typing import Any, Callable, Coroutine, Dict, Optional, List, Tuple
from urllib.parse import quote
import zhconv
from apscheduler.triggers.cron import CronTrigger
@@ -20,7 +21,8 @@ from app.plugins.imdbsource.officialapi import INTERESTS_ID
from app.plugins.imdbsource.schema import StaffPickEntry, ImdbTitle, StaffPickApiResponse, ImdbMediaInfo, SearchParams
from app.log import logger
from app.schemas import DiscoverSourceEventData, MediaRecognizeConvertEventData, RecommendSourceEventData
from app.schemas.types import ChainEventType, MediaType
from app.schemas.types import ChainEventType, MediaType, EventType
from app.scheduler import Scheduler
from app.utils.http import AsyncRequestUtils, RequestUtils
@@ -32,7 +34,7 @@ class ImdbSource(_PluginBase):
# 插件图标
plugin_icon = "IMDb_IOS-OSX_App.png"
# 插件版本
plugin_version = "1.6.3"
plugin_version = "1.6.7"
# 插件作者
plugin_author = "wumode"
# 作者主页
@@ -57,7 +59,7 @@ class ImdbSource(_PluginBase):
# 私有属性
_imdb_helper: ImdbHelper = None
_img_proxy_prefix: str = ''
_img_proxy_prefix: str = '/api/v1/system/cache/image?url='
_original_method: Optional[Callable] = None
_original_async_method: Optional[Callable[..., Coroutine[Any, Any, Optional[MediaInfo]]]] = None
_staff_picks_cache: Optional[StaffPickApiResponse] = None
@@ -134,7 +136,6 @@ class ImdbSource(_PluginBase):
if "media-imdb.com" not in settings.SECURITY_IMAGE_DOMAINS:
settings.SECURITY_IMAGE_DOMAINS.append("media-imdb.com")
if self._enabled:
if self._recognize_media and self._recognition_mode == 'auxiliary':
# 替换 ChainBase.recognize_media
if not (getattr(ChainBase.recognize_media, "_patched_by", object()) == id(self)):
@@ -203,15 +204,11 @@ class ImdbSource(_PluginBase):
if not self._staff_picks:
return None
def year_and_type(imdb_entry: StaffPickEntry, imdb_titles: List[ImdbTitle]) -> Tuple[MediaType, str | None, str | None]:
title = next((t for t in imdb_titles if t.id == imdb_entry.ttconst), None)
if not title:
return MediaType.MOVIE, datetime.now().date().strftime("%Y"), ''
def year_and_type(title: ImdbTitle) -> Tuple[MediaType, str | None]:
media_id = title.title_type.id
release_year = f"{title.release_year.year}" if title.release_year else datetime.now().date().strftime("%Y")
media_type = ImdbHelper.type_to_mtype(media_id.value)
media_plot = title.plot.plot_text.plain_text if title.plot and title.plot.plot_text else ''
return media_type, release_year, media_plot
return media_type, release_year
# 列配置
size_config = {
@@ -226,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
@@ -264,12 +261,15 @@ class ImdbSource(_PluginBase):
titles = imdb_items.titles
contents = []
for entry in entries:
imdb_title = next((t for t in titles if t.id == entry.ttconst), None)
if not imdb_title:
continue
cast = [name for related in entry.relatedconst for name in names if name.id == related]
mtype, year, plot = year_and_type(entry, titles)
mtype, year = year_and_type(imdb_title)
mp_url = f"/media?mediaid=imdb:{entry.ttconst}&title={entry.name}&year={year}&type={mtype.value}"
primary_img_url = next((f"{image.url}" for image in images
if image.id == entry.rmconst), '')
primary_img_url = f'{self._img_proxy_prefix}{primary_img_url}'
primary_img_url = f'{self._img_proxy_prefix}{quote(primary_img_url)}'
item1 = {
'component': 'VCarouselItem',
'props': {
@@ -285,27 +285,19 @@ class ImdbSource(_PluginBase):
},
'content': [
{
'component': 'RouterLink',
'component': 'h1',
'props': {
'to': mp_url,
'class': 'no-underline'
'class': 'mb-1 text-white text-shadow font-extrabold text-2xl line-clamp-2 overflow-hidden text-ellipsis ...'
},
'content': [{
'component': 'h1',
'props': {
'class': 'mb-1 text-white text-shadow font-extrabold text-2xl line-clamp-2 overflow-hidden text-ellipsis ...'
},
'html': f"{entry.name} <span class='text-base font-normal'>{year_and_type(entry, titles)[1]}</span>",
},
{
'component': 'span',
'props': {
'class': 'text-shadow line-clamp-2 overflow-hidden text-ellipsis ...'
},
'html': plot,
}
]
'html': f"{entry.name} <span class='text-base font-normal'>{year}</span>",
},
{
'component': 'span',
'props': {
'class': 'text-shadow line-clamp-2 overflow-hidden text-ellipsis ...'
},
'html': imdb_title.plot_text,
}
]
}
]
@@ -328,21 +320,22 @@ 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': [
{
'component': 'VAvatar',
'props': {
'size': f'{48 if is_mobile else 64}',
'class': 'mb-1'
'size': f'{54 if is_mobile else 64}',
'class': 'mb-1 hover-card',
},
'content': [
{
'component': 'VImg',
'props': {
'src': f"{self._img_proxy_prefix}"
f"{cs.primary_image.url if cs.primary_image else ''}",
f"{quote(cs.primary_image.url
if cs.primary_image else '')}",
'alt': cs.name_text.text,
'cover': True
}
@@ -351,7 +344,6 @@ class ImdbSource(_PluginBase):
},
]
},
{
'component': 'span',
'props': {
@@ -367,14 +359,15 @@ 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}{poster_url}"
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': {
'src': poster_url,
'alt': '海报',
'cover': True,
'class': 'rounded',
'class': 'rounded hover-poster',
'max-width': '160',
'max-height': '240',
'style': 'height: auto; aspect-ratio: 2/3;',
@@ -382,22 +375,92 @@ class ImdbSource(_PluginBase):
}
poster_ui = {
'component': 'div',
'component': 'VRow',
'props': {
'class': 'd-flex justify-center mt-2'
'align': 'center'
},
'content': [
{
'component': 'a',
'props': {
'href': f'#{mp_url}',
'href': f"https://www.imdb.com/title/{entry.ttconst}",
'target': '_blank',
'class': 'no-underline w-100',
'style': 'display: flex; justify-content: center;'
},
'content': [
poster_com
poster_com,
]
},
]
}
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': 'd-flex align-center mb-1',
},
'content': [
{
'component': 'div',
'props': {
'class': 'd-flex align-center',
},
'content': [
{
'component': 'VIcon',
'props': {
'color': 'warning',
'size': 16
},
'text': 'mdi-star'
},
{
'component': 'span',
'props': {
'class': 'text-truncate text-body-2 ml-1',
'style': 'color: rgba(231, 227, 252, 0.8);'
},
'text': f"{imdb_title.rating_text}",
},
]
},
{
'component': 'span',
'props': {
'class': 'text-truncate text-warning font-weight-bold ml-4',
'color': 'warning'
},
'text': entry.detail,
},
]
}
@@ -411,15 +474,17 @@ class ImdbSource(_PluginBase):
{
'component': 'a',
'props': {
'href': f"https://www.imdb.com/title/{entry.ttconst}",
'target': '_blank',
'href': f'#{mp_url}',
'rel': 'noopener noreferrer',
'class': 'text-h4 font-weight-bold mb-2 d-flex text-white align-center',
},
'content': [
{
'component': 'span',
'html': f"{entry.name}"
'html': f"{entry.name}",
'props': {
'class': 'text-truncate overflow-hidden',
}
},
{
'component': 'v-icon',
@@ -432,12 +497,13 @@ class ImdbSource(_PluginBase):
]
},
{
'component': 'div',
'props': {
'class': 'text-yellow font-weight-bold mb-2',
"component": 'div',
"props": {
"class": "d-flex align-center gap-1 mb-2",
},
'html': entry.detail
"content": meta_chips
},
rating_ui,
{
'component': 'span',
'props': {
@@ -468,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': [
# 左图:海报
@@ -483,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
@@ -510,7 +579,33 @@ class ImdbSource(_PluginBase):
contents.append(item1)
contents.append(item2)
style = {
'component': 'style',
'text': """
.hover-card {
border: 2px solid transparent;
transition: border-color 0.3s ease-in-out;
box-sizing: border-box;
}
.hover-card:hover {
border-color: #ff8400;
cursor: pointer;
}
.hover-poster {
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
transition: all 0.3s ease;
backface-visibility: hidden;
}
.hover-poster:hover {
transform: translateY(-6px);
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.4),
0 10px 10px -5px rgba(0,0,0,0.2);
cursor: pointer;
}
"""
}
elements = [
style,
{
'component': 'VCard',
'props': {
@@ -1000,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:
@@ -1029,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':
@@ -1085,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] = []
@@ -1282,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",
@@ -1337,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 = [
@@ -1357,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",
@@ -1539,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": {
@@ -1693,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"]
@@ -1988,3 +2116,13 @@ class ImdbSource(_PluginBase):
if not data:
return None
return ImdbSource._match_results(data, media_info)
@eventmanager.register(EventType.PluginReload)
def reload(self, event):
"""
响应插件重载事件
"""
plugin_id = event.event_data.get("plugin_id")
if plugin_id == self.__class__.__name__:
Scheduler().update_plugin_job(plugin_id)

View File

@@ -8,9 +8,10 @@ from app.log import logger
from app.utils.common import retry
from app.utils.http import RequestUtils, AsyncRequestUtils
from .schema.imdbapi import ImdbApiTitle, ImdbApiEpisode, ImdbApiCredit
from .schema.imdbapi import ImdbApiSearchTitlesResponse, ImdbApiListTitlesResponse, ImdbApiListTitleEpisodesResponse, \
ImdbApiListTitleSeasonsResponse, ImdbApiListTitleCreditsResponse, ImdbapiListTitleAKAsResponse
from .schema.imdbapi import ImdbApiTitle, ImdbApiEpisode, ImdbApiCredit, ImdbapiImage
from .schema.imdbapi import (ImdbApiSearchTitlesResponse, ImdbApiListTitlesResponse, ImdbApiListTitleEpisodesResponse,
ImdbApiListTitleSeasonsResponse, ImdbApiListTitleCreditsResponse,
ImdbapiListTitleAKAsResponse, ImdbApiTitleImagesResponse, ImdbapiCompanyCreditResponse)
from .schema.imdbtypes import ImdbType
@@ -678,3 +679,114 @@ class ImdbApiClient:
logger.debug(f"An error occurred while retrieving alternative titles: {e}")
return None
return ret
def images(self, title_id: str, types: list[str] | None = None, page_size: int | None = None,
page_token: str | None = None) -> ImdbApiTitleImagesResponse | None:
"""
Retrieve the images associated with a specific title.
:param title_id: Required. IMDb title ID in the format "tt1234567".
:param types: Optional. The types of images to filter by.
- 'poster'
- 'behind_the_scenes'
- 'still_frame'
:param page_size: Optional. The maximum number of images to return per page.
The value must be between 1 and 50. The default is 20.
:param page_token: Optional. Token for pagination, if applicable.
"""
path = '/titles/%s/images'
param: Dict[str, Any] = {}
if types:
param['types'] = types
if page_size is not None:
param['pageSize'] = page_size
if page_token is not None:
param['pageToken'] = page_token
try:
r = self._free_imdb_api(path=path % title_id, params=param)
if r is None:
return None
ret = ImdbApiTitleImagesResponse.model_validate(r)
except Exception as e:
logger.debug(f"An error occurred while retrieving images: {e}")
return None
return ret
async def async_images(self, title_id: str, types: list[str] | None = None, page_size: int = 20,
page_token: str | None = None) -> ImdbApiTitleImagesResponse | None:
path = '/titles/%s/images'
param: Dict[str, Any] = {}
if types:
param['types'] = types
if page_size is not None:
param['pageSize'] = page_size
if page_token is not None:
param['pageToken'] = page_token
try:
r = await self._async_free_imdb_api(path=path % title_id, params=param)
if r is None:
return None
ret = ImdbApiTitleImagesResponse.model_validate(r)
except Exception as e:
logger.debug(f"An error occurred while retrieving images: {e}")
return None
return ret
def images_generator(self, title_id: str, types: list[str] | None = None
) -> Generator[ImdbapiImage, None, None]:
page_token = None
while True:
response = self.images(
title_id=title_id,
types=types,
page_size=50,
page_token=page_token
)
if not response:
return
for image in response.images:
yield image
page_token = response.next_page_token
if not page_token:
break
async def async_images_generator(self, title_id: str, types: list[str] | None = None
) -> AsyncGenerator[ImdbapiImage, None]:
page_token = None
while True:
response = await self.async_images(
title_id=title_id,
types=types,
page_size=50,
page_token=page_token
)
if not response:
return
for image in response.images:
yield image
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:
@@ -233,6 +234,8 @@ class ImdbHelper:
break
except PersistedQueryNotFound:
await self.async_fetch_hash.cache_clear()
except RuntimeError:
pass
return edges
def _tv_release_data_by_season(self, title_id: str) -> Optional[Dict[str, ImdbapiPrecisionDate]]:
@@ -251,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:
"""
根据名称同时查询电影和电视剧,没有类型也没有年份时使用
@@ -513,7 +516,8 @@ class ImdbHelper:
akas = resp.akas if resp else []
credit_list = [credit for credit in self.imdbapi_client.credits_generator(title_id)]
episodes = [episode for episode in self.imdbapi_client.episodes_generator(title_id)]
return ImdbMediaInfo.from_title(details, akas=akas, api_credits=credit_list, episodes=episodes)
images = [image for image in self.imdbapi_client.images_generator(title_id)]
return ImdbMediaInfo.from_title(details, akas=akas, api_credits=credit_list, episodes=episodes, images=images)
async def async_update_info(self, title_id: str, info: ImdbMediaInfo) -> ImdbMediaInfo:
details = await self.imdbapi_client.async_title(title_id) or info
@@ -523,7 +527,8 @@ class ImdbHelper:
akas = resp.akas if resp else []
credit_list = [credit async for credit in self.imdbapi_client.async_credits_generator(title_id)]
episodes = [episode async for episode in self.imdbapi_client.async_episodes_generator(title_id)]
return ImdbMediaInfo.from_title(details, akas=akas, api_credits=credit_list, episodes=episodes)
images = [image async for image in self.imdbapi_client.async_images_generator(title_id)]
return ImdbMediaInfo.from_title(details, akas=akas, api_credits=credit_list, episodes=episodes, images=images)
@staticmethod
def convert_mediainfo(info: ImdbMediaInfo) -> MediaInfo:
@@ -545,6 +550,8 @@ class ImdbHelper:
mediainfo.origin_country = [origin_country.code for origin_country in info.origin_countries]
if info.primary_image and info.primary_image.url:
mediainfo.poster_path = info.primary_image.url
if info.images:
mediainfo.backdrop_path = info.backdrop_path() # noqa
mediainfo.genres = [{"id": genre, "name": genre} for genre in info.genres or []]
directors = []
actors = []
@@ -589,10 +596,7 @@ class ImdbHelper:
mediainfo.year = f"{info.release_year.year}"
mediainfo.title_year = f"{mediainfo.title} ({mediainfo.year})" if mediainfo.year else mediainfo.title
if info.primary_image:
primary_image = info.primary_image.url if info.primary_image else None
if primary_image:
poster_path = primary_image.replace('@._V1', '@._V1_QL75_UY414_CR6,0,280,414_')
mediainfo.poster_path = poster_path
mediainfo.poster_path = info.primary_image.poster_path()
if info.ratings_summary:
mediainfo.vote_average = info.ratings_summary.aggregate_rating
if info.runtime:

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

@@ -3,7 +3,7 @@ from typing import Optional, List, Tuple, Union
from pydantic import BaseModel, Field, ConfigDict
from .imdbapi import ImdbApiTitle, ImdbApiEpisode, ImdbApiCredit
from .imdbapi import ImdbApiTitle, ImdbApiEpisode, ImdbApiCredit, ImdbapiImage
from .imdbtypes import ImdbTitle, ImdbName, ImdbImage, ImdbVideo, AkasNode, TitleEdge
@@ -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
@@ -38,6 +38,7 @@ class ImdbMediaInfo(ImdbApiTitle):
akas: List[AkasNode] = Field(default_factory=list)
episodes: List[ImdbApiEpisode] = Field(default_factory=list)
credits: List[ImdbApiCredit] = Field(default_factory=list)
images: List[ImdbapiImage] = Field(default_factory=list)
@classmethod
def from_title(
@@ -46,14 +47,28 @@ class ImdbMediaInfo(ImdbApiTitle):
akas: Optional[List[AkasNode]] = None,
episodes: Optional[List[ImdbApiEpisode]] = None,
api_credits: Optional[List[ImdbApiCredit]] = None,
images: Optional[List[ImdbapiImage]] = None
) -> "ImdbMediaInfo":
return cls(
fields = {
**title.model_dump(exclude_none=True, by_alias=True),
akas=akas if akas is not None else [],
episodes=episodes if episodes is not None else [],
credits=api_credits if api_credits is not None else []
)
}
if akas is not None:
fields['akas'] = akas
if episodes is not None:
fields['episodes'] = episodes
if api_credits is not None:
fields['credits'] = api_credits
if images is not None:
fields['images'] = images
return cls(**fields)
def backdrop_path(self) -> str | None:
if self.images:
for image in self.images:
if image.url and image.type == 'still_frame':
# replace('@._V1', '@._V1_QL75_UX327_')
return image.url
return None
class ImdbApiHash(BaseModel):
advanced_title_search: str = Field(alias="AdvancedTitleSearch")
@@ -102,10 +117,10 @@ 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,
validate_assignment=False
frozen=True
)

View File

@@ -114,12 +114,15 @@ class ImdbApiEpisode(BaseModel):
release_date: Optional[ImdbapiPrecisionDate] = Field(None, alias='releaseDate')
class ImdbApiListTitleEpisodesResponse(BaseModel):
episodes: List[ImdbApiEpisode] = Field(default_factory=list)
class PagedResponse(BaseModel):
total_count: int = Field(alias='totalCount')
next_page_token: Optional[str] = Field(None, alias='nextPageToken')
class ImdbApiListTitleEpisodesResponse(PagedResponse):
episodes: List[ImdbApiEpisode] = Field(default_factory=list)
class ImdbApiSeason(BaseModel):
season: Optional[str] = None
episode_count: Optional[int] = Field(None, alias='episodeCount')
@@ -137,10 +140,8 @@ class ImdbApiCredit(BaseModel):
episode_count: Optional[int] = Field(None, alias='episodeCount')
class ImdbApiListTitleCreditsResponse(BaseModel):
class ImdbApiListTitleCreditsResponse(PagedResponse):
credits: List[ImdbApiCredit] = Field(default_factory=list)
total_count: int = Field(alias='totalCount')
next_page_token: Optional[str] = Field(None, alias='nextPageToken')
class ImdbapiAka(AkasNode):
@@ -149,3 +150,24 @@ class ImdbapiAka(AkasNode):
class ImdbapiListTitleAKAsResponse(BaseModel):
akas: List[ImdbapiAka]
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

@@ -1,166 +1,241 @@
from enum import Enum
from typing import Optional, List
from pydantic import BaseModel, Field
class ImdbType(Enum):
TV_SERIES = "tvSeries"
TV_MINI_SERIES = "tvMiniSeries"
MOVIE = "movie"
TV_MOVIE = "tvMovie"
MUSIC_VIDEO = "musicVideo"
TV_SHORT = "tvShort"
SHORT = "short"
TV_EPISODE = "tvEpisode"
TV_SPECIAL = "tvSpecial"
VIDEO_GAME = "videoGame"
VIDEO = "video"
PODCAST_SERIES = "podcastSeries"
PODCAST_EPISODE = "podcastEpisode"
class TitleType(BaseModel):
id: ImdbType
class ReleaseYear(BaseModel):
year: Optional[int] = None
class Country(BaseModel):
id: str
text: str
class TextField(BaseModel):
text: Optional[str] = ''
class ValueField(BaseModel):
value: Optional[str] = None
class SecondsField(BaseModel):
seconds: Optional[int] = None
class AkasNode(BaseModel):
text: Optional[str] = ''
country: Optional[Country] = None
language: Optional[TextField] = None
class AkasEdge(BaseModel):
node: AkasNode
class Akas(BaseModel):
edges: List[AkasEdge] = Field(default_factory=list)
class PlotText(BaseModel):
plain_text: Optional[str] = Field(default='', alias='plainText')
class Plot(BaseModel):
plot_text: Optional[PlotText] = Field(None, alias='plotText')
class ImdbImage(BaseModel):
id: str
url: str
width: Optional[int] = None
height: Optional[int] = None
class RankChange(BaseModel):
change_direction: Optional[str] = Field(default=None, alias='changeDirection')
difference: Optional[int] = None
class MeterRanking(BaseModel):
current_rank: Optional[int] = Field(default=None, alias='currentRank')
meter_type: Optional[str] = Field(default=None, alias='meterType')
rank_change: Optional[RankChange] = Field(default=None, alias='rankChange')
class RatingsSummary(BaseModel):
aggregate_rating: Optional[float] = Field(default=None, alias='aggregateRating')
vote_count: Optional[int] = Field(None, alias='voteCount')
class ImdbName(BaseModel):
id: str
name_text: TextField = Field(alias='nameText')
primary_image: Optional[ImdbImage] = Field(default=None, alias='primaryImage')
class ContentType(BaseModel):
display_name: ValueField = Field(alias='displayName')
id: str
class VideoUrl(BaseModel):
display_name: ValueField = Field(alias='displayName')
url: str
video_definition: str = Field(alias='videoDefinition')
video_mime_type: str = Field(alias='videoMimeType')
class ImdbDate(BaseModel):
year: Optional[int] = None
month: Optional[int] = None
day: Optional[int] = None
class Genre(BaseModel):
genre: Optional[TextField] = None
class TitleGenre(BaseModel):
genres: List[Genre] = Field(default_factory=list)
class Certificate(BaseModel):
rating: Optional[str] = None
class ImdbTitle(BaseModel):
id: str
title_text: TextField = Field(alias='titleText')
title_type: TitleType = Field(alias='titleType')
release_year: Optional[ReleaseYear] = Field(None, alias='releaseYear')
akas: Optional[Akas] = None
plot: Optional[Plot] = None
primary_image: Optional[ImdbImage] = Field(default=None, alias='primaryImage')
meter_ranking: Optional[MeterRanking] = Field(default=None, alias='meterRanking')
ratings_summary: Optional[RatingsSummary] = Field(default=None, alias='ratingsSummary')
release_date: Optional[ImdbDate] = Field(None, alias='releaseDate')
title_genres: Optional[TitleGenre] = Field(default=None, alias='titleGenres')
certificate: Optional[Certificate] = None
original_title_text: Optional[TextField] = Field(default=None, alias='originalTitleText')
runtime: Optional[SecondsField] = Field(default=None, alias='runtime')
class Thumbnail(BaseModel):
url: str
width: Optional[int] = None
height: Optional[int] = None
class ImdbVideo(BaseModel):
id: str
name: ValueField
content_type: ContentType = Field(alias='contentType')
preview_urls: List[VideoUrl] = Field(default_factory=list, alias='previewURLs')
playback_urls: List[VideoUrl] = Field(default_factory=list, alias='playbackURLs')
thumbnails: Optional[Thumbnail] = None
class TitleNode(BaseModel):
title: ImdbTitle
class TitleEdge(BaseModel):
node: TitleNode
from enum import Enum
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"
MOVIE = "movie"
TV_MOVIE = "tvMovie"
MUSIC_VIDEO = "musicVideo"
TV_SHORT = "tvShort"
SHORT = "short"
TV_EPISODE = "tvEpisode"
TV_SPECIAL = "tvSpecial"
VIDEO_GAME = "videoGame"
VIDEO = "video"
PODCAST_SERIES = "podcastSeries"
PODCAST_EPISODE = "podcastEpisode"
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
class Country(BaseModel):
id: str
text: str
class TextField(BaseModel):
text: Optional[str] = ''
class ValueField(BaseModel):
value: Optional[str] = None
class SecondsField(BaseModel):
seconds: Optional[int] = None
class AkasNode(BaseModel):
text: Optional[str] = ''
country: Optional[Country] = None
language: Optional[TextField] = None
class AkasEdge(BaseModel):
node: AkasNode
class Akas(BaseModel):
edges: List[AkasEdge] = Field(default_factory=list)
class PlotText(BaseModel):
plain_text: Optional[str] = Field(default='', alias='plainText')
class Plot(BaseModel):
plot_text: Optional[PlotText] = Field(None, alias='plotText')
class ImdbImage(BaseModel):
id: str
url: Optional[str] = None
width: Optional[int] = None
height: Optional[int] = None
def poster_path(self):
if self.url:
return self.url.replace('@._V1', '@._V1_QL75_UY414_CR6,0,280,414_')
return None
class RankChange(BaseModel):
change_direction: Optional[str] = Field(default=None, alias='changeDirection')
difference: Optional[int] = None
class MeterRanking(BaseModel):
current_rank: Optional[int] = Field(default=None, alias='currentRank')
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')
vote_count: Optional[int] = Field(None, alias='voteCount')
class ImdbName(BaseModel):
id: str
name_text: TextField = Field(alias='nameText')
primary_image: Optional[ImdbImage] = Field(default=None, alias='primaryImage')
class ContentType(BaseModel):
display_name: ValueField = Field(alias='displayName')
id: str
class VideoUrl(BaseModel):
display_name: ValueField = Field(alias='displayName')
url: str
video_definition: str = Field(alias='videoDefinition')
video_mime_type: str = Field(alias='videoMimeType')
class ImdbDate(BaseModel):
year: Optional[int] = None
month: Optional[int] = None
day: Optional[int] = None
class Genre(BaseModel):
genre: Optional[TextField] = None
class TitleGenre(BaseModel):
genres: List[Genre] = Field(default_factory=list)
class Certificate(BaseModel):
rating: Optional[str] = None
class ImdbTitle(BaseModel):
id: str
title_text: TextField = Field(alias='titleText')
title_type: TitleType = Field(alias='titleType')
release_year: Optional[ReleaseYear] = Field(None, alias='releaseYear')
akas: Optional[Akas] = None
plot: Optional[Plot] = None
primary_image: Optional[ImdbImage] = Field(default=None, alias='primaryImage')
meter_ranking: Optional[MeterRanking] = Field(default=None, alias='meterRanking')
ratings_summary: Optional[RatingsSummary] = Field(default=None, alias='ratingsSummary')
release_date: Optional[ImdbDate] = Field(None, alias='releaseDate')
title_genres: Optional[TitleGenre] = Field(default=None, alias='titleGenres')
certificate: Optional[Certificate] = None
original_title_text: Optional[TextField] = Field(default=None, alias='originalTitleText')
runtime: Optional[SecondsField] = Field(default=None, alias='runtime')
@property
def plot_text(self) -> str:
return self.plot.plot_text.plain_text if self.plot and self.plot.plot_text else ''
@property
def rating_text(self) -> str:
if self.ratings_summary and self.ratings_summary.aggregate_rating:
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
width: Optional[int] = None
height: Optional[int] = None
class ImdbVideo(BaseModel):
id: str
name: ValueField
content_type: ContentType = Field(alias='contentType')
preview_urls: List[VideoUrl] = Field(default_factory=list, alias='previewURLs')
playback_urls: List[VideoUrl] = Field(default_factory=list, alias='playbackURLs')
thumbnails: Optional[Thumbnail] = None
class TitleNode(BaseModel):
title: ImdbTitle
class TitleEdge(BaseModel):
node: TitleNode

View File

@@ -1,26 +1,32 @@
# 美剧生词标注
根据CEFR等级为英语影视剧标注高级词汇。
___
在影视剧入库后LexiAnnot 会读取媒体文件的MediaInfo和文件列表如果视频的原始语言为英语并且包含英文文本字幕LexiAnnot将为其生成包含词汇注释的`.en.ass`字幕文件。
在影视剧入库后LexiAnnot会读取媒体文件的MediaInfo和文件列表如果视频的原始语言为英语并且包含英文文本字幕LexiAnnot将为其生成包含词汇注释的.ass字幕文件。
## 主要功能
![](https://images2.imgbox.com/d6/b6/kZu6EH2a_o.png)
![](https://images2.imgbox.com/c8/3a/rEJBWu5v_o.png)
![](https://images2.imgbox.com/97/b7/d6RXFtwD_o.png)
![](https://images2.imgbox.com/56/c0/FBhJMvRD_o.jpg)
![](https://images2.imgbox.com/e8/8c/B1EJwst7_o.jpg)
![](https://images2.imgbox.com/8a/d4/AtgOe265_o.jpg)
# Gemini
- 识别视频的原始语言和字幕语言
- 自动适应原字幕样式
- 俚语 / 自造词 / 熟词生义标注和解释
- **[获取APIKEY](https://aistudio.google.com/app/apikey)**
- **[速率限制](https://ai.google.dev/gemini-api/docs/rate-limits)**
## 使用配置
**确保可以正常访问下面的域名**
- spaCy 模型
- spaCy 用于词形还原、POS 标注和命名实体识别,`en_core_web_sm``en_core_web_md` 已足够满足需求。
- LLM 设置
- 一集影视剧的字幕通常包含数千个单词,建议使用支持长文本输入的模型,选择一个适当的上下文窗口大小。
- 处理 60 min 的影视剧字幕大约会消耗 `60K`~`80K` token具体取决于字幕内容。
- 配置请参考 MoviePilot 智能助手的设置部分。
- Agent 工具
- 在聊天中使用 `/ai` 命令告诉智能助手你要标注的影视剧。
- googleapis.com
- google.dev
- aistudio.google.com
# CEFR
## CEFR
CEFR全称是Common European Framework of Reference for Languages。
@@ -36,20 +42,18 @@ CEFR全称是Common European Framework of Reference for Languages。
- **C1** (高级/Advanced):能够理解各种较长、要求较高的文本,并能识别隐含意义,表达流利、自然,能灵活有效地使用语言来应对各种目的。
- **C2** (精通/Proficient):能够轻松理解几乎所有听到的或读到的内容,能够非常流利、准确、精细地表达自己,即使在复杂的情况下也能区分细微的含义。
# 计划
## 计划
- 双语字幕支持
- ~~考试词汇标注~~
# FAQ
## FAQ
- **为什么需要用到Gemini**
- LexiAnnot使用的词典仅包含约18000个单词无法覆盖影视剧中的海量的俚语、习语、流行语等更广泛的表达形式
- **只能处理已有字幕的视频吗?**
- 是的,视频需要包含**英文文本字幕**
- **为什么无法处理一些包含字幕视频**
- 目前无法识别基于图片的字幕(通常是特效字幕)
# 感谢
## 感谢
- [coca-vocabulary-20000](https://github.com/llt22/coca-vocabulary-20000)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
import asyncio
from typing import Optional, Type
from pydantic import BaseModel
from app.agent.tools.base import MoviePilotTool
from app.core.plugin import PluginManager
from .schemas import VocabularyAnnotatingToolInput, QueryAnnotationTasksToolInput, Task
class VocabularyAnnotatingTool(MoviePilotTool):
"""词汇标注工具"""
# 工具名称
name: str = "vocabulary_annotating_tool"
# 工具描述
description: str = "Add new vocabulary annotation task to plugin LexiAnnot's task queue."
# 输入参数模型
args_schema: Type[BaseModel] = VocabularyAnnotatingToolInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据订阅参数生成友好的提示消息"""
skip_existing = kwargs.get("skip_existing", False)
video_path = kwargs.get("video_path", "")
message = f"正在添加字幕任务: {video_path!r}"
if skip_existing:
message += "(覆写方式:跳过已存在的字幕文件)"
else:
message += "(覆写方式:覆盖已存在的字幕文件)"
return message
async def run(self, video_path: str, skip_existing: bool = True, **kwargs) -> str:
"""
实现工具的核心逻辑(异步方法)
:param video_path: Path to the video file
:param skip_existing: Whether to skip existing subtitle files
:param kwargs: 其他参数,包含 explanation工具使用说明
:return: 工具执行结果,返回字符串格式
"""
try:
# 执行工具逻辑
result = await self._perform_operation(video_path, skip_existing)
# 返回执行结果
if not result:
return f"成功添加词汇标注任务: {video_path!r}"
else:
return f"添加任务出错: {result}"
except Exception as e:
return f"执行失败: {str(e)}"
async def _perform_operation(
self, video_path: str, skip_existing: bool
) -> str | None:
"""内部方法,执行具体操作"""
# 实现具体业务逻辑
plugins = PluginManager().running_plugins
plugin_instance = plugins.get("LexiAnnot")
if not plugin_instance:
return "LexiAnnot 插件未运行"
res = await asyncio.to_thread(
plugin_instance.add_task, video_file=video_path, skip_existing=skip_existing
)
if not res:
return "任务添加失败"
return None
class QueryAnnotationTasksTool(MoviePilotTool):
"""词汇标注任务查询工具"""
# 工具名称
name: str = "query_annotation_tasks_tool"
# 工具描述
description: str = "Query the latest vocabulary annotation tasks from plugin LexiAnnot."
# 输入参数模型
args_schema: Type[BaseModel] = QueryAnnotationTasksToolInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据订阅参数生成友好的提示消息"""
count = kwargs.get("count", 5)
return f"正在查询最近的 {count} 条字幕标注任务"
async def run(self, count: int, **kwargs) -> str:
"""
实现工具的核心逻辑(异步方法)
:param count: The max number of returned annotation tasks
:param kwargs: 其他参数,包含 explanation工具使用说明
:return: 工具执行结果,返回字符串格式
"""
try:
# 执行工具逻辑
plugins = PluginManager().running_plugins
plugin_instance = plugins.get("LexiAnnot")
if not plugin_instance:
return "LexiAnnot 插件未运行"
total: list[Task] = plugin_instance.get_tasks()
# Handle potential None in add_time
total.sort(key=lambda t: t.add_time or "", reverse=True)
tasks = total[:count]
if not tasks:
return "未查询到相关任务"
result_lines = [f"最近 {len(tasks)} 条标注任务:"]
for task in tasks:
status_val = (
task.status.value
if hasattr(task.status, "value")
else str(task.status)
)
info = f"\n🎥 **{task.video_path}**"
info += f"\n ID: {task.task_id}"
info += f"\n Status: {status_val}"
info += f"\n Added: {task.add_time or 'N/A'}"
if task.complete_time:
info += f"\n Completed: {task.complete_time}"
if task.message:
info += f"\n Message: {task.message}"
if task.statistics:
info += f"\n Words: {task.statistics.total_words} | Segments: {task.statistics.total_segments}"
result_lines.append(info)
return "\n".join(result_lines)
except Exception as e:
return f"执行失败: {str(e)}"

View File

@@ -0,0 +1,116 @@
from typing import Literal
from pydantic import BaseModel, Field, RootModel
from .schemas import PosDef, Cefr
class CefrEntry(BaseModel):
pos: Literal[
"noun",
"adverb",
"interjection",
"preposition",
"determiner",
"have-verb",
"modal auxiliary",
"adjective",
"number",
"be-verb",
"verb",
"conjunction",
"do-verb",
"infinitive-to",
"vern",
"pos",
"pronoun",
] = Field(..., description="Part of speech")
cefr: Cefr = Field(..., description="CEFR level")
notes: str | None = Field(default=None, description="Notes")
class CefrDictionary(RootModel):
root: dict[str, list[CefrEntry]]
def get(self, word: str) -> list[CefrEntry] | None:
return self.root.get(word)
class Coca20KEntry(BaseModel):
index: int = Field(..., description="Index of the entry")
phonetics_1: str = Field(..., description="Phonetics style 1")
phonetics_2: str = Field(..., description="Phonetics style 2")
pos_defs: list[PosDef] = Field(
..., description="List of part of speech definitions"
)
class Coca20KDictionary(RootModel):
root: dict[str, Coca20KEntry]
def get(self, word: str) -> Coca20KEntry | None:
return self.root.get(word)
class ShanBayDef(BaseModel):
# 'n.', 'v.', 'adv.', 'adj.', 'phrase.', 'int.', 'pron.', 'prep.', '.', 'conj.', 'num.', 'phrase v.', 'linkv.',
# 'det.', 'ordnumber.', 'prefix.', 'un.', 'vt.', 'mod. v.', 'abbr.', 'auxv.', 'modalv.', 'vi.', 'aux. v.',
# 'interj.', 'article.', 'infinitive.', 'suff.', 'ord.', 'art.', 'exclam.', 'n.[C]'
pos: str = Field(..., description="Part of speech")
definition_cn: str = Field(..., description="Definition in Chinese")
class ShanbayEntry(BaseModel):
ipa_uk: str = Field(..., description="UK IPA pronunciation")
ipa_us: str = Field(..., description="US IPA pronunciation")
defs: list[ShanBayDef] = Field(..., description="List of definitions")
class ShanbayDictionary(BaseModel):
"""Dictionary entries for various examinations."""
cet4: dict[str, ShanbayEntry] = Field(
..., alias="CET-4", description="CET-4 dictionary entries"
)
cet6: dict[str, ShanbayEntry] = Field(
..., alias="CET-6", description="CET-6 dictionary entries"
)
npee: dict[str, ShanbayEntry] = Field(
..., alias="NPEE", description="NPEE dictionary entries"
)
ielts: dict[str, ShanbayEntry] = Field(
..., alias="IELTS", description="IELTS dictionary entries"
)
toefl: dict[str, ShanbayEntry] = Field(
..., alias="TOEFL", description="TOEFL dictionary entries"
)
gre: dict[str, ShanbayEntry] = Field(
..., alias="GRE", description="GRE dictionary entries"
)
tem4: dict[str, ShanbayEntry] = Field(
..., alias="TEM-4", description="TEM-4 dictionary entries"
)
tem8: dict[str, ShanbayEntry] = Field(
..., alias="TEM-8", description="TEM-8 dictionary entries"
)
pet: dict[str, ShanbayEntry] = Field(
..., alias="PET", description="PET dictionary entries"
)
def query(self, word: str) -> dict[str, ShanbayEntry]:
result = {}
for field_name, field_info in ShanbayDictionary.model_fields.items():
value = getattr(self, field_name)
if word in value:
result[field_info.alias] = value[word]
return result
class Lexicon(BaseModel):
cefr: CefrDictionary = Field(..., description="CEFR dictionary")
coca20k: Coca20KDictionary = Field(..., description="COCA 20K dictionary")
examinations: ShanbayDictionary = Field(
..., description="Shanbay examinations dictionary"
)
swear_words: list[str] = Field(..., description="List of swear words")
version: str = Field(..., description="Version of the lexicon")

View File

@@ -0,0 +1,731 @@
import re
import threading
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.prompts import ChatPromptTemplate
from langchain.output_parsers import PydanticOutputParser
from pydantic import SecretStr
from app.core.config import settings
from app.schemas import Context
from app.schemas.types import MediaType
from app.log import logger
from .lexicon import CefrDictionary, Lexicon, Coca20KDictionary
from .schemas import (
SubtitleSegment,
PosDef,
Word,
Cefr,
WordMetadata,
SegmentList,
LlmFeedback,
UniversalPos,
LlmEnrichmentResult,
LlmTranslationResult,
)
from .spacyworker import SpacyWorker
_patterns = [
r"\d+th|\d?1st|\d?2nd|\d?3rd",
r"\w+'s$",
r"\w+'d$",
r"\w+'t$",
"[Ii]'m$",
r"\w+'re$",
r"\w+'ve$",
r"\w+'ll$",
]
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] = {
UniversalPos.ADJ: "adj.",
UniversalPos.ADV: "adv.",
UniversalPos.INTJ: "int.",
UniversalPos.NOUN: "n.",
UniversalPos.PROPN: "n.",
UniversalPos.VERB: "v.",
UniversalPos.AUX: "aux.",
UniversalPos.ADP: "prep.",
UniversalPos.CCONJ: "conj.",
UniversalPos.SCONJ: "conj.",
UniversalPos.DET: "det.",
UniversalPos.NUM: "num.",
UniversalPos.PART: "part.",
UniversalPos.PRON: "pron.",
UniversalPos.PUNCT: None,
UniversalPos.SYM: None,
UniversalPos.X: None,
}
def initialize_llm(
provider: str,
api_key: str,
model_name: str,
base_url: str | None,
temperature: float = 0.1,
max_retries: int = 3,
proxy: bool = False,
) -> BaseChatModel:
"""初始化 LLM"""
if provider == "google":
if proxy:
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=settings.LLM_MODEL,
api_key=SecretStr(api_key),
max_retries=3,
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
temperature=settings.LLM_TEMPERATURE,
openai_proxy=settings.PROXY_HOST,
)
from langchain_google_genai import ChatGoogleGenerativeAI
return ChatGoogleGenerativeAI(
model=model_name,
google_api_key=api_key, # noqa
max_retries=max_retries,
temperature=temperature,
)
elif provider == "deepseek":
from langchain_deepseek import ChatDeepSeek
return ChatDeepSeek(
model=model_name,
api_key=SecretStr(api_key),
max_retries=max_retries,
temperature=temperature,
)
else:
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=model_name,
api_key=SecretStr(api_key),
max_retries=max_retries,
base_url=base_url,
temperature=temperature,
openai_proxy=settings.PROXY_HOST if proxy else None,
)
def convert_pos_to_spacy(pos: str):
"""
将给定的词性列表转换为 spaCy 库中使用的词性标签
:param pos: 字符串形式词性
:returns: 一个包含对应spaCy词性标签的列表。对于无法直接映射的词性将返回None
"""
spacy_pos_map = {
"noun": "NOUN",
"adjective": "ADJ",
"adverb": "ADV",
"verb": "VERB",
"preposition": "ADP",
"conjunction": "CCONJ",
"determiner": "DET",
"pronoun": "PRON",
"interjection": "INTJ",
"number": "NUM",
}
pos_lower = pos.lower()
if pos_lower in spacy_pos_map:
spacy_pos = spacy_pos_map[pos_lower]
elif pos_lower == "be-verb":
spacy_pos = "AUX" # Auxiliary verb (e.g., be, do, have)
elif pos_lower == "vern":
spacy_pos = "VERB" # Assuming 'vern' is a typo for 'verb'
elif pos_lower == "modal auxiliary":
spacy_pos = "AUX" # Modal verbs are also auxiliaries
elif pos_lower == "do-verb":
spacy_pos = "AUX"
elif pos_lower == "have-verb":
spacy_pos = "AUX"
elif pos_lower == "infinitive-to":
spacy_pos = "PART" # Particle (e.g., to in "to go")
elif not pos_lower: # Handle empty strings
spacy_pos = None
else:
spacy_pos = None # For unmapped POS tags
return spacy_pos
def convert_spacy_to_universal(spacy_pos: str) -> UniversalPos:
"""
将 spaCy POS 标签转换为 UniversalPos 枚举
"""
# 创建映射字典
pos_mapping = {
"ADJ": UniversalPos.ADJ,
"ADV": UniversalPos.ADV,
"INTJ": UniversalPos.INTJ,
"NOUN": UniversalPos.NOUN,
"PROPN": UniversalPos.PROPN,
"VERB": UniversalPos.VERB,
"AUX": UniversalPos.AUX,
# 介词/后置词
"ADP": UniversalPos.ADP,
# 连词
"CCONJ": UniversalPos.CCONJ,
"SCONJ": UniversalPos.SCONJ,
# 限定词
"DET": UniversalPos.DET,
# 数词
"NUM": UniversalPos.NUM,
# 代词
"PRON": UniversalPos.PRON,
# 小品词
"PART": UniversalPos.PART,
# 标点
"PUNCT": UniversalPos.PUNCT,
# 符号
"SYM": UniversalPos.SYM,
# 其他
"X": UniversalPos.X,
# 特殊处理spaCy 可能返回的其他标签
"SPACE": UniversalPos.PUNCT, # 空格当作标点处理
"CONJ": UniversalPos.CCONJ, # 旧版 spaCy 的连词标签
}
# 转换为大写,确保一致
spacy_pos = spacy_pos.upper()
# 如果直接匹配,返回对应枚举
if spacy_pos in pos_mapping:
return pos_mapping[spacy_pos]
# 处理特殊情况:以特定前缀开头的标签
if spacy_pos.startswith("ADJ"):
return UniversalPos.ADJ
elif spacy_pos.startswith("ADV"):
return UniversalPos.ADV
elif spacy_pos.startswith("NOUN"):
return UniversalPos.NOUN
elif spacy_pos.startswith("VERB"):
return UniversalPos.VERB
elif spacy_pos.startswith("PROPN"):
return UniversalPos.PROPN
elif spacy_pos.startswith("PRON"):
return UniversalPos.PRON
# 默认返回 X未知
return UniversalPos.X
def get_cefr_by_spacy(
lemma_: str, pos_: str, cefr_lexicon: CefrDictionary
) -> Cefr | None:
word = lemma_.lower().strip("-*'")
result = cefr_lexicon.get(word)
if result:
all_cefr: list[Cefr] = []
if len(result) > 0:
for entry in result:
if pos_ == convert_pos_to_spacy(entry.pos):
return entry.cefr
all_cefr.append(entry.cefr)
return min(all_cefr)
return None
def query_coca20k(word: str, coca20k: Coca20KDictionary):
word = word.lower().strip("-*'")
return coca20k.get(word)
def _update_word_via_lexicon(word: Word, lexi: Lexicon) -> Word:
"""
使用词典信息更新单词对象
:param word: 需要更新的单词对象
:param lexi: 词典对象
:returns: 更新后的单词对象
"""
# query dictionary
cefr = get_cefr_by_spacy(word.lemma, word.pos.value, lexi.cefr)
res_of_coca = query_coca20k(word.lemma, lexi.coca20k)
if res_of_coca and not cefr:
cefr = None
res_of_exams = lexi.examinations.query(word.lemma)
exam_tags = [exam_id for exam_id in res_of_exams if exam_id in res_of_exams]
pos_defs = []
phonetics = ""
if res_of_exams:
for exam, value in res_of_exams.items():
phonetics = value.ipa_uk
defs = {}
for pos_def in value.defs:
pos = pos_def.pos
definition_cn = pos_def.definition_cn
defs.setdefault(pos, []).append(definition_cn)
for pos, meanings in defs.items():
pos_defs.append(PosDef(pos=pos, meanings=meanings))
break
elif res_of_coca:
phonetics = res_of_coca.phonetics_1
pos_defs = res_of_coca.pos_defs
word.exams = exam_tags
word.cefr = cefr
word.pos_defs = pos_defs
word.phonetics = phonetics
return word
def extract_advanced_words(segment: SubtitleSegment, lexi: Lexicon, spacy_worker: SpacyWorker,
simple_level: set[Cefr]) -> list[Word]:
text = segment.clean_text
doc = spacy_worker.submit(text)
last_end_pos = 0
lemma_to_query = []
words = []
for token in doc.tokens:
# filter tokens
if (
len(token.text) == 1
or token.is_stop
or token.is_punct
or token.ent_iob_ != "O"
):
continue
if token.pos_ not in pos_interests:
continue
if token.lemma_ in lexi.swear_words:
continue
striped = token.lemma_.strip("-[")
if any(p.match(striped) for p in filter_patterns):
continue
if striped in lemma_to_query:
continue
else:
lemma_to_query.append(striped)
striped_text = token.text.strip("-*[")
start_pos = text.find(striped_text, last_end_pos)
end_pos = start_pos + len(striped_text)
last_end_pos = end_pos
word = Word(
text=striped_text,
lemma=striped,
pos=convert_spacy_to_universal(token.pos_),
meta=WordMetadata(
start_pos=start_pos, end_pos=end_pos, context_id=segment.index
),
)
word = _update_word_via_lexicon(word, lexi)
if word.cefr and word.cefr in simple_level:
continue
words.append(word)
return words
def _find_segment_by_word_id(segments: list[SubtitleSegment], word_id: int) -> SubtitleSegment | None:
for segment in segments:
for word in segment.candidate_words:
if word.meta.word_id == word_id:
return segment
return None
def _update_word_metadata(
new_text: str, meta: WordMetadata, segment: SubtitleSegment
) -> WordMetadata | None:
"""
更新单词的元数据
:param new_text: 新的单词文本
:param meta: 单词的元数据对象
:param segment: 字幕片段对象
"""
text = segment.clean_text
p_end = meta.end_pos
new_len = len(new_text)
i = meta.start_pos - new_len + 1
i = max(0, i)
j = p_end + min(0, (len(text) - (p_end + new_len)))
for x in range(i, j + 1):
text_view = text[x : (x + new_len)]
if text_view == new_text:
return WordMetadata(
start_pos=x,
end_pos=x + new_len,
context_id=segment.index,
word_id=meta.word_id,
)
return None
def format_time_extended(milliseconds: int):
"""
将秒数转换为时间格式
:param milliseconds: 整数,表示毫秒数
:return: 字符串,格式为 HH:MM:SS 或 HH:MM:SS.mmm
"""
if milliseconds < 0:
sign = "-"
milliseconds = abs(milliseconds)
else:
sign = ""
hours = int(milliseconds // 3600000)
minutes = int((milliseconds % 3600000) // 60000)
seconds = (milliseconds % 60000) // 1000
milliseconds_remainder = milliseconds % 1000
return f"{sign}{hours:02d}:{minutes:02d}:{seconds:02d}.{milliseconds_remainder:03d}"
def _context_process_chain(
lexi: Lexicon,
llm: BaseChatModel,
segments: list[SubtitleSegment],
start: int,
end: int,
leaner_level: str = "C1",
media_name: str | None = None,
translate_sentences: bool = False
):
feedback_parser = PydanticOutputParser(pydantic_object=LlmFeedback)
def format_input(segment_list: list[SubtitleSegment]):
media_name_prefix = (
f"The following subtitles are from '{media_name}'.\n" if media_name else ""
)
return {
"media_name_prefix": media_name_prefix,
"context_text": " ".join([seg.clean_text for seg in segment_list]),
"candidate_words": "\n".join(
[
f"- {word.text} (WORD_ID: {word.meta.word_id}, LEMMA: {word.lemma}, CEFR: {word.cefr}, POS: {word.pos})"
for seg in segment_list
if start <= seg.index <= end
for word in seg.candidate_words
]
),
"leaner_level": leaner_level,
"format_instructions": feedback_parser.get_format_instructions(),
}
def refactor_by_feedback(feedback: LlmFeedback):
# Process LLM feedback to update segments
for word in feedback.candidate_words_feedback:
seg = _find_segment_by_word_id(segments, word.word_id)
if not seg or seg.index < start or seg.index > end:
continue
# Update word info based on feedback
if not word.should_keep:
seg.candidate_words = [
w for w in seg.candidate_words if w.meta.word_id != word.word_id
]
continue
for w in seg.candidate_words:
if w.meta.word_id == word.word_id:
word_text = word.text
if word_text is not None and word.text != w.text:
# Update metadata if text changed
if word.text not in seg.clean_text:
# If the word text is not found in the segment, skip updating metadata
continue
new_meta = _update_word_metadata(word_text, w.meta, seg)
if not new_meta:
continue
w.meta = new_meta
w.text = word_text
if word.pos:
w.pos = word.pos
if word.lemma:
w.lemma = word.lemma
# Add new words identified by LLM
for new_word in feedback.llm_identified_words:
for seg in segments:
if seg.index < start or seg.index > end:
continue
start_pos = seg.clean_text.find(new_word.text)
if start_pos == -1:
continue
if any(w.text == new_word.text for w in seg.candidate_words):
continue
if new_word.lemma in lexi.swear_words:
continue
new_meta = WordMetadata(
start_pos=start_pos,
end_pos=start_pos + len(new_word.text),
context_id=seg.index
)
built_word = Word(
text=new_word.text,
lemma=new_word.lemma,
pos=new_word.pos,
meta=new_meta
)
built_word = _update_word_via_lexicon(built_word, lexi)
if built_word.cefr and built_word.cefr <= leaner_level:
continue
seg.candidate_words.append(built_word)
prompt_template = ChatPromptTemplate.from_messages(
[
(
"system",
"""You are an expert in linguistics and language learning. Your task is to analyze subtitle segments.
Please perform the following tasks for an non-native English learner.
**CRITICAL INSTRUCTION**: The learner is at the {leaner_level} level.
They are proficient in vocabulary at or below this level.
Your goal is two-fold:
1. **Learning**: Identify content challenging for their current level.
2. **Comprehension**: Ensure they understand **specific or low-frequency vocabulary** crucial for the narrative, even if it is not "core" vocabulary.
1. **Review and Evaluate Candidate Words:**
* **Goal**: Filter out words that are easy, BUT **retain** rare or specific words needed for understanding.
* **Action**: Return feedback items **ONLY** for words that:
1. Should be **discarded** (too simple, trivial filler, profanity without cultural value). Set `should_keep` to `False`.
2. Need **correction** (wrong lemma, POS, or text boundary). Set `should_keep` to `True` and provide correct values.
* **Implicit Rule**: If a word is appropriate for the learner and has correct info, **DO NOT** include it in the output list.
* **Keep criteria**:
* Keep simple words **ONLY IF** used in a non-literal, metaphorical, or idiomatic sense.
* **Specific/Concrete Vocabulary**: Keep low-frequency words (e.g., like 'chamomile', 'cavernous' for B2) that are rare but essential for visualizing the scene or understanding the plot. **Do NOT discard these just because they are rare.**
* **Discard criteria**: Discard trivial conversational fillers ('gonna', 'wanna'), simple interjections, common profanity, and words well below {leaner_level} level (unless they fit the 'Keep criteria').
2. **Identify Missed Words:**
* Identify any additional single words or phrases (typically 1-3 words) from the `context_text` that may be important for {leaner_level} learners or for **plot comprehension**.
* **Targets**:
* **Slang, idioms, or modern colloquialisms.**
* **Low-frequency words** (e.g., 'shimmer', 'rugged') missed by the algorithm.
* **Words requiring cultural background.**
* Avoid repeating words already listed in `candidate_words`.
* Must exist in the exact form in `context_text`.
* Provide lemma and POS.
* **Do NOT include** simple high-frequency words, common fillers ('gonna', 'gotta'), onomatopoeia, or basic swear words.
-------------------------
{format_instructions}
""",
),
(
"human",
"""{media_name_prefix}Here is the context from the subtitles:
---
{context_text}
---
Here are the candidate words identified by a basic algorithm:
{candidate_words}
""",
),
]
)
feedback_chain = (
format_input | prompt_template | llm.with_structured_output(LlmFeedback).with_retry(stop_after_attempt=3)
)
result: LlmFeedback = feedback_chain.invoke(segments) # type: ignore
refactor_by_feedback(result)
# 丰富词义
if any(segment.candidate_words for segment in segments):
enrichment_prompt_template = ChatPromptTemplate.from_messages(
[
(
"system",
"""You are a linguistics and English-learning expert. Your goal is to enhance vocabulary learning for Chinese users.\n
For each word (identified by `WORD_ID`), provide:
1. **Translation:** A concise Chinese translation.
2. **Usage or Cultural Context (optional, in Chinese)**:
* **Keep it brief and clear.**
* ONLY include if:
- The word has a specific meaning in this context that differs from its common definition;
- It is slang, idiom, phrasal, metaphorical, or culturally loaded;
* ONLY provide this context when learners would likely struggle to understand the word's usage without it.
3. **Lexical Features**:
* Select the most appropriate tag(s) if applicable.
**For each word, provide the `word_id` to ensure proper mapping.**
**Your judgment should be based strictly on the provided subtitle context. DO NOT fabricate context or forced explanation.**
-------------------------
{format_instructions}
""",
),
(
"human",
"""{media_name_prefix}Here is the context from the subtitles:
---
{context_text}
---
Here are the words you need to enrich:
{words_to_enrich}
""",
),
]
)
enrichment_parser = PydanticOutputParser(pydantic_object=LlmEnrichmentResult)
def format_enrichment_input(segment_list: list[SubtitleSegment]):
media_name_prefix = (
f"The following subtitles are from '{media_name}'.\n"
if media_name
else ""
)
words_to_enrich = []
for seg in segment_list:
if start <= seg.index <= end:
for w in seg.candidate_words:
words_to_enrich.append(
f"- {w.text} (WORD_ID: {w.meta.word_id}, LEMMA: {w.lemma}, POS: {w.pos}, DEFINITIONS: {w.pos_defs_plaintext})"
)
return {
"media_name_prefix": media_name_prefix,
"context_text": " ".join([seg.clean_text for seg in segment_list]),
"words_to_enrich": "\n".join(words_to_enrich),
"format_instructions": enrichment_parser.get_format_instructions(),
}
enrichment_chain = (
format_enrichment_input
| enrichment_prompt_template
| llm.with_structured_output(LlmEnrichmentResult).with_retry(stop_after_attempt=3)
)
enrichment_result: LlmEnrichmentResult = enrichment_chain.invoke(segments) # type: ignore
for enriched_word_data in enrichment_result.enriched_words:
for segment in segments:
if segment.index < start or segment.index > end:
continue
for candidate_word in segment.candidate_words:
if candidate_word.meta.word_id == enriched_word_data.word_id:
candidate_word.llm_translation = enriched_word_data.translation
candidate_word.llm_usage_context = enriched_word_data.usage_context
candidate_word.lexical_features = enriched_word_data.lexical_features
break
# 整句翻译
if translate_sentences:
translation_parser = PydanticOutputParser(pydantic_object=LlmTranslationResult)
translation_prompt_template = ChatPromptTemplate.from_messages(
[
(
"system",
"""You are a professional subtitle translator. Your task is to translate English subtitle segments into natural, idiomatic Chinese.
**Guidelines:**
1. **Tone & Style:** Maintain the original tone (e.g., casual, formal, humorous, dramatic).
2. **Context:** Use the surrounding segments to ensure continuity and correct meaning.
3. **Conciseness:** Subtitles have space constraints. Keep translations concise but accurate.
4. **Formatting:** Return the result strictly matching the provided JSON schema.
-------------------------
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}
""",
),
(
"human",
"""{media_name_prefix}Here are the segments to translate:
---
{segments_text}
---
""",
),
]
)
def format_translation_input(segment_list: list[SubtitleSegment]):
media_name_prefix = (
f"The following subtitles are from '{media_name}'.\n"
if media_name
else ""
)
# Only translate segments within the current batch range (start to end)
segments_text_lines = []
for seg in segment_list:
if start <= seg.index <= end:
segments_text_lines.append(f"ID {seg.index}: {seg.clean_text}")
return {
"media_name_prefix": media_name_prefix,
"segments_text": "\n".join(segments_text_lines),
"format_instructions": translation_parser.get_format_instructions(),
}
translation_chain = (
format_translation_input
| translation_prompt_template
| llm.with_structured_output(LlmTranslationResult).with_retry(stop_after_attempt=3)
)
try:
translation_result: LlmTranslationResult = translation_chain.invoke(segments) # type: ignore
# Map translations back to segments
trans_map = {
t.index: t.translation for t in translation_result.translations
}
for segment in segments:
if segment.index in trans_map:
segment.Chinese = trans_map[segment.index]
except Exception as e:
logger.error(f"Error during sentence translation: {e}")
return [segment for segment in segments if start <= segment.index <= end]
def llm_process_chain(
lexi: Lexicon,
llm: BaseChatModel,
segments: SegmentList,
shutdown_event: threading.Event,
context_window: int = 30,
learner_level: str = "C1",
media_context: Context | None = None,
translate_sentences: bool = False,
) -> SegmentList:
"""
根据 LLM 的反馈更新字幕片段中的单词信息
:param lexi: 词典对象
:param llm: LLM 对象
:param segments: 字幕片段
:param shutdown_event: 关闭事件
:param context_window: 上下文窗口大小
:param learner_level: 学习者的 CEFR 水平
:param media_context: 媒体信息
:param translate_sentences: 是否翻译句子
:returns: 更新后的字幕片段列表
"""
media_name = None
if media_context and media_context.media_info and media_context.meta_info:
media_info = media_context.media_info
if media_info.type == MediaType.TV:
media_name = f"{media_info.title_year} {media_context.meta_info.season_episode}"
else:
media_name = f"{media_info.title_year}"
segments_list = []
for context, (start, end) in segments.context_generator(context_window=context_window, extra_len=2):
if shutdown_event.is_set():
break
logger.info(
f"Processing segments {format_time_extended(context[0].start_time)} ({context[0].index}) ->"
f" {format_time_extended(context[-1].end_time)} ({context[-1].index}) via LLM..."
)
segments_list.extend(
_context_process_chain(
lexi, llm, context, start, end, learner_level, media_name, translate_sentences
)
)
return SegmentList(root=segments_list)

View File

@@ -1,111 +0,0 @@
import time
from typing import Generic, List, TypeVar
from google import genai
from google.genai import types
from pydantic import BaseModel
class Context(BaseModel):
original_text: str
class Vocabulary(BaseModel):
lemma: str
Chinese: str
class TaskBase(BaseModel):
id: str
class VocabularyTranslationTask(TaskBase):
vocabulary: List[Vocabulary]
context: Context
index: int
class DialogueTranslationTask(TaskBase):
original_text: str
Chinese: str
index: int
T = TypeVar("T", bound=TaskBase)
class TranslationTasks(BaseModel, Generic[T]):
tasks: List[T]
class GeminiResponse(BaseModel, Generic[T]):
tasks: List[T]
total_token_count: int
success: bool
message: str = ""
def translate(
api_key: str,
translation_tasks: TranslationTasks[T],
system_instruction: str,
gemini_model: str = "gemini-2.0-flash",
temperature: float = 0.3,
max_retries: int = 3,
retry_delay: int = 10,
) -> GeminiResponse[T]:
"""
Query the Gemini API for translation tasks with retry logic.
:param api_key: Gemini API key
:param translation_tasks: Translation tasks
:param system_instruction: System instruction
:param gemini_model: Model name to use
:param temperature: Generation temperature
:param max_retries: Number of retry attempts
:param retry_delay: Delay between retries in seconds
returns: GeminiResponse containing the results
"""
messages = []
response_schema = type(translation_tasks)
for attempt in range(1, max_retries + 1):
try:
client = genai.Client(api_key=api_key)
response = client.models.generate_content(
model=gemini_model,
contents=translation_tasks.model_dump_json(),
config=types.GenerateContentConfig(
system_instruction=system_instruction,
response_mime_type="application/json",
response_schema=response_schema,
temperature=temperature,
),
)
if not response.parsed:
raise ValueError("Empty response from Gemini API")
translation_res = response.parsed
total_token_count = response.usage_metadata.total_token_count
return GeminiResponse(
tasks=translation_res.tasks,
total_token_count=total_token_count or 0,
success=True,
)
except Exception as e:
messages.append(f"Attempt {attempt} failed: {str(e)}")
if attempt < max_retries:
time.sleep(attempt*retry_delay)
return GeminiResponse(
tasks=[],
total_token_count=0,
success=False,
message="All retry attempts failed. " + "\n".join(messages),
)

View File

@@ -1,5 +1,4 @@
pysubs2~=1.8.0
langdetect~=1.0.9
pymediainfo~=7.0.1
spacy~=3.8.7
google-genai~=1.48.0
spacy~=3.8.11

View File

@@ -0,0 +1,367 @@
import re
import uuid
from collections import Counter
from enum import Enum, StrEnum
from typing import Literal, Generator, Iterator
from pydantic import BaseModel, Field, RootModel, model_validator, field_validator
from app.utils.singleton import Singleton
Cefr = Literal["C2", "C1", "B2", "B1", "A2", "A1"]
class UniversalPos(StrEnum):
"""Universal Part-of-Speech tags"""
ADJ = "ADJ" # Adjective
ADV = "ADV" # Adverb
INTJ = "INTJ" # Interjection
NOUN = "NOUN" # Noun
PROPN = "PROPN" # Proper noun
VERB = "VERB" # Verb
ADP = "ADP" # Adposition (preposition/postposition)
AUX = "AUX" # Auxiliary verb
CCONJ = "CCONJ" # Coordinating conjunction
DET = "DET" # Determiner
NUM = "NUM" # Numeral
PART = "PART" # Particle
PRON = "PRON" # Pronoun
SCONJ = "SCONJ" # Subordinating conjunction
PUNCT = "PUNCT" # Punctuation
SYM = "SYM" # Symbol
X = "X" # Other/unknown
class LexicalFeatures(StrEnum):
"""Lexical features for words."""
FORMAL = "formal"
INFORMAL = "informal"
SLANG = "slang"
COLLOQUIAL = "colloquial"
ARCHAIC = "archaic"
DIALECT = "dialect"
TECHNICAL = "technical"
LITERARY = "literary"
ABBREVIATION = "abbreviation"
NAME = "name"
IDIOMATIC = "idiomatic"
NEOLOGISM = "neologism"
GIBBERISH = "gibberish"
COMPOUND = "compound"
class IDGenerator(metaclass=Singleton):
"""Singleton class for generating unique IDs."""
_counter = 0
def next_id(self):
self._counter += 1
return self._counter
def reset(self):
self._counter = 0
class TaskStatus(Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELED = "canceled"
IGNORED = "ignored"
class TaskParams(BaseModel):
skip_existing: bool = Field(default=True, description="Whether to skip existing subtitle files")
class TasksApiParams(BaseModel):
operation: Literal["DELETE", "RETRY", "IGNORE"] = Field(
..., description="Operation to perform on the tasks"
)
task_id: str | None = Field(default=None, description="Unique identifier for the task")
class SegmentStatistics(BaseModel):
total_segments: int = Field(default=0, description="Total number of subtitle segments")
total_words: int = Field(default=0, description="Total number of candidate words")
cefr_distribution: dict[str, int] = Field(
default_factory=dict, description="Distribution of words by CEFR level"
)
pos_distribution: dict[str, int] = Field(
default_factory=dict, description="Distribution of words by Part of Speech"
)
exam_distribution: dict[str, int] = Field(
default_factory=dict, description="Distribution of words by Examination"
)
def to_string(self) -> str:
cefr_str = ", ".join(
[f"{level}({count})" for level, count in self.cefr_distribution.items()]
)
pos_str = ", ".join(
[f"{pos}({count})" for pos, count in self.pos_distribution.items()]
)
exam_str = ", ".join([f"{exam}({count})" for exam, count in self.exam_distribution.items()])
return (
f"Total Segments: {self.total_segments}\n"
f"Total Words: {self.total_words}\n"
f"CEFR Distribution: {cefr_str if cefr_str else 'N/A'}\n"
f"POS Distribution: {pos_str if pos_str else 'N/A'}\n"
f"Exam Distribution: {exam_str if exam_str else 'N/A'}"
)
class ProcessResult(BaseModel):
"""Result of processing a task."""
message: str | None = Field(default=None, description="Additional message or error information")
status: TaskStatus = Field(default=TaskStatus.PENDING, description="Current status of the task")
statistics: SegmentStatistics | None = Field(default=None, description="Statistics of the task")
class Task(BaseModel):
video_path: str = Field(..., description="Path to the video file")
task_id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique identifier for the task",
)
status: TaskStatus = Field(default=TaskStatus.PENDING, description="Current status of the task")
add_time: str | None = Field(default=None, description="Add time of the task, format %Y-%m-%d %H:%M:%S")
complete_time: str | None = Field(default=None, description="Complete time of the task")
tokens_used: int = Field(default=0, description="Number of used tokens")
message: str | None = Field(default=None, description="Additional message or error information")
params: TaskParams = Field(default_factory=TaskParams, description="Parameters for the task")
statistics: SegmentStatistics | None = Field(default=None, description="Statistics of the task")
class WordMetadata(BaseModel):
start_pos: int = Field(..., description="Start position of the word in the context sentence")
end_pos: int = Field(..., description="End position of the word in the context sentence")
context_id: int = Field(..., description="Identifier of the context sentence")
word_id: int = Field(
default_factory=lambda: IDGenerator().next_id(),
description="Identifier of the word in the context",
)
class PosDef(BaseModel):
# 'art.', 'v.', 'aux.', 'conj.', 'prep.', 'adv.', 'adj.', 'n.', 'vt.', 'pron.', 'det.', 'vi.', 'int.'
# 'num.', 'abbr.', 'na.', 'quant.', 'phr.'
pos: str = Field(..., description="Part of speech")
meanings: list[str] = Field(..., description="List of definitions")
@property
def plaintext(self):
return f"{self.pos} {'; '.join(self.meanings)}"
class WordBase(BaseModel):
text: str = Field(..., description="The word or phrase")
lemma: str = Field(..., description="Lemma form of the word")
pos: UniversalPos = Field(default=UniversalPos.X, description="Universal POS tag of the word")
class Word(WordBase):
phonetics: str | None = Field(default=None, description="Phonetic transcription of the word")
meta: WordMetadata = Field(default_factory=WordMetadata, description="Additional metadata")
cefr: Cefr | None = Field(default=None, description="CEFR level")
exams: list[str] = Field(
default_factory=list,
description="Exams whose vocabulary syllabus include this word",
)
pos_defs: list[PosDef] = Field(default_factory=list, description="Part of speech definitions")
llm_translation: str | None = Field(default=None, description="LLM generated Chinese translation")
llm_usage_context: str | None = Field(default=None, description="LLM generated cultural context")
lexical_features: list[LexicalFeatures] = Field(default_factory=list, description="Lexical features")
llm_example_sentences: list[str] = Field(default_factory=list, description="LLM generated example sentences")
@property
def pos_defs_plaintext(self) -> str:
return " ".join(
[
f"{index}. {pos_def.plaintext}"
for index, pos_def in enumerate(self.pos_defs)
]
)
class SubtitleSegment(BaseModel):
index: int = Field(..., description="Index of the subtitle segment")
start_time: int = Field(
..., description="Start time of the subtitle segment in milliseconds"
)
end_time: int = Field(..., description="End time of the subtitle segment in milliseconds")
plaintext: str = Field(..., description="Text content of the subtitle segment")
Chinese: str | None = Field(default=None, description="Chinese translation of the subtitle segment")
candidate_words: list[Word] = Field(
default_factory=list, description="List of words worth learning in the segment"
)
def words_append(self, word: Word):
"""
向字幕片段中添加一个单词到 words_worth_larning 列表中。
:param word: 要添加的单词对象。
"""
self.candidate_words.append(word)
@staticmethod
def _replace_with_spaces(_text):
"""
使用等长的空格替换文本中的 [xxx] 模式。
例如:"[Hi]" 会被替换成 " " (4个空格)
"""
pattern = r"(\[.*?\])"
return re.sub(pattern, lambda match: " " * len(match.group(1)), _text)
@property
def clean_text(self) -> str:
"""
获取清理后的文本内容,去除换行符并将 [xxx] 模式替换为空格。
"""
return SubtitleSegment._replace_with_spaces(self.plaintext.replace("\n", " "))
def __lt__(self, other: object):
if not isinstance(other, SubtitleSegment):
return NotImplemented
return self.index < other.index
class SegmentList(RootModel):
root: list[SubtitleSegment] = Field(
default_factory=list, description="List of subtitle segments"
)
@property
def statistics(self) -> SegmentStatistics:
all_words = [word for seg in self.root for word in seg.candidate_words]
cefr_counts = Counter(word.cefr if word.cefr else "Other" for word in all_words)
pos_counts = Counter(word.pos.value if word.pos else "Other" for word in all_words)
exam_counts = Counter(exam for word in all_words for exam in word.exams)
return SegmentStatistics(
total_segments=len(self.root),
total_words=len(all_words),
cefr_distribution=dict(cefr_counts),
pos_distribution=dict(pos_counts),
exam_distribution=dict(exam_counts)
)
def context_generator(
self, context_window: int, extra_len: int = 1
) -> Generator[tuple[list[SubtitleSegment], tuple[int, int]], None, None]:
"""
生成包含上下文窗口的字幕片段列表
:param context_window: 上下文窗口大小
:param extra_len: 额外长度,用于调整窗口大小
:yield: 包含上下文的字幕片段列表。
"""
total_segments = len(self.root)
for i in range((total_segments + context_window - 1) // context_window):
real_start = i * context_window
real_end = min(total_segments, (i + 1) * context_window) - 1
start_index = max(0, i * context_window - extra_len)
end_index = min(total_segments, (i + 1) * context_window + extra_len)
yield (
self.root[start_index:end_index],
(self.root[real_start].index, self.root[real_end].index),
)
def sort(self):
self.root.sort()
@model_validator(mode="after")
def sort_root(self):
self.root.sort()
return self
def __iter__(self) -> Iterator[SubtitleSegment]:
return iter(self.root)
class SpacyToken(BaseModel):
lemma_: str = Field(..., description="Lemma form of the word (string)")
pos_: str = Field(..., description="POS tag of the word")
text: str = Field(..., description="Text of the word")
is_stop: bool = Field(default=False, description="Indicates if the word is a stop word")
is_punct: bool = Field(default=False, description="Indicates if the word is punctuation")
ent_iob_: str = Field(..., description="Entity IOB")
class SpacyNamedEntity(BaseModel):
text: str = Field(..., description="Text of the entity")
label_: str = Field(..., description="Label of the entity")
class NlpResult(BaseModel):
tokens: list[SpacyToken] = Field(default_factory=list, description="List of tokens")
entities: list[SpacyNamedEntity] = Field(default_factory=list, description="List of named entities")
class LlmFeedbackAboutCandidateWord(BaseModel):
should_keep: bool = Field(..., description="Indicates whether to keep the candidate word")
# reason: str | None = Field(default=None, description="Concise reason for the decision")
word_id: int = Field(..., description="Identifier of the word in the context")
text: str | None = Field(default=None, description="The vocabulary word or phrase")
lemma: str | None = Field(default=None, description="Lemma form of the word")
pos: UniversalPos | None = Field(
default=None,
description="Universal POS tag of the word. Options: ADJ, ADV, INTJ, NOUN, PROPN, "
"VERB, ADP, AUX, CCONJ, DET, NUM, PART, PRON, SCONJ, PUNCT, SYM, X",
)
class LlmFeedback(BaseModel):
candidate_words_feedback: list[LlmFeedbackAboutCandidateWord] = Field(
default_factory=list, description="Feedback about candidate words."
)
llm_identified_words: list[WordBase] = Field(
default_factory=list, description="List of words identified by the LLM."
)
class LlmWordEnrichment(BaseModel):
word_id: int = Field(..., description="Identifier of the word in the context")
translation: str | None = Field(default=None, description="Chinese translation of the word")
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")
class LlmSegmentTranslation(BaseModel):
index: int = Field(..., description="Index of the subtitle segment")
translation: str = Field(..., description="Natural Chinese translation of the segment")
class LlmTranslationResult(BaseModel):
translations: list[LlmSegmentTranslation] = Field(default_factory=list, description="List of segment translations")
class VocabularyAnnotatingToolInput(BaseModel):
explanation: str = Field(
...,
description="This is a tool for adding a new vocabulary-annotating task to AnnotLexi",
)
video_path: str = Field(..., description="Path to the video file")
skip_existing: bool = Field(default=True, description="Whether to skip existing subtitle files")
class QueryAnnotationTasksToolInput(BaseModel):
count: int = Field(default=5, description="The maximum number of returned annotation tasks")
explanation: str = Field(..., description="This is a tool for querying the latest annotation tasks in AnnotLexi")

View File

@@ -1,29 +1,28 @@
from multiprocessing import Process, Queue
from typing import Dict, List
import spacy
from spacy.tokenizer import Tokenizer
from app.core.cache import cached
from app.log import logger
from .schemas import SpacyNamedEntity, SpacyToken, NlpResult
class SpacyWorker:
def __init__(self, model='en_core_web_sm'):
def __init__(self, model="en_core_web_sm"):
self.task_q = Queue()
self.result_q = Queue()
self.status_q = Queue()
self.model = model
# 启动子进程
logger.info(f"正在启动 SpacyWorker 子进程...")
logger.info("正在启动 SpacyWorker 子进程...")
self.proc = Process(target=self.run, args=(self.model,))
self.proc.start()
# 等待子进程返回模型加载状态
status, info = self.status_q.get()
if status == 'error':
if status == "error":
self.proc.join()
raise RuntimeError(f"spaCy 模型加载失败: {info}")
else:
@@ -39,35 +38,50 @@ class SpacyWorker:
try:
nlp = SpacyWorker.load_nlp(model)
infixes = list(nlp.Defaults.infixes)
infixes = [i for i in infixes if '-' not in i]
infixes = [i for i in infixes if "-" not in i]
infix_re = spacy.util.compile_infix_regex(infixes)
nlp.tokenizer = Tokenizer(
nlp.vocab,
prefix_search=nlp.tokenizer.prefix_search,
suffix_search=nlp.tokenizer.suffix_search,
infix_finditer=infix_re.finditer,
token_match=nlp.tokenizer.token_match
token_match=nlp.tokenizer.token_match,
)
except Exception as e:
self.status_q.put(('error', str(e)))
self.status_q.put(("error", str(e)))
return
# 告诉主进程加载成功
self.status_q.put(('ok', None))
self.status_q.put(("ok", None))
while True:
text = self.task_q.get()
if text is None:
break
doc = nlp(text)
self.result_q.put([{'text': token.text, 'pos_': token.pos_, 'lemma_': token.lemma_} for token in doc])
tokens = []
entities = []
for token in doc:
tokens.append(
SpacyToken(
lemma_=token.lemma_,
pos_=token.pos_,
text=token.text,
is_stop=token.is_stop,
is_punct=token.is_punct,
ent_iob_=token.ent_iob_,
)
)
for ent in doc.ents:
entities.append(SpacyNamedEntity(text=ent.text, label_=ent.label_))
self.result_q.put(NlpResult(tokens=tokens, entities=entities))
@staticmethod
@cached(maxsize=1, ttl=3600 * 6)
def load_nlp(model: str) -> spacy.Language:
return spacy.load(model)
def submit(self, text: str) -> List[Dict[str, str]]:
def submit(self, text: str) -> NlpResult:
"""
提交任务并等待结果
"""

View File

@@ -0,0 +1,44 @@
from typing import Generator, Any, overload
from pysubs2 import SSAEvent
from .schemas import SubtitleSegment
class SubtitleProcessor:
def __init__(self):
self._events: list[SSAEvent] = []
def append(self, event: SSAEvent):
self._events.append(event)
def segment_generator(self) -> Generator[SubtitleSegment, None, None]:
for index, event in enumerate(self._events):
yield SubtitleSegment(
index=index,
start_time=event.start,
end_time=event.end,
plaintext=event.plaintext,
)
@overload
def __getitem__(self, item: int) -> SSAEvent:
pass
@overload
def __getitem__(self, s: slice) -> list[SSAEvent]:
pass
def __getitem__(self, item: Any) -> Any:
return self._events[item]
def style_text(style: str, text: str) -> str:
"""
使用指定的样式包装文本。
:param style: 样式名称
:param text: 要包装的文本
:return: 包含样式的文本
"""
return f"{{\\r{style}}}{text}{{\\r}}"

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@ class MoviePilotUpdateNotify(_PluginBase):
# 插件图标
plugin_icon = "Moviepilot_A.png"
# 插件版本
plugin_version = "2.2"
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
@@ -171,7 +171,7 @@ class MoviePilotUpdateNotify(_PluginBase):
"""
result = self.__get_latest_version("https://api.github.com/repos/jxxghp/MoviePilot/releases")
if result:
return result['tag_name'], result['body'], result['published_at']
return result['tag_name'], f"{result['body'] or ''}", result['published_at']
return None, None, None
def __get_front_latest(self):
@@ -180,7 +180,7 @@ class MoviePilotUpdateNotify(_PluginBase):
"""
result = self.__get_latest_version("https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases")
if result:
return result['tag_name'], result['body'], result['published_at']
return result['tag_name'], f"{result['body'] or ''}", result['published_at']
return None, None, None
def get_state(self) -> bool:

View File

@@ -0,0 +1,261 @@
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, List, Dict, Tuple
from urllib.parse import urlparse, parse_qs
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from app.core.config import settings
try:
# MP v2.8.8+
from app.helper.image import WallpaperHelper
except ImportError:
# 旧版MP
from app.helper.wallpaper import WallpaperHelper
from app.log import logger
from app.plugins import _PluginBase
from app.utils.http import RequestUtils
class TmdbWallpaper(_PluginBase):
# 插件名称
plugin_name = "登录壁纸本地化"
# 插件描述
plugin_desc = "将MoviePilot的登录壁纸下载到本地。"
# 插件图标
plugin_icon = "Macos_Sierra.png"
# 插件版本
plugin_version = "1.4.2"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
plugin_config_prefix = "tmdbwallpaper_"
# 加载顺序
plugin_order = 99
# 可使用的用户级别
auth_level = 1
# 私有属性
_hours = None
_savepath = None
_enabled = False
_onlyonce = False
_scheduler = None
def init_plugin(self, config: dict = None):
if config:
self._enabled = config.get("enabled")
self._hours = int(config.get("hours")) if config.get("hours") else None
self._savepath = config.get('savepath')
self._onlyonce = config.get("onlyonce")
if self._enabled or self._onlyonce:
savepath = Path(self._savepath)
if self._savepath and not savepath.exists():
logger.info(f"创建保存目录:{self._savepath}")
savepath.mkdir(parents=True, exist_ok=True)
# 立即运行一次
if self._onlyonce:
# 定时服务
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
logger.info(f"登录壁纸本地化服务启动,立即运行一次")
self._scheduler.add_job(self.wallpaper_local, 'date',
run_date=datetime.now(
tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3)
)
# 关闭一次性开关
self._onlyonce = False
# 保存配置
self.update_config({
"enabled": self._enabled,
"hours": self._hours,
"savepath": self._savepath,
"onlyonce": self._onlyonce
})
if self._scheduler.get_jobs():
# 启动服务
self._scheduler.print_jobs()
self._scheduler.start()
def get_state(self) -> bool:
return True if self._enabled and self._hours and self._savepath else False
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'onlyonce',
'label': '立即运行一次',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'hours',
'label': '更新频率(小时)',
'placeholder': '1'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 8
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'savepath',
'label': '保存路径',
'placeholder': '/config/wallpapers'
}
}
]
}
]
},
]
}
], {
"enabled": False,
"hours": 1,
"savepath": "/config/wallpapers"
}
def get_page(self) -> List[dict]:
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self.get_state():
return [{
"id": "TmdbWallpaper",
"name": "登录壁纸本地化服务",
"trigger": "interval",
"func": self.wallpaper_local,
"kwargs": {
"minutes": self._hours * 60
}
}]
return []
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:
print(str(e))
def wallpaper_local(self):
"""
下载MoviePilot的登录壁纸到本地
"""
def __save_file(_url: str, _filename: str):
"""
保存文件
"""
try:
savepath = Path(self._savepath)
logger.info(f"下载壁纸:{_url}")
r = RequestUtils().get_res(_url)
if r and r.status_code == 200:
with open(savepath / _filename, "wb") as f:
f.write(r.content)
except Exception as e:
logger.error(f"下载壁纸失败:{str(e)}")
if not self._savepath:
return
urls = WallpaperHelper().get_wallpapers(10) or []
for url in urls:
if settings.WALLPAPER == "tmdb":
filename = url.split("/")[-1]
elif settings.WALLPAPER == "bing":
# 解析url参数获取id的值
parsed_url = urlparse(url)
query_params = parse_qs(parsed_url.query)
param_value = query_params.get("id")
filename = param_value[0] if param_value else None
else:
# 其他壁纸类型直接使用url的文件名hash
filename = url.split("/")[-1]
# 没有后缀的文件名,添加.jpg后缀
if not filename.endswith(".jpg"):
filename += ".jpg"
__save_file(url, filename)

View File

@@ -6,19 +6,26 @@ import socket
import time
from datetime import datetime, timedelta
from ipaddress import IPv4Network, IPv6Network, IPv4Address, IPv6Address
from pathlib import Path
from typing import Any, List, Dict, Tuple, Optional, Literal, overload
from urllib.parse import urlparse
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from fastapi import Response
from pydantic import BaseModel, Field
from torrentool.api import Torrent
from torrentool.exceptions import BencodeDecodingError
from app.chain.torrents import TorrentsChain
from app.core.config import settings
from app.core.event import eventmanager, Event
from app.db.site_oper import SiteOper
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.plugins import _PluginBase
from app.scheduler import Scheduler
from app.schemas.types import EventType, NotificationType
from app.utils.http import RequestUtils
from .dns_helper import DnsHelper
@@ -51,11 +58,11 @@ class ToBypassTrackers(_PluginBase):
# 插件名称
plugin_name = "绕过Trackers"
# 插件描述
plugin_desc = "提供tracker服务器IP地址列表帮助IPv6连接绕过OpenClash。"
plugin_desc = "提供 Tracker 服务器 IP 地址列表,帮助 IPv6 连接绕过 OpenClash。"
# 插件图标
plugin_icon = "Clash_A.png"
# 插件版本
plugin_version = "1.5.0"
plugin_version = "1.5.2"
# 插件作者
plugin_author = "wumode"
# 作者主页
@@ -74,6 +81,7 @@ class ToBypassTrackers(_PluginBase):
# 开关
_enabled: bool = False
_cron: str = ""
_sync_cron: str = ""
_notify: bool = False
_onlyonce: bool = False
_custom_trackers: str = ""
@@ -84,24 +92,15 @@ class ToBypassTrackers(_PluginBase):
_bypass_ipv4: bool = True
_bypass_ipv6: bool = True
_dns_input: str | None = None
trackers: Dict[str, List[str]] = {}
def init_plugin(self, config: dict = None):
self.stop_service()
self.trackers = {}
try:
site_file = settings.ROOT_PATH/'app'/'plugins'/self.__class__.__name__.lower()/'sites'/'trackers'
with open(site_file, "r", encoding="utf-8") as f:
base64_str = f.read()
self.trackers = json.loads(base64.b64decode(base64_str).decode("utf-8"))
except Exception as e:
logger.error(f"插件加载错误:{e}")
# 配置
if config:
self._enabled = bool(config.get("enabled"))
self._cron = config.get("cron") or "0 4 * * *"
self._sync_cron = config.get("sync_cron") or "30 4 * * 1"
self._onlyonce = bool(config.get("onlyonce"))
self._notify = bool(config.get("notify"))
self._custom_trackers = config.get("custom_trackers") or ""
@@ -137,6 +136,7 @@ class ToBypassTrackers(_PluginBase):
{
"enabled": self._enabled,
"cron": self._cron,
"sync_cron": self._sync_cron,
"onlyonce": self._onlyonce,
"bypassed_sites": self._bypassed_sites,
"custom_trackers": self._custom_trackers,
@@ -146,7 +146,7 @@ class ToBypassTrackers(_PluginBase):
"china_ip_route": self._china_ip_route,
"china_ipv6_route": self._china_ipv6_route,
"bypass_ipv6": self._bypass_ipv6,
"bypass_ipv4": self._bypass_ipv4
"bypass_ipv4": self._bypass_ipv4,
}
)
@@ -327,7 +327,7 @@ class ToBypassTrackers(_PluginBase):
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
'md': 4
},
'content': [
{
@@ -344,7 +344,24 @@ class ToBypassTrackers(_PluginBase):
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
'md': 4
},
'content': [
{
'component': 'VCronField',
'props': {
'model': 'sync_cron',
'label': 'Trackers 更新周期',
'placeholder': '30 4 * * 1'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
@@ -526,7 +543,8 @@ class ToBypassTrackers(_PluginBase):
"china_ip_route": True,
"china_ipv6_route": True,
"bypass_ipv4": True,
"bypass_ipv6": True
"bypass_ipv6": True,
"sync_cron": "30 4 * * 1"
}
def get_page(self) -> List[dict]:
@@ -558,7 +576,6 @@ class ToBypassTrackers(_PluginBase):
'title': '绕过的 Tracker 服务器 IP 列表',
'subtitle': '以下是已解析并添加到绕过列表中的 Tracker 服务器 IP 地址,'
'请在 OpenClash 中配置「绕过中国大陆 IP」并订阅本列表以实现绕过效果。',
'variant': 'elevated',
},
'content': [
{
@@ -589,7 +606,7 @@ class ToBypassTrackers(_PluginBase):
{
'component': 'VCard',
'props': {
'class': 'mb-4',
'class': 'pa-0',
'title': '排除的 IP 列表',
'variant': 'elevated',
},
@@ -664,15 +681,85 @@ class ToBypassTrackers(_PluginBase):
}]
"""
if self.get_state():
return [{
"id": "ToBypassTrackers",
"name": "绕过Trackers服务",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.update_ips,
"kwargs": {}
}]
return [
{
"id": "UpdateIPs",
"name": "更新IP列表",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.update_ips,
"kwargs": {}
},
{
"id": "GetTrackers",
"name": "更新Trackers",
"trigger": CronTrigger.from_crontab(self._sync_cron),
"func": self.refresh_trackers,
"kwargs": {}
}
]
return []
@eventmanager.register(EventType.PluginReload)
def reload(self, event):
"""
响应插件重载事件
"""
plugin_id = event.event_data.get("plugin_id")
if plugin_id == self.__class__.__name__:
Scheduler().update_plugin_job(plugin_id)
@property
def trackers(self) -> dict[str, list[str]]:
trackers: dict[str, list[str]] = {}
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())
else:
file = settings.ROOT_PATH / 'app' / 'plugins' / self.__class__.__name__.lower() / 'sites' / 'trackers'
with open(file, "r", encoding="utf-8") as f:
base64_str = f.read()
trackers = json.loads(base64.b64decode(base64_str).decode("utf-8"))
except Exception as e:
logger.error(f"trackers 加载错误:{e}")
return trackers
def refresh_trackers(self):
"""更新 Tracker 服务器列表"""
logger.info("开始从站点获取最新 Tracker 服务器 ...")
trackers = self.trackers
sites = [site for site in SiteOper().list_order_by_pri() if site.id in self._bypassed_sites]
torrents_chain = TorrentsChain()
for site in sites:
torrents = torrents_chain.browse(domain=site.domain)
if not torrents:
continue
torrent_url = torrents[0].enclosure
_, content, _, _, error_msg = TorrentHelper().download_torrent(
url=torrent_url,
cookie=site.cookie,
ua=site.ua or settings.USER_AGENT,
proxy=bool(site.proxy))
if not content or error_msg:
continue
try:
torrent = Torrent.from_string(content)
except BencodeDecodingError as e:
logger.error(f"解析 {site.name} 种子文件失败: {e}")
continue
servers: list[str] = []
for urls in torrent.announce_urls:
for url in urls:
parsed = urlparse(url)
if parsed.hostname:
servers.append(parsed.hostname)
if servers:
trackers[site.domain] = servers
tracker_file = Path(self.get_data_path() / "trackers.json")
tracker_file.write_text(json.dumps(trackers, indent=4))
logger.info("已更新 Tracker 服务器列表")
def bypassed_ips(self, protocol: Literal['4', '6']) -> Response:
data_key = "ipv4_txt" if protocol == '4' else "ipv6_txt"
data = self.get_data(data_key) or ""
@@ -696,14 +783,18 @@ class ToBypassTrackers(_PluginBase):
for ip in ip_list:
detail = bypassed.get(ip)
excluded_detail = excluded.get(ip)
if ip in excluded and excluded_detail is not None:
sub_message = f"{ip}"
if excluded_detail is not None:
detail_msg = '\n'.join(f"{k}: {v}" for k,v in excluded_detail.to_dict().items())
message += f"\nIP 地址 {ip} 在排除列表中,不会被绕过\n{detail_msg}\n"
elif ip in bypassed and detail is not None:
sub_message += f" 在排除列表中:\n{detail_msg}\n"
if detail is not None:
detail_msg = '\n'.join(f"{k}: {v}" for k,v in detail.to_dict().items())
message += f"\nIP 地址 {ip} 会被绕过\n{detail_msg}\n"
sub_message += f" 在绕过列表中\n{detail_msg}\n"
if detail and not excluded_detail:
sub_message += f"✈️ 会被绕过。\n"
else:
message += f"\nIP 地址 {ip} 不在绕过列表中\n"
sub_message += f"🛑 不会被绕过\n"
message += sub_message + "\n"
self.post_message(channel=channel, user=userid, text=message, title=f"{host}")
@overload
@@ -892,26 +983,26 @@ class ToBypassTrackers(_PluginBase):
timestamp=int(time.time())))
except ValueError:
exempted_domains.append(exempted_domain)
cidr_details_dict = {detail.ip_cidr: detail for detail in cidr_details}
asyncio.run(resolve_all(exempted_domains, exempted_ipv6, exempted_ip, exempted_cidr_details))
for ip in exempted_ip:
index = ToBypassTrackers._search_subnet(ip, ip_list)
if index == -1:
continue
subnet = ip_list[index]
ip_list.pop(index)
if subnet.prefixlen < 12:
new_subnet = IPv4Network((ip.network_address, subnet.prefixlen + 8), strict=False)
ip_list.extend(subnet.address_exclude(new_subnet))
while (index:= ToBypassTrackers._search_subnet(ip, ip_list)) != -1:
subnet = ip_list[index]
ip_list.pop(index)
source = cidr_details_dict[str(subnet)].domain if str(subnet) in cidr_details_dict else "CN"
logger.warn(f"Excluding subnet {subnet} ({source}) for exempted IP {ip}")
if subnet.prefixlen < 12:
new_subnet = IPv4Network((ip.network_address, subnet.prefixlen + 8), strict=False)
ip_list.extend(subnet.address_exclude(new_subnet))
for ip in exempted_ipv6:
index = ToBypassTrackers._search_subnet(ip, ipv6_list)
if index == -1:
continue
subnet = ipv6_list[index]
ipv6_list.pop(index)
if subnet.prefixlen < 32:
new_subnet = IPv6Network((ip.network_address, min(32, subnet.prefixlen + 8)), strict=False)
ipv6_list.extend(subnet.address_exclude(new_subnet))
while (index:=ToBypassTrackers._search_subnet(ip, ipv6_list)) != -1:
subnet = ipv6_list[index]
ipv6_list.pop(index)
source = cidr_details_dict[str(subnet)].domain if str(subnet) in cidr_details_dict else "CN"
logger.warn(f"Excluding subnet {subnet} ({source}) for exempted IP {ip}")
if subnet.prefixlen < 32:
new_subnet = IPv6Network((ip.network_address, min(32, subnet.prefixlen + 8)), strict=False)
ipv6_list.extend(subnet.address_exclude(new_subnet))
ipv4_txt = "\n".join(str(net) for net in ip_list)
ipv6_txt = "\n".join(str(net) for net in ipv6_list)
self.save_data("ipv4_txt", ipv4_txt)

View File

@@ -1 +1 @@
eyI1MnB0LnNpdGUiOiBbIjUycHQuc2l0ZSJdLCAiYXVkaWVuY2VzLm1lIjogWyJ0LmF1ZGllbmNlcy5tZSIsICJ0cmFja2VyLmNpbmVmaWxlcy5pbmZvIl0sICJidHNjaG9vbC5jbHViIjogWyJwdC5idHNjaG9vbC5jbHViIl0sICJieXIucHQiOiBbInRyYWNrZXIuYnlyLnB0Il0sICJjYXJwdC5uZXQiOiBbInRyYWNrZXIuY2FycHQubmV0Il0sICJjcmFicHQudmlwIjogWyJjcmFicHQudmlwIl0sICJjc3B0LnRvcCI6IFsidHJhY2tlci5jc3B0LnRvcCIsICJ0cmFja2VyLmNzcHQuY2MiLCAidHJhY2tlci5jc3B0LmRhdGUiXSwgImRpc2NmYW4ubmV0IjogWyJkaXNjZmFuLnh5eiJdLCAiZWFzdGdhbWUub3JnIjogWyJwdC5lYXN0Z2FtZS5vcmciXSwgImV0OC5vcmciOiBbImV0OC5vcmciLCAidC5ldDgub3JnIl0sICJnYW1lZ2FtZXB0LmNvbSI6IFsid3d3LmdhbWVnYW1lcHQuY29tIl0sICJoZGFyZWEuY2x1YiI6IFsidHJhY2tlci5oZGFyZWEuY2x1YiJdLCAiaGRkb2xieS5jb20iOiBbInQuaGRkb2xieS5jb20iXSwgImhkZmFucy5vcmciOiBbImhkZmFucy5vcmciXSwgImhka3lsLmluIjogWyJ0cmFja2VyLmhka3lsLmluIiwgInd3dy5oZGt5bGluLnRvcCJdLCAiaGR0aW1lLm9yZyI6IFsiaGR0aW1lLm9yZyJdLCAiaGl0cHQuY29tIjogWyJoaXRwdC5jb20iXSwgImh1ZGJ0Lmh1c3QuZWR1LmNuIjogWyJodWRidC5odXN0LmVkdS5jbiJdLCAiaWNjMjAyMi5jb20iOiBbInRyYWNrZXIuaWNjMjAyMi54eXoiXSwgImlsb2xpY29uLmNvbSI6IFsidHJhY2tlci5pbG9saWNvbi5jYyJdLCAia2VlcGZyZHMuY29tIjogWyJ0cmFja2VyLmtlZXBmcmRzLmNvbSJdLCAibS10ZWFtLmNjIjogWyJ0cmFja2VyLm0tdGVhbS5jYyIsICJ0cmFja2VyLm0tdGVhbS5pbyJdLCAibW9uaWthZGVzaWduLnVrIjogWyJ0cmFja2VyLm1vbmlrYWRlc2lnbi51ayIsICJkYWlraXJhaS5tb25pa2FkZXNpZ24udWsiLCAiYW5pbWUtbm8taW5kZXguY29tIl0sICJuaWNlcHQubmV0IjogWyJ3d3cubmljZXB0Lm5ldCJdLCAib2twdC5uZXQiOiBbInd3dy5va3B0Lm5ldCJdLCAicHRob21lLm5ldCI6IFsicHRob21lLm5ldCJdLCAicHRsZ3Mub3JnIjogWyJwdGwuZ3MiLCAicmVsYXkwMS5wdGwuZ3MiXSwgInB0c2Jhby5jbHViIjogWyJwdHNiYW8uY2x1YiJdLCAicHR0aW1lLm9yZyI6IFsid3d3LnB0dGltZS5vcmciXSwgInB0em9uZS54eXoiOiBbInB0em9uZS54eXoiXSwgInFpbmd3YXB0LmNvbSI6IFsidHJhY2tlci5xaW5nd2EucHJvIiwgInRyYWNrZXIucWluZ3dhcHQuY29tIiwgInRyYWNrZXIucWluZ3dhcHQub3JnIl0sICJyYWluZ2ZoLnRvcCI6IFsicmFpbmdmaC50b3AiXSwgInJvdXNpLnppcCI6IFsiaGl0cHQuY29tIl0sICJzcHJpbmdzdW5kYXkubmV0IjogWyJvbjYuc3ByaW5nc3VuZGF5Lm5ldCIsICJvbi5zcHJpbmdzdW5kYXkubmV0Il0sICJ0anVwdC5vcmciOiBbInRyYWNrZXItcHVibGljLnRqdXB0Lm9yZyJdLCAidG90aGVnbG9yeS5pbSI6IFsidHJhY2tlci50b3RoZWdsb3J5LmltIl0sICJ1Mi5kbWh5Lm9yZyI6IFsiZGF5ZHJlYW0uZG1oeS5iZXN0Il0sICJ4aW5neXVuZ2UudG9wIjogWyJ0cmFja2VyLnhpbmd5dW5nZS50b3AiLCAidHJhY2tlci54aW5neXVuZ2Uuc2JzIl0sICJ6bXB0LmNjIjogWyJ6bXB0LmNjIl0sICJoaGFuY2x1Yi50b3AiOiBbInRyYWNrZXIuaGhhbmNsdWIudG9wIl0sICJoZGNpdHkuY2l0eSI6IFsic3luYy5sZW5pdGVyLm9yZyJdfQ==
eyI1MnB0LnNpdGUiOiBbIjUycHQuc2l0ZSJdLCAiYXVkaWVuY2VzLm1lIjogWyJ0LmF1ZGllbmNlcy5tZSIsICJ0cmFja2VyLmNpbmVmaWxlcy5pbmZvIl0sICJidHNjaG9vbC5jbHViIjogWyJwdC5idHNjaG9vbC5jbHViIl0sICJieXIucHQiOiBbInRyYWNrZXIuYnlyLnB0Il0sICJjYXJwdC5uZXQiOiBbInRyYWNrZXIuY2FycHQubmV0Il0sICJjcmFicHQudmlwIjogWyJjcmFicHQudmlwIl0sICJjc3B0LnRvcCI6IFsidHJhY2tlci5jc3B0LnRvcCIsICJ0cmFja2VyLmNzcHQuY2MiLCAidHJhY2tlci5jc3B0LmRhdGUiXSwgImRpc2NmYW4ubmV0IjogWyJkaXNjZmFuLnh5eiJdLCAiZWFzdGdhbWUub3JnIjogWyJwdC5lYXN0Z2FtZS5vcmciXSwgImV0OC5vcmciOiBbImV0OC5vcmciLCAidC5ldDgub3JnIl0sICJnYW1lZ2FtZXB0LmNvbSI6IFsid3d3LmdhbWVnYW1lcHQuY29tIl0sICJoZGFyZWEuY2x1YiI6IFsidHJhY2tlci5oZGFyZWEuY2x1YiJdLCAiaGRkb2xieS5jb20iOiBbInQuaGRkb2xieS5jb20iXSwgImhkZmFucy5vcmciOiBbImhkZmFucy5vcmciXSwgImhka3lsLmluIjogWyJ0cmFja2VyLmhka3lsLmluIiwgInd3dy5oZGt5bGluLnRvcCJdLCAiaGR0aW1lLm9yZyI6IFsiaGR0aW1lLm9yZyJdLCAiaGl0cHQuY29tIjogWyJoaXRwdC5jb20iXSwgImh1ZGJ0Lmh1c3QuZWR1LmNuIjogWyJodWRidC5odXN0LmVkdS5jbiJdLCAiaWNjMjAyMi5jb20iOiBbInRyYWNrZXIuaWNjMjAyMi54eXoiXSwgImlsb2xpY29uLmNvbSI6IFsidHJhY2tlci5pbG9saWNvbi5jYyJdLCAia2VlcGZyZHMuY29tIjogWyJ0cmFja2VyLmtlZXBmcmRzLmNvbSJdLCAibS10ZWFtLmNjIjogWyJ0cmFja2VyLm0tdGVhbS5jYyIsICJ0cmFja2VyLm0tdGVhbS5pbyJdLCAibW9uaWthZGVzaWduLnVrIjogWyJ0cmFja2VyLm1vbmlrYWRlc2lnbi51ayIsICJkYWlraXJhaS5tb25pa2FkZXNpZ24udWsiLCAiYW5pbWUtbm8taW5kZXguY29tIl0sICJuaWNlcHQubmV0IjogWyJ3d3cubmljZXB0Lm5ldCJdLCAib2twdC5uZXQiOiBbInd3dy5va3B0Lm5ldCJdLCAicHRob21lLm5ldCI6IFsicHRob21lLm5ldCJdLCAicHRsZ3Mub3JnIjogWyJwdGwuZ3MiLCAicmVsYXkwMS5wdGwuZ3MiXSwgInB0c2Jhby5jbHViIjogWyJwdHNiYW8uY2x1YiJdLCAicHR0aW1lLm9yZyI6IFsid3d3LnB0dGltZS5vcmciXSwgInB0em9uZS54eXoiOiBbInB0em9uZS54eXoiXSwgInFpbmd3YXB0LmNvbSI6IFsidHJhY2tlci5xaW5nd2EucHJvIiwgInRyYWNrZXIucWluZ3dhcHQuY29tIiwgInRyYWNrZXIucWluZ3dhcHQub3JnIl0sICJyYWluZ2ZoLnRvcCI6IFsicmFpbmdmaC50b3AiXSwgInJvdXNpLnppcCI6IFsiaGl0cHQuY29tIl0sICJzcHJpbmdzdW5kYXkubmV0IjogWyJvbjYuc3ByaW5nc3VuZGF5Lm5ldCIsICJvbi5zcHJpbmdzdW5kYXkubmV0Il0sICJ0anVwdC5vcmciOiBbInRyYWNrZXItcHVibGljLnRqdXB0Lm9yZyJdLCAidG90aGVnbG9yeS5pbSI6IFsidHJhY2tlci50b3RoZWdsb3J5LmltIl0sICJ1Mi5kbWh5Lm9yZyI6IFsiZGF5ZHJlYW0uZG1oeS5iZXN0Il0sICJ4aW5neXVuZ2UudG9wIjogWyJ0cmFja2VyLnhpbmd5dW5nZS50b3AiLCAidHJhY2tlci54aW5neXVuZ2Uuc2JzIl0sICJ6bXB0LmNjIjogWyJ6bXB0LmNjIl0sICJoaGFuY2x1Yi50b3AiOiBbInRyYWNrZXIuaGhhbmNsdWIudG9wIl0sICJoZGNpdHkuY2l0eSI6IFsic3luYy5sZW5pdGVyLm9yZyJdLCAib3VyYml0cy5jbHViIjogWyJvdXJiaXRzLmNsdWIiXX0=

View File

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

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

@@ -50,14 +50,14 @@ class DoubanRank(_PluginBase):
mediachain: MediaChain = None
_scheduler = None
_douban_address = {
'movie-ustop': 'https://rsshub.app/douban/movie/ustop',
'movie-weekly': 'https://rsshub.app/douban/movie/weekly',
'movie-real-time': 'https://rsshub.app/douban/movie/weekly/movie_real_time_hotest',
'show-domestic': 'https://rsshub.app/douban/movie/weekly/show_domestic',
'movie-hot-gaia': 'https://rsshub.app/douban/movie/weekly/movie_hot_gaia',
'tv-hot': 'https://rsshub.app/douban/movie/weekly/tv_hot',
'movie-top250': 'https://rsshub.app/douban/movie/weekly/movie_top250',
'movie-top250-full': 'https://rsshub.app/douban/list/movie_top250',
'movie-ustop': '/douban/movie/ustop',
'movie-weekly': '/douban/movie/weekly',
'movie-real-time': '/douban/movie/weekly/movie_real_time_hotest',
'show-domestic': '/douban/movie/weekly/show_domestic',
'movie-hot-gaia': '/douban/movie/weekly/movie_hot_gaia',
'tv-hot': '/douban/movie/weekly/tv_hot',
'movie-top250': '/douban/movie/weekly/movie_top250',
'movie-top250-full': '/douban/list/movie_top250',
}
_enabled = False
_cron = ""
@@ -68,6 +68,7 @@ class DoubanRank(_PluginBase):
_clear = False
_clearflag = False
_proxy = False
_rsshub = "https://rsshub.app"
def init_plugin(self, config: dict = None):
self.downloadchain = DownloadChain()
@@ -80,6 +81,7 @@ class DoubanRank(_PluginBase):
self._proxy = config.get("proxy")
self._onlyonce = config.get("onlyonce")
self._vote = float(config.get("vote")) if config.get("vote") else 0
self._rsshub = config.get("rsshub") or "https://rsshub.app"
rss_addrs = config.get("rss_addrs")
if rss_addrs:
if isinstance(rss_addrs, str):
@@ -243,7 +245,7 @@ class DoubanRank(_PluginBase):
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
'md': 4
},
'content': [
{
@@ -260,7 +262,7 @@ class DoubanRank(_PluginBase):
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
'md': 4
},
'content': [
{
@@ -272,6 +274,23 @@ class DoubanRank(_PluginBase):
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'rsshub',
'label': 'RSSHub地址',
'placeholder': 'https://rsshub.app'
}
}
]
}
]
},
@@ -351,6 +370,7 @@ class DoubanRank(_PluginBase):
"proxy": False,
"onlyonce": False,
"vote": "",
"rsshub": "https://rsshub.app",
"ranks": [],
"rss_addrs": "",
"clear": False
@@ -514,6 +534,7 @@ class DoubanRank(_PluginBase):
"cron": self._cron,
"onlyonce": self._onlyonce,
"vote": self._vote,
"rsshub": self._rsshub,
"ranks": self._ranks,
"rss_addrs": '\n'.join(map(str, self._rss_addrs)),
"clear": self._clear
@@ -524,7 +545,10 @@ class DoubanRank(_PluginBase):
刷新RSS
"""
logger.info(f"开始刷新豆瓣榜单 ...")
addr_list = self._rss_addrs + [self._douban_address.get(rank) for rank in self._ranks]
# 构建完整的RSS地址
rsshub_base = self._rsshub.rstrip('/')
rank_addrs = [f"{rsshub_base}{self._douban_address.get(rank)}" for rank in self._ranks if self._douban_address.get(rank)]
addr_list = self._rss_addrs + rank_addrs
if not addr_list:
logger.info(f"未设置榜单RSS地址")
return

View File

@@ -23,7 +23,7 @@ class InvitesSignin(_PluginBase):
# 插件图标
plugin_icon = "invites.png"
# 插件版本
plugin_version = "2.0.0"
plugin_version = "2.0.2"
# 插件作者
plugin_author = "thsrite"
# 作者主页
@@ -42,13 +42,15 @@ class InvitesSignin(_PluginBase):
_cookie = None
_onlyonce = False
_notify = False
# 代理相关
_use_proxy = True # 是否使用代理,默认启用
_history_days = None
_username = None
_user_password = None
_retry_count = 2
_retry_interval = 5
# User-Agent 字符串常量
_user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36"
_user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0"
# 定时器
_scheduler: Optional[BackgroundScheduler] = None
@@ -63,6 +65,7 @@ class InvitesSignin(_PluginBase):
self._cookie = config.get("cookie")
self._notify = config.get("notify")
self._onlyonce = config.get("onlyonce")
self._use_proxy = config.get("use_proxy", True)
self._history_days = int(config.get("history_days") or 30)
self._username = config.get("username")
self._user_password = config.get("user_password")
@@ -83,6 +86,7 @@ class InvitesSignin(_PluginBase):
"enabled": self._enabled,
"cookie": self._cookie,
"notify": self._notify,
"use_proxy": self._use_proxy,
"history_days": self._history_days,
"username": self._username,
"user_password": self._user_password,
@@ -95,23 +99,53 @@ class InvitesSignin(_PluginBase):
self._scheduler.print_jobs()
self._scheduler.start()
def __get_proxies(self):
"""
获取代理设置
"""
if not self._use_proxy:
logger.debug("未启用代理")
return None
try:
# 获取系统代理设置
if hasattr(settings, 'PROXY') and settings.PROXY:
logger.debug(f"使用系统代理: {settings.PROXY}")
return settings.PROXY
else:
logger.debug("系统代理未配置")
return None
except Exception as e:
logger.error(f"获取代理设置出错: {str(e)}")
return None
def __get_new_session(self, flarum_remember: str) -> str:
"""获取新的session"""
headers = {
"Cookie": f"flarum_remember={flarum_remember}",
"User-Agent": self._user_agent
"User-Agent": self._user_agent,
"Upgrade-Insecure-Requests": "1",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
}
response = RequestUtils(headers=headers).get_res(url="https://invites.fun", allow_redirects=False)
# 获取代理
proxies = self.__get_proxies()
# 尝试获取新session禁止重定向以便捕获Set-Cookie
response = RequestUtils(headers=headers, proxies=proxies).get_res(url="https://invites.fun", allow_redirects=False)
if not response:
return None
# 从Set-Cookie响应头中提取新的flarum_session
# 1. 优先尝试从 response.cookies 中获取 (requests 自动处理)
if response.cookies.get('flarum_session'):
return response.cookies.get('flarum_session')
# 2. 作为备用,尝试从 Set-Cookie 响应头中提取
cookies = response.headers.get('Set-Cookie', '')
session_match = re.search(r'flarum_session=([^;]+)', cookies)
if session_match:
return session_match.group(1)
return None
def __get_remember_value(self, cookie: str) -> str:
@@ -146,12 +180,16 @@ class InvitesSignin(_PluginBase):
try:
# 第一步获取初始session和csrf token
headers_get = {
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'accept-language': 'zh-CN,zh;q=0.9',
'user-agent': self._user_agent
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'zh-CN,zh;q=0.9',
'User-Agent': self._user_agent,
'Upgrade-Insecure-Requests': '1'
}
response_get = RequestUtils(headers=headers_get).get_res('https://invites.fun/')
# 获取代理
proxies = self.__get_proxies()
response_get = RequestUtils(headers=headers_get, proxies=proxies).get_res('https://invites.fun/')
if not response_get or response_get.status_code != 200:
logger.error("获取初始session失败")
return {"success": False, "error": "获取初始session失败"}
@@ -177,12 +215,12 @@ class InvitesSignin(_PluginBase):
}
headers_login = {
'accept': '*/*',
'content-type': 'application/json; charset=UTF-8',
'origin': 'https://invites.fun',
'referer': 'https://invites.fun/',
'Accept': '*/*',
'Content-Type': 'application/json; charset=UTF-8',
'Origin': 'https://invites.fun',
'Referer': 'https://invites.fun/',
'x-csrf-token': csrf_token,
'user-agent': headers_get['user-agent']
'User-Agent': self._user_agent
}
json_data_login = {
@@ -191,7 +229,7 @@ class InvitesSignin(_PluginBase):
'remember': True,
}
login_response = RequestUtils(cookies=cookies_login, headers=headers_login).post_res(
login_response = RequestUtils(cookies=cookies_login, headers=headers_login, proxies=proxies).post_res(
'https://invites.fun/login',
json=json_data_login
)
@@ -236,8 +274,76 @@ class InvitesSignin(_PluginBase):
logger.error(f"登录过程中发生异常: {e}")
return {"success": False, "error": f"登录异常: {e}"}
def __update_cookie_if_changed(self, new_cookie_str: str):
"""
检查Cookie是否发生变化如果有变化则更新配置
"""
try:
if not new_cookie_str:
return
# 解析新Cookie
new_cookies = self.__parse_cookie_string(new_cookie_str)
new_remember = new_cookies.get('flarum_remember')
new_session = new_cookies.get('flarum_session')
if not new_remember or not new_session:
return
# 解析旧Cookie
old_cookies = self.__parse_cookie_string(self._cookie or "")
old_remember = old_cookies.get('flarum_remember')
old_session = old_cookies.get('flarum_session')
# 对比是否变化
if new_remember != old_remember or new_session != old_session:
# 构造标准格式的Cookie字符串
final_cookie = f"flarum_remember={new_remember}; flarum_session={new_session}"
logger.info(f"Cookie已更新保存新配置")
# 更新内存中的配置
self._cookie = final_cookie
# 更新持久化配置
self.update_config({
"onlyonce": self._onlyonce,
"cron": self._cron,
"enabled": self._enabled,
"cookie": self._cookie,
"notify": self._notify,
"use_proxy": self._use_proxy,
"history_days": self._history_days,
"username": self._username,
"user_password": self._user_password,
"retry_count": self._retry_count,
"retry_interval": self._retry_interval
})
else:
logger.debug("Cookie未发生变化无需更新")
except Exception as e:
logger.error(f"更新Cookie配置失败: {e}")
def __signin(self):
"""药丸签到"""
# 1. 检查今日是否已签到
try:
history = self.get_data('history') or []
if history:
# 按时间倒序排序
history = sorted(history, key=lambda x: x.get("date") or "", reverse=True)
last_checkin = history[0]
last_date = last_checkin.get("date", "")
if last_date:
# 获取今日日期字符串 YYYY-MM-DD
today_str = datetime.now().strftime('%Y-%m-%d')
if last_date.startswith(today_str):
logger.info(f"今日已签到 ({last_date}),跳过本次任务")
return
except Exception as e:
logger.warning(f"检查签到历史失败: {e}")
for attempt in range(self._retry_count):
logger.info(f"开始第 {attempt + 1} 次签到尝试")
@@ -301,8 +407,11 @@ class InvitesSignin(_PluginBase):
new_cookie = f"flarum_remember={flarum_remember}; flarum_session={new_session}"
logger.info("成功刷新session")
# 获取代理
proxies = self.__get_proxies()
# 4. 使用新cookie获取csrfToken和userId
res = RequestUtils(cookies=new_cookie).get_res(url="https://invites.fun")
res = RequestUtils(cookies=new_cookie, proxies=proxies).get_res(url="https://invites.fun")
if not res or res.status_code != 200:
logger.error("请求药丸错误")
return False
@@ -329,7 +438,13 @@ class InvitesSignin(_PluginBase):
return False
# 执行签到
return self.__perform_checkin(userId, new_cookie, csrfToken)
result = self.__perform_checkin(userId, new_cookie, csrfToken)
# 如果签到成功尝试更新Cookie
if result:
self.__update_cookie_if_changed(new_cookie)
return result
except Exception as e:
logger.error(f"Cookie签到过程中发生异常: {e}")
@@ -353,12 +468,18 @@ class InvitesSignin(_PluginBase):
cookie_str = f"flarum_remember={login_result['flarum_remember']}; flarum_session={login_result['flarum_session']}"
# 执行签到
return self.__perform_checkin(
result = self.__perform_checkin(
login_result['user_id'],
cookie_str,
login_result['csrf_token']
)
# 如果签到成功尝试更新Cookie
if result:
self.__update_cookie_if_changed(cookie_str)
return result
except Exception as e:
logger.error(f"登录签到过程中发生异常: {e}")
return False
@@ -397,9 +518,12 @@ class InvitesSignin(_PluginBase):
logger.error("cookie中缺少必要的flarum_remember或flarum_session值")
return False
# 获取代理
proxies = self.__get_proxies()
# 执行签到请求
checkin_url = f'https://invites.fun/api/users/{user_id}'
response = RequestUtils(cookies=cookies, headers=headers).post_res(
response = RequestUtils(cookies=cookies, headers=headers, proxies=proxies).post_res(
checkin_url,
json=json_data
)
@@ -512,9 +636,10 @@ class InvitesSignin(_PluginBase):
{'component': 'VDivider'},
{'component': 'VCardText', 'content': [
{'component': 'VRow', 'content': [
{'component': 'VCol', 'props': {'cols': 12, 'md': 4}, 'content': [{'component': 'VSwitch', 'props': {'model': 'enabled', 'label': '启用插件', 'color': 'primary'}}]},
{'component': 'VCol', 'props': {'cols': 12, 'md': 4}, 'content': [{'component': 'VSwitch', 'props': {'model': 'notify', 'label': '开启通知', 'color': 'info'}}]},
{'component': 'VCol', 'props': {'cols': 12, 'md': 4}, 'content': [{'component': 'VSwitch', 'props': {'model': 'onlyonce', 'label': '立即运行一次', 'color': 'success'}}]},
{'component': 'VCol', 'props': {'cols': 12, 'md': 3}, 'content': [{'component': 'VSwitch', 'props': {'model': 'enabled', 'label': '启用插件', 'color': 'primary'}}]},
{'component': 'VCol', 'props': {'cols': 12, 'md': 3}, 'content': [{'component': 'VSwitch', 'props': {'model': 'use_proxy', 'label': '使用代理', 'color': 'warning'}}]},
{'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'}}]},
]},
]}
]
@@ -534,7 +659,7 @@ class InvitesSignin(_PluginBase):
{'component': 'VCol', 'props': {'cols': 12, 'md': 3}, 'content': [
{'component': 'VTextField', 'props': {
'model': 'username',
'label': '药丸用户名',
'label': '用户名',
'placeholder': '请输入用户名',
'prepend-inner-icon': 'mdi-account',
'autocomplete': 'new-username',
@@ -545,8 +670,8 @@ class InvitesSignin(_PluginBase):
{'component': 'VCol', 'props': {'cols': 12, 'md': 3}, 'content': [
{'component': 'VTextField', 'props': {
'model': 'user_password',
'label': '药丸密码',
'placeholder': '请输入药丸密码',
'label': '密码',
'placeholder': '请输入密码',
'prepend-inner-icon': 'mdi-lock',
'type': 'password',
'autocomplete': 'new-password',
@@ -576,7 +701,7 @@ class InvitesSignin(_PluginBase):
{'component': 'VCol', 'props': {'cols': 12, 'md': 6}, 'content': [
{'component': 'VTextField', 'props': {
'model': 'cookie',
'label': '药丸Cookie',
'label': 'Cookie',
'placeholder': '需要包含 flarum_remember 值',
'prepend-inner-icon': 'mdi-cookie',
'type': 'password',
@@ -670,6 +795,7 @@ class InvitesSignin(_PluginBase):
"enabled": False,
"onlyonce": False,
"notify": False,
"use_proxy": True,
"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.4"
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
@@ -151,7 +152,7 @@ class MoviePilotUpdateNotify(_PluginBase):
if version_res:
ver_json = version_res.json()
version = f"{ver_json['tag_name']}"
description = f"{ver_json['body']}"
description = f"{ver_json['body'] or ''}"
update_time = f"{ver_json['published_at']}"
return version, description, update_time
else:
@@ -167,7 +168,7 @@ class MoviePilotUpdateNotify(_PluginBase):
if version_res:
ver_json = version_res.json()
version = f"{ver_json['tag_name']}"
description = f"{ver_json['body']}"
description = f"{ver_json['body'] or ''}"
update_time = f"{ver_json['published_at']}"
return version, description, update_time
else:

View File

@@ -16,7 +16,7 @@ class NtfyClient:
headers = {
"Title": title.encode(encoding='utf-8'),
"Markdown": "true" if format_as_markdown else "false",
"Icon": "https://movie-pilot.org/images/logo.png",
"Icon": "https://cdn.jsdelivr.net/gh/jxxghp/MoviePilot-Frontend@v2/public/logo.png",
}
if self._token:
@@ -62,7 +62,7 @@ class NtfyMsg(_PluginBase):
# 插件图标
plugin_icon = "Ntfy_A.png"
# 插件版本
plugin_version = "1.1"
plugin_version = "1.3"
# 插件作者
plugin_author = "lethargicScribe"
# 作者主页
@@ -353,9 +353,9 @@ class NtfyMsg(_PluginBase):
# 类型
msg_type: NotificationType = msg_body.get("type")
# 标题
title = msg_body.get("title")
title = msg_body.get("title") or "\u200b"
# 文本
text = msg_body.get("text")
text = msg_body.get("text") or "\u200b"
if not title and not text:
logger.warn("标题和内容不能同时为空")