mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-06-13 07:26:50 +00:00
Compare commits
162 Commits
ClashRuleP
...
AutoSignIn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ae826cf14 | ||
|
|
f438490ca5 | ||
|
|
b938ca5bf3 | ||
|
|
028103b900 | ||
|
|
bb1f159198 | ||
|
|
6fa42abc17 | ||
|
|
95b952c27f | ||
|
|
6631d06a04 | ||
|
|
1afce8c607 | ||
|
|
82c825e349 | ||
|
|
ff7d7b1fa4 | ||
|
|
328ed9884a | ||
|
|
4d1b90abc8 | ||
|
|
c5afdfc2da | ||
|
|
fdbd5ad501 | ||
|
|
d66605ae99 | ||
|
|
145e9747a9 | ||
|
|
87e4dcd211 | ||
|
|
633c8bad97 | ||
|
|
0927d0388a | ||
|
|
323289aa74 | ||
|
|
1f80e3b078 | ||
|
|
0ac725383e | ||
|
|
659f4f2b0d | ||
|
|
d65979323e | ||
|
|
d2503648a9 | ||
|
|
fffad33cc5 | ||
|
|
ae99671190 | ||
|
|
528b938f0f | ||
|
|
722f8da96d | ||
|
|
c53a3dc152 | ||
|
|
e29f59c28c | ||
|
|
c2c1320b18 | ||
|
|
e15733b7de | ||
|
|
02a2518fce | ||
|
|
861f416aad | ||
|
|
17cf85c1c1 | ||
|
|
6dbf539d88 | ||
|
|
24b9c2ec29 | ||
|
|
9a8e939414 | ||
|
|
a6b5286bf9 | ||
|
|
490c740c54 | ||
|
|
39d64a1cf4 | ||
|
|
a0272dfcaf | ||
|
|
44d3db72b4 | ||
|
|
48b5d1018e | ||
|
|
738e224ba3 | ||
|
|
6f2a0b2213 | ||
|
|
c2ccdf2b8e | ||
|
|
adb6230eea | ||
|
|
aa89750d1f | ||
|
|
4ca2d14076 | ||
|
|
8bd590e1ea | ||
|
|
d7effcd625 | ||
|
|
a7b830e4fd | ||
|
|
5b8f5b406f | ||
|
|
69b430bdc3 | ||
|
|
00d3346dfc | ||
|
|
7452540a93 | ||
|
|
d98902e536 | ||
|
|
5ecefb4a41 | ||
|
|
814149e0f3 | ||
|
|
d306145a14 | ||
|
|
da72e1b252 | ||
|
|
b6fc76cdb7 | ||
|
|
7842375d11 | ||
|
|
f6d83a5d31 | ||
|
|
97b8e7028a | ||
|
|
cc6cc55ad0 | ||
|
|
52063367f8 | ||
|
|
0003e4382b | ||
|
|
e2cbe22e8d | ||
|
|
436983e49e | ||
|
|
8829414a47 | ||
|
|
9f46c829db | ||
|
|
0de6531aed | ||
|
|
a5a96b74e3 | ||
|
|
f7b1a027f5 | ||
|
|
bde04fd7e1 | ||
|
|
af38909f58 | ||
|
|
5ccd80c4f1 | ||
|
|
ebf407b8b2 | ||
|
|
d0be1feec5 | ||
|
|
02fbbc87b4 | ||
|
|
ce1804cd0f | ||
|
|
53da73f11e | ||
|
|
fb3d8e9c0d | ||
|
|
5039a94bbf | ||
|
|
3ae993050b | ||
|
|
0dddb4675f | ||
|
|
56abaaf31c | ||
|
|
900f4fec95 | ||
|
|
88688672db | ||
|
|
cc6b95e5a1 | ||
|
|
377808f3da | ||
|
|
1d5e44e02c | ||
|
|
ff9c35041e | ||
|
|
d9afb64d00 | ||
|
|
6d60123272 | ||
|
|
84fcc3762f | ||
|
|
77b34dba5c | ||
|
|
4d8f36f674 | ||
|
|
5ccbb412eb | ||
|
|
4a0c700e6b | ||
|
|
00c65a0983 | ||
|
|
b961a52440 | ||
|
|
707feedda2 | ||
|
|
07c6ee1341 | ||
|
|
fd360cf21d | ||
|
|
a267df9e5d | ||
|
|
8feecbcb42 | ||
|
|
4224939f30 | ||
|
|
234ceba60c | ||
|
|
5c8a6647e2 | ||
|
|
5b763dff42 | ||
|
|
ee453841df | ||
|
|
6768d2c244 | ||
|
|
cb14efcc68 | ||
|
|
7871dfd0b8 | ||
|
|
99d1bfe37e | ||
|
|
b65c1b8bf7 | ||
|
|
517a16f0a3 | ||
|
|
89bfb9750d | ||
|
|
01eac66a6a | ||
|
|
cd53b8d454 | ||
|
|
d986f45634 | ||
|
|
0ceb633d96 | ||
|
|
2965743cfe | ||
|
|
9fa02d62e2 | ||
|
|
b2bd0f3701 | ||
|
|
de0e83f830 | ||
|
|
94b6df246e | ||
|
|
6b895919a0 | ||
|
|
a9830202e8 | ||
|
|
e96eece117 | ||
|
|
107b8e408f | ||
|
|
6629aeadef | ||
|
|
b0e5680260 | ||
|
|
a322274d77 | ||
|
|
be2289739a | ||
|
|
7536a8782e | ||
|
|
4d71a24fbc | ||
|
|
85ac9dd393 | ||
|
|
75c65b96d4 | ||
|
|
7d8433b768 | ||
|
|
d66413dd7a | ||
|
|
a0c9afc3ed | ||
|
|
e0c39170e6 | ||
|
|
8e199afe24 | ||
|
|
e68d915f36 | ||
|
|
b3e78c3e5e | ||
|
|
f02b90552b | ||
|
|
e93bfc6667 | ||
|
|
131463cfbe | ||
|
|
b963398987 | ||
|
|
ed395a26a9 | ||
|
|
03a2b35930 | ||
|
|
5a642e1e51 | ||
|
|
a8813b0272 | ||
|
|
66ce816a31 | ||
|
|
241e3200f8 | ||
|
|
19f52d6217 |
102
README.md
102
README.md
@@ -24,6 +24,7 @@ MoviePilot官方插件市场:https://github.com/jxxghp/MoviePilot-Plugins
|
||||
- [13. 如何将插件功能集成到工作流?](#13-如何将插件功能集成到工作流)
|
||||
- [14. 如何在插件中通过消息持续与用户交互?](#14-如何在插件中通过消息持续与用户交互)
|
||||
- [15. 如何在插件中使用系统级统一缓存?](#15-如何在插件中使用系统级统一缓存)
|
||||
- [16. 如何在插件中注册智能体工具?](#16-如何在插件中注册智能体工具)
|
||||
- [版本发布](#版本发布)
|
||||
- [1. 如何发布插件版本?](#1-如何发布插件版本)
|
||||
- [2. 如何开发V2版本的插件以及实现插件多版本兼容?](#2-如何开发v2版本的插件以及实现插件多版本兼容)
|
||||
@@ -1352,6 +1353,107 @@ def get_actions(self) -> List[Dict[str, Any]]:
|
||||
- 大文件或二进制数据建议使用文件缓存后端
|
||||
- 在插件卸载时清理相关缓存,避免内存泄漏
|
||||
|
||||
### 16. 如何在插件中注册智能体工具?
|
||||
**(仅支持 `v2.8.0+` 版本)**
|
||||
- MoviePilot的AI智能体功能支持通过插件扩展工具能力,插件可以注册自定义工具供智能体调用,实现更丰富的功能扩展。
|
||||
- 1. 实现 `get_agent_tools()` 方法,返回工具类列表:
|
||||
```python
|
||||
def get_agent_tools(self) -> List[Type]:
|
||||
"""
|
||||
获取插件智能体工具
|
||||
返回工具类列表,每个工具类必须继承自 MoviePilotTool
|
||||
"""
|
||||
return [MyCustomTool, AnotherTool]
|
||||
```
|
||||
|
||||
- 2. 创建工具类,必须继承自 `MoviePilotTool` 并实现相关要求:
|
||||
```python
|
||||
from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.chain import ToolChain
|
||||
|
||||
class MyToolInput(BaseModel):
|
||||
"""工具输入参数模型"""
|
||||
explanation: str = Field(..., description="工具使用说明")
|
||||
query: str = Field(..., description="查询内容")
|
||||
limit: Optional[int] = Field(10, description="返回结果数量限制")
|
||||
|
||||
class MyCustomTool(MoviePilotTool):
|
||||
"""自定义工具示例"""
|
||||
# 工具名称,用于智能体识别和调用
|
||||
name: str = "my_custom_tool"
|
||||
|
||||
# 工具描述,用于智能体理解工具功能,建议详细描述工具用途和使用场景
|
||||
description: str = "This tool is used to perform custom operations. Use it when you need to query or process specific data."
|
||||
|
||||
# 输入参数模型,定义工具接收的参数及其类型和说明
|
||||
args_schema: Type[BaseModel] = MyToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据订阅参数生成友好的提示消息"""
|
||||
pass
|
||||
|
||||
async def run(self, query: str, limit: Optional[int] = None, **kwargs) -> str:
|
||||
"""
|
||||
实现工具的核心逻辑(异步方法)
|
||||
:param query: 查询内容
|
||||
:param limit: 结果数量限制
|
||||
:param kwargs: 其他参数,包含 explanation(工具使用说明)
|
||||
:return: 工具执行结果,返回字符串格式
|
||||
"""
|
||||
try:
|
||||
# 获取上下文信息(系统自动注入)
|
||||
session_id = self._session_id
|
||||
user_id = self._user_id
|
||||
channel = self._channel
|
||||
source = self._source
|
||||
username = self._username
|
||||
|
||||
# 执行工具逻辑
|
||||
result = await self._perform_operation(query, limit)
|
||||
|
||||
# 可以通过 send_tool_message 发送消息给用户
|
||||
await self.send_tool_message(f"操作完成: {result}", title="工具执行")
|
||||
|
||||
# 返回执行结果
|
||||
return f"成功处理查询 '{query}',返回 {len(result)} 条结果"
|
||||
except Exception as e:
|
||||
return f"执行失败: {str(e)}"
|
||||
|
||||
async def _perform_operation(self, query: str, limit: int):
|
||||
"""内部方法,执行具体操作"""
|
||||
# 实现具体业务逻辑
|
||||
pass
|
||||
```
|
||||
|
||||
- 3. 工具类可用的上下文属性和方法:
|
||||
- `self._session_id`: 当前会话ID
|
||||
- `self._user_id`: 用户ID
|
||||
- `self._channel`: 消息渠道(如 Telegram、Slack 等)
|
||||
- `self._source`: 消息来源
|
||||
- `self._username`: 用户名
|
||||
- `self.send_tool_message(message: str, title: str = "")`: 发送消息给用户
|
||||
- `ToolChain()`: 访问处理链功能,可调用系统其他功能
|
||||
|
||||
- 4. 工具类实现要求:
|
||||
- **必须继承自 `app.agent.tools.base.MoviePilotTool`**
|
||||
- **必须实现 `run` 方法**(异步方法),接收参数并返回字符串结果
|
||||
- **必须实现 `get_tool_message` 方法**,以显示友好的工具执行提示给用户
|
||||
- **必须定义 `name` 属性**(字符串),工具的唯一标识
|
||||
- **必须定义 `description` 属性**(字符串),详细描述工具功能,帮助智能体理解何时使用该工具
|
||||
- **可选定义 `args_schema` 属性**(Pydantic模型类),用于定义输入参数的结构和验证
|
||||
|
||||
- 5. 注意事项:
|
||||
- 工具的描述(`description`)应该清晰明确,帮助智能体理解工具的功能和使用场景
|
||||
- 工具的参数模型(`args_schema`)应该包含详细的字段描述,帮助智能体正确构造参数
|
||||
- 工具执行结果应该返回有意义的字符串,便于智能体理解和向用户展示
|
||||
- 工具可以通过 `send_tool_message` 方法向用户发送实时消息,提升交互体验
|
||||
- 工具类在初始化时会自动注入会话和用户信息,可以通过私有属性访问
|
||||
- 如果工具需要访问插件实例,需要自行通过 `PluginManager` 获取
|
||||
- 工具执行时间应该尽量短,避免阻塞智能体的响应
|
||||
- 建议在工具执行过程中添加适当的错误处理和日志记录
|
||||
|
||||
|
||||
## 版本发布
|
||||
|
||||
|
||||
28
package.json
28
package.json
@@ -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": {
|
||||
@@ -174,11 +175,12 @@
|
||||
"name": "媒体文件同步删除",
|
||||
"description": "同步删除历史记录、源文件和下载任务。",
|
||||
"labels": "文件整理",
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.2",
|
||||
"icon": "mediasyncdel.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.7.2": "兼容windows路径",
|
||||
"v1.7.1": "修复删除剧集辅种失败报错问题",
|
||||
"v1.7": "修复重新整理被一并删除问题",
|
||||
"v1.6": "修复删除辅种",
|
||||
@@ -320,11 +322,12 @@
|
||||
"name": "IYUU自动辅种",
|
||||
"description": "基于IYUU官方Api实现自动辅种。",
|
||||
"labels": "做种,IYUU",
|
||||
"version": "1.9.11",
|
||||
"version": "1.9.12",
|
||||
"icon": "IYUU.png",
|
||||
"author": "jxxghp",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.9.12": "修复海豹不能辅种的问题",
|
||||
"v1.9.11": "修复馒头不能辅种的问题",
|
||||
"v1.9.10": "Revert 辅种结束后,一起开始所有辅种后暂停的种子(排除了出错的种子)",
|
||||
"v1.9.9": "修复qb辅种结束后自动开始暂停的种子",
|
||||
@@ -464,12 +467,16 @@
|
||||
"name": "药丸签到",
|
||||
"description": "药丸论坛签到。",
|
||||
"labels": "站点",
|
||||
"version": "1.4.1",
|
||||
"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": "自定义保留消息天数"
|
||||
}
|
||||
@@ -492,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+版本"
|
||||
}
|
||||
@@ -804,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": {
|
||||
@@ -841,7 +852,6 @@
|
||||
"icon": "Macos_Sierra.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.4.1": "修复Bing壁纸命名问题",
|
||||
"v1.3": "适配MoviePilot v2.5.3+版本",
|
||||
|
||||
@@ -24,11 +24,13 @@
|
||||
"name": "站点刷流",
|
||||
"description": "自动托管刷流,将会提高对应站点的访问频率。",
|
||||
"labels": "刷流,仪表板",
|
||||
"version": "4.3.3",
|
||||
"version": "4.3.5",
|
||||
"icon": "brush.jpg",
|
||||
"author": "jxxghp,InfinityPacer",
|
||||
"author": "jxxghp,InfinityPacer,Seed680",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v4.3.5": "提升匹配规则时的健壮性",
|
||||
"v4.3.4": "添加RSS支持配置选项",
|
||||
"v4.3.2": "增加'删除促销结束的未完成下载'功能",
|
||||
"v4.3.1": "修复了一些细节问题",
|
||||
"v4.3": "支持带宽采样并计算平均值,以优化刷流效率",
|
||||
@@ -42,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": "增加保号风险提示",
|
||||
@@ -61,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 版本下载任务分类与标签插件"
|
||||
}
|
||||
},
|
||||
@@ -88,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 版本媒体库服务器通知插件"
|
||||
@@ -102,11 +117,12 @@
|
||||
"name": "ChatGPT",
|
||||
"description": "消息交互支持与ChatGPT对话。",
|
||||
"labels": "消息通知,识别",
|
||||
"version": "2.1.7",
|
||||
"version": "2.1.8",
|
||||
"icon": "Chatgpt_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.1.8": "修复 OpenAI API >=1.0.0 兼容性问题",
|
||||
"v2.1.7":"独立安装OpenAi SDK依赖",
|
||||
"v2.1.6": "支持自定义辅助识别提示词",
|
||||
"v2.1.5": "兼容一些模型返回json数据信息用markdown语法包裹的情况",
|
||||
@@ -243,11 +259,12 @@
|
||||
"name": "IYUU自动辅种",
|
||||
"description": "基于IYUU官方Api实现自动辅种。",
|
||||
"labels": "做种,IYUU",
|
||||
"version": "2.14",
|
||||
"version": "2.15",
|
||||
"icon": "IYUU.png",
|
||||
"author": "jxxghp,CKun",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v2.15": "修复海豹不能辅种的问题",
|
||||
"v2.14": "修复馒头不能辅种的问题",
|
||||
"v2.13": "开启跳过校验后需手动开启自动开始",
|
||||
"v2.12": "增加qb下载器分类复用配置",
|
||||
@@ -369,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"
|
||||
@@ -434,11 +453,14 @@
|
||||
"name": "绕过Trackers",
|
||||
"description": "提供tracker服务器IP地址列表,帮助IPv6连接绕过OpenClash。",
|
||||
"labels": "工具",
|
||||
"version": "1.4.3",
|
||||
"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": "修复插件动作",
|
||||
"v1.4.1": "修复通知类型错误",
|
||||
@@ -453,11 +475,18 @@
|
||||
"name": "IMDb源",
|
||||
"description": "让探索,推荐和媒体识别支持IMDb数据源。",
|
||||
"labels": "探索",
|
||||
"version": "1.5.8",
|
||||
"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",
|
||||
"v1.5.8": "修改UA",
|
||||
"v1.5.7": "改进异常处理",
|
||||
"v1.5.6": "固定仪表盘组件海报比例; 修复 bug",
|
||||
@@ -471,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": "修复按日期排序错误",
|
||||
@@ -485,12 +514,17 @@
|
||||
"name": "Clash Rule Provider",
|
||||
"description": "随时为Clash添加一些额外的规则。",
|
||||
"labels": "工具",
|
||||
"version": "2.0.7",
|
||||
"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": "修复已知问题",
|
||||
"v2.0.7": "修复子规则比较错误",
|
||||
"v2.0.6": "修复已知问题; 改进对代理组的配置和验证",
|
||||
"v2.0.5": "完善了对嵌套逻辑规则和子规则的配置和验证",
|
||||
@@ -524,11 +558,17 @@
|
||||
"name": "美剧生词标注",
|
||||
"description": "根据CEFR等级,为英语影视剧标注高级词汇。",
|
||||
"labels": "英语",
|
||||
"version": "1.1.2",
|
||||
"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 模型常驻内存",
|
||||
"v1.1.1": "添加任务页面; 改进 spaCy 模型加载逻辑",
|
||||
"v1.1.0": "支持考试词汇标注; 优化分词处理; 修复错误",
|
||||
@@ -562,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": "首次发布:可配置报告模块、历史记录页面、下载器通用化、存储路径可配置"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class AutoSignIn(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "signin.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.7"
|
||||
plugin_version = "2.8.2"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
|
||||
114
plugins.v2/autosignin/sites/rousipro.py
Normal file
114
plugins.v2/autosignin/sites/rousipro.py
Normal 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, "模拟登录失败,无法访问网站"
|
||||
@@ -79,6 +79,7 @@ class BrushConfig:
|
||||
self.qb_category = config.get("qb_category")
|
||||
self.site_hr_active = config.get("site_hr_active", False)
|
||||
self.site_skip_tips = config.get("site_skip_tips", False)
|
||||
self.rss_support = config.get("rss_support", False)
|
||||
|
||||
self.brush_tag = "刷流"
|
||||
# 站点独立配置
|
||||
@@ -123,7 +124,8 @@ class BrushConfig:
|
||||
"qb_category",
|
||||
"site_hr_active",
|
||||
"site_skip_tips",
|
||||
"del_no_free"
|
||||
"del_no_free",
|
||||
"rss_support"
|
||||
# 当新增支持字段时,仅在此处添加字段名
|
||||
}
|
||||
try:
|
||||
@@ -193,7 +195,8 @@ class BrushConfig:
|
||||
"del_no_free": false,
|
||||
"qb_category": "刷流",
|
||||
"site_hr_active": true,
|
||||
"site_skip_tips": true
|
||||
"site_skip_tips": true,
|
||||
"rss_support": true
|
||||
}]"""
|
||||
return desc + config
|
||||
|
||||
@@ -259,9 +262,9 @@ class BrushFlow(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "brush.jpg"
|
||||
# 插件版本
|
||||
plugin_version = "4.3.3"
|
||||
plugin_version = "4.3.5"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp,InfinityPacer"
|
||||
plugin_author = "jxxghp,InfinityPacer,Seed680"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/InfinityPacer"
|
||||
# 插件配置项ID前缀
|
||||
@@ -1638,6 +1641,22 @@ class BrushFlow(_PluginBase):
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'rss_support',
|
||||
'label': '启用RSS支持',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1817,7 +1836,8 @@ class BrushFlow(_PluginBase):
|
||||
"freeleech": "free",
|
||||
"hr": "yes",
|
||||
"enable_site_config": False,
|
||||
"site_config": BrushConfig.get_demo_site_config()
|
||||
"site_config": BrushConfig.get_demo_site_config(),
|
||||
"rss_support": False
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
@@ -2002,7 +2022,14 @@ class BrushFlow(_PluginBase):
|
||||
return True
|
||||
|
||||
logger.info(f"开始获取站点 {siteinfo.name} 的新种子 ...")
|
||||
torrents = TorrentsChain().browse(domain=siteinfo.domain)
|
||||
|
||||
# 根据rss_support配置决定使用browse还是rss方法获取种子
|
||||
brush_config = self.__get_brush_config(sitename=siteinfo.name)
|
||||
if brush_config.rss_support:
|
||||
torrents = TorrentsChain().rss(domain=siteinfo.domain)
|
||||
else:
|
||||
torrents = TorrentsChain().browse(domain=siteinfo.domain)
|
||||
|
||||
if not torrents:
|
||||
logger.info(f"站点 {siteinfo.name} 没有获取到种子")
|
||||
return True
|
||||
@@ -2219,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:
|
||||
@@ -3048,6 +3093,7 @@ class BrushFlow(_PluginBase):
|
||||
"enable_site_config": brush_config.enable_site_config,
|
||||
"site_config": brush_config.site_config,
|
||||
"del_no_free": brush_config.del_no_free,
|
||||
"rss_support": brush_config.rss_support,
|
||||
"_tabs": self._tabs
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
sentry_sdk~=2.35.1
|
||||
sentry_sdk~=2.44.0
|
||||
@@ -17,7 +17,7 @@ class ChatGPT(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "Chatgpt_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.1.7"
|
||||
plugin_version = "2.1.8"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
|
||||
@@ -14,22 +14,33 @@ class OpenAi:
|
||||
_api_url: str = None
|
||||
_model: str = "gpt-3.5-turbo"
|
||||
_prompt: str = '接下来我会给你一个电影或电视剧的文件名,你需要识别文件名中的名称、版本、分段、年份、分瓣率、季集等信息,并按以下JSON格式返回:{"name":string,"version":string,"part":string,"year":string,"resolution":string,"season":number|null,"episode":number|null},特别注意返回结果需要严格附合JSON格式,不需要有任何其它的字符。如果中文电影或电视剧的文件名中存在谐音字或字母替代的情况,请还原最有可能的结果。'
|
||||
_client: openai.OpenAI = None
|
||||
|
||||
def __init__(self, api_key: str = None, api_url: str = None, proxy: dict = None, model: str = None, compatible:
|
||||
bool = False, customize_prompt: str = None):
|
||||
def __init__(self, api_key: str = None, api_url: str = None,
|
||||
proxy: dict = None, model: str = None,
|
||||
compatible: bool = False, customize_prompt: str = None):
|
||||
self._api_key = api_key
|
||||
self._api_url = api_url
|
||||
if compatible:
|
||||
openai.api_base = self._api_url
|
||||
else:
|
||||
openai.api_base = self._api_url + "/v1"
|
||||
openai.api_key = self._api_key
|
||||
if proxy and proxy.get("https"):
|
||||
openai.proxy = proxy.get("https")
|
||||
if model:
|
||||
self._model = model
|
||||
if customize_prompt:
|
||||
self._prompt = customize_prompt
|
||||
|
||||
# 初始化 OpenAI 客户端
|
||||
if self._api_key and self._api_url:
|
||||
base_url = self._api_url if compatible else self._api_url + "/v1"
|
||||
http_client = None
|
||||
if proxy and proxy.get("https"):
|
||||
import httpx
|
||||
proxy_url = proxy.get("https")
|
||||
# httpx 支持字符串格式的代理 URL
|
||||
http_client = httpx.Client(proxies=proxy_url, timeout=60.0)
|
||||
self._client = openai.OpenAI(
|
||||
api_key=self._api_key,
|
||||
base_url=base_url,
|
||||
http_client=http_client
|
||||
)
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return True if self._api_key else False
|
||||
|
||||
@@ -82,6 +93,8 @@ class OpenAi:
|
||||
"""
|
||||
获取模型
|
||||
"""
|
||||
if not self._client:
|
||||
raise ValueError("OpenAI client not initialized. Please check API key and API URL.")
|
||||
if not isinstance(message, list):
|
||||
if prompt:
|
||||
message = [
|
||||
@@ -101,9 +114,10 @@ class OpenAi:
|
||||
"content": message
|
||||
}
|
||||
]
|
||||
return openai.ChatCompletion.create(
|
||||
# 新版本 API 不支持 user 参数,需要从 kwargs 中移除
|
||||
kwargs.pop('user', None)
|
||||
return self._client.chat.completions.create(
|
||||
model=self._model,
|
||||
user=user,
|
||||
messages=message,
|
||||
**kwargs
|
||||
)
|
||||
@@ -170,11 +184,11 @@ class OpenAi:
|
||||
if result:
|
||||
self.__save_session(userid, text)
|
||||
return result
|
||||
except openai.error.RateLimitError as e:
|
||||
except openai.RateLimitError as e:
|
||||
return f"请求被ChatGPT拒绝了,{str(e)}"
|
||||
except openai.error.APIConnectionError as e:
|
||||
except openai.APIConnectionError as e:
|
||||
return f"ChatGPT网络连接失败:{str(e)}"
|
||||
except openai.error.Timeout as e:
|
||||
except openai.APITimeoutError as e:
|
||||
return f"没有接收到ChatGPT的返回消息:{str(e)}"
|
||||
except Exception as e:
|
||||
return f"请求ChatGPT出现错误:{str(e)}"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
openai~=0.27.2
|
||||
@@ -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)
|
||||
@@ -1,28 +1,34 @@
|
||||
import copy
|
||||
import pytz
|
||||
import yaml
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional, List, Dict, Tuple
|
||||
|
||||
import yaml
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.config import settings
|
||||
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.scheduler import Scheduler
|
||||
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"
|
||||
# 插件描述
|
||||
@@ -30,7 +36,7 @@ class ClashRuleProvider(_ClashRuleProviderBase):
|
||||
# 插件图标
|
||||
plugin_icon = "Mihomo_Meta_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.0.7"
|
||||
plugin_version = "2.1.2"
|
||||
# 插件作者
|
||||
plugin_author = "wumode"
|
||||
# 作者主页
|
||||
@@ -42,99 +48,83 @@ class ClashRuleProvider(_ClashRuleProviderBase):
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
def __init__(self):
|
||||
# Configuration attributes
|
||||
super().__init__()
|
||||
|
||||
# Runtime variables
|
||||
self.services: Optional[ClashRuleProviderService] = None
|
||||
self.api: Optional[ClashRuleProviderApi] = None
|
||||
# Runtime variables
|
||||
services: ClashRuleProviderService
|
||||
api: ClashRuleProviderApi
|
||||
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:
|
||||
self.config.from_dict(conf)
|
||||
self.__update_config()
|
||||
try:
|
||||
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()
|
||||
self.scheduler = AsyncIOScheduler(timezone=settings.TZ, event_loop=Scheduler().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]]:
|
||||
@@ -154,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)}
|
||||
@@ -165,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, []
|
||||
|
||||
@@ -178,25 +168,22 @@ class ClashRuleProvider(_ClashRuleProviderBase):
|
||||
self.scheduler.remove_all_jobs()
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown()
|
||||
self.scheduler = None
|
||||
except Exception as e:
|
||||
logger.error(f"退出插件失败:{e}")
|
||||
self.services = None
|
||||
self.api = None
|
||||
|
||||
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 = []
|
||||
@@ -206,41 +193,40 @@ 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.to_dict()
|
||||
def _update_config(self):
|
||||
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)
|
||||
def update_cloudflare_ips_handler(self, event: Event = None):
|
||||
def update_cloudflare_ips_handler(self, event: Event):
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "update_cloudflare_ips":
|
||||
return
|
||||
@@ -250,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)
|
||||
|
||||
@@ -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,41 +164,63 @@ 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=bool(True), summary="获取规则集规则")
|
||||
@apis.register(path="/ruleset", methods=["GET"], allow_anonymous=True, summary="获取规则集规则")
|
||||
def get_ruleset(self, name: str, apikey: str) -> PlainTextResponse:
|
||||
_apikey = self.config.apikey or settings.API_TOKEN
|
||||
if not secrets.compare_digest(_apikey, apikey):
|
||||
@@ -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 请求")
|
||||
|
||||
@@ -1,38 +1,8 @@
|
||||
from abc import ABC
|
||||
from typing import Final, Optional, 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"
|
||||
|
||||
def __init__(self):
|
||||
# Configuration attributes
|
||||
super().__init__()
|
||||
|
||||
# Runtime variables
|
||||
self.state: Optional[PluginState] = None
|
||||
self.config: Optional[PluginConfig] = None
|
||||
self.store: Optional[PluginStore] = None
|
||||
self.scheduler: Optional[AsyncIOScheduler] = None
|
||||
|
||||
@@ -1,140 +1,90 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from .models.api import ClashApi
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginConfig:
|
||||
class SubscriptionConfig(BaseModel):
|
||||
url: str
|
||||
rules: Optional[bool] = True
|
||||
rule_providers: Optional[bool] = Field(default=True, alias='rule-providers')
|
||||
proxies: Optional[bool] = True
|
||||
proxy_groups: Optional[bool] = Field(default=True, alias='proxy-groups')
|
||||
proxy_providers: Optional[bool] = Field(default=True, alias='proxy-providers')
|
||||
|
||||
@field_validator('url')
|
||||
@classmethod
|
||||
def validate_url(cls, v: str) -> str:
|
||||
return v.strip()
|
||||
|
||||
|
||||
class PluginConfig(BaseModel):
|
||||
"""
|
||||
A dataclass to hold all the configuration of the ClashRuleProvider plugin.
|
||||
"""
|
||||
enabled = False
|
||||
proxy = False
|
||||
notify = False
|
||||
subscriptions_config: List[Dict[str, Any]] = field(default_factory=list)
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
)
|
||||
|
||||
enabled: bool = False
|
||||
proxy: bool = False
|
||||
notify: bool = False
|
||||
subscriptions_config: list[SubscriptionConfig] = Field(default_factory=list)
|
||||
movie_pilot_url: str = ''
|
||||
cron_string = '30 12 * * *'
|
||||
timeout = 10
|
||||
retry_times = 3
|
||||
filter_keywords: List[str] = field(default_factory=list)
|
||||
auto_update_subscriptions = True
|
||||
cron_string: str = '30 12 * * *'
|
||||
timeout: int = 10
|
||||
retry_times: int = 3
|
||||
filter_keywords: List[str] = Field(default_factory=list)
|
||||
auto_update_subscriptions: bool = True
|
||||
ruleset_prefix: str = '📂<='
|
||||
acl4ssr_prefix: str = '🗂️=>'
|
||||
group_by_region: bool = False
|
||||
group_by_country: bool = False
|
||||
refresh_delay: int = 5
|
||||
enable_acl4ssr: bool = False
|
||||
dashboard_components: List[str] = field(default_factory=list)
|
||||
dashboard_components: List[str] = Field(default_factory=list)
|
||||
clash_template: str = ''
|
||||
hint_geo_dat: bool = False
|
||||
best_cf_ip: List[str] = field(default_factory=list)
|
||||
best_cf_ip: List[str] = Field(default_factory=list)
|
||||
apikey: Optional[str] = None
|
||||
clash_dashboards: List[Dict[str, str]] = field(default_factory=list)
|
||||
clash_dashboards: List[ClashApi] = Field(default_factory=list)
|
||||
active_dashboard: Optional[int] = None
|
||||
identifiers: list[str] = Field(default_factory=list)
|
||||
cache_ttl: int = 3600
|
||||
|
||||
def from_dict(self, config: Dict[str, Any]):
|
||||
if 'enabled' in config:
|
||||
self.enabled = bool(config.get("enabled"))
|
||||
if 'proxy' in config:
|
||||
self.proxy = bool(config.get("proxy"))
|
||||
if 'notify' in config:
|
||||
self.notify = bool(config.get("notify"))
|
||||
sub_links = config.get("sub_links") or []
|
||||
self.subscriptions_config = config.get("subscriptions_config") or []
|
||||
self.subscriptions_config.extend(
|
||||
[{'url': url, 'rules': True, 'rule-providers': True, 'proxies': True, 'proxy-groups': True,
|
||||
'proxy-providers': True}
|
||||
for url in sub_links]
|
||||
)
|
||||
|
||||
clash_dashboards = config.get("clash_dashboards")
|
||||
if clash_dashboards is None:
|
||||
clash_dashboards = [{'url': config.get('clash_dashboard_url') or '',
|
||||
'secret': config.get('clash_dashboard_secret') or ''}]
|
||||
self.clash_dashboards = []
|
||||
for clash_dashboard in clash_dashboards:
|
||||
url = (clash_dashboard.get("url") or '').rstrip('/')
|
||||
if url and not (url.startswith('http://') or url.startswith('https://')):
|
||||
@field_validator('clash_dashboards')
|
||||
@classmethod
|
||||
def validate_clash_dashboards(cls, v: List[ClashApi]):
|
||||
for item in v:
|
||||
url = item.url.rstrip('/')
|
||||
if not (url.startswith('http://') or url.startswith('https://')):
|
||||
url = 'http://' + url
|
||||
self.clash_dashboards.append({'url': url, 'secret': clash_dashboard.get('secret') or ''})
|
||||
item.url = url
|
||||
return v
|
||||
|
||||
self.movie_pilot_url = (config.get("movie_pilot_url") or '').rstrip('/')
|
||||
if config.get("cron_string"):
|
||||
self.cron_string = config.get("cron_string")
|
||||
if config.get("timeout"):
|
||||
self.timeout = config.get("timeout")
|
||||
if config.get("retry_times"):
|
||||
self.retry_times = config.get("retry_times")
|
||||
if config.get("refresh_delay"):
|
||||
self.refresh_delay = config.get("refresh_delay")
|
||||
if config.get("filter_keywords"):
|
||||
self.filter_keywords = config.get("filter_keywords")
|
||||
if config.get("clash_template"):
|
||||
self.clash_template = config.get("clash_template")
|
||||
if config.get("best_cf_ip"):
|
||||
self.best_cf_ip = config.get("best_cf_ip")
|
||||
self.ruleset_prefix = (config.get("ruleset_prefix") or '').strip()
|
||||
if config.get("acl4ssr_prefix"):
|
||||
self.acl4ssr_prefix = config.get("acl4ssr_prefix").strip()
|
||||
if 'auto_update_subscriptions' in config:
|
||||
self.auto_update_subscriptions = config.get("auto_update_subscriptions")
|
||||
if 'group_by_region' in config:
|
||||
self.group_by_region = config.get("group_by_region")
|
||||
if 'group_by_country' in config:
|
||||
self.group_by_country = config.get("group_by_country")
|
||||
if 'enable_acl4ssr' in config:
|
||||
self.enable_acl4ssr = config.get("enable_acl4ssr")
|
||||
if 'dashboard_components' in config:
|
||||
self.dashboard_components = config.get("dashboard_components")
|
||||
if 'hint_geo_dat' in config:
|
||||
self.hint_geo_dat = config.get("hint_geo_dat")
|
||||
self.active_dashboard = config.get("active_dashboard")
|
||||
if self.active_dashboard is None and self.clash_dashboards:
|
||||
self.active_dashboard = 0
|
||||
self.apikey = config.get("apikey")
|
||||
@field_validator('movie_pilot_url')
|
||||
@classmethod
|
||||
def validate_movie_pilot_url(cls, v: str):
|
||||
return v.rstrip('/')
|
||||
|
||||
@property
|
||||
def sub_links(self) -> List[str]:
|
||||
for sub in self.subscriptions_config:
|
||||
sub['url'] = sub['url'].strip()
|
||||
return [sub['url'] for sub in self.subscriptions_config if sub.get('url')]
|
||||
return [sub.url for sub in self.subscriptions_config]
|
||||
|
||||
@property
|
||||
def dashboard_url(self) -> str:
|
||||
dashboard_url = ''
|
||||
if self.active_dashboard is not None and self.active_dashboard in range(len(self.clash_dashboards)):
|
||||
dashboard_url = self.clash_dashboards[self.active_dashboard].get("url")
|
||||
dashboard_url = self.clash_dashboards[self.active_dashboard].url
|
||||
return dashboard_url
|
||||
|
||||
@property
|
||||
def dashboard_secret(self) -> str:
|
||||
dashboard_secret = ''
|
||||
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].get("secret")
|
||||
dashboard_secret = self.clash_dashboards[self.active_dashboard].secret
|
||||
return dashboard_secret
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'enabled': self.enabled,
|
||||
'proxy': self.proxy,
|
||||
'notify': self.notify,
|
||||
'subscriptions_config': self.subscriptions_config,
|
||||
'clash_dashboards': self.clash_dashboards,
|
||||
'movie_pilot_url': self.movie_pilot_url,
|
||||
'cron_string': self.cron_string,
|
||||
'timeout': self.timeout,
|
||||
'retry_times': self.retry_times,
|
||||
'filter_keywords': self.filter_keywords,
|
||||
'auto_update_subscriptions': self.auto_update_subscriptions,
|
||||
'ruleset_prefix': self.ruleset_prefix,
|
||||
'acl4ssr_prefix': self.acl4ssr_prefix,
|
||||
'group_by_region': self.group_by_region,
|
||||
'group_by_country': self.group_by_country,
|
||||
'refresh_delay': self.refresh_delay,
|
||||
'enable_acl4ssr': self.enable_acl4ssr,
|
||||
'dashboard_components': self.dashboard_components,
|
||||
'clash_template': self.clash_template,
|
||||
'hint_geo_dat': self.hint_geo_dat,
|
||||
'best_cf_ip': self.best_cf_ip,
|
||||
'active_dashboard': self.active_dashboard,
|
||||
'apikey': self.apikey
|
||||
}
|
||||
def get_sub_conf(self, url: str) -> SubscriptionConfig:
|
||||
return next((conf for conf in self.subscriptions_config if conf.url == url), SubscriptionConfig(url=url))
|
||||
|
||||
3
plugins.v2/clashruleprovider/dist/assets/Meta-1zu2nKV2.js
vendored
Normal file
3
plugins.v2/clashruleprovider/dist/assets/Meta-1zu2nKV2.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
const MetaLogo = "/assets/Meta-uqWbsmWL.png";
|
||||
|
||||
export { MetaLogo as M };
|
||||
BIN
plugins.v2/clashruleprovider/dist/assets/Meta-uqWbsmWL.png
vendored
Normal file
BIN
plugins.v2/clashruleprovider/dist/assets/Meta-uqWbsmWL.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
File diff suppressed because it is too large
Load Diff
1479
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-CY46uj5g.js
vendored
Normal file
1479
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-CY46uj5g.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
4
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-CwbjkOP2.css
vendored
Normal file
4
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-CwbjkOP2.css
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
.plugin-config[data-v-3fef8398] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
|
||||
.plugin-config[data-v-5f383f33] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
.dashboard-widget[data-v-de7a088e] {
|
||||
.dashboard-widget[data-v-318a5020] {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
84
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-CJILOVp4.css
vendored
Normal file
84
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-CJILOVp4.css
vendored
Normal 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
14246
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-DhQfGEOD.js
vendored
Normal file
14246
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-DhQfGEOD.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
208
plugins.v2/clashruleprovider/dist/assets/_plugin-vue_export-helper-D32QZFxh.js
vendored
Normal file
208
plugins.v2/clashruleprovider/dist/assets/_plugin-vue_export-helper-D32QZFxh.js
vendored
Normal 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 };
|
||||
@@ -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 _ };
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -1,39 +1,29 @@
|
||||
import time
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Union, Iterator
|
||||
|
||||
from .clashruleparser import ClashRuleParser
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from ..models.metadata import Metadata
|
||||
from ..models.rule import Action, RoutingRuleType, MatchRule, ClashRule, LogicRule
|
||||
from ..models.ruleitem import RuleItem, RuleData
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuleItem:
|
||||
"""Clash rule item"""
|
||||
rule: Union[ClashRule, LogicRule, MatchRule]
|
||||
remark: str = field(default="")
|
||||
time_modified: float = field(default=0)
|
||||
|
||||
class ClashRuleManager:
|
||||
"""Clash rule manager"""
|
||||
def __init__(self):
|
||||
self.rules: List[RuleItem] = []
|
||||
|
||||
def import_rules(self, rules_list: List[Dict[str, str]]):
|
||||
self.rules = []
|
||||
def import_rules(self, rules_list: List[Dict[str, Any]]):
|
||||
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)
|
||||
@@ -63,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)
|
||||
@@ -100,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"""
|
||||
@@ -112,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):
|
||||
|
||||
@@ -3,27 +3,31 @@ from typing import List, Dict, Any, Optional, Union
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ..models.rule import RuleType, Action, RoutingRuleType, MatchRule, ClashRule, LogicRule, SubRule
|
||||
from ..models.rule import RuleType, Action, RoutingRuleType, MatchRule, ClashRule, LogicRule, SubRule, AdditionalParam
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -33,7 +37,7 @@ class ClashRuleParser:
|
||||
return None
|
||||
try:
|
||||
if clash_rule.get("type") in ('AND', 'OR', 'NOT'):
|
||||
conditions = clash_rule.get("conditions")
|
||||
conditions = clash_rule.get("conditions", [])
|
||||
if not conditions:
|
||||
return None
|
||||
conditions = [ClashRuleParser._remove_parenthesis(f"({c})") for c in conditions]
|
||||
@@ -105,6 +109,8 @@ class ClashRuleParser:
|
||||
raise ValueError(f"Unknown rule type: {rule_type_str}")
|
||||
|
||||
# Try to convert action to enum, otherwise keep as string (custom proxy group)
|
||||
if additional_params is not None:
|
||||
additional_params = AdditionalParam(additional_params)
|
||||
try:
|
||||
action_enum = Action(action.upper())
|
||||
final_action = action_enum
|
||||
@@ -121,7 +127,7 @@ class ClashRuleParser:
|
||||
|
||||
@staticmethod
|
||||
def _parenthesis_balance(s: str) -> Optional[int]:
|
||||
"""Calculate balance of parenthesis"""
|
||||
"""Calculate the balance of parenthesis"""
|
||||
balance = 0
|
||||
for i, char in enumerate(s):
|
||||
if char == '(':
|
||||
@@ -218,9 +224,9 @@ class ClashRuleParser:
|
||||
def _parse_logic_conditions(conditions_str: str) -> List[Union[ClashRule, LogicRule]]:
|
||||
"""
|
||||
Parse conditions within logic rules, supporting nested logic.
|
||||
examples of conditions_str:
|
||||
(DOMAIN,baidu.com)
|
||||
(AND,(DOMAIN,baidu.com),(NETWORK,TCP))
|
||||
The examples of conditions_str:
|
||||
- (DOMAIN,baidu.com)
|
||||
- (AND,(DOMAIN,baidu.com),(NETWORK,TCP))
|
||||
"""
|
||||
|
||||
def __extract_condition_strings(_con_str: str) -> List[str]:
|
||||
@@ -286,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"""
|
||||
@@ -308,7 +309,7 @@ class ClashRuleParser:
|
||||
def validate_rule(rule: ClashRule) -> bool:
|
||||
"""Validate a parsed rule"""
|
||||
try:
|
||||
# Basic validation based on rule type
|
||||
# Basic validation based on the rule type
|
||||
if rule.rule_type in [RoutingRuleType.IP_CIDR, RoutingRuleType.IP_CIDR6]:
|
||||
# Validate CIDR format
|
||||
return '/' in rule.payload
|
||||
@@ -318,7 +319,7 @@ class ClashRuleParser:
|
||||
return rule.payload.isdigit() or '-' in rule.payload
|
||||
|
||||
elif rule.rule_type == RoutingRuleType.NETWORK:
|
||||
# Validate network type
|
||||
# Validate the network type
|
||||
return rule.payload.lower() in ['tcp', 'udp']
|
||||
|
||||
elif rule.rule_type == RoutingRuleType.DOMAIN_REGEX or rule.rule_type == RoutingRuleType.PROCESS_PATH_REGEX:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@ class HysteriaConverter(BaseConverter):
|
||||
"type": "hysteria",
|
||||
"server": parsed.hostname,
|
||||
"port": parsed.port,
|
||||
"udp": True
|
||||
}
|
||||
|
||||
auth_str = query.get("auth")
|
||||
|
||||
@@ -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")
|
||||
|
||||
118
plugins.v2/clashruleprovider/helper/dataupgrader/v_2_1_0.py
Normal file
118
plugins.v2/clashruleprovider/helper/dataupgrader/v_2_1_0.py
Normal 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")
|
||||
@@ -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.parse_obj(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.parse_obj(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.dict(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.dict(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
|
||||
@@ -1,3 +1,6 @@
|
||||
from .proxy import *
|
||||
from .hosts import *
|
||||
from .ruleitem import *
|
||||
from .ruleproviders import *
|
||||
from .proxygroups import *
|
||||
from .proxyproviders import *
|
||||
|
||||
@@ -1,47 +1,71 @@
|
||||
from typing import List, Optional, Union, Literal
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
|
||||
class Config:
|
||||
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()))
|
||||
|
||||
233
plugins.v2/clashruleprovider/models/configuration.py
Normal file
233
plugins.v2/clashruleprovider/models/configuration.py
Normal 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
|
||||
31
plugins.v2/clashruleprovider/models/datamodel.py
Normal file
31
plugins.v2/clashruleprovider/models/datamodel.py
Normal 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)
|
||||
32
plugins.v2/clashruleprovider/models/datapatch.py
Normal file
32
plugins.v2/clashruleprovider/models/datapatch.py
Normal 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]
|
||||
93
plugins.v2/clashruleprovider/models/generics.py
Normal file
93
plugins.v2/clashruleprovider/models/generics.py
Normal 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]
|
||||
33
plugins.v2/clashruleprovider/models/hosts.py
Normal file
33
plugins.v2/clashruleprovider/models/hosts.py
Normal 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
|
||||
25
plugins.v2/clashruleprovider/models/metadata.py
Normal file
25
plugins.v2/clashruleprovider/models/metadata.py
Normal 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))
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Union
|
||||
import jsonpatch
|
||||
from typing import Union, Any
|
||||
|
||||
from pydantic import Field, BaseModel
|
||||
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,
|
||||
@@ -43,5 +45,34 @@ ProxyType = Union[
|
||||
WireGuardProxy,
|
||||
]
|
||||
|
||||
class Proxy(BaseModel):
|
||||
__root__: ProxyType = Field(..., discriminator="type")
|
||||
|
||||
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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional, Literal
|
||||
|
||||
from pydantic import Field, validator
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
@@ -15,12 +15,11 @@ class MieruProxy(ProxyBase):
|
||||
'MULTIPLEXING_OFF', 'MULTIPLEXING_LOW', 'MULTIPLEXING_MIDDLE', 'MULTIPLEXING_HIGH']] = 'MULTIPLEXING_LOW'
|
||||
handshake_mode: Optional[Literal['HANDSHAKE_STANDARD', 'HANDSHAKE_NO_WAIT']] = 'HANDSHAKE_STANDARD'
|
||||
|
||||
@validator('port', 'port_range', allow_reuse=True)
|
||||
def validate_port_config(cls, v, values):
|
||||
port = values.get('port')
|
||||
port_range = values.get('port_range')
|
||||
if not port and not port_range:
|
||||
raise ValueError("either port or port-range must be set")
|
||||
if port and port_range:
|
||||
raise ValueError("port and port-range cannot be set at the same time")
|
||||
return v
|
||||
@model_validator(mode='after')
|
||||
def validate_port_config(self):
|
||||
"""Pydantic v2 style model-level validation."""
|
||||
if not getattr(self, 'port', None) and not getattr(self, 'port_range', None):
|
||||
raise ValueError("either 'port' or 'port-range' must be set")
|
||||
if getattr(self, 'port', None) and getattr(self, 'port_range', None):
|
||||
raise ValueError("'port' and 'port-range' cannot be set at the same time")
|
||||
return self
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional, Dict, Literal, List, Union
|
||||
|
||||
from pydantic import Field, BaseModel, validator
|
||||
from pydantic import Field, BaseModel, field_validator, ValidationInfo
|
||||
|
||||
from .proxybase import ProxyBase
|
||||
|
||||
@@ -87,24 +87,24 @@ class ShadowsocksProxy(ProxyBase):
|
||||
RestlsPluginOpts,
|
||||
]] = Field(None, alias='plugin-opts')
|
||||
|
||||
class Config:
|
||||
extra = 'allow'
|
||||
allow_population_by_field_name = True
|
||||
|
||||
@validator('plugin_opts', allow_reuse=True)
|
||||
def validate_plugin_opts(cls, v, values):
|
||||
plugin = values.get('plugin')
|
||||
@field_validator("plugin_opts")
|
||||
@classmethod
|
||||
def validate_plugin_opts(cls, v, info: ValidationInfo):
|
||||
plugin = info.data.get("plugin")
|
||||
if plugin and v:
|
||||
if not isinstance(plugin, str):
|
||||
raise ValueError("plugin must be a string")
|
||||
plugin_model_map = {
|
||||
'obfs': ObfsPluginOpts,
|
||||
'v2ray-plugin': V2rayPluginOpts,
|
||||
'gost-plugin': GostPluginOpts,
|
||||
'shadow-tls': ShadowTlsPluginOpts,
|
||||
'restls': RestlsPluginOpts
|
||||
"obfs": "ObfsPluginOpts",
|
||||
"v2ray-plugin": "V2rayPluginOpts",
|
||||
"gost-plugin": "GostPluginOpts",
|
||||
"shadow-tls": "ShadowTlsPluginOpts",
|
||||
"restls": "RestlsPluginOpts",
|
||||
}
|
||||
|
||||
expected_model = plugin_model_map.get(plugin)
|
||||
if expected_model and not isinstance(v, expected_model):
|
||||
raise ValueError(f"{plugin} plugin requires {expected_model.__name__}")
|
||||
if expected_model and v.__class__.__name__ != expected_model:
|
||||
raise ValueError(f"{plugin} plugin requires {expected_model}")
|
||||
|
||||
return v
|
||||
|
||||
@@ -12,7 +12,3 @@ class ShadowsocksRProxy(ProxyBase):
|
||||
obfs_param: Optional[str] = Field(None, alias='obfs-param')
|
||||
protocol: Literal['origin', 'auth_sha1_v4', 'auth_aes128_md5', 'auth_aes128_sha1', 'auth_chain_a', 'auth_chain_b']
|
||||
protocol_param: Optional[str] = Field(None, alias='protocol-param')
|
||||
|
||||
class Config:
|
||||
extra = 'allow'
|
||||
allow_population_by_field_name = True
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import jsonpatch
|
||||
import re
|
||||
from typing import List, Optional, Union, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from pydantic import BaseModel, Field, field_validator, RootModel, model_validator
|
||||
|
||||
from .generics import ResourceItem, ResourceList
|
||||
|
||||
|
||||
class ProxyGroupBase(BaseModel):
|
||||
@@ -12,46 +15,48 @@ 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.")
|
||||
|
||||
@validator('expected_status', allow_reuse=True)
|
||||
@field_validator('expected_status')
|
||||
@classmethod
|
||||
def validate_expected_status(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None or v == '*':
|
||||
return v
|
||||
@@ -71,51 +76,88 @@ 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]
|
||||
|
||||
|
||||
class ProxyGroup(BaseModel):
|
||||
__root__: ProxyGroupType = Field(..., discriminator='type')
|
||||
class ProxyGroup(RootModel[ProxyGroupType]):
|
||||
root: ProxyGroupType = Field(..., discriminator='type')
|
||||
|
||||
def dict(self, **kwargs):
|
||||
return self.__root__.dict(**kwargs)
|
||||
@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
|
||||
|
||||
130
plugins.v2/clashruleprovider/models/proxyproviders.py
Normal file
130
plugins.v2/clashruleprovider/models/proxyproviders.py
Normal 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
|
||||
@@ -1,7 +1,7 @@
|
||||
from enum import Enum
|
||||
from enum import Enum, StrEnum
|
||||
from typing import Any, List, Optional, Union, Dict, Literal
|
||||
|
||||
from pydantic import BaseModel, validator
|
||||
from pydantic import BaseModel, field_validator, ValidationInfo
|
||||
|
||||
|
||||
class AdditionalParam(Enum):
|
||||
@@ -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
|
||||
@@ -95,13 +92,13 @@ class ClashRule(RuleBase):
|
||||
def condition_string(self) -> str:
|
||||
return f"{self.rule_type.value},{self.payload}"
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'type': self.rule_type.value,
|
||||
'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:
|
||||
@@ -110,9 +107,13 @@ class ClashRule(RuleBase):
|
||||
rule_str += f",{self.additional_params.value}"
|
||||
return rule_str
|
||||
|
||||
@validator('payload', allow_reuse=True)
|
||||
def validate_payload(cls, v: str, values: Dict[str, Any]) -> Optional[str]:
|
||||
if values.get('rule_type') == RoutingRuleType.NETWORK and v.upper() not in ('TCP', 'UDP'):
|
||||
@field_validator('payload', mode='after')
|
||||
@classmethod
|
||||
def validate_payload(cls, v: Optional[str], info: ValidationInfo) -> Optional[str]:
|
||||
# 获取其他字段的值
|
||||
rule_type = info.data['rule_type']
|
||||
|
||||
if rule_type == RoutingRuleType.NETWORK and v is not None and v.upper() not in ('TCP', 'UDP'):
|
||||
raise ValueError('Payload must be TCP or UDP')
|
||||
return v
|
||||
|
||||
@@ -127,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())
|
||||
|
||||
@@ -135,10 +136,11 @@ 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)
|
||||
}
|
||||
|
||||
@validator('conditions', allow_reuse=True)
|
||||
@field_validator('conditions', mode='after')
|
||||
@classmethod
|
||||
def validate_conditions(cls, v: List[Union[ClashRule, 'LogicRule']]) -> List[Union[ClashRule, 'LogicRule']]:
|
||||
if not v:
|
||||
raise ValueError('A condition list must be provided')
|
||||
@@ -161,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:
|
||||
@@ -180,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:
|
||||
|
||||
40
plugins.v2/clashruleprovider/models/ruleitem.py
Normal file
40
plugins.v2/clashruleprovider/models/ruleitem.py
Normal 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)
|
||||
@@ -1,59 +1,76 @@
|
||||
from typing import List, Optional, Literal
|
||||
from typing import Annotated, List, Optional, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, validator, HttpUrl
|
||||
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")
|
||||
|
||||
@validator("url", pre=True, always=True, allow_reuse=True)
|
||||
def check_url_for_http_type(cls, v, values):
|
||||
if values.get("type") == "http" and v is None:
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def validate_type_relationships(cls, values):
|
||||
"""Perform cross-field validation before the model is created."""
|
||||
type_ = values.get('type')
|
||||
url = values.get('url')
|
||||
path = values.get('path')
|
||||
payload = values.get('payload')
|
||||
format_ = values.get('format', 'yaml')
|
||||
behavior = values.get('behavior')
|
||||
|
||||
# url check
|
||||
if type_ == "http" and url is None:
|
||||
raise ValueError("url must be configured if the type is 'http'")
|
||||
elif values.get("type") != "http":
|
||||
return None
|
||||
return v
|
||||
if type_ != "http" and 'url' in values:
|
||||
values['url'] = None
|
||||
|
||||
@validator("path", pre=True, always=True, allow_reuse=True)
|
||||
def check_path_for_file_type(cls, v, values):
|
||||
if values.get("type") == "file" and v is None:
|
||||
# path check
|
||||
if type_ == "file" and path is None:
|
||||
raise ValueError("path must be configured if the type is 'file'")
|
||||
elif values.get("type") != "file":
|
||||
return None
|
||||
return v
|
||||
if type_ != "file" and 'path' in values:
|
||||
values['path'] = None
|
||||
|
||||
@validator("payload", pre=True, always=True, allow_reuse=True)
|
||||
def handle_payload_for_non_inline_type(cls, v, values):
|
||||
# If type is not inline, payload should be ignored (set to None)
|
||||
if values.get("type") != "inline" and v is not None:
|
||||
return None
|
||||
return v
|
||||
# payload handling
|
||||
if type_ == "inline":
|
||||
if payload is None:
|
||||
raise ValueError("payload must be configured if the type is 'inline'")
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("payload must be a list of strings when type is 'inline'")
|
||||
elif 'payload' in values:
|
||||
values['payload'] = None
|
||||
|
||||
@validator("payload", allow_reuse=True)
|
||||
def check_payload_type_for_inline(cls, v, values):
|
||||
if values.get("type") == "inline" and v is not None and not isinstance(v, list):
|
||||
raise ValueError("payload must be a list of strings when type is 'inline'")
|
||||
if values.get("type") == "inline" and v is None:
|
||||
raise ValueError("payload must be configured if the type is 'inline'")
|
||||
return v
|
||||
|
||||
@validator("format", allow_reuse=True)
|
||||
def check_format_with_behavior(cls, v, values):
|
||||
behavior = values.get("behavior")
|
||||
if v == "mrs" and behavior not in ["domain", "ipcidr"]:
|
||||
# format-behavior rule
|
||||
if format_ == "mrs" and behavior not in {"domain", "ipcidr"}:
|
||||
raise ValueError("mrs format only supports 'domain' or 'ipcidr' behavior")
|
||||
return v
|
||||
|
||||
return values
|
||||
|
||||
|
||||
class RuleProviders(BaseModel):
|
||||
__root__: dict[str, RuleProvider]
|
||||
class RuleProviderData(ResourceItem[RuleProvider]):
|
||||
"""Rule Provider Data"""
|
||||
pass
|
||||
|
||||
|
||||
class RuleProviders(ResourceList[RuleProviderData]):
|
||||
"""Rule Providers Collection"""
|
||||
pass
|
||||
|
||||
57
plugins.v2/clashruleprovider/models/types.py
Normal file
57
plugins.v2/clashruleprovider/models/types.py
Normal 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:
|
||||
...
|
||||
@@ -1,2 +1,5 @@
|
||||
websockets
|
||||
sse_starlette~=2.3.6
|
||||
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
@@ -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()
|
||||
))
|
||||
|
||||
@@ -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)
|
||||
769
plugins.v2/dailysummary/__init__.py
Normal file
769
plugins.v2/dailysummary/__init__.py
Normal file
@@ -0,0 +1,769 @@
|
||||
"""MoviePilot 活动总结插件 — 定时发送每日/每周/每月活动总结通知"""
|
||||
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import NotificationType
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.schemas.types import EventType
|
||||
from app.core.config import settings
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.db.plugindata_oper import PluginDataOper
|
||||
from app.db.models.siteuserdata import SiteUserData
|
||||
from app.db import ScopedSession
|
||||
|
||||
|
||||
# ─── 模块注册表:key → 中文名,各报告按需选取 ───
|
||||
|
||||
MODULES = OrderedDict([
|
||||
("download", "下载记录"),
|
||||
("transfer", "入库记录"),
|
||||
("signin", "签到状态"),
|
||||
("brush", "刷流统计"),
|
||||
("downloader", "下载器概览"),
|
||||
("site_delta", "站点增量"),
|
||||
("site_current", "站点快照"),
|
||||
("subscribe", "订阅进度"),
|
||||
("storage", "存储空间"),
|
||||
])
|
||||
|
||||
MODULE_OPTIONS = [{"title": name, "value": key} for key, name in MODULES.items()]
|
||||
|
||||
# 各报告类型的默认模块
|
||||
DEFAULT_DAILY_MODULES = ["download", "transfer", "signin", "brush", "downloader", "site_delta"]
|
||||
DEFAULT_WEEKLY_MODULES = ["download", "transfer", "subscribe", "site_delta", "brush"]
|
||||
DEFAULT_MONTHLY_MODULES = [
|
||||
"download", "transfer", "subscribe", "site_current",
|
||||
"site_delta", "storage", "brush", "downloader",
|
||||
]
|
||||
|
||||
WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||
|
||||
# 历史记录上限
|
||||
MAX_HISTORY = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimeRange:
|
||||
"""报告的时间范围"""
|
||||
start: datetime
|
||||
end: datetime
|
||||
start_str: str # "YYYY-MM-DD HH:MM:SS" — 用于数据库查询
|
||||
start_date: str # "YYYY-MM-DD"
|
||||
end_date: str # "YYYY-MM-DD"
|
||||
report_type: str # "daily" / "weekly" / "monthly"
|
||||
prefix: str # "今日" / "本周" / "本月"
|
||||
|
||||
|
||||
class DailySummary(_PluginBase):
|
||||
plugin_name = "活动总结"
|
||||
plugin_desc = "定时发送每日/每周/每月活动总结通知,支持自定义报告模块、历史记录查看"
|
||||
plugin_icon = "Bark_A.png"
|
||||
plugin_version = "2.0.0"
|
||||
plugin_author = "yuhoye"
|
||||
author_url = "https://github.com/yuhoye"
|
||||
plugin_config_prefix = "dailysummary_"
|
||||
plugin_order = 30
|
||||
auth_level = 1
|
||||
|
||||
# ─── 配置字段 ───
|
||||
|
||||
_enabled: bool = False
|
||||
_notify: bool = True
|
||||
_daily_cron: str = "0 23 * * *"
|
||||
_weekly_cron: str = "0 23 * * 1"
|
||||
_monthly_cron: str = "0 23 1 * *"
|
||||
_onlyonce: bool = False
|
||||
_test_type: str = "daily"
|
||||
|
||||
_daily_modules: list = None
|
||||
_weekly_modules: list = None
|
||||
_monthly_modules: list = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
if config:
|
||||
self._enabled = config.get("enabled", False)
|
||||
self._notify = config.get("notify", True)
|
||||
self._daily_cron = config.get("daily_cron", "0 23 * * *")
|
||||
self._weekly_cron = config.get("weekly_cron", "0 23 * * 1")
|
||||
self._monthly_cron = config.get("monthly_cron", "0 23 1 * *")
|
||||
self._onlyonce = config.get("onlyonce", False)
|
||||
self._test_type = config.get("test_type", "daily")
|
||||
self._daily_modules = config.get("daily_modules") or DEFAULT_DAILY_MODULES
|
||||
self._weekly_modules = config.get("weekly_modules") or DEFAULT_WEEKLY_MODULES
|
||||
self._monthly_modules = config.get("monthly_modules") or DEFAULT_MONTHLY_MODULES
|
||||
else:
|
||||
self._daily_modules = DEFAULT_DAILY_MODULES
|
||||
self._weekly_modules = DEFAULT_WEEKLY_MODULES
|
||||
self._monthly_modules = DEFAULT_MONTHLY_MODULES
|
||||
|
||||
if self._onlyonce:
|
||||
self._onlyonce = False
|
||||
self._save_config()
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
test_func = {
|
||||
"daily": self.send_daily,
|
||||
"weekly": self.send_weekly,
|
||||
"monthly": self.send_monthly,
|
||||
}.get(self._test_type, self.send_daily)
|
||||
scheduler.add_job(
|
||||
func=test_func,
|
||||
trigger="date",
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="立即测试",
|
||||
)
|
||||
scheduler.start()
|
||||
|
||||
def _save_config(self):
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"notify": self._notify,
|
||||
"daily_cron": self._daily_cron,
|
||||
"weekly_cron": self._weekly_cron,
|
||||
"monthly_cron": self._monthly_cron,
|
||||
"onlyonce": False,
|
||||
"test_type": self._test_type,
|
||||
"daily_modules": self._daily_modules,
|
||||
"weekly_modules": self._weekly_modules,
|
||||
"monthly_modules": self._monthly_modules,
|
||||
})
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{"cmd": "/daily_summary", "event": EventType.PluginAction, "desc": "发送每日总结", "category": "工具", "data": {"action": "daily_summary"}},
|
||||
{"cmd": "/weekly_summary", "event": EventType.PluginAction, "desc": "发送每周总结", "category": "工具", "data": {"action": "weekly_summary"}},
|
||||
{"cmd": "/monthly_summary", "event": EventType.PluginAction, "desc": "发送每月总结", "category": "工具", "data": {"action": "monthly_summary"}},
|
||||
]
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
return [{
|
||||
"path": "/clear_history",
|
||||
"endpoint": self._api_clear_history,
|
||||
"methods": ["POST"],
|
||||
"summary": "清除历史记录",
|
||||
}]
|
||||
|
||||
def _api_clear_history(self) -> dict:
|
||||
self.save_data("history", [])
|
||||
logger.info("[DailySummary] 历史记录已清除")
|
||||
return {"success": True}
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
if not self._enabled:
|
||||
return []
|
||||
services = []
|
||||
if self._daily_cron:
|
||||
services.append({
|
||||
"id": "DailySummary_daily",
|
||||
"name": "每日总结",
|
||||
"trigger": CronTrigger.from_crontab(self._daily_cron),
|
||||
"func": self.send_daily,
|
||||
"kwargs": {},
|
||||
})
|
||||
if self._weekly_cron:
|
||||
services.append({
|
||||
"id": "DailySummary_weekly",
|
||||
"name": "每周总结",
|
||||
"trigger": CronTrigger.from_crontab(self._weekly_cron),
|
||||
"func": self.send_weekly,
|
||||
"kwargs": {},
|
||||
})
|
||||
if self._monthly_cron:
|
||||
services.append({
|
||||
"id": "DailySummary_monthly",
|
||||
"name": "每月总结",
|
||||
"trigger": CronTrigger.from_crontab(self._monthly_cron),
|
||||
"func": self.send_monthly,
|
||||
"kwargs": {},
|
||||
})
|
||||
return services
|
||||
|
||||
# ─── 配置表单:三 Tab 布局 ───
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
test_options = [
|
||||
{"title": "每日总结", "value": "daily"},
|
||||
{"title": "每周总结", "value": "weekly"},
|
||||
{"title": "每月总结", "value": "monthly"},
|
||||
]
|
||||
return [
|
||||
{
|
||||
"component": "VForm",
|
||||
"content": [
|
||||
# ── 基本设置(VTabs 外面,避免弹出菜单被裁剪) ──
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 3},
|
||||
'content': [{'component': 'VSwitch', 'props': {'model': 'enabled', 'label': '启用插件'}}]},
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 3},
|
||||
'content': [{'component': 'VSwitch', 'props': {'model': 'notify', 'label': '发送通知'}}]},
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 3},
|
||||
'content': [{'component': 'VSwitch', 'props': {'model': 'onlyonce', 'label': '立即测试一次'}}]},
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 3},
|
||||
'content': [{'component': 'VSelect', 'props': {'model': 'test_type', 'label': '测试类型', 'items': test_options}}]},
|
||||
],
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 4},
|
||||
'content': [{'component': 'VCronField', 'props': {'model': 'daily_cron', 'label': '每日周期', 'placeholder': '5位cron表达式'}}]},
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 4},
|
||||
'content': [{'component': 'VCronField', 'props': {'model': 'weekly_cron', 'label': '每周周期', 'placeholder': '5位cron表达式'}}]},
|
||||
{'component': 'VCol', 'props': {'cols': 12, 'md': 4},
|
||||
'content': [{'component': 'VCronField', 'props': {'model': 'monthly_cron', 'label': '每月周期', 'placeholder': '5位cron表达式'}}]},
|
||||
],
|
||||
},
|
||||
# ── 报告内容 ──
|
||||
{
|
||||
"component": "VRow",
|
||||
"props": {"style": "margin-top: 8px;"},
|
||||
"content": [
|
||||
{"component": "VCol", "props": {"cols": 12},
|
||||
"content": [{"component": "VAlert", "props": {"type": "info", "variant": "tonal", "text": "选择各报告中包含的信息模块,模块按选择顺序显示在报告中"}}]},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{"component": "VCol", "props": {"cols": 12, "md": 4},
|
||||
"content": [{"component": "VSelect", "props": {
|
||||
"model": "daily_modules", "label": "日报模块",
|
||||
"items": MODULE_OPTIONS, "multiple": True, "chips": True, "closable-chips": True,
|
||||
}}]},
|
||||
{"component": "VCol", "props": {"cols": 12, "md": 4},
|
||||
"content": [{"component": "VSelect", "props": {
|
||||
"model": "weekly_modules", "label": "周报模块",
|
||||
"items": MODULE_OPTIONS, "multiple": True, "chips": True, "closable-chips": True,
|
||||
}}]},
|
||||
{"component": "VCol", "props": {"cols": 12, "md": 4},
|
||||
"content": [{"component": "VSelect", "props": {
|
||||
"model": "monthly_modules", "label": "月报模块",
|
||||
"items": MODULE_OPTIONS, "multiple": True, "chips": True, "closable-chips": True,
|
||||
}}]},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": True,
|
||||
"daily_cron": "0 23 * * *",
|
||||
"weekly_cron": "0 23 * * 1",
|
||||
"monthly_cron": "0 23 1 * *",
|
||||
"onlyonce": False,
|
||||
"test_type": "daily",
|
||||
"daily_modules": DEFAULT_DAILY_MODULES,
|
||||
"weekly_modules": DEFAULT_WEEKLY_MODULES,
|
||||
"monthly_modules": DEFAULT_MONTHLY_MODULES,
|
||||
}
|
||||
|
||||
# ─── 历史记录页面 ───
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
history = self.get_data("history") or []
|
||||
|
||||
# 模块配置摘要
|
||||
def _module_names(modules):
|
||||
return "、".join(MODULES.get(m, m) for m in (modules or []))
|
||||
|
||||
config_cards = [
|
||||
self._config_card('📊 日报模块', _module_names(self._daily_modules), self._daily_cron),
|
||||
self._config_card('📈 周报模块', _module_names(self._weekly_modules), self._weekly_cron),
|
||||
self._config_card('📅 月报模块', _module_names(self._monthly_modules), self._monthly_cron),
|
||||
]
|
||||
|
||||
if not history:
|
||||
return [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': config_cards,
|
||||
},
|
||||
{
|
||||
'component': 'div',
|
||||
'text': '暂无发送记录',
|
||||
'props': {'class': 'text-center mt-4'},
|
||||
},
|
||||
]
|
||||
|
||||
daily_count = sum(1 for r in history if r.get("type") == "daily")
|
||||
weekly_count = sum(1 for r in history if r.get("type") == "weekly")
|
||||
monthly_count = sum(1 for r in history if r.get("type") == "monthly")
|
||||
|
||||
items = [
|
||||
{
|
||||
'time': r.get('time', ''),
|
||||
'type_label': {'daily': '日报', 'weekly': '周报', 'monthly': '月报'}.get(r.get('type'), ''),
|
||||
'title': r.get('title', ''),
|
||||
'preview': (r.get('text', '')[:80] + '...') if len(r.get('text', '')) > 80 else r.get('text', ''),
|
||||
}
|
||||
for r in reversed(history)
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': config_cards + [
|
||||
# 发送统计
|
||||
self._stat_card('日报', f'{daily_count} 份'),
|
||||
self._stat_card('周报', f'{weekly_count} 份'),
|
||||
self._stat_card('月报', f'{monthly_count} 份'),
|
||||
# 历史记录表格
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {'cols': 12, 'class': 'd-none d-sm-block'},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VDataTableVirtual',
|
||||
'props': {
|
||||
'class': 'text-sm',
|
||||
'headers': [
|
||||
{'title': '时间', 'key': 'time', 'sortable': True},
|
||||
{'title': '类型', 'key': 'type_label', 'sortable': True},
|
||||
{'title': '标题', 'key': 'title', 'sortable': False},
|
||||
{'title': '预览', 'key': 'preview', 'sortable': False},
|
||||
],
|
||||
'items': items,
|
||||
'height': '30rem',
|
||||
'density': 'compact',
|
||||
'fixed-header': True,
|
||||
'hide-no-data': True,
|
||||
'hover': True,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _config_card(title: str, modules_text: str, cron: str) -> dict:
|
||||
return {
|
||||
'component': 'VCol',
|
||||
'props': {'cols': 12, 'md': 4},
|
||||
'content': [{
|
||||
'component': 'VCard',
|
||||
'props': {'variant': 'tonal'},
|
||||
'content': [{
|
||||
'component': 'VCardText',
|
||||
'content': [
|
||||
{'component': 'div', 'props': {'class': 'text-subtitle-2 mb-1'}, 'text': f'{title} ⏰ {cron}'},
|
||||
{'component': 'span', 'props': {'class': 'text-caption'}, 'text': modules_text},
|
||||
],
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _stat_card(title: str, value: str) -> dict:
|
||||
return {
|
||||
'component': 'VCol',
|
||||
'props': {'cols': 4, 'md': 4},
|
||||
'content': [{
|
||||
'component': 'VCard',
|
||||
'props': {'variant': 'tonal'},
|
||||
'content': [{
|
||||
'component': 'VCardText',
|
||||
'props': {'class': 'text-center pa-2'},
|
||||
'content': [
|
||||
{'component': 'div', 'props': {'class': 'text-caption'}, 'text': title},
|
||||
{'component': 'div', 'props': {'class': 'text-h6'}, 'text': value},
|
||||
],
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
def stop_service(self):
|
||||
pass
|
||||
|
||||
# ─── 命令处理 ───
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def handle_command(self, event: Event = None):
|
||||
if not event:
|
||||
return
|
||||
action = (event.event_data or {}).get("action", "")
|
||||
handler = {
|
||||
"daily_summary": self.send_daily,
|
||||
"weekly_summary": self.send_weekly,
|
||||
"monthly_summary": self.send_monthly,
|
||||
}.get(action)
|
||||
if handler:
|
||||
handler()
|
||||
|
||||
# ─── 统一报告引擎 ───
|
||||
|
||||
def send_daily(self):
|
||||
header, text = self._build_report("daily")
|
||||
self._send(report_type="daily", title=header, text=text)
|
||||
|
||||
def send_weekly(self):
|
||||
header, text = self._build_report("weekly")
|
||||
self._send(report_type="weekly", title=header, text=text)
|
||||
|
||||
def send_monthly(self):
|
||||
header, text = self._build_report("monthly")
|
||||
self._send(report_type="monthly", title=header, text=text)
|
||||
|
||||
def _build_report(self, report_type: str) -> Tuple[str, str]:
|
||||
logger.info(f"[DailySummary] 开始生成 {report_type} 总结")
|
||||
tr = self._calc_time_range(report_type)
|
||||
modules = {
|
||||
"daily": self._daily_modules,
|
||||
"weekly": self._weekly_modules,
|
||||
"monthly": self._monthly_modules,
|
||||
}.get(report_type, self._daily_modules)
|
||||
|
||||
sections = []
|
||||
for mod in modules:
|
||||
result = self._run_section(mod, tr)
|
||||
if result:
|
||||
sections.append(result)
|
||||
|
||||
header = self._make_header(report_type, tr)
|
||||
text = "\n\n".join(sections) if sections else "无数据"
|
||||
return header, text
|
||||
|
||||
def _calc_time_range(self, report_type: str) -> TimeRange:
|
||||
tz = pytz.timezone(settings.TZ)
|
||||
now = datetime.now(tz)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
if report_type == "daily":
|
||||
start = today_start
|
||||
prefix = "今日"
|
||||
elif report_type == "weekly":
|
||||
start = today_start - timedelta(days=now.weekday())
|
||||
prefix = "本周"
|
||||
else:
|
||||
start = today_start.replace(day=1)
|
||||
prefix = "本月"
|
||||
|
||||
return TimeRange(
|
||||
start=start,
|
||||
end=now,
|
||||
start_str=start.strftime("%Y-%m-%d 00:00:00"),
|
||||
start_date=start.strftime("%Y-%m-%d"),
|
||||
end_date=today_start.strftime("%Y-%m-%d"),
|
||||
report_type=report_type,
|
||||
prefix=prefix,
|
||||
)
|
||||
|
||||
def _make_header(self, report_type: str, tr: TimeRange) -> str:
|
||||
now = tr.end
|
||||
if report_type == "daily":
|
||||
return f"📊 每日总结 ({now.strftime('%m-%d')} {WEEKDAY_NAMES[now.weekday()]})"
|
||||
elif report_type == "weekly":
|
||||
return f"📈 周报 ({tr.start.strftime('%m-%d')} ~ {now.strftime('%m-%d')})"
|
||||
else:
|
||||
return f"📅 月报 ({now.strftime('%Y年%m月')})"
|
||||
|
||||
def _run_section(self, module: str, tr: TimeRange) -> Optional[str]:
|
||||
handler = {
|
||||
"download": self._section_download,
|
||||
"transfer": self._section_transfer,
|
||||
"signin": self._section_signin,
|
||||
"brush": self._section_brush,
|
||||
"downloader": self._section_downloader,
|
||||
"site_delta": self._section_site_delta,
|
||||
"site_current": self._section_site_current,
|
||||
"subscribe": self._section_subscribe,
|
||||
"storage": self._section_storage,
|
||||
}.get(module)
|
||||
if not handler:
|
||||
return None
|
||||
try:
|
||||
return handler(tr)
|
||||
except Exception as e:
|
||||
logger.error(f"[DailySummary] 模块 {module} 执行失败: {e}")
|
||||
return f"【{MODULES.get(module, module)}】数据读取失败"
|
||||
|
||||
# ─── 各模块实现 ───
|
||||
|
||||
def _section_download(self, tr: TimeRange) -> Optional[str]:
|
||||
downloads = self._get_downloads(tr.start_str)
|
||||
if not downloads:
|
||||
return f"【{tr.prefix}下载】无"
|
||||
|
||||
# 日报:详细列表;周报/月报:分类汇总
|
||||
if tr.report_type == "daily":
|
||||
lines = [f"【{tr.prefix}下载 {len(downloads)} 部】"]
|
||||
for d in downloads:
|
||||
ep = f" {d.seasons}{d.episodes}" if d.episodes else (f" {d.seasons}" if d.seasons else "")
|
||||
site = f" - {d.torrent_site}" if d.torrent_site else ""
|
||||
lines.append(f" • {d.title}{ep}{site}")
|
||||
return "\n".join(lines)
|
||||
|
||||
type_count = {}
|
||||
for d in downloads:
|
||||
cat = d.media_category or d.type or "其他"
|
||||
type_count[cat] = type_count.get(cat, 0) + 1
|
||||
type_summary = " | ".join(f"{k} {v}" for k, v in sorted(type_count.items(), key=lambda x: -x[1]))
|
||||
return f"【{tr.prefix}下载】共 {len(downloads)} 部\n {type_summary}"
|
||||
|
||||
def _section_transfer(self, tr: TimeRange) -> Optional[str]:
|
||||
transfers = self._get_transfers(tr.start_str)
|
||||
success = [t for t in transfers if t.status]
|
||||
if not success:
|
||||
return f"【{tr.prefix}入库】无"
|
||||
|
||||
# 日报:详细列表;周报/月报:分类汇总
|
||||
if tr.report_type == "daily":
|
||||
lines = [f"【{tr.prefix}入库 {len(success)} 部】"]
|
||||
for t in success:
|
||||
ep = f" {t.seasons}{t.episodes}" if t.episodes else (f" {t.seasons}" if t.seasons else "")
|
||||
cat = f" → {t.category}" if t.category else ""
|
||||
lines.append(f" • {t.title}{ep}{cat}")
|
||||
return "\n".join(lines)
|
||||
|
||||
cat_count = {}
|
||||
for t in success:
|
||||
cat = t.category or t.type or "其他"
|
||||
cat_count[cat] = cat_count.get(cat, 0) + 1
|
||||
cat_summary = " | ".join(f"{k} {v}" for k, v in sorted(cat_count.items(), key=lambda x: -x[1]))
|
||||
return f"【{tr.prefix}入库】共 {len(success)} 部\n {cat_summary}"
|
||||
|
||||
def _section_signin(self, tr: TimeRange) -> str:
|
||||
pdo = PluginDataOper()
|
||||
plugin_id = "AutoSignIn"
|
||||
now = tr.end
|
||||
key = f"{now.month}月{now.day}日"
|
||||
data = pdo.get_data(plugin_id, key)
|
||||
if not data:
|
||||
return "【签到】今日无签到记录"
|
||||
|
||||
signin_records = [r for r in data if "模拟登录" not in r.get("status", "")]
|
||||
total = len(signin_records)
|
||||
success = sum(1 for r in signin_records if "成功" in r.get("status", ""))
|
||||
failed = [r for r in signin_records if "成功" not in r.get("status", "")]
|
||||
|
||||
if success == total:
|
||||
return f"【签到】✅ 全部成功 ({success}/{total})"
|
||||
|
||||
fail_sites = ", ".join(r["site"] for r in failed)
|
||||
return f"【签到】⚠️ {success}/{total} 成功\n 失败: {fail_sites}"
|
||||
|
||||
def _section_brush(self, tr: TimeRange) -> str:
|
||||
pdo = PluginDataOper()
|
||||
plugin_ids = ["BrushFlow"]
|
||||
|
||||
total_uploaded = 0
|
||||
total_downloaded = 0
|
||||
total_active = 0
|
||||
total_deleted = 0
|
||||
total_count = 0
|
||||
|
||||
for pid in plugin_ids:
|
||||
stat = pdo.get_data(pid, "statistic")
|
||||
if not stat:
|
||||
continue
|
||||
total_uploaded += stat.get("uploaded", 0) + stat.get("active_uploaded", 0)
|
||||
total_downloaded += stat.get("downloaded", 0)
|
||||
total_active += stat.get("active", 0)
|
||||
total_deleted += stat.get("deleted", 0)
|
||||
total_count += stat.get("count", 0)
|
||||
|
||||
return (
|
||||
f"【刷流】总种: {total_count} | 活跃: {total_active} | 已删: {total_deleted}\n"
|
||||
f" 总↑ {_human_size(total_uploaded)} | 总↓ {_human_size(total_downloaded)}"
|
||||
)
|
||||
|
||||
def _section_downloader(self, tr: TimeRange) -> Optional[str]:
|
||||
"""通过 DownloaderHelper 获取所有已配置下载器的概览"""
|
||||
try:
|
||||
from app.helper.downloader import DownloaderHelper
|
||||
except ImportError:
|
||||
logger.warning("[DailySummary] DownloaderHelper 不可用")
|
||||
return None
|
||||
|
||||
services = DownloaderHelper().get_services()
|
||||
if not services:
|
||||
return None
|
||||
|
||||
lines = ["【下载器概览】"]
|
||||
for name, svc in services.items():
|
||||
if not svc or not svc.instance:
|
||||
continue
|
||||
inst = svc.instance
|
||||
completed = inst.get_completed_torrents() or []
|
||||
downloading = inst.get_downloading_torrents() or []
|
||||
total = len(completed) + len(downloading)
|
||||
ti = inst.transfer_info()
|
||||
up_speed = ti.get("up_info_speed", 0) if ti else 0
|
||||
dl_speed = ti.get("dl_info_speed", 0) if ti else 0
|
||||
lines.append(f" {name}: 种子 {total} | ↑{_human_size(up_speed)}/s | ↓{_human_size(dl_speed)}/s")
|
||||
|
||||
return "\n".join(lines) if len(lines) > 1 else None
|
||||
|
||||
def _section_site_delta(self, tr: TimeRange) -> Optional[str]:
|
||||
with ScopedSession() as db:
|
||||
start_data = SiteUserData.get_by_date(db, tr.start_date)
|
||||
end_data = SiteUserData.get_by_date(db, tr.end_date)
|
||||
|
||||
if not start_data or not end_data:
|
||||
return None
|
||||
|
||||
start_map = {d.domain: d for d in start_data}
|
||||
end_map = {d.domain: d for d in end_data}
|
||||
|
||||
label = {"daily": "站点增量", "weekly": "站点周增量", "monthly": "站点月增量"}.get(tr.report_type, "站点增量")
|
||||
lines = [f"【{label}】", " 站点 ↑上传 ↓下载 魔力变化"]
|
||||
|
||||
has_data = False
|
||||
for domain, end in sorted(end_map.items(), key=lambda x: (x[1].upload or 0), reverse=True):
|
||||
start = start_map.get(domain)
|
||||
if not start:
|
||||
continue
|
||||
up_delta = (end.upload or 0) - (start.upload or 0)
|
||||
down_delta = (end.download or 0) - (start.download or 0)
|
||||
bonus_delta = (end.bonus or 0) - (start.bonus or 0)
|
||||
|
||||
no_change = up_delta == 0 and down_delta == 0 and bonus_delta == 0
|
||||
data_anomaly = up_delta < 0 or down_delta < 0
|
||||
if no_change or data_anomaly:
|
||||
continue
|
||||
|
||||
has_data = True
|
||||
name = (end.name or domain)[:6].ljust(6)
|
||||
bonus_str = f"+{bonus_delta:.0f}" if bonus_delta >= 0 else f"{bonus_delta:.0f}"
|
||||
lines.append(f" {name} {_human_size(up_delta):>10} {_human_size(down_delta):>10} {bonus_str:>8}")
|
||||
|
||||
return "\n".join(lines) if has_data else None
|
||||
|
||||
def _section_site_current(self, tr: TimeRange) -> Optional[str]:
|
||||
with ScopedSession() as db:
|
||||
data = SiteUserData.get_latest(db)
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
lines = ["【站点数据】", " 站点 总↑ 总↓ 分享率 魔力"]
|
||||
|
||||
for d in sorted(data, key=lambda x: (x.upload or 0), reverse=True):
|
||||
if not d.upload and not d.download:
|
||||
continue
|
||||
name = (d.name or d.domain)[:6].ljust(6)
|
||||
ratio = f"{d.ratio:.2f}" if d.ratio else "∞"
|
||||
bonus = f"{d.bonus:.0f}" if d.bonus else "0"
|
||||
lines.append(f" {name} {_human_size(d.upload or 0):>10} {_human_size(d.download or 0):>10} {ratio:>6} {bonus:>8}")
|
||||
|
||||
return "\n".join(lines) if len(lines) > 2 else None
|
||||
|
||||
def _section_subscribe(self, tr: TimeRange) -> str:
|
||||
subs = SubscribeOper().list(state="R") or []
|
||||
if not subs:
|
||||
return "【订阅进度】无活跃订阅"
|
||||
|
||||
lines = [f"【订阅进度】{len(subs)} 部进行中"]
|
||||
for s in subs:
|
||||
total = s.total_episode or 0
|
||||
lack = s.lack_episode or 0
|
||||
done = total - lack
|
||||
season = f" S{s.season}" if s.season else ""
|
||||
progress = f" {done}/{total}" if total > 0 else ""
|
||||
lines.append(f" • {s.name}{season}{progress}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _section_storage(self, tr: TimeRange) -> Optional[str]:
|
||||
volumes = self._parse_storage_paths()
|
||||
if not volumes:
|
||||
return None
|
||||
|
||||
lines = ["【存储空间】"]
|
||||
has_data = False
|
||||
for path, label in volumes:
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
stat = os.statvfs(path)
|
||||
total = stat.f_blocks * stat.f_frsize
|
||||
used = (stat.f_blocks - stat.f_bfree) * stat.f_frsize
|
||||
if total == 0:
|
||||
continue
|
||||
has_data = True
|
||||
pct = used / total * 100
|
||||
lines.append(f" {label}: 已用 {_human_size(used)} / {_human_size(total)} ({pct:.0f}%)")
|
||||
return "\n".join(lines) if has_data else None
|
||||
|
||||
def _parse_storage_paths(self) -> List[Tuple[str, str]]:
|
||||
"""自动检测 MP 的 LIBRARY_PATH / DOWNLOAD_PATH"""
|
||||
paths = []
|
||||
if hasattr(settings, "LIBRARY_PATH") and settings.LIBRARY_PATH:
|
||||
paths.append((settings.LIBRARY_PATH, "媒体库"))
|
||||
if hasattr(settings, "DOWNLOAD_PATH") and settings.DOWNLOAD_PATH:
|
||||
paths.append((settings.DOWNLOAD_PATH, "下载目录"))
|
||||
return paths
|
||||
|
||||
# ─── 数据查询 ───
|
||||
|
||||
def _get_downloads(self, since: str) -> list:
|
||||
try:
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
with ScopedSession() as db:
|
||||
return db.query(DownloadHistory).filter(
|
||||
DownloadHistory.date > since
|
||||
).order_by(DownloadHistory.date.desc()).all()
|
||||
except Exception as e:
|
||||
logger.error(f"[DailySummary] 查询下载记录失败: {e}")
|
||||
return []
|
||||
|
||||
def _get_transfers(self, since: str) -> list:
|
||||
try:
|
||||
return TransferHistoryOper().list_by_date(since) or []
|
||||
except Exception as e:
|
||||
logger.error(f"[DailySummary] 查询入库记录失败: {e}")
|
||||
return []
|
||||
|
||||
# ─── 发送通知 + 保存历史 ───
|
||||
|
||||
def _send(self, report_type: str, title: str, text: str):
|
||||
logger.info(f"[DailySummary] {title}\n{text}")
|
||||
|
||||
if self._notify:
|
||||
self.post_message(mtype=NotificationType.Plugin, title=title, text=text)
|
||||
|
||||
# 保存历史记录
|
||||
tz = pytz.timezone(settings.TZ)
|
||||
now = datetime.now(tz)
|
||||
record = {
|
||||
"time": now.strftime("%Y-%m-%d %H:%M"),
|
||||
"type": report_type,
|
||||
"title": title,
|
||||
"text": text,
|
||||
}
|
||||
history = self.get_data("history") or []
|
||||
history.append(record)
|
||||
# 保留最近 MAX_HISTORY 条
|
||||
if len(history) > MAX_HISTORY:
|
||||
history = history[-MAX_HISTORY:]
|
||||
self.save_data("history", history)
|
||||
|
||||
|
||||
# ─── 工具函数 ───
|
||||
|
||||
def _human_size(size_bytes: float) -> str:
|
||||
if size_bytes is None or size_bytes == 0:
|
||||
return "0 B"
|
||||
negative = size_bytes < 0
|
||||
size_bytes = abs(size_bytes)
|
||||
for unit in ["B", "KB", "MB", "GB", "TB", "PB"]:
|
||||
if size_bytes < 1024:
|
||||
formatted = f"{size_bytes:.1f} {unit}" if size_bytes != int(size_bytes) else f"{int(size_bytes)} {unit}"
|
||||
return f"-{formatted}" if negative else formatted
|
||||
size_bytes /= 1024
|
||||
return f"{size_bytes:.1f} EB"
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
File diff suppressed because it is too large
Load Diff
792
plugins.v2/imdbsource/imdbapi.py
Normal file
792
plugins.v2/imdbsource/imdbapi.py
Normal file
@@ -0,0 +1,792 @@
|
||||
from typing import Any, AsyncGenerator, Dict, Generator, List, Optional, Final
|
||||
|
||||
import requests
|
||||
import httpx
|
||||
|
||||
from app.core.cache import cached
|
||||
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, ImdbapiImage
|
||||
from .schema.imdbapi import (ImdbApiSearchTitlesResponse, ImdbApiListTitlesResponse, ImdbApiListTitleEpisodesResponse,
|
||||
ImdbApiListTitleSeasonsResponse, ImdbApiListTitleCreditsResponse,
|
||||
ImdbapiListTitleAKAsResponse, ImdbApiTitleImagesResponse, ImdbapiCompanyCreditResponse)
|
||||
from .schema.imdbtypes import ImdbType
|
||||
|
||||
|
||||
CACHE_LIFESPAN: Final[int] = 86400
|
||||
|
||||
|
||||
class ImdbApiClient:
|
||||
BASE_URL = 'https://api.imdbapi.dev'
|
||||
|
||||
def __init__(self, proxies: Optional[Dict[str, str]] = None, ua: Optional[str] = None) -> None:
|
||||
self._req = RequestUtils(ua=ua, accept_type="application/json",
|
||||
proxies=proxies, session=requests.Session())
|
||||
if proxies:
|
||||
proxy_url = proxies.get("https") or proxies.get("http")
|
||||
else:
|
||||
proxy_url = None
|
||||
self._free_api_client = httpx.AsyncClient(timeout=10, proxy=proxy_url)
|
||||
|
||||
self._async_req = AsyncRequestUtils(
|
||||
ua=ua,
|
||||
accept_type="application/json",
|
||||
client=self._free_api_client
|
||||
)
|
||||
|
||||
@retry(Exception, logger=logger, delay=1)
|
||||
@cached(maxsize=4096, ttl=CACHE_LIFESPAN)
|
||||
def _free_imdb_api(self, path: str, params: Optional[dict] = None) -> Optional[dict]:
|
||||
r = self._req.get_res(url=f"{self.BASE_URL}{path}", params=params, raise_exception=True)
|
||||
if r is None:
|
||||
return None
|
||||
if r.status_code != 200:
|
||||
try:
|
||||
logger.warning(
|
||||
f"Free IMDb API returned non-200 status: {r.status_code} for path={path} params={params}"
|
||||
)
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
return r.json()
|
||||
|
||||
@retry(Exception, logger=logger, delay=1)
|
||||
@cached(maxsize=4096, ttl=CACHE_LIFESPAN)
|
||||
async def _async_free_imdb_api(self, path: str, params: Optional[dict] = None) -> Optional[dict]:
|
||||
r = await self._async_req.get_res(url=f"{self.BASE_URL}{path}", params=params, raise_exception=True)
|
||||
if r is None:
|
||||
return None
|
||||
if r.status_code != 200:
|
||||
try:
|
||||
logger.warning(
|
||||
f"Free IMDb API returned non-200 status: {r.status_code} for path={path} params={params}"
|
||||
)
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
return r.json()
|
||||
|
||||
def search_titles(self, query: str, limit: Optional[int] = None) -> Optional[ImdbApiSearchTitlesResponse]:
|
||||
"""
|
||||
Search for titles using a query string.
|
||||
|
||||
:param query: Required. The search query for titles.
|
||||
:param limit: Optional. Limit the number of results returned. The maximum is 50.
|
||||
:return: Search results.
|
||||
"""
|
||||
path = '/search/titles'
|
||||
params: Dict[str, Any] = {'query': query}
|
||||
if limit:
|
||||
params['limit'] = limit
|
||||
try:
|
||||
r = self._free_imdb_api(path=path, params=params)
|
||||
if r is None:
|
||||
return None
|
||||
ret = ImdbApiSearchTitlesResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while searching for titles: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
async def async_search_titles(self, query: str, limit: Optional[int] = None
|
||||
) -> Optional[ImdbApiSearchTitlesResponse]:
|
||||
endpoint = '/search/titles'
|
||||
params: Dict[str, Any] = {'query': query}
|
||||
if limit:
|
||||
params['limit'] = limit
|
||||
try:
|
||||
r = await self._async_free_imdb_api(path=endpoint, params=params)
|
||||
if r is None:
|
||||
return None
|
||||
ret = ImdbApiSearchTitlesResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while searching for titles: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
def advanced_search(self, query: str, limit: Optional[int] = None,
|
||||
media_types: Optional[List[ImdbType]] = None,
|
||||
year: Optional[int] = None) -> Optional[List[ImdbApiTitle]]:
|
||||
"""
|
||||
Perform an advanced search for titles using a query string with additional filters.
|
||||
|
||||
:param query: The search query for titles.
|
||||
:param limit: The maximum number of results to return.
|
||||
:param media_types: The type of titles to filter by.
|
||||
:param year: The start year for filtering titles.
|
||||
:return: Search results.
|
||||
"""
|
||||
|
||||
data = self.search_titles(query=query, limit=limit)
|
||||
if data is None:
|
||||
return None
|
||||
ret = data.titles
|
||||
if year:
|
||||
ret = [title for title in ret if title.start_year == year]
|
||||
if media_types:
|
||||
ret = [title for title in ret if title.type in media_types]
|
||||
return ret
|
||||
|
||||
async def async_advanced_search(self, query: str, limit: Optional[int] = None,
|
||||
media_types: Optional[List[ImdbType]] = None,
|
||||
year: Optional[int] = None) -> Optional[List[ImdbApiTitle]]:
|
||||
"""
|
||||
Perform an advanced search for titles using a query string with additional filters.
|
||||
|
||||
:param query: The search query for titles.
|
||||
:param limit: The maximum number of results to return.
|
||||
:param media_types: The type of titles to filter by.
|
||||
:param year: The start year for filtering titles.
|
||||
:return: Search results.
|
||||
"""
|
||||
|
||||
res = await self.async_search_titles(query=query, limit=limit)
|
||||
if res is None:
|
||||
return None
|
||||
data = res.titles
|
||||
if year:
|
||||
data = [title for title in res.titles if title.start_year == year]
|
||||
if media_types:
|
||||
data = [title for title in res.titles if title.type in media_types]
|
||||
return data
|
||||
|
||||
def titles(self,
|
||||
types: Optional[List[ImdbType]] = None,
|
||||
genres: Optional[List[str]] = None,
|
||||
country_codes: Optional[List[str]] = None,
|
||||
language_codes: Optional[List[str]] = None,
|
||||
name_ids: Optional[List[str]] = None,
|
||||
interest_ids: Optional[List[str]] = None,
|
||||
start_year: Optional[int] = None,
|
||||
end_year: Optional[int] = None,
|
||||
min_vote_count: Optional[int] = None,
|
||||
max_vote_count: Optional[int] = None,
|
||||
min_aggregate_rating: Optional[float] = None,
|
||||
max_aggregate_rating: Optional[float] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
page_token: Optional[str] = None) -> Optional[ImdbApiListTitlesResponse]:
|
||||
"""
|
||||
Retrieve a list of titles with optional filters.
|
||||
|
||||
:param types: Optional. The type of titles to filter by. If not specified,
|
||||
all types are returned.
|
||||
- MOVIE: Represents a movie title.
|
||||
- TV_SERIES: Represents a TV series title.
|
||||
- TV_MINI_SERIES: Represents a TV miniseries title.
|
||||
- TV_SPECIAL: Represents a TV special title.
|
||||
- TV_MOVIE: Represents a TV movie title.
|
||||
- SHORT: Represents a short title.
|
||||
- VIDEO: Represents a video title.
|
||||
- VIDEO_GAME: Represents a video game title.
|
||||
:param genres: Optional. The genres to filter titles by. If not specified,
|
||||
titles from all genres are returned.
|
||||
:param country_codes: Optional. The ISO 3166-1 alpha-2 country codes to
|
||||
filter titles by. If not specified, titles from all countries are
|
||||
returned. Example: "US" for the United States, "GB" for the United
|
||||
Kingdom.
|
||||
:param language_codes: Optional. The ISO 639-1 or ISO 639-2 language codes
|
||||
to filter titles by. If not specified, titles in all languages are
|
||||
returned.
|
||||
:param name_ids: Optional. The IDs of names to filter titles by.
|
||||
:param interest_ids: Optional. The IDs of interests to filter titles by.
|
||||
If not specified, titles associated with all interests are returned.
|
||||
:param start_year: Optional. The start year for filtering titles.
|
||||
:param end_year: Optional. The end year for filtering titles.
|
||||
:param min_vote_count: Optional. The minimum number of votes a title must
|
||||
have to be included. If not specified, titles with any number of votes
|
||||
are included. The value must be between 0 and 1,000,000,000. Default is 0.
|
||||
:param max_vote_count: Optional. The maximum number of votes a title can
|
||||
have to be included. If not specified, titles with any number of votes
|
||||
are included. The value must be between 0 and 1,000,000,000.
|
||||
:param min_aggregate_rating: Optional. The minimum rating a title must have
|
||||
to be included. If not specified, titles with any rating are included.
|
||||
The value must be between 0.0 and 10.0.
|
||||
:param max_aggregate_rating: Optional. The maximum rating a title can have
|
||||
to be included. If not specified, titles with any rating are included.
|
||||
The value must be between 0.0 and 10.0.
|
||||
:param sort_by: Optional. The sorting order for the titles. If not
|
||||
specified, titles are sorted by popularity.
|
||||
- SORT_BY_POPULARITY: Sort by popularity. Used to rank titles based on
|
||||
viewership, ratings, or cultural impact.
|
||||
- SORT_BY_RELEASE_DATE: Sort by release date. Newer titles typically
|
||||
appear before older ones.
|
||||
- SORT_BY_USER_RATING: Sort by average user rating, reflecting audience
|
||||
reception.
|
||||
- SORT_BY_USER_RATING_COUNT: Sort by number of user ratings, indicating
|
||||
engagement or popularity.
|
||||
- SORT_BY_YEAR: Sort by release year, with newer titles typically first.
|
||||
:param sort_order: Optional. The sorting order for the titles. If not
|
||||
specified, titles are sorted in ascending order.
|
||||
- ASC: Sort in ascending order.
|
||||
- DESC: Sort in descending order.
|
||||
:param page_token: Optional. Token for pagination, if applicable.
|
||||
:return: A dictionary containing the list of titles and pagination info.
|
||||
"""
|
||||
|
||||
path = '/titles'
|
||||
params: Dict[str, Any] = {}
|
||||
if types:
|
||||
params['types'] = [t.value for t in types]
|
||||
if genres:
|
||||
params['genres'] = genres
|
||||
if country_codes:
|
||||
params['countryCodes'] = country_codes
|
||||
if language_codes:
|
||||
params['languageCodes'] = language_codes
|
||||
if name_ids:
|
||||
params['nameIds'] = name_ids
|
||||
if interest_ids:
|
||||
params['interestIds'] = interest_ids
|
||||
if start_year:
|
||||
params['startYear'] = start_year
|
||||
if end_year:
|
||||
params['endYear'] = end_year
|
||||
if min_vote_count:
|
||||
params['minVoteCount'] = min_vote_count
|
||||
if max_vote_count:
|
||||
params['maxVoteCount'] = max_vote_count
|
||||
if min_aggregate_rating:
|
||||
params['minAggregateRating'] = min_aggregate_rating
|
||||
if max_aggregate_rating:
|
||||
params['maxAggregateRating'] = max_aggregate_rating
|
||||
if sort_by:
|
||||
params['sortBy'] = sort_by
|
||||
if sort_order:
|
||||
params['sortOrder'] = sort_order
|
||||
if page_token:
|
||||
params['pageToken'] = page_token
|
||||
|
||||
try:
|
||||
return ImdbApiListTitlesResponse.model_validate(self._free_imdb_api(path=path, params=params))
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while listing titles: {e}")
|
||||
return None
|
||||
|
||||
async def async_titles(self,
|
||||
types: Optional[List[ImdbType]] = None,
|
||||
genres: Optional[List[str]] = None,
|
||||
country_codes: Optional[List[str]] = None,
|
||||
language_codes: Optional[List[str]] = None,
|
||||
name_ids: Optional[List[str]] = None,
|
||||
interest_ids: Optional[List[str]] = None,
|
||||
start_year: Optional[int] = None,
|
||||
end_year: Optional[int] = None,
|
||||
min_vote_count: Optional[int] = None,
|
||||
max_vote_count: Optional[int] = None,
|
||||
min_aggregate_rating: Optional[float] = None,
|
||||
max_aggregate_rating: Optional[float] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
page_token: Optional[str] = None) -> Optional[ImdbApiListTitlesResponse]:
|
||||
path = '/titles'
|
||||
params: Dict[str, Any] = {}
|
||||
if types:
|
||||
params['types'] = [t.value for t in types]
|
||||
if genres:
|
||||
params['genres'] = genres
|
||||
if country_codes:
|
||||
params['countryCodes'] = country_codes
|
||||
if language_codes:
|
||||
params['languageCodes'] = language_codes
|
||||
if name_ids:
|
||||
params['nameIds'] = name_ids
|
||||
if interest_ids:
|
||||
params['interestIds'] = interest_ids
|
||||
if start_year:
|
||||
params['startYear'] = start_year
|
||||
if end_year:
|
||||
params['endYear'] = end_year
|
||||
if min_vote_count:
|
||||
params['minVoteCount'] = min_vote_count
|
||||
if max_vote_count:
|
||||
params['maxVoteCount'] = max_vote_count
|
||||
if min_aggregate_rating:
|
||||
params['minAggregateRating'] = min_aggregate_rating
|
||||
if max_aggregate_rating:
|
||||
params['maxAggregateRating'] = max_aggregate_rating
|
||||
if sort_by:
|
||||
params['sortBy'] = sort_by
|
||||
if sort_order:
|
||||
params['sortOrder'] = sort_order
|
||||
if page_token:
|
||||
params['pageToken'] = page_token
|
||||
|
||||
try:
|
||||
r = await self._async_free_imdb_api(path=path, params=params)
|
||||
if r is None:
|
||||
return None
|
||||
return ImdbApiListTitlesResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while listing titles: {e}")
|
||||
return None
|
||||
|
||||
def titles_generator(self,
|
||||
types: Optional[List[ImdbType]] = None,
|
||||
genres: Optional[List[str]] = None,
|
||||
country_codes: Optional[List[str]] = None,
|
||||
language_codes: Optional[List[str]] = None,
|
||||
name_ids: Optional[List[str]] = None,
|
||||
interest_ids: Optional[List[str]] = None,
|
||||
start_year: Optional[int] = None,
|
||||
end_year: Optional[int] = None,
|
||||
min_vote_count: Optional[int] = None,
|
||||
max_vote_count: Optional[int] = None,
|
||||
min_aggregate_rating: Optional[float] = None,
|
||||
max_aggregate_rating: Optional[float] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
) -> Generator[ImdbApiTitle, None, None]:
|
||||
page_token = None
|
||||
while True:
|
||||
response = self.titles(
|
||||
types=types,
|
||||
genres=genres,
|
||||
country_codes=country_codes,
|
||||
language_codes=language_codes,
|
||||
name_ids=name_ids,
|
||||
interest_ids=interest_ids,
|
||||
start_year=start_year,
|
||||
end_year=end_year,
|
||||
min_vote_count=min_vote_count,
|
||||
max_vote_count=max_vote_count,
|
||||
min_aggregate_rating=min_aggregate_rating,
|
||||
max_aggregate_rating=max_aggregate_rating,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
page_token=page_token
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
for title in response.titles:
|
||||
yield title
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
async def async_titles_generator(self,
|
||||
types: Optional[List[ImdbType]] = None,
|
||||
genres: Optional[List[str]] = None,
|
||||
country_codes: Optional[List[str]] = None,
|
||||
language_codes: Optional[List[str]] = None,
|
||||
name_ids: Optional[List[str]] = None,
|
||||
interest_ids: Optional[List[str]] = None,
|
||||
start_year: Optional[int] = None,
|
||||
end_year: Optional[int] = None,
|
||||
min_vote_count: Optional[int] = None,
|
||||
max_vote_count: Optional[int] = None,
|
||||
min_aggregate_rating: Optional[float] = None,
|
||||
max_aggregate_rating: Optional[float] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
) -> AsyncGenerator[ImdbApiTitle, None]:
|
||||
|
||||
page_token = None
|
||||
while True:
|
||||
response = await self.async_titles(
|
||||
types=types,
|
||||
genres=genres,
|
||||
country_codes=country_codes,
|
||||
language_codes=language_codes,
|
||||
name_ids=name_ids,
|
||||
interest_ids=interest_ids,
|
||||
start_year=start_year,
|
||||
end_year=end_year,
|
||||
min_vote_count=min_vote_count,
|
||||
max_vote_count=max_vote_count,
|
||||
min_aggregate_rating=min_aggregate_rating,
|
||||
max_aggregate_rating=max_aggregate_rating,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
page_token=page_token
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
|
||||
for title in response.titles:
|
||||
yield title
|
||||
|
||||
page_token = response.next_page_token
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
def title(self, title_id: str) -> Optional[ImdbApiTitle]:
|
||||
"""
|
||||
Retrieve a title's details using its IMDb ID.
|
||||
|
||||
:param title_id: The IMDb title ID in the format 'tt1234567'.
|
||||
:return: Details.
|
||||
"""
|
||||
path = '/titles/%s'
|
||||
try:
|
||||
r = self._free_imdb_api(path=path % title_id)
|
||||
ret = ImdbApiTitle.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving details: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
async def async_title(self, title_id: str) -> Optional[ImdbApiTitle]:
|
||||
path = '/titles/%s'
|
||||
try:
|
||||
r = await self._async_free_imdb_api(path=path % title_id)
|
||||
if r is None:
|
||||
return None
|
||||
ret = ImdbApiTitle.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving details: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
def episodes(self, title_id: str, season: Optional[str] = None,
|
||||
page_size: Optional[int] = None, page_token: Optional[str] = None) -> Optional[
|
||||
ImdbApiListTitleEpisodesResponse]:
|
||||
"""
|
||||
Retrieve the episodes associated with a specific title.
|
||||
|
||||
:param title_id: Required. IMDb title ID in the format "tt1234567".
|
||||
:param season: Optional. The season number to filter episodes by.
|
||||
:param page_size: Optional. The maximum number of episodes 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.
|
||||
:return: Episodes.
|
||||
|
||||
"""
|
||||
path = '/titles/%s/episodes'
|
||||
param: Dict[str, Any] = {}
|
||||
if season is not None:
|
||||
param['season'] = season
|
||||
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)
|
||||
ret = ImdbApiListTitleEpisodesResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving episodes: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
async def async_episodes(self, title_id: str, season: Optional[str] = None,
|
||||
page_size: Optional[int] = None, page_token: Optional[str] = None
|
||||
) -> Optional[ImdbApiListTitleEpisodesResponse]:
|
||||
|
||||
path = '/titles/%s/episodes'
|
||||
param: Dict[str, Any] = {}
|
||||
if season is not None:
|
||||
param['season'] = season
|
||||
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 = ImdbApiListTitleEpisodesResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving episodes: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
def episodes_generator(self, title_id: str, season: Optional[str] = None) -> Generator[ImdbApiEpisode, None, None]:
|
||||
page_token = None
|
||||
while True:
|
||||
response = self.episodes(
|
||||
title_id=title_id,
|
||||
season=season,
|
||||
page_size=50,
|
||||
page_token=page_token
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
|
||||
for episode in response.episodes:
|
||||
yield episode
|
||||
|
||||
page_token = response.next_page_token
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
async def async_episodes_generator(self, title_id: str, season: Optional[str] = None
|
||||
) -> AsyncGenerator[ImdbApiEpisode, None]:
|
||||
page_token = None
|
||||
while True:
|
||||
response = await self.async_episodes(
|
||||
title_id=title_id,
|
||||
season=season,
|
||||
page_size=50,
|
||||
page_token=page_token
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
|
||||
for episode in response.episodes:
|
||||
yield episode
|
||||
|
||||
page_token = response.next_page_token
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
def seasons(self, title_id: str) -> Optional[ImdbApiListTitleSeasonsResponse]:
|
||||
"""
|
||||
Retrieve the seasons associated with a specific title.
|
||||
|
||||
:param title_id: Required. IMDb title ID in the format "tt1234567".
|
||||
:return: Seasons.
|
||||
"""
|
||||
path = '/titles/%s/seasons'
|
||||
try:
|
||||
r = self._free_imdb_api(path=path % title_id)
|
||||
ret = ImdbApiListTitleSeasonsResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving seasons: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
async def async_seasons(self, title_id: str) -> Optional[ImdbApiListTitleSeasonsResponse]:
|
||||
path = '/titles/%s/seasons'
|
||||
try:
|
||||
r = await self._async_free_imdb_api(path=path % title_id)
|
||||
if r is None:
|
||||
return None
|
||||
ret = ImdbApiListTitleSeasonsResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving seasons: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
def credits(self, title_id: str, categories: Optional[List[str]] = None,
|
||||
page_size: Optional[int] = None, page_token: Optional[str] = None
|
||||
) -> Optional[ImdbApiListTitleCreditsResponse]:
|
||||
"""
|
||||
Retrieve the credits associated with a specific title.
|
||||
|
||||
:param title_id: Required. IMDb title ID in the format "tt1234567".
|
||||
:param categories: Optional. The categories of credits to filter by.
|
||||
:param page_size: Optional. The maximum number of credits 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.
|
||||
:return: Credits.
|
||||
|
||||
"""
|
||||
path = '/titles/%s/credits'
|
||||
param: Dict[str, Any] = {}
|
||||
if categories:
|
||||
param['categories'] = categories
|
||||
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)
|
||||
ret = ImdbApiListTitleCreditsResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving credits: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
async def async_credits(self, title_id: str, categories: Optional[List[str]] = None,
|
||||
page_size: Optional[int] = None, page_token: Optional[str] = None) -> Optional[
|
||||
ImdbApiListTitleCreditsResponse]:
|
||||
|
||||
path = '/titles/%s/credits'
|
||||
param: Dict[str, Any] = {}
|
||||
if categories:
|
||||
param['categories'] = categories
|
||||
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 = ImdbApiListTitleCreditsResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving credits: {e}")
|
||||
return None
|
||||
return ret
|
||||
|
||||
def credits_generator(self, title_id: str, categories: Optional[List[str]] = None
|
||||
) -> Generator[ImdbApiCredit, None, None]:
|
||||
page_token = None
|
||||
while True:
|
||||
response = self.credits(
|
||||
title_id=title_id,
|
||||
categories=categories,
|
||||
page_size=50,
|
||||
page_token=page_token
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
|
||||
for credit in response.credits:
|
||||
yield credit
|
||||
|
||||
page_token = response.next_page_token
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
async def async_credits_generator(self, title_id: str, categories: Optional[List[str]] = None
|
||||
) -> AsyncGenerator[ImdbApiCredit, None]:
|
||||
page_token = None
|
||||
while True:
|
||||
response = await self.async_credits(
|
||||
title_id=title_id,
|
||||
categories=categories,
|
||||
page_size=50,
|
||||
page_token=page_token
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
|
||||
for credit in response.credits:
|
||||
yield credit
|
||||
|
||||
page_token = response.next_page_token
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
def akas(self, title_id: str) -> Optional[ImdbapiListTitleAKAsResponse]:
|
||||
"""
|
||||
Retrieve the alternative titles (AKAs) associated with a specific title.
|
||||
|
||||
:param title_id: Required. IMDb title ID in the format "tt1234567".
|
||||
:return: AKAs.
|
||||
"""
|
||||
path = '/titles/%s/akas'
|
||||
try:
|
||||
r = self._free_imdb_api(path=path % title_id)
|
||||
ret = ImdbapiListTitleAKAsResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while retrieving alternative titles: {e}")
|
||||
return None
|
||||
if r is None:
|
||||
return None
|
||||
return ret
|
||||
|
||||
async def async_akas(self, title_id: str) -> Optional[ImdbapiListTitleAKAsResponse]:
|
||||
path = '/titles/%s/akas'
|
||||
try:
|
||||
r = await self._async_free_imdb_api(path=path % title_id)
|
||||
if r is None:
|
||||
return None
|
||||
ret = ImdbapiListTitleAKAsResponse.model_validate(r)
|
||||
except Exception as e:
|
||||
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
|
||||
File diff suppressed because one or more lines are too long
613
plugins.v2/imdbsource/officialapi.py
Normal file
613
plugins.v2/imdbsource/officialapi.py
Normal file
@@ -0,0 +1,613 @@
|
||||
import re
|
||||
from textwrap import dedent
|
||||
from typing import Any, Dict, List, Optional, Final, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.cache import cached
|
||||
from app.log import logger
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils, AsyncRequestUtils
|
||||
|
||||
from .schema.imdbtypes import ImdbType
|
||||
from .schema import VerticalList, AdvancedTitleSearchResponse, AdvancedTitleSearch, TitleEdge, SearchParams
|
||||
|
||||
INTERESTS_ID: Final[Dict[str, Dict[str, str]]] = {
|
||||
"Action": {
|
||||
"Action": "in0000001",
|
||||
"Action Epic": "in0000002",
|
||||
"B-Action": "in0000003",
|
||||
"Car Action": "in0000004",
|
||||
"Disaster": "in0000005",
|
||||
"Gun Fu": "in0000197",
|
||||
"Kung Fu": "in0000198",
|
||||
"Martial Arts": "in0000006",
|
||||
"One-Person Army Action": "in0000007",
|
||||
"Samurai": "in0000199",
|
||||
"Superhero": "in0000008",
|
||||
"Sword & Sandal": "in0000009",
|
||||
"War": "in0000010",
|
||||
"War Epic": "in0000011",
|
||||
"Wuxia": "in0000200"
|
||||
},
|
||||
"Adventure": {
|
||||
"Adventure": "in0000012",
|
||||
"Adventure Epic": "in0000015",
|
||||
"Desert Adventure": "in0000013",
|
||||
"Dinosaur Adventure": "in0000014",
|
||||
"Globetrotting Adventure": "in0000016",
|
||||
"Jungle Adventure": "in0000017",
|
||||
"Mountain Adventure": "in0000018",
|
||||
"Quest": "in0000019",
|
||||
"Road Trip": "in0000020",
|
||||
"Sea Adventure": "in0000021",
|
||||
"Swashbuckler": "in0000022",
|
||||
"Teen Adventure": "in0000023",
|
||||
"Urban Adventure": "in0000024"
|
||||
},
|
||||
"Animation": {
|
||||
"Adult Animation": "in0000025",
|
||||
"Animation": "in0000026",
|
||||
"Computer Animation": "in0000028",
|
||||
"Hand-Drawn Animation": "in0000029",
|
||||
"Stop Motion Animation": "in0000030"
|
||||
},
|
||||
"Anime": {
|
||||
"Anime": "in0000027",
|
||||
"Isekai": "in0000201",
|
||||
"Iyashikei": "in0000202",
|
||||
"Josei": "in0000203",
|
||||
"Mecha": "in0000204",
|
||||
"Seinen": "in0000205",
|
||||
"Shōjo": "in0000207",
|
||||
"Shōnen": "in0000206",
|
||||
"Slice of Life": "in0000208"
|
||||
},
|
||||
"Comedy": {
|
||||
"Body Swap Comedy": "in0000031",
|
||||
"Buddy Comedy": "in0000032",
|
||||
"Buddy Cop": "in0000033",
|
||||
"Comedy": "in0000034",
|
||||
"Dark Comedy": "in0000035",
|
||||
"Farce": "in0000036",
|
||||
"High-Concept Comedy": "in0000037",
|
||||
"Mockumentary": "in0000038",
|
||||
"Parody": "in0000039",
|
||||
"Quirky Comedy": "in0000040",
|
||||
"Raunchy Comedy": "in0000041",
|
||||
"Satire": "in0000042",
|
||||
"Screwball Comedy": "in0000043",
|
||||
"Sitcom": "in0000044",
|
||||
"Sketch Comedy": "in0000045",
|
||||
"Slapstick": "in0000046",
|
||||
"Stand-Up": "in0000047",
|
||||
"Stoner Comedy": "in0000048",
|
||||
"Teen Comedy": "in0000049"
|
||||
},
|
||||
"Crime": {
|
||||
"Caper": "in0000050",
|
||||
"Cop Drama": "in0000051",
|
||||
"Crime": "in0000052",
|
||||
"Drug Crime": "in0000053",
|
||||
"Film Noir": "in0000054",
|
||||
"Gangster": "in0000055",
|
||||
"Heist": "in0000056",
|
||||
"Police Procedural": "in0000057",
|
||||
"True Crime": "in0000058"
|
||||
},
|
||||
"Documentary": {
|
||||
"Crime Documentary": "in0000059",
|
||||
"Documentary": "in0000060",
|
||||
"Docuseries": "in0000061",
|
||||
"Faith & Spirituality Documentary": "in0000062",
|
||||
"Food Documentary": "in0000063",
|
||||
"History Documentary": "in0000064",
|
||||
"Military Documentary": "in0000065",
|
||||
"Music Documentary": "in0000066",
|
||||
"Nature Documentary": "in0000067",
|
||||
"Political Documentary": "in0000068",
|
||||
"Science & Technology Documentary": "in0000069",
|
||||
"Sports Documentary": "in0000070",
|
||||
"Travel Documentary": "in0000071"
|
||||
},
|
||||
"Drama": {
|
||||
"Biography": "in0000072",
|
||||
"Coming-of-Age": "in0000073",
|
||||
"Costume Drama": "in0000074",
|
||||
"Docudrama": "in0000075",
|
||||
"Drama": "in0000076",
|
||||
"Epic": "in0000077",
|
||||
"Financial Drama": "in0000078",
|
||||
"Historical Epic": "in0000079",
|
||||
"History": "in0000080",
|
||||
"Korean Drama": "in0000209",
|
||||
"Legal Drama": "in0000081",
|
||||
"Medical Drama": "in0000082",
|
||||
"Period Drama": "in0000083",
|
||||
"Political Drama": "in0000084",
|
||||
"Prison Drama": "in0000085",
|
||||
"Psychological Drama": "in0000086",
|
||||
"Showbiz Drama": "in0000087",
|
||||
"Soap Opera": "in0000088",
|
||||
"Teen Drama": "in0000089",
|
||||
"Telenovela": "in0000210",
|
||||
"Tragedy": "in0000090",
|
||||
"Workplace Drama": "in0000091"
|
||||
},
|
||||
"Family": {
|
||||
"Animal Adventure": "in0000092",
|
||||
"Family": "in0000093"
|
||||
},
|
||||
"Fantasy": {
|
||||
"Dark Fantasy": "in0000095",
|
||||
"Fairy Tale": "in0000097",
|
||||
"Fantasy": "in0000098",
|
||||
"Fantasy Epic": "in0000096",
|
||||
"Supernatural Fantasy": "in0000099",
|
||||
"Sword & Sorcery": "in0000100",
|
||||
"Teen Fantasy": "in0000101"
|
||||
},
|
||||
"Game Show": {
|
||||
"Beauty Competition": "in0000102",
|
||||
"Cooking Competition": "in0000103",
|
||||
"Game Show": "in0000105",
|
||||
"Quiz Show": "in0000104",
|
||||
"Survival Competition": "in0000106",
|
||||
"Talent Competition": "in0000107"
|
||||
},
|
||||
"Horror": {
|
||||
"B-Horror": "in0000108",
|
||||
"Body Horror": "in0000109",
|
||||
"Folk Horror": "in0000110",
|
||||
"Found Footage Horror": "in0000111",
|
||||
"Horror": "in0000112",
|
||||
"Monster Horror": "in0000113",
|
||||
"Psychological Horror": "in0000114",
|
||||
"Slasher Horror": "in0000115",
|
||||
"Splatter Horror": "in0000116",
|
||||
"Supernatural Horror": "in0000117",
|
||||
"Teen Horror": "in0000118",
|
||||
"Vampire Horror": "in0000119",
|
||||
"Werewolf Horror": "in0000120",
|
||||
"Witch Horror": "in0000121",
|
||||
"Zombie Horror": "in0000122"
|
||||
},
|
||||
"Lifestyle": {
|
||||
"Beauty Makeover": "in0000123",
|
||||
"Cooking & Food": "in0000124",
|
||||
"Home Improvement": "in0000125",
|
||||
"Lifestyle": "in0000126",
|
||||
"News": "in0000211",
|
||||
"Talk Show": "in0000127",
|
||||
"Travel": "in0000128"
|
||||
},
|
||||
"Music": {
|
||||
"Concert": "in0000129",
|
||||
"Music": "in0000130"
|
||||
},
|
||||
"Musical": {
|
||||
"Classic Musical": "in0000131",
|
||||
"Jukebox Musical": "in0000132",
|
||||
"Musical": "in0000133",
|
||||
"Pop Musical": "in0000134",
|
||||
"Rock Musical": "in0000135"
|
||||
},
|
||||
"Mystery": {
|
||||
"Bumbling Detective": "in0000136",
|
||||
"Cozy Mystery": "in0000137",
|
||||
"Hard-boiled Detective": "in0000138",
|
||||
"Mystery": "in0000139",
|
||||
"Suspense Mystery": "in0000140",
|
||||
"Whodunnit": "in0000141"
|
||||
},
|
||||
"Reality TV": {
|
||||
"Business Reality TV": "in0000142",
|
||||
"Crime Reality TV": "in0000143",
|
||||
"Dating Reality TV": "in0000144",
|
||||
"Docusoap Reality TV": "in0000145",
|
||||
"Hidden Camera": "in0000146",
|
||||
"Paranormal Reality TV": "in0000147",
|
||||
"Reality TV": "in0000148"
|
||||
},
|
||||
"Romance": {
|
||||
"Dark Romance": "in0000149",
|
||||
"Feel-Good Romance": "in0000151",
|
||||
"Romance": "in0000152",
|
||||
"Romantic Comedy": "in0000153",
|
||||
"Romantic Epic": "in0000150",
|
||||
"Steamy Romance": "in0000154",
|
||||
"Teen Romance": "in0000155",
|
||||
"Tragic Romance": "in0000156"
|
||||
},
|
||||
"Sci-Fi": {
|
||||
"Alien Invasion": "in0000157",
|
||||
"Artificial Intelligence": "in0000158",
|
||||
"Cyberpunk": "in0000159",
|
||||
"Dystopian Sci-Fi": "in0000160",
|
||||
"Kaiju": "in0000161",
|
||||
"Sci-Fi": "in0000162",
|
||||
"Sci-Fi Epic": "in0000163",
|
||||
"Space Sci-Fi": "in0000164",
|
||||
"Steampunk": "in0000165",
|
||||
"Time Travel": "in0000166"
|
||||
},
|
||||
"Seasonal": {
|
||||
"Holiday": "in0000192",
|
||||
"Holiday Animation": "in0000193",
|
||||
"Holiday Comedy": "in0000194",
|
||||
"Holiday Family": "in0000195",
|
||||
"Holiday Romance": "in0000196"
|
||||
},
|
||||
"Short": {
|
||||
"Short": "in0000212"
|
||||
},
|
||||
"Sport": {
|
||||
"Baseball": "in0000167",
|
||||
"Basketball": "in0000168",
|
||||
"Boxing": "in0000169",
|
||||
"Extreme Sport": "in0000170",
|
||||
"Football": "in0000171",
|
||||
"Motorsport": "in0000172",
|
||||
"Soccer": "in0000173",
|
||||
"Sport": "in0000174",
|
||||
"Water Sport": "in0000175"
|
||||
},
|
||||
"Thriller": {
|
||||
"Conspiracy Thriller": "in0000176",
|
||||
"Cyber Thriller": "in0000177",
|
||||
"Erotic Thriller": "in0000178",
|
||||
"Giallo": "in0000179",
|
||||
"Legal Thriller": "in0000180",
|
||||
"Political Thriller": "in0000181",
|
||||
"Psychological Thriller": "in0000182",
|
||||
"Serial Killer": "in0000183",
|
||||
"Spy": "in0000184",
|
||||
"Survival": "in0000185",
|
||||
"Thriller": "in0000186"
|
||||
},
|
||||
"Western": {
|
||||
"Classical Western": "in0000187",
|
||||
"Contemporary Western": "in0000188",
|
||||
"Spaghetti Western": "in0000190",
|
||||
"Western": "in0000191",
|
||||
"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 voteCount} }
|
||||
names(ids: $names) { ...NameParts }
|
||||
videos(ids: $videos) { ...VideoParts }
|
||||
images(ids: $images) { ...ImageParts }
|
||||
}
|
||||
fragment TitleParts on Title {
|
||||
id
|
||||
titleText { text }
|
||||
titleType { id }
|
||||
releaseYear { year }
|
||||
akas(first: 50) { edges { node { text country { id text } language { text } } } }
|
||||
plot { plotText {plainText}}
|
||||
primaryImage { id url width height }
|
||||
releaseDate {day month year}
|
||||
titleGenres {genres {genre { text }}}
|
||||
certificate { rating }
|
||||
originalTitleText{ text }
|
||||
runtime { seconds }
|
||||
}
|
||||
fragment NameParts on Name {
|
||||
id
|
||||
nameText { text }
|
||||
primaryImage { id url width height }
|
||||
}
|
||||
fragment ImageParts on Image {
|
||||
id
|
||||
height
|
||||
width
|
||||
url
|
||||
}
|
||||
fragment VideoParts on Video {
|
||||
id
|
||||
name { value }
|
||||
contentType { displayName { value } id }
|
||||
previewURLs { displayName { value } url videoDefinition videoMimeType }
|
||||
playbackURLs { displayName { value } url videoDefinition videoMimeType }
|
||||
thumbnail { height url width }
|
||||
}
|
||||
""")
|
||||
|
||||
|
||||
class PersistedQueryNotFound(Exception):
|
||||
def __init__(self, message: str, code: int = None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
class OfficialApiClient:
|
||||
BASE_URL = "https://caching.graphql.imdb.com/"
|
||||
|
||||
def __init__(self, proxies: Optional[Dict[str, str]] = None,
|
||||
ua: Optional[str] = None):
|
||||
self._req = RequestUtils(accept_type="application/json",
|
||||
content_type="application/json",
|
||||
timeout=10,
|
||||
ua=ua,
|
||||
proxies=proxies,
|
||||
session=requests.Session())
|
||||
if proxies:
|
||||
proxy_url = proxies.get("https") or proxies.get("http")
|
||||
else:
|
||||
proxy_url = None
|
||||
self._client = httpx.AsyncClient(timeout=10, proxy=proxy_url)
|
||||
self._async_req = AsyncRequestUtils(accept_type="application/json", content_type="application/json",
|
||||
client=self._client, ua=ua)
|
||||
self.flat_interest_id = {}
|
||||
for category, value in INTERESTS_ID.items():
|
||||
for name, in_id in value.items():
|
||||
self.flat_interest_id[name] = in_id
|
||||
|
||||
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
|
||||
async def _async_request(self, params: Dict[str, Any], sha256: str) -> Optional[Dict]:
|
||||
params["extensions"] = {"persistedQuery": {"sha256Hash": sha256, "version": 1}}
|
||||
data = await self._async_req.post_json(f"{self.BASE_URL}", json=params, raise_exception=True)
|
||||
if not data:
|
||||
return None
|
||||
if "errors" in data:
|
||||
error = data.get("errors")[0] if data.get("errors") else {}
|
||||
return {'error': error}
|
||||
return data.get("data")
|
||||
|
||||
@retry(Exception, logger=logger, delay=1)
|
||||
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
|
||||
def _query_graphql(self, query: str, variables: Dict[str, Any]) -> Optional[dict]:
|
||||
params = {'query': query, 'variables': variables}
|
||||
data = self._req.post_json(f"{self.BASE_URL}", json=params, raise_exception=True)
|
||||
if not data:
|
||||
return {'error': 'Query failed.'}
|
||||
if "errors" in data:
|
||||
error = data.get("errors")[0] if data.get("errors") else {}
|
||||
return {'error': error}
|
||||
return data.get("data")
|
||||
|
||||
@retry(Exception, logger=logger, delay=1)
|
||||
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
|
||||
async def _async_query_graphql(self, query: str, variables: Dict[str, Any]) -> Optional[Dict]:
|
||||
params = {'query': query, 'variables': variables}
|
||||
data = await self._async_req.post_json(f"{self.BASE_URL}", json=params, raise_exception=True)
|
||||
if not data:
|
||||
return None
|
||||
if "errors" in data:
|
||||
error = data.get("errors")[0] if data.get("errors") else {}
|
||||
return {'error': error}
|
||||
return data.get("data")
|
||||
|
||||
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
|
||||
def vertical_list_page_items(self,
|
||||
titles: Optional[List[str]] = None,
|
||||
names: Optional[List[str]] = None,
|
||||
images: Optional[List[str]] = None,
|
||||
videos: Optional[List[str]] = None,
|
||||
is_registered: bool = False
|
||||
) -> Optional[VerticalList]:
|
||||
variables = {'images': images or [],
|
||||
'titles': titles or [],
|
||||
'names': names or [],
|
||||
'videos': videos or [],
|
||||
'isRegistered': is_registered,
|
||||
}
|
||||
try:
|
||||
data = self._query_graphql(IMDB_GRAPHQL_QUERY, variables)
|
||||
if 'error' in data:
|
||||
error = data['error']
|
||||
if error:
|
||||
logger.error(f"Error querying VerticalListPageItems: {error}")
|
||||
return None
|
||||
ret = VerticalList.model_validate(data)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while querying VerticalListPageItems: {e}")
|
||||
return None
|
||||
|
||||
return ret
|
||||
|
||||
@cached(maxsize=1024, ttl=CACHE_LIFETIME)
|
||||
async def async_vertical_list_page_items(self,
|
||||
titles: Optional[List[str]] = None,
|
||||
names: Optional[List[str]] = None,
|
||||
images: Optional[List[str]] = None,
|
||||
videos: Optional[List[str]] = None,
|
||||
is_registered: bool = False
|
||||
) -> Optional[VerticalList]:
|
||||
variables = {'images': images or [],
|
||||
'titles': titles or [],
|
||||
'names': names or [],
|
||||
'videos': videos or [],
|
||||
'isRegistered': is_registered,
|
||||
}
|
||||
try:
|
||||
data = await self._async_query_graphql(IMDB_GRAPHQL_QUERY, variables)
|
||||
if 'error' in data:
|
||||
error = data['error']
|
||||
if error:
|
||||
logger.error(f"Error querying VerticalListPageItems: {error}")
|
||||
return None
|
||||
ret = VerticalList.model_validate(data)
|
||||
except Exception as e:
|
||||
logger.debug(f"An error occurred while querying VerticalListPageItems: {e}")
|
||||
return None
|
||||
|
||||
return ret
|
||||
|
||||
@retry(Exception, logger=logger, delay=1)
|
||||
async def async_advanced_title_search(self,
|
||||
params: SearchParams,
|
||||
sha256: str,
|
||||
last_cursor: Optional[str] = None,
|
||||
) -> Optional[AdvancedTitleSearch]:
|
||||
|
||||
variables: Dict[str, Any] = {"first": 50,
|
||||
"locale": "en-US",
|
||||
"sortBy": params.sort_by,
|
||||
"sortOrder": params.sort_order,
|
||||
}
|
||||
operation_name = 'AdvancedTitleSearch'
|
||||
if params.title_types:
|
||||
title_type_ids = []
|
||||
for title_type in params.title_types:
|
||||
if title_type in ImdbType._value2member_map_:
|
||||
title_type_ids.append(title_type)
|
||||
if len(title_type_ids):
|
||||
variables["titleTypeConstraint"] = {"anyTitleTypeIds": title_type_ids}
|
||||
if params.genres:
|
||||
variables["genreConstraint"] = {"allGenreIds": params.genres, "excludeGenreIds": []}
|
||||
if params.countries:
|
||||
variables["originCountryConstraint"] = {"allCountries": params.countries}
|
||||
if params.languages:
|
||||
variables["languageConstraint"] = {"anyPrimaryLanguages": params.languages}
|
||||
if params.rating_min or params.rating_max:
|
||||
rating_min = params.rating_min if params.rating_min else 1
|
||||
rating_min = max(rating_min, 1)
|
||||
rating_max = params.rating_max if params.rating_max else 10
|
||||
rating_max = min(rating_max, 10)
|
||||
variables["userRatingsConstraint"] = {"aggregateRatingRange": {"max": rating_max, "min": rating_min}}
|
||||
if params.release_date_start or params.release_date_end:
|
||||
release_dict = {}
|
||||
if params.release_date_start:
|
||||
release_dict["start"] = params.release_date_start
|
||||
if params.release_date_end:
|
||||
release_dict["end"] = params.release_date_end
|
||||
variables["releaseDateConstraint"] = {"releaseDateRange": release_dict}
|
||||
if params.award_constraint:
|
||||
constraints = []
|
||||
for award in params.award_constraint:
|
||||
c = self._award_to_constraint(award)
|
||||
if c:
|
||||
constraints.append(c)
|
||||
variables["awardConstraint"] = {"allEventNominations": constraints}
|
||||
if params.ranked:
|
||||
constraints = []
|
||||
for r in params.ranked:
|
||||
c = OfficialApiClient._ranked_list_to_constraint(r)
|
||||
if c:
|
||||
constraints.append(c)
|
||||
variables["rankedTitleListConstraint"] = {"allRankedTitleLists": constraints,
|
||||
"excludeRankedTitleLists": []}
|
||||
if params.interests:
|
||||
constraints = []
|
||||
for interest in params.interests:
|
||||
in_id = self.flat_interest_id.get(interest)
|
||||
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
|
||||
|
||||
params = {"operationName": operation_name,
|
||||
"variables": variables}
|
||||
data = await self._async_request(params, sha256)
|
||||
if not data:
|
||||
return None
|
||||
if 'error' in data:
|
||||
error = data['error']
|
||||
if error:
|
||||
if error.get('message') == 'PersistedQueryNotFound':
|
||||
await self._async_request.cache_clear()
|
||||
raise PersistedQueryNotFound(error['message'])
|
||||
return None
|
||||
try:
|
||||
ret = AdvancedTitleSearchResponse.model_validate(data)
|
||||
except ValidationError as err:
|
||||
logger.error(f"{err}")
|
||||
return None
|
||||
return ret.advanced_title_search
|
||||
|
||||
async def advanced_title_search_generator(self, params: SearchParams, sha256: str) -> AsyncGenerator[
|
||||
TitleEdge, None]:
|
||||
last_cursor = None
|
||||
while True:
|
||||
response = await self.async_advanced_title_search(params, sha256, last_cursor=last_cursor)
|
||||
if not response:
|
||||
return
|
||||
|
||||
for edge in response.edges:
|
||||
yield edge
|
||||
|
||||
last_cursor = response.page_info.end_cursor
|
||||
if not last_cursor or not response.page_info.has_next_page:
|
||||
break
|
||||
|
||||
@staticmethod
|
||||
def _ranked_list_to_constraint(ranked: str) -> Optional[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"
|
||||
"""
|
||||
pattern = r'^(TOP_RATED_MOVIES|LOWEST_RATED_MOVIES)-(\d+)$'
|
||||
match = re.match(pattern, ranked)
|
||||
if match:
|
||||
ranked_title_list_type = match.group(1)
|
||||
rank_range = int(match.group(2))
|
||||
constraint = {"rankRange": {"max": rank_range}, "rankedTitleListType": ranked_title_list_type}
|
||||
return constraint
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _award_to_constraint(award: str) -> Optional[Dict]:
|
||||
pattern = r'^(ev\d+)(?:-(best\w+))?-(Winning|Nominated)$'
|
||||
match = re.match(pattern, award)
|
||||
constraint = {}
|
||||
if match:
|
||||
# 第一部分:evXXXXXXXX
|
||||
ev_id = match.group(1)
|
||||
# 第二部分:bestXX(可选)
|
||||
best = match.group(2)
|
||||
# 第三部分:Winning/Nominated
|
||||
status = match.group(3)
|
||||
constraint["eventId"] = ev_id
|
||||
if status == "Winning":
|
||||
constraint["winnerFilter"] = "WINNER_ONLY"
|
||||
if best:
|
||||
constraint["searchAwardCategoryId"] = best
|
||||
return constraint
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def interests_id(self) -> Dict[str, str]:
|
||||
return self.flat_interest_id
|
||||
139
plugins.v2/imdbsource/schema/__init__.py
Normal file
139
plugins.v2/imdbsource/schema/__init__.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from enum import Enum
|
||||
from typing import Optional, List, Tuple, Union
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
from .imdbapi import ImdbApiTitle, ImdbApiEpisode, ImdbApiCredit, ImdbapiImage
|
||||
from .imdbtypes import ImdbTitle, ImdbName, ImdbImage, ImdbVideo, AkasNode, TitleEdge
|
||||
|
||||
|
||||
class ErrorType(Enum):
|
||||
PERSISTED_QUERY_NOT_FOUND = 'PERSISTED_QUERY_NOT_FOUND'
|
||||
|
||||
|
||||
class StaffPickEntry(BaseModel):
|
||||
name: str
|
||||
ttconst: str = Field(..., alias='id')
|
||||
rmconst: str
|
||||
detail: Optional[str] = ""
|
||||
description: Optional[str] = ""
|
||||
relatedconst: List[str] = Field(default_factory=list, alias='relatedConst')
|
||||
viconst: Optional[str] = None
|
||||
|
||||
|
||||
class VerticalList(BaseModel):
|
||||
titles: List[ImdbTitle] = Field(default_factory=list)
|
||||
names: List[ImdbName] = Field(default_factory=list)
|
||||
videos: List[ImdbVideo] = Field(default_factory=list)
|
||||
images: List[ImdbImage] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StaffPickApiResponse(BaseModel):
|
||||
updated_at: Optional[str]
|
||||
entries: List[StaffPickEntry] = Field(default_factory=list)
|
||||
imdb_items: VerticalList
|
||||
|
||||
|
||||
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(
|
||||
cls,
|
||||
title: ImdbApiTitle,
|
||||
akas: Optional[List[AkasNode]] = None,
|
||||
episodes: Optional[List[ImdbApiEpisode]] = None,
|
||||
api_credits: Optional[List[ImdbApiCredit]] = None,
|
||||
images: Optional[List[ImdbapiImage]] = None
|
||||
) -> "ImdbMediaInfo":
|
||||
fields = {
|
||||
**title.model_dump(exclude_none=True, by_alias=True),
|
||||
}
|
||||
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")
|
||||
|
||||
|
||||
class PageInfo(BaseModel):
|
||||
has_previous_page: Optional[bool] = Field(None, alias="hasPreviousPage")
|
||||
has_next_page: Optional[bool] = Field(None, alias="hasNextPage")
|
||||
start_cursor: Optional[str] = Field(None, alias="startCursor")
|
||||
end_cursor: Optional[str] = Field(None, alias="endCursor")
|
||||
|
||||
|
||||
class FilterInfo(BaseModel):
|
||||
filter_id: Optional[str] = Field(default=None, alias='filterId')
|
||||
text: Optional[str] = Field(default=None, alias='text')
|
||||
total: Optional[int] = Field(default=None, alias='total')
|
||||
|
||||
|
||||
class SearchState(BaseModel):
|
||||
total: int = 0
|
||||
page_info: PageInfo = Field(default_factory=PageInfo, alias="pageInfo")
|
||||
genres: List[FilterInfo] = Field(default_factory=list)
|
||||
keywords: List[FilterInfo] = Field(default_factory=list)
|
||||
title_types: List[FilterInfo] = Field(default_factory=list, alias='titleTypes')
|
||||
|
||||
|
||||
class AdvancedTitleSearch(SearchState):
|
||||
edges: List[TitleEdge] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdvancedTitleSearchResponse(BaseModel):
|
||||
advanced_title_search: AdvancedTitleSearch = Field(default_factory=AdvancedTitleSearch, alias="advancedTitleSearch")
|
||||
|
||||
|
||||
class SearchParams(BaseModel):
|
||||
title_types: Optional[Tuple[str, ...]] = None
|
||||
genres: Optional[Tuple[str, ...]] = None
|
||||
sort_by: str = 'POPULARITY'
|
||||
sort_order: str = 'ASC'
|
||||
rating_min: Optional[float] = None
|
||||
rating_max: Optional[float] = None
|
||||
countries: Optional[Tuple[str, ...]] = None
|
||||
languages: Optional[Tuple[str, ...]] = None
|
||||
release_date_end: Optional[str] = None
|
||||
release_date_start: Optional[str] = None
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
class ErrorExtension(BaseModel):
|
||||
code: Union[ErrorType, str]
|
||||
error_type: str = Field('CLIENT', alias='errorType')
|
||||
is_retryable: bool = Field(False, alias='isRetryable')
|
||||
|
||||
|
||||
class ErrorValue(BaseModel):
|
||||
message: Optional[str] = Field(default=None, alias='message')
|
||||
extensions: Optional[ErrorExtension]
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
errors: Optional[List[ErrorValue]] = Field(default_factory=list)
|
||||
173
plugins.v2/imdbsource/schema/imdbapi.py
Normal file
173
plugins.v2/imdbsource/schema/imdbapi.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .imdbtypes import ImdbType, RatingsSummary, AkasNode, ImdbDate
|
||||
|
||||
|
||||
class ImdbapiImage(BaseModel):
|
||||
url: Optional[str] = None
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
type: Optional[str] = None
|
||||
|
||||
|
||||
class ImdbApiMetacritic(BaseModel):
|
||||
url: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
review_count: Optional[int] = Field(None, alias='reviewCount')
|
||||
|
||||
|
||||
class ImdbApiMeterRanking(BaseModel):
|
||||
current_rank: Optional[int] = Field(None, alias='currentRank')
|
||||
change_direction: Optional[str] = Field(None, alias='changeDirection')
|
||||
difference: Optional[int] = None
|
||||
|
||||
|
||||
class ImdbApiPerson(BaseModel):
|
||||
id: Optional[str] = None
|
||||
display_name: Optional[str] = Field(None, alias='displayName')
|
||||
alternative_names: Optional[List[str]] = Field(None, alias='alternativeNames')
|
||||
primary_image: Optional[ImdbapiImage] = Field(None, alias='primaryImage')
|
||||
primary_professions: Optional[List[str]] = Field(None, alias='primaryProfessions')
|
||||
biography: Optional[str] = None
|
||||
height_cm: Optional[float] = Field(None, alias='heightCm')
|
||||
birth_name: Optional[str] = Field(None, alias='birthName')
|
||||
birth_date: Optional[ImdbDate] = Field(None, alias='birthDate')
|
||||
birth_location: Optional[str] = Field(None, alias='birthLocation')
|
||||
death_date: Optional[ImdbDate] = Field(None, alias='deathDate')
|
||||
death_location: Optional[str] = Field(None, alias='deathLocation')
|
||||
death_reason: Optional[str] = Field(None, alias='deathReason')
|
||||
meter_ranking: Optional[ImdbApiMeterRanking] = Field(None, alias='meterRanking')
|
||||
|
||||
|
||||
class ImdbApiCountry(BaseModel):
|
||||
# The ISO 3166-1 alpha-2 country code for the title, (e.g. "US" for the United States, "JP" for Japan)
|
||||
code: Optional[str] = None
|
||||
# The name of the country in English.
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class ImdbApiLanguage(BaseModel):
|
||||
# The ISO 639-3 language code for the title, (e.g. "eng" for English, "jpn" for Japanese)
|
||||
code: Optional[str] = None
|
||||
# The name of the language in English.
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class ImdbapiPrecisionDate(BaseModel):
|
||||
year: Optional[int] = None
|
||||
month: Optional[int] = None
|
||||
day: Optional[int] = None
|
||||
|
||||
|
||||
class ImdbApiInterest(BaseModel):
|
||||
id: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
primary_image: Optional[ImdbapiImage] = Field(None, alias='primaryImage')
|
||||
description: Optional[str] = None
|
||||
is_subgenre: Optional[bool] = Field(None, alias='isSubgenre')
|
||||
similar_interests: Optional[List['ImdbApiInterest']] = Field(None, alias='similarInterests')
|
||||
|
||||
|
||||
class ImdbApiTitle(BaseModel):
|
||||
id: str
|
||||
type: ImdbType
|
||||
is_adult: Optional[bool] = Field(None, alias='isAdult')
|
||||
primary_title: Optional[str] = Field(None, alias='primaryTitle')
|
||||
original_title: Optional[str] = Field(None, alias='originalTitle')
|
||||
primary_image: Optional[ImdbapiImage] = Field(None, alias='primaryImage')
|
||||
start_year: Optional[int] = Field(None, alias='startYear')
|
||||
end_year: Optional[int] = Field(None, alias='endYear')
|
||||
runtime_seconds: Optional[int] = Field(None, alias='runtimeSeconds')
|
||||
genres: Optional[List[str]] = None
|
||||
rating: Optional[RatingsSummary] = None
|
||||
metacritic: Optional[ImdbApiMetacritic] = None
|
||||
plot: Optional[str] = None
|
||||
directors: Optional[List[ImdbApiPerson]] = Field(default_factory=list)
|
||||
writers: Optional[List[ImdbApiPerson]] = Field(default_factory=list)
|
||||
stars: Optional[List[ImdbApiPerson]] = Field(default_factory=list)
|
||||
origin_countries: Optional[List[ImdbApiCountry]] = Field(default_factory=list, alias='originCountries')
|
||||
spoken_languages: Optional[List[ImdbApiLanguage]] = Field(default_factory=list, alias='spokenLanguages')
|
||||
interests: Optional[List[ImdbApiInterest]] = None
|
||||
|
||||
|
||||
class ImdbApiSearchTitlesResponse(BaseModel):
|
||||
titles: List[ImdbApiTitle]
|
||||
|
||||
|
||||
class ImdbApiListTitlesResponse(BaseModel):
|
||||
titles: List[ImdbApiTitle] = Field(default_factory=list)
|
||||
total_count: int = Field(alias='totalCount')
|
||||
next_page_token: Optional[str] = Field(None, alias='nextPageToken')
|
||||
|
||||
|
||||
class ImdbApiEpisode(BaseModel):
|
||||
id: str
|
||||
title: Optional[str] = None
|
||||
primary_image: Optional[ImdbapiImage] = Field(None, alias='primaryImage')
|
||||
season: Optional[str] = Field(None, alias='season')
|
||||
episode_number: Optional[int] = Field(None, alias='episodeNumber')
|
||||
runtime_seconds: Optional[int] = Field(None, alias='runtimeSeconds')
|
||||
plot: Optional[str] = Field(None, alias='plot')
|
||||
rating: Optional[RatingsSummary] = Field(None, alias='rating')
|
||||
release_date: Optional[ImdbapiPrecisionDate] = Field(None, alias='releaseDate')
|
||||
|
||||
|
||||
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')
|
||||
|
||||
|
||||
class ImdbApiListTitleSeasonsResponse(BaseModel):
|
||||
seasons: List[ImdbApiSeason] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ImdbApiCredit(BaseModel):
|
||||
title: Optional[ImdbApiTitle] = None
|
||||
name: Optional[ImdbApiPerson] = None
|
||||
category: Optional[str] = None
|
||||
characters: Optional[List[str]] = None
|
||||
episode_count: Optional[int] = Field(None, alias='episodeCount')
|
||||
|
||||
|
||||
class ImdbApiListTitleCreditsResponse(PagedResponse):
|
||||
credits: List[ImdbApiCredit] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ImdbapiAka(AkasNode):
|
||||
attributes: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
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')
|
||||
241
plugins.v2/imdbsource/schema/imdbtypes.py
Normal file
241
plugins.v2/imdbsource/schema/imdbtypes.py
Normal file
@@ -0,0 +1,241 @@
|
||||
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
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Event
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
@@ -33,7 +34,7 @@ class IYUUAutoSeed(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "IYUU.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.14"
|
||||
plugin_version = "2.15"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp,CKun"
|
||||
# 作者主页
|
||||
@@ -1224,6 +1225,12 @@ class IYUUAutoSeed(_PluginBase):
|
||||
"""
|
||||
return True if "monikadesign." in url else False
|
||||
|
||||
def __is_gpw(url: str):
|
||||
"""
|
||||
判断是否为gpw站点
|
||||
"""
|
||||
return True if "greatposterwall." in url else False
|
||||
|
||||
def __get_mteam_enclosure(tid: str, apikey: str):
|
||||
"""
|
||||
获取mteam种子下载链接
|
||||
@@ -1264,6 +1271,69 @@ class IYUUAutoSeed(_PluginBase):
|
||||
rsskey = rss_match.group(1)
|
||||
return f"{site.get('url')}torrents/download/{tid}.{rsskey}"
|
||||
|
||||
def __get_gpw_torrent_url_from_page(seed: dict, site: dict):
|
||||
"""
|
||||
从详情页面获取下载链接
|
||||
"""
|
||||
if not site.get('url'):
|
||||
logger.warn(f"站点 {site.get('name')} 未获取站点地址,无法获取种子下载链接")
|
||||
return None
|
||||
|
||||
try:
|
||||
page_url = f"{site.get('url')}torrents.php?torrentid={seed.get('torrent_id')}&hit=1"
|
||||
logger.info(f"正在获取种子下载链接:{page_url} ...")
|
||||
|
||||
res = RequestUtils(
|
||||
cookies=site.get("cookie"),
|
||||
ua=site.get("ua") or settings.USER_AGENT,
|
||||
proxies=settings.PROXY if site.get("proxy") else None
|
||||
).get_res(url=page_url)
|
||||
|
||||
|
||||
if res is None or res.status_code not in (200, 500):
|
||||
logger.error(f"获取种子下载链接失败,请求失败:{page_url},{res.status_code if res else ''}")
|
||||
return None
|
||||
# Fix encoding
|
||||
if "charset=utf-8" in res.text or "charset=UTF-8" in res.text:
|
||||
res.encoding = "UTF-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
|
||||
if not res.text:
|
||||
logger.warn(f"获取种子下载链接失败,页面内容为空:{page_url}")
|
||||
return None
|
||||
# 使用xpath从页面中获取下载链接
|
||||
html = etree.HTML(res.text)
|
||||
if html is None:
|
||||
logger.warning(f"解析页面失败:{page_url}")
|
||||
return None
|
||||
|
||||
xpath = "//a[contains(@href, 'torrents.php?action=download')]/@href"
|
||||
urls = html.xpath(xpath)
|
||||
|
||||
if not urls:
|
||||
logger.warning(f"获取种子下载链接失败,未找到下载链接:{page_url}")
|
||||
return None
|
||||
|
||||
torrent_id = str(seed.get("torrent_id"))
|
||||
matched_url = None
|
||||
# Strict match using regex id=xxxx
|
||||
for u in urls:
|
||||
if re.search(rf"id={torrent_id}(?:&|$)", u):
|
||||
matched_url = u
|
||||
break
|
||||
if not matched_url:
|
||||
logger.warning(f"未找到与 torrent_id={torrent_id} 对应的下载链接")
|
||||
return None
|
||||
|
||||
final_url = urljoin(site['url'], matched_url)
|
||||
|
||||
logger.info(f"获取种子下载链接成功:{final_url}")
|
||||
return final_url
|
||||
except Exception as e:
|
||||
logger.warn(f"获取种子下载链接失败:{str(e)}")
|
||||
return None
|
||||
|
||||
def __is_special_site(url: str):
|
||||
"""
|
||||
判断是否为特殊站点
|
||||
@@ -1288,6 +1358,10 @@ class IYUUAutoSeed(_PluginBase):
|
||||
if __is_monika(site.get('url')):
|
||||
# 返回种子id和站点配置中所Monika的rss链接
|
||||
return __get_monika_torrent(tid=seed.get("torrent_id"), rssurl=site.get("rss"))
|
||||
if __is_gpw(site.get('url')):
|
||||
# 从详情页面获取下载链接
|
||||
return __get_gpw_torrent_url_from_page(seed=seed, site=site)
|
||||
|
||||
elif __is_special_site(site.get('url')):
|
||||
# 从详情页面获取下载链接
|
||||
return self.__get_torrent_url_from_page(seed=seed, site=site)
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
# 美剧生词标注
|
||||
|
||||
根据CEFR等级,为英语影视剧标注高级词汇。
|
||||
___
|
||||
在影视剧入库后,LexiAnnot 会读取媒体文件的MediaInfo和文件列表,如果视频的原始语言为英语并且包含英文文本字幕,LexiAnnot将为其生成包含词汇注释的`.en.ass`字幕文件。
|
||||
|
||||
在影视剧入库后,LexiAnnot会读取媒体文件的MediaInfo和文件列表,如果视频的原始语言为英语并且包含英文文本字幕,LexiAnnot将为其生成包含词汇注释的.ass字幕文件。
|
||||
## 主要功能
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
# 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
130
plugins.v2/lexiannot/agenttool.py
Normal file
130
plugins.v2/lexiannot/agenttool.py
Normal 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)}"
|
||||
116
plugins.v2/lexiannot/lexicon.py
Normal file
116
plugins.v2/lexiannot/lexicon.py
Normal 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")
|
||||
731
plugins.v2/lexiannot/pipeline.py
Normal file
731
plugins.v2/lexiannot/pipeline.py
Normal 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)
|
||||
@@ -1,220 +0,0 @@
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
from typing import List, Dict, Any, Type, Union
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
|
||||
class Context(BaseModel):
|
||||
original_text: str
|
||||
|
||||
|
||||
class Vocabulary(BaseModel):
|
||||
lemma: str
|
||||
Chinese: str
|
||||
|
||||
|
||||
class VocabularyTranslationTask(BaseModel):
|
||||
index: int
|
||||
vocabulary: List[Vocabulary]
|
||||
context: Context
|
||||
|
||||
|
||||
class DialogueTranslationTask(BaseModel):
|
||||
index: int
|
||||
original_text: str
|
||||
Chinese: str
|
||||
|
||||
|
||||
class GeminiResponse(BaseModel):
|
||||
tasks: List[Union[VocabularyTranslationTask, DialogueTranslationTask]]
|
||||
total_token_count: int
|
||||
success: bool
|
||||
message: str = ""
|
||||
|
||||
|
||||
def validate_input_data(request_data: Dict[str, Any]) -> None:
|
||||
"""Validate the input data structure"""
|
||||
if not isinstance(request_data, dict):
|
||||
raise ValueError("Input data must be a dictionary")
|
||||
if "tasks" not in request_data:
|
||||
raise ValueError("Missing 'tasks' in input data")
|
||||
if "params" not in request_data:
|
||||
raise ValueError("Missing 'params' in input data")
|
||||
|
||||
params = request_data["params"]
|
||||
required_params = ["api_key", "system_instruction", "schema"]
|
||||
for param in required_params:
|
||||
if param not in params:
|
||||
raise ValueError(f"Missing required parameter: {param}")
|
||||
|
||||
|
||||
def get_task_schema(schema_name: str) -> Type[BaseModel]:
|
||||
"""Get the appropriate schema class based on the schema name"""
|
||||
schema_map = {
|
||||
'DialogueTranslationTask': DialogueTranslationTask,
|
||||
'VocabularyTranslationTask': VocabularyTranslationTask
|
||||
}
|
||||
if schema_name not in schema_map:
|
||||
raise ValueError(f"Unknown schema name: {schema_name}")
|
||||
return schema_map[schema_name]
|
||||
|
||||
|
||||
def query_gemini(
|
||||
api_key: str,
|
||||
translation_tasks: List[Dict[str, Any]],
|
||||
task_schema: Type[Union[VocabularyTranslationTask, DialogueTranslationTask]],
|
||||
system_instruction: str,
|
||||
gemini_model: str = "gemini-2.0-flash",
|
||||
temperature: float = 0.3,
|
||||
max_retries: int = 3,
|
||||
retry_delay: int = 10
|
||||
) -> GeminiResponse:
|
||||
"""
|
||||
Query the Gemini API for translation tasks with retry logic.
|
||||
|
||||
Args:
|
||||
api_key: Gemini API key
|
||||
translation_tasks: List of translation tasks
|
||||
task_schema: Pydantic model for the task type
|
||||
system_instruction: System instruction for the model
|
||||
gemini_model: Model name to use
|
||||
temperature: Generation temperature
|
||||
max_retries: Number of retry attempts
|
||||
retry_delay: Delay between retries in seconds
|
||||
|
||||
Returns:
|
||||
GeminiResponse containing the results
|
||||
"""
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from google.genai.types import SchemaUnion
|
||||
client = genai.Client(api_key=api_key)
|
||||
messages = []
|
||||
translation_res = []
|
||||
total_token_count = 0
|
||||
|
||||
# Validate input tasks before sending to API
|
||||
try:
|
||||
translation_res = [task_schema(**task) for task in translation_tasks]
|
||||
except ValidationError as e:
|
||||
return GeminiResponse(
|
||||
tasks=[],
|
||||
total_token_count=0,
|
||||
success=False,
|
||||
message=f"Input validation failed: {str(e)}"
|
||||
)
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model=gemini_model,
|
||||
contents=json.dumps(translation_tasks, ensure_ascii=False),
|
||||
config=types.GenerateContentConfig(
|
||||
system_instruction=system_instruction,
|
||||
response_mime_type="application/json",
|
||||
response_schema=list[task_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,
|
||||
total_token_count=total_token_count,
|
||||
success=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
messages.append(f"Attempt {attempt} failed: {str(e)}")
|
||||
if attempt < max_retries:
|
||||
time.sleep(retry_delay)
|
||||
|
||||
return GeminiResponse(
|
||||
tasks=[],
|
||||
total_token_count=0,
|
||||
success=False,
|
||||
message="All retry attempts failed. " + "\n".join(messages)
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
# Read and parse input
|
||||
'''{
|
||||
"tasks": [{
|
||||
"index": 0,
|
||||
"original_text": "That was eight years ago.",
|
||||
"Chinese": ""
|
||||
}, {
|
||||
"index": 1,
|
||||
"original_text": "Much has changed.",
|
||||
"Chinese": ""
|
||||
}],
|
||||
"params": {
|
||||
"api_key": "",
|
||||
"system_instruction": "You are an expert translator. You will be given a list of dialogue translation tasks in JSON format. For each entry, provide the most appropriate translation in Simplified Chinese based on the context. \\nOnly complete the `Chinese` field. Do not include pinyin, explanations, or any additional information.",
|
||||
"schema": "DialogueTranslationTask"
|
||||
}
|
||||
}'''
|
||||
input_text = sys.stdin.read()
|
||||
if not input_text:
|
||||
raise ValueError("No input provided")
|
||||
|
||||
request_data = json.loads(input_text)
|
||||
validate_input_data(request_data)
|
||||
|
||||
# Extract parameters
|
||||
tasks = request_data["tasks"]
|
||||
params = request_data["params"]
|
||||
|
||||
# Get schema and make API call
|
||||
schema = get_task_schema(params["schema"])
|
||||
response = query_gemini(
|
||||
api_key=params["api_key"],
|
||||
translation_tasks=tasks,
|
||||
task_schema=schema,
|
||||
system_instruction=params["system_instruction"],
|
||||
gemini_model=params.get("model", "gemini-2.0-flash"),
|
||||
temperature=float(params.get("temperature", 0.3)),
|
||||
max_retries=int(params.get("max_retries", 3))
|
||||
)
|
||||
|
||||
# Prepare output
|
||||
if response.success:
|
||||
result = {
|
||||
"success": True,
|
||||
"data": {
|
||||
"tasks": [task.model_dump() for task in response.tasks],
|
||||
"total_token_count": response.total_token_count
|
||||
}
|
||||
}
|
||||
else:
|
||||
result = {
|
||||
"success": False,
|
||||
"message": response.message
|
||||
}
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
error = {
|
||||
"success": False,
|
||||
"message": f"Invalid JSON input: {str(e)}"
|
||||
}
|
||||
print(json.dumps(error))
|
||||
except Exception as e:
|
||||
error = {
|
||||
"success": False,
|
||||
"message": f"Unexpected error: {str(e)}"
|
||||
}
|
||||
print(json.dumps(error))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,5 +1,4 @@
|
||||
pysubs2~=1.8.0
|
||||
langdetect~=1.0.9
|
||||
pymediainfo~=7.0.1
|
||||
thinc==8.3.4
|
||||
spacy==3.8.7
|
||||
spacy~=3.8.11
|
||||
367
plugins.v2/lexiannot/schemas.py
Normal file
367
plugins.v2/lexiannot/schemas.py
Normal 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")
|
||||
@@ -1,84 +1,98 @@
|
||||
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
|
||||
|
||||
|
||||
class SpacyWorker:
|
||||
|
||||
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 子进程...")
|
||||
self.proc = Process(target=self.run, args=(self.model,))
|
||||
self.proc.start()
|
||||
|
||||
# 等待子进程返回模型加载状态
|
||||
status, info = self.status_q.get()
|
||||
if status == 'error':
|
||||
self.proc.join()
|
||||
raise RuntimeError(f"spaCy 模型加载失败: {info}")
|
||||
else:
|
||||
logger.info(f"spaCy 模型 `{self.model}` 加载成功")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.close()
|
||||
|
||||
def run(self, model: str):
|
||||
try:
|
||||
nlp = SpacyWorker.load_nlp(model)
|
||||
infixes = list(nlp.Defaults.infixes)
|
||||
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
|
||||
)
|
||||
except Exception as e:
|
||||
self.status_q.put(('error', str(e)))
|
||||
return
|
||||
|
||||
# 告诉主进程加载成功
|
||||
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])
|
||||
|
||||
@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]]:
|
||||
"""
|
||||
提交任务并等待结果
|
||||
"""
|
||||
self.task_q.put(text)
|
||||
return self.result_q.get()
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
关闭子进程
|
||||
"""
|
||||
if self.proc.is_alive():
|
||||
self.task_q.put(None)
|
||||
self.proc.join()
|
||||
logger.info(f"SpacyWorker 子进程退出")
|
||||
from multiprocessing import Process, Queue
|
||||
|
||||
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"):
|
||||
self.task_q = Queue()
|
||||
self.result_q = Queue()
|
||||
self.status_q = Queue()
|
||||
self.model = model
|
||||
|
||||
# 启动子进程
|
||||
logger.info("正在启动 SpacyWorker 子进程...")
|
||||
self.proc = Process(target=self.run, args=(self.model,))
|
||||
self.proc.start()
|
||||
|
||||
# 等待子进程返回模型加载状态
|
||||
status, info = self.status_q.get()
|
||||
if status == "error":
|
||||
self.proc.join()
|
||||
raise RuntimeError(f"spaCy 模型加载失败: {info}")
|
||||
else:
|
||||
logger.info(f"spaCy 模型 `{self.model}` 加载成功")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.close()
|
||||
|
||||
def run(self, model: str):
|
||||
try:
|
||||
nlp = SpacyWorker.load_nlp(model)
|
||||
infixes = list(nlp.Defaults.infixes)
|
||||
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,
|
||||
)
|
||||
except Exception as e:
|
||||
self.status_q.put(("error", str(e)))
|
||||
return
|
||||
|
||||
# 告诉主进程加载成功
|
||||
self.status_q.put(("ok", None))
|
||||
|
||||
while True:
|
||||
text = self.task_q.get()
|
||||
if text is None:
|
||||
break
|
||||
doc = nlp(text)
|
||||
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) -> NlpResult:
|
||||
"""
|
||||
提交任务并等待结果
|
||||
"""
|
||||
self.task_q.put(text)
|
||||
return self.result_q.get()
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
关闭子进程
|
||||
"""
|
||||
if self.proc.is_alive():
|
||||
self.task_q.put(None)
|
||||
self.proc.join()
|
||||
logger.info("SpacyWorker 子进程退出")
|
||||
|
||||
44
plugins.v2/lexiannot/subtitle.py
Normal file
44
plugins.v2/lexiannot/subtitle.py
Normal 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
@@ -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:
|
||||
|
||||
261
plugins.v2/tmdbwallpaper/__init__.py
Normal file
261
plugins.v2/tmdbwallpaper/__init__.py
Normal 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)
|
||||
@@ -3,33 +3,66 @@ import base64
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
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.plugins.tobypasstrackers.dns_helper import DnsHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import EventType, NotificationType
|
||||
from app.utils.http import RequestUtils
|
||||
from .dns_helper import DnsHelper
|
||||
|
||||
|
||||
class IpCidrItem(BaseModel):
|
||||
# IP CIDR
|
||||
ip_cidr: str
|
||||
# 解析时间
|
||||
timestamp: int = Field(default=0)
|
||||
# DNS
|
||||
nameserver: str | None = Field(default=None)
|
||||
# 域名
|
||||
domain: str | None = Field(default=None)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
if self.timestamp:
|
||||
dns_time = datetime.fromtimestamp(int(self.timestamp)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
else:
|
||||
dns_time = '-'
|
||||
return {
|
||||
'ip_cidr': self.ip_cidr,
|
||||
'domain': self.domain or '',
|
||||
'nameserver': self.nameserver or '-',
|
||||
'datetime': dns_time
|
||||
}
|
||||
|
||||
|
||||
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.4.3"
|
||||
plugin_version = "1.5.2"
|
||||
# 插件作者
|
||||
plugin_author = "wumode"
|
||||
# 作者主页
|
||||
@@ -40,13 +73,16 @@ class ToBypassTrackers(_PluginBase):
|
||||
plugin_order = 21
|
||||
# 可使用的用户级别
|
||||
auth_level = 2
|
||||
|
||||
# CN IP lists
|
||||
chn_route6_lists_url = "https://ispip.clang.cn/all_cn_ipv6.txt"
|
||||
chn_route_lists_url = "https://ispip.clang.cn/all_cn.txt"
|
||||
# 定时器
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
# 开关
|
||||
_enabled: bool = False
|
||||
_cron: str = ""
|
||||
_notify = False
|
||||
_sync_cron: str = ""
|
||||
_notify: bool = False
|
||||
_onlyonce: bool = False
|
||||
_custom_trackers: str = ""
|
||||
_exempted_domains: str = ""
|
||||
@@ -55,39 +91,26 @@ class ToBypassTrackers(_PluginBase):
|
||||
_china_ipv6_route: bool = True
|
||||
_bypass_ipv4: bool = True
|
||||
_bypass_ipv6: bool = True
|
||||
_dns_input: str = ""
|
||||
ipv6_txt: str = ""
|
||||
ipv4_txt: str = ""
|
||||
trackers: Dict[str, List[str]] = {}
|
||||
_dns_input: str | None = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
|
||||
self.stop_service()
|
||||
|
||||
self.trackers = {}
|
||||
self.ipv6_txt = self.get_data("ipv6_txt") if self.get_data("ipv6_txt") else ""
|
||||
self.ipv4_txt = self.get_data("ipv4_txt") if self.get_data("ipv4_txt") else ""
|
||||
try:
|
||||
site_file = settings.ROOT_PATH/'app'/'plugins'/'tobypasstrackers'/'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 = config.get("enabled")
|
||||
self._cron = config.get("cron")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._notify = config.get("notify")
|
||||
self._custom_trackers = config.get("custom_trackers")
|
||||
self._exempted_domains = config.get("exempted_domains")
|
||||
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 ""
|
||||
self._exempted_domains = config.get("exempted_domains") or ""
|
||||
self._bypassed_sites = config.get("bypassed_sites") or []
|
||||
self._bypass_ipv4 = config.get("bypass_ipv4")
|
||||
self._bypass_ipv6 = config.get("bypass_ipv6")
|
||||
self._dns_input = config.get("dns_input")
|
||||
self._china_ipv6_route = config.get("china_ipv6_route")
|
||||
self._china_ip_route = config.get("china_ip_route")
|
||||
self._bypass_ipv4 = bool(config.get("bypass_ipv4"))
|
||||
self._bypass_ipv6 = bool(config.get("bypass_ipv6"))
|
||||
self._dns_input: str | None = config.get("dns_input")
|
||||
self._china_ipv6_route = bool(config.get("china_ipv6_route"))
|
||||
self._china_ip_route = bool(config.get("china_ip_route"))
|
||||
# 过滤掉已删除的站点
|
||||
all_sites = [site.id for site in SiteOper().list_order_by_pri()]
|
||||
self._bypassed_sites = [site_id for site_id in all_sites if site_id in self._bypassed_sites]
|
||||
@@ -95,7 +118,7 @@ class ToBypassTrackers(_PluginBase):
|
||||
if self._enabled or self._onlyonce:
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
if self._onlyonce:
|
||||
logger.info(f"立即运行一次")
|
||||
logger.info("立即运行一次")
|
||||
self._scheduler.add_job(self.update_ips, "date",
|
||||
run_date=datetime.now(
|
||||
tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3)
|
||||
@@ -113,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,
|
||||
@@ -122,20 +146,30 @@ 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,
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
return [{
|
||||
"cmd": "/refresh_tracker_ips",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "更新 Tracker IP 列表",
|
||||
"data": {
|
||||
"action": "refresh_tracker_ips"
|
||||
return [
|
||||
{
|
||||
"cmd": "/refresh_tracker_ips",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "更新 Tracker IP 列表",
|
||||
"data": {
|
||||
"action": "refresh_tracker_ips"
|
||||
}
|
||||
},
|
||||
{
|
||||
"cmd": "/check_ip",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "检测 IP 是否在绕过列表中: /check_ip <域名或IP>",
|
||||
"data": {
|
||||
"action": "check_ip"
|
||||
}
|
||||
}
|
||||
}]
|
||||
]
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -147,13 +181,15 @@ class ToBypassTrackers(_PluginBase):
|
||||
"summary": "API说明"
|
||||
}]
|
||||
"""
|
||||
return [{
|
||||
"path": "/bypassed_ips",
|
||||
"endpoint": self.bypassed_ips,
|
||||
"methods": ["GET"],
|
||||
"summary": "绕过的IP",
|
||||
"description": "绕过Clash核心的IP地址列表",
|
||||
}]
|
||||
return [
|
||||
{
|
||||
"path": "/bypassed_ips",
|
||||
"endpoint": self.bypassed_ips,
|
||||
"methods": ["GET"],
|
||||
"summary": "绕过的 IP",
|
||||
"description": "绕过 Clash 核心的 IP 地址列表",
|
||||
}
|
||||
]
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
site_options = ([{"title": site.name, "value": site.id}
|
||||
@@ -261,7 +297,7 @@ class ToBypassTrackers(_PluginBase):
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'china_ip_route',
|
||||
'label': '合并中国大陆IPv4列表',
|
||||
'label': '合并中国大陆 IPv4 列表',
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -277,7 +313,7 @@ class ToBypassTrackers(_PluginBase):
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'china_ipv6_route',
|
||||
'label': '合并中国大陆IPv6列表',
|
||||
'label': '合并中国大陆 IPv6 列表',
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -291,7 +327,7 @@ class ToBypassTrackers(_PluginBase):
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -308,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': [
|
||||
{
|
||||
@@ -316,7 +369,7 @@ class ToBypassTrackers(_PluginBase):
|
||||
'props': {
|
||||
'model': 'dns_input',
|
||||
'label': 'DNS 服务器',
|
||||
'placeholder': '留空则使用本地DNS'
|
||||
'placeholder': '留空则使用本地 DNS'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -357,9 +410,9 @@ class ToBypassTrackers(_PluginBase):
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'custom_trackers',
|
||||
'label': '自定义Tracker服务器',
|
||||
'label': '自定义 Tracker 服务器',
|
||||
'rows': 3,
|
||||
'placeholder': '每行一个域名或IP'
|
||||
'placeholder': '每行一个域名或 IP'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -375,9 +428,76 @@ class ToBypassTrackers(_PluginBase):
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'exempted_domains',
|
||||
'label': '排除的域名和IP',
|
||||
'label': '排除的域名和 IP',
|
||||
'rows': 3,
|
||||
'placeholder': '每行一个域名或IP'
|
||||
'placeholder': '每行一个域名或 IP'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCard',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCardItem',
|
||||
'props': {
|
||||
'prepend-icon': 'mdi-link-variant',
|
||||
'title': '订阅 URL',
|
||||
'subtitle': '请先在 MoviePilot 设置中配置「访问域名」',
|
||||
'class': 'pb-0'
|
||||
},
|
||||
},
|
||||
{
|
||||
'component': 'VCardActions',
|
||||
'props': {
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VBtn',
|
||||
'text': 'IPv4',
|
||||
'props': {
|
||||
'append-icon': 'mdi-open-in-new',
|
||||
'href': self.api_url(protocol=4),
|
||||
'target': '_blank'
|
||||
},
|
||||
|
||||
},
|
||||
{
|
||||
'component': 'VBtn',
|
||||
'text': 'IPv6',
|
||||
'props': {
|
||||
'append-icon': 'mdi-open-in-new',
|
||||
'href': self.api_url(protocol=6),
|
||||
'target': '_blank'
|
||||
},
|
||||
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'title': 'DNS 服务器示例',
|
||||
'border': 'start',
|
||||
'variant': 'tonal',
|
||||
'text': '仅填一个: '
|
||||
'「223.5.5.5」、'
|
||||
'「[2400:3200::1]:53」、'
|
||||
'「quic://dns.alidns.com:853」、'
|
||||
'「https://dns.alidns.com/dns-query」。'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -396,60 +516,13 @@ class ToBypassTrackers(_PluginBase):
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': 'DNS 服务器示例 (仅填一个): '
|
||||
'「94.140.14.140」、'
|
||||
'「94.140.14.140:53」、'
|
||||
'「[2a10:50c0::1:ff]:53」、'
|
||||
'「https://unfiltered.adguard-dns.com/dns-query」。'
|
||||
'仅支持UDP和HTTPS方法, 留空使用本地DNS查询。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '【订阅URL】'
|
||||
f'「IPv4 API」: /api/v1/plugin/ToBypassTrackers/bypassed_ips?apikey={settings.API_TOKEN}&protocol=4; '
|
||||
f'「IPv6 API」: /api/v1/plugin/ToBypassTrackers/bypassed_ips?apikey={settings.API_TOKEN}&protocol=6'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '【如何使用】'
|
||||
'在「OpenClash->插件设置->中国大陆IP路由」选择「绕过中国大陆」; '
|
||||
'在「OpenClash->插件设置->Chnroute Update」填入「订阅URL」。'
|
||||
'color': 'info',
|
||||
'border': 'start',
|
||||
'title': '如何使用',
|
||||
'text': '在「OpenClash->插件设置->流量控制->绕过指定区域 IP」选择「绕过中国大陆」; '
|
||||
'在「OpenClash->插件设置->大陆白名单订阅」填入「订阅 URL」。'
|
||||
'使用聊天命令`/check_ip <域名或IP>`检查 IP 是否在绕过列表。'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -470,11 +543,99 @@ 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]:
|
||||
pass
|
||||
headers = [
|
||||
{'title': 'IP CIDR', 'key': 'ip_cidr', 'sortable': True},
|
||||
{'title': '域名', 'key': 'domain', 'sortable': True},
|
||||
{'title': 'DNS', 'key': 'nameserver', 'sortable': True},
|
||||
{'title': '解析时间', 'key': 'datetime', 'sortable': True},
|
||||
]
|
||||
items = [IpCidrItem.model_validate(detail).to_dict()
|
||||
for detail in (self.get_data("cidr_details") or []) if detail.get('domain') != 'CN']
|
||||
excluded_items = [IpCidrItem.model_validate(detail).to_dict()
|
||||
for detail in (self.get_data("excluded_cidr_details") or [])]
|
||||
|
||||
return [
|
||||
{
|
||||
'component': 'VWindow',
|
||||
'props': {
|
||||
'show-arrows': 'hover',
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VWindowItem',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCard',
|
||||
'props': {
|
||||
'class': 'pa-0',
|
||||
'title': '绕过的 Tracker 服务器 IP 列表',
|
||||
'subtitle': '以下是已解析并添加到绕过列表中的 Tracker 服务器 IP 地址,'
|
||||
'请在 OpenClash 中配置「绕过中国大陆 IP」并订阅本列表以实现绕过效果。',
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VDataTableVirtual',
|
||||
'props': {
|
||||
'class': 'text-sm',
|
||||
'headers': headers,
|
||||
'items': items,
|
||||
'height': '30rem',
|
||||
'density': 'compact',
|
||||
'fixed-header': True,
|
||||
'hide-no-data': True,
|
||||
'hover': True
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VWindowItem',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCard',
|
||||
'props': {
|
||||
'class': 'pa-0',
|
||||
'title': '排除的 IP 列表',
|
||||
'variant': 'elevated',
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VDataTableVirtual',
|
||||
'props': {
|
||||
'class': 'text-sm',
|
||||
'headers': headers,
|
||||
'items': excluded_items,
|
||||
'height': '30rem',
|
||||
'density': 'compact',
|
||||
'fixed-header': True,
|
||||
'hide-no-data': True,
|
||||
'hover': True
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
def get_dashboard(self, key: str = None, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
|
||||
"""
|
||||
@@ -491,10 +652,14 @@ class ToBypassTrackers(_PluginBase):
|
||||
"""
|
||||
pass
|
||||
|
||||
def api_url(self, protocol: int = 4) -> str:
|
||||
return settings.MP_DOMAIN(f'/api/v1/plugin/{self.__class__.__name__}/bypassed_ips?apikey={settings.API_TOKEN}'
|
||||
f'&protocol={protocol}')
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
@@ -516,86 +681,226 @@ 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 []
|
||||
|
||||
def bypassed_ips(self, protocol: str) -> Response:
|
||||
if protocol == '6':
|
||||
return Response(content=self.ipv6_txt, media_type="text/plain")
|
||||
return Response(content=self.ipv4_txt, media_type="text/plain")
|
||||
@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 ""
|
||||
return Response(content=data, media_type="text/plain")
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def update_ips(self, event: Optional[Event]=None):
|
||||
def __is_ip_in_subnet(ip_input: str, su_bnet: str) -> bool:
|
||||
"""
|
||||
Check if the given IP address is in the specified subnet.
|
||||
def check_ip(self, event: Event):
|
||||
"""检查 IP 地址 是否在绕过列表"""
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "check_ip":
|
||||
return
|
||||
host = event_data.get("arg_str")
|
||||
channel = event_data.get("channel")
|
||||
userid = event_data.get("userid")
|
||||
logger.info(f"检查 IP 是否绕过: {host} (来自用户 {userid},渠道 {channel})")
|
||||
ip_list, bypassed, excluded = self._check_details(host)
|
||||
if not ip_list:
|
||||
self.post_message(channel=channel, user=userid, text=f"无法解析 host: {host}", title=f"{host}")
|
||||
return
|
||||
message = ""
|
||||
for ip in ip_list:
|
||||
detail = bypassed.get(ip)
|
||||
excluded_detail = excluded.get(ip)
|
||||
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())
|
||||
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())
|
||||
sub_message += f" 在绕过列表中:\n{detail_msg}\n"
|
||||
if detail and not excluded_detail:
|
||||
sub_message += f"✈️ 会被绕过。\n"
|
||||
else:
|
||||
sub_message += f"🛑 不会被绕过。\n"
|
||||
message += sub_message + "\n"
|
||||
self.post_message(channel=channel, user=userid, text=message, title=f"{host}")
|
||||
|
||||
:param ip_input: IP address as a string (e.g., '192.168.1.1')
|
||||
:param su_bnet: Subnet in CIDR notation (e.g., '192.168.1.0/24')
|
||||
:return: True if IP is in the subnet, False otherwise
|
||||
"""
|
||||
ip_obj = ipaddress.ip_address(ip_input)
|
||||
subnet_obj = ipaddress.ip_network(su_bnet, strict=False)
|
||||
return ip_obj in subnet_obj
|
||||
@overload
|
||||
def _load_cn_ip_lists(self, family: type[IPv4Network]) -> list[IPv4Network]: ...
|
||||
|
||||
def __search_ip(_ip, ips_list):
|
||||
i = 0
|
||||
for ip_range in ips_list:
|
||||
if __is_ip_in_subnet(_ip, ip_range):
|
||||
return i
|
||||
i += 1
|
||||
return -1
|
||||
@overload
|
||||
def _load_cn_ip_lists(self, family: type[IPv6Network]) -> list[IPv6Network]: ...
|
||||
|
||||
def __exclude_ip_range(range_b: str, range_a: str):
|
||||
"""
|
||||
Exclude IP range A from IP range B and return the remaining subranges.
|
||||
def _load_cn_ip_lists(self, family: type[IPv4Network] | type[IPv6Network] = IPv4Network
|
||||
) -> list[IPv4Network | IPv6Network]:
|
||||
ip_list: list[IPv4Network | IPv6Network] = []
|
||||
if family is IPv4Network:
|
||||
url = self.chn_route_lists_url
|
||||
elif family is IPv6Network:
|
||||
url = self.chn_route6_lists_url
|
||||
else:
|
||||
raise NotImplementedError(f"unknown address family {family}")
|
||||
res = RequestUtils().get_res(url=url, raise_exception=True)
|
||||
if res is None or res.status_code != 200:
|
||||
logger.warn(f"无法获取 CN IP 列表: {url}")
|
||||
raise ConnectionError
|
||||
route_list = res.text.strip().split('\n')
|
||||
for cn_ip_cidr in route_list:
|
||||
subnet = ipaddress.ip_network(cn_ip_cidr, strict=False)
|
||||
if isinstance(subnet, family):
|
||||
ip_list.append(subnet)
|
||||
return ip_list
|
||||
|
||||
:param range_b: The larger IP range in CIDR notation (must include range_a).
|
||||
:param range_a: The smaller IP range to exclude in CIDR notation.
|
||||
:return: List of remaining IP subranges in CIDR notation.
|
||||
"""
|
||||
net_b = ipaddress.ip_network(range_b, strict=False)
|
||||
net_a = ipaddress.ip_network(range_a, strict=False)
|
||||
def _search_details(self, ip_list: list[IPv4Address | IPv6Address], data_key: str) -> dict[str, IpCidrItem | None]:
|
||||
cidr_details = [IpCidrItem.model_validate(detail) for detail in (self.get_data(data_key) or [])]
|
||||
ip_cidr_list = [ipaddress.ip_network(item.ip_cidr, strict=False) for item in cidr_details]
|
||||
details: dict[str, IpCidrItem | None] = {}
|
||||
for ip in ip_list:
|
||||
index = ToBypassTrackers._search_ip(ip, ip_cidr_list)
|
||||
if index == -1:
|
||||
details[str(ip)] = None
|
||||
continue
|
||||
details[str(ip)] = cidr_details[index]
|
||||
return details
|
||||
|
||||
if not (net_a.subnet_of(net_b)):
|
||||
raise ValueError("Range A is not fully contained within Range B.")
|
||||
def _check_details(self, host: str) -> tuple[list[str], dict[str, IpCidrItem | None], dict[str, IpCidrItem | None]]:
|
||||
try:
|
||||
ip_list = [ipaddress.ip_address(host)]
|
||||
except ValueError:
|
||||
dns = DnsHelper(dns_server=self._dns_input)
|
||||
resolved = asyncio.run(dns.resolve_name(host))
|
||||
if resolved is None:
|
||||
return [], {}, {}
|
||||
ip_list = [ipaddress.ip_address(ip) for ip in resolved]
|
||||
details = self._search_details(ip_list, "cidr_details")
|
||||
excluded = self._search_details(ip_list, "excluded_cidr_details")
|
||||
return [str(ip) for ip in ip_list], details, excluded
|
||||
|
||||
remaining_ranges = list(net_b.address_exclude(net_a))
|
||||
@staticmethod
|
||||
def _search_ip(ip: IPv4Address | IPv6Address, ips_list: list[IPv4Network | IPv6Network]) -> int:
|
||||
i = 0
|
||||
for ip_range in ips_list:
|
||||
if ip in ip_range:
|
||||
return i
|
||||
i += 1
|
||||
return -1
|
||||
|
||||
return [str(sub_net) for sub_net in remaining_ranges]
|
||||
@staticmethod
|
||||
def _search_subnet(ip: IPv4Network | IPv6Network, ips_list: list[IPv4Network | IPv6Network]) -> int:
|
||||
i = 0
|
||||
for ip_range in ips_list:
|
||||
if ip.subnet_of(ip_range):
|
||||
return i
|
||||
i += 1
|
||||
return -1
|
||||
|
||||
async def resolve_and_check(domain_, results_, failed_msg_, dns_type_, ip_list_):
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def update_ips(self, event: Optional[Event] = None):
|
||||
|
||||
async def resolve_and_check(domain_: str, results_: dict[str, bool], failed_msg_: list[str],
|
||||
family: int, ip_list_: list[IPv4Network | IPv6Network],
|
||||
cidr_details_: list[IpCidrItem]):
|
||||
try:
|
||||
addresses = await query_helper.query_dns(domain_, dns_type_)
|
||||
addresses = await query_helper.resolve_name(domain_, family)
|
||||
if addresses is None:
|
||||
failed_msg_.append(f"【{domain_name_map.get(domain_, domain_)}】 {domain_}: {dns_type_} 记录查询失败")
|
||||
dns_type = "AAAA" if family == socket.AF_INET6 else "A"
|
||||
failed_msg_.append(f"【{domain_name_map.get(domain_, domain_)}】 {domain_}: {dns_type} 记录查询失败")
|
||||
results_[domain_name_map.get(domain_, domain_)] = False
|
||||
return
|
||||
|
||||
for address in addresses:
|
||||
has_flag = any(__is_ip_in_subnet(address, subnet) for subnet in ip_list_)
|
||||
for ip_str in addresses:
|
||||
ip_obj = ipaddress.ip_address(ip_str)
|
||||
has_flag = any(ip_obj in sub_net for sub_net in ip_list_)
|
||||
if not has_flag:
|
||||
if dns_type_ == "AAAA":
|
||||
ip_list_.append(address)
|
||||
else:
|
||||
ip_list_.append(address)
|
||||
logger.info(f"Resolving【{domain_name_map.get(domain_, domain_)}】{address} ({domain_})")
|
||||
net_obj = ipaddress.ip_network(ip_obj, strict=False)
|
||||
ip_list_.append(net_obj)
|
||||
ip_cidr_item = IpCidrItem(ip_cidr=str(net_obj), domain=domain_,
|
||||
timestamp=int(time.time()), nameserver=query_helper.nameserver)
|
||||
cidr_details_.append(ip_cidr_item)
|
||||
logger.info(f"Resolving【{domain_name_map.get(domain_, domain_)}】{ip_str} ({domain_})")
|
||||
except Exception as e:
|
||||
logger.exception(f"处理 {domain_} 出错: {e}")
|
||||
logger.warn(f"处理 {domain_} 出错: {e}")
|
||||
results_[domain_name_map.get(domain_, domain_)] = False
|
||||
|
||||
async def resolve_all(domains_, ipv6_list_, ip_list_):
|
||||
async def resolve_all(domains_: list[str], ipv6_list_: list[IPv6Network], ip_list_: list[IPv4Network],
|
||||
details: list[IpCidrItem]):
|
||||
tasks = [
|
||||
resolve_and_check(domain_, results_v6, failed_msg, "AAAA", ipv6_list_)
|
||||
resolve_and_check(domain_, results_v6, failed_msg, socket.AF_INET6, ipv6_list_, details)
|
||||
for domain_ in domains_
|
||||
]
|
||||
tasks.extend([resolve_and_check(domain_, results, failed_msg, "A", ip_list_)
|
||||
tasks.extend([resolve_and_check(domain_, results, failed_msg, socket.AF_INET, ip_list_, details)
|
||||
for domain_ in domains_])
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
@@ -604,29 +909,27 @@ class ToBypassTrackers(_PluginBase):
|
||||
if not event_data or event_data.get("action") != "refresh_tracker_ips":
|
||||
return
|
||||
query_helper = DnsHelper(self._dns_input)
|
||||
logger.info(f"开始通过 {query_helper.method_name} 解析DNS")
|
||||
chnroute6_lists_url = "https://ispip.clang.cn/all_cn_ipv6.txt"
|
||||
chnroute_lists_url = "https://ispip.clang.cn/all_cn.txt"
|
||||
ipv6_list = []
|
||||
ip_list = []
|
||||
logger.info(f"开始通过 {query_helper.nameserver} 解析DNS")
|
||||
|
||||
ipv6_list: list[IPv6Network] = []
|
||||
ip_list: list[IPv4Network] = []
|
||||
domains = []
|
||||
success_msg = []
|
||||
failed_msg = []
|
||||
results = {}
|
||||
results: dict[str, bool] = {} # 解析结果
|
||||
unsupported_msg = []
|
||||
results_v6 = {}
|
||||
results_v6: dict[str, bool] = {}
|
||||
cidr_details: list[IpCidrItem] = []
|
||||
exempted_cidr_details: list[IpCidrItem] = []
|
||||
|
||||
# 加载 CN IP 列表
|
||||
if self._china_ipv6_route:
|
||||
# Load Chnroute6 Lists
|
||||
res = RequestUtils().get_res(url=chnroute6_lists_url)
|
||||
if res is not None and res.status_code == 200:
|
||||
chnroute6_lists = res.text.strip().split('\n')
|
||||
ipv6_list = [*chnroute6_lists]
|
||||
ipv6_list = self._load_cn_ip_lists(family=IPv6Network)
|
||||
if self._china_ip_route:
|
||||
# Load Chnroute Lists
|
||||
res = RequestUtils().get_res(url=chnroute_lists_url)
|
||||
if res is not None and res.status_code == 200:
|
||||
chnroute_lists = res.text.strip().split('\n')
|
||||
ip_list = [*chnroute_lists]
|
||||
ip_list = self._load_cn_ip_lists(family=IPv4Network)
|
||||
for ip in ipv6_list + ip_list:
|
||||
cidr_details.append(IpCidrItem(ip_cidr=str(ip), domain="CN", timestamp=int(time.time())))
|
||||
|
||||
do_sites = {site.domain: site.name for site in SiteOper().list_order_by_pri() if
|
||||
site.id in self._bypassed_sites}
|
||||
domain_name_map = {}
|
||||
@@ -643,70 +946,74 @@ class ToBypassTrackers(_PluginBase):
|
||||
for custom_tracker in self._custom_trackers.split('\n'):
|
||||
if custom_tracker:
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET, custom_tracker)
|
||||
if self._bypass_ipv4:
|
||||
ip_list.append(f"{custom_tracker}/32")
|
||||
except socket.error:
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET6, custom_tracker)
|
||||
address = ipaddress.ip_address(custom_tracker)
|
||||
net = ipaddress.ip_network(address)
|
||||
if isinstance(net, IPv4Network):
|
||||
if self._bypass_ipv4:
|
||||
ip_list.append(net)
|
||||
elif isinstance(net, IPv6Network):
|
||||
if self._bypass_ipv6:
|
||||
ipv6_list.append(ipaddress.ip_network(f"{custom_tracker}/128", strict=False).compressed)
|
||||
except socket.error:
|
||||
ipv6_list.append(net)
|
||||
except ValueError:
|
||||
domains.append(custom_tracker)
|
||||
v6_ips = []
|
||||
v4_ips = []
|
||||
asyncio.run(resolve_all(domains, v6_ips, v4_ips))
|
||||
ipv6_list.extend([ipaddress.ip_network(f"{ad}/128", strict=False).compressed for ad in v6_ips])
|
||||
ip_list.extend([f"{ad}/32" for ad in v4_ips])
|
||||
v6_nets = []
|
||||
v4_nets = []
|
||||
asyncio.run(resolve_all(domains, v6_nets, v4_nets, cidr_details))
|
||||
ipv6_list.extend(v6_nets)
|
||||
ip_list.extend(v4_nets)
|
||||
for result in results:
|
||||
if results[result]:
|
||||
success_msg.append(f"【{result}】 Trackers已被添加")
|
||||
exempted_ip = []
|
||||
exempted_ipv6 = []
|
||||
exempted_ip: list[IPv4Network] = []
|
||||
exempted_ipv6: list[IPv6Network] = []
|
||||
exempted_domains = []
|
||||
for exempted_domain in self._exempted_domains.split('\n'):
|
||||
if exempted_domain:
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET, exempted_domain)
|
||||
if self._bypass_ipv4:
|
||||
exempted_ip.append(f"{exempted_domain}")
|
||||
except socket.error:
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET6, exempted_domain)
|
||||
if self._bypass_ipv6:
|
||||
exempted_ipv6.append(f"{exempted_domain}")
|
||||
except socket.error:
|
||||
exempted_domains.append(exempted_domain)
|
||||
address = ipaddress.ip_address(exempted_domain)
|
||||
net = ipaddress.ip_network(address)
|
||||
|
||||
asyncio.run(resolve_all(exempted_domains, exempted_ip, exempted_ipv6))
|
||||
if isinstance(net, IPv4Network):
|
||||
if self._bypass_ipv4:
|
||||
exempted_ip.append(net)
|
||||
elif isinstance(net, IPv6Network):
|
||||
if self._bypass_ipv6:
|
||||
exempted_ipv6.append(net)
|
||||
exempted_cidr_details.append(IpCidrItem(ip_cidr=str(net), domain=exempted_domain,
|
||||
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 = __search_ip(ip, ip_list)
|
||||
if index == -1:
|
||||
continue
|
||||
ip_larger = ip_list[index]
|
||||
ip_list.pop(index)
|
||||
length = int(ip_larger.split('/')[1])
|
||||
if length < 12:
|
||||
remaining_ip = __exclude_ip_range(ip_larger, f"{ip}/{length + 8}")
|
||||
ip_list.extend(remaining_ip)
|
||||
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 = __search_ip(ip, ipv6_list)
|
||||
if index == -1:
|
||||
continue
|
||||
ip_larger = ipv6_list[index]
|
||||
ipv6_list.pop(index)
|
||||
length = int(ip_larger.split('/')[1])
|
||||
if length < 32:
|
||||
remaining_ip = __exclude_ip_range(ip_larger, f"{ip}/{min(32, length + 8)}")
|
||||
ipv6_list.extend(remaining_ip)
|
||||
self.ipv4_txt = "\n".join(ip_list)
|
||||
self.ipv6_txt = "\n".join(ipv6_list)
|
||||
self.save_data("ipv4_txt", self.ipv4_txt)
|
||||
self.save_data("ipv6_txt", self.ipv6_txt)
|
||||
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)
|
||||
self.save_data("ipv6_txt", ipv6_txt)
|
||||
self.save_data("cidr_details", [detail.model_dump() for detail in cidr_details])
|
||||
self.save_data("excluded_cidr_details", [detail.model_dump() for detail in exempted_cidr_details])
|
||||
if self._notify:
|
||||
res_message = success_msg + failed_msg
|
||||
res_message = "\n".join(res_message)
|
||||
self.post_message(title=f"【绕过Trackers】",
|
||||
mtype=NotificationType.Plugin,
|
||||
text=f"{res_message}"
|
||||
)
|
||||
self.post_message(
|
||||
title=f"【{self.plugin_name}】",
|
||||
mtype=NotificationType.Plugin,
|
||||
text=f"{res_message}"
|
||||
)
|
||||
|
||||
@@ -1,86 +1,108 @@
|
||||
import re
|
||||
from typing import Optional, List, Callable
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import dns.asyncresolver
|
||||
import dns.resolver
|
||||
from dns import asyncresolver, query
|
||||
from dns.nameserver import Do53Nameserver, DoHNameserver, DoTNameserver, DoQNameserver
|
||||
from dns.resolver import NoAnswer, NXDOMAIN
|
||||
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class DnsHelper:
|
||||
def __init__(self, dns_server: str):
|
||||
self.method_name = "Local"
|
||||
self.doh_url = "https://dns.alidns.com/dns-query"
|
||||
self.__resolver = dns.asyncresolver.Resolver()
|
||||
self.__dns_query_method = self.__query_method(dns_server)
|
||||
|
||||
def __query_method(self, dns_input: str) -> Callable:
|
||||
if not dns_input:
|
||||
return self.query_dns_local
|
||||
if dns_input.startswith('https://'):
|
||||
self.doh_url = dns_input
|
||||
self.method_name = dns_input
|
||||
return self.query_dns_doh
|
||||
udp_match = re.match(r"^(?:udp://)?(\[?.+?]?)(?::(\d+))?$", dns_input)
|
||||
if udp_match:
|
||||
try:
|
||||
self.__resolver.nameservers = [udp_match.group(1).strip('[]')]
|
||||
if udp_match.group(2):
|
||||
self.__resolver.port = int(udp_match.group(2))
|
||||
self.method_name = f"udp://{self.__resolver.nameservers[0]}:{self.__resolver.port}"
|
||||
except Exception as e:
|
||||
logger.warn(f'{e}, using default resolver')
|
||||
return self.query_dns_local
|
||||
return self.query_dns_udp
|
||||
logger.warn(f'Unknown method {dns_input}, using default resolver')
|
||||
return self.query_dns_local
|
||||
def __init__(self, dns_server: str | None = None):
|
||||
self._resolver = asyncresolver.Resolver()
|
||||
self._use_tcp: bool = False
|
||||
if dns_server:
|
||||
self.nameserver = dns_server
|
||||
|
||||
async def query_dns(self, domain: str, dns_type: str = "A") -> Optional[List[str]]:
|
||||
answers = await self.__dns_query_method(domain, dns_type)
|
||||
return answers
|
||||
@property
|
||||
def nameserver(self) ->str:
|
||||
nameserver = self._resolver.nameservers[0]
|
||||
return str(nameserver)
|
||||
|
||||
async def query_dns_local(self, domain: str, dns_type: str = "A") -> Optional[List[str]]:
|
||||
@nameserver.setter
|
||||
def nameserver(self, value: str | None):
|
||||
if value is None:
|
||||
self._resolver = asyncresolver.Resolver()
|
||||
return
|
||||
self._parse_dns_server(value)
|
||||
|
||||
@staticmethod
|
||||
def get_ip_from_hostname(hostname) -> str | None:
|
||||
try:
|
||||
answer = await self.__resolver.resolve(domain, dns_type)
|
||||
return [record.address for record in answer if hasattr(record, "address")]
|
||||
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
|
||||
return []
|
||||
except Exception as e:
|
||||
# logger.error(f"本地DNS查询错误: {e} {domain}")
|
||||
# 获取IP地址
|
||||
ip = socket.gethostbyname(hostname)
|
||||
return ip
|
||||
except socket.gaierror:
|
||||
return None
|
||||
|
||||
async def query_dns_doh(self, domain: str, dns_type: str = 'A') -> Optional[List[str]]:
|
||||
@staticmethod
|
||||
def is_ip_address(hostname):
|
||||
try:
|
||||
# 尝试解析为IP地址
|
||||
ipaddress.ip_address(hostname)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _parse_dns_server(self, dns_server: str):
|
||||
if "://" not in dns_server:
|
||||
dns_server = f"udp://{dns_server}"
|
||||
parsed = urlparse(dns_server)
|
||||
|
||||
# check and resolve the hostname
|
||||
hostname = parsed.hostname
|
||||
if hostname is None:
|
||||
return
|
||||
if DnsHelper.is_ip_address(hostname):
|
||||
address = hostname
|
||||
hostname = None
|
||||
else:
|
||||
address = DnsHelper.get_ip_from_hostname(hostname)
|
||||
if address is None:
|
||||
return
|
||||
|
||||
nameserver = None
|
||||
match parsed.scheme:
|
||||
case "udp":
|
||||
nameserver = Do53Nameserver(address, parsed.port or 53)
|
||||
case "tcp":
|
||||
nameserver = Do53Nameserver(address, parsed.port or 53)
|
||||
self._use_tcp = True
|
||||
case "https":
|
||||
nameserver = DoHNameserver(url=dns_server)
|
||||
case "tls":
|
||||
nameserver = DoTNameserver(address=address, port=parsed.port or 853, hostname=hostname)
|
||||
case "h3":
|
||||
nameserver = DoHNameserver(url=dns_server.replace("h3://", "https://"),
|
||||
http_version=query.HTTPVersion.H3)
|
||||
case "quic":
|
||||
nameserver = DoQNameserver(address=address, port=parsed.port or 853, server_hostname=hostname)
|
||||
case _:
|
||||
nameserver = None
|
||||
if nameserver is None:
|
||||
self._resolver = asyncresolver.Resolver()
|
||||
return
|
||||
self._resolver.nameservers = [nameserver]
|
||||
|
||||
async def resolve_name(self, domain: str, family: int = socket.AF_UNSPEC) -> list[str] | None:
|
||||
"""
|
||||
使用 DNS-over-HTTPS (DoH) 异步解析域名。
|
||||
异步解析域名
|
||||
|
||||
:param domain: 要解析的域名
|
||||
:param dns_type: DNS 记录类型,例如 'A', 'AAAA'
|
||||
:param family: The address family
|
||||
- socket.AF_UNSPEC: both IPv4 and IPv6 addresses
|
||||
- socket.AF_INET6: IPv6 addresses only
|
||||
- socket.AF_INET: IPv4 addresses only
|
||||
:return: IP 地址列表,或 None
|
||||
"""
|
||||
|
||||
try:
|
||||
query = dns.message.make_query(domain, dns_type)
|
||||
response = await dns.asyncquery.https(query, self.doh_url)
|
||||
return [
|
||||
item.address for rrset in response.answer for item in rrset.items
|
||||
if hasattr(item, "address")
|
||||
]
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
async def query_dns_udp(self, domain: str, dns_type: str = 'A') -> Optional[List[str]]:
|
||||
"""
|
||||
使用 UDP 异步方式解析域名
|
||||
|
||||
:param domain: 域名
|
||||
:param dns_type: 记录类型,如 A、AAAA
|
||||
:return: IP地址列表 或 None
|
||||
"""
|
||||
|
||||
try:
|
||||
answer = await self.__resolver.resolve(domain, dns_type)
|
||||
return [record.address for record in answer]
|
||||
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
|
||||
answer = await self._resolver.resolve_name(domain, family=family, tcp=self._use_tcp)
|
||||
return [a for a in answer.addresses()]
|
||||
except (NoAnswer, NXDOMAIN):
|
||||
return []
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug(f"DNS查询出错 ({domain}): {e} ")
|
||||
return None
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
dnspython~=2.7.0
|
||||
aioquic~=1.2.0
|
||||
dnspython~=2.8.0
|
||||
aioquic~=1.2.0
|
||||
@@ -1 +1 @@
|
||||
eyI1MnB0LnNpdGUiOiBbIjUycHQuc2l0ZSJdLCAiYXVkaWVuY2VzLm1lIjogWyJ0LmF1ZGllbmNlcy5tZSIsICJ0cmFja2VyLmNpbmVmaWxlcy5pbmZvIl0sICJidHNjaG9vbC5jbHViIjogWyJwdC5idHNjaG9vbC5jbHViIl0sICJieXIucHQiOiBbInRyYWNrZXIuYnlyLnB0Il0sICJjYXJwdC5uZXQiOiBbInRyYWNrZXIuY2FycHQubmV0Il0sICJjcmFicHQudmlwIjogWyJjcmFicHQudmlwIl0sICJjc3B0LnRvcCI6IFsidHJhY2tlci5jc3B0LnRvcCIsICJ0cmFja2VyLmNzcHQuY2MiLCAidHJhY2tlci5jc3B0LmRhdGUiXSwgImRpc2NmYW4ubmV0IjogWyJkaXNjZmFuLnh5eiJdLCAiZWFzdGdhbWUub3JnIjogWyJwdC5lYXN0Z2FtZS5vcmciXSwgImV0OC5vcmciOiBbImV0OC5vcmciLCAidC5ldDgub3JnIl0sICJnYW1lZ2FtZXB0LmNvbSI6IFsid3d3LmdhbWVnYW1lcHQuY29tIl0sICJoZGFyZWEuY2x1YiI6IFsidHJhY2tlci5oZGFyZWEuY2x1YiJdLCAiaGRkb2xieS5jb20iOiBbInQuaGRkb2xieS5jb20iXSwgImhkZmFucy5vcmciOiBbImhkZmFucy5vcmciXSwgImhka3lsLmluIjogWyJ0cmFja2VyLmhka3lsLmluIl0sICJoZHRpbWUub3JnIjogWyJoZHRpbWUub3JnIl0sICJoaXRwdC5jb20iOiBbImhpdHB0LmNvbSJdLCAiaHVkYnQuaHVzdC5lZHUuY24iOiBbImh1ZGJ0Lmh1c3QuZWR1LmNuIl0sICJpY2MyMDIyLmNvbSI6IFsidHJhY2tlci5pY2MyMDIyLnh5eiJdLCAiaWxvbGljb24uY29tIjogWyJ0cmFja2VyLmlsb2xpY29uLmNjIl0sICJrZWVwZnJkcy5jb20iOiBbInRyYWNrZXIua2VlcGZyZHMuY29tIl0sICJtLXRlYW0uY2MiOiBbInRyYWNrZXIubS10ZWFtLmNjIiwgInRyYWNrZXIubS10ZWFtLmlvIl0sICJtb25pa2FkZXNpZ24udWsiOiBbInRyYWNrZXIubW9uaWthZGVzaWduLnVrIiwgImRhaWtpcmFpLm1vbmlrYWRlc2lnbi51ayIsICJhbmltZS1uby1pbmRleC5jb20iXSwgIm5pY2VwdC5uZXQiOiBbInd3dy5uaWNlcHQubmV0Il0sICJva3B0Lm5ldCI6IFsid3d3Lm9rcHQubmV0Il0sICJwdGhvbWUubmV0IjogWyJwdGhvbWUubmV0Il0sICJwdGxncy5vcmciOiBbInB0bC5ncyIsICJyZWxheTAxLnB0bC5ncyJdLCAicHRzYmFvLmNsdWIiOiBbInB0c2Jhby5jbHViIl0sICJwdHRpbWUub3JnIjogWyJ3d3cucHR0aW1lLm9yZyJdLCAicHR6b25lLnh5eiI6IFsicHR6b25lLnh5eiJdLCAicWluZ3dhcHQuY29tIjogWyJ0cmFja2VyLnFpbmd3YS5wcm8iLCAidHJhY2tlci5xaW5nd2FwdC5jb20iXSwgInJhaW5nZmgudG9wIjogWyJyYWluZ2ZoLnRvcCJdLCAicm91c2kuemlwIjogWyJoaXRwdC5jb20iXSwgInNwcmluZ3N1bmRheS5uZXQiOiBbIm9uNi5zcHJpbmdzdW5kYXkubmV0IiwgIm9uLnNwcmluZ3N1bmRheS5uZXQiXSwgInRqdXB0Lm9yZyI6IFsidHJhY2tlci1wdWJsaWMudGp1cHQub3JnIl0sICJ0b3RoZWdsb3J5LmltIjogWyJ0cmFja2VyLnRvdGhlZ2xvcnkuaW0iXSwgInUyLmRtaHkub3JnIjogWyJkYXlkcmVhbS5kbWh5LmJlc3QiXSwgInhpbmd5dW5nZS50b3AiOiBbInRyYWNrZXIueGluZ3l1bmdlLnRvcCIsICJ0cmFja2VyLnhpbmd5dW5nZS5zYnMiXSwgInptcHQuY2MiOiBbInptcHQuY2MiXSwgImhoYW5jbHViLnRvcCI6IFsidHJhY2tlci5oaGFuY2x1Yi50b3AiXSwgImhkY2l0eS5jaXR5IjogWyJzeW5jLmxlbml0ZXIub3JnIl19
|
||||
eyI1MnB0LnNpdGUiOiBbIjUycHQuc2l0ZSJdLCAiYXVkaWVuY2VzLm1lIjogWyJ0LmF1ZGllbmNlcy5tZSIsICJ0cmFja2VyLmNpbmVmaWxlcy5pbmZvIl0sICJidHNjaG9vbC5jbHViIjogWyJwdC5idHNjaG9vbC5jbHViIl0sICJieXIucHQiOiBbInRyYWNrZXIuYnlyLnB0Il0sICJjYXJwdC5uZXQiOiBbInRyYWNrZXIuY2FycHQubmV0Il0sICJjcmFicHQudmlwIjogWyJjcmFicHQudmlwIl0sICJjc3B0LnRvcCI6IFsidHJhY2tlci5jc3B0LnRvcCIsICJ0cmFja2VyLmNzcHQuY2MiLCAidHJhY2tlci5jc3B0LmRhdGUiXSwgImRpc2NmYW4ubmV0IjogWyJkaXNjZmFuLnh5eiJdLCAiZWFzdGdhbWUub3JnIjogWyJwdC5lYXN0Z2FtZS5vcmciXSwgImV0OC5vcmciOiBbImV0OC5vcmciLCAidC5ldDgub3JnIl0sICJnYW1lZ2FtZXB0LmNvbSI6IFsid3d3LmdhbWVnYW1lcHQuY29tIl0sICJoZGFyZWEuY2x1YiI6IFsidHJhY2tlci5oZGFyZWEuY2x1YiJdLCAiaGRkb2xieS5jb20iOiBbInQuaGRkb2xieS5jb20iXSwgImhkZmFucy5vcmciOiBbImhkZmFucy5vcmciXSwgImhka3lsLmluIjogWyJ0cmFja2VyLmhka3lsLmluIiwgInd3dy5oZGt5bGluLnRvcCJdLCAiaGR0aW1lLm9yZyI6IFsiaGR0aW1lLm9yZyJdLCAiaGl0cHQuY29tIjogWyJoaXRwdC5jb20iXSwgImh1ZGJ0Lmh1c3QuZWR1LmNuIjogWyJodWRidC5odXN0LmVkdS5jbiJdLCAiaWNjMjAyMi5jb20iOiBbInRyYWNrZXIuaWNjMjAyMi54eXoiXSwgImlsb2xpY29uLmNvbSI6IFsidHJhY2tlci5pbG9saWNvbi5jYyJdLCAia2VlcGZyZHMuY29tIjogWyJ0cmFja2VyLmtlZXBmcmRzLmNvbSJdLCAibS10ZWFtLmNjIjogWyJ0cmFja2VyLm0tdGVhbS5jYyIsICJ0cmFja2VyLm0tdGVhbS5pbyJdLCAibW9uaWthZGVzaWduLnVrIjogWyJ0cmFja2VyLm1vbmlrYWRlc2lnbi51ayIsICJkYWlraXJhaS5tb25pa2FkZXNpZ24udWsiLCAiYW5pbWUtbm8taW5kZXguY29tIl0sICJuaWNlcHQubmV0IjogWyJ3d3cubmljZXB0Lm5ldCJdLCAib2twdC5uZXQiOiBbInd3dy5va3B0Lm5ldCJdLCAicHRob21lLm5ldCI6IFsicHRob21lLm5ldCJdLCAicHRsZ3Mub3JnIjogWyJwdGwuZ3MiLCAicmVsYXkwMS5wdGwuZ3MiXSwgInB0c2Jhby5jbHViIjogWyJwdHNiYW8uY2x1YiJdLCAicHR0aW1lLm9yZyI6IFsid3d3LnB0dGltZS5vcmciXSwgInB0em9uZS54eXoiOiBbInB0em9uZS54eXoiXSwgInFpbmd3YXB0LmNvbSI6IFsidHJhY2tlci5xaW5nd2EucHJvIiwgInRyYWNrZXIucWluZ3dhcHQuY29tIiwgInRyYWNrZXIucWluZ3dhcHQub3JnIl0sICJyYWluZ2ZoLnRvcCI6IFsicmFpbmdmaC50b3AiXSwgInJvdXNpLnppcCI6IFsiaGl0cHQuY29tIl0sICJzcHJpbmdzdW5kYXkubmV0IjogWyJvbjYuc3ByaW5nc3VuZGF5Lm5ldCIsICJvbi5zcHJpbmdzdW5kYXkubmV0Il0sICJ0anVwdC5vcmciOiBbInRyYWNrZXItcHVibGljLnRqdXB0Lm9yZyJdLCAidG90aGVnbG9yeS5pbSI6IFsidHJhY2tlci50b3RoZWdsb3J5LmltIl0sICJ1Mi5kbWh5Lm9yZyI6IFsiZGF5ZHJlYW0uZG1oeS5iZXN0Il0sICJ4aW5neXVuZ2UudG9wIjogWyJ0cmFja2VyLnhpbmd5dW5nZS50b3AiLCAidHJhY2tlci54aW5neXVuZ2Uuc2JzIl0sICJ6bXB0LmNjIjogWyJ6bXB0LmNjIl0sICJoaGFuY2x1Yi50b3AiOiBbInRyYWNrZXIuaGhhbmNsdWIudG9wIl0sICJoZGNpdHkuY2l0eSI6IFsic3luYy5sZW5pdGVyLm9yZyJdLCAib3VyYml0cy5jbHViIjogWyJvdXJiaXRzLmNsdWIiXX0=
|
||||
@@ -66,7 +66,7 @@ class AutoSubv2(_PluginBase):
|
||||
# 主题色
|
||||
plugin_color = "#2C4F7E"
|
||||
# 插件版本
|
||||
plugin_version = "2.3"
|
||||
plugin_version = "2.5"
|
||||
# 插件作者
|
||||
plugin_author = "TimoYoung"
|
||||
# 作者主页
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import re
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Event
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
@@ -34,7 +35,7 @@ class IYUUAutoSeed(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "IYUU.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.9.11"
|
||||
plugin_version = "1.9.12"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
@@ -1051,6 +1052,12 @@ class IYUUAutoSeed(_PluginBase):
|
||||
判断是否为monika站点
|
||||
"""
|
||||
return True if "monikadesign." in url else False
|
||||
|
||||
def __is_gpw(url: str):
|
||||
"""
|
||||
判断是否为gpw站点
|
||||
"""
|
||||
return True if "greatposterwall." in url else False
|
||||
|
||||
def __get_mteam_enclosure(tid: str, apikey: str):
|
||||
"""
|
||||
@@ -1092,6 +1099,69 @@ class IYUUAutoSeed(_PluginBase):
|
||||
rsskey = rss_match.group(1)
|
||||
return f"{site.get('url')}torrents/download/{tid}.{rsskey}"
|
||||
|
||||
def __get_gpw_torrent_url_from_page(seed: dict, site: dict):
|
||||
"""
|
||||
从详情页面获取下载链接
|
||||
"""
|
||||
if not site.get('url'):
|
||||
logger.warn(f"站点 {site.get('name')} 未获取站点地址,无法获取种子下载链接")
|
||||
return None
|
||||
|
||||
try:
|
||||
page_url = f"{site.get('url')}torrents.php?torrentid={seed.get('torrent_id')}&hit=1"
|
||||
logger.info(f"正在获取种子下载链接:{page_url} ...")
|
||||
|
||||
res = RequestUtils(
|
||||
cookies=site.get("cookie"),
|
||||
ua=site.get("ua") or settings.USER_AGENT,
|
||||
proxies=settings.PROXY if site.get("proxy") else None
|
||||
).get_res(url=page_url)
|
||||
|
||||
|
||||
if res is None or res.status_code not in (200, 500):
|
||||
logger.error(f"获取种子下载链接失败,请求失败:{page_url},{res.status_code if res else ''}")
|
||||
return None
|
||||
# Fix encoding
|
||||
if "charset=utf-8" in res.text or "charset=UTF-8" in res.text:
|
||||
res.encoding = "UTF-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
|
||||
if not res.text:
|
||||
logger.warn(f"获取种子下载链接失败,页面内容为空:{page_url}")
|
||||
return None
|
||||
# 使用xpath从页面中获取下载链接
|
||||
html = etree.HTML(res.text)
|
||||
if html is None:
|
||||
logger.warning(f"解析页面失败:{page_url}")
|
||||
return None
|
||||
|
||||
xpath = "//a[contains(@href, 'torrents.php?action=download')]/@href"
|
||||
urls = html.xpath(xpath)
|
||||
|
||||
if not urls:
|
||||
logger.warning(f"获取种子下载链接失败,未找到下载链接:{page_url}")
|
||||
return None
|
||||
|
||||
torrent_id = str(seed.get("torrent_id"))
|
||||
matched_url = None
|
||||
# Strict match using regex id=xxxx
|
||||
for u in urls:
|
||||
if re.search(rf"id={torrent_id}(?:&|$)", u):
|
||||
matched_url = u
|
||||
break
|
||||
if not matched_url:
|
||||
logger.warning(f"未找到与 torrent_id={torrent_id} 对应的下载链接")
|
||||
return None
|
||||
|
||||
final_url = urljoin(site['url'], matched_url)
|
||||
|
||||
logger.info(f"获取种子下载链接成功:{final_url}")
|
||||
return final_url
|
||||
except Exception as e:
|
||||
logger.warn(f"获取种子下载链接失败:{str(e)}")
|
||||
return None
|
||||
|
||||
def __is_special_site(url: str):
|
||||
"""
|
||||
判断是否为特殊站点
|
||||
@@ -1116,6 +1186,9 @@ class IYUUAutoSeed(_PluginBase):
|
||||
if __is_monika(site.get('url')):
|
||||
# 返回种子id和站点配置中所Monika的rss链接
|
||||
return __get_monika_torrent(tid=seed.get("torrent_id"), rssurl=site.get("rss"))
|
||||
if __is_gpw(site.get('url')):
|
||||
# 从详情页面获取下载链接
|
||||
return __get_gpw_torrent_url_from_page(seed=seed, site=site)
|
||||
elif __is_special_site(site.get('url')):
|
||||
# 从详情页面获取下载链接
|
||||
return self.__get_torrent_url_from_page(seed=seed, site=site)
|
||||
|
||||
@@ -29,7 +29,7 @@ class MediaSyncDel(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "mediasyncdel.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.7.1"
|
||||
plugin_version = "1.7.2"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
@@ -630,6 +630,8 @@ class MediaSyncDel(_PluginBase):
|
||||
media_name = event_data.item_name
|
||||
# 媒体路径
|
||||
media_path = event_data.item_path
|
||||
# 兼容windows路径 如 C:\test.mp4 转换为 C:/test.mp4
|
||||
media_path = media_path.replace('\\', '/')
|
||||
# tmdb_id
|
||||
tmdb_id = event_data.tmdb_id
|
||||
# 季数
|
||||
@@ -710,6 +712,8 @@ class MediaSyncDel(_PluginBase):
|
||||
media_name = event_data.item_name
|
||||
# 媒体路径
|
||||
media_path = event_data.item_path
|
||||
# 兼容windows路径 如 C:\test.mp4 转换为 C:/test.mp4
|
||||
media_path = media_path.replace('\\', '/')
|
||||
# tmdb_id
|
||||
tmdb_id = event_data.tmdb_id
|
||||
# 季数
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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("标题和内容不能同时为空")
|
||||
|
||||
Reference in New Issue
Block a user